diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 77e41e2ffd..bf00cfd08d 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -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" diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 71a9cf07e5..0ae53a3ce9 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -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$/, ]; diff --git a/frontend/editor/src/core/assets/brand/branding-logo/logo-mark.svg b/frontend/editor/src/core/assets/brand/branding-logo/logo-mark.svg new file mode 100644 index 0000000000..de7d337f91 --- /dev/null +++ b/frontend/editor/src/core/assets/brand/branding-logo/logo-mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/editor/src/core/assets/brand/branding-logo/wordmark-dark.svg b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-dark.svg new file mode 100644 index 0000000000..5c29f32920 --- /dev/null +++ b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/editor/src/core/assets/brand/branding-logo/wordmark-light.svg b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-light.svg new file mode 100644 index 0000000000..a9eeaa9e74 --- /dev/null +++ b/frontend/editor/src/core/assets/brand/branding-logo/wordmark-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx index 5491138e6b..94d2e35e47 100644 --- a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx @@ -181,11 +181,11 @@ const AddFileCard = ({ {/* Instruction Text */} {terminology.dropFilesHere} diff --git a/frontend/editor/src/core/components/fileEditor/FileEditor.module.css b/frontend/editor/src/core/components/fileEditor/FileEditor.module.css index c5770f1ea7..410ade863b 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditor.module.css +++ b/frontend/editor/src/core/components/fileEditor/FileEditor.module.css @@ -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: diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 9ad7963b6f..3805b4b912 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -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 = ({ 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 = ({ 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) { diff --git a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx index 1ba57618f0..6c23178219 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx @@ -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} /> )} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.css b/frontend/editor/src/core/components/shared/AppSwitch.css deleted file mode 100644 index bccf8fbceb..0000000000 --- a/frontend/editor/src/core/components/shared/AppSwitch.css +++ /dev/null @@ -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; -} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx b/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx deleted file mode 100644 index 6d4449fe58..0000000000 --- a/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx +++ /dev/null @@ -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 = { - title: "Shared/AppSwitch", - component: AppSwitch, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -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: () => {}, - }, -}; diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx index b713e75e7c..71d35ed5ad 100644 --- a/frontend/editor/src/core/components/shared/AppSwitch.tsx +++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx @@ -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 ( - - - - ); -} - -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 , 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 ( - - - - - - {apps.map((app) => ( - onSwitch(app.id)} - leading={} - > - {app.label} - - ))} - - + {app.label} + + ))} + ); } diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx index 298a46e1ac..aaf55148ef 100644 --- a/frontend/editor/src/core/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/core/components/shared/AppSwitcher.tsx @@ -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 ( + + ); } diff --git a/frontend/editor/src/core/components/shared/BrandMark.css b/frontend/editor/src/core/components/shared/BrandMark.css new file mode 100644 index 0000000000..7ddff9b4c7 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandMark.css @@ -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; + } +} diff --git a/frontend/editor/src/core/components/shared/BrandMark.tsx b/frontend/editor/src/core/components/shared/BrandMark.tsx new file mode 100644 index 0000000000..f140eddf7f --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandMark.tsx @@ -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 ( + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.css b/frontend/editor/src/core/components/shared/BrandSwitcher.css new file mode 100644 index 0000000000..dc1659ea15 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandSwitcher.css @@ -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; +} diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx new file mode 100644 index 0000000000..92deb518b2 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandSwitcher.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BrandSwitcher } from "@app/components/shared/BrandSwitcher"; + +const meta: Meta = { + 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; + +export const Playground: Story = {}; diff --git a/frontend/editor/src/core/components/shared/BrandSwitcher.tsx b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx new file mode 100644 index 0000000000..474173fee2 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BrandSwitcher.tsx @@ -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 ( +
+ + + + + + + + +
+ ); +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index 7dce3d32b9..e2393114df 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -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 */ diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 501c5385d1..7d09261226 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -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( collapsed = false, onToggleCollapse, onOpenSettings, - toggleAriaLabel, - toggleIcon, onUploadFiles, onPickGoogleDriveFiles, onSearchClick, extraAction, + toggleAriaLabel, + toggleIcon, }, ref, ) { @@ -833,110 +833,78 @@ const FileSidebar = forwardRef( )}
- {/* Header: hamburger + branding */} - -
onToggleCollapse?.()} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onToggleCollapse?.(); +
+ + {onToggleCollapse && ( + 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. */} - - {toggleIcon ?? } - - {!collapsed && ( - - )} - {!collapsed && ( - // The header row itself toggles collapse; stop the switcher's - // clicks and key presses from reaching it. - e.stopPropagation()} - onKeyDown={(e) => e.stopPropagation()} - > - - - )} -
- + {toggleIcon ?? } + + )} +
- {/* Search row */} - -
e.key === "Enter" && handleSearchClick() - : undefined - } + {/* Box 1 — top controls (search + open / my files / cloud). No title. */} + + {/* Search row */} + - {searchActive && !collapsed ? ( - { - e.stopPropagation(); - handleSearchClose(); - }} - /> - ) : ( - - )} - {!collapsed && - (searchActive ? ( - setSearchQuery(e.target.value)} - placeholder={t( - "fileSidebar.searchPlaceholder", - "Search files...", - )} - onClick={(e) => e.stopPropagation()} +
e.key === "Enter" && handleSearchClick() + : undefined + } + > + {searchActive && !collapsed ? ( + { + e.stopPropagation(); + handleSearchClose(); + }} /> ) : ( - - {t("fileSidebar.search", "Search")} - - ))} -
-
+ + )} + {!collapsed && + (searchActive ? ( + setSearchQuery(e.target.value)} + placeholder={t( + "fileSidebar.searchPlaceholder", + "Search files...", + )} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {t("fileSidebar.search", "Search")} + + ))} +
+
- {/* Scrollable content */} -
{/* 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( )}
)} + - {/* Files section - always visible when expanded */} - {!collapsed && ( -
-
- - {t("fileSidebar.files", "Files")} - - - navigate("/files")} - title={t( - "fileSidebar.openFileManager", - "Browse all files & folders", - )} - aria-label={t( - "fileSidebar.openFileManager", - "Browse all files & folders", - )} - data-testid="open-files-page" - > - - - nativeFileInputRef.current?.click()} - title={t("fileSidebar.addFiles", "Add files")} - aria-label={t("fileSidebar.addFiles", "Add files")} - > - - -
- - - - {!stubsLoaded ? ( -
- + {/* Box 2 — the file tree (this box scrolls). */} + +
+ {/* Files section - always visible when expanded */} + {!collapsed && ( +
+
+ + {t("fileSidebar.library", "PDF Library")} + + + navigate("/files")} + title={t( + "fileSidebar.openFileManager", + "Browse all files & folders", + )} + aria-label={t( + "fileSidebar.openFileManager", + "Browse all files & folders", + )} + data-testid="open-files-page" + > + + + nativeFileInputRef.current?.click()} + title={t("fileSidebar.addFiles", "Add files")} + aria-label={t("fileSidebar.addFiles", "Add files")} + > + +
- ) : filteredFileStubs.length > 0 ? ( -
- {fileGroups ? ( - <> - {fileGroups.map((group) => { - const isOpen = - groupOpen[group.id] ?? group.defaultExpanded; - return ( -
- -
- {isOpen && group.stubs.map(renderFileRow)} -
-
- ); - })} - - - ) : ( - filteredFileStubs.map(renderFileRow) - )} -
- ) : ( - !searchActive && ( -
-

- {t("fileSidebar.noFiles", "No files yet")} -

-

- {t("fileSidebar.dropHint", "Open files to get started")} -

+ + + + {!stubsLoaded ? ( +
+
- ) - )} -
- )} -
+ ) : filteredFileStubs.length > 0 ? ( +
+ {fileGroups ? ( + <> + {fileGroups.map((group) => { + const isOpen = + groupOpen[group.id] ?? group.defaultExpanded; + return ( +
+ +
+ {isOpen && group.stubs.map(renderFileRow)} +
+
+ ); + })} + + + ) : ( + filteredFileStubs.map(renderFileRow) + )} +
+ ) : ( + !searchActive && ( +
+

+ {t("fileSidebar.noFiles", "No files yet")} +

+

+ {t( + "fileSidebar.dropHint", + "Open files to get started", + )} +

+
+ ) + )} +
+ )} +
+
{/* Kebab "Save to cloud" upload modal (one file at a time). */} @@ -1325,65 +1306,70 @@ const FileSidebar = forwardRef( {/* Getting-started checklist, floating above the footer (SaaS only). */} - {/* Bottom bar: user name + settings */} - -
+ {/* Bottom bar: user name + settings */} + 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} >
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 ? ( - setPictureFailed(true)} - /> - ) : ( - displayName.charAt(0).toUpperCase() +
+ {showProfilePicture ? ( + setPictureFailed(true)} + /> + ) : ( + displayName.charAt(0).toUpperCase() + )} +
+ {!collapsed && ( + + {displayName} + + )} + {onOpenSettings && !collapsed && ( +
+ +
)}
- {!collapsed && ( - - {displayName} - - )} - {onOpenSettings && !collapsed && ( -
- -
- )} -
-
+
+
); }, diff --git a/frontend/editor/src/core/components/shared/LandingActions.tsx b/frontend/editor/src/core/components/shared/LandingActions.tsx index ec237b0394..d69f90a1ef 100644 --- a/frontend/editor/src/core/components/shared/LandingActions.tsx +++ b/frontend/editor/src/core/components/shared/LandingActions.tsx @@ -33,6 +33,7 @@ export function LandingActions({ diff --git a/frontend/editor/src/core/ui/StatusBadge.css b/frontend/editor/src/core/ui/StatusBadge.css index 230c99b40d..4f64737b32 100644 --- a/frontend/editor/src/core/ui/StatusBadge.css +++ b/frontend/editor/src/core/ui/StatusBadge.css @@ -2,35 +2,21 @@ display: inline-flex; align-items: center; gap: 0.375rem; - border-radius: var(--radius-pill); font-family: var(--font-sans); font-weight: 500; letter-spacing: 0.01em; - border: 1px solid transparent; line-height: 1; color: var(--sui-status-c, var(--c-text-subtle)); - background: color-mix( - in srgb, - var(--sui-status-c, var(--c-text-subtle)) 12%, - transparent - ); - border-color: color-mix( - in srgb, - var(--sui-status-c, var(--c-text-subtle)) 28%, - transparent - ); } + .sui-status--sm { font-size: 0.6875rem; - padding: 0.125rem 0.5rem; } .sui-status--md { font-size: 0.75rem; - padding: 0.1875rem 0.625rem; } .sui-status--lg { font-size: 0.8125rem; - padding: 0.3125rem 0.75rem; } .sui-status__dot { @@ -38,25 +24,40 @@ height: 0.375rem; border-radius: 50%; background: currentColor; - position: relative; -} -.sui-status__dot--pulse::after { - content: ""; - position: absolute; - inset: -0.125rem; - border-radius: 50%; - border: 2px solid currentColor; - animation: pulseRing 1.4s ease-out infinite; } -/* Neutral keeps the plain muted surface rather than an accent tint. */ +.sui-status--pill { + border-radius: var(--radius-pill); + border: 1px solid + color-mix( + in srgb, + var(--sui-status-c, var(--c-text-subtle)) 28%, + transparent + ); + background: color-mix( + in srgb, + var(--sui-status-c, var(--c-text-subtle)) 12%, + transparent + ); +} +.sui-status--pill.sui-status--sm { + padding: 0.125rem 0.5rem; +} +.sui-status--pill.sui-status--md { + padding: 0.1875rem 0.625rem; +} +.sui-status--pill.sui-status--lg { + padding: 0.3125rem 0.75rem; +} + .sui-status--neutral { color: var(--c-text-subtle); +} +.sui-status--pill.sui-status--neutral { background: var(--c-surface-sunken); border-color: var(--c-border-subtle); } -/* Tones only pick the accent; the base rule builds the fill + border. `-dark` - is theme-adaptive, so text stays legible on the pale fill in both themes. */ + .sui-status--success { --sui-status-c: var(--color-green-dark); } diff --git a/frontend/editor/src/core/ui/StatusBadge.stories.tsx b/frontend/editor/src/core/ui/StatusBadge.stories.tsx index 8b77c58e41..9edeb00717 100644 --- a/frontend/editor/src/core/ui/StatusBadge.stories.tsx +++ b/frontend/editor/src/core/ui/StatusBadge.stories.tsx @@ -33,7 +33,7 @@ export const AllTones: Story = { }; export const Live: Story = { - args: { tone: "success", pulse: true, children: "Live" }, + args: { tone: "success", children: "Live" }, }; export const Sizes: Story = { diff --git a/frontend/editor/src/core/ui/StatusBadge.tsx b/frontend/editor/src/core/ui/StatusBadge.tsx index 5ffd5d44f9..7d80819edd 100644 --- a/frontend/editor/src/core/ui/StatusBadge.tsx +++ b/frontend/editor/src/core/ui/StatusBadge.tsx @@ -14,28 +14,21 @@ export type StatusSize = "sm" | "md" | "lg"; export interface StatusBadgeProps { tone?: StatusTone; size?: StatusSize; - /** Show a leading coloured dot. */ showDot?: boolean; - /** Render the dot with a pulse animation (active / live indicator). */ - pulse?: boolean; children?: ReactNode; className?: string; } -/** - * Inline status pill used across surfaces — pipeline rows, document status, - * deployments, audit logs. Tone maps to semantic meaning, not raw colour. - */ export function StatusBadge({ tone = "neutral", size = "md", showDot = true, - pulse = false, children, className, }: StatusBadgeProps) { const cls = [ "sui-status", + showDot ? "" : "sui-status--pill", `sui-status--${tone}`, `sui-status--${size}`, className ?? "", @@ -44,12 +37,7 @@ export function StatusBadge({ .join(" "); return ( - {showDot && ( - - )} + {showDot && } {children} ); diff --git a/frontend/editor/src/core/ui/accents.css b/frontend/editor/src/core/ui/accents.css index 404f98cc52..cf83edacaf 100644 --- a/frontend/editor/src/core/ui/accents.css +++ b/frontend/editor/src/core/ui/accents.css @@ -2,16 +2,26 @@ * accents derive from --color-* tokens (auto dark), neutral/brand/ai are explicit. */ .sui-acc-default { - --_solid: var(--c-primary); - --_solid-hover: var(--c-primary-hover); - --_on: #ffffff; + --_solid: var(--c-btn-solid); + --_solid-hover: color-mix( + in srgb, + var(--c-btn-solid) 85%, + var(--c-btn-inverse) + ); + --_on: var(--c-btn-inverse); --_text: var(--c-primary-hover); --_bd: color-mix(in srgb, var(--c-primary) 38%, var(--c-surface)); --_tint: color-mix(in srgb, var(--c-primary) 12%, transparent); -} - -html[data-app-theme="custom"] .sui-acc-default { - --_on: var(--c-text-on-primary); + --_solid-2: var(--c-btn-secondary); + --_solid-2-hover: color-mix( + in srgb, + var(--c-btn-secondary) 92%, + var(--c-btn-solid) + ); + --_on-2: var(--c-btn-solid); + --_bd-2: var(--c-btn-secondary-border); + --_tert-text: var(--c-text); + --_tert-tint: var(--c-hover); } /* Danger is pinned to a fixed deep red (not the theme-lightened coral), so the fill and the outline/text are the SAME red in both light and dark. */ diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts index d94dfab813..45212d8038 100644 --- a/frontend/editor/src/core/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -1,5 +1,6 @@ export * from "@app/ui/Button"; export * from "@app/ui/ActionIcon"; +export * from "@app/ui/Logo"; export * from "@app/ui/FilePicker"; export * from "@app/ui/SegmentedControl"; export * from "@app/ui/StatusBadge"; @@ -8,6 +9,7 @@ export * from "@app/ui/ToggleSwitch"; export * from "@app/ui/ProgressBar"; export * from "@app/ui/MetricCard"; export * from "@app/ui/NavItem"; +export * from "@app/ui/NavSurface"; export * from "@app/ui/PanelHeader"; export * from "@app/ui/CodeBlock"; export * from "@app/ui/SectionDivider"; diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx index 9d7c2c095e..21896d9a1d 100644 --- a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx +++ b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx @@ -1,9 +1,18 @@ +import { Logo } from "@app/ui/Logo"; +import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher"; + /** * Desktop inherits proprietary's layers but does not ship the portal (see - * desktop/routes/adminRouteExtensions), so shadow the switcher back to empty — - * otherwise the desktop bundle would reference @portal via the proprietary - * switcher's imports. + * desktop/routes/adminRouteExtensions), so there's nothing to switch to — + * shadow the brand header back to a plain logo. (Also avoids the desktop + * bundle referencing @portal via the proprietary switcher's imports.) */ -export function AppSwitcher() { - return null; +export function AppSwitcher({ collapsed }: AppSwitcherProps) { + return ( + + ); } diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css index 6535832399..152c45f7c5 100644 --- a/frontend/editor/src/portal/components/AppShell.css +++ b/frontend/editor/src/portal/components/AppShell.css @@ -43,9 +43,6 @@ } .portal-shell__topbar-wordmark { - height: 1.375rem; - width: auto; - display: block; margin-right: auto; } diff --git a/frontend/editor/src/portal/components/AppShell.tsx b/frontend/editor/src/portal/components/AppShell.tsx index c50b4000e9..77c8f90686 100644 --- a/frontend/editor/src/portal/components/AppShell.tsx +++ b/frontend/editor/src/portal/components/AppShell.tsx @@ -3,11 +3,9 @@ import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; import { ActionIcon } from "@app/ui"; import { Sidebar } from "@portal/components/Sidebar"; -import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; import { MenuIcon, SearchIcon } from "@portal/components/icons"; -import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg"; -import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg"; +import { Logo } from "@app/ui/Logo"; import "@portal/components/AppShell.css"; /** @@ -17,7 +15,6 @@ import "@portal/components/AppShell.css"; */ function MobileTopbar() { const { t } = useTranslation(); - const { theme } = useTheme(); const { mobileNavOpen, toggleMobileNav, openSearch } = useUI(); return (
@@ -30,10 +27,11 @@ function MobileTopbar() { > - {t("portal.shell.sidebar.brandSuffix")}