Compare commits

...
Author SHA1 Message Date
EthanHealy01 5731f97c18 Merge branch 'UI/editor-changes' into frontend/my-files-improvements 2026-08-01 12:13:08 +01:00
EthanHealy01 c33bb51b67 Merge branch 'UI/new-design' into UI/editor-changes 2026-08-01 12:13:06 +01:00
EthanHealy01 dac5036887 Keep the active-row contrast issue out of the NavSurface story
The a11y gate flagged a new color-contrast violation: the story rendered an
active NavItem, whose accent-on-tint text is 3.3:1. That's a known NavItem
issue, already baselined against its own story - this story is about the
surface, so it doesn't need to duplicate it.
2026-08-01 12:12:57 +01:00
EthanHealy01 9271813a51 Merge branch 'UI/editor-changes' into frontend/my-files-improvements 2026-08-01 03:18:28 +01:00
EthanHealy01 dfe0246d05 Merge branch 'UI/new-design' into UI/editor-changes 2026-08-01 03:17:16 +01:00
EthanHealy01 a217d0c75d Merge remote-tracking branch 'origin/main' into UI/new-design
# Conflicts:
#	frontend/editor/src/core/components/shared/FileSidebar.css
2026-08-01 03:14:20 +01:00
EthanHealy01 c7c7e7c405 Merge branch 'UI/editor-changes' into frontend/my-files-improvements 2026-08-01 02:50:05 +01:00
EthanHealy01 8af623304c Merge branch 'UI/new-design' into UI/editor-changes
# Conflicts:
#	frontend/editor/src/core/styles/index.css
2026-08-01 02:49:01 +01:00
EthanHealy01 cbf78a46e8 Nav surfaces are a SUI component, not a loose class
sui-nav-surface was a bare class in the global stylesheet that five call sites
had to remember to spell correctly. It's a NavSurface component in core/ui now,
owning its CSS like every other SUI primitive, with an `as` prop for the cases
where the box is a landmark (the processor's nav sections are sections).
2026-08-01 02:48:25 +01:00
EthanHealy01 8c4cdfe6b9 Notify only on structural storage changes
Notifying on every write turned out to be too broad: a 61-file policy batch
writes metadata per file mid-run, and the refresh each one triggered made the
workspace re-add files - usePolicyAutoRun.batch's "workspace never grows past
61" hung for minutes instead of passing in four seconds.

The listener now fires only when the SET of files changes (store, delete,
clear, folder moves), which is what the lists actually render. In-place edits
to an existing row - thumbnail, metadata, leaf flag - stay silent; the callers
that need them visible bump the revision themselves, as they did before.

Also completes the fileStorage mocks in the policy batch test, which omitted
subscribeToChanges and so threw on provider mount, and spells out the effect's
unsubscribe rather than returning it from a concise arrow.
2026-07-31 11:43:06 +01:00
EthanHealy01 d870fe82f1 My Files: keep the lists in sync, persist the view, delete without stalling
The file lists went stale because refreshing them keyed off a revision that
only IndexedDBContext bumped, and plenty of writers never go through it -
policy runs, share-link imports, watched folders and FileManagerContext all
call fileStorage directly. fileStorage now notifies its own listeners after
every write and delete, and the context subscribes, so a change refreshes the
lists no matter which path made it. The bumps coalesce on a microtask: a
50-file delete used to fire one bump per write, each re-reading every stub.

Thumbnails sometimes never appeared because useLazyThumbnail latched
`attempted` before the queued generation ran, while the effect cancelled that
work whenever it re-ran - and it re-ran on the identity of indexedDB and
updateStirlingFileStub. A row that lost the race stayed blank for the session.
Those two now live in a ref, so the effect only re-runs on the file, and the
latch is set when generation actually finishes.

Deleting a large selection left the grid on stale contents until the IDB write
and a full reconcile had finished. The rows and the selection now clear up
front and the dialog closes immediately; the write that follows notifies, and
that single refresh reconciles. The explicit refresh at the end went with it -
it was a second full pass over every stub for the same delete.

Grid/list choice persists in preferences (localStorage) instead of resetting
to grid on every load.
2026-07-31 02:38:07 +01:00
EthanHealy01 2343ec0486 Nav rows on one shared button; settings modal on app surfaces
Sidebar nav rows were three separate implementations: the shared NavItem in
the processor, hand-rolled divs in the editor's file sidebar, and near-copies
of the same CSS. They now all render NavItem, which grew the props the editor
rows needed - disabled (aria-disabled, so a tooltip can still explain why),
iconOnly for the collapsed rail, and hoverIcon for the Google Drive mark - and
forwards a ref so Mantine's Tooltip can wrap it directly.

Hover was near-invisible on these surfaces (--c-hover is a ~2% step on the
raised nav boxes), so it moves to --c-active and lifts the label and icon to
full contrast. Rows are full-bleed: the highlight spans its surface edge to
edge, with .sui-nav-surface clipping it at the rounded corners. That let the
processor drop the negative-margin geometry it used to fake the same effect.

The settings modal reads as a slice of the app: surface rail, canvas content,
and every section card normalised to a --c-surface box with a hairline border
and no shadow (the API-key card's drop shadow was the worst offender). Cards
were added where sections had none - Overview, Passwords & Security, Team,
People, Teams - and the ~24 page titles that just restated the nav label are
gone, since the modal header already names the page.
2026-07-31 02:16:15 +01:00
EthanHealy01 ddfc1f1712 Fetch portal access per identity, with no module-level cache
usePortalAccess held its answer in module state for the lifetime of the page.
That is wrong because the SPA can swap users without a reload: Supabase fires
SIGNED_OUT/SIGNED_IN in place (session revoked, signed out in another tab, or
a failed token refresh), and only the settings Logout button does a hard
location.assign. An admin's cached `true` therefore survived into the next
user's session, offering them an editor->processor switcher they can't use.
The processor is still gated server-side by SaasPortalGate, so this was a
misleading affordance rather than an access hole.

Rather than keying the cache on the user id, drop the module cache entirely:
the hook keeps its answer in React state and re-fetches when the Supabase
identity changes (or clears it on sign-out). The hook has exactly one
consumer — the sidebar switcher, mounted once — so a cross-mount cache bought
one request per page load at the cost of user-scoped module globals that have
to be invalidated correctly (the bug above, and an Aikido finding). Guests now
skip the request entirely instead of firing /me on every mount.

Tests cover the identity swap, sign-out, the guest case, a failed lookup and
a response landing after unmount; five of the six fail against the previous
implementation.
2026-07-30 22:38:19 +01:00
EthanHealy01 ebb8d1112d Address review: nav tokens, dark button fill, dead code, /files back-arrow
- Revert the PrepaidBundle.java comment rewrap; it was spotlessApply output
  from the saas flavor, which CI never checks (spotless runs on core only).
- Declare --c-btn-solid in both dark blocks. It only resolved before because
  every dark selector lands on <html>; a subtree-scoped dark theme (the
  Storybook decorator) inherited the light ink and gave ink-on-ink buttons.
- ChatFABButton: use var(--shadow-md)/--shadow-lg instead of hardcoded rgba,
  so the shadow adapts to the dark canvas like PdfViewerToolbar already does.
- Stop emitting sui-status--dot and sui-logo--horizontal; both are the default
  variant whose look is the absence of the modifier, and neither had any CSS.
- FileSidebar: consume toggleIcon/toggleAriaLabel again. HomePage still passed
  them for /files, where the control navigates home, so it announced "Expand
  sidebar" while doing something else.
- Add --radius-nav, --nav-gutter, --nav-rail-w and --motion-spring, and point
  the new nav surfaces at them. The two coupled calc()s now read off the
  gutter rather than a bare 0.5rem.
- main-dashboard spec: click the search toggle and assert the field mounts,
  rather than only asserting the toggle exists.
2026-07-30 22:21:39 +01:00
Reece Browne 5cf9d91854 Merge branch 'main' into UI/new-design 2026-07-30 09:49:52 +01:00
EthanHealy01 f1a4026b79 Tool panel: use the same toggle icon collapsed and expanded
The collapsed rail still showed a chevron while the expanded header showed the
mirrored panel glyph. One control, one icon — matching the left sidebar, which
also keeps a single icon across both states.
2026-07-30 03:08:18 +01:00
EthanHealy01 60f054e0c5 Hide Keyboard Shortcuts in the portal; mirror the toggle icon for the right rail
Stopgap: opening the hotkeys section in the portal white-screens the app, so
the settings shells take a hiddenSectionKeys prop and the portal host passes
["hotkeys"] with a TODO to fix the section properly. Both shells (core and
the saas override) honour it, since @app resolves per build.

The tool panel's collapse control used a bare chevron; give SidebarToggleIcon
a mirrored variant (divider on the right) so the right rail's toggle matches
the left sidebar's.
2026-07-30 03:00:25 +01:00
EthanHealy01 e06ceb99b2 Fix CI: drop stories for deleted components, clear subtle-text contrast
Two failures, both from this branch:

typecheck / a11y — the merge brought stories for AppSwitch and
StirlingLogoOutline, components this branch removed as dead. The a11y gate
maps a changed source file to its sibling story, so AppSwitch.tsx pulled in a
story that could no longer load, and a story yielding no results makes the
checker refuse the partial scan. Delete both stories.

a11y — .portal-infra__section-sub rendered --c-text-subtle (--p-gray-500) on
the new --p-paper canvas at 4.39:1. gray-500 passed on the white canvas it
replaced, so lowering the canvas broke it. Add gray-550 and point light-mode
--c-text-subtle at it (4.87:1), fixing every instance rather than one class.
2026-07-30 02:06:18 +01:00
EthanHealy01 c489d73904 Merge branch 'main' into UI/new-design 2026-07-30 01:52:17 +01:00
Reece Browne d798d7a468 Merge branch 'main' into UI/new-design 2026-07-29 16:12:02 +01:00
Reece Browne 3b44dbaea0 Merge branch 'main' into UI/new-design 2026-07-29 13:12:11 +01:00
EthanHealy01 40d80d1beb FileSidebar: let the brand switcher menu escape the collapsed rail
The sidebar and the workbench column are both z-10, and the workbench comes
later in the DOM, so it painted over the part of the app-switch menu that
spills past the 3.5rem collapsed rail. Put the sidebar on the dropdown layer
so the menu stays whole.
2026-07-27 15:24:01 +01:00
EthanHealy01 5cd0580303 Viewer: float the bottom toolbar and reveal it upwards
The bar was welded to the viewport edge (top-only corners, square bottom).
Give it the nav-surface treatment used by the workbench rails plus a 0.5rem
gutter, and slide it up on mount to mirror the top bar's 280ms ease. The
reveal sits on the fixed dock rather than the toolbar itself, since the
file-preview modal renders the same toolbar at the top of its layout.
2026-07-27 15:06:23 +01:00
EthanHealy01 713eaba1c7 AddFileCard: fill the portrait thumbnail slot at a fixed size
Each thumbnail derives --thumb-aspect from its own PDF's page dimensions, so
a hardcoded 8.5/11 made the card narrower than the pages beside it. Use the
slot's own numbers instead: 240px wide (card 260px less its 10px side
padding) by 284px tall, offset 36px down.
2026-07-27 14:50:12 +01:00
EthanHealy01 a58954e190 AddFileCard: size to the rendered page, not the whole thumb wrap
The 310px .thumbWrap also holds the always-reserved 26px toolchain bar above
the page, so the visible page is 284px tall starting 36px down (10px card
padding + the bar). Matching the wrap made the card 26px too tall and sat it
26px high; match the page band instead.
2026-07-27 14:44:48 +01:00
EthanHealy01 95c20054f1 AddFileCard: match the portrait PDF thumbnail footprint
The card was a squarer 260x310 next to ~240x310 A4 thumbnails. Derive the
width from the same 8.5/11 aspect at the 310px thumb height so it reads as
another portrait page in the row.
2026-07-27 14:40:22 +01:00
EthanHealy01 c535a09fd2 AddFileCard: align to the thumbnail band, not the whole grid cell
margin:auto centred the card against the full cell height, which includes
the filename/date caption under each thumbnail, dropping it ~33px below the
thumbnails. Match the thumbnail band instead: 310px tall (.thumbWrap) at the
file card's 10px padding-top, so the midlines line up exactly.
2026-07-27 14:37:57 +01:00
EthanHealy01 61ecf26034 AddFileCard: neutral hint text, centre the card in its grid row
The drop hint used --c-primary, so it read as a blue link rather than
instructions; use --c-text-muted. The card also has a fixed height with a
top-anchored margin, leaving it riding high against the taller file cards
(thumbnail + caption) in the same grid row — auto margins centre it.
2026-07-27 14:11:29 +01:00
EthanHealy01 9139b5fa51 Open the tool search before typing in the remaining e2e specs
The tool panel header now shows a search toggle, so the field only mounts
once it's pressed. The live XSS spec still filled the placeholder directly
and timed out; language-localization asserted the same placeholder as its
English-text check (it only passed because it sits behind a visibility
guard). Click the toggle / assert its label instead.
2026-07-26 01:19:20 +01:00
EthanHealy01 a64bf3843b WorkbenchBar: even up the gutters either side of the top bar
The file sidebar's own 0.5rem padding already provides the left gutter, so
the bar's matching left margin doubled it to 1rem while the right stayed at
0.5rem (the tool panel floats with margin-left: 0).
2026-07-25 23:00:24 +01:00
EthanHealy01 1aa99babd8 FileSidebar: match nav row label colour to the tool list
Search / Open from computer / My Files rows were --c-text-muted while the
tool panel's items inherit --c-text, so the two rails read at different
weights. Use --c-text for both.
2026-07-25 22:59:12 +01:00
EthanHealy01 f64c110017 Remove brand/switcher dead code orphaned by the new design
- StirlingLogoOutline: the chat panel header was its only consumer and now
  renders the shared BrandMark, leaving it with zero references.
- AppSwitch: the chevron-button switcher lost its last consumer when the
  processor sidebar moved to BrandSwitcher. Only AppSwitchMenuItems and
  AppSwitchTarget are still imported, so the file keeps just those (and
  AppSwitch.css goes with it, both classes unused).
- FileSidebar.css: drop the hamburger-header rules (.file-sidebar-header,
  .file-sidebar-menu-icon, .file-sidebar-brand-text, .file-sidebar-app-switch)
  left behind when the header became the logo switcher.
2026-07-25 22:48:04 +01:00
EthanHealy01 97bd412a30 fix CI 2026-07-25 22:38:17 +01:00
EthanHealy01 9fd20f731c Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into UI/new-design
# Conflicts:
#	frontend/editor/scripts/lint/theme-lint.mjs
#	frontend/editor/src/core/components/fileEditor/FileEditor.module.css
#	frontend/editor/src/core/components/shared/FileSidebar.css
#	frontend/editor/src/core/components/shared/FileSidebar.tsx
#	frontend/editor/src/core/components/shared/LandingPage.css
#	frontend/editor/src/core/components/shared/WorkbenchBar.css
#	frontend/editor/src/core/components/tools/RightSidebar.tsx
#	frontend/editor/src/core/components/tools/ToolPicker.tsx
#	frontend/editor/src/core/theme/colors.css
#	frontend/editor/src/core/theme/primitives.css
#	frontend/editor/src/core/ui/ChatFABButton.css
#	frontend/editor/src/core/ui/StatusBadge.css
#	frontend/editor/src/core/ui/accents.css
#	frontend/editor/src/portal/components/Sidebar.css
#	frontend/editor/src/portal/components/Sidebar.tsx
#	frontend/editor/src/portal/components/billing/billing.css
#	frontend/editor/src/portal/views/SourceBuilder.css
#	frontend/editor/src/portal/views/Sources.css
#	frontend/editor/src/proprietary/auth/ui/auth-theme.css
#	frontend/editor/src/proprietary/components/chat/ChatPanel.css
#	frontend/editor/src/saas/routes/Login.tsx
#	frontend/editor/src/saas/routes/Signup.tsx
#	frontend/editor/src/saas/styles/saas-theme.css
2026-07-25 21:36:53 +01:00
EthanHealy01 2264ddc705 last changes of part one 2026-07-25 21:19:36 +01:00
EthanHealy01 97dfebc2f6 FAB + chat panel polish: shared brand mark, borderless composer, canvas gutter
- ChatFABButton: render shared BrandMark (matches centre logo + processor
  mark exactly) instead of a bespoke inline SVG; secondary-button fill+border;
  mark wrapped aria-hidden since the button carries the accessible name.
- ChatPanel: header uses BrandMark (two-tone red) with no icon badge behind it;
  composer drops the 1px box-shadow ring (keeps the soft drop shadow); focus
  glow uses the primary-button token instead of brand red.
- HomePage: give the workbench Group a --c-bg background so the gutter around
  the floating tool panel reads as canvas, not a white sheet.
2026-07-25 19:17:58 +01:00
EthanHealy01 0798457a39 initial changes to move over to the new theme. Changed colors, button styling, nav styling and more 2026-07-24 20:57:57 +01:00
EthanHealy01 b1bcf02c13 Merge remote-tracking branch 'origin/main' into UI/color-token-migration
# Conflicts:
#	frontend/editor/src/portal/components/PolicySummary.css
#	frontend/editor/src/portal/components/ProcessingStatusStrip.css
#	frontend/editor/src/portal/components/RecentActivity.css
#	frontend/editor/src/portal/components/RecentActivity.tsx
2026-07-22 23:41:19 +01:00
EthanHealy01 8dab6812bd Merge main into UI/color-token-migration
Resolve 3 portal CSS conflicts taking main's responsive changes (100dvh,
viewport clamps) while keeping our --c-* tokens (main's --color-* tokens no
longer exist on this branch). Re-enforce app-wide colour linting on merged
portal CSS: AppShell scrim -> var(--c-overlay), Sidebar drawer shadow ->
structural rgba(0,0,0,.35).
2026-07-22 14:54:18 +01:00
EthanHealy01 09d68b4e85 merge main and fix CI 2026-07-22 14:47:22 +01:00
EthanHealy01 2e275a02b0 Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into UI/color-token-migration 2026-07-22 11:05:11 +01:00
EthanHealy01 1a9a7ee3e3 no primatives, no hardcoded hex or rgb 2026-07-22 11:05:04 +01:00
EthanHealy01 50e92add40 Merge remote-tracking branch 'origin/main' into UI/color-token-migration 2026-07-20 18:46:31 +01:00
EthanHealy01 c0fddcd2bd style: prettier-format files touched by the compat migration 2026-07-20 14:59:59 +01:00
EthanHealy01 733bf8306e docs(theme): update README for compat.css removal 2026-07-20 14:47:24 +01:00
EthanHealy01 9302402fb5 refactor(theme): migrate all legacy compat aliases to --c-* and remove compat.css
Replace every var(--legacy) reference (bg-/text-/border-/color-* families, 452
refs across 25 files) with the canonical --c-* token the alias resolved to, and
drop the now-redundant hex fallbacks. Delete compat.css and unwire it from
index.css + theme-lint. Pure value-preserving substitution — no visual change.
2026-07-20 14:45:59 +01:00
EthanHealy01 b8fbe05222 chore(theme): scope PR to colour-token consolidation
- Remove the accent-picker feature (ColorGridPicker, customPrimary, presets,
  prefs, wiring) — to be reintroduced in its own PR per review.
- Reset accents.css, theme-lint.mjs and lint:colors to main's baseline
  (app-wide colour enforcement moves to a follow-up).
- Keep the hex/rgb -> token swaps and the semantic tint token definitions
  (--c-primary-tint/-border, --c-danger/success-subtle) that back those swaps.
- Add aliases: [lint:colours] to lint:colors (review suggestion).
2026-07-20 14:07:09 +01:00
EthanHealy01 097cec9b2a Merge remote-tracking branch 'origin/main' into UI/color-token-migration
# Conflicts:
#	frontend/editor/src/core/ui/StatusBadge.css
#	frontend/editor/src/portal/components/Sidebar.css
#	frontend/editor/src/portal/views/AgentBuilder.css
#	frontend/editor/src/proprietary/auth/ui/auth.css
#	frontend/editor/src/proprietary/routes/AuthCallback.module.css
#	frontend/editor/src/proprietary/routes/Login.tsx
#	frontend/editor/src/saas/routes/OAuthConsent.tsx
2026-07-20 13:56:47 +01:00
EthanHealy01 c62a175174 make the color variables for the danger success and warning buttons so they won't be overwritten by other css variables. Inline styles may still apply 2026-07-17 14:49:05 +01:00
EthanHealy01 12daf0760a fix(theme): pin editor to the neutral default accent
The accent injection tinted dark-mode surfaces blue when the legacy-theme
migration seeded darkPrimary with a concrete colour. With the picker hidden,
always use data-accent="default" so dark mode stays neutral zinc with blue
buttons.
2026-07-17 00:11:05 +01:00
EthanHealy01 a74ddac70b chore(theme): hide accent-colour picker row; keep default light/dark accent 2026-07-17 00:08:03 +01:00
EthanHealy01 30b61749b9 Merge branch 'main' into UI/color-token-migration 2026-07-16 16:16:19 +01:00
EthanHealy01 df6344353b fix 2026-07-16 15:14:18 +01:00
EthanHealy01 70932e2c46 merge with main 2026-07-16 14:52:23 +01:00
EthanHealy01 f9c6671bb7 chore(theme): drop Storybook contrast-audit tool; rely on theme-lint gates
The interactive contrast-audit story is superseded by the blocking
theme-lint checks (default tone-gate <1.6:1 + css-colors/code-colors),
which run in task frontend:lint:colors on every CI run. Remove the tool
and its storySort pin.
2026-07-16 13:19:22 +01:00
EthanHealy01 4aba81c10b fix(theme): define brand-red + ai-accent primitives for accents.css
The sui-acc-brand / sui-acc-ai classes reference --p-brand-red-* and
--p-cyan/indigo-* primitives that main's palette didn't define, so the brand
accent's background was unset and buttons (e.g. the login CTA) fell back to
Mantine blue. Add the missing palette entries.
2026-07-16 12:06:49 +01:00
EthanHealy01 f25989c59e Merge remote-tracking branch 'origin/main' into UI/color-token-migration
# Conflicts:
#	frontend/editor/src/portal/views/DeveloperDocs.css
2026-07-16 11:54:44 +01:00
EthanHealy01 7a74fadd2a chore: prettier-format theme-lint.mjs and theme.css 2026-07-16 11:27:29 +01:00
EthanHealy01 498e248401 chore(theme): re-apply app-wide colour linter + migrate remaining literals
After merging main (#7009 theme) and the contrast fixes into #7011:
- Restore the app-wide css-colors + code-colors linter modes in theme-lint,
  wired into task frontend:lint:colors (both blocking).
- Migrate the hardcoded colours main still had in tokens.css, theme.css,
  billing.css and FileEditor.module.css to primitives, so css-colors passes.
- Keep main's warn-only --c-* contrast report + the blocking status-tone gate.
2026-07-16 11:08:18 +01:00
EthanHealy01 0b07537892 Merge branch 'UI/color-contrast-quick-fixes' into UI/color-token-migration 2026-07-16 10:41:04 +01:00
EthanHealy01 aec9faeef6 Merge remote-tracking branch 'origin/main' into UI/color-token-migration
# Conflicts:
#	.taskfiles/frontend.yml
#	frontend/.storybook/preview.tsx
#	frontend/editor/index.html
#	frontend/editor/scripts/lint/theme-lint.mjs
#	frontend/editor/src/core/components/fileEditor/FileEditor.module.css
#	frontend/editor/src/core/components/shared/ThemeProvider.tsx
#	frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx
#	frontend/editor/src/core/styles/theme.css
#	frontend/editor/src/core/theme/README.md
#	frontend/editor/src/core/theme/colors.css
#	frontend/editor/src/core/theme/index.css
#	frontend/editor/src/core/theme/mantineTheme.ts
#	frontend/editor/src/core/theme/primitives.css
#	frontend/editor/src/core/tokens/tokens.css
#	frontend/editor/src/core/ui/LabelChip.css
#	frontend/editor/src/portal/components/billing/billing.css
#	frontend/editor/src/portal/views/Components.css
#	frontend/editor/src/portal/views/Policies.css
#	frontend/editor/src/portal/views/Sources.css
#	frontend/editor/src/proprietary/auth/ui/auth-theme.css
#	frontend/editor/src/proprietary/components/policies/LabelsEditor.css
#	frontend/editor/src/saas/components/shared/FileSidebarGroupControls.css
#	frontend/editor/src/saas/styles/saas-theme.css
2026-07-16 10:40:49 +01:00
EthanHealy01 413005490c fix(contrast-audit): skip SVG by namespace, not win.SVGElement
win.SVGElement isn't on the Window type (tsc TS2339), and cross-realm
instanceof against a nested iframe's constructor is fragile anyway. Match
the SVG namespace on the element instead — typed, realm-safe, same intent.
2026-07-16 00:30:22 +01:00
EthanHealy01 6ee2e3b232 frontend fix with task 2026-07-16 00:19:21 +01:00
EthanHealy01 bbf34088e6 fix(contrast-audit): build scan iframe URL via URLSearchParams
CodeQL flagged the templated iframe.src as DOM-text-reinterpreted-as-HTML.
Build the query with URLSearchParams (encodes every value) and clamp the
theme to a known token. Functionally identical (globals decodes back to
theme:<light|dark>), no more untrusted-looking interpolation in the URL.
2026-07-16 00:16:18 +01:00
EthanHealy01 50d6b049cf pushing quick fixes to some pre-existing and or newly introduced (in #7009) color contrast issues 2026-07-15 23:12:36 +01:00
EthanHealy01 df00d61480 feat(settings): remove custom accent-colour picker (presets + Default only)
Drop the custom-hue ColorInput from the accent picker in settings — users pick
from the preset swatches or Default, no arbitrary hues. Removes the ColorInput
usage and its now-dead deps (draft state, clampAccentChoice, DEFAULT_ACCENT_COLOR,
ColorInput import). The preset grid + Default cell (shared ColorGridPicker) and
everything else are unchanged.
2026-07-14 15:58:40 +01:00
EthanHealy01 562166419b chore(lint): remove now-dead manager.tsx import exemption
Follow-up to removing .storybook/manager.tsx — the one-file no-restricted-imports
exemption pointed at a file that no longer exists.
2026-07-14 13:49:29 +01:00
EthanHealy01 2b77fb345a chore(storybook): remove accent-picker manager addon + its lint exemption
The .storybook/manager.tsx accent toolbar was the only thing importing editor
code into Storybook's (alias-less, esbuild) manager bundle, forcing a lint
exemption. Remove the addon and the exemption entirely. preview.tsx still
declares accentLight/accentDark globals (default accent) so stories are
unaffected; the swatch picker toolbar is just gone.

eslint clean + storybook build passes, no import overrides remain.
2026-07-14 13:49:29 +01:00
EthanHealy01 bedf797f46 fix(storybook): manager.tsx imports relatively (esbuild manager has no aliases)
The prior change aliased manager.tsx to @core, but Storybook's manager bundle
(esbuild) doesn't resolve aliases — only the preview/Vite bundle does — so the
storybook build failed. Revert manager.tsx to a relative import and scope a
one-file no-restricted-imports exemption to .storybook/manager.tsx (the only
file that genuinely can't use an alias). preview.tsx keeps @public (Vite-built).

Verified: eslint clean + storybook build succeeds.
2026-07-14 13:35:20 +01:00
EthanHealy01 86378dbc64 Merge branch 'UI/consolidate-theme-variables' into UI/color-token-migration 2026-07-14 12:18:07 +01:00
EthanHealy01 d560335a6c chore(lint): drop blanket no-restricted-imports:off for node scripts
Removing the nodeGlobs 'off' (it masked a real violation) and fixing the two
imports it was hiding, so the relative-import ban applies to build tooling too:
- .storybook/manager.tsx: relative editor/src import -> @core/constants/theme
- .storybook/preview.tsx: relative public-asset import -> new @public/* alias
  (wired in main.ts viteFinal + .storybook/tsconfig paths), since no src alias
  covers public/. No eslint-disable / suppressions.

Node globals for the nodeGlobs layer are kept.
2026-07-14 12:09:05 +01:00
EthanHealy01 73704feb10 chore(lint): drop blanket no-restricted-imports:off for node scripts
Removing the nodeGlobs 'off' (it masked a real violation) and fixing the two
imports it was hiding, so the relative-import ban applies to build tooling too:
- .storybook/manager.tsx: relative editor/src import -> @core/constants/theme
- .storybook/preview.tsx: relative public-asset import -> new @public/* alias
  (wired in main.ts viteFinal + .storybook/tsconfig paths), since no src alias
  covers public/. No eslint-disable / suppressions.

Node globals for the nodeGlobs layer are kept.
2026-07-14 12:08:31 +01:00
EthanHealy01 b49b04bd68 Merge branch 'main' into UI/consolidate-theme-variables 2026-07-14 11:37:39 +01:00
EthanHealy01 873caf5172 merge main 2026-07-14 11:34:49 +01:00
EthanHealy01 193b096495 fix(theme): restore --color-hero-navy for the white hero CTA text
The main merge dropped --color-hero-navy from tokens.css on the assumption
nothing referenced it, but the white hero CTA text (EditorStatusCard.css /
WelcomeBanner.css) still uses it — leaving it undefined (invisible text on the
white button). Restore the token; the hero *background* stays accent-responsive.
2026-07-14 01:10:24 +01:00
EthanHealy01 1cdecfaf58 Merge remote-tracking branch 'origin/main' into UI/consolidate-theme-variables 2026-07-14 00:02:26 +01:00
EthanHealy01 c6f8ed43fd refactor(ui): extract shared ColorGridPicker for the accent picker
Per review on #7009: move the accent swatch grid out of GeneralSection into a
reusable core/ui/ColorGridPicker (swatch trigger + popover + radiogroup grid
with an optional default/unset icon cell + a footer slot). GeneralSection now
renders <ColorGridPicker> and passes the presets, default option and the
custom-colour ColorInput as the footer.

Removes the three raw-<button> eslint-disables from GeneralSection; the raw
elements now live in the DS layer (core/ui/), which the lint rule exempts.
Markup/styles preserved verbatim, so the control is unchanged visually.
2026-07-13 19:09:26 +01:00
EthanHealy01 05de5d7e2d chore(i18n): drop em-dash from theme accent description
Per review on #7009 — avoid em-dashes in UI copy.
2026-07-13 18:58:53 +01:00
EthanHealy01 449353150d feat(lint): make contrast check blocking + fix dark subtle-text AA
Per review on #7009: the contrast check was warn-only and opt-in, so it would
never catch regressions. Enforce it here (the follow-up PR):
- theme-lint.mjs `contrast` mode now exits non-zero on any sub-floor pair
- wired into `task frontend:lint:colors` (runs in the blocking lint gate)
- fixed the two failing pairs: --c-text-subtle on dark/portal-dark surfaces
  (3.36/3.67 → 4.74/5.18) by adding --p-zinc-250 and pointing dark subtle at it

All text-on-surface / on-primary pairs now clear WCAG AA per theme.
2026-07-13 18:49:46 +01:00
EthanHealy01 0f6f5b4e0b feat(lint): enforce no hardcoded colour in TS/TSX DOM code (code-colors)
Add a blocking 'code-colors' mode to theme-lint: default-deny with layered
exemptions (structural; var()/readColor/canvas/pdf-lib contexts; a
// theme-allow-color opt-out; exempt paths for rendering/vendor/config/
illustration areas). File list from 'git ls-files' (no directory walk).
Wired into 'task frontend:lint:colors'; README + script header updated.

Also removes the temporary color-migration-audit.md scratch file.
2026-07-13 17:42:21 +01:00
EthanHealy01 941dc35cda refactor(ui): route TS/TSX inline-style colours through the palette
Migrate hardcoded colour literals in DOM inline styles to tokens:
- auth screens (OAuthConsent/LoggedInState) + Login/Signup -> fixed --p-*
  (AuthLayout forces light; semantic --c-* would break there)
- FileCard/LanguagePicker/ErrorBoundary/ToolStep/etc -> --c-* / --p-*
- Payg avatar palette + DesktopOnboardingModal gradient -> exact --p-*
- rgba tints -> color-mix(in srgb, var(--p-*) N%, transparent)

Canvas/PDF rendering, colour pickers, vendor brand and self-contained docs
keep numeric colour by design.
2026-07-13 17:40:43 +01:00
EthanHealy01 93bc796e2f add primatives and remove the rgb codes and hex codes that we can remove 2026-07-13 16:36:12 +01:00
EthanHealy01 544a1f02eb feat(lint): enforce zero hardcoded colour across all source CSS
Add a blocking 'css-colors' mode to theme-lint that flags any hardcoded
colour in source .css under editor/src (primitives.css + generated
output.css exempt). File list is sourced from 'git ls-files' rather than a
directory walk, so there's no readdir→readFile path; comments and structural
black/white/transparent are ignored. Wired into 'task frontend:lint:colors'.

Update theme README + script header for the expanded palette and the now
app-wide enforcement.
2026-07-13 15:08:03 +01:00
EthanHealy01 6800db8029 refactor(css): migrate all hardcoded colours to primitives (CSS → zero literals)
Move every non-structural colour literal in source CSS into the primitive
palette and reference via var(--p-*):
- 194 hex + 139 rgba() across 38 files replaced
- 90 new primitives added (brand azure/red, violet/indigo, cyan, pink,
  emerald, slate, navy, Notion paper, illustration art, vendor/OS-media)
- near-duplicate neutrals snapped to existing ramp values
- coloured rgba() alpha tints -> color-mix(in srgb, var(--p-*) N%, transparent)
- structural rgba(0,0,0/255,255,255) and comment breadcrumbs left intact

Source CSS now contains zero hardcoded colour outside primitives.css.
Build + theme-lint pass; every var(--p-*) reference resolves.
2026-07-13 15:03:44 +01:00
EthanHealy01 2748fe3157 refactor(css): consolidate redundant status/brand colours to tokens
- billing status chips (FULL/WARNED/DEGRADED) -> adaptive --c-success/
  warning/danger(-subtle); now theme-aware instead of fixed pale literals
- unify saas guest-button red #9c2f30 -> #af3434 (single Stirling red)
- unify SpendCapControl dark accent #7ab4ff -> #66b8ff (match billing azure)
- disabled plan-button grey #7e7e7e -> var(--p-gray-500)

Distinct non-structural CSS hex: 95 -> 86.
2026-07-13 14:43:02 +01:00
EthanHealy01 b7bcbc9f45 refactor(css): merge adjacent duplicate rulesets into grouped selectors
Merge only ADJACENT rules (consecutive, whitespace-only gap, same nesting) with
identical declaration blocks — cascade-safe (non-adjacent merges can reorder the
cascade, so left alone). 22 merges across 14 files, net -104 lines. Build +
theme-lint green.
2026-07-13 11:47:15 +01:00
EthanHealy01 d7f51d7f72 feat(theme): add --c-warning + subtle status tokens; migrate auth alerts
Adds --c-warning (amber, per-theme) and --c-success/danger/warning-subtle
(pale status surfaces) to colors.css — the recurring tokens the consolidation
agents flagged as missing. Migrates the auth error/success alert boxes onto
them. Remaining status-colour consumers tracked in color-migration-audit.md.
2026-07-13 10:58:56 +01:00
EthanHealy01 2c0728c1a7 refactor(theme): collapse dark-override blocks to adaptive --c-* (agents)
Six parallel passes across component CSS: collapsed manual light/dark override
blocks to the adaptive --c-* tokens (navy overrides → neutral surfaces, per the
neutral-dark direction), migrated semantic colours to --c-*, stripped dead
--c-*/--p-* fallbacks, removed a duplicate ruleset. Brand/data-viz/illustration
colours kept and catalogued in color-migration-audit.md with the --c-* tokens
still needed to finish. Build + theme-lint green.
2026-07-13 10:55:38 +01:00
EthanHealy01 cca8c700d3 refactor(theme): strip dead --c-primary hex fallbacks (partial)
Removes the dead #6366f1 fallback from var(--c-primary, #6366f1) in 4 files
(--c-primary is always defined). Partial progress on the colour consolidation;
the full override-collapse pass is pending (agent fan-out blocked).
2026-07-13 09:41:07 +01:00
EthanHealy01 17b0fae153 refactor(theme): tokenize exact-match hardcoded colours (script)
Auto-replaced 48 hardcoded hex colours that match a primitive within Δ≤2
(visually identical) with var(--p-*), across all component CSS. Generated
color-migration-audit.md listing the 50 near-matches and 93 genuinely-unique
colours for the follow-up agent consolidation pass. Build + theme-lint green.
2026-07-13 02:02:33 +01:00
EthanHealy01 c5fbf85035 refactor(theme): tokenize matching hex + remove dead legacy tokens
- theme.css: convert the 5 hardcoded UI colours that exactly match a primitive
  to var(--p-*) / color-mix(var(--p-*)); bespoke logo/illustration fills, brand
  azure/gradients and the deliberate flash-yellow have no primitive equivalent
  and are left.
- Remove 11 verified-dead legacy tokens (--text-always-*, --landing-drop-*,
  --link-*) — 0 refs and not built dynamically (excluded all --color-*/--accent-*
  since those prefixes ARE constructed at runtime). theme.css 902 → 865 lines.
Build + theme-lint green.
2026-07-13 01:50:36 +01:00
EthanHealy01 ee6684f6a4 refactor(theme): delete compat.css — components reference --c-* directly
Migrated all remaining legacy-alias consumers (incl. stories/tests) to --c-*
(199 refs / 58 files), promoted the computed aliases to first-class tokens
(--c-accent-fg made universal on :root; --c-primary-tint / --c-primary-border
added), then removed compat.css, its @import, and its linter entry. No legacy
colour-alias layer remains; build + theme-lint green.

The larger theme.css/tokens.css legacy definition files are a separate cleanup.
2026-07-13 01:25:38 +01:00
EthanHealy01 925caf6d11 refactor(theme): drop 71 now-dead compat aliases
After migrating consumers to --c-* (prior commit), 71 of the 93 1:1 compat
aliases have zero references anywhere (verified across src + tailwind.config +
index.html; the 22 that remain are kept alive by stories/portal mantine theme).
Removing the dead ones cuts compat.css roughly in half — the real consolidation
payoff, not just a rename.
2026-07-13 01:14:13 +01:00
EthanHealy01 a70f80279a chore: remove one-shot compat-colour codemod after applying it
The migration is permanent in the committed files; the throwaway walker isn't
referenced anywhere and tripped Aikido's readdir→readFile heuristic.
2026-07-13 01:10:39 +01:00
EthanHealy01 99be0ae3c6 fix(theme): loading splash follows theme (no white flash in dark mode)
LoadingFallback renders inside Suspense before MantineProvider sets its colour
scheme, so --mantine-color-body was still Mantine's light default (white flash
when the portal/app splash appears in dark mode). Switch to --c-bg/--c-text,
which are driven by the pre-paint attributes on <html> and are correct from the
first frame.
2026-07-13 00:56:51 +01:00
EthanHealy01 d8599e2c6f Merge branch 'main' into UI/consolidate-theme-variables 2026-07-13 00:51:20 +01:00
EthanHealy01 7b43bb483e refactor(theme): migrate hardcoded hex in component CSS to tokens
Per-area passes replaced hardcoded surface/text/border/status hex in component
stylesheets with semantic --c-* tokens (--p-* only where an exact value had to
be preserved). Left intentionally: structural black/white/transparent, OAuth
brand colours, deliberate multi-hue gradients, data-viz/status hues without a
semantic fit, and dead var(--c-*, #fallback) fallbacks. ~90 sites across 17
files. Build + theme-lint green.
2026-07-13 00:48:40 +01:00
EthanHealy01 5f6b88005e refactor(theme): migrate compat colour aliases to --c-* tokens
Codemod (editor/scripts/migrate-compat-colors.mjs) rewrites every var(--legacy)
reference to the canonical --c-* token it maps to in compat.css (1:1 aliases
only; computed color-mix/fallback aliases left in place). 218 files, 1947
references. Pure rename — compat.css already guarantees each alias equals its
--c-* target, so no behaviour change.
2026-07-13 00:36:08 +01:00
EthanHealy01 41f507a9f0 chore(theme): dedupe default-blue literal in theme constants
Collapse the two #3b82f6 occurrences (DEFAULT_ACCENT_COLOR + first preset) into
a single documented BLUE_500 const. The accent hexes stay JS literals by
necessity — stored verbatim and parsed by deriveAccessiblePrimary, so CSS
var(--p-*) refs can't be used; theme-lint scopes to CSS only.
2026-07-13 00:25:56 +01:00
EthanHealy01 ffc0755b37 chore(storybook): use DEFAULT_ACCENT_COLOR instead of hardcoded #3b82f6
Replace the literal blue in preview.tsx globalTypes/fallback and manager.tsx
with the DEFAULT_ACCENT_COLOR constant from constants/theme.
2026-07-13 00:23:30 +01:00
EthanHealy01 91e0815f93 fix(theme): lift dark canvas ~2% off pure black (#0a0a0b → #0f0f10)
Dark mode's darkest surface (--c-bg / --p-zinc-950) read as near-black. Lift
it ~2% lighter across every representation (primitive, Tailwind --gray-50/
--background channels, Mantine dark-7) so the canvas is a deep charcoal, never
pure black — consistent, not a one-off override.
2026-07-13 00:04:26 +01:00
EthanHealy01 e3b4bc2412 chore(theme-lint): read a fixed file list, not readdir→readFile
Static scanners (Aikido) flag the directory-listing→file-read flow as a
file-inclusion risk. Read a fixed THEME_FILES list via constant paths instead
(matching the other unflagged scripts); readdir is now only used to fail if a
new theme .css isn't registered, so coverage still can't lapse.
2026-07-12 23:59:31 +01:00
EthanHealy01 e9785b6b2f chore(theme): harden lint script, trim comments, fix CI lint/format
- theme-lint.mjs: resolve root, skip symlinks, refuse reads outside the
  theme dir (addresses Aikido file-inclusion finding)
- remove unused eslint-disable in .storybook/preview.tsx (frontend-validation
  --max-warnings=0)
- condense multi-line comments to one line; drop style-choice CSS comments
- prettier formatting
2026-07-12 23:54:26 +01:00
EthanHealy01 31b7c0069d fix comment 2026-07-12 23:18:33 +01:00
EthanHealy01 a8a5f3a88f initial colors and theme improvements 2026-07-12 23:17:37 +01:00
150 changed files with 3256 additions and 2551 deletions
@@ -3304,7 +3304,6 @@ description = "Model Context Protocol (MCP) lets AI assistants like Claude use y
guestInfo = "Guest users can't connect MCP clients. Create an account to use the MCP server and let your AI assistant run Stirling PDF tools on your behalf."
navLabel = "MCP Server"
tip = "Every action your assistant runs is performed as your account and counts toward your usage, just like using Stirling PDF directly."
title = "MCP Server"
viewApiKeys = "View API keys"
[config.mcp.copy]
@@ -3876,10 +3875,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"
@@ -4829,8 +4828,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"
@@ -4895,7 +4893,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!"
@@ -8649,7 +8646,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"
@@ -10634,8 +10630,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"
@@ -11592,7 +11590,6 @@ noMembersFound = "No members found"
role = "Role"
searchMembers = "Search members..."
team = "Team"
title = "People"
unlockAccount = "Unlock Account"
unlockUserError = "Failed to unlock user account"
unlockUserSuccess = "User account unlocked successfully"
@@ -11746,7 +11743,6 @@ system = "System"
teamActions = "Team actions"
teamName = "Team Name"
teamNotFound = "Team not found"
title = "Teams"
totalMembers = "Total Members"
viewTeam = "View Team"
@@ -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$/,
];
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
import {
TextInput,
Group,
Paper,
Text,
Stack,
Alert,
@@ -216,8 +217,9 @@ const TeamSection: React.FC = () => {
return (
<Stack gap="lg">
{/* Header */}
<div>
{/* A personal team's name ("My Team") only restates the nav label, and it
has no rename/leave actions - so the header row is dropped there. */}
{!isPersonalTeam && (
<Group justify="space-between" align="center">
<div style={{ flex: 1 }}>
{isEditingName ? (
@@ -299,7 +301,7 @@ const TeamSection: React.FC = () => {
</Button>
)}
</Group>
</div>
)}
{/* Error/Success Messages */}
{error && (
@@ -316,8 +318,8 @@ const TeamSection: React.FC = () => {
{/* Invite Members */}
{isTeamLeader && (
<div>
<Text fw={600} size="md" mb="sm">
<Paper withBorder p="md" radius="md">
<Text fw={600} size="sm" mb="sm">
{t("team.invite.title", "Invite Team Member")}
</Text>
<form onSubmit={handleInvite}>
@@ -347,12 +349,11 @@ const TeamSection: React.FC = () => {
</Button>
</Group>
</form>
</div>
</Paper>
)}
{/* Team Members Table */}
<div>
<Text fw={600} size="md" mb="sm">
<Paper withBorder p="md" radius="md">
<Text fw={600} size="sm" mb="sm">
{t("team.members.title", "Team Members")}
</Text>
<Table
@@ -539,7 +540,7 @@ const TeamSection: React.FC = () => {
)}
</Table.Tbody>
</Table>
</div>
</Paper>
</Stack>
);
};
@@ -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:
@@ -87,7 +87,7 @@
text-transform: uppercase;
}
/* Tree rows - mirror .file-sidebar-action-row from FileSidebar.css so the
/* Tree rows - mirror the shared .sui-navitem (core/ui/NavItem.css) so the
slide-out folder navigator reads as a continuation of the main sidebar's
design language. Same row height, padding, font weight, muted icon
treatment. The hover/active state uses the same --hover-bg pill that
@@ -116,11 +116,11 @@
}
.files-page-tree-node:hover {
background: var(--c-hover);
background: var(--c-active);
}
.files-page-tree-node.is-active {
background: var(--c-hover);
background: var(--c-active);
color: var(--c-text);
font-weight: 500;
}
@@ -1,9 +1,15 @@
/* AppConfigModal styles */
/* Nav rail is a surface, like the app's side panels; content is the canvas. */
.mantine-Modal-content:has(.modal-container) {
background: var(--c-bg);
}
.modal-container {
display: flex;
gap: 0;
height: 45rem; /* 720px */
max-height: 90dvh; /* matches Mantine's default modal max-height so the outer wrapper never has to scroll */
background: var(--c-bg);
}
.modal-nav {
@@ -13,6 +19,8 @@
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--c-surface);
border-right: 1px solid var(--c-border-subtle);
}
/* Mobile: compact icon-only navigation */
@@ -121,6 +129,7 @@
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--c-bg);
}
.modal-content-scroll {
@@ -166,6 +175,8 @@
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--c-bg);
border-bottom: 1px solid var(--c-border-subtle);
}
.modal-body {
@@ -173,6 +184,21 @@
padding-top: 1rem;
}
/* Normalise every section's Paper/Card here; exclusions are inline overlays. */
.modal-body
:is(.mantine-Paper-root, .mantine-Card-root):not(
.mantine-Menu-dropdown,
.mantine-Popover-dropdown,
.mantine-HoverCard-dropdown,
.mantine-Modal-content,
.mantine-Tooltip-tooltip
) {
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-nav);
box-shadow: none;
}
.settings-search-select {
min-width: 10rem;
}
@@ -214,7 +240,7 @@
bottom: 0;
left: 0;
right: 0;
background: var(--c-surface);
background: var(--c-bg);
border-top: 1px solid var(--c-border-subtle);
padding: 1rem 2rem;
margin: 0 -2rem;
@@ -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
@@ -165,13 +168,10 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
const colors = useMemo(
() => ({
navBg: "var(--c-bg-raised)",
sectionTitle: "var(--c-text-subtle)",
navItem: "var(--modal-nav-item)",
navItemActive: "var(--c-accent-fg)",
navItemActiveBg: "var(--c-primary-subtle)",
contentBg: "var(--c-surface)",
headerBorder: "var(--c-border-subtle)",
}),
[],
);
@@ -218,13 +218,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) {
@@ -269,13 +273,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
>
<div className="modal-container" data-tour="settings-modal">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? "mobile" : ""}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<div className={`modal-nav ${isMobile ? "mobile" : ""}`}>
<div className="modal-nav-scroll">
{configNavSections.map((section) => (
<div key={section.title} className="modal-nav-section">
@@ -394,13 +392,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
<div className="modal-content" data-tour="settings-content-area">
<div className="modal-content-scroll">
{/* Sticky header with section title and small close button */}
<div
className="modal-header"
style={{
background: colors.contentBg,
borderBottom: `1px solid ${colors.headerBorder}`,
}}
>
<div className="modal-header">
<Text fw={700} size="lg">
{activeLabel}
</Text>
@@ -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,64 @@
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;
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-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;
}
/* ---- Native file drag-and-drop ---- */
.file-sidebar[data-file-drag-over] {
@@ -58,86 +129,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;
.file-sidebar .sui-navitem {
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;
gap: 0;
cursor: pointer;
border-radius: 4px;
margin: 0 4px;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.file-sidebar-search-row:not(.active):hover {
background-color: var(--c-hover);
}
.file-sidebar-search-icon {
color: var(--c-text-subtle) !important;
font-size: 18px !important;
flex-shrink: 0;
.file-sidebar .sui-navitem__label {
animation: sidebar-content-in 0.12s ease 0.18s both;
white-space: nowrap;
}
/* ---- Search row (open state) ---- */
.file-sidebar-search-close {
cursor: pointer;
}
@@ -147,9 +148,8 @@
background: transparent;
border: none;
outline: none;
font-size: 14px;
font-size: inherit;
color: var(--c-text);
margin-left: 12px;
min-width: 0;
}
@@ -157,12 +157,6 @@
color: var(--c-text-subtle);
}
.file-sidebar-search-label {
margin-left: 12px;
font-size: 14px;
color: var(--c-text-muted);
}
/* ---- Scrollable content ---- */
/* This is a flex column - action rows are fixed, only the file list scrolls */
.file-sidebar-scroll {
@@ -170,7 +164,8 @@
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
overflow-y: auto;
overflow-x: hidden;
}
.file-sidebar-scroll::-webkit-scrollbar {
@@ -184,112 +179,6 @@
border-radius: 2px;
}
/* ---- Action rows (Open from Computer, etc.) ---- */
.file-sidebar-action-row {
display: flex;
align-items: center;
height: 32px;
padding: 0 14px;
cursor: pointer;
border-radius: 4px;
margin: 0 4px;
gap: 0;
transition: background-color 0.15s ease;
flex-shrink: 0;
}
.file-sidebar-action-row:hover {
background-color: var(--c-hover);
}
.file-sidebar-action-row.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: auto;
}
.file-sidebar-action-row.disabled:hover {
background-color: transparent;
}
.file-sidebar-action-icon {
color: var(--c-text-subtle) !important;
font-size: 18px !important;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
/* When the icon is a wrapper around an SVG (extraAction case), force
* the SVG to inherit the wrapper's 18px sizing instead of MUI's default
* 1.5rem so it matches the other rail icons. */
.file-sidebar-action-icon > svg {
font-size: inherit;
width: 1em;
height: 1em;
}
.file-sidebar-action-label {
margin-left: 12px;
font-size: 14px;
color: var(--c-text-muted);
white-space: nowrap;
}
/* ---- Cloud storage rows ---- */
.file-sidebar-cloud-row {
display: flex;
align-items: center;
height: 32px;
padding: 0 14px;
cursor: pointer;
border-radius: 4px;
margin: 0 4px;
gap: 0;
transition: background-color 0.15s ease;
flex-shrink: 0;
}
.file-sidebar-cloud-row:not(.disabled):hover {
background-color: var(--c-hover);
}
.file-sidebar-cloud-row.disabled {
cursor: default;
opacity: 0.5;
}
.file-sidebar-cloud-icon-wrapper {
position: relative;
width: 18px;
height: 18px;
flex-shrink: 0;
}
.file-sidebar-cloud-icon-gray {
position: absolute;
inset: 0;
transition: opacity 0.2s ease;
opacity: 1;
}
.file-sidebar-cloud-icon-color {
position: absolute;
inset: 0;
transition: opacity 0.2s ease;
opacity: 0;
}
.file-sidebar-cloud-row:not(.disabled):hover .file-sidebar-cloud-icon-gray {
opacity: 0;
}
.file-sidebar-cloud-row:not(.disabled):hover .file-sidebar-cloud-icon-color {
opacity: 1;
}
/* ---- Files section ---- */
.file-sidebar-files-section {
flex: 1;
@@ -452,18 +341,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 +457,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,7 +8,9 @@ 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 { NavItem } from "@app/ui/NavItem";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useFileState, useFileActions } from "@app/contexts/file/fileHooks";
@@ -29,10 +31,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 +153,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
collapsed = false,
onToggleCollapse,
onOpenSettings,
toggleAriaLabel,
toggleIcon,
onUploadFiles,
onPickGoogleDriveFiles,
onSearchClick,
extraAction,
toggleAriaLabel,
toggleIcon,
},
ref,
) {
@@ -833,89 +834,43 @@ 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 ? (
<div className="sui-navitem sui-navitem--field file-sidebar-search-row">
<span className="sui-navitem__icon">
<CloseIcon
className="file-sidebar-search-close"
onClick={handleSearchClose}
/>
</span>
<input
ref={searchInputRef}
className="file-sidebar-search-input sidebar-content-fade"
@@ -925,18 +880,20 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
"fileSidebar.searchPlaceholder",
"Search files...",
)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="file-sidebar-search-label sidebar-content-fade">
{t("fileSidebar.search", "Search")}
</span>
))}
</div>
</Tooltip>
</div>
) : (
<NavItem
id="search"
className="file-sidebar-search-row"
label={t("fileSidebar.search", "Search")}
icon={<SearchIcon />}
iconOnly={collapsed}
onClick={handleSearchClick}
/>
)}
</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. */}
@@ -963,8 +920,11 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-action-row"
<NavItem
id="openFromComputer"
label={t("fileSidebar.openFromComputer", "Open from computer")}
icon={<UploadFileIcon />}
iconOnly={collapsed}
// `files-button` is the long-standing upload entry-point
// testid: click + setInputFiles on `file-input` above. Tour
// anchor lives here too - the tour now spotlights the native
@@ -977,26 +937,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
// is reachable via "My Files" below.
nativeFileInputRef.current?.click();
}}
role="button"
tabIndex={0}
aria-label={t(
"fileSidebar.openFromComputer",
"Open from computer",
)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
nativeFileInputRef.current?.click();
}
}}
>
<UploadFileIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.openFromComputer", "Open from computer")}
</span>
)}
</div>
/>
</Tooltip>
{extraAction && (
@@ -1019,34 +960,15 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
!(extraAction.disabled && extraAction.disabledTooltip)
}
>
<div
className={`file-sidebar-action-row${extraAction.disabled ? " disabled" : ""}`}
<NavItem
id="extraAction"
label={extraAction.label}
icon={extraAction.icon}
iconOnly={collapsed}
disabled={extraAction.disabled}
data-testid={extraAction.testId}
onClick={() => {
if (extraAction.disabled) return;
extraAction.onClick();
}}
role="button"
tabIndex={extraAction.disabled ? -1 : 0}
aria-disabled={extraAction.disabled}
aria-label={extraAction.label}
onKeyDown={(e) => {
if (extraAction.disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
extraAction.onClick();
}
}}
>
<span className="file-sidebar-action-icon">
{extraAction.icon}
</span>
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{extraAction.label}
</span>
)}
</div>
onClick={extraAction.onClick}
/>
</Tooltip>
)}
@@ -1056,30 +978,17 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
withinPortal
disabled={!collapsed}
>
<div
className="file-sidebar-action-row"
<NavItem
id="myFiles"
label={t("fileSidebar.myFiles", "My Files")}
icon={<FolderOpenIcon />}
iconOnly={collapsed}
data-testid="my-files-button"
onClick={() => {
if (collapsed && onToggleCollapse) onToggleCollapse();
navigate("/files");
}}
role="button"
tabIndex={0}
aria-label={t("fileSidebar.myFiles", "My Files")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
navigate("/files");
}
}}
>
<FolderOpenIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.myFiles", "My Files")}
</span>
)}
</div>
/>
</Tooltip>
{!shouldHideGoogleDrive && (
@@ -1096,206 +1005,187 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
withinPortal
disabled={!collapsed}
>
<div
className={`file-sidebar-cloud-row${!isGoogleDriveEnabled ? " disabled" : ""}`}
onClick={handleGoogleDriveClick}
role="button"
tabIndex={isGoogleDriveEnabled ? 0 : -1}
aria-disabled={!isGoogleDriveEnabled}
aria-label={
!isGoogleDriveEnabled
? t(
"fileSidebar.googleDriveDisabled",
"Google Drive is not configured",
)
: t("fileSidebar.googleDrive", "Open from Google Drive")
<NavItem
id="googleDrive"
label={t("fileSidebar.googleDrive", "Google Drive")}
icon={<GoogleDriveIcon />}
// Only light up the brand mark when Drive is configured.
hoverIcon={
isGoogleDriveEnabled ? (
<GoogleDriveIcon colored />
) : undefined
}
>
<div className="file-sidebar-cloud-icon-wrapper">
<GoogleDriveIcon
className="file-sidebar-cloud-icon-gray"
style={{ color: "var(--c-text-muted)" }}
/>
{isGoogleDriveEnabled && (
<GoogleDriveIcon
colored
className="file-sidebar-cloud-icon-color"
/>
)}
</div>
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("fileSidebar.googleDrive", "Google Drive")}
</span>
)}
</div>
iconOnly={collapsed}
disabled={!isGoogleDriveEnabled}
onClick={handleGoogleDriveClick}
/>
</Tooltip>
)}
{/* Watched Folders entry */}
{WATCHED_FOLDERS_ENABLED && (
<div
className="file-sidebar-action-row"
<NavItem
id="watchedFolders"
label={t("watchedFolders.sidebarTitle", "Watched Folders")}
icon={<FolderSpecialIcon />}
iconOnly={collapsed}
isActive={isWatchedFoldersActive}
data-testid="watchedFolders-button"
data-active={isWatchedFoldersActive}
onClick={openWatchedFolders}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === "Enter" && openWatchedFolders()}
aria-label={t("watchedFolders.sidebarTitle", "Watched Folders")}
style={
isWatchedFoldersActive
? { backgroundColor: "var(--c-active)" }
: undefined
}
>
<FolderSpecialIcon className="file-sidebar-action-icon" />
{!collapsed && (
<span className="file-sidebar-action-label sidebar-content-fade">
{t("watchedFolders.sidebarTitle", "Watched Folders")}
</span>
)}
</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 +1215,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;
}
@@ -57,7 +57,6 @@ export interface DesktopUpdateModeControl {
}
interface GeneralSectionProps {
hideTitle?: boolean;
hideUpdateSection?: boolean;
hideAdminBanner?: boolean;
/** Desktop-only: Tauri updater install state, passed from the desktop override. */
@@ -76,7 +75,6 @@ interface GeneralSectionProps {
}
const GeneralSection: React.FC<GeneralSectionProps> = ({
hideTitle = false,
hideUpdateSection = false,
hideAdminBanner = false,
desktopInstall,
@@ -170,19 +168,12 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
return (
<Stack gap="lg">
{!hideTitle && (
<div>
<Text fw={600} size="lg">
{t("settings.general.title", "General")}
</Text>
<Text size="sm" c="dimmed">
{t(
"settings.general.description",
"Configure general application preferences.",
)}
</Text>
</div>
)}
<Text size="sm" c="dimmed">
{t(
"settings.general.description",
"Configure general application preferences.",
)}
</Text>
{!hideAdminBanner && loginDisabled && !bannerDismissed && (
<Paper
@@ -147,9 +147,6 @@ const HotkeysSection: React.FC = () => {
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">
{t("settings.hotkeys.title", "Keyboard Shortcuts")}
</Text>
<Text size="sm" c="dimmed">
{t(
"settings.hotkeys.description",
@@ -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",
}}
>
@@ -0,0 +1,3 @@
/** View-toggle modes; tuple keeps the union and iterator in sync. */
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const;
export type FilesPageViewMode = (typeof FILES_PAGE_VIEW_MODES)[number];
@@ -30,10 +30,13 @@ import { useFileActions } from "@app/contexts/file/fileHooks";
import { useFolders } from "@app/contexts/FolderContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { type FilesPageViewMode } from "@app/constants/filesPageView";
/** View-toggle modes; tuple keeps the union and iterator in sync. */
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const;
export type FilesPageViewMode = (typeof FILES_PAGE_VIEW_MODES)[number];
export {
FILES_PAGE_VIEW_MODES,
type FilesPageViewMode,
} from "@app/constants/filesPageView";
export type FilesPageSortMode =
| "name-asc"
| "name-desc"
@@ -153,6 +156,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const { actions: fileActions } = useFileActions();
const { config: appConfig } = useAppConfig();
const { isAnonymous } = useAuth();
const { preferences, updatePreference } = usePreferences();
const [allFiles, setAllFiles] = useState<StirlingFileStub[]>([]);
const [loading, setLoading] = useState(true);
@@ -230,7 +234,12 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
}, [folders.currentFolderId, clearSelection]);
// View + sort + search + filters ----------------------------------------
const [viewMode, setViewMode] = useState<FilesPageViewMode>("grid");
// Persisted so the choice survives a reload (preferences are localStorage-backed).
const viewMode = preferences.filesPageViewMode;
const setViewMode = useCallback(
(mode: FilesPageViewMode) => updatePreference("filesPageViewMode", mode),
[updatePreference],
);
const [sortMode, setSortMode] = useState<FilesPageSortMode>("modified-desc");
const [search, setSearch] = useState("");
const [originFilter, setOriginFilter] =
@@ -415,6 +424,18 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
.map((id) => fileMap.get(id))
.filter((s): s is StirlingFileStub => Boolean(s));
// Drop the rows now rather than after the cloud round-trip and the IDB
// rewrite - deleting a large selection otherwise leaves the grid frozen
// on the old contents for seconds. The refresh that follows the write is
// the source of truth; anything that failed to delete reappears.
const removedIds = new Set(fileIds);
setAllFiles((prev) => prev.filter((f) => !removedIds.has(f.id)));
setSelectedFileIds((prev) => {
const next = new Set(prev);
for (const id of removedIds) next.delete(id);
return next;
});
// Cloud delete (owner-only). Dedup by remoteStorageId since a history
// chain shares a single server file.
if (scope === "cloud" || scope === "everywhere") {
@@ -459,16 +480,12 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
}
}
const removedIds = new Set(fileIds);
setSelectedFileIds((prev) => {
const next = new Set(prev);
for (const id of removedIds) next.delete(id);
return next;
});
// reconcile picks up the cloud deletions and strips stale remote pointers.
await refresh();
// The storage write notifies IndexedDBContext, whose revision bump
// re-runs refresh() - which reconciles the cloud deletions and strips
// stale remote pointers. No explicit refresh here: it would be a second
// full pass over every stub for the same delete.
},
[fileMap, fileActions, folders, refresh, t],
[fileMap, fileActions, folders, t],
);
const removeFiles = useCallback(
@@ -501,9 +518,10 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const confirmRemoveFiles = useCallback(
async (scope: DeleteScope) => {
await performDelete(deleteDialogFileIds, scope);
const fileIds = deleteDialogFileIds;
setDeleteDialogOpen(false);
setDeleteDialogFileIds([]);
await performDelete(fileIds, scope);
},
[deleteDialogFileIds, performDelete],
);
@@ -7,6 +7,7 @@ import React, {
createContext,
useContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
@@ -80,6 +81,27 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const [revision, setRevision] = useState(0);
const bumpRevision = useCallback(() => setRevision((r) => r + 1), []);
// One bump per burst: a 50-file delete or a folder drop fires a write per
// file, and each bump re-reads every stub downstream. Coalescing on a
// microtask turns that storm into a single refresh.
const pendingBump = useRef(false);
const scheduleBump = useCallback(() => {
if (pendingBump.current) return;
pendingBump.current = true;
queueMicrotask(() => {
pendingBump.current = false;
bumpRevision();
});
}, [bumpRevision]);
// Writes that bypass this context (policy runs, share-link imports, watched
// folders) reach the same store, so subscribe at the storage layer instead of
// trusting every call site to announce itself.
useEffect(() => {
const unsubscribe = fileStorage.subscribeToChanges(scheduleBump);
return unsubscribe;
}, [scheduleBump]);
// LRU File cache to avoid repeated ArrayBuffer→File conversions
const fileCache = useRef(
new Map<FileId, { file: File; lastAccessed: number }>(),
@@ -148,10 +170,9 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
);
}
bumpRevision();
return storedFile;
},
[bumpRevision],
[evictLRUEntries],
);
const loadFile = useCallback(
@@ -188,17 +209,13 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
[],
);
const deleteFile = useCallback(
async (fileId: FileId): Promise<void> => {
// Remove from cache
fileCache.current.delete(fileId);
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
// Remove from cache
fileCache.current.delete(fileId);
// Remove from IndexedDB
await fileStorage.deleteStirlingFile(fileId);
bumpRevision();
},
[bumpRevision],
);
// Remove from IndexedDB
await fileStorage.deleteStirlingFile(fileId);
}, []);
const loadLeafMetadata = useCallback(async (): Promise<
StirlingFileStub[]
@@ -223,9 +240,8 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
// Delete all in a single IDB transaction
await fileStorage.deleteMultipleStirlingFiles(fileIds);
bumpRevision();
},
[bumpRevision],
[],
);
const clearAll = useCallback(async (): Promise<void> => {
@@ -234,8 +250,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
// Clear IndexedDB
await fileStorage.clearAll();
bumpRevision();
}, [bumpRevision]);
}, []);
const getStorageStats = useCallback(async () => {
return await fileStorage.getStorageStats();
@@ -244,37 +259,33 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const updateThumbnail = useCallback(
async (fileId: FileId, thumbnail: string): Promise<boolean> => {
const result = await fileStorage.updateThumbnail(fileId, thumbnail);
if (result) bumpRevision();
if (result) scheduleBump();
return result;
},
[bumpRevision],
[scheduleBump],
);
const markFileAsProcessed = useCallback(
async (fileId: FileId): Promise<boolean> => {
const result = await fileStorage.markFileAsProcessed(fileId);
if (result) bumpRevision();
if (result) scheduleBump();
return result;
},
[bumpRevision],
[scheduleBump],
);
const moveFilesToFolder = useCallback(
async (fileIds: FileId[], folderId: FolderId | null): Promise<FileId[]> => {
const updated = await fileStorage.moveFilesToFolder(fileIds, folderId);
if (updated.length > 0) bumpRevision();
return updated;
return await fileStorage.moveFilesToFolder(fileIds, folderId);
},
[bumpRevision],
[],
);
const clearFolderForFiles = useCallback(
async (folderIds: FolderId[]): Promise<number> => {
const cleared = await fileStorage.clearFolderForFiles(folderIds);
if (cleared > 0) bumpRevision();
return cleared;
return await fileStorage.clearFolderForFiles(folderIds);
},
[bumpRevision],
[],
);
// Memoize the context value so consumers' useIndexedDB() reference stays
@@ -42,18 +42,23 @@ export function useLazyThumbnail(
thumbnailUrl?: string,
): string | undefined {
const [thumb, setThumb] = useState<string | undefined>(thumbnailUrl);
const attempted = useRef(false);
// Latched only once a generation actually finishes (or is ruled out), so a
// re-render that cancels queued work doesn't leave the row permanently blank.
const generated = useRef(false);
const indexedDB = useIndexedDB();
const { updateStirlingFileStub } = useFileManagement();
// Held in a ref so their identity can't re-run the effect: a re-run cancels
// the queued task, and the row would then be skipped forever.
const depsRef = useRef({ indexedDB, updateStirlingFileStub });
depsRef.current = { indexedDB, updateStirlingFileStub };
useEffect(() => {
if (thumbnailUrl) setThumb(thumbnailUrl);
}, [thumbnailUrl]);
useEffect(() => {
if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT)
if (thumbnailUrl || generated.current || size >= THUMBNAIL_SIZE_LIMIT)
return;
attempted.current = true;
let cancelled = false;
scheduleLazyThumb(async () => {
@@ -61,13 +66,22 @@ export function useLazyThumbnail(
// in the queue — skip the expensive byte load entirely.
if (cancelled) return;
try {
const file = await indexedDB.loadFile(fileId);
if (!file || cancelled) return;
const file = await depsRef.current.indexedDB.loadFile(fileId);
if (cancelled) return;
// No cached bytes to render from: don't queue this row again.
if (!file) {
generated.current = true;
return;
}
const thumbnail = await generateThumbnailForFile(file);
if (cancelled || !thumbnail) return;
if (cancelled) return;
generated.current = true;
if (!thumbnail) return;
setThumb(thumbnail);
void indexedDB.updateThumbnail(fileId, thumbnail);
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
void depsRef.current.indexedDB.updateThumbnail(fileId, thumbnail);
depsRef.current.updateStirlingFileStub(fileId, {
thumbnailUrl: thumbnail,
});
} catch {
// non-critical
}
@@ -76,7 +90,7 @@ export function useLazyThumbnail(
return () => {
cancelled = true;
};
}, [fileId, size, thumbnailUrl, indexedDB, updateStirlingFileStub]);
}, [fileId, size, thumbnailUrl]);
return thumb;
}
@@ -499,6 +499,7 @@ export default function HomePage() {
gap={0}
h="100%"
className="flex-nowrap flex"
bg="var(--c-bg)"
>
<MyFilesAwareFileSidebar
ref={quickAccessRef}
@@ -64,6 +64,37 @@ export function legacyDerivedFromTool(
class FileStorageService {
private readonly dbConfig = DATABASE_CONFIGS.FILES;
private readonly storeName = "files";
private readonly changeListeners = new Set<() => void>();
/**
* Notified when the SET of stored files changes - added, deleted, moved
* between folders. Views subscribe via IndexedDBContext rather than relying
* on each call site to announce itself; plenty of them (share-link imports,
* watched folders, FileManagerContext) write here directly and used to leave
* the file lists stale.
*
* Deliberately NOT fired for in-place edits to an existing row (thumbnail,
* metadata, leaf flag): those run per file inside tool and policy batches,
* where a global refresh per write is both wasteful and, for callers that
* react by re-reading the workspace, unsafe. Callers that need those visible
* bump the revision themselves.
*/
subscribeToChanges(listener: () => void): () => void {
this.changeListeners.add(listener);
return () => {
this.changeListeners.delete(listener);
};
}
private notifyChanged(): void {
for (const listener of this.changeListeners) {
try {
listener();
} catch (error) {
console.error("[fileStorage] change listener failed", error);
}
}
}
/**
* Get database connection using centralized manager
@@ -176,6 +207,7 @@ class FileStorageService {
reject(request.error);
};
request.onsuccess = () => {
this.notifyChanged();
resolve();
};
} catch (error) {
@@ -486,6 +518,7 @@ class FileStorageService {
});
});
if (updated.length > 0) this.notifyChanged();
return updated;
}
@@ -534,6 +567,7 @@ class FileStorageService {
}
});
if (cleared > 0) this.notifyChanged();
return cleared;
}
@@ -549,7 +583,10 @@ class FileStorageService {
const request = store.delete(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
request.onsuccess = () => {
this.notifyChanged();
resolve();
};
});
}
@@ -563,7 +600,10 @@ class FileStorageService {
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
transaction.oncomplete = () => resolve();
transaction.oncomplete = () => {
this.notifyChanged();
resolve();
};
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () =>
reject(transaction.error ?? new Error("Transaction aborted"));
@@ -628,7 +668,10 @@ class FileStorageService {
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
request.onsuccess = () => {
this.notifyChanged();
resolve();
};
});
}
@@ -3,6 +3,7 @@ import {
DEFAULT_TOOL_PANEL_MODE,
} from "@app/constants/toolPanel";
import { type ThemeMode } from "@app/constants/theme";
import { type FilesPageViewMode } from "@app/constants/filesPageView";
export type LogoVariant = "modern" | "classic";
@@ -44,6 +45,8 @@ export interface UserPreferences {
hideUnavailableConversions: boolean;
logoVariant: LogoVariant | null;
pdfRenderMode: PdfRenderMode;
/** Last grid/list choice on the files page. */
filesPageViewMode: FilesPageViewMode;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
@@ -64,6 +67,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
hideUnavailableConversions: false,
logoVariant: null,
pdfRenderMode: "normal",
filesPageViewMode: "grid",
};
const STORAGE_KEY = "stirlingpdf_preferences";
+51 -4
View File
@@ -241,8 +241,36 @@
/* Config Modal colors (light mode) */
--modal-nav-item: var(--p-gray-700);
/* 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;
@@ -469,8 +497,27 @@
/* Onboarding (dark mode) */
--onboarding-step-active: var(--p-gray-250);
--onboarding-step-inactive: var(--p-zinc-500);
/* 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 */
@@ -70,6 +70,8 @@ const mockedApiClient = vi.mocked(apiClient);
vi.mock("../../services/fileStorage", () => ({
fileStorage: {
init: vi.fn().mockResolvedValue(undefined),
// IndexedDBContext subscribes to storage changes on mount.
subscribeToChanges: vi.fn().mockReturnValue(() => {}),
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
return Promise.resolve({
id: `mock-id-${file.name}`,
@@ -68,6 +68,8 @@ const mockedApiClient = vi.mocked(apiClient);
vi.mock("../../services/fileStorage", () => ({
fileStorage: {
init: vi.fn().mockResolvedValue(undefined),
// IndexedDBContext subscribes to storage changes on mount.
subscribeToChanges: vi.fn().mockReturnValue(() => {}),
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
return Promise.resolve({
id: `mock-id-${file.name}`,
@@ -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)>');
@@ -876,4 +876,81 @@ test.describe("Files page", () => {
expect(after).toBeGreaterThanOrEqual(before + 24);
});
});
test.describe("Persistence and bulk actions", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
});
test("grid/list choice survives a reload", async ({ page }) => {
await seedFiles(page, [
{ id: "f1", name: "one.pdf", remoteStorageId: null },
{ id: "f2", name: "two.pdf", remoteStorageId: null },
]);
await gotoFilesPage(page);
await page.locator('label[for$="-list"]').first().click();
await expect(page.locator(".files-page-list-row").first()).toBeVisible();
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-list-row").first()).toBeVisible({
timeout: 10_000,
});
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem("stirlingpdf_preferences") ?? "{}"),
);
expect(stored.filesPageViewMode).toBe("list");
});
test("deleting a large selection clears the grid without waiting on storage", async ({
page,
}) => {
await seedFiles(
page,
Array.from({ length: 50 }, (_, i) => ({
id: `bulk-${i}`,
name: `bulk_${i}.pdf`,
remoteStorageId: null,
})),
);
await gotoFilesPage(page);
const cards = page.locator(
".files-page-card:not(.files-page-skeleton-card)",
);
await expect(cards).toHaveCount(50, { timeout: 10_000 });
await page.getByRole("button", { name: /select all/i }).click();
await page.keyboard.press("Delete");
// Rows go immediately - the IDB write and the refresh that follows it
// must not hold the grid on stale contents.
await expect(cards).toHaveCount(0, { timeout: 2_000 });
// ...and the delete really reached storage. Checked directly because the
// seed init-script would re-populate IDB on a reload.
await expect
.poll(
() =>
page.evaluate(
() =>
new Promise<number>((resolve) => {
const open = window.indexedDB.open("stirling-pdf-files");
open.onsuccess = () => {
const db = open.result;
const req = db
.transaction("files", "readonly")
.objectStore("files")
.count();
req.onsuccess = () => {
resolve(req.result);
db.close();
};
};
}),
),
{ timeout: 10_000 },
)
.toBe(0);
});
});
});
@@ -54,10 +54,12 @@ test.describe("13. Language / Localization", () => {
// Step 5: Wait for page reload (language change triggers window.location.reload())
await page.waitForLoadState("domcontentloaded");
// Step 6: Verify the UI text is in English
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({
timeout: 10000,
});
// Step 6: Verify the UI text is in English. The tool search is a
// header toggle, so assert its English label rather than the field,
// which only mounts once the toggle is pressed.
await expect(
page.getByRole("button", { name: /search tools/i }).first(),
).toBeVisible({ timeout: 10000 });
}
});
});
@@ -17,6 +17,14 @@ test.describe("2. Main Dashboard / Home Page", () => {
page.locator('[data-testid="config-button"]').first(),
).toBeVisible();
// Tool search sits behind a header toggle now, so assert the affordance
// AND that pressing it actually mounts a usable search field — dropping
// the second half would stop covering the input entirely.
const searchToggle = page
.getByRole("button", { name: /search tools/i })
.first();
await expect(searchToggle).toBeVisible();
await searchToggle.click();
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
await expect(
@@ -74,7 +82,10 @@ test.describe("2. Main Dashboard / Home Page", () => {
await page.goto("/");
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
// Tool search is a header toggle; the field mounts only once pressed.
await expect(
page.getByRole("button", { name: /search tools/i }).first(),
).toBeVisible();
});
});
@@ -1,13 +1,24 @@
import type { Page } from "@playwright/test";
import { test, expect } from "@app/tests/helpers/stub-test-base";
/**
* The tool panel header shows a search *toggle*; the field only mounts once
* it's pressed. Open it and hand back the focused input.
*/
async function openToolSearch(page: Page) {
await page.getByRole("button", { name: /search tools/i }).click();
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await expect(searchBox).toBeVisible({ timeout: 5000 });
return searchBox;
}
test.describe("3. Tool Search", () => {
test.describe("3.1 Search - Happy Path", () => {
test("should filter tools in real time based on search input", async ({
page,
}) => {
// Step 1: Click on the search box
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await searchBox.click();
// Step 1: Open the search box from the header toggle
const searchBox = await openToolSearch(page);
// Step 2: Type "merge"
await searchBox.fill("merge");
@@ -31,9 +42,8 @@ test.describe("3. Tool Search", () => {
test("should handle queries with no matching tools gracefully", async ({
page,
}) => {
// Step 1: Click on the search box
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await searchBox.click();
// Step 1: Open the search box from the header toggle
const searchBox = await openToolSearch(page);
// Step 2: Type xyznonexistent123
await searchBox.fill("xyznonexistent123");
@@ -61,7 +71,7 @@ test.describe("3. Tool Search", () => {
test.describe("3.3 Search - Special Characters", () => {
test("should sanitize search input against XSS", async ({ page }) => {
// Step 1: Type XSS payload into the search box
const searchBox = page.getByPlaceholder(/search|cari/i).first();
const searchBox = await openToolSearch(page);
await searchBox.fill("<script>alert(1)</script>");
// Step 2: Verify no script execution occurs (no alert dialog)
+37 -23
View File
@@ -5,9 +5,9 @@
:root,
[data-theme="light"],
html[data-app-theme="light"] {
--c-bg: var(--p-gray-50);
--c-bg: var(--p-paper);
--c-bg-raised: var(--p-white);
--c-surface: var(--p-white);
--c-surface: var(--p-snow);
--c-surface-raised: var(--p-white);
--c-surface-sunken: var(--p-gray-100);
--c-input-bg: var(--p-white);
@@ -15,12 +15,17 @@ html[data-app-theme="light"] {
--c-active: var(--p-gray-100);
--c-overlay: rgba(0, 0, 0, 0.5);
--c-text: var(--p-gray-900);
--c-text: var(--p-ink);
--c-text-muted: var(--p-gray-600);
--c-text-subtle: var(--p-gray-500);
--c-text-subtle: var(--p-gray-550);
--c-text-on-primary: var(--p-white);
--c-border: var(--p-gray-250);
--c-btn-solid: var(--c-text);
--c-btn-inverse: var(--p-snow);
--c-btn-secondary: var(--c-btn-inverse);
--c-btn-secondary-border: var(--c-border);
--c-border: var(--p-c-f0f0f0);
--c-border-subtle: var(--p-gray-200);
--c-border-strong: var(--p-gray-400);
@@ -55,7 +60,8 @@ html[data-app-theme="light"] {
marks, vendor colours, categorical avatar dots, static illustrations,
and the multi-hue gradients on feature/upgrade/onboarding surfaces.
Named here so components reference a --c-* token, never a raw --p-*. */
--c-brand-mark: var(--p-brand-red-650); /* Stirling logo mark fill */
--c-brand-mark: var(--p-brand-red-650);
--c-brand-mark-soft: var(--p-brand-red-400); /* Stirling logo mark fill */
--c-accent-stripe: var(--p-periwinkle-500); /* Stripe "connect" CTA */
/* Feature-accent hues (fixed) used as stops in multi-hue gradients. */
@@ -110,9 +116,9 @@ html[data-app-theme="light"] {
/* ── MIDNIGHT (original navy) — also the default portal/Storybook dark ────── */
[data-theme="dark"],
html[data-app-theme="midnight"] {
--c-bg: var(--p-zinc-900);
--c-bg: var(--p-c-141416);
--c-bg-raised: var(--p-zinc-850);
--c-surface: var(--p-zinc-800);
--c-surface: var(--p-c-1a1a1d);
--c-surface-raised: var(--p-zinc-650);
--c-surface-sunken: var(--p-zinc-850);
--c-input-bg: var(--p-zinc-650);
@@ -120,12 +126,16 @@ html[data-app-theme="midnight"] {
--c-active: var(--p-gray-800);
--c-overlay: rgba(0, 0, 0, 0.6);
--c-text: var(--p-zinc-100);
--c-text: var(--p-snow);
--c-text-muted: var(--p-zinc-200);
--c-text-subtle: var(--p-zinc-300);
--c-text-on-primary: var(--p-white);
--c-btn-solid: var(--c-text);
--c-btn-inverse: var(--p-ink);
--c-btn-secondary: var(--p-c-1a1a1d);
--c-btn-secondary-border: var(--p-c-343439);
--c-border: var(--p-zinc-650);
--c-border: var(--p-c-28282d);
--c-border-subtle: rgba(255, 255, 255, 0.05);
--c-border-strong: var(--p-zinc-500);
@@ -157,9 +167,9 @@ html[data-app-theme="custom"] {
--c-accent-fg: var(--c-primary);
/* Primary-tinted surfaces (light base). Neutralised by the default override. */
--c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-gray-50));
--c-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-paper));
--c-bg-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
--c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-white));
--c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-snow));
--c-surface-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
--c-surface-sunken: color-mix(
in srgb,
@@ -169,7 +179,7 @@ html[data-app-theme="custom"] {
--c-input-bg: color-mix(in srgb, var(--c-primary) 2%, var(--p-white));
--c-hover: color-mix(in srgb, var(--c-primary) 9%, var(--p-gray-50));
--c-active: color-mix(in srgb, var(--c-primary) 13%, var(--p-gray-100));
--c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-gray-250));
--c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-f0f0f0));
--c-border-subtle: color-mix(
in srgb,
var(--c-primary) 10%,
@@ -270,16 +280,20 @@ html[data-app-theme="custom"] {
/* ── DARK — editor dark theme: neutral text/borders/icons + accent-tinted surfaces (default override opts out). After :root so it wins for dark. ── */
html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
/* Neutral text / borders / overlay (not accent-tinted). */
--c-text: var(--p-zinc-100);
--c-text: var(--p-snow);
--c-text-muted: var(--p-zinc-200);
--c-text-subtle: var(--p-zinc-300);
--c-btn-solid: var(--c-text);
--c-btn-inverse: var(--p-ink);
--c-btn-secondary: var(--p-c-1a1a1d);
--c-btn-secondary-border: var(--p-c-343439);
--c-border-strong: var(--p-zinc-500);
--c-overlay: rgba(0, 0, 0, 0.6);
/* Accent-tinted surfaces (dark base). Neutralised by the default override. */
--c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-950));
--c-bg: color-mix(in srgb, var(--c-primary) 8%, var(--p-c-141416));
--c-bg-raised: color-mix(in srgb, var(--c-primary) 9%, var(--p-zinc-850));
--c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-zinc-800));
--c-surface: color-mix(in srgb, var(--c-primary) 8%, var(--p-c-1a1a1d));
--c-surface-raised: color-mix(
in srgb,
var(--c-primary) 9%,
@@ -293,7 +307,7 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
--c-input-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-zinc-900));
--c-hover: color-mix(in srgb, var(--c-primary) 12%, var(--p-zinc-750));
--c-active: color-mix(in srgb, var(--c-primary) 15%, var(--p-zinc-700));
--c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-zinc-650));
--c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-28282d));
--c-border-subtle: color-mix(
in srgb,
var(--c-primary) 10%,
@@ -335,27 +349,27 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
/* ── DEFAULT (no tint) — surfaces opt out of the accent tint (neutral white/grey light, zinc dark); --c-primary stays for buttons. Extra [data-accent="default"] beats the tinted blocks. ── */
html[data-app-theme="custom"][data-accent="default"] {
--c-bg: var(--p-gray-50);
--c-bg: var(--p-paper);
--c-bg-raised: var(--p-white);
--c-surface: var(--p-white);
--c-surface: var(--p-snow);
--c-surface-raised: var(--p-white);
--c-surface-sunken: var(--p-gray-100);
--c-input-bg: var(--p-white);
--c-hover: var(--p-gray-50);
--c-active: var(--p-gray-100);
--c-border: var(--p-gray-250);
--c-border: var(--p-c-f0f0f0);
--c-border-subtle: var(--p-gray-200);
}
html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="dark"] {
--c-bg: var(--p-zinc-950);
--c-bg: var(--p-c-141416);
--c-bg-raised: var(--p-zinc-850);
--c-surface: var(--p-zinc-800);
--c-surface: var(--p-c-1a1a1d);
--c-surface-raised: var(--p-zinc-775);
--c-surface-sunken: var(--p-zinc-900);
--c-input-bg: var(--p-zinc-900);
--c-hover: var(--p-zinc-750);
--c-active: var(--p-zinc-700);
--c-border: var(--p-zinc-650);
--c-border: var(--p-c-28282d);
--c-border-subtle: var(--p-zinc-700);
}
@@ -28,6 +28,10 @@
--radius-xl: 16px;
--radius-pill: 9999px;
--radius-nav: 0.625rem;
--nav-gutter: 0.5rem;
--nav-rail-w: 3.5rem;
/* ── Layout sizing ── */
--footer-height: 2rem;
--landing-stack-w: 224px;
@@ -51,6 +55,7 @@
--motion-base: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--motion-slow: 0.3s ease;
--motion-enter: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
--motion-spring: 0.22s cubic-bezier(0.32, 0.72, 0, 1);
--fullscreen-anim-duration-in: 0.28s;
--fullscreen-anim-duration-out: 0.22s;
+13 -1
View File
@@ -11,6 +11,10 @@
--p-gray-300: #d1d5db;
--p-gray-400: #9ca3af;
--p-gray-500: #6b7280;
/* Subtle body text in light mode. gray-500 clears 4.5:1 on pure white but
only reaches 4.39:1 on the --p-paper canvas; this is the same hue nudged
dark enough to pass (4.87:1) while staying lighter than gray-600. */
--p-gray-550: #646b76;
--p-gray-600: #4b5563;
--p-gray-700: #374151;
--p-gray-800: #1f2937;
@@ -29,6 +33,10 @@
--p-zinc-300: #71717a;
--p-zinc-200: #a1a1aa;
--p-zinc-100: #f4f4f5;
--p-c-141416: #141416;
--p-c-1a1a1d: #1a1a1d;
--p-c-28282d: #28282d;
--p-c-343439: #343439;
--p-blue-400: #60a5fa;
--p-blue-500: #3b82f6;
--p-blue-600: #2563eb;
@@ -46,6 +54,7 @@
/* Brand-red + ai-accent scales, consumed by core/ui/accents.css. */
--p-brand-red-200: #d9a8a8;
--p-brand-red-300: #d98a8a;
--p-brand-red-400: #ad7373;
--p-brand-red-650: #8e3131;
--p-brand-red-700: #7a2929;
--p-brand-red-900: #5a2424;
@@ -84,6 +93,10 @@
--p-tint-blue: #eef1fb;
--p-tint-violet: #f6f4fc;
--p-tint-pink: #fbf4f7;
--p-paper: #f5f4f1;
--p-ink: #373530;
--p-snow: #fafafa;
--p-c-f0f0f0: #f0f0f0;
/* Notion-style procurement view palette. */
--p-notion-blue: #2383e2;
@@ -92,7 +105,6 @@
--p-notion-ink: #37352f;
--p-notion-gray: #9b9a97;
--p-notion-gray-strong: #787774;
--p-notion-paper: #f5f4f1;
--p-notion-paper-2: #f0eee9;
--p-notion-border: #e3e1dc;
--p-notion-border-2: #eae8e3;
@@ -326,16 +326,6 @@
opacity: 0.5;
}
}
@keyframes pulseRing {
0% {
transform: scale(0.8);
opacity: 1;
}
100% {
transform: scale(2.2);
opacity: 0;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
+16 -9
View File
@@ -103,15 +103,22 @@ export const ActionIcon = forwardRef<HTMLButtonElement, ActionIconProps>(
"--ai-hover-color": "var(--c-text)",
"--ai-bd": "1px solid transparent",
}
: {
"--ai-bg": "transparent",
"--ai-hover": "var(--_tint)",
"--ai-color": "var(--_text)",
"--ai-bd":
variant === "secondary"
? "1px solid var(--_bd)"
: "1px solid transparent",
};
: variant === "secondary"
? {
// Filled when the accent defines --_solid-2 (default = inverse
// ink/snow); otherwise falls back to the outlined look.
"--ai-bg": "var(--_solid-2, transparent)",
"--ai-hover": "var(--_solid-2-hover, var(--_tint))",
"--ai-color": "var(--_on-2, var(--_text))",
"--ai-bd": "1px solid var(--_bd-2, var(--_bd))",
}
: {
// tertiary (ghost) — neutral text + hover for the default accent.
"--ai-bg": "transparent",
"--ai-hover": "var(--_tert-tint, var(--_tint))",
"--ai-color": "var(--_tert-text, var(--_text))",
"--ai-bd": "1px solid transparent",
};
// Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing.
const Comp = MantineActionIcon as ElementType;
+17 -9
View File
@@ -193,15 +193,23 @@ const ButtonRoot = forwardRef<HTMLButtonElement, ButtonProps>(
"--button-hover-color": "var(--c-text)",
"--button-bd": "1px solid transparent",
}
: {
"--button-bg": "transparent",
"--button-hover": "var(--_tint)",
"--button-color": "var(--_text)",
"--button-bd":
variant === "secondary"
? "1px solid var(--_bd)"
: "1px solid transparent",
};
: variant === "secondary"
? {
// Filled when the accent defines --_solid-2 (default = inverse
// ink/snow); otherwise falls back to the outlined look.
"--button-bg": "var(--_solid-2, transparent)",
"--button-hover": "var(--_solid-2-hover, var(--_tint))",
"--button-color": "var(--_on-2, var(--_text))",
"--button-bd": "1px solid var(--_bd-2, var(--_bd))",
}
: {
// tertiary (ghost) — neutral text + hover when the accent
// defines --_tert-* (default); otherwise the accent link colour.
"--button-bg": "transparent",
"--button-hover": "var(--_tert-tint, var(--_tint))",
"--button-color": "var(--_tert-text, var(--_text))",
"--button-bd": "1px solid transparent",
};
// Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing.
const Comp = MantineButton as ElementType;
@@ -5,11 +5,10 @@
width: 56px;
height: 56px;
border-radius: 16px;
border: none;
background: var(--c-primary);
color: var(--c-text-on-primary);
border: 1px solid var(--c-btn-secondary-border);
background: var(--c-btn-secondary);
cursor: pointer;
box-shadow: 0 4px 16px color-mix(in srgb, var(--c-primary) 40%, transparent);
box-shadow: var(--shadow-md);
transition:
transform 180ms cubic-bezier(0.32, 0.72, 0, 1),
box-shadow 180ms ease;
@@ -20,7 +19,7 @@
.chat-fab-btn:hover {
transform: scale(1.09);
box-shadow: 0 6px 22px color-mix(in srgb, var(--c-primary) 52%, transparent);
box-shadow: var(--shadow-lg);
}
.chat-fab-btn:active {
+5 -14
View File
@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes } from "react";
import { BrandMark } from "@app/components/shared/BrandMark";
import "@app/ui/ChatFABButton.css";
export interface ChatFABButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
@@ -25,20 +26,10 @@ export function ChatFABButton({
return (
<button type="button" className={classes} {...rest}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={28}
height={28}
viewBox="0 0 192 192"
fill="currentColor"
aria-hidden="true"
>
<path
d="M68.48 102.4 L184.73 6.45 L184.73 96.05 L68.48 192 Z"
opacity="0.7"
/>
<path d="M7.26 95.83 L123.37 0 L123.37 89.5 L7.26 185.33 Z" />
</svg>
{/* Decorative: the button itself carries the accessible name. */}
<span aria-hidden="true" style={{ display: "inline-flex" }}>
<BrandMark height="1.875rem" />
</span>
{loading && !showTick && (
<span className="chat-fab-btn__pulse" aria-hidden="true" />
)}
@@ -34,9 +34,7 @@ export const SpaceBetween: Story = {
}}
>
<span>Pipeline name</span>
<StatusBadge tone="success" pulse>
healthy
</StatusBadge>
<StatusBadge tone="success">healthy</StatusBadge>
</Inline>
),
};
+33
View File
@@ -0,0 +1,33 @@
/* Shared brand lockup (mark + "Stirling" wordmark). The wordmark toggles by
colour scheme so one component works in the editor and the portal. */
.sui-logo {
display: inline-flex;
align-items: center;
line-height: 1;
}
.sui-logo--vertical {
flex-direction: column;
justify-content: center;
}
.sui-logo__mark,
.sui-logo__wordmark {
display: block;
width: auto;
}
/* Light wordmark by default; dark wordmark only under a dark scheme. */
.sui-logo__wordmark--dark {
display: none;
}
[data-mantine-color-scheme="dark"] .sui-logo__wordmark--light,
[data-theme="dark"] .sui-logo__wordmark--light {
display: none;
}
[data-mantine-color-scheme="dark"] .sui-logo__wordmark--dark,
[data-theme="dark"] .sui-logo__wordmark--dark {
display: block;
}
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Logo } from "@app/ui/Logo";
const meta: Meta<typeof Logo> = {
title: "Brand/Logo",
component: Logo,
parameters: { layout: "centered" },
args: { variant: "iconAndText", orientation: "horizontal" },
argTypes: {
variant: {
control: "inline-radio",
options: ["iconOnly", "iconAndText", "textOnly"],
},
orientation: {
control: "inline-radio",
options: ["horizontal", "vertical"],
},
},
};
export default meta;
type Story = StoryObj<typeof Logo>;
export const Playground: Story = {};
/** The three variants side by side (toggle Storybook's theme to see the
* wordmark track light/dark). */
export const Variants: Story = {
render: () => (
<div style={{ display: "flex", gap: 48, alignItems: "center" }}>
<Logo variant="iconOnly" />
<Logo variant="textOnly" />
<Logo variant="iconAndText" />
</div>
),
};
/** iconAndText stacked — as used in the workbench empty state. */
export const Stacked: Story = {
render: () => (
<Logo
variant="iconAndText"
orientation="vertical"
iconHeight="3.5rem"
textHeight="1.5rem"
gap="0.75rem"
/>
),
};
+89
View File
@@ -0,0 +1,89 @@
import type { CSSProperties } from "react";
import markUrl from "@app/assets/brand/branding-logo/logo-mark.svg";
import wordmarkLightUrl from "@app/assets/brand/branding-logo/wordmark-light.svg";
import wordmarkDarkUrl from "@app/assets/brand/branding-logo/wordmark-dark.svg";
import "@app/ui/Logo.css";
/** iconOnly = mark; textOnly = "Stirling" wordmark; iconAndText = both. */
export type LogoVariant = "iconOnly" | "iconAndText" | "textOnly";
interface LogoProps {
variant?: LogoVariant;
/** Layout for iconAndText: mark left of text, or stacked above it. */
orientation?: "horizontal" | "vertical";
/** Height of the mark (CSS length). */
iconHeight?: string;
/** Height of the wordmark (CSS length). */
textHeight?: string;
/** Gap between mark and wordmark. */
gap?: string;
className?: string;
style?: CSSProperties;
alt?: string;
}
/**
* Shared brand lockup used across editor + processor. The mark is theme-
* agnostic; the wordmark swaps light/dark via CSS so it tracks the active
* colour scheme in both the editor (data-mantine-color-scheme) and the portal
* (data-theme).
*/
export function Logo({
variant = "iconAndText",
orientation = "horizontal",
iconHeight = "1.75rem",
textHeight = "1rem",
gap = "0.5rem",
className,
style,
alt = "Stirling",
}: LogoProps) {
const showIcon = variant === "iconOnly" || variant === "iconAndText";
const showText = variant === "textOnly" || variant === "iconAndText";
const cls = [
"sui-logo",
orientation === "vertical" ? "sui-logo--vertical" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
// Layout set inline so a consumer's className can't restack the lockup.
const layoutStyle: CSSProperties = {
display: orientation === "vertical" ? "flex" : "inline-flex",
flexDirection: orientation === "vertical" ? "column" : "row",
alignItems: "center",
gap,
};
return (
<span className={cls} style={{ ...layoutStyle, ...style }}>
{showIcon && (
<img
className="sui-logo__mark"
src={markUrl}
alt={showText ? "" : alt}
aria-hidden={showText ? true : undefined}
style={{ height: iconHeight }}
/>
)}
{showText && (
<>
<img
className="sui-logo__wordmark sui-logo__wordmark--light"
src={wordmarkLightUrl}
alt={alt}
style={{ height: textHeight }}
/>
<img
className="sui-logo__wordmark sui-logo__wordmark--dark"
src={wordmarkDarkUrl}
alt={alt}
style={{ height: textHeight }}
/>
</>
)}
</span>
);
}
+65 -3
View File
@@ -1,17 +1,79 @@
.sui-metric-strip {
.sui-metric-strip--grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
}
@media (max-width: 50rem) {
.sui-metric-strip {
.sui-metric-strip--grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 30rem) {
.sui-metric-strip {
.sui-metric-strip--grid {
grid-template-columns: 1fr;
}
}
.sui-metric-strip--row {
display: flex;
align-items: center;
gap: 1.75rem;
padding: 0.625rem 1.25rem;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.sui-metric-strip__leading {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
padding-right: 1.25rem;
border-right: 1px solid var(--c-border-subtle);
align-self: stretch;
/* Neutral (ink) tint for a currentColor icon in the leading slot. */
color: var(--c-text);
}
.sui-metric-strip--row .sui-metric {
flex: 1 1 0;
min-width: 0;
padding: 0;
gap: 0;
background: none;
border: none;
box-shadow: none;
}
.sui-metric-strip--row .sui-metric__label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--c-text-subtle);
}
.sui-metric-strip--row .sui-metric__value {
font-size: 1.0625rem;
font-weight: 600;
}
.sui-metric-strip--row .sui-metric__footer {
font-size: 0.6875rem;
}
@media (max-width: 50rem) {
.sui-metric-strip--row {
flex-wrap: wrap;
gap: 1.25rem 2rem;
}
.sui-metric-strip__leading {
border-right: none;
padding-right: 0;
flex-basis: 100%;
}
.sui-metric-strip--row .sui-metric {
flex-basis: 40%;
}
}
@@ -2,6 +2,25 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { MetricStrip } from "@app/ui/MetricStrip";
import { MetricCard } from "@app/ui/MetricCard";
function ShieldIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={22}
height={22}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
const meta: Meta<typeof MetricStrip> = {
title: "Layout/MetricStrip",
component: MetricStrip,
@@ -22,3 +41,30 @@ export const Default: Story = {
</MetricStrip>
),
};
export const Row: Story = {
render: () => (
<MetricStrip layout="row" leading={<ShieldIcon />}>
<MetricCard
label="Active policies"
value="2"
description="Enforcing on upload/export"
/>
<MetricCard
label="Paused"
value="0"
description="Configured but not firing"
/>
<MetricCard
label="Categories"
value="6"
description="Available to configure"
/>
<MetricCard
label="Docs enforced"
value="0"
description="Across active policies"
/>
</MetricStrip>
),
};
+23 -9
View File
@@ -3,21 +3,35 @@ import "@app/ui/MetricStrip.css";
export interface MetricStripProps {
children: ReactNode;
layout?: "grid" | "row";
leading?: ReactNode;
className?: string;
}
/**
* Responsive grid wrapper for a row of {@link MetricCard}s the prototype's
* "metric strip" (Home, Sources, Usage, Infrastructure all use it). Four-up on
* wide screens, two-up below 50rem.
* Wrapper for a set of {@link MetricCard}s. In the default `grid` layout it's
* the prototype's four-up "metric strip" (Home, Sources, Usage,
* Infrastructure). In `row` layout the cards render as inline columns inside a
* single bordered strip with an optional leading logo/title section.
*/
export function MetricStrip({ children, className }: MetricStripProps) {
export function MetricStrip({
children,
layout = "grid",
leading,
className,
}: MetricStripProps) {
const classes = [
"sui-metric-strip",
`sui-metric-strip--${layout}`,
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<div
className={["sui-metric-strip", className ?? ""]
.filter(Boolean)
.join(" ")}
>
<div className={classes}>
{layout === "row" && leading != null && (
<div className="sui-metric-strip__leading">{leading}</div>
)}
{children}
</div>
);
+72 -9
View File
@@ -1,22 +1,32 @@
/* Shared sidebar nav row; surface-specific spacing lives with each surface. */
.sui-navitem {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.4375rem 0.75rem;
margin: 0.0625rem 0.5rem;
border-radius: var(--radius-lg);
color: var(--c-text-subtle);
padding: 0.5rem 0.75rem;
/* Full-bleed: the highlight spans its surface edge to edge, with the
surface's own overflow clipping it at the rounded corners. */
margin: 0;
border-radius: 0;
color: var(--c-text-muted);
font-size: 0.8125rem;
font-weight: 400;
/* Own the button chrome; the editor and portal reset buttons differently. */
appearance: none;
border: none;
background: transparent;
font-family: inherit;
cursor: pointer;
transition:
background var(--motion-fast),
color var(--motion-fast);
text-align: left;
}
/* --c-hover alone is near-invisible on the surfaces these rows sit on. */
.sui-navitem:hover {
background: var(--c-hover);
color: var(--c-text-muted);
background: var(--c-active);
color: var(--c-text);
}
.sui-navitem.is-active {
background: var(--c-primary-subtle);
@@ -25,15 +35,31 @@
}
.sui-navitem.is-active:hover {
background: var(--c-primary-subtle);
color: var(--c-accent-fg);
}
.sui-navitem.is-disabled {
opacity: 0.5;
cursor: default;
}
.sui-navitem.is-disabled:hover {
background: transparent;
color: var(--c-text-muted);
}
.sui-navitem__icon {
width: 1rem;
height: 1rem;
width: 1.125rem;
height: 1.125rem;
font-size: 1.125rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Icons arrive from several sources; pin them all to the row's icon box. */
.sui-navitem__icon > svg {
font-size: inherit;
width: 1em;
height: 1em;
}
.sui-navitem__label {
flex: 1;
}
@@ -44,7 +70,44 @@
}
.sui-navitem:focus-visible {
outline: 0.125rem solid var(--c-primary);
outline-offset: 0.125rem;
outline-offset: -0.125rem;
}
/* ---- Collapsed rail (icon only) ---- */
.sui-navitem--icon-only {
justify-content: center;
padding-inline: 0;
}
/* ---- Hover icon swap ---- */
.sui-navitem__icon--swap {
position: relative;
}
.sui-navitem__icon--swap > span {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transition: opacity var(--motion-fast);
}
.sui-navitem__icon-hover {
opacity: 0;
}
.sui-navitem:hover:not(.is-disabled) .sui-navitem__icon-rest {
opacity: 0;
}
.sui-navitem:hover:not(.is-disabled) .sui-navitem__icon-hover {
opacity: 1;
}
/* ---- Field row: a row hosting an inline control, so not a <button> ---- */
.sui-navitem--field {
cursor: default;
}
.sui-navitem--field:hover {
background: transparent;
color: var(--c-text-muted);
}
/* ---- Status accent (optional) ---- */
@@ -2,6 +2,7 @@ import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { NavItem } from "@app/ui/NavItem";
import { SectionDivider } from "@app/ui/SectionDivider";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
function Dot({ color = "var(--c-primary)" }: { color?: string }) {
return (
@@ -67,6 +68,100 @@ export const WithTrailingBadge: Story = {
},
};
/** Every state side by side; hover a row to see the hover treatment. */
export const States: Story = {
render: () => (
<div>
<NavItem id="rest" label="Rest" icon={<Dot />} />
<NavItem id="active" label="Active" icon={<Dot />} isActive />
<NavItem id="disabled" label="Disabled" icon={<Dot />} disabled />
<NavItem
id="accent"
label="Accent (status edge)"
icon={<Dot color="var(--color-green)" />}
accent="green"
/>
</div>
),
};
/** Collapsed rail: `iconOnly` centres the icon and drops the label. */
export const IconOnlyRail: Story = {
decorators: [
(S) => (
<div
style={{
width: "3rem",
background: "var(--c-bg-raised)",
padding: "6px 0",
border: "1px solid var(--c-border)",
borderRadius: 6,
}}
>
<S />
</div>
),
],
render: () => (
<div>
<NavItem id="search" label="Search" icon={<Dot />} iconOnly />
<NavItem
id="files"
label="My Files"
icon={<Dot color="var(--color-purple)" />}
iconOnly
/>
<NavItem id="home" label="Home" icon={<Dot />} iconOnly isActive />
</div>
),
};
/** `hoverIcon` cross-fades in on hover, e.g. a vendor mark gaining colour. */
export const HoverIconSwap: Story = {
render: () => (
<div>
<NavItem
id="drive"
label="Google Drive"
icon={<GoogleDriveIcon />}
hoverIcon={<GoogleDriveIcon colored />}
/>
<NavItem
id="drive-off"
label="Google Drive (not configured)"
icon={<GoogleDriveIcon />}
disabled
/>
</div>
),
};
/** The field variant: a row hosting an inline control, so not a `<button>`. */
export const FieldRow: Story = {
render: () => (
<div>
<div className="sui-navitem sui-navitem--field">
<span className="sui-navitem__icon">
<Dot />
</span>
<input
placeholder="Search files..."
style={{
flex: 1,
minWidth: 0,
background: "transparent",
border: "none",
outline: "none",
font: "inherit",
color: "var(--c-text)",
}}
/>
</div>
<NavItem id="search" label="Search" icon={<Dot />} />
</div>
),
};
export const InContext_SidebarGroup: Story = {
render: () => {
function Bound() {
+77 -45
View File
@@ -1,15 +1,26 @@
import type { ReactNode } from "react";
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import "@app/ui/NavItem.css";
export type NavItemAccent = "blue" | "purple" | "green" | "amber" | "red";
export interface NavItemProps {
type NativeButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
"onClick" | "disabled" | "className" | "id" | "aria-label" | "children"
>;
export interface NavItemProps extends NativeButtonProps {
/** Stable view id passed to the click handler. */
id: string;
label: string;
icon?: ReactNode;
/** Show the active highlight (navActive background, navActiveText colour). */
/** Second icon cross-faded in on hover (e.g. a vendor mark gaining colour). */
hoverIcon?: ReactNode;
/** Show the active highlight (primary-subtle background, accent text). */
isActive?: boolean;
/** Dimmed and inert, but still hoverable so a tooltip can explain why. */
disabled?: boolean;
/** Collapsed rail: icon only; the label stays the accessible name. */
iconOnly?: boolean;
/**
* Optional status accent: draws a left edge bar in the tone colour and tints
* the leading icon to match. For listing live/paused/etc. entities.
@@ -21,45 +32,66 @@ export interface NavItemProps {
className?: string;
}
/**
* Sidebar navigation row matching the prototype's hover + active styling.
*
* Active styling: navActive background, navActiveText colour, weight 500.
* Hover styling: navHover background, navHoverText colour (only when not
* already active). An optional `accent` adds a status edge bar + icon tint.
*/
export function NavItem({
id,
label,
icon,
isActive,
accent,
trailing,
onClick,
className,
}: NavItemProps) {
return (
<button
type="button"
onClick={() => onClick?.(id)}
className={[
"sui-navitem",
isActive ? "is-active" : "",
accent ? "sui-navitem--accent" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
data-accent={accent}
aria-current={isActive ? "page" : undefined}
>
{icon && (
<span className="sui-navitem__icon" aria-hidden>
{icon}
</span>
)}
<span className="sui-navitem__label">{label}</span>
{trailing && <span className="sui-navitem__trailing">{trailing}</span>}
</button>
);
}
/** The shared sidebar navigation row (portal nav, editor file sidebar). */
export const NavItem = forwardRef<HTMLButtonElement, NavItemProps>(
function NavItem(
{
id,
label,
icon,
hoverIcon,
isActive,
disabled,
iconOnly,
accent,
trailing,
onClick,
className,
...rest
},
ref,
) {
return (
<button
ref={ref}
type="button"
onClick={disabled ? undefined : () => onClick?.(id)}
tabIndex={disabled ? -1 : undefined}
className={[
"sui-navitem",
isActive ? "is-active" : "",
disabled ? "is-disabled" : "",
iconOnly ? "sui-navitem--icon-only" : "",
accent ? "sui-navitem--accent" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
data-accent={accent}
aria-current={isActive ? "page" : undefined}
aria-disabled={disabled || undefined}
aria-label={label}
{...rest}
>
{icon &&
(hoverIcon ? (
<span
className="sui-navitem__icon sui-navitem__icon--swap"
aria-hidden
>
<span className="sui-navitem__icon-rest">{icon}</span>
<span className="sui-navitem__icon-hover">{hoverIcon}</span>
</span>
) : (
<span className="sui-navitem__icon" aria-hidden>
{icon}
</span>
))}
{!iconOnly && <span className="sui-navitem__label">{label}</span>}
{!iconOnly && trailing && (
<span className="sui-navitem__trailing">{trailing}</span>
)}
</button>
);
},
);
@@ -0,0 +1,7 @@
.sui-nav-surface {
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: var(--radius-nav);
/* Clips full-bleed nav rows to the rounded corners. */
overflow: hidden;
}
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { NavSurface } from "@app/ui/NavSurface";
import { NavItem } from "@app/ui/NavItem";
function Dot() {
return (
<span
style={{
width: 14,
height: 14,
borderRadius: 3,
background: "var(--c-primary)",
display: "inline-block",
}}
/>
);
}
const meta: Meta<typeof NavSurface> = {
title: "Primitives/NavSurface",
component: NavSurface,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ width: "15rem", background: "var(--c-bg)", padding: 12 }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof NavSurface>;
/** The box a sidebar's rows sit in. NavItem's own states are covered by its
* own stories - the active row's accent-on-tint contrast is a known
* NavItem issue, and duplicating it here would just baseline it twice. */
export const Default: Story = {
args: {
children: (
<div style={{ padding: "0.5rem 0" }}>
<NavItem id="home" label="Home" icon={<Dot />} />
<NavItem id="sources" label="Sources" icon={<Dot />} />
<NavItem id="documents" label="Documents" icon={<Dot />} />
</div>
),
},
};
/** `as` renders a landmark element instead of a div. */
export const AsSection: Story = {
args: {
as: "section",
"aria-label": "Processor",
children: (
<div style={{ padding: "0.75rem" }}>
<span style={{ fontSize: 13, color: "var(--c-text-muted)" }}>
Any content, not just nav rows.
</span>
</div>
),
},
};
@@ -0,0 +1,30 @@
import { forwardRef, type HTMLAttributes } from "react";
import "@app/ui/NavSurface.css";
export interface NavSurfaceProps extends HTMLAttributes<HTMLDivElement> {
/** Element to render; `section`/`aside` when the box is a landmark. */
as?: "div" | "section" | "aside";
}
/**
* The floating box a sidebar's contents sit in: nav sections, the editor's
* file rail, the account footer. Surface fill, hairline border, nav radius.
*/
export const NavSurface = forwardRef<HTMLDivElement, NavSurfaceProps>(
function NavSurface(
{ as: Component = "div", className, children, ...rest },
ref,
) {
return (
<Component
ref={ref}
className={["sui-nav-surface", className ?? ""]
.filter(Boolean)
.join(" ")}
{...rest}
>
{children}
</Component>
);
},
);
@@ -50,9 +50,7 @@ export const WithActions: Story = {
accent: "purple",
actions: (
<>
<StatusBadge tone="success" pulse>
Healthy
</StatusBadge>
<StatusBadge tone="success">Healthy</StatusBadge>
<Button size="sm" variant="secondary">
Edit composition
</Button>
+28 -27
View File
@@ -2,35 +2,21 @@
display: inline-flex;
align-items: center;
gap: 0.375rem;
border-radius: var(--radius-pill);
font-family: var(--font-sans);
font-weight: 500;
letter-spacing: 0.01em;
border: 1px solid transparent;
line-height: 1;
color: var(--sui-status-c, var(--c-text-subtle));
background: color-mix(
in srgb,
var(--sui-status-c, var(--c-text-subtle)) 12%,
transparent
);
border-color: color-mix(
in srgb,
var(--sui-status-c, var(--c-text-subtle)) 28%,
transparent
);
}
.sui-status--sm {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
}
.sui-status--md {
font-size: 0.75rem;
padding: 0.1875rem 0.625rem;
}
.sui-status--lg {
font-size: 0.8125rem;
padding: 0.3125rem 0.75rem;
}
.sui-status__dot {
@@ -38,25 +24,40 @@
height: 0.375rem;
border-radius: 50%;
background: currentColor;
position: relative;
}
.sui-status__dot--pulse::after {
content: "";
position: absolute;
inset: -0.125rem;
border-radius: 50%;
border: 2px solid currentColor;
animation: pulseRing 1.4s ease-out infinite;
}
/* Neutral keeps the plain muted surface rather than an accent tint. */
.sui-status--pill {
border-radius: var(--radius-pill);
border: 1px solid
color-mix(
in srgb,
var(--sui-status-c, var(--c-text-subtle)) 28%,
transparent
);
background: color-mix(
in srgb,
var(--sui-status-c, var(--c-text-subtle)) 12%,
transparent
);
}
.sui-status--pill.sui-status--sm {
padding: 0.125rem 0.5rem;
}
.sui-status--pill.sui-status--md {
padding: 0.1875rem 0.625rem;
}
.sui-status--pill.sui-status--lg {
padding: 0.3125rem 0.75rem;
}
.sui-status--neutral {
color: var(--c-text-subtle);
}
.sui-status--pill.sui-status--neutral {
background: var(--c-surface-sunken);
border-color: var(--c-border-subtle);
}
/* Tones only pick the accent; the base rule builds the fill + border. `-dark`
is theme-adaptive, so text stays legible on the pale fill in both themes. */
.sui-status--success {
--sui-status-c: var(--color-green-dark);
}
@@ -33,7 +33,7 @@ export const AllTones: Story = {
};
export const Live: Story = {
args: { tone: "success", pulse: true, children: "Live" },
args: { tone: "success", children: "Live" },
};
export const Sizes: Story = {
+2 -14
View File
@@ -14,28 +14,21 @@ export type StatusSize = "sm" | "md" | "lg";
export interface StatusBadgeProps {
tone?: StatusTone;
size?: StatusSize;
/** Show a leading coloured dot. */
showDot?: boolean;
/** Render the dot with a pulse animation (active / live indicator). */
pulse?: boolean;
children?: ReactNode;
className?: string;
}
/**
* Inline status pill used across surfaces — pipeline rows, document status,
* deployments, audit logs. Tone maps to semantic meaning, not raw colour.
*/
export function StatusBadge({
tone = "neutral",
size = "md",
showDot = true,
pulse = false,
children,
className,
}: StatusBadgeProps) {
const cls = [
"sui-status",
showDot ? "" : "sui-status--pill",
`sui-status--${tone}`,
`sui-status--${size}`,
className ?? "",
@@ -44,12 +37,7 @@ export function StatusBadge({
.join(" ");
return (
<span className={cls}>
{showDot && (
<span
className={`sui-status__dot${pulse ? " sui-status__dot--pulse" : ""}`}
aria-hidden
/>
)}
{showDot && <span className="sui-status__dot" aria-hidden />}
<span className="sui-status__label">{children}</span>
</span>
);
+17 -7
View File
@@ -2,16 +2,26 @@
* accents derive from --color-* tokens (auto dark), neutral/brand/ai are explicit. */
.sui-acc-default {
--_solid: var(--c-primary);
--_solid-hover: var(--c-primary-hover);
--_on: #ffffff;
--_solid: var(--c-btn-solid);
--_solid-hover: color-mix(
in srgb,
var(--c-btn-solid) 85%,
var(--c-btn-inverse)
);
--_on: var(--c-btn-inverse);
--_text: var(--c-primary-hover);
--_bd: color-mix(in srgb, var(--c-primary) 38%, var(--c-surface));
--_tint: color-mix(in srgb, var(--c-primary) 12%, transparent);
}
html[data-app-theme="custom"] .sui-acc-default {
--_on: var(--c-text-on-primary);
--_solid-2: var(--c-btn-secondary);
--_solid-2-hover: color-mix(
in srgb,
var(--c-btn-secondary) 92%,
var(--c-btn-solid)
);
--_on-2: var(--c-btn-solid);
--_bd-2: var(--c-btn-secondary-border);
--_tert-text: var(--c-text);
--_tert-tint: var(--c-hover);
}
/* Danger is pinned to a fixed deep red (not the theme-lightened coral), so the
fill and the outline/text are the SAME red in both light and dark. */
+2
View File
@@ -1,5 +1,6 @@
export * from "@app/ui/Button";
export * from "@app/ui/ActionIcon";
export * from "@app/ui/Logo";
export * from "@app/ui/FilePicker";
export * from "@app/ui/SegmentedControl";
export * from "@app/ui/StatusBadge";
@@ -8,6 +9,7 @@ export * from "@app/ui/ToggleSwitch";
export * from "@app/ui/ProgressBar";
export * from "@app/ui/MetricCard";
export * from "@app/ui/NavItem";
export * from "@app/ui/NavSurface";
export * from "@app/ui/PanelHeader";
export * from "@app/ui/CodeBlock";
export * from "@app/ui/SectionDivider";
@@ -1,9 +1,18 @@
import { Logo } from "@app/ui/Logo";
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
/**
* Desktop inherits proprietary's layers but does not ship the portal (see
* desktop/routes/adminRouteExtensions), so shadow the switcher back to empty
* otherwise the desktop bundle would reference @portal via the proprietary
* switcher's imports.
* desktop/routes/adminRouteExtensions), so there's nothing to switch to
* shadow the brand header back to a plain logo. (Also avoids the desktop
* bundle referencing @portal via the proprietary switcher's imports.)
*/
export function AppSwitcher() {
return null;
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
return (
<Logo
variant={collapsed ? "iconOnly" : "iconAndText"}
iconHeight="1.6rem"
textHeight="1.3rem"
/>
);
}
@@ -43,9 +43,6 @@
}
.portal-shell__topbar-wordmark {
height: 1.375rem;
width: auto;
display: block;
margin-right: auto;
}
@@ -3,11 +3,9 @@ import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { ActionIcon } from "@app/ui";
import { Sidebar } from "@portal/components/Sidebar";
import { useTheme } from "@portal/contexts/ThemeContext";
import { useUI } from "@portal/contexts/UIContext";
import { MenuIcon, SearchIcon } from "@portal/components/icons";
import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg";
import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg";
import { Logo } from "@app/ui/Logo";
import "@portal/components/AppShell.css";
/**
@@ -17,7 +15,6 @@ import "@portal/components/AppShell.css";
*/
function MobileTopbar() {
const { t } = useTranslation();
const { theme } = useTheme();
const { mobileNavOpen, toggleMobileNav, openSearch } = useUI();
return (
<header className="portal-shell__topbar">
@@ -30,10 +27,11 @@ function MobileTopbar() {
>
<MenuIcon size={20} />
</ActionIcon>
<img
<Logo
variant="iconAndText"
iconHeight="1.6rem"
textHeight="1.3rem"
className="portal-shell__topbar-wordmark"
src={theme === "dark" ? wordmarkDark : wordmarkLight}
alt={t("portal.shell.sidebar.brandSuffix")}
/>
<ActionIcon
variant="tertiary"
@@ -75,6 +75,10 @@ export function PortalSettingsHost() {
urlSync={false}
initialSection={initialSection}
extraSections={extraSections}
// TODO: opening Keyboard Shortcuts in the portal white-screens
// the app (the section expects editor-only context). Hidden here
// as a stopgap; fix the section properly and drop this.
hiddenSectionKeys={["hotkeys"]}
/>
</ThemeProvider>
</PreferencesProvider>
@@ -2,13 +2,32 @@
width: 15rem;
height: 100vh;
height: 100dvh; /* track mobile browser chrome */
background: var(--c-bg-raised);
border-right: 1px solid var(--c-border);
background: var(--c-bg);
display: flex;
flex-direction: column;
flex-shrink: 0;
position: sticky;
top: 0;
/* Slide between the full rail and the collapsed icon rail. Overridden by the
drawer's transform transition under the mobile breakpoint below. */
transition: width var(--motion-spring);
}
@media (prefers-reduced-motion: reduce) {
.portal-sidebar {
transition: none;
}
}
/* Nav labels stay on one line and are clipped by the narrowing rail so they
reveal/hide cleanly as the width animates rather than wrapping. */
.portal-sidebar__nav,
.portal-sidebar__footer {
overflow-x: hidden;
}
.portal-sidebar .sui-navitem__label,
.portal-sidebar__section-label {
white-space: nowrap;
}
/* Mobile close button (shared ActionIcon) — only shown inside the drawer. */
@@ -17,6 +36,11 @@
flex-shrink: 0;
}
.portal-sidebar__collapse {
margin-left: auto;
flex-shrink: 0;
}
/* Off-canvas drawer under the shell breakpoint (keep in sync with
AppShell.css and Sidebar.tsx). Slides from the inline-start edge so RTL
locales get the mirrored behavior for free. */
@@ -46,58 +70,68 @@
.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%;
}
.portal-sidebar[data-collapsed] .sui-navitem.is-active {
border-left: none;
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,12 +139,17 @@
gap: 0.5rem;
}
/* Each section is a labelled card: a small header above its nav items. */
.portal-sidebar .sui-navitem {
padding-inline: 0.875rem;
}
.portal-sidebar .sui-navitem.is-active {
border-left: 3px solid var(--c-primary);
padding-left: calc(0.875rem - 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;
padding: 0.5rem 0 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
@@ -118,8 +157,8 @@
.portal-sidebar__section-label {
margin: 0;
padding: 0 0.5rem;
font-size: 0.6875rem;
padding: 0 0.875rem;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--c-text-subtle);
@@ -128,14 +167,11 @@
.portal-sidebar__group {
display: flex;
flex-direction: column;
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;
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,

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