New design, part one: shared branding, nav surfaces and theme tokens (#7163)

## Overview

First part of the move over to the new designs. This lays the groundwork
(shared brand components, button/nav styling, theme tokens) and applies
it across the editor and the processor. Later parts will build on top of
it.

## What's changed

**Branding**
- Shared `Logo` and `BrandMark` components used everywhere, so the mark
and wordmark are identical across the editor, processor, auth pages and
the chat FAB.
- The sidebar logo doubles as the editor to processor switcher, morphing
into a chevron on hover. It only appears for users who can actually
reach the processor.

**Navigation and layout**
- Both sidebars restructured onto the floating nav surface treatment,
with rounded panels sitting on the app canvas.
- The editor file sidebar is now three sections (controls, PDF Library,
settings) and the workbench top bar and tools panel match.
- Added a collapse toggle to both sidebars, with an animated expand and
collapse and a tidy icon rail when collapsed. The processor did not have
a desktop collapse before.

**Components**
- Buttons and action icons now share one styling system, so both react
to the same tokens.
- Secondary buttons in dark mode use a neutral fill and border instead
of inheriting the primary colour.
- Status badges default to a clean dot with no background, with a filled
pill as the alternative.
- Metric strips gained a row layout with an optional leading icon.

**Theme**
- Colour tokens consolidated. Literal colours live only in the palette
file, everything else references the semantic `--c-*` tokens.
- `saas-theme.css` removed and the parts that were genuinely needed
moved into the shared theme, so all builds get them.
- The colour linter enforces this across the app and runs in CI.

## Notes

- Nothing functional should change here, it is styling plus the sidebar
collapse feature.
- Main has been merged in. The Sources and billing pages picked up
changes from main during that merge and are worth a look alongside the
new styling.

---------

Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
This commit is contained in:
EthanHealy01
2026-08-03 14:59:28 +00:00
committed by GitHub
co-authored by Reece Browne
parent 22815b5458
commit c91d63f215
106 changed files with 2121 additions and 1366 deletions
@@ -3877,10 +3877,10 @@ customizeGroups = "Customize groups"
dropHint = "Open files to get started"
dropToAdd = "Drop files to add"
expand = "Expand sidebar"
files = "Files"
googleDrive = "Google Drive"
googleDriveDisabled = "Google Drive is not configured"
leaveMyFiles = "Leave My Files"
library = "PDF Library"
myFiles = "My Files"
noFiles = "No files yet"
openFileManager = "Browse all files & folders"
@@ -4830,8 +4830,7 @@ welcomeTitle = "You've been invited!"
addFiles = "Add Files"
mobileUpload = "Upload from Mobile"
openFromComputer = "Open from computer"
uploadFromComputer = "Upload from computer"
workbenchEmptyStateHero = "Drop a PDF anywhere"
uploadFromComputer = "Browse files"
[language]
direction = "ltr"
@@ -4896,7 +4895,6 @@ signInWith = "Sign in with"
title = "Sign in"
unexpectedError = "Unexpected error: {{message}}"
updatePassword = "Update password"
useEmailInstead = "Login with email"
useMagicLink = "Use magic link instead"
username = "Username"
youAreLoggedIn = "You are logged in!"
@@ -8720,7 +8718,6 @@ account-link = "Account link"
[portal.shell.sidebar]
appEditor = "Editor"
appProcessor = "Processor"
brandSuffix = "Stirling Processor"
linkAccount = "Link Stirling account"
primaryNav = "Primary navigation"
switchApp = "Switch app"
@@ -10761,8 +10758,10 @@ backToAllTools = "Back to all tools"
collapse = "Collapse panel"
expand = "Expand panel"
goBack = "Go back"
pdfTools = "PDF Tools"
placeholder = "Choose a tool to get started"
premiumFeature = "Premium feature:"
searchTools = "Search tools"
toolsHeader = "Tools"
viewAllTools = "View all tools"
@@ -740,7 +740,6 @@ const PRIMITIVE_LAYER = [
/^editor\/src\/core\/theme\//,
/^editor\/src\/core\/styles\/theme\.css$/,
/^editor\/src\/core\/tokens\/tokens\.css$/,
/^editor\/src\/saas\/styles\/saas-theme\.css$/,
/^editor\/src\/proprietary\/auth\/ui\/auth-theme\.css$/,
/^editor\/src\/core\/ui\/accents\.css$/,
];
@@ -0,0 +1,4 @@
<svg width="71" height="79" viewBox="0 0 71 79" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 39L46.5 0V35.5L0 74.5V39Z" fill="#AD7373"/>
<path d="M24 43L70.5 4V39.5L24 78.5V43Z" fill="#8E3131"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -181,11 +181,11 @@ const AddFileCard = ({
{/* Instruction Text */}
<span
className="text-[var(--c-primary)]"
style={{
fontSize: ".8rem",
textAlign: "center",
marginTop: "0.5rem",
color: "var(--c-text-muted)",
}}
>
{terminology.dropFilesHere}
@@ -325,13 +325,19 @@
========================= */
.addFileCard {
width: 260px;
max-width: 260px;
height: calc(310px - 0.5rem);
margin: 0.5rem auto 0;
background: var(--c-bg);
border: 1.5px solid var(--c-border);
border-radius: 12px;
/* Fill the same slot a portrait page thumbnail does. Height: the 310px
.thumbWrap minus the always-reserved 26px toolchain bar (22px + 4px) that
sits above the page. Width: the file card's 260px minus its 10px side
padding. Offset 36px down (that padding-top + the bar) so the two line up.
A fixed size rather than an aspect-ratio, because each thumbnail derives
--thumb-aspect from its own PDF's page dimensions. */
height: calc(310px - 26px);
width: calc(260px - 20px);
max-width: 100%;
margin: 36px auto auto;
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: 0.625rem;
box-shadow: var(--shadow-md);
cursor: pointer;
transition:
@@ -47,6 +47,8 @@ interface AppConfigModalProps {
initialSection?: NavKey | null;
/** Host-specific sections appended after the build's registry sections. */
extraSections?: ConfigNavSection[];
/** Registry section keys to drop, for hosts a section can't run in. */
hiddenSectionKeys?: NavKey[];
}
// Extract section from URL path (e.g., /settings/people -> people)
@@ -65,6 +67,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
urlSync = true,
initialSection,
extraSections,
hiddenSectionKeys,
}) => {
const { t } = useTranslation();
// Initialize from the URL so a deep link (`/settings/people`) lands on the
@@ -218,13 +221,17 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
handleCloseSync,
config?.showSettingsWhenNoLogin ?? true,
);
const configNavSections = useMemo(
() =>
extraSections?.length
? [...registrySections, ...extraSections]
: registrySections,
[registrySections, extraSections],
);
const configNavSections = useMemo(() => {
const base = hiddenSectionKeys?.length
? registrySections
.map((s) => ({
...s,
items: s.items.filter((i) => !hiddenSectionKeys.includes(i.key)),
}))
.filter((s) => s.items.length > 0)
: registrySections;
return extraSections?.length ? [...base, ...extraSections] : base;
}, [registrySections, extraSections, hiddenSectionKeys]);
const activeLabel = useMemo(() => {
for (const section of configNavSections) {
@@ -20,6 +20,8 @@ interface AppConfigModalLazyProps {
initialSection?: NavKey | null;
/** Host-specific sections appended after the build's registry sections. */
extraSections?: ConfigNavSection[];
/** Registry section keys to drop, for hosts a section can't run in. */
hiddenSectionKeys?: NavKey[];
}
export default function AppConfigModalLazy({
@@ -28,6 +30,7 @@ export default function AppConfigModalLazy({
urlSync,
initialSection,
extraSections,
hiddenSectionKeys,
}: AppConfigModalLazyProps) {
const [shouldMount, setShouldMount] = useState(false);
@@ -44,6 +47,7 @@ export default function AppConfigModalLazy({
urlSync={urlSync}
initialSection={initialSection}
extraSections={extraSections}
hiddenSectionKeys={hiddenSectionKeys}
/>
)}
</Suspense>
@@ -1,27 +0,0 @@
/* Trigger: bare square icon button that blends into either sidebar's header. */
.app-switch-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-sm);
color: var(--c-text-subtle);
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.app-switch-btn:hover {
background: var(--c-hover);
color: var(--c-text-muted);
}
.app-switch-icon {
width: 1rem;
height: 1.0625rem;
display: block;
}
@@ -1,35 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AppSwitch } from "@app/components/shared/AppSwitch";
/** The editor ⇄ processor app switcher rendered by both the editor and portal sidebars. */
const meta: Meta<typeof AppSwitch> = {
title: "Shared/AppSwitch",
component: AppSwitch,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Editor: Story = {
args: {
current: "editor",
theme: "light",
onSwitch: () => {},
},
};
export const Processor: Story = {
args: {
current: "processor",
theme: "light",
onSwitch: () => {},
},
};
export const DarkTheme: Story = {
args: {
current: "editor",
theme: "dark",
onSwitch: () => {},
},
};
@@ -1,52 +1,27 @@
import { useTranslation } from "react-i18next";
import { Button, Dropdown } from "@app/ui";
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
import "@app/components/shared/AppSwitch.css";
import { Dropdown } from "@app/ui";
import { BrandMark } from "@app/components/shared/BrandMark";
export type AppSwitchTarget = "editor" | "processor";
function ChevronDownIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
interface AppSwitchProps {
interface AppSwitchMenuItemsProps {
/** The app this switcher is rendered in (shown as active in the menu). */
current: AppSwitchTarget;
/** Resolved color scheme; picks the brand mark for the menu items. */
theme: "light" | "dark";
/** Invoked with the selected app; only called for apps other than `current`. */
onSwitch: (app: AppSwitchTarget) => void;
className?: string;
}
/**
* The editor processor app switcher (chevron button → app menu). The editor
* and portal sidebars render this same element so the two apps present one
* identical switcher; each host supplies its own theme source and navigation.
* The editor / processor items for the app-switch menu. Rendered inside the
* BrandSwitcher's logo dropdown, which both apps use as their switcher. The
* mark is the shared <BrandMark>, which recolours itself from the theme
* tokens, so no colour-scheme prop needs threading down here.
*/
export function AppSwitch({
export function AppSwitchMenuItems({
current,
theme,
onSwitch,
className,
}: AppSwitchProps) {
}: AppSwitchMenuItemsProps) {
const { t } = useTranslation();
const mark = theme === "dark" ? markDark : markLight;
const apps: Array<{ id: AppSwitchTarget; label: string }> = [
{
id: "processor",
@@ -55,28 +30,17 @@ export function AppSwitch({
{ id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") },
];
return (
<Dropdown.Root align="end" className={className}>
<Dropdown.Trigger>
<Button
variant="tertiary"
className="app-switch-btn"
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
<>
{apps.map((app) => (
<Dropdown.Item
key={app.id}
active={current === app.id}
onSelect={app.id === current ? undefined : () => onSwitch(app.id)}
leading={<BrandMark height="1.125rem" />}
>
<ChevronDownIcon />
</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="11rem">
{apps.map((app) => (
<Dropdown.Item
key={app.id}
active={current === app.id}
onSelect={app.id === current ? undefined : () => onSwitch(app.id)}
leading={<img className="app-switch-icon" src={mark} alt="" />}
>
{app.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Root>
{app.label}
</Dropdown.Item>
))}
</>
);
}
@@ -1,8 +1,22 @@
import { Logo } from "@app/ui/Logo";
export interface AppSwitcherProps {
/** Icon-only brand mark for the collapsed rail. */
collapsed?: boolean;
}
/**
* Core stub for the sidebar app switcher. Builds that bundle the admin portal
* (proprietary/saas) shadow this with a real switcher; core has no portal, so
* there is nothing to switch to.
* Sidebar brand header. Core has no admin portal to switch to, so it just
* shows the Stirling logo. Builds that bundle the portal (proprietary/saas)
* shadow this with a version whose logo doubles as the editor⇄processor
* switcher.
*/
export function AppSwitcher() {
return null;
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
return (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
@@ -0,0 +1,56 @@
/* Morphing Stirling mark. At rest: the two-tone red brand parallelograms
(from the `d` attributes in the markup). When an ancestor marked
[data-brandmark-morph] is hovered / focused / open, each parallelogram is
transformed into one arm of a smaller, symmetric downward chevron in the
primary text colour.
The morph uses a CSS `transform` (not the `d` property) so it works in every
browser: both the logo shape and its target chevron arm are parallelograms,
and an affine matrix maps one exactly onto the other. The matrices below were
solved to send each path's 4 corners onto the chevron-arm corners:
arm A (left): M13 27 L35.5 41 L35.5 53 L13 39 Z
arm B (right): M35.5 41 L58 27 L58 39 L35.5 53 Z
(mirror images — equal area + dimensions). */
.sui-brandmark {
display: block;
width: auto;
overflow: visible;
}
.sui-brandmark__a,
.sui-brandmark__b {
transform-box: view-box;
transform-origin: 0 0;
transition:
transform var(--motion-slow),
fill var(--motion-slow);
}
/* Rest state — brand mark. */
.sui-brandmark__a {
fill: var(--c-brand-mark-soft);
}
.sui-brandmark__b {
fill: var(--c-brand-mark);
}
/* Morphed state — the two chevron arms, both in the primary text colour. */
[data-brandmark-morph]:hover .sui-brandmark__a,
[data-brandmark-morph]:focus-visible .sui-brandmark__a,
[data-brandmark-morph].is-open .sui-brandmark__a {
fill: var(--c-text);
transform: matrix(0.483871, 0.584583, 0, 0.338028, 13, 13.8169);
}
[data-brandmark-morph]:hover .sui-brandmark__b,
[data-brandmark-morph]:focus-visible .sui-brandmark__b,
[data-brandmark-morph].is-open .sui-brandmark__b {
fill: var(--c-text);
transform: matrix(0.483871, -0.017568, 0, 0.338028, 23.887097, 26.886428);
}
@media (prefers-reduced-motion: reduce) {
.sui-brandmark__a,
.sui-brandmark__b {
transition: none;
}
}
@@ -0,0 +1,36 @@
import "@app/components/shared/BrandMark.css";
interface BrandMarkProps {
/** Height of the mark (CSS length). */
height?: string;
className?: string;
}
/**
* The Stirling logo mark as inline SVG so it can morph. At rest it is the
* two-tone red brand mark; when an ancestor marked `[data-brandmark-morph]` is
* hovered / focused / open (`.is-open`), the two parallelograms slide into a
* smaller downward chevron in the primary text colour — a self-explaining
* "this opens a menu" affordance. See BrandMark.css for the morph geometry.
*/
export function BrandMark({ height = "1.6rem", className }: BrandMarkProps) {
return (
<svg
className={`sui-brandmark${className ? ` ${className}` : ""}`}
viewBox="0 0 71 79"
style={{ height }}
role="img"
aria-label="Stirling"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="sui-brandmark__a"
d="M0 39 L46.5 0 L46.5 35.5 L0 74.5 Z"
/>
<path
className="sui-brandmark__b"
d="M24 43 L70.5 4 L70.5 39.5 L24 78.5 Z"
/>
</svg>
);
}
@@ -0,0 +1,15 @@
/* Logo + app-switch dropdown, shared between the editor and the processor.
The logo itself is the trigger (its mark morphs into a chevron on hover). */
.sui-brand-switcher {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
/* Tighten the ghost-button padding so the lockup sits flush like a plain logo,
and negative-margin it back so the hover surface still extends past the text. */
.sui-brand-switcher__trigger.sui-btn {
--button-padding-x: 0.375rem;
margin-inline: -0.375rem;
}
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
const meta: Meta<typeof BrandSwitcher> = {
title: "Brand/BrandSwitcher",
component: BrandSwitcher,
parameters: { layout: "centered" },
args: { current: "processor", onSwitch: () => {} },
argTypes: {
current: { control: "inline-radio", options: ["editor", "processor"] },
},
};
export default meta;
type Story = StoryObj<typeof BrandSwitcher>;
export const Playground: Story = {};
@@ -0,0 +1,57 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Dropdown } from "@app/ui";
import { Logo } from "@app/ui/Logo";
import { BrandMark } from "@app/components/shared/BrandMark";
import {
AppSwitchMenuItems,
type AppSwitchTarget,
} from "@app/components/shared/AppSwitch";
import "@app/components/shared/BrandSwitcher.css";
interface BrandSwitcherProps {
/** The app this is rendered in (shown active in the menu). */
current: AppSwitchTarget;
/** Called with the selected app (only for the non-current one). */
onSwitch: (app: AppSwitchTarget) => void;
/** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */
collapsed?: boolean;
className?: string;
}
/**
* Brand lockup that doubles as the editor⇄processor switcher. The whole logo
* is the dropdown trigger: on hover / focus / open the mark morphs into a
* downward chevron (see BrandMark), so no separate chevron button is needed.
* Shared so the editor and the processor present one identical header.
*/
export function BrandSwitcher({
current,
onSwitch,
collapsed = false,
className,
}: BrandSwitcherProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className={`sui-brand-switcher${className ? ` ${className}` : ""}`}>
<Dropdown.Root align="start" open={open} onOpenChange={setOpen}>
<Dropdown.Trigger>
<Button
variant="quiet"
data-brandmark-morph
className={`sui-brand-switcher__trigger${open ? " is-open" : ""}`}
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
leftSection={<BrandMark height="1.6rem" />}
>
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="11rem">
<AppSwitchMenuItems current={current} onSwitch={onSwitch} />
</Dropdown.Menu>
</Dropdown.Root>
</div>
);
}
@@ -1,17 +1,32 @@
/* ========== FILE SIDEBAR ========== */
.file-sidebar {
background-color: var(--c-bg-raised);
border-right: 1px solid var(--c-border-subtle);
background-color: var(--c-bg);
display: flex;
flex-direction: column;
height: 100%;
position: relative;
z-index: 10;
/* Animating width + min-width + max-width together can leave the flex layout
stuck on the pre-animation size in some browsers. Snap instead and rely
on the inner content fade for visual smoothness. */
/* Above the workbench column (also z-10, but later in the DOM, so it would
otherwise paint over us). The brand switcher's menu is wider than the
collapsed rail and has to spill across that boundary intact. */
z-index: var(--z-dropdown);
flex-shrink: 0;
/* Slide the rail between collapsed/expanded. The delayed content-fade
(sidebar-content-in, 0.18s) is timed against this 0.22s so labels resolve
only after the width has settled — no squashed text mid-animation. */
transition:
width var(--motion-spring),
min-width var(--motion-spring),
max-width var(--motion-spring);
/* Gap around the floating boxes. */
padding: var(--nav-gutter);
gap: var(--nav-gutter);
}
@media (prefers-reduced-motion: reduce) {
.file-sidebar {
transition: none;
}
}
.file-sidebar-inner {
@@ -19,8 +34,74 @@
flex-direction: column;
flex: 1;
min-height: 0;
gap: 0.5rem;
}
/* ---- Brand header (logo / editor⇄processor switcher) ---- */
.file-sidebar-brand {
display: flex;
align-items: center;
min-height: 40px;
padding: 0 0.375rem;
flex-shrink: 0;
}
.file-sidebar-collapse-toggle {
margin-left: auto;
flex-shrink: 0;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-brand {
flex-direction: column;
gap: 0.25rem;
padding: 0;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle {
margin-left: 0;
}
/* ---- Three floating nav-surface boxes (controls / files / footer) ---- */
/* Horizontal padding is 0 so row highlights bleed to the surface edges; each
row's own inner padding keeps its text/icon indented. */
.file-sidebar-controls {
padding: 0.25rem 0;
flex-shrink: 0;
}
.file-sidebar-files-box {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0.25rem 0;
overflow: hidden;
}
.file-sidebar-footer-box {
padding: 0.25rem 0;
flex-shrink: 0;
}
/* Collapsed rail: the file tree isn't rendered, so hide its (empty) box and
let the boxes stack at the top — controls, then the settings footer right
after — instead of the files box stretching to fill. */
.file-sidebar[data-collapsed="true"] .file-sidebar-controls,
.file-sidebar[data-collapsed="true"] .file-sidebar-footer-box {
padding: 0.25rem;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-files-box {
display: none;
}
.file-sidebar[data-collapsed="true"] .file-sidebar-inner {
flex: 0 0 auto;
}
/* Centre each row's icon in the narrow rail (no side padding/margin to shove
it off the edge). */
.file-sidebar[data-collapsed="true"] .file-sidebar-search-row,
.file-sidebar[data-collapsed="true"] .file-sidebar-action-row,
.file-sidebar[data-collapsed="true"] .file-sidebar-cloud-row {
justify-content: center;
padding-inline: 0;
margin: 0;
}
/* ---- Native file drag-and-drop ---- */
.file-sidebar[data-file-drag-over] {
@@ -58,72 +139,16 @@
color: var(--mantine-color-blue-6, var(--c-primary));
}
/* ---- Header ---- */
.file-sidebar-header {
display: flex;
align-items: center;
height: 48px;
padding: 0 14px;
gap: 10px;
cursor: pointer;
border-radius: 4px;
margin: 4px 4px 0 4px;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
/* Icons stay left-aligned during animation; overflow:hidden on inner clips text naturally */
.file-sidebar-header:hover {
background-color: var(--c-hover);
}
.file-sidebar-menu-icon {
color: var(--c-text-subtle) !important;
font-size: 18px !important;
flex-shrink: 0;
}
/* Inherits font-size so swap-in icons render at 18px like the original. */
.file-sidebar-menu-icon > svg {
font-size: inherit;
width: 1em;
height: 1em;
}
/* Flip directional toggle icons in RTL (skipped for the symmetric burger). */
[dir="rtl"] .file-sidebar-menu-icon[data-toggle-flip-rtl="true"] > svg {
transform: scaleX(-1);
}
.file-sidebar-brand-text {
height: 22px;
width: auto;
flex-shrink: 0;
}
/* App switcher (portal builds only) sits at the far end of the header row.
The content-fade animation makes this span a stacking context, which would
trap the menu's z-index below later sidebar rows — elevate the span so the
open menu paints above them. */
.file-sidebar-app-switch {
margin-inline-start: auto;
display: flex;
align-items: center;
position: relative;
z-index: var(--z-dropdown);
}
/* ---- Search row ---- */
.file-sidebar-search-row {
display: flex;
align-items: center;
min-height: 32px;
padding: 0 14px;
padding: 0 8px;
gap: 0;
cursor: pointer;
border-radius: 4px;
margin: 0 4px;
margin: 0;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
@@ -160,7 +185,7 @@
.file-sidebar-search-label {
margin-left: 12px;
font-size: 14px;
color: var(--c-text-muted);
color: var(--c-text);
}
/* ---- Scrollable content ---- */
@@ -170,7 +195,8 @@
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
overflow-x: hidden;
}
.file-sidebar-scroll::-webkit-scrollbar {
@@ -189,10 +215,10 @@
display: flex;
align-items: center;
height: 32px;
padding: 0 14px;
padding: 0 8px;
cursor: pointer;
border-radius: 4px;
margin: 0 4px;
margin: 0;
gap: 0;
transition: background-color 0.15s ease;
flex-shrink: 0;
@@ -234,7 +260,7 @@
.file-sidebar-action-label {
margin-left: 12px;
font-size: 14px;
color: var(--c-text-muted);
color: var(--c-text);
white-space: nowrap;
}
@@ -243,10 +269,10 @@
display: flex;
align-items: center;
height: 32px;
padding: 0 14px;
padding: 0 8px;
cursor: pointer;
border-radius: 4px;
margin: 0 4px;
margin: 0;
gap: 0;
transition: background-color 0.15s ease;
flex-shrink: 0;
@@ -452,18 +478,16 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px 6px 14px;
margin: 4px 0 0 0;
border-top: 1px solid var(--c-border-subtle);
padding: 0 6px 2px 6px;
margin: 0;
flex-shrink: 0;
}
.file-sidebar-section-label {
font-size: 13px;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--c-text-subtle);
text-transform: uppercase;
letter-spacing: -0.01em;
color: var(--c-text);
}
/* Slim "Adding files… X/Y" progress row shown during a bulk drop's pre-scan,
@@ -570,10 +594,9 @@
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid var(--c-border-subtle);
padding: 4px 6px;
flex-shrink: 0;
min-height: 48px;
min-height: 40px;
}
/* Bottom bar settings icon tracks the right edge during collapse animation */
@@ -8,6 +8,7 @@ import React, {
} from "react";
import { Loader, Tooltip } from "@mantine/core";
import { ActionIcon } from "@app/ui/ActionIcon";
import { NavSurface } from "@app/ui/NavSurface";
import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -29,10 +30,9 @@ import {
} from "@app/contexts/IndexedDBContext";
import { accountService } from "@app/services/accountService";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { Wordmark } from "@app/components/shared/Wordmark";
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
import type { StirlingFileStub } from "@app/types/fileContext";
import MenuIcon from "@mui/icons-material/Menu";
import SearchIcon from "@mui/icons-material/Search";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
@@ -152,12 +152,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
collapsed = false,
onToggleCollapse,
onOpenSettings,
toggleAriaLabel,
toggleIcon,
onUploadFiles,
onPickGoogleDriveFiles,
onSearchClick,
extraAction,
toggleAriaLabel,
toggleIcon,
},
ref,
) {
@@ -833,110 +833,78 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
</div>
)}
<div className="file-sidebar-inner">
{/* Header: hamburger + branding */}
<Tooltip
label={toggleAriaLabel ?? t("fileSidebar.expand", "Expand sidebar")}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-header"
onClick={() => onToggleCollapse?.()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleCollapse?.();
<div className="file-sidebar-brand">
<AppSwitcher collapsed={collapsed} />
{onToggleCollapse && (
<ActionIcon
variant="tertiary"
size="md"
className="file-sidebar-collapse-toggle"
onClick={() => onToggleCollapse()}
aria-label={
toggleAriaLabel ??
(collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar"))
}
}}
aria-label={
toggleAriaLabel ??
(collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar"))
}
>
{/* Wrapper carries sizing; data-toggle-flip-rtl flips icon in RTL. */}
<span
className="file-sidebar-menu-icon"
data-toggle-flip-rtl={toggleIcon ? "true" : undefined}
>
{toggleIcon ?? <MenuIcon />}
</span>
{!collapsed && (
<Wordmark
alt="Stirling PDF"
className="file-sidebar-brand-text sidebar-content-fade"
/>
)}
{!collapsed && (
// The header row itself toggles collapse; stop the switcher's
// clicks and key presses from reaching it.
<span
className="file-sidebar-app-switch sidebar-content-fade"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<AppSwitcher />
</span>
)}
</div>
</Tooltip>
{toggleIcon ?? <SidebarToggleIcon size={18} />}
</ActionIcon>
)}
</div>
{/* Search row */}
<Tooltip
label={t("fileSidebar.search", "Search")}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
onClick={!searchActive ? handleSearchClick : undefined}
role={!searchActive ? "button" : undefined}
tabIndex={!searchActive ? 0 : undefined}
onKeyDown={
!searchActive
? (e) => e.key === "Enter" && handleSearchClick()
: undefined
}
{/* Box 1 — top controls (search + open / my files / cloud). No title. */}
<NavSurface className="file-sidebar-controls">
{/* Search row */}
<Tooltip
label={t("fileSidebar.search", "Search")}
position="right"
withinPortal
disabled={!collapsed}
>
{searchActive && !collapsed ? (
<CloseIcon
className="file-sidebar-search-icon"
onClick={(e) => {
e.stopPropagation();
handleSearchClose();
}}
/>
) : (
<SearchIcon className="file-sidebar-search-icon" />
)}
{!collapsed &&
(searchActive ? (
<input
ref={searchInputRef}
className="file-sidebar-search-input sidebar-content-fade"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t(
"fileSidebar.searchPlaceholder",
"Search files...",
)}
onClick={(e) => e.stopPropagation()}
<div
className={`file-sidebar-search-row${searchActive && !collapsed ? " active" : ""}`}
onClick={!searchActive ? handleSearchClick : undefined}
role={!searchActive ? "button" : undefined}
tabIndex={!searchActive ? 0 : undefined}
onKeyDown={
!searchActive
? (e) => e.key === "Enter" && handleSearchClick()
: undefined
}
>
{searchActive && !collapsed ? (
<CloseIcon
className="file-sidebar-search-icon"
onClick={(e) => {
e.stopPropagation();
handleSearchClose();
}}
/>
) : (
<span className="file-sidebar-search-label sidebar-content-fade">
{t("fileSidebar.search", "Search")}
</span>
))}
</div>
</Tooltip>
<SearchIcon className="file-sidebar-search-icon" />
)}
{!collapsed &&
(searchActive ? (
<input
ref={searchInputRef}
className="file-sidebar-search-input sidebar-content-fade"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t(
"fileSidebar.searchPlaceholder",
"Search files...",
)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="file-sidebar-search-label sidebar-content-fade">
{t("fileSidebar.search", "Search")}
</span>
))}
</div>
</Tooltip>
{/* Scrollable content */}
<div className="file-sidebar-scroll">
{/* Hidden native file input - kept outside the !collapsed gate so
the "Open from computer" row below (always rendered) can fire
it in either sidebar state without a silent no-op. */}
@@ -1157,145 +1125,158 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
)}
</div>
)}
</NavSurface>
{/* Files section - always visible when expanded */}
{!collapsed && (
<div className="file-sidebar-files-section sidebar-content-fade">
<div className="file-sidebar-section-header">
<span className="file-sidebar-section-label">
{t("fileSidebar.files", "Files")}
</span>
<FileSidebarGroupControls stubs={filteredFileStubs} />
<ActionIcon
variant="quiet"
className="file-sidebar-section-btn file-sidebar-section-btn-external"
onClick={() => navigate("/files")}
title={t(
"fileSidebar.openFileManager",
"Browse all files & folders",
)}
aria-label={t(
"fileSidebar.openFileManager",
"Browse all files & folders",
)}
data-testid="open-files-page"
>
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<ActionIcon
variant="quiet"
className="file-sidebar-section-btn file-sidebar-section-btn-add"
onClick={() => nativeFileInputRef.current?.click()}
title={t("fileSidebar.addFiles", "Add files")}
aria-label={t("fileSidebar.addFiles", "Add files")}
>
<AddIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</div>
<BulkAddProgressRow />
{!stubsLoaded ? (
<div className="file-sidebar-loading">
<Loader size="sm" color="var(--c-text-subtle)" />
{/* Box 2 — the file tree (this box scrolls). */}
<NavSurface className="file-sidebar-files-box">
<div className="file-sidebar-scroll">
{/* Files section - always visible when expanded */}
{!collapsed && (
<div className="file-sidebar-files-section sidebar-content-fade">
<div className="file-sidebar-section-header">
<span className="file-sidebar-section-label">
{t("fileSidebar.library", "PDF Library")}
</span>
<FileSidebarGroupControls stubs={filteredFileStubs} />
<ActionIcon
variant="quiet"
className="file-sidebar-section-btn file-sidebar-section-btn-external"
onClick={() => navigate("/files")}
title={t(
"fileSidebar.openFileManager",
"Browse all files & folders",
)}
aria-label={t(
"fileSidebar.openFileManager",
"Browse all files & folders",
)}
data-testid="open-files-page"
>
<OpenInNewIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
<ActionIcon
variant="quiet"
className="file-sidebar-section-btn file-sidebar-section-btn-add"
onClick={() => nativeFileInputRef.current?.click()}
title={t("fileSidebar.addFiles", "Add files")}
aria-label={t("fileSidebar.addFiles", "Add files")}
>
<AddIcon sx={{ fontSize: "1rem" }} />
</ActionIcon>
</div>
) : filteredFileStubs.length > 0 ? (
<div className="file-sidebar-file-list">
{fileGroups ? (
<>
{fileGroups.map((group) => {
const isOpen =
groupOpen[group.id] ?? group.defaultExpanded;
return (
<div className="file-sidebar-group" key={group.id}>
<Button
variant="quiet"
fullWidth
justify="between"
className="file-sidebar-group-header"
onClick={() =>
setGroupOpenState(group.id, !isOpen)
}
aria-expanded={isOpen}
leftSection={
<>
{isOpen ? (
<KeyboardArrowDownIcon
sx={{ fontSize: "1.1rem" }}
/>
) : (
<KeyboardArrowRightIcon
sx={{ fontSize: "1.1rem" }}
/>
)}
{group.icon && (
<LocalIcon
icon={group.icon}
width="1.05rem"
className="file-sidebar-group-icon"
style={
group.color
? { color: group.color }
: undefined
}
/>
)}
</>
}
rightSection={
<span className="file-sidebar-group-count">
{group.stubs.length}
</span>
}
>
<span className="file-sidebar-group-label">
{group.label}
</span>
</Button>
<div className="file-sidebar-group-items">
{isOpen && group.stubs.map(renderFileRow)}
</div>
</div>
);
})}
<Button
variant="quiet"
fullWidth
justify="between"
className="file-sidebar-view-all"
onClick={() => navigate("/files")}
rightSection={
<KeyboardArrowRightIcon sx={{ fontSize: "1rem" }} />
}
>
{t(
"fileSidebar.viewAll",
"View all {{count}} files",
{
count: filteredFileStubs.length,
},
)}
</Button>
</>
) : (
filteredFileStubs.map(renderFileRow)
)}
</div>
) : (
!searchActive && (
<div className="file-sidebar-empty">
<p className="file-sidebar-empty-text">
{t("fileSidebar.noFiles", "No files yet")}
</p>
<p className="file-sidebar-empty-hint">
{t("fileSidebar.dropHint", "Open files to get started")}
</p>
<BulkAddProgressRow />
{!stubsLoaded ? (
<div className="file-sidebar-loading">
<Loader size="sm" color="var(--c-text-subtle)" />
</div>
)
)}
</div>
)}
</div>
) : filteredFileStubs.length > 0 ? (
<div className="file-sidebar-file-list">
{fileGroups ? (
<>
{fileGroups.map((group) => {
const isOpen =
groupOpen[group.id] ?? group.defaultExpanded;
return (
<div
className="file-sidebar-group"
key={group.id}
>
<Button
variant="quiet"
fullWidth
justify="between"
className="file-sidebar-group-header"
onClick={() =>
setGroupOpenState(group.id, !isOpen)
}
aria-expanded={isOpen}
leftSection={
<>
{isOpen ? (
<KeyboardArrowDownIcon
sx={{ fontSize: "1.1rem" }}
/>
) : (
<KeyboardArrowRightIcon
sx={{ fontSize: "1.1rem" }}
/>
)}
{group.icon && (
<LocalIcon
icon={group.icon}
width="1.05rem"
className="file-sidebar-group-icon"
style={
group.color
? { color: group.color }
: undefined
}
/>
)}
</>
}
rightSection={
<span className="file-sidebar-group-count">
{group.stubs.length}
</span>
}
>
<span className="file-sidebar-group-label">
{group.label}
</span>
</Button>
<div className="file-sidebar-group-items">
{isOpen && group.stubs.map(renderFileRow)}
</div>
</div>
);
})}
<Button
variant="quiet"
fullWidth
justify="between"
className="file-sidebar-view-all"
onClick={() => navigate("/files")}
rightSection={
<KeyboardArrowRightIcon
sx={{ fontSize: "1rem" }}
/>
}
>
{t(
"fileSidebar.viewAll",
"View all {{count}} files",
{
count: filteredFileStubs.length,
},
)}
</Button>
</>
) : (
filteredFileStubs.map(renderFileRow)
)}
</div>
) : (
!searchActive && (
<div className="file-sidebar-empty">
<p className="file-sidebar-empty-text">
{t("fileSidebar.noFiles", "No files yet")}
</p>
<p className="file-sidebar-empty-hint">
{t(
"fileSidebar.dropHint",
"Open files to get started",
)}
</p>
</div>
)
)}
</div>
)}
</div>
</NavSurface>
</div>
{/* Kebab "Save to cloud" upload modal (one file at a time). */}
@@ -1325,65 +1306,70 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
{/* Getting-started checklist, floating above the footer (SaaS only). */}
<SidebarChecklistSlot collapsed={collapsed} />
{/* Bottom bar: user name + settings */}
<Tooltip
label={
onOpenSettings
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
: displayName
}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-bottom-bar"
onClick={onOpenSettings}
role={onOpenSettings ? "button" : undefined}
tabIndex={onOpenSettings ? 0 : undefined}
onKeyDown={
{/* Box 3 — account footer (avatar + name + settings). */}
<NavSurface className="file-sidebar-footer-box">
{/* Bottom bar: user name + settings */}
<Tooltip
label={
onOpenSettings
? (e) => e.key === "Enter" && onOpenSettings()
: undefined
}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
? `${displayName} - ${t("fileSidebar.openSettings", "Open settings")}`
: displayName
}
style={onOpenSettings ? { cursor: "pointer" } : undefined}
position="right"
withinPortal
disabled={!collapsed}
>
<div
className={`file-sidebar-bottom-avatar${
showProfilePicture ? " file-sidebar-bottom-avatar--picture" : ""
}`}
aria-label={displayName}
className="file-sidebar-bottom-bar"
onClick={onOpenSettings}
role={onOpenSettings ? "button" : undefined}
tabIndex={onOpenSettings ? 0 : undefined}
onKeyDown={
onOpenSettings
? (e) => e.key === "Enter" && onOpenSettings()
: undefined
}
data-testid={onOpenSettings ? "config-button" : undefined}
data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
: displayName
}
style={onOpenSettings ? { cursor: "pointer" } : undefined}
>
{showProfilePicture ? (
<img
src={profilePictureUrl}
alt=""
className="file-sidebar-bottom-avatar-img"
onError={() => setPictureFailed(true)}
/>
) : (
displayName.charAt(0).toUpperCase()
<div
className={`file-sidebar-bottom-avatar${
showProfilePicture
? " file-sidebar-bottom-avatar--picture"
: ""
}`}
aria-label={displayName}
>
{showProfilePicture ? (
<img
src={profilePictureUrl}
alt=""
className="file-sidebar-bottom-avatar-img"
onError={() => setPictureFailed(true)}
/>
) : (
displayName.charAt(0).toUpperCase()
)}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<div className="file-sidebar-bottom-settings">
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</div>
)}
</div>
{!collapsed && (
<span className="file-sidebar-bottom-name sidebar-content-fade">
{displayName}
</span>
)}
{onOpenSettings && !collapsed && (
<div className="file-sidebar-bottom-settings">
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
</div>
)}
</div>
</Tooltip>
</Tooltip>
</NavSurface>
</div>
);
},
@@ -33,6 +33,7 @@ export function LandingActions({
<Group gap="sm" justify="center" wrap="wrap" mb="xs">
<Button
className="landing-btn-primary"
px="xl"
leftSection={
<LocalIcon icon={icons.uploadIconName} width="1rem" height="1rem" />
}
@@ -47,6 +48,7 @@ export function LandingActions({
<Button
variant="secondary"
className="landing-btn-secondary"
px="xl"
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
onClick={(e) => {
e.stopPropagation();
@@ -3,6 +3,32 @@
All custom properties are defined in theme.css.
============================================================ */
/* ── Entrance animation (editor empty state) ─────────────────
Logo fades/slides up first (0.2s), then the actions after it finishes
(0.2s delay + 0.2s) — 0.4s total. */
@keyframes landing-fade-up {
from {
opacity: 0;
transform: translateY(0.75rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.landing-logo-enter {
animation: landing-fade-up 0.2s ease-out both;
}
.landing-actions-enter {
animation: landing-fade-up 0.2s ease-out 0.2s both;
}
@media (prefers-reduced-motion: reduce) {
.landing-logo-enter,
.landing-actions-enter {
animation: none;
}
}
/* ── Hero text ───────────────────────────────────────────── */
.landing-title {
margin: 1.75rem 0 1.5rem;
@@ -110,31 +136,21 @@
}
/* ── Action buttons ──────────────────────────────────────── */
.landing-btn-primary {
background: var(--landing-hero-gradient) !important;
color: var(--c-text-on-primary) !important;
border: none !important;
border-radius: 0.75rem !important;
font-weight: 600 !important;
}
.landing-btn-primary,
.landing-btn-secondary {
border-radius: 0.75rem !important;
font-weight: 600 !important;
border-color: var(--landing-button-border, var(--c-border)) !important;
background-color: var(--landing-button-bg, var(--c-surface)) !important;
color: var(--c-accent-fg, var(--c-text)) !important;
}
.landing-btn-secondary:hover {
background-color: var(
--landing-button-hover-bg,
var(--landing-button-bg, var(--c-surface))
) !important;
}
/* Icon-only variant: accent colour instead of button text colour */
.landing-btn-primary,
.landing-btn-secondary,
.landing-btn-icon {
color: var(--c-primary) !important;
min-height: 3rem !important;
}
.landing-btn-icon {
width: 3rem !important;
min-width: 3rem !important;
border-radius: 0.75rem !important;
}
/* Dropzone accept/reject outlines. Mantine 8 no longer supports nested
@@ -1,17 +1,15 @@
import React, { useState } from "react";
import { Container } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { useTranslation } from "react-i18next";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack";
import { Logo } from "@app/ui/Logo";
import { LandingActions } from "@app/components/shared/LandingActions";
import "@app/components/shared/LandingPage.css";
const LandingPage = () => {
const { t } = useTranslation();
const { addFiles } = useFileHandler();
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const terminology = useFileActionTerminology();
@@ -77,17 +75,24 @@ const LandingPage = () => {
},
}}
>
<LandingDocumentStack />
<h3 className="landing-title">
{t("landing.workbenchEmptyStateHero", "Drop a PDF anywhere")}
</h3>
<LandingActions
fileInputRef={fileInputRef}
onUploadClick={() => void handleNativeUploadClick()}
onMobileUploadClick={() => setMobileUploadModalOpen(true)}
onFileSelect={handleFileSelect}
<Logo
variant="iconAndText"
orientation="vertical"
iconHeight="5rem"
textHeight="2.5rem"
gap="1rem"
className="landing-logo-enter"
style={{ marginBottom: "2.5rem" }}
/>
<div className="landing-actions-enter">
<LandingActions
fileInputRef={fileInputRef}
onUploadClick={() => void handleNativeUploadClick()}
onMobileUploadClick={() => setMobileUploadModalOpen(true)}
onFileSelect={handleFileSelect}
/>
</div>
</Dropzone>
<MobileUploadModal
@@ -0,0 +1,39 @@
interface SidebarToggleIconProps {
/** Square size in px. */
size?: number;
/** Put the divided-off rail on the right, for a right-hand panel. */
mirrored?: boolean;
className?: string;
}
/**
* "Toggle sidebar" glyph — a rounded panel with a divided-off left rail —
* shared by the editor FileSidebar and the processor Sidebar so their
* collapse/expand controls read identically. Inherits colour via currentColor.
*/
export function SidebarToggleIcon({
size = 18,
mirrored = false,
className,
}: SidebarToggleIconProps) {
// Mirror by moving the divider, not by flipping the whole glyph, so the
// rounded corners and stroke widths stay identical between the two.
const dividerX = mirrored ? 14.5 : 9.5;
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="16" rx="2.5" />
<line x1={dividerX} y1="4" x2={dividerX} y2="20" />
</svg>
);
}
@@ -8,8 +8,13 @@
align-content: flex-start;
min-height: 40px;
padding: 0 8px;
background-color: var(--c-bg-raised);
border-bottom: 1px solid var(--c-border-subtle);
/* No left margin: the file sidebar's own 0.5rem padding already provides the
gutter on that side, so adding one here would double it and leave the bar
further from the sidebar than it is from the tool panel. */
margin: var(--nav-gutter) var(--nav-gutter) 0 0;
background-color: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-nav);
flex-shrink: 0;
z-index: 50;
}
@@ -15,9 +15,9 @@ import { ToolPanelHeader } from "@app/components/shared/ToolPanelHeader";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { ActionIcon } from "@app/ui/ActionIcon";
import { withViewTransition } from "@app/utils/viewTransition";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
import CloseIcon from "@mui/icons-material/Close";
import SearchIcon from "@mui/icons-material/Search";
import { ToolId } from "@app/types/toolId";
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
@@ -79,6 +79,7 @@ export default function RightSidebar() {
};
const [allToolsView, setAllToolsView] = useState(false);
const [headerSearchOpen, setHeaderSearchOpen] = useState(false);
const handleShowAllTools = () => {
withViewTransition(() => setAllToolsView(true));
@@ -96,7 +97,7 @@ export default function RightSidebar() {
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
const showHeaderSearch = showCloseButton || leftPanelView === "toolPicker";
const showHeaderSearch = showCloseButton || headerSearchOpen;
const handleHeaderBack = () => {
if (inToolView) {
@@ -168,7 +169,7 @@ export default function RightSidebar() {
ref={toolPanelRef}
data-sidebar="tool-panel"
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--c-bg-raised)] border-l border-[var(--c-border-subtle)] transition-all duration-300 ease-out ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} ${isMobile || fullscreenExpanded ? "border-l border-[var(--c-border-subtle)]" : "tool-panel--floating"} transition-all duration-300 ease-out ${isMobile ? "h-full border-r-0" : fullscreenExpanded ? "h-screen" : ""} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
style={{
width: computedWidth(),
padding: "0",
@@ -188,7 +189,7 @@ export default function RightSidebar() {
className="tool-panel__expand-btn tool-panel__toggle-vt"
onClick={handleExpand}
>
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
<SidebarToggleIcon size={18} mirrored />
</ActionIcon>
</div>
<div className="tool-panel__collapsed-divider" />
@@ -263,37 +264,62 @@ export default function RightSidebar() {
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
autoFocus
/>
</div>
) : null}
{showCloseButton ? (
<ActionIcon
variant="tertiary"
size="md"
shape="circle"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="secondary"
size="md"
shape="circle"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
<span className="tool-panel__compact-title">
{t("toolPanel.pdfTools", "PDF Tools")}
</span>
)}
<div className="tool-panel__compact-header-actions">
{!showCloseButton && (
<ActionIcon
variant="tertiary"
size="md"
shape="circle"
onClick={() => {
if (headerSearchOpen) handleHeaderSearchChange("");
setHeaderSearchOpen((open) => !open);
}}
aria-label={t("toolPanel.searchTools", "Search tools")}
className="tool-panel__expand-btn"
>
{headerSearchOpen ? (
<CloseIcon sx={{ fontSize: "1.1rem" }} />
) : (
<SearchIcon sx={{ fontSize: "1.1rem" }} />
)}
</ActionIcon>
)}
{showCloseButton ? (
<ActionIcon
variant="tertiary"
size="md"
shape="circle"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="secondary"
size="md"
shape="circle"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn tool-panel__toggle-vt"
>
<SidebarToggleIcon size={18} mirrored />
</ActionIcon>
)}
</div>
</div>
)}
@@ -11,6 +11,7 @@
.tool-panel {
position: relative;
background: var(--c-surface);
transition:
width 0.3s ease,
max-width 0.3s ease;
@@ -18,6 +19,14 @@
user-select: none;
}
.tool-panel--floating {
margin: var(--nav-gutter) var(--nav-gutter) var(--nav-gutter) 0;
height: calc(100vh - (var(--nav-gutter) * 2));
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-nav);
}
.tool-panel__collapsed-strip {
display: flex;
flex-direction: column;
@@ -151,8 +160,21 @@
box-sizing: border-box;
}
.tool-panel__compact-header .tool-panel__expand-btn {
.tool-panel__compact-title {
flex: 1 1 auto;
min-width: 0;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--c-text);
}
.tool-panel__compact-header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
flex-shrink: 0;
}
.tool-panel__compact-header-search {
@@ -50,7 +50,7 @@ const SCROLLABLE_STYLE: React.CSSProperties = {
const CONTAINER_STYLE: React.CSSProperties = {
display: "flex",
flexDirection: "column",
background: "var(--c-bg-raised)",
background: "var(--c-surface)",
};
const toTitleCase = (s: string) =>
s.replace(
@@ -1364,9 +1364,12 @@ const EmbedPdfViewerContent = ({
{/* Bottom Toolbar Overlay */}
{effectiveFile && (
<div
className="pdf-viewer-toolbar-dock"
style={{
position: "fixed",
bottom: 0,
// Gutter matching the workbench rails, so the bar reads as a
// floating card rather than one welded to the viewport edge.
bottom: "0.5rem",
left: 0,
right: 0,
zIndex: 50,
@@ -0,0 +1,35 @@
/* Floating viewer toolbar: a nav-surface card like the workbench rails rather
than a slab welded to the viewport edge. Shared by both consumers (the
viewer's bottom dock and the file-preview modal, which puts it up top). */
.pdf-viewer-toolbar {
border-radius: var(--radius-nav);
border: 1px solid var(--c-border-subtle);
background: var(--c-surface);
box-shadow: var(--shadow-md);
}
/* The viewer's fixed bottom dock. The reveal lives here, not on the toolbar
itself, so it only plays where the bar actually enters from off-screen —
mirroring the workbench top bar's 280ms ease, upwards instead of downwards. */
.pdf-viewer-toolbar-dock {
animation: pdf-viewer-toolbar-in 280ms ease both;
}
@keyframes pdf-viewer-toolbar-in {
from {
/* The bar's own height plus its gutter, so it starts fully off-screen
however tall the controls wrap. */
transform: translateY(calc(100% + var(--nav-gutter)));
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.pdf-viewer-toolbar-dock {
animation: none;
}
}
@@ -5,6 +5,7 @@ import { useViewer } from "@app/contexts/ViewerContext";
import { useIsPhone } from "@app/hooks/useIsMobile";
import { Tooltip } from "@app/components/shared/Tooltip";
import { ActionIcon } from "@app/ui/ActionIcon";
import "@app/components/viewer/PdfViewerToolbar.css";
import FirstPageIcon from "@mui/icons-material/FirstPage";
import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
@@ -148,8 +149,7 @@ export function PdfViewerToolbar({
return (
<Paper
radius="xl xl 0 0"
shadow="sm"
className="pdf-viewer-toolbar"
p={12}
pb={12}
style={{
@@ -159,11 +159,6 @@ export function PdfViewerToolbar({
rowGap: 8,
gap: 10,
justifyContent: "center",
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
pointerEvents: "auto",
}}
>
@@ -499,6 +499,7 @@ export default function HomePage() {
gap={0}
h="100%"
className="flex-nowrap flex"
bg="var(--c-bg)"
>
<MyFilesAwareFileSidebar
ref={quickAccessRef}
+53
View File
@@ -244,6 +244,37 @@
/* API Keys section colors (light mode) */
--api-keys-card-shadow: rgba(0, 0, 0, 0.06);
--modal-nav-bg: var(--p-gray-100);
--modal-nav-section-title: var(--p-gray-500);
--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: var(--p-white);
--modal-header-border: rgba(0, 0, 0, 0.06);
--api-keys-card-bg: var(--p-white);
--api-keys-card-border: var(--p-gray-200);
--api-keys-input-bg: var(--p-gray-50);
--api-keys-input-border: var(--p-gray-200);
--usage-inactive: var(--p-gray-200);
--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);
--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);
/* PDF Report Colors (always light) */
--pdf-light-text-muted: 100 116 139;
--pdf-light-box-bg: 248 250 252;
@@ -472,6 +503,28 @@
/* API Keys section colors (dark mode) */
--api-keys-card-shadow: none;
/* Migrated from the former saas-theme.css (dark) — see the light block. */
--modal-nav-bg: var(--p-zinc-850);
--modal-nav-section-title: var(--p-zinc-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);
--api-keys-card-bg: var(--p-zinc-800);
--api-keys-card-border: var(--p-zinc-650);
--api-keys-input-bg: var(--p-zinc-850);
--api-keys-input-border: var(--p-zinc-650);
--usage-inactive: var(--p-zinc-650);
--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);
/* Code token colors (dark mode - Cursor-like) */
--code-kw-color: var(--p-blue-400); /* purple */
--code-str-color: var(--p-green-500); /* green */
@@ -12,7 +12,9 @@ test.describe("20. Edge Cases and Security", () => {
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('"><img src=x onerror=alert(1)>');
@@ -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 });
}
});
});
@@ -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();
});
});
@@ -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("<script>alert(1)</script>");
// Step 2: Verify no script execution occurs (no alert dialog)
+37 -23
View File
@@ -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);
}
@@ -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;
+13 -1
View File
@@ -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;
@@ -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);
+16 -9
View File
@@ -103,15 +103,22 @@ export const ActionIcon = forwardRef<HTMLButtonElement, ActionIconProps>(
"--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;
+17 -9
View File
@@ -193,15 +193,23 @@ const ButtonRoot = forwardRef<HTMLButtonElement, ButtonProps>(
"--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;
@@ -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 {
+5 -14
View File
@@ -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<HTMLButtonElement> {
@@ -25,20 +26,10 @@ export function ChatFABButton({
return (
<button type="button" className={classes} {...rest}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={28}
height={28}
viewBox="0 0 192 192"
fill="currentColor"
aria-hidden="true"
>
<path
d="M68.48 102.4 L184.73 6.45 L184.73 96.05 L68.48 192 Z"
opacity="0.7"
/>
<path d="M7.26 95.83 L123.37 0 L123.37 89.5 L7.26 185.33 Z" />
</svg>
{/* Decorative: the button itself carries the accessible name. */}
<span aria-hidden="true" style={{ display: "inline-flex" }}>
<BrandMark height="1.875rem" />
</span>
{loading && !showTick && (
<span className="chat-fab-btn__pulse" aria-hidden="true" />
)}
@@ -34,9 +34,7 @@ export const SpaceBetween: Story = {
}}
>
<span>Pipeline name</span>
<StatusBadge tone="success" pulse>
healthy
</StatusBadge>
<StatusBadge tone="success">healthy</StatusBadge>
</Inline>
),
};
+33
View File
@@ -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;
}
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Logo } from "@app/ui/Logo";
const meta: Meta<typeof Logo> = {
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<typeof Logo>;
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: () => (
<div style={{ display: "flex", gap: 48, alignItems: "center" }}>
<Logo variant="iconOnly" />
<Logo variant="textOnly" />
<Logo variant="iconAndText" />
</div>
),
};
/** iconAndText stacked — as used in the workbench empty state. */
export const Stacked: Story = {
render: () => (
<Logo
variant="iconAndText"
orientation="vertical"
iconHeight="3.5rem"
textHeight="1.5rem"
gap="0.75rem"
/>
),
};
+89
View File
@@ -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 (
<span className={cls} style={{ ...layoutStyle, ...style }}>
{showIcon && (
<img
className="sui-logo__mark"
src={markUrl}
alt={showText ? "" : alt}
aria-hidden={showText ? true : undefined}
style={{ height: iconHeight }}
/>
)}
{showText && (
<>
<img
className="sui-logo__wordmark sui-logo__wordmark--light"
src={wordmarkLightUrl}
alt={alt}
style={{ height: textHeight }}
/>
<img
className="sui-logo__wordmark sui-logo__wordmark--dark"
src={wordmarkDarkUrl}
alt={alt}
style={{ height: textHeight }}
/>
</>
)}
</span>
);
}
+65 -3
View File
@@ -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%;
}
}
@@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
width={22}
height={22}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
const meta: Meta<typeof MetricStrip> = {
title: "Layout/MetricStrip",
component: MetricStrip,
@@ -22,3 +41,30 @@ export const Default: Story = {
</MetricStrip>
),
};
export const Row: Story = {
render: () => (
<MetricStrip layout="row" leading={<ShieldIcon />}>
<MetricCard
label="Active policies"
value="2"
description="Enforcing on upload/export"
/>
<MetricCard
label="Paused"
value="0"
description="Configured but not firing"
/>
<MetricCard
label="Categories"
value="6"
description="Available to configure"
/>
<MetricCard
label="Docs enforced"
value="0"
description="Across active policies"
/>
</MetricStrip>
),
};
+23 -9
View File
@@ -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 (
<div
className={["sui-metric-strip", className ?? ""]
.filter(Boolean)
.join(" ")}
>
<div className={classes}>
{layout === "row" && leading != null && (
<div className="sui-metric-strip__leading">{leading}</div>
)}
{children}
</div>
);
+1
View File
@@ -52,6 +52,7 @@ export function NavItem({
.join(" ")}
data-accent={accent}
aria-current={isActive ? "page" : undefined}
aria-label={label}
>
{icon && (
<span className="sui-navitem__icon" aria-hidden>
@@ -0,0 +1,5 @@
.sui-nav-surface {
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-nav);
}
@@ -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 (
<span
style={{
width: 14,
height: 14,
borderRadius: 3,
background: "var(--c-primary)",
display: "inline-block",
}}
/>
);
}
const meta: Meta<typeof NavSurface> = {
title: "Primitives/NavSurface",
component: NavSurface,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ width: "15rem", background: "var(--c-bg)", padding: 12 }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof NavSurface>;
/** 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: (
<div style={{ padding: "0.5rem 0" }}>
<NavItem id="home" label="Home" icon={<Dot />} />
<NavItem id="sources" label="Sources" icon={<Dot />} />
<NavItem id="documents" label="Documents" icon={<Dot />} />
</div>
),
},
};
/** `as` renders a landmark element instead of a div. */
export const AsSection: Story = {
args: {
as: "section",
"aria-label": "Processor",
children: (
<div style={{ padding: "0.75rem" }}>
<span style={{ fontSize: 13, color: "var(--c-text-muted)" }}>
Any content, not just nav rows.
</span>
</div>
),
},
};
@@ -0,0 +1,30 @@
import { forwardRef, type HTMLAttributes } from "react";
import "@app/ui/NavSurface.css";
export interface NavSurfaceProps extends HTMLAttributes<HTMLDivElement> {
/** Element to render; `section`/`aside` when the box is a landmark. */
as?: "div" | "section" | "aside";
}
/**
* 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<HTMLDivElement, NavSurfaceProps>(
function NavSurface(
{ as: Component = "div", className, children, ...rest },
ref,
) {
return (
<Component
ref={ref}
className={["sui-nav-surface", className ?? ""]
.filter(Boolean)
.join(" ")}
{...rest}
>
{children}
</Component>
);
},
);
@@ -50,9 +50,7 @@ export const WithActions: Story = {
accent: "purple",
actions: (
<>
<StatusBadge tone="success" pulse>
Healthy
</StatusBadge>
<StatusBadge tone="success">Healthy</StatusBadge>
<Button size="sm" variant="secondary">
Edit composition
</Button>
+28 -27
View File
@@ -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);
}
@@ -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 = {
+2 -14
View File
@@ -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 (
<span className={cls}>
{showDot && (
<span
className={`sui-status__dot${pulse ? " sui-status__dot--pulse" : ""}`}
aria-hidden
/>
)}
{showDot && <span className="sui-status__dot" aria-hidden />}
<span className="sui-status__label">{children}</span>
</span>
);
+17 -7
View File
@@ -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. */
+2
View File
@@ -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";
@@ -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 (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
@@ -43,9 +43,6 @@
}
.portal-shell__topbar-wordmark {
height: 1.375rem;
width: auto;
display: block;
margin-right: auto;
}
@@ -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 (
<header className="portal-shell__topbar">
@@ -30,10 +27,11 @@ function MobileTopbar() {
>
<MenuIcon size={20} />
</ActionIcon>
<img
<Logo
variant="iconAndText"
iconHeight="1.6rem"
textHeight="1.3rem"
className="portal-shell__topbar-wordmark"
src={theme === "dark" ? wordmarkDark : wordmarkLight}
alt={t("portal.shell.sidebar.brandSuffix")}
/>
<ActionIcon
variant="tertiary"
@@ -75,6 +75,10 @@ export function PortalSettingsHost() {
urlSync={false}
initialSection={initialSection}
extraSections={extraSections}
// TODO: opening Keyboard Shortcuts in the portal white-screens
// the app (the section expects editor-only context). Hidden here
// as a stopgap; fix the section properly and drop this.
hiddenSectionKeys={["hotkeys"]}
/>
</ThemeProvider>
</PreferencesProvider>
@@ -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;
@@ -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) => (
<NavItem
key={entry.id}
id={entry.id}
label={t(`portal.nav.${entry.id}`)}
icon={entry.icon}
isActive={activeView === entry.id}
onClick={(id) => {
// 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 = (
<NavItem
key={entry.id}
id={entry.id}
label={label}
icon={entry.icon}
isActive={activeView === entry.id}
onClick={(id) => {
// 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 ? (
<Tooltip key={entry.id} label={label} position="right" withinPortal>
<div className="portal-sidebar__navtip">{item}</div>
</Tooltip>
) : (
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}
>
<div className="portal-sidebar__logo">
<img
className="portal-sidebar__mark"
src={mark}
alt=""
aria-hidden="true"
/>
{/* 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. */}
<img
className="portal-sidebar__wordmark portal-sidebar__wordmark--light"
src={wordmarkLight}
alt="Stirling"
/>
<img
className="portal-sidebar__wordmark portal-sidebar__wordmark--dark"
src={wordmarkDark}
alt="Stirling"
<BrandSwitcher
current="processor"
onSwitch={goToEditor}
collapsed={collapsed}
/>
<AppSwitch
className="portal-sidebar__app-switch"
current="processor"
theme={theme}
onSwitch={goToEditor}
/>
<ActionIcon
variant="tertiary"
className="portal-sidebar__collapse"
aria-label={
collapsed
? t("fileSidebar.expand", "Expand sidebar")
: t("fileSidebar.collapse", "Collapse sidebar")
}
onClick={toggleSidebarCollapsed}
>
<SidebarToggleIcon size={18} />
</ActionIcon>
<ActionIcon
variant="tertiary"
@@ -120,18 +130,22 @@ export function Sidebar() {
<nav className="portal-sidebar__nav">
{NAV_SECTIONS.map((section) => (
<section key={section.labelKey} className="portal-sidebar__section">
<NavSurface
key={section.labelKey}
as="section"
className="portal-sidebar__section"
>
<h2 className="portal-sidebar__section-label">
{t(section.labelKey)}
</h2>
<div className="portal-sidebar__group">
{renderGroup(section.entries)}
</div>
</section>
</NavSurface>
))}
</nav>
<div className="portal-sidebar__footer">
<NavSurface className="portal-sidebar__footer">
<LinkAccountFooterItem />
<NavItem
id="settings"
@@ -139,7 +153,7 @@ export function Sidebar() {
icon={<SettingsIcon />}
onClick={() => openSettings()}
/>
</div>
</NavSurface>
</aside>
);
}
@@ -68,7 +68,7 @@ export function LinkedInstancesTable({
{t("portal.accountLink.instances.revoked", "Revoked")}
</StatusBadge>
) : (
<StatusBadge tone="success" size="sm" pulse>
<StatusBadge tone="success" size="sm">
{t("portal.accountLink.instances.active", "Active")}
</StatusBadge>
),
@@ -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 (
<MetricStrip>
<MetricStrip layout="row" leading={<EditorIcon size={22} />}>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} height="5.5rem" />
<Skeleton key={i} width="5rem" height="2rem" />
))}
</MetricStrip>
);
}
return (
<MetricStrip>
<MetricStrip layout="row" leading={<EditorIcon size={22} />}>
{summary.metrics.map((m) => (
<MetricCard
key={m.label}
@@ -70,11 +70,7 @@ export function DeploymentTargets({ targets, onUpgrade }: Props) {
{target.tagline}
</p>
</div>
<StatusBadge
tone={STATE_BADGE_TONE[target.state]}
size="sm"
pulse={target.state === "running"}
>
<StatusBadge tone={STATE_BADGE_TONE[target.state]} size="sm">
{t(`portal.editorAdmin.targets.state.${target.state}`)}
</StatusBadge>
</div>
@@ -69,11 +69,7 @@ export function InstanceHealthTable({ instances }: Props) {
key: "status",
header: t("portal.editorAdmin.health.columns.status"),
render: (i) => (
<StatusBadge
tone={INSTANCE_STATUS_TONE[i.status]}
size="sm"
pulse={i.status === "healthy"}
>
<StatusBadge tone={INSTANCE_STATUS_TONE[i.status]} size="sm">
{t(INSTANCE_STATUS_LABEL[i.status])}
</StatusBadge>
),
@@ -5,6 +5,7 @@ import {
Card,
EmptyState,
MetricCard,
MetricStrip,
StatusBadge,
Table,
Tabs,
@@ -140,7 +141,7 @@ export function AuditTab() {
/>
{data && (
<section className="portal-infra__metrics">
<MetricStrip layout="row">
<MetricCard
label={t("portal.infrastructure.audit.metrics.totalEvents")}
value={data.summary.totalEvents.toLocaleString()}
@@ -157,7 +158,7 @@ export function AuditTab() {
label={t("portal.infrastructure.audit.metrics.config")}
value={data.summary.config.toLocaleString()}
/>
</section>
</MetricStrip>
)}
{!forbidden && (
@@ -74,11 +74,7 @@ export function DeploymentsTab() {
key: "status",
header: t("portal.infrastructure.deployments.regionColumns.status"),
render: (r) => (
<StatusBadge
tone={REGION_TONE[r.status]}
size="sm"
pulse={r.status === "healthy"}
>
<StatusBadge tone={REGION_TONE[r.status]} size="sm">
{t(REGION_LABEL[r.status])}
</StatusBadge>
),
@@ -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) => (
<StatusBadge
tone={MODEL_TONE[m.status]}
size="sm"
pulse={m.status === "active"}
>
<StatusBadge tone={MODEL_TONE[m.status]} size="sm">
{t(MODEL_LABEL[m.status])}
</StatusBadge>
),
@@ -174,7 +171,7 @@ export function ModelsTab() {
/>
{data && (
<section className="portal-infra__metrics">
<MetricStrip layout="row">
<MetricCard
label={t("portal.infrastructure.models.metrics.activeModels")}
value={data.summary.activeModels}
@@ -193,7 +190,7 @@ export function ModelsTab() {
: t("portal.infrastructure.models.metrics.included")
}
/>
</section>
</MetricStrip>
)}
<section>
@@ -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 (
<MetricStrip>
<MetricStrip layout="row" leading={<PipelinesIcon size={22} />}>
{KPI_LABEL_KEYS.map((labelKey, i) => {
const k = loading ? undefined : data?.kpis[i];
return (
@@ -49,11 +49,7 @@ export function PipelinesTable({ pipelines, onRowClick }: PipelinesTableProps) {
key: "status",
header: t("portal.pipelines.table.status"),
render: (p) => (
<StatusBadge
tone={STATUS_TONE[p.status]}
size="sm"
pulse={p.status === "active"}
>
<StatusBadge tone={STATUS_TONE[p.status]} size="sm">
{t(`portal.pipelines.status.${p.status}`)}
</StatusBadge>
),
@@ -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 (
<MetricStrip>
<MetricStrip layout="row" leading={<PoliciesIcon size={22} />}>
<MetricCard
label={t("portal.policies.summary.active.label")}
value={s ? s.active : "—"}
@@ -100,11 +100,7 @@ export function PolicyCatalogueTable({
if (entry.policy) {
const paused = entry.policy.state.status === "paused";
return (
<StatusBadge
tone={paused ? "warning" : "success"}
size="sm"
pulse={!paused}
>
<StatusBadge tone={paused ? "warning" : "success"} size="sm">
{paused
? t("portal.policies.status.paused")
: t("portal.policies.status.active")}
@@ -87,7 +87,6 @@ export function PolicyCategoryCard({
<StatusBadge
tone={status === "paused" ? "warning" : "success"}
size="sm"
pulse={status !== "paused"}
>
{status === "paused"
? t("portal.policies.status.paused")
@@ -196,10 +196,7 @@ export function PolicyDetailPanel({
>
{/* Status + trigger strip */}
<div className="portal-policies__detail-status">
<StatusBadge
tone={isPaused ? "warning" : "success"}
pulse={!isPaused}
>
<StatusBadge tone={isPaused ? "warning" : "success"}>
{isPaused
? t("portal.policies.status.paused")
: t("portal.policies.status.active")}
@@ -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 (
<MetricStrip>
<MetricStrip layout="row" leading={<SourcesIcon size={22} />}>
{KPI_LABEL_KEYS.map((labelKey, i) => {
const k = loading ? undefined : data?.kpis[i];
return (
@@ -61,11 +61,7 @@ export function SourcesTable({ sources, onRowClick }: SourcesTableProps) {
key: "status",
header: t("portal.sources.table.status"),
render: (s) => (
<StatusBadge
tone={STATUS_TONE[s.status]}
size="sm"
pulse={s.status === "active"}
>
<StatusBadge tone={STATUS_TONE[s.status]} size="sm">
{t(`portal.sources.status.${s.status}`)}
</StatusBadge>
),
@@ -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<UIContextValue | null>(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,
@@ -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;
@@ -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;
@@ -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) {
<Button
type="button"
variant="quiet"
accent="brand"
accent="default"
onClick={onSignUp}
className="auth-signup-link"
>
@@ -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}
</Button>
@@ -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);
@@ -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<typeof StirlingLogoOutline>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Large: Story = {
args: {
size: 64,
},
};
@@ -1,21 +0,0 @@
/**
* A temp stirling logo, may change in future.
*/
export function StirlingLogoOutline({ size = 20 }: { size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 192 192"
fill="currentColor"
aria-hidden="true"
>
<path
d="M68.48 102.4 L184.73 6.45 L184.73 96.05 L68.48 192 Z"
opacity="0.5"
/>
<path d="M7.26 95.83 L123.37 0 L123.37 89.5 L7.26 185.33 Z" />
</svg>
);
}
@@ -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. */
@@ -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 (
<Box className="chat-panel chat-panel--embedded">
<PanelHeader
icon={<StirlingLogoOutline size={16} />}
title={t("agents.stirling_name", "Stirling")}
icon={<BrandMark height="26px" className="chat-panel__header-mark" />}
title={
<Logo
variant="textOnly"
textHeight="17px"
alt={t("agents.stirling_name", "Stirling")}
/>
}
loading={isLoading}
className="chat-panel__header"
barClassName="chat-panel__agent-pill-vt"
@@ -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 (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
return (
<AppSwitch
<BrandSwitcher
current="editor"
theme={colorScheme === "dark" ? "dark" : "light"}
onSwitch={() => navigate(PORTAL_BASENAME)}
collapsed={collapsed}
/>
);
}
@@ -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<string | null>(null);
const [showEmailForm, setShowEmailForm] = useState(false);
const [showEmailForm, setShowEmailForm] = useState(true);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(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() {
</div>
) : undefined
}
beforeEmailForm={
hasSSOProviders && !showEmailForm && isUserPassAllowed ? (
<div className="auth-section">
<Button
type="button"
variant="tertiary"
hover={false}
onClick={() => 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")}
</Button>
</div>
) : undefined
}
footer={
<>
{isFirstTimeSetup &&
@@ -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")}
</Button>
+1 -1
View File
@@ -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";
@@ -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;
}
@@ -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<AppConfigModalProps> = ({
@@ -40,6 +42,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
onClose,
initialSection,
extraSections,
hiddenSectionKeys,
}) => {
const isMobile = useMediaQuery("(max-width: 1024px)");
@@ -153,8 +156,23 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
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) {
@@ -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 (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
return (
<BrandSwitcher
current="editor"
onSwitch={() => navigate(PORTAL_BASENAME)}
collapsed={collapsed}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More