mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
co-authored by
Reece Browne
parent
22815b5458
commit
c91d63f215
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user