From 1abd23cf94756607d3b024f296ea78e8f035147a Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 6 Jul 2026 11:21:09 +0200 Subject: [PATCH 01/35] fix(frontend): respect analytics config before initializing PostHog (#6812) # Description of Changes Please provide a summary of the changes, including: - What was changed - Moved PostHog startup out of `index.tsx` and into a config-aware initializer inside `AppProviders`. - Added a dedicated `usePosthogTracking` hook that only initializes PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog` is not disabled. - Kept cookie-consent handling in the same flow so consent is applied only after PostHog is actually initialized. - Removed the unconditional `PostHogProvider` and `posthog.init(...)` bootstrap from the app entrypoint. - Added targeted frontend tests covering analytics-disabled and analytics-enabled startup behavior. - Why the change was made - The previous frontend bootstrap initialized PostHog before app config was loaded, so disabling analytics in the UI or via environment settings did not prevent PostHog network activity. - This change makes analytics behavior follow the server-provided config instead of always connecting on page load. Closes #6358 --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../src/core/components/AppProviders.tsx | 7 ++ .../core/hooks/usePosthogTracking.test.tsx | 76 +++++++++++++++++ .../src/core/hooks/usePosthogTracking.ts | 83 +++++++++++++++++++ frontend/editor/src/index.tsx | 37 +-------- 4 files changed, 169 insertions(+), 34 deletions(-) create mode 100644 frontend/editor/src/core/hooks/usePosthogTracking.test.tsx create mode 100644 frontend/editor/src/core/hooks/usePosthogTracking.ts diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index ede9ed266a..f050ae7f9b 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -27,6 +27,7 @@ import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestra import { PageEditorProvider } from "@app/contexts/PageEditorContext"; import { BannerProvider } from "@app/contexts/BannerContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; +import { usePosthogTracking } from "@app/hooks/usePosthogTracking"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; import { useLogoAssets } from "@app/hooks/useLogoAssets"; @@ -43,6 +44,11 @@ function ScarfTrackingInitializer() { return null; } +function PosthogTrackingInitializer() { + usePosthogTracking(); + return null; +} + // Component to run app-level initialization (must be inside AppProviders for context access) function AppInitializer() { useAppInitialization(); @@ -122,6 +128,7 @@ export function AppProviders({ retryOptions={appConfigRetryOptions} {...appConfigProviderProps} > + diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx new file mode 100644 index 0000000000..41f5fad7dc --- /dev/null +++ b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx @@ -0,0 +1,76 @@ +import { ReactNode } from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +const posthogState = vi.hoisted(() => ({ loaded: false })); +const posthogMock = vi.hoisted(() => ({ + get __loaded() { + return posthogState.loaded; + }, + init: vi.fn(() => { + posthogState.loaded = true; + }), + opt_out_capturing: vi.fn(), + opt_in_capturing: vi.fn(), + set_config: vi.fn(), + has_opted_in_capturing: vi.fn(() => false), +})); + +vi.mock("posthog-js", () => ({ + default: posthogMock, +})); + +import { usePosthogTracking } from "@app/hooks/usePosthogTracking"; + +describe("usePosthogTracking", () => { + beforeEach(() => { + posthogState.loaded = false; + posthogMock.init.mockClear(); + posthogMock.opt_out_capturing.mockClear(); + posthogMock.opt_in_capturing.mockClear(); + posthogMock.set_config.mockClear(); + vi.stubEnv("VITE_PUBLIC_POSTHOG_KEY", "test-key"); + vi.stubEnv("VITE_PUBLIC_POSTHOG_HOST", "https://eu.i.posthog.com"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not initialize PostHog when analytics is disabled", async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => usePosthogTracking(), { wrapper }); + + await waitFor(() => { + expect(posthogMock.init).not.toHaveBeenCalled(); + }); + }); + + it("initializes PostHog when analytics is enabled", async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => usePosthogTracking(), { wrapper }); + + await waitFor(() => { + expect(posthogMock.init).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.ts b/frontend/editor/src/core/hooks/usePosthogTracking.ts new file mode 100644 index 0000000000..a8037ec4a7 --- /dev/null +++ b/frontend/editor/src/core/hooks/usePosthogTracking.ts @@ -0,0 +1,83 @@ +import { useEffect } from "react"; +import posthog from "posthog-js"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; + +function applyPosthogConsent(): void { + if (typeof window === "undefined" || !posthog.__loaded) { + return; + } + + const optedIn = + window.CookieConsent?.acceptedService?.("posthog", "analytics") || false; + + if (optedIn) { + posthog.set_config({ persistence: "localStorage+cookie" }); + posthog.opt_in_capturing(); + return; + } + + posthog.opt_out_capturing(); + posthog.set_config({ persistence: "memory" }); +} + +function ensurePosthogInitialized(): boolean { + if (typeof window === "undefined") { + return false; + } + + const posthogKey = import.meta.env.VITE_PUBLIC_POSTHOG_KEY; + const posthogHost = import.meta.env.VITE_PUBLIC_POSTHOG_HOST; + + if (!posthogKey || !posthogHost) { + return false; + } + + if (!posthog.__loaded) { + posthog.init(posthogKey, { + api_host: posthogHost, + defaults: "2025-05-24", + capture_exceptions: true, + debug: false, + opt_out_capturing_by_default: true, + persistence: "memory", + cross_subdomain_cookie: false, + }); + } + + return true; +} + +export function usePosthogTracking(): void { + const { config } = useAppConfig(); + + useEffect(() => { + const analyticsEnabled = config?.enableAnalytics === true; + const posthogEnabled = analyticsEnabled && config?.enablePosthog !== false; + + if (!posthogEnabled) { + if (posthog.__loaded) { + posthog.opt_out_capturing(); + posthog.set_config({ persistence: "memory" }); + } + return; + } + + if (!ensurePosthogInitialized()) { + return; + } + + applyPosthogConsent(); + + const handleConsentChange = () => { + applyPosthogConsent(); + }; + + window.addEventListener("cc:onConsent", handleConsentChange); + window.addEventListener("cc:onChange", handleConsentChange); + + return () => { + window.removeEventListener("cc:onConsent", handleConsentChange); + window.removeEventListener("cc:onChange", handleConsentChange); + }; + }, [config?.enableAnalytics, config?.enablePosthog]); +} diff --git a/frontend/editor/src/index.tsx b/frontend/editor/src/index.tsx index 017138cebc..d776918bd1 100644 --- a/frontend/editor/src/index.tsx +++ b/frontend/editor/src/index.tsx @@ -13,8 +13,6 @@ import { ColorSchemeScript } from "@mantine/core"; import { BrowserRouter } from "react-router-dom"; import App from "@app/App"; import "@app/i18n"; // Initialize i18next -import posthog from "posthog-js"; -import { PostHogProvider } from "@posthog/react"; import { BASE_PATH } from "@app/constants/app"; import { startEagerWasmCompilation } from "@app/services/wasmPrecompiler"; @@ -35,33 +33,6 @@ if (typeof window !== "undefined") { } } -posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, { - api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, - defaults: "2025-05-24", - capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this - debug: false, - opt_out_capturing_by_default: true, // Opt-out by default, controlled by cookie consent - persistence: "memory", // No cookies/localStorage written until user opts in - cross_subdomain_cookie: false, -}); - -function updatePosthogConsent() { - if (!posthog.__loaded) return; - const optIn = - window.CookieConsent?.acceptedService?.("posthog", "analytics") || false; - if (optIn) { - posthog.set_config({ persistence: "localStorage+cookie" }); - posthog.opt_in_capturing(); - } else { - posthog.opt_out_capturing(); - posthog.set_config({ persistence: "memory" }); - } - console.log("Updated PostHog consent: ", optIn ? "opted in" : "opted out"); -} - -window.addEventListener("cc:onConsent", updatePosthogConsent); -window.addEventListener("cc:onChange", updatePosthogConsent); - const container = document.getElementById("root"); if (!container) { throw new Error("Root container missing in index.html"); @@ -71,10 +42,8 @@ const root = ReactDOM.createRoot(container); // Finds the root DOM element root.render( - - - - - + + + , ); From 12563050a60ce3e07f02795bb3a50007400da89b Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 6 Jul 2026 11:21:41 +0200 Subject: [PATCH 02/35] Generate frontend license report on push (#6877) # Description of Changes `app/allowed-licenses.json` has been modified in preparation for when "org.springframework.boot" is upgraded to version "4.0.7". --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../frontend-backend-licenses-update.yml | 8 ++++++++ app/allowed-licenses.json | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 41d3f35c6e..cc9aa023e3 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -98,6 +98,13 @@ jobs: - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + + - name: Generate frontend license report (Push only) + if: github.event_name == 'push' + env: + PR_IS_FORK: "false" + run: task frontend:licenses:generate + - name: Generate frontend license report (internal PR) if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false env: @@ -353,6 +360,7 @@ jobs: - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + - name: Check licenses and generate report id: license-check run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index cd2fe06a3b..9f1ff96359 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -80,10 +80,18 @@ "moduleName": ".*", "moduleLicense": "Apache License Version 2.0" }, + { + "moduleName": ".*", + "moduleLicense": "Apache License version 2.0" + }, { "moduleName": ".*", "moduleLicense": "Apache License, Version 2.0" }, + { + "moduleName": ".*", + "moduleLicense": "Apache License, version 2.0" + }, { "moduleName": ".*", "moduleLicense": "The Apache License, Version 2.0" @@ -108,6 +116,10 @@ "moduleName": ".*", "moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)" }, + { + "moduleName": ".*", + "moduleLicense": "Mozilla Public License Version 2.0" + }, { "moduleName": ".*", "moduleLicense": "CDDL+GPL License" @@ -172,6 +184,14 @@ "moduleName": ".*", "moduleLicense": "Eclipse Public License, Version 2.0" }, + { + "moduleName": ".*", + "moduleLicense": "EPL-2.0" + }, + { + "moduleName": ".*", + "moduleLicense": "LGPL-2.1-only" + }, { "moduleName": ".*", "moduleLicense": "Ubuntu Font Licence 1.0" From a15e8227b4db9b07c158d08a7cf9e1ffa49f0b5f Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 6 Jul 2026 11:21:57 +0200 Subject: [PATCH 03/35] fix(ci): upload Playwright reports from the correct frontend directory (#6859) # Description of Changes This change fixes the artifact upload path used by the Playwright E2E workflows after the frontend directory structure was updated. ### What was changed - Updated the Playwright report artifact path from: - `frontend/editor/playwright-report/` - to `frontend/playwright-report/` - Applied the fix to: - `build-enterprise.yml` - `e2e-stubbed.yml` - `nightly.yml` - Renamed the nightly Playwright artifact from: - `playwright-nightly-${{ github.run_id }}` - to `playwright-report-nightly-${{ github.run_id }}` for consistency with the other workflows. ### Why the change was made The workflows attempted to upload artifacts from a directory that no longer exists, causing GitHub Actions to report: > No files were found with the provided path: `frontend/editor/playwright-report/` Updating the upload path ensures Playwright reports are successfully collected and available for debugging failed E2E runs. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/workflows/build-enterprise.yml | 2 +- .github/workflows/build.yml | 3 +++ .github/workflows/coverage-aggregate.yml | 25 +++++++++++++++++------- .github/workflows/e2e-live.yml | 2 +- .github/workflows/e2e-stubbed.yml | 2 +- .github/workflows/nightly.yml | 4 ++-- 6 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index d29002ac37..2db275284b 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -285,5 +285,5 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-enterprise-${{ github.run_id }} - path: frontend/editor/playwright-report/ + path: frontend/playwright-report/ retention-days: 7 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7018a38120..0e0a702cc2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -202,6 +202,9 @@ jobs: contents: read uses: ./.github/workflows/coverage-aggregate.yml secrets: inherit + with: + frontend-validation-result: ${{ needs.frontend-validation.result }} + playwright-e2e-live-result: ${{ needs.playwright-e2e-live.result }} # Single status check that branch protection should mark as required. # Succeeds when every upstream job is either `success` or `skipped` (path- diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index bbfaf09363..ce792bcadc 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -13,6 +13,17 @@ name: Aggregate backend coverage # producers themselves on: workflow_call: + inputs: + frontend-validation-result: + description: Result of the frontend-validation producer job + required: false + type: string + default: skipped + playwright-e2e-live-result: + description: Result of the playwright-e2e-live producer job + required: false + type: string + default: skipped permissions: contents: read @@ -196,9 +207,9 @@ jobs: # -------------------------------------------------------------- - name: Download vitest coverage artifact # frontend-validation uploads as `frontend-coverage`. Tolerate - # absence so a backend-only PR still produces the matrix with - # just backend rows populated. - if: always() + # absence on backend-only runs by skipping the download entirely + # when the producer job was not part of this workflow run. + if: inputs.frontend-validation-result == 'success' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 with: name: frontend-coverage @@ -206,12 +217,12 @@ jobs: continue-on-error: true - name: Download Playwright frontend coverage artifact - # e2e-live uploads as `playwright-frontend-coverage-`. - # Same tolerance as vitest - matrix script handles missing inputs. - if: always() + # e2e-live uploads the artifact with a stable name. Skip the + # download entirely when the producer job did not run. + if: inputs.playwright-e2e-live-result == 'success' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 with: - name: playwright-frontend-coverage-${{ github.run_id }} + name: playwright-frontend-coverage path: matrix-inputs/playwright/ continue-on-error: true diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index eb6d8d7be5..44d5443a73 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -169,7 +169,7 @@ jobs: if: always() && steps.pw-frontend-coverage.outputs.summary == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: playwright-frontend-coverage-${{ github.run_id }} + name: playwright-frontend-coverage path: | .test-state/playwright/coverage-pw-summary/ .test-state/playwright/coverage-pw/ diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index dd6bcc8ea7..33bb24ee4c 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -50,5 +50,5 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-stubbed-${{ github.run_id }} - path: frontend/editor/playwright-report/ + path: frontend/playwright-report/ retention-days: 7 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 11a8ea2be5..1801876bcd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -53,8 +53,8 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: playwright-nightly-${{ github.run_id }} - path: frontend/editor/playwright-report/ + name: playwright-report-nightly-${{ github.run_id }} + path: frontend/playwright-report/ retention-days: 14 # Builds all desktop platforms on a schedule so the Rust dependency cache is From e630d6697b39235a785b2cc224f8f9e409cfa607 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 6 Jul 2026 10:22:41 +0100 Subject: [PATCH 04/35] Fix tooltip positioning on Add Page Numbers (#6885) # Description of Changes ## Before image ## After image --- .../addPageNumbers/AddPageNumbersAppearanceSettings.tsx | 5 +++++ .../tools/addPageNumbers/AddPageNumbersPositionSettings.tsx | 2 ++ 2 files changed, 7 insertions(+) diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx index 627f7c1b9e..11ca767bca 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx +++ b/frontend/editor/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx @@ -27,6 +27,7 @@ const AddPageNumbersAppearanceSettings = ({ return ( 001). Set 0 to disable.", @@ -90,6 +93,7 @@ const AddPageNumbersAppearanceSettings = ({ Date: Mon, 6 Jul 2026 10:25:46 +0100 Subject: [PATCH 05/35] Fix Playwright tests in Firefox and Safari (#6868) # Description of Changes Playwright tests currently fail in Firefox and Safari because of inconsistent behaviour across the browsers. This is causing the nightlies to fail every night. This PR fixes the test behaviour to work consistently across browsers (most of the issues were to do with the tests opening the file picker, which was being automatically suppressed in Chromium, but not the other browsers). --- .../src/core/tests/helpers/stub-test-base.ts | 2 + .../src/core/tests/helpers/test-base.ts | 2 + .../src/core/tests/helpers/ui-helpers.ts | 46 +++++++++++++++++-- .../live/encrypted-unlock-then-tool.spec.ts | 3 ++ .../stubbed/certificate-validation.spec.ts | 5 ++ .../src/core/tests/stubbed/convert.spec.ts | 14 ++++-- .../stubbed/encrypted-pdf-unlock.spec.ts | 12 ++++- .../stubbed/files-page-screenshots.spec.ts | 13 +++++- .../tests/stubbed/pdf-text-search.spec.ts | 5 +- .../stubbed/viewer-text-selection.spec.ts | 16 +++++++ 10 files changed, 105 insertions(+), 13 deletions(-) diff --git a/frontend/editor/src/core/tests/helpers/stub-test-base.ts b/frontend/editor/src/core/tests/helpers/stub-test-base.ts index 5752108d42..e6ae98c6f7 100644 --- a/frontend/editor/src/core/tests/helpers/stub-test-base.ts +++ b/frontend/editor/src/core/tests/helpers/stub-test-base.ts @@ -6,6 +6,7 @@ import { skipOnboarding, type MockAppApiOptions, } from "@app/tests/helpers/api-stubs"; +import { suppressNativeFilePicker } from "@app/tests/helpers/ui-helpers"; /** * Custom Playwright fixture for backend-free specs. @@ -57,6 +58,7 @@ export const test = base.extend({ seedJwt: [false, { option: true }], page: async ({ page, stubOptions, autoGoto, seedJwt }, use) => { + suppressNativeFilePicker(page); await seedCookieConsent(page); if (seedJwt) { // Logged-in users hit the orchestrator path that surfaces the diff --git a/frontend/editor/src/core/tests/helpers/test-base.ts b/frontend/editor/src/core/tests/helpers/test-base.ts index 1659bc3abd..90de9432f9 100644 --- a/frontend/editor/src/core/tests/helpers/test-base.ts +++ b/frontend/editor/src/core/tests/helpers/test-base.ts @@ -1,6 +1,7 @@ import { test as base, expect } from "@playwright/test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { suppressNativeFilePicker } from "@app/tests/helpers/ui-helpers"; /** * Custom test fixture that: @@ -40,6 +41,7 @@ const COVERAGE_DIR = path.resolve( export const test = base.extend({ page: async ({ page }, use, testInfo) => { + suppressNativeFilePicker(page); await page.context().addCookies([ { name: "cc_cookie", diff --git a/frontend/editor/src/core/tests/helpers/ui-helpers.ts b/frontend/editor/src/core/tests/helpers/ui-helpers.ts index 7c08f93f60..353037c106 100644 --- a/frontend/editor/src/core/tests/helpers/ui-helpers.ts +++ b/frontend/editor/src/core/tests/helpers/ui-helpers.ts @@ -11,6 +11,37 @@ import { expect, type Page, type Locator } from "@playwright/test"; const MANTINE_MODAL_OVERLAY = ".mantine-Modal-overlay"; +/** + * Suppress the native OS file picker for the whole page, on every browser. + * + * Several upload entry points (the FileSidebar "Open from computer" button, + * the Mantine ``, AddFileCard, etc.) open a file dialog by clicking + * a hidden ``. On firefox/webkit Playwright only intercepts + * that dialog while the page has a `filechooser` listener - it toggles + * `Page.setInterceptFileChooserDialog` off the event subscription. With no + * listener the real OS picker leaks onto the host and hangs the nightly run. + * + * Registering a (no-op) `filechooser` listener flips that interception on for + * every browser, so the dialog is suppressed at the browser level however it + * was triggered - a programmatic `.click()`, a ` {parameters.attachments?.length > 0 && ( @@ -120,10 +110,17 @@ const AddAttachmentsSettings = ({ ({(file.size / 1024).toFixed(1)} KB) - + } + aria-label={t( + "AddAttachmentsRequest.removeFile", + "Remove file", + )} size="sm" - variant="subtle" - color="red" + variant="tertiary" + accent="danger" style={{ flexShrink: 0 }} onClick={() => { const newAttachments = ( @@ -132,9 +129,7 @@ const AddAttachmentsSettings = ({ onParameterChange("attachments", newAttachments); }} disabled={disabled} - > - - + /> ))} diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx index 37247c863f..fe92d1ff42 100644 --- a/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx +++ b/frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx @@ -5,6 +5,7 @@ import { pdfWorkerManager } from "@app/services/pdfWorkerManager"; import { useThumbnailGeneration } from "@app/hooks/useThumbnailGeneration"; import styles from "@app/components/tools/addPageNumbers/PageNumberPreview.module.css"; import { PrivateContent } from "@app/components/shared/PrivateContent"; +import { Button } from "@app/ui/Button"; // Simple utilities for page numbers (adapted from stamp) const A4_ASPECT_RATIO = 0.707; @@ -256,9 +257,9 @@ export default function PageNumberPreview({ const idx = (i + 1) as 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; const selected = parameters.position === idx; return ( - + ); })} diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx b/frontend/editor/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx index 7912cd0bcd..76f8be4109 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx +++ b/frontend/editor/src/core/components/tools/addStamp/StampPositionFormattingSettings.tsx @@ -4,7 +4,6 @@ import { Select, Stack, ColorInput, - Button, Slider, Text, NumberInput, @@ -13,6 +12,8 @@ import { AddStampParameters } from "@app/components/tools/addStamp/useAddStampPa import LocalIcon from "@app/components/shared/LocalIcon"; import styles from "@app/components/tools/addStamp/StampPreview.module.css"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; interface StampPositionFormattingSettingsProps { @@ -55,7 +56,7 @@ const StampPositionFormattingSettings = ({ return ( + - + - + diff --git a/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx b/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx index 9659c4317a..88f2d72f6c 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx +++ b/frontend/editor/src/core/components/tools/addStamp/StampPreview.tsx @@ -11,6 +11,7 @@ import { } from "@app/components/tools/addStamp/StampPreviewUtils"; import styles from "@app/components/tools/addStamp/StampPreview.module.css"; import { PrivateContent } from "@app/components/shared/PrivateContent"; +import { Button } from "@app/ui/Button"; type Props = { parameters: AddStampParameters; @@ -429,9 +430,9 @@ export default function StampPreview({ parameters.position === idx && (parameters.overrideX < 0 || parameters.overrideY < 0); return ( - + ); })} diff --git a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx index 1625affa59..4c7ee96c5b 100644 --- a/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx +++ b/frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.tsx @@ -5,7 +5,6 @@ import { Textarea, TextInput, Select, - Button, Text, Divider, Accordion, @@ -15,6 +14,7 @@ import { Box, Paper, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { AddStampParameters } from "@app/components/tools/addStamp/useAddStampParameters"; import ButtonSelector from "@app/components/shared/ButtonSelector"; import styles from "@app/components/tools/addStamp/StampPreview.module.css"; @@ -236,7 +236,6 @@ const StampSetupSettings = ({ { value: "image", label: t("watermark.type.2", "Image") }, ]} disabled={disabled} - buttonClassName={styles.modeToggleButton} textClassName={styles.modeToggleButtonText} /> @@ -673,8 +672,8 @@ const StampSetupSettings = ({ id="stamp-image-input" /> - diff --git a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx index de8c82d449..322d2d87fc 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Group, Text, ActionIcon, Menu, Button, Box } from "@mantine/core"; +import { Group, Text, Menu, Box } from "@mantine/core"; +import { Button as SharedButton } from "@app/ui/Button"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -181,31 +182,24 @@ export default function AutomationEntry({ onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - + {showMenu && ( setIsMenuOpen(false)} > - } + variant="tertiary" + accent="neutral" size="md" aria-label={t( "automate.entryMenu.label", @@ -234,9 +229,7 @@ export default function AutomationEntry({ transition: "opacity 0.2s ease", pointerEvents: shouldShowMenu ? "auto" : "none", }} - > - - + /> diff --git a/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx index 95e85e8026..c8c20cf607 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationImportModal.tsx @@ -3,13 +3,13 @@ import { useTranslation } from "react-i18next"; import { Alert, Badge, - Button, Group, Modal, Stack, Text, Textarea, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { Dropzone } from "@mantine/dropzone"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import { Z_INDEX_AUTOMATE_MODAL } from "@app/styles/zIndex"; @@ -218,7 +218,7 @@ export default function AutomationImportModal({ )} - )} diff --git a/frontend/editor/src/core/components/tools/automate/IconSelector.tsx b/frontend/editor/src/core/components/tools/automate/IconSelector.tsx index cd67d1487f..1b0909cbc0 100644 --- a/frontend/editor/src/core/components/tools/automate/IconSelector.tsx +++ b/frontend/editor/src/core/components/tools/automate/IconSelector.tsx @@ -1,14 +1,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Box, - Text, - Stack, - Button, - SimpleGrid, - Tooltip, - Popover, -} from "@mantine/core"; +import { Box, Text, Stack, SimpleGrid, Tooltip, Popover } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { iconMap, iconOptions } from "@app/components/tools/automate/iconMap"; import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; @@ -58,12 +51,13 @@ export default function IconSelector({ zIndex={Z_INDEX_AUTOMATE_DROPDOWN} > - + diff --git a/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.tsx b/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.tsx index b70f6b925e..93631e087c 100644 --- a/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.tsx +++ b/frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.tsx @@ -1,15 +1,7 @@ import { Suspense, useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { - Modal, - Title, - Button, - Group, - Stack, - Text, - Alert, - Loader, -} from "@mantine/core"; +import { Modal, Title, Group, Stack, Text, Alert, Loader } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { Z_INDEX_AUTOMATE_MODAL } from "@app/styles/zIndex"; import SettingsIcon from "@mui/icons-material/Settings"; import CheckIcon from "@mui/icons-material/Check"; @@ -125,7 +117,7 @@ export default function ToolConfigurationModal({ {isServerCertificateEnabled && ( )} {isHardwareAvailable && ( diff --git a/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx index 820636af35..b384ce8d5c 100644 --- a/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx +++ b/frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { Alert, - Button, Group, Loader, NumberInput, @@ -10,6 +9,7 @@ import { Text, TextInput, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters"; import { @@ -284,22 +284,34 @@ const HardwareCertificateSettings = ({ {supported.windows && supported.pkcs11 && (
@@ -332,7 +344,7 @@ const HardwareCertificateSettings = ({ )} />
removeSignature(sig.id)} aria-label={t( @@ -177,7 +172,7 @@ export const SelectSignatureModal: React.FC = ({ - diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx index 7945c697f4..15fe50eb90 100644 --- a/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx +++ b/frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.tsx @@ -1,15 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ActionIcon, - Box, - Button, - Group, - Menu, - Modal, - SegmentedControl, - Stack, - Text, -} from "@mantine/core"; +import { Box, Group, Menu, Modal, Stack, Text } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button } from "@app/ui/Button"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; import { useTranslation } from "react-i18next"; import DrawIcon from "@mui/icons-material/Draw"; import OpenWithIcon from "@mui/icons-material/OpenWith"; @@ -363,11 +356,10 @@ export default function SignControlsPanel({
) : ( ) : ( - setDropdownOpened(!dropdownOpened)} @@ -106,7 +108,7 @@ const GroupedFormatDropdown = ({ : "var(--dropdown-trigger-text)", }} > - + {selectedLabel} @@ -119,7 +121,7 @@ const GroupedFormatDropdown = ({ }} /> - + handleOptionSelect(option.value)} disabled={option.enabled === false} + rightSection={ + option.usesCloud ? ( + + ) : undefined + } style={{ fontSize: "0.75rem", height: "2rem", @@ -160,15 +173,6 @@ const GroupedFormatDropdown = ({ }} > {option.label} - {option.usesCloud && ( - - )} ))} diff --git a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx index 65b0e8f0fe..710391dd6c 100644 --- a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx +++ b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx @@ -4,11 +4,11 @@ import { Text, Box, Group, - ActionIcon, Center, Alert, Checkbox, } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; import { CropParametersHook } from "@app/hooks/tools/crop/useCropParameters"; @@ -193,7 +193,7 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { {t("crop.preview.title", "Crop Area Selection")} { e.preventDefault(); if (hasChildren) handleToggle(bookmark.id); @@ -272,13 +271,17 @@ export default function BookmarkEditor({ )} > { e.preventDefault(); handleAddChild(bookmark.id); }} disabled={disabled} + aria-label={t( + "editTableOfContents.editor.actions.addChild", + "Add child bookmark", + )} > @@ -290,13 +293,16 @@ export default function BookmarkEditor({ )} > { e.preventDefault(); handleAddSibling(bookmark.id); }} disabled={disabled} + aria-label={t( + "editTableOfContents.editor.actions.addSibling", + "Add sibling bookmark", + )} > @@ -308,13 +314,17 @@ export default function BookmarkEditor({ )} > { e.preventDefault(); handleRemove(bookmark.id); }} disabled={disabled} + aria-label={t( + "editTableOfContents.editor.actions.remove", + "Remove bookmark", + )} > @@ -399,8 +409,7 @@ export default function BookmarkEditor({ - + - + - - file && onImportJson(file)} accept="application/json" disabled={disabled} + variant="secondary" + leftSection={} + fullWidth > - {(props) => ( - - )} - - + {t("editTableOfContents.actions.importJson", "Import JSON")} + - + @@ -184,16 +167,15 @@ export default function EditTableOfContentsSettings({ - - + - + diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx index 43702d5c65..148917dba6 100644 --- a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Box, Button, Card, Divider, Group, Stack, Text } from "@mantine/core"; +import { Box, Card, Divider, Group, Stack, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import LocalIcon from "@app/components/shared/LocalIcon"; import { BookmarkNode } from "@app/utils/editTableOfContents"; import ErrorNotification from "@app/components/tools/shared/ErrorNotification"; @@ -150,7 +151,6 @@ const EditTableOfContentsWorkbenchView = ({ )} + ); const { key: disabledKey, fallback: disabledFallback } = diff --git a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx index 32fa181a16..464403e286 100644 --- a/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx +++ b/frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.tsx @@ -14,6 +14,7 @@ import { useToolMeta, getDisabledLabel, } from "@app/components/tools/fullscreen/shared"; +import { Button } from "@app/ui/Button"; interface DetailedToolItemProps { id: string; @@ -56,8 +57,8 @@ const DetailedToolItem: React.FC = ({ const disabledMessage = t(disabledKey, disabledFallback); return ( - + ); }; diff --git a/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx index 7c1c5fe423..487252ae81 100644 --- a/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx +++ b/frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; -import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core"; +import { Alert, Group, Loader, Stack, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import type { GetPdfInfoOperationHook } from "@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation"; import { downloadFile } from "@app/services/downloadService"; @@ -72,7 +73,6 @@ const GetPdfInfoResults = ({ {t("getPdfInfo.downloads", "Downloads")} - diff --git a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx index cc6389845a..0cbd88e619 100644 --- a/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx +++ b/frontend/editor/src/core/components/tools/pdfTextEditor/PdfTextEditorView.tsx @@ -7,11 +7,9 @@ import React, { useState, } from "react"; import { - ActionIcon, Alert, Badge, Box, - Button, Card, Divider, Group, @@ -24,6 +22,8 @@ import { Text, Tooltip, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { Dropzone } from "@mantine/dropzone"; import { useTranslation } from "react-i18next"; import AutorenewIcon from "@mui/icons-material/Autorenew"; @@ -1553,10 +1553,10 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => { {resizeHandle} {activeGroupId === groupId && ( { + ) : ( )} diff --git a/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.tsx b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.tsx index 8b40b8721a..50cc3d2fe2 100644 --- a/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.tsx +++ b/frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { - ActionIcon, Alert, Badge, Box, @@ -13,6 +12,7 @@ import { Tooltip, } from "@mantine/core"; import { LocalIcon } from "@app/components/shared/LocalIcon"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { SavedSignature, SavedSignatureType, @@ -301,7 +301,7 @@ export const SavedSignaturesSection = ({ setActivePersonalIndex((prev) => Math.max(0, prev - 1)) @@ -315,7 +315,7 @@ export const SavedSignaturesSection = ({ /> setActivePersonalIndex((prev) => @@ -351,8 +351,7 @@ export const SavedSignaturesSection = ({ onUseSignature(activePersonalSignature)} disabled={disabled} @@ -365,8 +364,8 @@ export const SavedSignaturesSection = ({ onDeleteSignature(activePersonalSignature) @@ -432,7 +431,7 @@ export const SavedSignaturesSection = ({ setActiveSharedIndex((prev) => Math.max(0, prev - 1)) @@ -446,7 +445,7 @@ export const SavedSignaturesSection = ({ /> setActiveSharedIndex((prev) => @@ -478,8 +477,7 @@ export const SavedSignaturesSection = ({ onUseSignature(activeSharedSignature)} disabled={disabled} @@ -493,8 +491,8 @@ export const SavedSignaturesSection = ({ {isAdmin && ( onDeleteSignature(activeSharedSignature) @@ -564,7 +562,7 @@ export const SavedSignaturesSection = ({ setActiveLocalStorageIndex((prev) => @@ -580,7 +578,7 @@ export const SavedSignaturesSection = ({ /> setActiveLocalStorageIndex((prev) => @@ -616,8 +614,7 @@ export const SavedSignaturesSection = ({ onUseSignature(activeLocalStorageSignature) @@ -632,8 +629,8 @@ export const SavedSignaturesSection = ({ onDeleteSignature(activeLocalStorageSignature) diff --git a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx index de7a410b0d..648ada1c47 100644 --- a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx @@ -2,16 +2,15 @@ import { useTranslation } from "react-i18next"; import { Stack, - Button, Text, Alert, - SegmentedControl, Divider, - ActionIcon, Tooltip, Group, Box, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; import { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; import { useSignature } from "@app/contexts/SignatureContext"; import { useViewer } from "@app/contexts/ViewerContext"; @@ -437,9 +436,9 @@ const SignSettings = ({ const button = ( ) : ( - + } > - - - {translate("mode.resume", "Resume placement")} - - + {translate("mode.resume", "Resume placement")} + ) ) : null; @@ -1185,7 +1168,7 @@ const SignSettings = ({ onChange={(value) => handleSignatureSourceChange(value as SignatureSource) } - data={sourceOptions} + options={sourceOptions} /> )} {renderSignatureBuilder()} @@ -1228,7 +1211,7 @@ const SignSettings = ({ /> {onSave && ( - )} diff --git a/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.tsx b/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.tsx index d951951421..10bd556df9 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.tsx @@ -1,31 +1,38 @@ import React from "react"; -import { ActionIcon } from "@mantine/core"; -import type { MantineSize } from "@mantine/core"; import { useTranslation } from "react-i18next"; import StarRoundedIcon from "@mui/icons-material/StarRounded"; import StarBorderRoundedIcon from "@mui/icons-material/StarBorderRounded"; - +import { ActionIcon } from "@app/ui/ActionIcon"; +import type { ActionIconSize } from "@app/ui/ActionIcon"; +type FavoriteStarSize = "xs" | ActionIconSize; +const SIZE_MAP: Record = { + xs: "sm", + sm: "sm", + md: "md", + lg: "lg", + xl: "xl", +}; interface FavoriteStarProps { isFavorite: boolean; onToggle: () => void; className?: string; - size?: MantineSize; + size?: FavoriteStarSize; } const FavoriteStar: React.FC = ({ isFavorite, onToggle, className, - size = "xs", + size = "sm", }) => { const { t } = useTranslation(); return ( { e.stopPropagation(); onToggle(); diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx index e184ae6d58..efbe3bb2cb 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx @@ -1,5 +1,6 @@ import React, { memo } from "react"; -import { Button, Badge } from "@mantine/core"; +import { Badge } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { Tooltip } from "@app/components/shared/Tooltip"; import { ToolIcon } from "@app/components/shared/ToolIcon"; @@ -143,84 +144,83 @@ const ToolButton: React.FC = ({ ); - + const buttonIcon = ( + + ); const buttonContent = ( - <> - +
-
+ {tool.versionStatus === "alpha" && ( + + {t("toolPanel.alpha", "Alpha")} + + )} + {typeof badgeCount === "number" && badgeCount > 0 && ( + + {badgeCount} + + )} + {usesCloud && !visuallyUnavailable && } +
+ {showDescription && tool.description && ( + + {tool.description} + + )} + {matchedSynonym && ( + - - {tool.versionStatus === "alpha" && ( - - {t("toolPanel.alpha", "Alpha")} - - )} - {typeof badgeCount === "number" && badgeCount > 0 && ( - - {badgeCount} - - )} - {usesCloud && !visuallyUnavailable && } -
- {showDescription && tool.description && ( - - {tool.description} - - )} - {matchedSynonym && ( - - {matchedSynonym} - - )} -
- + {matchedSynonym} +
+ )} + ); const handleExternalClick = (e: React.MouseEvent) => { @@ -234,24 +234,21 @@ const ToolButton: React.FC = ({ const buttonElement = navProps ? ( // For internal tools with URLs, render Button as an anchor for proper link behavior diff --git a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx index 40bd9d3045..e9ae53183e 100644 --- a/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/BookmarkSidebar.tsx @@ -3,16 +3,15 @@ import { Box, ScrollArea, Text, - ActionIcon, Loader, Stack, TextInput, NumberInput, - Button, Group, - UnstyledButton, } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useViewer } from "@app/contexts/ViewerContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useFileContext } from "@app/contexts/FileContext"; @@ -20,6 +19,7 @@ import { isStirlingFile, type FileId } from "@app/types/fileContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import apiClient from "@app/services/apiClient"; import { PdfBookmarkObject, PdfActionType } from "@embedpdf/models"; +import { useTranslation } from "react-i18next"; import BookmarksIcon from "@mui/icons-material/BookmarksRounded"; import "@app/components/viewer/SidebarBase.css"; import "@app/components/viewer/BookmarkSidebar.css"; @@ -93,6 +93,7 @@ export const BookmarkSidebar = ({ getScrollState, toggleBookmarkSidebar, } = useViewer(); + const { t } = useTranslation(); const { handleToolSelectForced } = useToolWorkflow(); const { selectors, actions: fileActions } = useFileContext(); const [expanded, setExpanded] = useState>({}); @@ -475,6 +476,34 @@ export const BookmarkSidebar = ({ })); }; + const expandAll = useCallback(() => { + const allExpanded: Record = {}; + const expandRecursive = (nodes: BookmarkNode[]) => { + nodes.forEach((node) => { + if (node.children && node.children.length > 0) { + allExpanded[node.id] = true; + expandRecursive(node.children as BookmarkNode[]); + } + }); + }; + expandRecursive(bookmarksWithIds); + setExpanded(allExpanded); + }, [bookmarksWithIds]); + + const collapseAll = useCallback(() => { + const allCollapsed: Record = {}; + const collapseRecursive = (nodes: BookmarkNode[]) => { + nodes.forEach((node) => { + if (node.children && node.children.length > 0) { + allCollapsed[node.id] = false; + collapseRecursive(node.children as BookmarkNode[]); + } + }); + }; + collapseRecursive(bookmarksWithIds); + setExpanded(allCollapsed); + }, [bookmarksWithIds]); + const handleBookmarkClick = ( bookmark: PdfBookmarkObject, event: React.MouseEvent, @@ -569,9 +598,10 @@ export const BookmarkSidebar = ({ > {hasChildren ? ( { event.stopPropagation(); toggleNode(node.id); @@ -654,12 +684,54 @@ export const BookmarkSidebar = ({ + {bookmarkSupport && bookmarksWithIds.length > 0 && ( + <> + {Object.values(expanded).some((val) => val === false) ? ( + + + + ) : ( + + + + )} + + )} @@ -706,7 +778,7 @@ export const BookmarkSidebar = ({ {currentError} - @@ -726,7 +798,6 @@ export const BookmarkSidebar = ({ )} - {showEmptyState && !isAddingBookmark && ( @@ -862,9 +930,12 @@ export const BookmarkSidebar = ({ flexShrink: 0, }} > - @@ -883,7 +954,7 @@ export const BookmarkSidebar = ({ Need to reorder or nest? Open the Bookmark Editor - + )} diff --git a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx index cc470b3ea6..e25f9671d9 100644 --- a/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/CommentsSidebar.tsx @@ -5,16 +5,15 @@ import { Text, Textarea, Stack, - ActionIcon, Group, Tooltip, TextInput, Menu, Modal, - Button, - UnstyledButton, } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import DeleteIcon from "@mui/icons-material/Delete"; import CheckIcon from "@mui/icons-material/CheckRounded"; import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; @@ -120,7 +119,6 @@ function getCommentDisplayContent(entry: { /** Placeholder authors we never show; use current user's name from context instead. */ const PLACEHOLDER_AUTHORS = new Set(["Guest", "Digital Signature", ""]); - function getAuthorName( obj: Pick, currentDisplayName: string, @@ -188,7 +186,6 @@ function getIconByType(type: number | undefined): string { if (type === 15) return "edit"; return "comment"; } - function isCommentAnnotation(ann: PdfAnnotationObject): boolean { const customData = getStirlingAnnotationMetadata(ann).customData; const toolId = customData?.toolId ?? customData?.annotationToolId; @@ -252,7 +249,6 @@ function getAnnotationToolId(ann: PdfAnnotationObject): string { const customData = getStirlingAnnotationMetadata(ann).customData; return customData?.toolId ?? customData?.annotationToolId ?? ""; } - function getAnnotationTypeLabel( ann: PdfAnnotationObject, t: (key: string, fallback: string) => string, @@ -285,7 +281,6 @@ function getAnnotationTypeLabel( if (type === 1) return t("viewer.comments.typeComment", "Comment"); return t("viewer.comments.typeComment", "Comment"); } - function AnnotationTypeIcon({ ann }: { ann: PdfAnnotationObject }) { const toolId = getAnnotationToolId(ann); const iconName = TOOL_ICON_MAP[toolId] ?? getIconByType(ann?.type); @@ -714,9 +709,10 @@ export function CommentsSidebar({ @@ -727,7 +723,15 @@ export function CommentsSidebar({ - + @@ -746,11 +750,14 @@ export function CommentsSidebar({ )} {toggleCommentsSidebar && ( @@ -775,9 +782,9 @@ export function CommentsSidebar({ {isPlacingComment ? ( ) : ( @@ -943,9 +942,13 @@ export function CommentsSidebar({ )} > handleLocateAnnotation(pageIndex, ann) } @@ -962,9 +965,13 @@ export function CommentsSidebar({ )} > { handleSendMainComment( pageIndex, @@ -1043,9 +1053,7 @@ export function CommentsSidebar({ }} disabled={!(draft ?? "").trim()} > - + @@ -1112,7 +1120,9 @@ export function CommentsSidebar({ > {canEditReply && !isEditingReply ? ( - { setEditingReplyKey( @@ -1135,7 +1145,7 @@ export function CommentsSidebar({ "Edit", )} - + ) : null} {rTimestamp ? ( @@ -1178,9 +1188,12 @@ export function CommentsSidebar({ )} > handleSaveReplyEdit( replyEditKey, @@ -1196,7 +1209,6 @@ export function CommentsSidebar({ @@ -1252,13 +1264,12 @@ export function CommentsSidebar({ )} > handleSendReply( pageIndex, @@ -1268,9 +1279,7 @@ export function CommentsSidebar({ } disabled={!replyDraft.trim()} > - +
@@ -1305,10 +1314,10 @@ export function CommentsSidebar({ )} - - - diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 24bc42b02e..555f93df9d 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -6,7 +6,9 @@ import React, { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { Box, Center, Text, ActionIcon, Button, Stack } from "@mantine/core"; +import { Box, Center, Text, Stack } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import CloseIcon from "@mui/icons-material/Close"; import LockIcon from "@mui/icons-material/Lock"; @@ -1225,15 +1227,14 @@ const EmbedPdfViewerContent = ({ {/* Close Button - Only show in preview mode */} {onClose && previewFile && ( @@ -1261,7 +1262,6 @@ const EmbedPdfViewerContent = ({ )} - + {/* Navigate / Open */} - + ); }, diff --git a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx index 1b54fa1680..ed4a2e2263 100644 --- a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; -import { Box, Button, Center, Stack, Text } from "@mantine/core"; +import { Box, Center, Stack, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import ArticleIcon from "@mui/icons-material/Article"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; @@ -81,8 +82,8 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) { {isConvertAvailable && ( +
)} {/* Previous Page Button */} - +
{/* Page Input */} {/* Next Page Button */} - +
{/* Last Page Button */} {!isPhone && ( - + )} {/* Dual Page Toggle */} @@ -261,21 +246,24 @@ export function PdfViewerToolbar({ position="top" arrow > - + )} @@ -292,11 +280,9 @@ export function PdfViewerToolbar({ position="top" arrow > - + )} @@ -323,9 +309,7 @@ export function PdfViewerToolbar({ style={{ marginLeft: 16, flexShrink: 0 }} > @@ -346,9 +330,7 @@ export function PdfViewerToolbar({ label={null} /> @@ -376,9 +358,7 @@ export function PdfViewerToolbar({ > @@ -172,14 +162,11 @@ function RedactionSelectionMenuInner({ position="top" > diff --git a/frontend/editor/src/core/components/viewer/SearchInterface.tsx b/frontend/editor/src/core/components/viewer/SearchInterface.tsx index f111837860..2c93aff17c 100644 --- a/frontend/editor/src/core/components/viewer/SearchInterface.tsx +++ b/frontend/editor/src/core/components/viewer/SearchInterface.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect, useRef } from "react"; -import { Box, TextInput, ActionIcon, Text, Group } from "@mantine/core"; +import { Box, TextInput, Text, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { LocalIcon } from "@app/components/shared/LocalIcon"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { ViewerContext } from "@app/contexts/ViewerContext"; interface SearchInterfaceProps { @@ -200,7 +201,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { {t("search.title", "Search PDF")} @@ -275,7 +276,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { diff --git a/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx b/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx index 94919eb4f2..3fb62df970 100644 --- a/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx +++ b/frontend/editor/src/core/components/viewer/ThumbnailSidebar.tsx @@ -1,5 +1,7 @@ import { useState, useEffect, useRef } from "react"; -import { Box, ScrollArea, Text, ActionIcon } from "@mantine/core"; +import { Box, ScrollArea, Text } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { useTranslation } from "react-i18next"; import { useViewer } from "@app/contexts/ViewerContext"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import LocalIcon from "@app/components/shared/LocalIcon"; @@ -17,6 +19,7 @@ export function ThumbnailSidebar({ onToggle, activeFileId, }: ThumbnailSidebarProps) { + const { t } = useTranslation(); const { getScrollState, scrollActions, getThumbnailAPI } = useViewer(); const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({}); @@ -182,11 +185,14 @@ export function ThumbnailSidebar({ diff --git a/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx b/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx index 78888f4188..32b4b1ce11 100644 --- a/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx +++ b/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from "react"; -import { ActionIcon } from "@mantine/core"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; import { ViewerContext } from "@app/contexts/ViewerContext"; import { useSignature } from "@app/contexts/SignatureContext"; @@ -186,12 +186,15 @@ export default function ViewerAnnotationControls({ portalTarget={document.body} >
+ ); } diff --git a/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.tsx b/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.tsx index e1f169b85b..2d73a0f558 100644 --- a/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.tsx +++ b/frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.tsx @@ -1,4 +1,4 @@ -import { Button } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import { useTranslation } from "react-i18next"; @@ -13,9 +13,9 @@ export function NonPdfBanner({ onConvertToPdf }: NonPdfBannerProps) { return ( {!deletePromptIsServerOnly && ( {!deletePromptIsServerOnly && ( - diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index 2e5d2aa08d..c1b4f572bc 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -38,6 +38,7 @@ import { useFileHandler } from "@app/hooks/useFileHandler"; import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel"; import type { FileSidebarProps } from "@app/components/shared/FileSidebar"; +import { Button } from "@app/ui/Button"; import "@app/pages/HomePage.css"; const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed"; @@ -404,7 +405,8 @@ export default function HomePage() {
)}
- + {toolAvailability["automate"]?.available !== false && ( - + )} - - +
{/* Back button - floating top left */} - - + {/* Video feed - fills available space */} {/* Capture button */} - + @@ -1291,15 +1289,14 @@ export default function MobileScannerPage() { align="center" style={{ maxWidth: "500px", margin: "0 auto" }} > - - + - + @@ -1383,23 +1379,22 @@ export default function MobileScannerPage() { > - - + - + @@ -1413,17 +1408,22 @@ export default function MobileScannerPage() { ) - - +
{ }); const attachments = doc.getAttachments(); expect(attachments.length).toBeGreaterThan(0); - expect(attachments.map((a) => a.name)).toContain(attachmentName); + expect(attachments.map((a: { name?: string }) => a.name)).toContain( + attachmentName, + ); }); test("Add bookmark from viewer sidebar adds the bookmark to the produced PDF outline", async ({ diff --git a/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts b/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts index f72d642a8e..9b66fec1d9 100644 --- a/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/cert-sign-wizard.spec.ts @@ -59,7 +59,7 @@ async function mockHardwareEndpoints(page: Page) { } test.describe("CertSign tool - certificate source model", () => { - test("renders, accepts a PDF, and exposes the Upload source", async ({ + test("renders, accepts a PDF, and defaults to upload when no other sources exist", async ({ page, }) => { await page.route("**/api/v1/security/cert-sign", (route) => @@ -76,10 +76,14 @@ test.describe("CertSign tool - certificate source model", () => { await uploadFiles(page, SAMPLE_PDF); await expect(page).toHaveURL(/\/cert-sign/); - // Source step always offers "Upload" (the former "Manual" mode). + // With no server/hardware sources, the picker collapses to a hint and the + // flow proceeds in the default MANUAL (upload) mode — no lone Upload CTA. await expect( - page.getByRole("button", { name: /^upload$/i }).first(), - ).toBeAttached({ timeout: 10_000 }); + page.getByText(/no other certificate sources are available/i).first(), + ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("button", { name: /^upload$/i })).toHaveCount( + 0, + ); }); test("does NOT offer 'This device' when not running as desktop", async ({ @@ -89,9 +93,10 @@ test.describe("CertSign tool - certificate source model", () => { await page.waitForLoadState("domcontentloaded"); await uploadFiles(page, SAMPLE_PDF); + // No alternative sources: the picker is a hint, and hardware is never offered. await expect( - page.getByRole("button", { name: /^upload$/i }).first(), - ).toBeAttached({ timeout: 10_000 }); + page.getByText(/no other certificate sources are available/i).first(), + ).toBeVisible({ timeout: 10_000 }); await expect( page.getByRole("button", { name: /this device/i }), ).toHaveCount(0); diff --git a/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts b/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts index aeb6eb0de2..f24e13f312 100644 --- a/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts @@ -93,7 +93,11 @@ test.describe("PageEditor (multitool) rotation save", () => { // 4. The exported /Rotate must match what the editor showed: page 3 upright // (0), the untouched pages keeping their source rotation. const outDoc = await PDFDocument.load(fs.readFileSync(tmpOut)); - const outRotations = outDoc.getPages().map((p) => p.getRotation().angle); + const outRotations = outDoc + .getPages() + .map( + (p: { getRotation: () => { angle: number } }) => p.getRotation().angle, + ); fs.rmSync(tmpOut, { force: true }); expect(outRotations).toEqual([0, 90, 0, 180]); }); diff --git a/frontend/editor/src/core/tests/stubbed/watermark-tool.spec.ts b/frontend/editor/src/core/tests/stubbed/watermark-tool.spec.ts index e2d3ffcb71..c72fbadba0 100644 --- a/frontend/editor/src/core/tests/stubbed/watermark-tool.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/watermark-tool.spec.ts @@ -33,11 +33,11 @@ test.describe("Watermark tool — mode selection after upload", () => { test("post-upload UI renders mode cards or settings (whatever the chooser is)", async ({ page, }) => { - // The chooser may render as Mantine cards or buttons depending on the build. - // Either flavour is fine; what we want to catch is "post-upload watermark - // page is empty / errored". + // The chooser may render as Mantine cards, buttons, or a segmented control + // depending on the build. Either flavour is fine; what we want to catch is + // "post-upload watermark page is empty / errored". const choices = page.locator( - '.mantine-Card-root, button:has-text("Text"), button:has-text("Image"), button:has-text("File")', + '.mantine-Card-root, .mantine-SegmentedControl-root label, button:has-text("Text"), button:has-text("Image"), button:has-text("File")', ); await expect .poll(async () => choices.count(), { timeout: 10_000 }) diff --git a/frontend/editor/src/core/theme/mantineTheme.ts b/frontend/editor/src/core/theme/mantineTheme.ts index 061ac0e819..374c3c1a79 100644 --- a/frontend/editor/src/core/theme/mantineTheme.ts +++ b/frontend/editor/src/core/theme/mantineTheme.ts @@ -58,6 +58,23 @@ const gray: MantineColorsTuple = [ "var(--color-gray-900)", ]; +// Navy-indigo dark scale — replaces Mantine's neutral gray defaults so all +// dark-mode components (SegmentedControl, inputs, dropdowns, etc.) use the +// portal palette automatically via --mantine-color-dark-*. +// dark-0..3 = text/icon shades, dark-4..7 = surface elevations, dark-8..9 = deepest bg. +const dark: MantineColorsTuple = [ + "#c2c8e0", // dark-0 — primary text on dark bg + "#9299b0", // dark-1 — secondary text + "#6e7898", // dark-2 — muted text / icons + "#4a5282", // dark-3 — subtle text / dividers + "#1c2340", // dark-4 — elevated surface / selected bg (e.g. SegmentedControl indicator) + "#131729", // dark-5 — card / panel surface + "#0d1020", // dark-6 — toolbar / sidebar bg (e.g. SegmentedControl root) + "#090b18", // dark-7 — page background (deepest reachable surface) + "#07091a", // dark-8 + "#050714", // dark-9 +]; + export const mantineTheme = createTheme({ // Primary color primaryColor: "primary", @@ -68,6 +85,7 @@ export const mantineTheme = createTheme({ green, yellow, gray, + dark, }, // Spacing system - uses CSS variables diff --git a/frontend/editor/src/core/tokens/tokens.css b/frontend/editor/src/core/tokens/tokens.css index c8ba2171a0..189b88cb8e 100644 --- a/frontend/editor/src/core/tokens/tokens.css +++ b/frontend/editor/src/core/tokens/tokens.css @@ -117,6 +117,8 @@ --color-toggle-off: #cbd5e1; --color-badge-red: #ef4444; + /* Segmented control — semantic tokens so dark mode can decouple from blue surfaces */ + /* Shadows */ --shadow-sm: inset 0 0 0 1px #e3e8ee; --shadow-md: inset 0 0 0 1px #e3e8ee, 0 1px 2px rgba(15, 23, 42, 0.04); @@ -241,6 +243,8 @@ --color-toggle-off: #3d4f6a; + /* Segmented control — blue-toned dark to match app surface colors */ + --shadow-sm: inset 0 0 0 1px #283248; --shadow-md: inset 0 0 0 1px #283248, 0 1px 3px rgba(0, 0, 0, 0.3); --shadow-lg: inset 0 0 0 1px #283248, 0 8px 24px rgba(0, 0, 0, 0.4); @@ -405,6 +409,15 @@ } } +/* Default transition set (avoids text-colour transitions); not !important so components can override. */ +*, +*::before, +*::after { + transition-property: + background, background-color, border, border-color, box-shadow, opacity, + transform, fill, stroke, outline-color; +} + /* Visually-hidden utility for content meant only for screen readers. */ .sr-only { position: absolute; diff --git a/frontend/editor/src/core/tools/AddStamp.tsx b/frontend/editor/src/core/tools/AddStamp.tsx index 9a7422188e..6070ae5e89 100644 --- a/frontend/editor/src/core/tools/AddStamp.tsx +++ b/frontend/editor/src/core/tools/AddStamp.tsx @@ -126,7 +126,6 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }, ]} disabled={endpointLoading} - buttonClassName={styles.modeToggleButton} textClassName={styles.modeToggleButtonText} /> )} diff --git a/frontend/editor/src/core/tools/Compare.tsx b/frontend/editor/src/core/tools/Compare.tsx index 81cf263ded..b555ecfb9b 100644 --- a/frontend/editor/src/core/tools/Compare.tsx +++ b/frontend/editor/src/core/tools/Compare.tsx @@ -2,15 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import CompareRoundedIcon from "@mui/icons-material/CompareRounded"; import CloseIcon from "@mui/icons-material/Close"; -import { - Box, - Group, - Stack, - Text, - Button, - Modal, - ActionIcon, -} from "@mantine/core"; +import { Box, Group, Stack, Text, Modal } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import SwapVertRoundedIcon from "@mui/icons-material/SwapVertRounded"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool"; @@ -33,7 +27,7 @@ import type { CompareWorkbenchData } from "@app/types/compare"; import { getDefaultWorkbench } from "@app/types/workbench"; import { truncateCenter } from "@app/utils/textUtils"; import { - FileSelectorPicker, + FileSelectorPicker as PopoverFileSelector, FileSelectorResult, } from "@app/components/shared/FileSelectorPicker"; import "@app/components/tools/compare/compareView.css"; @@ -373,9 +367,14 @@ const Compare = (props: BaseToolProps) => { }} > clearSlot(role)} aria-label={t("compare.clearSlot", "Remove file")} > @@ -421,7 +420,7 @@ const Compare = (props: BaseToolProps) => { data-slot-state="empty" style={{ width: "100%" }} > - {
+ )}
@@ -543,13 +541,12 @@ const Compare = (props: BaseToolProps) => { {idx < CHAPTERS.length - 1 && }
))} diff --git a/frontend/editor/src/core/tools/Merge.tsx b/frontend/editor/src/core/tools/Merge.tsx index 9138dfa4f0..ae2bed26fe 100644 --- a/frontend/editor/src/core/tools/Merge.tsx +++ b/frontend/editor/src/core/tools/Merge.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Stack, Text } from "@mantine/core"; +import { Stack, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; import MergeSettings from "@app/components/tools/merge/MergeSettings"; import MergeFileSorter from "@app/components/tools/merge/MergeFileSorter"; @@ -181,8 +182,8 @@ const Merge = (props: BaseToolProps) => { )} - - - {filterOptions.map((f) => ( - + + {filterOptions.map((f) => { + const active = filters.includes(f.key); + return ( + + setFilters((prev) => + prev.includes(f.key) + ? prev.filter((k) => k !== f.key) + : [...prev, f.key], + ) + } + > {f.label} - ))} - - + ); + })} + {controller.loading && items.length === 0 ? (
diff --git a/frontend/editor/src/core/tools/annotate/AnnotationPanel.tsx b/frontend/editor/src/core/tools/annotate/AnnotationPanel.tsx index 17f2e028c0..6986f4d7e4 100644 --- a/frontend/editor/src/core/tools/annotate/AnnotationPanel.tsx +++ b/frontend/editor/src/core/tools/annotate/AnnotationPanel.tsx @@ -4,17 +4,17 @@ import { useTranslation } from "react-i18next"; import { Text, Group, - ActionIcon, Stack, Slider, Box, Tooltip as MantineTooltip, - Button, Tooltip, Paper, Menu, Modal, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import LocalIcon from "@app/components/shared/LocalIcon"; import { ColorPicker, @@ -365,12 +365,10 @@ export function AnnotationPanel(props: AnnotationPanelProps) { activateAnnotationTool(tool.id)} disabled={!annotationsVisible} aria-label={tool.label} @@ -641,9 +639,12 @@ export function AnnotationPanel(props: AnnotationPanelProps) { setTextAlignment("left")} size="md" + aria-label={t("annotation.alignLeft", "Align left")} > setTextAlignment("center")} size="md" + aria-label={t("annotation.alignCenter", "Align center")} > setTextAlignment("right")} size="md" + aria-label={t("annotation.alignRight", "Align right")} > void handleApplyChangesClick()} + style={{ marginTop: "0.75rem" }} > {t("annotation.saveChanges", "Save Changes")} @@ -1324,14 +1318,13 @@ export function AnnotationPanel(props: AnnotationPanelProps) { diff --git a/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx b/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx index d6186af566..da8cfc785a 100644 --- a/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx +++ b/frontend/editor/src/core/tools/formFill/FormSaveBar.tsx @@ -10,20 +10,14 @@ * save UX that users expect from browser PDF viewers. */ import React, { useCallback, useState } from "react"; -import { - Stack, - Group, - Text, - Button, - Transition, - CloseButton, - Paper, - Badge, -} from "@mantine/core"; +import { Stack, Group, Text, Transition, Paper, Badge } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import DownloadIcon from "@mui/icons-material/Download"; import SaveIcon from "@mui/icons-material/Save"; import EditNoteIcon from "@mui/icons-material/EditNote"; +import CloseIcon from "@mui/icons-material/Close"; import { useFormFill } from "@app/tools/formFill/FormFillContext"; import { downloadFileWithPolicy } from "@app/services/exportWithPolicy"; @@ -160,37 +154,36 @@ export function FormSaveBar({ - setDismissed(true)} aria-label={t("viewer.formBar.dismiss", "Dismiss")} - /> + > + + {isDirty && ( diff --git a/frontend/editor/src/core/ui/ActionIcon.css b/frontend/editor/src/core/ui/ActionIcon.css new file mode 100644 index 0000000000..1a56a484e2 --- /dev/null +++ b/frontend/editor/src/core/ui/ActionIcon.css @@ -0,0 +1,21 @@ +/* Mantine ActionIcon-backed — colour driven by the shared accent palette (accents.css). */ +@import "./accents.css"; + +/* ---- shape ---- */ +.sui-ai--circle.mantine-ActionIcon-root { + --ai-radius: 50%; +} +.sui-ai--pill.mantine-ActionIcon-root { + --ai-radius: 9999px; +} + +/* ---- gradient accents: brighten on hover instead of a hard bg swap ---- */ +.sui-acc-ai.sui-ai--primary:not([data-disabled]):hover, +.sui-acc-premium.sui-ai--primary:not([data-disabled]):hover { + filter: brightness(1.06); +} + +/* no-hover: !important to beat Mantine's own :hover background rule. */ +.sui-ai--no-hover:not([data-disabled]):hover { + background: var(--ai-bg, transparent) !important; +} diff --git a/frontend/editor/src/core/ui/ActionIcon.stories.tsx b/frontend/editor/src/core/ui/ActionIcon.stories.tsx new file mode 100644 index 0000000000..a4df1359dc --- /dev/null +++ b/frontend/editor/src/core/ui/ActionIcon.stories.tsx @@ -0,0 +1,132 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ActionIcon } from "@app/ui/ActionIcon"; + +const Plus = () => ( + + + +); +const Trash = () => ( + + + +); + +const ACCENTS = [ + "default", + "neutral", + "brand", + "ai", + "premium", + "danger", + "success", + "warning", +] as const; + +const meta: Meta = { + title: "Primitives/ActionIcon", + component: ActionIcon, + parameters: { layout: "centered" }, + args: { + variant: "primary", + accent: "default", + size: "md", + "aria-label": "Add", + }, + argTypes: { + variant: { + control: "inline-radio", + options: ["primary", "secondary", "tertiary", "quiet"], + }, + accent: { control: "inline-radio", options: ACCENTS }, + size: { control: "inline-radio", options: ["sm", "md", "lg", "xl"] }, + shape: { control: "inline-radio", options: ["default", "circle", "pill"] }, + }, +}; +export default meta; +type Story = StoryObj; + +const Wrap = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const Playground: Story = { + render: (args) => ( + + + + ), +}; + +/** The three variants × every accent. */ +export const Accents: Story = { + render: () => ( +
+ {(["primary", "secondary", "tertiary"] as const).flatMap((variant) => + ACCENTS.map((accent) => ( + + + + )), + )} +
+ ), +}; + +/** Square at every size; the icon scales with `1em`. */ +export const Sizes: Story = { + render: () => ( + + {(["sm", "md", "lg", "xl"] as const).map((size) => ( + + + + ))} + + ), +}; + +export const Shapes: Story = { + render: () => ( + + + + + + + + + + + + ), +}; diff --git a/frontend/editor/src/core/ui/ActionIcon.tsx b/frontend/editor/src/core/ui/ActionIcon.tsx new file mode 100644 index 0000000000..44584be569 --- /dev/null +++ b/frontend/editor/src/core/ui/ActionIcon.tsx @@ -0,0 +1,140 @@ +import { ActionIcon as MantineActionIcon } from "@mantine/core"; +import { forwardRef } from "react"; +import type { + ComponentPropsWithoutRef, + CSSProperties, + ElementType, + ReactNode, +} from "react"; +import { CONTROL_HEIGHT } from "@app/ui/controlSizes"; +import "@app/ui/ActionIcon.css"; + +/** Icon-only button (square) — same variant/accent dials as Button. */ +export type ActionIconVariant = "primary" | "secondary" | "tertiary" | "quiet"; +export type ActionIconAccent = + | "default" + | "neutral" + | "brand" + | "ai" + | "premium" + | "danger" + | "success" + | "warning"; +export type ActionIconSize = "sm" | "md" | "lg" | "xl"; +export type ActionIconShape = "default" | "circle" | "pill"; + +type ActionIconOwnProps = { + variant?: ActionIconVariant; + accent?: ActionIconAccent; + size?: ActionIconSize; + shape?: ActionIconShape; + /** Required — an icon-only control must have an accessible name. */ + "aria-label": string; + loading?: boolean; + /** false = no hover background change. */ + hover?: boolean; + /** Polymorphic root element (e.g. `"a"` or a router Link). */ + as?: ElementType; + style?: CSSProperties; + /** The icon. */ + children?: ReactNode; +}; + +export type ActionIconProps = ActionIconOwnProps & + Omit< + ComponentPropsWithoutRef<"button">, + keyof ActionIconOwnProps | "color" + > & { + href?: string; + target?: string; + rel?: string; + }; + +const MANTINE_VARIANT: Record = { + primary: "filled", + secondary: "outline", + tertiary: "subtle", + quiet: "subtle", +}; + +export const ActionIcon = forwardRef( + function ActionIcon( + { + variant = "primary", + accent = "default", + size = "md", + shape = "default", + loading = false, + hover = true, + as, + disabled, + className, + style, + children, + ...rest + }, + ref, + ) { + const classes = [ + "sui-ai", + `sui-acc-${accent}`, + `sui-ai--${variant}`, + shape !== "default" ? `sui-ai--${shape}` : "", + !hover ? "sui-ai--no-hover" : "", + className ?? "", + ] + .filter(Boolean) + .join(" "); + + // Accent palette → Mantine ActionIcon vars (inline to win). --ai-bd is a full `border` shorthand. + const accentVars = + variant === "primary" + ? { + "--ai-bg": "var(--_solid)", + "--ai-hover": "var(--_solid-hover)", + "--ai-color": "var(--_on)", + "--ai-bd": "1px solid transparent", + } + : variant === "quiet" + ? { + "--ai-bg": "transparent", + "--ai-hover": "transparent", + "--ai-color": "var(--_text)", + "--ai-hover-color": "var(--color-text-1)", + "--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", + }; + + // Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing. + const Comp = MantineActionIcon as ElementType; + + return ( + + {children} + + ); + }, +); diff --git a/frontend/editor/src/proprietary/ui/Avatar.css b/frontend/editor/src/core/ui/Avatar.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Avatar.css rename to frontend/editor/src/core/ui/Avatar.css diff --git a/frontend/editor/src/proprietary/ui/Avatar.stories.tsx b/frontend/editor/src/core/ui/Avatar.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Avatar.stories.tsx rename to frontend/editor/src/core/ui/Avatar.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/Avatar.tsx b/frontend/editor/src/core/ui/Avatar.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Avatar.tsx rename to frontend/editor/src/core/ui/Avatar.tsx diff --git a/frontend/editor/src/proprietary/ui/Banner.css b/frontend/editor/src/core/ui/Banner.css similarity index 85% rename from frontend/editor/src/proprietary/ui/Banner.css rename to frontend/editor/src/core/ui/Banner.css index 7d249295b4..937905a915 100644 --- a/frontend/editor/src/proprietary/ui/Banner.css +++ b/frontend/editor/src/core/ui/Banner.css @@ -76,19 +76,10 @@ align-items: center; } +/* Sizing/background/hover are owned by the ActionIcon; this just keeps it from + * stretching and sizes the × glyph. */ .sui-banner__close { flex: 0 0 auto; - width: 1.5rem; - height: 1.5rem; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - color: var(--color-text-4); - font-size: 1rem; + font-size: 1.125rem; line-height: 1; } -.sui-banner__close:hover { - background: rgba(0, 0, 0, 0.06); - color: var(--color-text-1); -} diff --git a/frontend/editor/src/proprietary/ui/Banner.stories.tsx b/frontend/editor/src/core/ui/Banner.stories.tsx similarity index 96% rename from frontend/editor/src/proprietary/ui/Banner.stories.tsx rename to frontend/editor/src/core/ui/Banner.stories.tsx index c6b8ddfd1e..891cf66b78 100644 --- a/frontend/editor/src/proprietary/ui/Banner.stories.tsx +++ b/frontend/editor/src/core/ui/Banner.stories.tsx @@ -39,7 +39,7 @@ export const WithAction: Story = { title: "Approaching cap", description: "389k of 500k docs processed.", action: ( - ), diff --git a/frontend/editor/src/proprietary/ui/Banner.tsx b/frontend/editor/src/core/ui/Banner.tsx similarity index 71% rename from frontend/editor/src/proprietary/ui/Banner.tsx rename to frontend/editor/src/core/ui/Banner.tsx index b7db925740..ca89f57aab 100644 --- a/frontend/editor/src/proprietary/ui/Banner.tsx +++ b/frontend/editor/src/core/ui/Banner.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { ActionIcon } from "@app/ui/ActionIcon"; import "@app/ui/Banner.css"; export type BannerTone = "info" | "success" | "warning" | "danger" | "neutral"; @@ -7,21 +8,15 @@ export interface BannerProps { tone?: BannerTone; title?: ReactNode; description?: ReactNode; - /** Right-aligned action — typically a button. */ action?: ReactNode; - /** When set, shows an × button that calls this handler. */ onDismiss?: () => void; - /** Optional leading icon (caller supplies — keeps the primitive icon-set-agnostic). */ + /** Caller-supplied icon keeps this component icon-set-agnostic. */ icon?: ReactNode; className?: string; children?: ReactNode; } -/** - * Inline alert. Use `tone` to convey severity; pair with `action` for - * "Approaching cap → Upgrade" style flows. Use `Toast` (separate primitive) - * for transient/dismissible notifications layered over the UI. - */ +/** Inline alert. Use Toast for transient overlay notifications instead. */ export function Banner({ tone = "info", title, @@ -51,14 +46,17 @@ export function Banner({ {action &&
{action}
} {onDismiss && ( - + × +
)} ); diff --git a/frontend/editor/src/core/ui/Button.css b/frontend/editor/src/core/ui/Button.css new file mode 100644 index 0000000000..5592711979 --- /dev/null +++ b/frontend/editor/src/core/ui/Button.css @@ -0,0 +1,41 @@ +/* Mantine-backed Button — colour driven by the shared accent palette (accents.css). */ +@import "./accents.css"; + +/* ---- icon-only: square, no horizontal padding ---- */ +.sui-btn--icon.mantine-Button-root { + --button-padding-x: 0; + width: var(--button-height); + min-width: var(--button-height); +} +/* Zero the section margin / hide the empty label so the lone icon centres. */ +.sui-btn--icon .mantine-Button-section { + margin-inline: 0; +} +.sui-btn--icon .mantine-Button-label { + display: none; +} + +/* ---- shape ---- */ +.sui-btn--circle.mantine-Button-root { + --button-radius: 50%; +} +.sui-btn--pill.mantine-Button-root { + --button-radius: 9999px; +} + +/* ---- gradient accents: brighten on hover instead of a hard bg swap ---- */ +/* [data-disabled] too: :disabled never matches polymorphic (as="a") roots. */ +.sui-acc-ai.sui-btn--primary:not(:disabled):not([data-disabled]):hover, +.sui-acc-premium.sui-btn--primary:not(:disabled):not([data-disabled]):hover { + filter: brightness(1.06); +} + +/* ---- overflow: default wraps the label; Mantine's label is nowrap ---- */ +.sui-btn--wrap .mantine-Button-label { + white-space: normal; +} + +/* no-hover: !important to beat Mantine's own :hover background rule. */ +.sui-btn--no-hover:not(:disabled):not([data-disabled]):hover { + background: var(--button-bg, transparent) !important; +} diff --git a/frontend/editor/src/core/ui/Button.stories.tsx b/frontend/editor/src/core/ui/Button.stories.tsx new file mode 100644 index 0000000000..2def9e099f --- /dev/null +++ b/frontend/editor/src/core/ui/Button.stories.tsx @@ -0,0 +1,293 @@ +import type { ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { Button } from "@app/ui/Button"; + +/* tiny inline icons for the demos */ +const Plus = () => ( + + + +); +const Arrow = () => ( + + + +); +const Trash = () => ( + + + +); +const Sparkle = () => ( + + + + +); + +const meta: Meta = { + title: "Primitives/Button", + component: Button, + parameters: { layout: "centered" }, + args: { text: "Button", variant: "primary", accent: "default", size: "md" }, + argTypes: { + variant: { + control: "inline-radio", + options: ["primary", "secondary", "tertiary", "quiet"], + }, + accent: { + control: "inline-radio", + options: [ + "default", + "neutral", + "brand", + "ai", + "premium", + "danger", + "success", + "warning", + ], + }, + size: { control: "inline-radio", options: ["sm", "md", "lg", "xl"] }, + justify: { + control: "inline-radio", + options: ["center", "start", "end", "between"], + }, + shape: { control: "inline-radio", options: ["default", "circle", "pill"] }, + text: { control: "text" }, + }, +}; +export default meta; +type Story = StoryObj; + +const Wrap = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); + +/** Tweak every prop live with the controls. */ +export const Playground: Story = {}; + +/** The three fill treatments. */ +export const Variants: Story = { + render: (args) => ( + + - +
Compose document workflows from typed operations.

- - +

Agents

Wire your agent via MCP, REST, or tool definitions.

-
diff --git a/frontend/editor/src/proprietary/ui/Card.tsx b/frontend/editor/src/core/ui/Card.tsx similarity index 86% rename from frontend/editor/src/proprietary/ui/Card.tsx rename to frontend/editor/src/core/ui/Card.tsx index 27eb7aed62..dbdb1a393a 100644 --- a/frontend/editor/src/proprietary/ui/Card.tsx +++ b/frontend/editor/src/core/ui/Card.tsx @@ -1,9 +1,17 @@ import type { HTMLAttributes, ReactNode } from "react"; import "@app/ui/Card.css"; +/** Subset of the shared accent dial that has a styled strip (see Card.css). */ +export type CardAccent = + | "default" + | "premium" + | "danger" + | "success" + | "warning"; + export interface CardProps extends HTMLAttributes { /** Adds an accent strip on the left edge of the card. */ - accent?: "blue" | "purple" | "green" | "amber" | "red"; + accent?: CardAccent; /** * Padding profile. `tight` = 0.75rem, `default` = 1.125rem, `loose` = 1.5rem. * `none` removes padding so the card surface can host edge-to-edge content diff --git a/frontend/editor/src/proprietary/ui/ChatFABButton.css b/frontend/editor/src/core/ui/ChatFABButton.css similarity index 100% rename from frontend/editor/src/proprietary/ui/ChatFABButton.css rename to frontend/editor/src/core/ui/ChatFABButton.css diff --git a/frontend/editor/src/proprietary/ui/ChatFABButton.stories.tsx b/frontend/editor/src/core/ui/ChatFABButton.stories.tsx similarity index 90% rename from frontend/editor/src/proprietary/ui/ChatFABButton.stories.tsx rename to frontend/editor/src/core/ui/ChatFABButton.stories.tsx index c6986dad14..5e85738b57 100644 --- a/frontend/editor/src/proprietary/ui/ChatFABButton.stories.tsx +++ b/frontend/editor/src/core/ui/ChatFABButton.stories.tsx @@ -6,7 +6,7 @@ const meta: Meta = { component: ChatFABButton, parameters: { layout: "centered" }, argTypes: { - isLoading: { control: "boolean" }, + loading: { control: "boolean" }, showTick: { control: "boolean" }, onClick: { action: "clicked" }, }, @@ -19,7 +19,7 @@ export const Default: Story = {}; /** Agent is actively working — the logo paths animate and a green pulse dot appears. */ export const Loading: Story = { - args: { isLoading: true }, + args: { loading: true }, }; /** Agent finished while the panel was closed — tick badge pops in to signal an unread result. */ @@ -33,5 +33,5 @@ export const Tick: Story = { * In practice this transition is handled by the parent via `hasUnviewedResult`. */ export const TickWhileLoading: Story = { - args: { isLoading: true, showTick: true }, + args: { loading: true, showTick: true }, }; diff --git a/frontend/editor/src/proprietary/ui/ChatFABButton.tsx b/frontend/editor/src/core/ui/ChatFABButton.tsx similarity index 84% rename from frontend/editor/src/proprietary/ui/ChatFABButton.tsx rename to frontend/editor/src/core/ui/ChatFABButton.tsx index 8ee8e74d7a..5ba3c8113b 100644 --- a/frontend/editor/src/proprietary/ui/ChatFABButton.tsx +++ b/frontend/editor/src/core/ui/ChatFABButton.tsx @@ -2,21 +2,21 @@ import type { ButtonHTMLAttributes } from "react"; import "@app/ui/ChatFABButton.css"; export interface ChatFABButtonProps extends ButtonHTMLAttributes { - /** Shows a green pulse dot to indicate the agent is actively working. */ - isLoading?: boolean; - /** Shows a green tick badge to indicate an unread result is waiting. */ + /** Green pulse dot — agent is working. */ + loading?: boolean; + /** Green tick badge — unread result waiting. */ showTick?: boolean; } export function ChatFABButton({ - isLoading = false, + loading = false, showTick = false, className, ...rest }: ChatFABButtonProps) { const classes = [ "chat-fab-btn", - isLoading ? "chat-fab-btn--loading" : "", + loading ? "chat-fab-btn--loading" : "", showTick ? "chat-fab-btn--tick" : "", className ?? "", ] @@ -39,7 +39,7 @@ export function ChatFABButton({ /> - {isLoading && !showTick && ( + {loading && !showTick && (
diff --git a/frontend/editor/src/proprietary/ui/Collapsible.css b/frontend/editor/src/core/ui/Collapsible.css
similarity index 100%
rename from frontend/editor/src/proprietary/ui/Collapsible.css
rename to frontend/editor/src/core/ui/Collapsible.css
diff --git a/frontend/editor/src/proprietary/ui/Collapsible.stories.tsx b/frontend/editor/src/core/ui/Collapsible.stories.tsx
similarity index 100%
rename from frontend/editor/src/proprietary/ui/Collapsible.stories.tsx
rename to frontend/editor/src/core/ui/Collapsible.stories.tsx
diff --git a/frontend/editor/src/proprietary/ui/Collapsible.tsx b/frontend/editor/src/core/ui/Collapsible.tsx
similarity index 100%
rename from frontend/editor/src/proprietary/ui/Collapsible.tsx
rename to frontend/editor/src/core/ui/Collapsible.tsx
diff --git a/frontend/editor/src/proprietary/ui/ColorInput.tsx b/frontend/editor/src/core/ui/ColorInput.tsx
similarity index 100%
rename from frontend/editor/src/proprietary/ui/ColorInput.tsx
rename to frontend/editor/src/core/ui/ColorInput.tsx
diff --git a/frontend/editor/src/proprietary/ui/DataRow.css b/frontend/editor/src/core/ui/DataRow.css
similarity index 100%
rename from frontend/editor/src/proprietary/ui/DataRow.css
rename to frontend/editor/src/core/ui/DataRow.css
diff --git a/frontend/editor/src/proprietary/ui/DataRow.stories.tsx b/frontend/editor/src/core/ui/DataRow.stories.tsx
similarity index 100%
rename from frontend/editor/src/proprietary/ui/DataRow.stories.tsx
rename to frontend/editor/src/core/ui/DataRow.stories.tsx
diff --git a/frontend/editor/src/proprietary/ui/DataRow.tsx b/frontend/editor/src/core/ui/DataRow.tsx
similarity index 100%
rename from frontend/editor/src/proprietary/ui/DataRow.tsx
rename to frontend/editor/src/core/ui/DataRow.tsx
diff --git a/frontend/editor/src/proprietary/ui/Drawer.css b/frontend/editor/src/core/ui/Drawer.css
similarity index 83%
rename from frontend/editor/src/proprietary/ui/Drawer.css
rename to frontend/editor/src/core/ui/Drawer.css
index d2b90f26a7..8972bbefc2 100644
--- a/frontend/editor/src/proprietary/ui/Drawer.css
+++ b/frontend/editor/src/core/ui/Drawer.css
@@ -75,22 +75,10 @@
   color: var(--color-text-4);
 }
 
+/* Sizing/background/hover are owned by the Button; this just keeps it from
+ * stretching. */
 .sui-drawer__close {
   flex: 0 0 auto;
-  width: 1.75rem;
-  height: 1.75rem;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  border-radius: var(--radius-sm);
-  color: var(--color-text-4);
-  transition:
-    background var(--motion-fast),
-    color var(--motion-fast);
-}
-.sui-drawer__close:hover {
-  background: var(--color-bg-hover);
-  color: var(--color-text-1);
 }
 
 .sui-drawer__body {
diff --git a/frontend/editor/src/proprietary/ui/Drawer.stories.tsx b/frontend/editor/src/core/ui/Drawer.stories.tsx
similarity index 91%
rename from frontend/editor/src/proprietary/ui/Drawer.stories.tsx
rename to frontend/editor/src/core/ui/Drawer.stories.tsx
index 3600d774a2..c1df90536b 100644
--- a/frontend/editor/src/proprietary/ui/Drawer.stories.tsx
+++ b/frontend/editor/src/core/ui/Drawer.stories.tsx
@@ -71,11 +71,11 @@ export const WithFooter: Story = {
             subtitle="COI Compliance · us-east-1"
             footer={
               <>
-                
-                
-                
+                
+                
               
             }
           >
diff --git a/frontend/editor/src/proprietary/ui/Drawer.tsx b/frontend/editor/src/core/ui/Drawer.tsx
similarity index 75%
rename from frontend/editor/src/proprietary/ui/Drawer.tsx
rename to frontend/editor/src/core/ui/Drawer.tsx
index 20279c3c59..7327b854dc 100644
--- a/frontend/editor/src/proprietary/ui/Drawer.tsx
+++ b/frontend/editor/src/core/ui/Drawer.tsx
@@ -1,6 +1,7 @@
 import { useEffect, useId, type ReactNode } from "react";
 import { createPortal } from "react-dom";
 import { FocusTrap } from "@mantine/core";
+import { Button } from "@app/ui/Button";
 import "@app/ui/Drawer.css";
 
 export type DrawerSide = "right" | "left";
@@ -10,11 +11,10 @@ export interface DrawerProps {
   open: boolean;
   onClose: () => void;
   side?: DrawerSide;
-  /** Width preset. sm=22rem, md=27.5rem, lg=36rem. */
+  /** sm=22rem, md=27.5rem, lg=36rem. */
   width?: DrawerWidth;
   title?: ReactNode;
   subtitle?: ReactNode;
-  /** Sticky footer slot below the body. */
   footer?: ReactNode;
   disableBackdropClose?: boolean;
   disableEscapeClose?: boolean;
@@ -23,12 +23,7 @@ export interface DrawerProps {
   children?: ReactNode;
 }
 
-/**
- * Side drawer — sibling to {@link Modal}, with our own brand shell. Tab focus
- * trapping, initial focus, and focus restoration on close are delegated to
- * Mantine's ; we keep the slide-in chrome, scroll lock, and ESC /
- * backdrop dismissal.
- */
+/** Tab focus trapping and restoration are delegated to Mantine's FocusTrap. */
 export function Drawer({
   open,
   onClose,
@@ -104,27 +99,31 @@ export function Drawer({
                 )}
                 {subtitle && 
{subtitle}
} - + leftSection={ + + + + + } + /> )}
{children}
diff --git a/frontend/editor/src/proprietary/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Dropdown.css rename to frontend/editor/src/core/ui/Dropdown.css diff --git a/frontend/editor/src/proprietary/ui/Dropdown.stories.tsx b/frontend/editor/src/core/ui/Dropdown.stories.tsx similarity index 90% rename from frontend/editor/src/proprietary/ui/Dropdown.stories.tsx rename to frontend/editor/src/core/ui/Dropdown.stories.tsx index 7e05785ee6..ad84a09fb1 100644 --- a/frontend/editor/src/proprietary/ui/Dropdown.stories.tsx +++ b/frontend/editor/src/core/ui/Dropdown.stories.tsx @@ -20,7 +20,7 @@ export const Basic: Story = { render: () => ( - + console.log("a")}>Item A @@ -37,7 +37,7 @@ export const WithDivider: Story = { render: () => ( - + Profile @@ -53,7 +53,7 @@ export const WithTrailingHints: Story = { render: () => ( - + Search @@ -71,7 +71,7 @@ export const AlignStart: Story = { render: () => ( - + One diff --git a/frontend/editor/src/proprietary/ui/Dropdown.tsx b/frontend/editor/src/core/ui/Dropdown.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Dropdown.tsx rename to frontend/editor/src/core/ui/Dropdown.tsx diff --git a/frontend/editor/src/proprietary/ui/EmptyState.css b/frontend/editor/src/core/ui/EmptyState.css similarity index 100% rename from frontend/editor/src/proprietary/ui/EmptyState.css rename to frontend/editor/src/core/ui/EmptyState.css diff --git a/frontend/editor/src/proprietary/ui/EmptyState.stories.tsx b/frontend/editor/src/core/ui/EmptyState.stories.tsx similarity index 90% rename from frontend/editor/src/proprietary/ui/EmptyState.stories.tsx rename to frontend/editor/src/core/ui/EmptyState.stories.tsx index ac153ec0b0..859b498c5f 100644 --- a/frontend/editor/src/proprietary/ui/EmptyState.stories.tsx +++ b/frontend/editor/src/core/ui/EmptyState.stories.tsx @@ -31,10 +31,10 @@ export const WithCTAs: Story = { "The fastest way in is forking a pre-bundled pipeline like PII Sweep or Compliance Pack.", actions: ( <> - - + ), }, diff --git a/frontend/editor/src/proprietary/ui/EmptyState.tsx b/frontend/editor/src/core/ui/EmptyState.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/EmptyState.tsx rename to frontend/editor/src/core/ui/EmptyState.tsx diff --git a/frontend/editor/src/core/ui/FilePicker.tsx b/frontend/editor/src/core/ui/FilePicker.tsx new file mode 100644 index 0000000000..3e33b20e46 --- /dev/null +++ b/frontend/editor/src/core/ui/FilePicker.tsx @@ -0,0 +1,55 @@ +import { FileButton as MantineFileButton } from "@mantine/core"; +import type { ForwardedRef, ReactNode } from "react"; +import { Button } from "@app/ui/Button"; +import type { ButtonProps } from "@app/ui/Button"; + +type TriggerButtonProps = Omit< + ButtonProps, + "onClick" | "onChange" | "children" +>; + +export interface FilePickerProps< + Multiple extends boolean = false, +> extends TriggerButtonProps { + /** Called with the picked file(s); null when the dialog is dismissed. */ + onChange: (payload: Multiple extends true ? File[] : File | null) => void; + accept?: string; + multiple?: Multiple; + /** Ref to a function that clears the current selection (Mantine resetRef). */ + resetRef?: ForwardedRef<() => void>; + name?: string; + capture?: boolean | "user" | "environment"; + /** Trigger label. */ + children?: ReactNode; +} + +/** File-picker button: a shared Button trigger over a hidden file input (Mantine FileButton). */ +export function FilePicker({ + onChange, + accept, + multiple, + resetRef, + name, + capture, + disabled, + children, + ...buttonProps +}: FilePickerProps) { + return ( + + onChange={onChange} + accept={accept} + multiple={multiple} + resetRef={resetRef} + name={name} + capture={capture} + disabled={disabled} + > + {(props) => ( + + )} + + ); +} diff --git a/frontend/editor/src/proprietary/ui/FormField.css b/frontend/editor/src/core/ui/FormField.css similarity index 100% rename from frontend/editor/src/proprietary/ui/FormField.css rename to frontend/editor/src/core/ui/FormField.css diff --git a/frontend/editor/src/proprietary/ui/FormField.tsx b/frontend/editor/src/core/ui/FormField.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/FormField.tsx rename to frontend/editor/src/core/ui/FormField.tsx diff --git a/frontend/editor/src/proprietary/ui/Forms.stories.tsx b/frontend/editor/src/core/ui/Forms.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Forms.stories.tsx rename to frontend/editor/src/core/ui/Forms.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/IconBadge.css b/frontend/editor/src/core/ui/IconBadge.css similarity index 100% rename from frontend/editor/src/proprietary/ui/IconBadge.css rename to frontend/editor/src/core/ui/IconBadge.css diff --git a/frontend/editor/src/proprietary/ui/IconBadge.stories.tsx b/frontend/editor/src/core/ui/IconBadge.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/IconBadge.stories.tsx rename to frontend/editor/src/core/ui/IconBadge.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/IconBadge.tsx b/frontend/editor/src/core/ui/IconBadge.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/IconBadge.tsx rename to frontend/editor/src/core/ui/IconBadge.tsx diff --git a/frontend/editor/src/proprietary/ui/Inline.css b/frontend/editor/src/core/ui/Inline.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Inline.css rename to frontend/editor/src/core/ui/Inline.css diff --git a/frontend/editor/src/proprietary/ui/Inline.stories.tsx b/frontend/editor/src/core/ui/Inline.stories.tsx similarity index 88% rename from frontend/editor/src/proprietary/ui/Inline.stories.tsx rename to frontend/editor/src/core/ui/Inline.stories.tsx index b6e4ed54eb..10233e1371 100644 --- a/frontend/editor/src/proprietary/ui/Inline.stories.tsx +++ b/frontend/editor/src/core/ui/Inline.stories.tsx @@ -15,9 +15,9 @@ type Story = StoryObj; export const Default: Story = { render: () => ( - - - + + + ), }; diff --git a/frontend/editor/src/proprietary/ui/Inline.tsx b/frontend/editor/src/core/ui/Inline.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Inline.tsx rename to frontend/editor/src/core/ui/Inline.tsx diff --git a/frontend/editor/src/proprietary/ui/Input.css b/frontend/editor/src/core/ui/Input.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Input.css rename to frontend/editor/src/core/ui/Input.css diff --git a/frontend/editor/src/proprietary/ui/Input.tsx b/frontend/editor/src/core/ui/Input.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Input.tsx rename to frontend/editor/src/core/ui/Input.tsx diff --git a/frontend/editor/src/proprietary/ui/ListRow.css b/frontend/editor/src/core/ui/ListRow.css similarity index 100% rename from frontend/editor/src/proprietary/ui/ListRow.css rename to frontend/editor/src/core/ui/ListRow.css diff --git a/frontend/editor/src/proprietary/ui/ListRow.stories.tsx b/frontend/editor/src/core/ui/ListRow.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/ListRow.stories.tsx rename to frontend/editor/src/core/ui/ListRow.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/ListRow.tsx b/frontend/editor/src/core/ui/ListRow.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/ListRow.tsx rename to frontend/editor/src/core/ui/ListRow.tsx diff --git a/frontend/editor/src/proprietary/ui/MantineForms.css b/frontend/editor/src/core/ui/MantineForms.css similarity index 100% rename from frontend/editor/src/proprietary/ui/MantineForms.css rename to frontend/editor/src/core/ui/MantineForms.css diff --git a/frontend/editor/src/proprietary/ui/MantineForms.stories.tsx b/frontend/editor/src/core/ui/MantineForms.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/MantineForms.stories.tsx rename to frontend/editor/src/core/ui/MantineForms.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/MethodBadge.css b/frontend/editor/src/core/ui/MethodBadge.css similarity index 100% rename from frontend/editor/src/proprietary/ui/MethodBadge.css rename to frontend/editor/src/core/ui/MethodBadge.css diff --git a/frontend/editor/src/proprietary/ui/MethodBadge.stories.tsx b/frontend/editor/src/core/ui/MethodBadge.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/MethodBadge.stories.tsx rename to frontend/editor/src/core/ui/MethodBadge.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/MethodBadge.tsx b/frontend/editor/src/core/ui/MethodBadge.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/MethodBadge.tsx rename to frontend/editor/src/core/ui/MethodBadge.tsx diff --git a/frontend/editor/src/proprietary/ui/MetricCard.css b/frontend/editor/src/core/ui/MetricCard.css similarity index 100% rename from frontend/editor/src/proprietary/ui/MetricCard.css rename to frontend/editor/src/core/ui/MetricCard.css diff --git a/frontend/editor/src/proprietary/ui/MetricCard.stories.tsx b/frontend/editor/src/core/ui/MetricCard.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/MetricCard.stories.tsx rename to frontend/editor/src/core/ui/MetricCard.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/MetricCard.tsx b/frontend/editor/src/core/ui/MetricCard.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/MetricCard.tsx rename to frontend/editor/src/core/ui/MetricCard.tsx diff --git a/frontend/editor/src/proprietary/ui/MetricStrip.css b/frontend/editor/src/core/ui/MetricStrip.css similarity index 100% rename from frontend/editor/src/proprietary/ui/MetricStrip.css rename to frontend/editor/src/core/ui/MetricStrip.css diff --git a/frontend/editor/src/proprietary/ui/MetricStrip.stories.tsx b/frontend/editor/src/core/ui/MetricStrip.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/MetricStrip.stories.tsx rename to frontend/editor/src/core/ui/MetricStrip.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/MetricStrip.tsx b/frontend/editor/src/core/ui/MetricStrip.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/MetricStrip.tsx rename to frontend/editor/src/core/ui/MetricStrip.tsx diff --git a/frontend/editor/src/proprietary/ui/Modal.css b/frontend/editor/src/core/ui/Modal.css similarity index 83% rename from frontend/editor/src/proprietary/ui/Modal.css rename to frontend/editor/src/core/ui/Modal.css index d63985d97d..7d9ef68746 100644 --- a/frontend/editor/src/proprietary/ui/Modal.css +++ b/frontend/editor/src/core/ui/Modal.css @@ -67,23 +67,10 @@ color: var(--color-text-4); } +/* Sizing/background/hover are owned by the Button; this just keeps it from + * stretching. */ .sui-modal__close { flex: 0 0 auto; - width: 1.75rem; - height: 1.75rem; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - color: var(--color-text-4); - transition: - background var(--motion-fast), - color var(--motion-fast); -} - -.sui-modal__close:hover { - background: var(--color-bg-hover); - color: var(--color-text-1); } .sui-modal__body { diff --git a/frontend/editor/src/proprietary/ui/Modal.tsx b/frontend/editor/src/core/ui/Modal.tsx similarity index 66% rename from frontend/editor/src/proprietary/ui/Modal.tsx rename to frontend/editor/src/core/ui/Modal.tsx index c0de1d475c..09b019e0ef 100644 --- a/frontend/editor/src/proprietary/ui/Modal.tsx +++ b/frontend/editor/src/core/ui/Modal.tsx @@ -1,6 +1,7 @@ import { useEffect, useId, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { FocusTrap } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import "@app/ui/Modal.css"; export type ModalWidth = "sm" | "md" | "lg" | "xl"; @@ -8,31 +9,20 @@ export type ModalWidth = "sm" | "md" | "lg" | "xl"; export interface ModalProps { open: boolean; onClose: () => void; - /** Optional heading slot rendered above the body. */ title?: ReactNode; - /** Optional sub-heading rendered under the title. */ subtitle?: ReactNode; - /** Optional footer slot rendered below the body. */ footer?: ReactNode; - /** Width preset. sm=24rem, md=32rem, lg=48rem, xl=64rem. Defaults to md. */ + /** sm=24rem, md=32rem, lg=48rem, xl=64rem. */ width?: ModalWidth; - /** Disable click-on-backdrop dismissal. Defaults to false. */ disableBackdropClose?: boolean; - /** Disable Escape-key dismissal. Defaults to false. */ disableEscapeClose?: boolean; - /** Accessible name when no visible `title` is provided. */ + /** Accessible name when no visible title is provided. */ ariaLabel?: string; className?: string; children?: ReactNode; } -/** - * Portal-rendered modal with our own brand shell (header / body / footer, - * width presets, backdrop). The hard part — trapping Tab focus inside the - * dialog, initial focus, and restoring focus to the opener on close — is - * delegated to Mantine's rather than hand-rolled. ESC and - * backdrop click close by default; the caller owns the open state. - */ +/** Tab focus trapping and restoration are delegated to Mantine's FocusTrap. */ export function Modal({ open, onClose, @@ -102,27 +92,31 @@ export function Modal({ )} {subtitle &&
{subtitle}
} - + leftSection={ + + + + + } + /> )}
{children}
diff --git a/frontend/editor/src/proprietary/ui/MultiSelect.tsx b/frontend/editor/src/core/ui/MultiSelect.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/MultiSelect.tsx rename to frontend/editor/src/core/ui/MultiSelect.tsx diff --git a/frontend/editor/src/proprietary/ui/NavItem.css b/frontend/editor/src/core/ui/NavItem.css similarity index 100% rename from frontend/editor/src/proprietary/ui/NavItem.css rename to frontend/editor/src/core/ui/NavItem.css diff --git a/frontend/editor/src/proprietary/ui/NavItem.stories.tsx b/frontend/editor/src/core/ui/NavItem.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/NavItem.stories.tsx rename to frontend/editor/src/core/ui/NavItem.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/NavItem.tsx b/frontend/editor/src/core/ui/NavItem.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/NavItem.tsx rename to frontend/editor/src/core/ui/NavItem.tsx diff --git a/frontend/editor/src/proprietary/ui/NumberInput.tsx b/frontend/editor/src/core/ui/NumberInput.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/NumberInput.tsx rename to frontend/editor/src/core/ui/NumberInput.tsx diff --git a/frontend/editor/src/proprietary/ui/PanelHeader.css b/frontend/editor/src/core/ui/PanelHeader.css similarity index 100% rename from frontend/editor/src/proprietary/ui/PanelHeader.css rename to frontend/editor/src/core/ui/PanelHeader.css diff --git a/frontend/editor/src/proprietary/ui/PanelHeader.stories.tsx b/frontend/editor/src/core/ui/PanelHeader.stories.tsx similarity index 75% rename from frontend/editor/src/proprietary/ui/PanelHeader.stories.tsx rename to frontend/editor/src/core/ui/PanelHeader.stories.tsx index 36f193e1d8..715adbab65 100644 --- a/frontend/editor/src/proprietary/ui/PanelHeader.stories.tsx +++ b/frontend/editor/src/core/ui/PanelHeader.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; -import DeleteSweepIcon from "@mui/icons-material/DeleteSweep"; import { PanelHeader } from "@app/ui/PanelHeader"; import { StatusBadge } from "@app/ui/StatusBadge"; +import { Button } from "@app/ui/Button"; const meta: Meta = { title: "Primitives/PanelHeader", @@ -31,16 +31,11 @@ export const Accented: Story = { * the menu (e.g. the chat header's "Clear chat"). */ export const WithMenu: Story = { args: { - title: "Stirling", - menuLabel: "Stirling agent options", menuItems: [ - { - key: "clear", - icon: , - label: "Clear chat", - onClick: () => {}, - }, + { label: "Clear chat", onClick: () => {} }, + { label: "Export history", onClick: () => {} }, ], + menuLabel: "Chat options", }, }; @@ -54,9 +49,17 @@ export const WithActions: Story = { args: { accent: "purple", actions: ( - - Active - + <> + + Healthy + + + + ), }, }; diff --git a/frontend/editor/src/proprietary/ui/PanelHeader.tsx b/frontend/editor/src/core/ui/PanelHeader.tsx similarity index 94% rename from frontend/editor/src/proprietary/ui/PanelHeader.tsx rename to frontend/editor/src/core/ui/PanelHeader.tsx index 26ba1b738c..3d9a0383ba 100644 --- a/frontend/editor/src/proprietary/ui/PanelHeader.tsx +++ b/frontend/editor/src/core/ui/PanelHeader.tsx @@ -69,9 +69,6 @@ export function PanelHeader({ }: PanelHeaderProps) { const hasMenu = menuItems != null && menuItems.length > 0; - // Tint the icon badge with the category colour when an accent is given. Inline - // so it wins over the default blue treatment in both light and dark mode; the - // --color-* tokens are theme-aware and match the badge tint used elsewhere. const iconStyle: CSSProperties | undefined = accent ? { color: `var(--color-${accent})`, diff --git a/frontend/editor/src/proprietary/ui/ProgressBar.css b/frontend/editor/src/core/ui/ProgressBar.css similarity index 100% rename from frontend/editor/src/proprietary/ui/ProgressBar.css rename to frontend/editor/src/core/ui/ProgressBar.css diff --git a/frontend/editor/src/proprietary/ui/ProgressBar.stories.tsx b/frontend/editor/src/core/ui/ProgressBar.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/ProgressBar.stories.tsx rename to frontend/editor/src/core/ui/ProgressBar.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/ProgressBar.tsx b/frontend/editor/src/core/ui/ProgressBar.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/ProgressBar.tsx rename to frontend/editor/src/core/ui/ProgressBar.tsx diff --git a/frontend/editor/src/proprietary/ui/Radio.css b/frontend/editor/src/core/ui/Radio.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Radio.css rename to frontend/editor/src/core/ui/Radio.css diff --git a/frontend/editor/src/proprietary/ui/Radio.tsx b/frontend/editor/src/core/ui/Radio.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Radio.tsx rename to frontend/editor/src/core/ui/Radio.tsx diff --git a/frontend/editor/src/proprietary/ui/SectionDivider.css b/frontend/editor/src/core/ui/SectionDivider.css similarity index 100% rename from frontend/editor/src/proprietary/ui/SectionDivider.css rename to frontend/editor/src/core/ui/SectionDivider.css diff --git a/frontend/editor/src/proprietary/ui/SectionDivider.stories.tsx b/frontend/editor/src/core/ui/SectionDivider.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/SectionDivider.stories.tsx rename to frontend/editor/src/core/ui/SectionDivider.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/SectionDivider.tsx b/frontend/editor/src/core/ui/SectionDivider.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/SectionDivider.tsx rename to frontend/editor/src/core/ui/SectionDivider.tsx diff --git a/frontend/editor/src/proprietary/ui/SectionHeader.css b/frontend/editor/src/core/ui/SectionHeader.css similarity index 100% rename from frontend/editor/src/proprietary/ui/SectionHeader.css rename to frontend/editor/src/core/ui/SectionHeader.css diff --git a/frontend/editor/src/proprietary/ui/SectionHeader.stories.tsx b/frontend/editor/src/core/ui/SectionHeader.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/SectionHeader.stories.tsx rename to frontend/editor/src/core/ui/SectionHeader.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/SectionHeader.tsx b/frontend/editor/src/core/ui/SectionHeader.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/SectionHeader.tsx rename to frontend/editor/src/core/ui/SectionHeader.tsx diff --git a/frontend/editor/src/core/ui/SegmentedControl.css b/frontend/editor/src/core/ui/SegmentedControl.css new file mode 100644 index 0000000000..62e05c1941 --- /dev/null +++ b/frontend/editor/src/core/ui/SegmentedControl.css @@ -0,0 +1,38 @@ +/* Mantine-backed SegmentedControl — colour from the shared accent palette (accents.css). */ +@import "./accents.css"; + +/* Compact the track and let labels fill the fixed height (matches Button/ActionIcon). */ +.sui-seg.mantine-SegmentedControl-root { + --sc-padding: 0.1875rem; /* 3px track inset */ + align-items: stretch; +} +.sui-seg .mantine-SegmentedControl-control { + display: flex; +} +.sui-seg .mantine-SegmentedControl-label { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding-block: 0; +} +/* Mantine wraps label content in .innerLabel; flex it so an icon + text sit + * centered together with a consistent gap. Without this the inline-block icon + * aligns to the text baseline and rides ~3px high. */ +.sui-seg .mantine-SegmentedControl-innerLabel { + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; +} + +/* secondary: strip the boxed track AND the inter-segment separators so only the + * tinted active pill reads — no track chrome at all. */ +.sui-seg--secondary.mantine-SegmentedControl-root { + background: transparent !important; + border-color: transparent !important; + box-shadow: none !important; +} +.sui-seg--secondary .mantine-SegmentedControl-control::before { + background-color: transparent !important; +} diff --git a/frontend/editor/src/core/ui/SegmentedControl.stories.tsx b/frontend/editor/src/core/ui/SegmentedControl.stories.tsx new file mode 100644 index 0000000000..0f16141a5a --- /dev/null +++ b/frontend/editor/src/core/ui/SegmentedControl.stories.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined"; +import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; + +const meta: Meta = { + title: "Primitives/SegmentedControl", + component: SegmentedControl, + parameters: { layout: "centered" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [v, setV] = useState("medium"); + return ( + + ); + }, +}; + +export const FullWidth: Story = { + render: () => { + const [v, setV] = useState("pdf"); + return ( +
+ +
+ ); + }, +}; + +/** `primary` (default) shows an accent-filled active pill; `secondary` drops the + * track chrome and tints the active pill + label in the accent colour. The + * highlight slides between segments. */ +export const Variants: Story = { + render: () => { + const [a, setA] = useState("viewer"); + const [b, setB] = useState("viewer"); + const options = [ + { label: "Viewer", value: "viewer" }, + { label: "Page Editor", value: "pages" }, + { label: "Active Files", value: "files" }, + ]; + return ( +
+ + +
+ ); + }, +}; + +export const Small: Story = { + render: () => { + const [v, setV] = useState("pages"); + return ( + + ); + }, +}; + +/** Icon + label per segment (mirrors the workbench view switcher). Icons and + * labels should sit centered together in each segment. */ +export const WithIcons: Story = { + render: () => { + const [v, setV] = useState("viewer"); + return ( + + + Viewer + + ), + }, + { + value: "files", + label: ( + <> + + Active Files + + ), + }, + ]} + /> + ); + }, +}; diff --git a/frontend/editor/src/core/ui/SegmentedControl.tsx b/frontend/editor/src/core/ui/SegmentedControl.tsx new file mode 100644 index 0000000000..a82874cc45 --- /dev/null +++ b/frontend/editor/src/core/ui/SegmentedControl.tsx @@ -0,0 +1,103 @@ +import { SegmentedControl as MantineSegmentedControl } from "@mantine/core"; +import type { CSSProperties, ReactNode } from "react"; +import { CONTROL_HEIGHT } from "@app/ui/controlSizes"; +import "@app/ui/SegmentedControl.css"; + +/** Same accent dial as Button. Only the active segment is accented. */ +export type SegmentedAccent = + | "default" + | "neutral" + | "brand" + | "ai" + | "premium" + | "danger" + | "success" + | "warning"; +export type SegmentedSize = "xs" | "sm" | "md" | "lg"; +/** primary = accent-filled active pill; secondary = subtle tinted pill, no track chrome. */ +export type SegmentedVariant = "primary" | "secondary"; + +// Overall height, in sync with Button/ActionIcon via CONTROL_HEIGHT (xs is segmented-only). +const SEG_HEIGHT: Record = { + xs: "26px", + sm: CONTROL_HEIGHT.sm, + md: CONTROL_HEIGHT.md, + lg: CONTROL_HEIGHT.lg, +}; + +export interface SegmentedOption { + label: ReactNode; + value: T; + disabled?: boolean; +} + +export interface SegmentedControlProps { + options: SegmentedOption[]; + value: T; + onChange: (value: T) => void; + accent?: SegmentedAccent; + size?: SegmentedSize; + variant?: SegmentedVariant; + fullWidth?: boolean; + disabled?: boolean; + /** Alias of `disabled`; also disables the control. */ + loading?: boolean; + /** Accessible name for the radiogroup. */ + ariaLabel?: string; + className?: string; + style?: CSSProperties; +} + +/** Single-select control with a sliding highlight (Mantine-backed). */ +export function SegmentedControl({ + options, + value, + onChange, + accent = "default", + size = "md", + variant = "primary", + fullWidth = false, + disabled = false, + loading = false, + ariaLabel, + className, + style, +}: SegmentedControlProps) { + const classes = [ + "sui-seg", + `sui-acc-${accent}`, + `sui-seg--${variant}`, + className ?? "", + ] + .filter(Boolean) + .join(" "); + + // Drive Mantine's indicator/label colour vars from the accent palette. + const accentVars = + variant === "primary" + ? { "--sc-color": "var(--_solid)", "--sc-label-color": "var(--_on)" } + : { "--sc-color": "var(--_tint)", "--sc-label-color": "var(--_text)" }; + + return ( + ({ + label: o.label, + value: o.value, + disabled: o.disabled, + }))} + value={value} + onChange={(v) => onChange(v as T)} + size={size} + fullWidth={fullWidth} + disabled={disabled || loading} + aria-label={ariaLabel} + /> + ); +} diff --git a/frontend/editor/src/proprietary/ui/Select.css b/frontend/editor/src/core/ui/Select.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Select.css rename to frontend/editor/src/core/ui/Select.css diff --git a/frontend/editor/src/proprietary/ui/Select.tsx b/frontend/editor/src/core/ui/Select.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Select.tsx rename to frontend/editor/src/core/ui/Select.tsx diff --git a/frontend/editor/src/proprietary/ui/SettingsRow.css b/frontend/editor/src/core/ui/SettingsRow.css similarity index 100% rename from frontend/editor/src/proprietary/ui/SettingsRow.css rename to frontend/editor/src/core/ui/SettingsRow.css diff --git a/frontend/editor/src/proprietary/ui/SettingsRow.stories.tsx b/frontend/editor/src/core/ui/SettingsRow.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/SettingsRow.stories.tsx rename to frontend/editor/src/core/ui/SettingsRow.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/SettingsRow.tsx b/frontend/editor/src/core/ui/SettingsRow.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/SettingsRow.tsx rename to frontend/editor/src/core/ui/SettingsRow.tsx diff --git a/frontend/editor/src/proprietary/ui/SettingsShell.css b/frontend/editor/src/core/ui/SettingsShell.css similarity index 88% rename from frontend/editor/src/proprietary/ui/SettingsShell.css rename to frontend/editor/src/core/ui/SettingsShell.css index 5e56a7934e..5d0e591c8b 100644 --- a/frontend/editor/src/proprietary/ui/SettingsShell.css +++ b/frontend/editor/src/core/ui/SettingsShell.css @@ -109,24 +109,10 @@ align-items: center; gap: 0.5rem; } +/* Sizing/background/hover are owned by the Button; this just keeps it from + * stretching. */ .sui-settings-shell__close { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.75rem; - height: 1.75rem; - border: none; - border-radius: var(--radius-sm); - background: none; - color: var(--color-text-3); - cursor: pointer; - transition: - background var(--motion-fast), - color var(--motion-fast); -} -.sui-settings-shell__close:hover { - background: var(--color-bg-hover); - color: var(--color-text-1); + flex: 0 0 auto; } .sui-settings-shell__body { diff --git a/frontend/editor/src/proprietary/ui/SettingsShell.stories.tsx b/frontend/editor/src/core/ui/SettingsShell.stories.tsx similarity index 94% rename from frontend/editor/src/proprietary/ui/SettingsShell.stories.tsx rename to frontend/editor/src/core/ui/SettingsShell.stories.tsx index 4d14cd98c9..cd266c95a1 100644 --- a/frontend/editor/src/proprietary/ui/SettingsShell.stories.tsx +++ b/frontend/editor/src/core/ui/SettingsShell.stories.tsx @@ -58,8 +58,8 @@ export const Default: Story = { onClose={() => {}} footer={ <> - - + + } > diff --git a/frontend/editor/src/proprietary/ui/SettingsShell.tsx b/frontend/editor/src/core/ui/SettingsShell.tsx similarity index 65% rename from frontend/editor/src/proprietary/ui/SettingsShell.tsx rename to frontend/editor/src/core/ui/SettingsShell.tsx index 77dd36e2ca..8e86715d80 100644 --- a/frontend/editor/src/proprietary/ui/SettingsShell.tsx +++ b/frontend/editor/src/core/ui/SettingsShell.tsx @@ -1,18 +1,17 @@ import type { ReactNode } from "react"; +import { Button } from "@app/ui/Button"; import "@app/ui/SettingsShell.css"; export interface SettingsNavItem { key: string; label: string; - /** Optional leading glyph. */ icon?: ReactNode; - /** Optional trailing badge (e.g. a plan gate or count). */ + /** Trailing badge (e.g. plan gate or count). */ badge?: ReactNode; disabled?: boolean; } export interface SettingsNavSection { - /** Uppercase group heading above its items. */ title: string; items: SettingsNavItem[]; } @@ -21,28 +20,17 @@ export interface SettingsShellProps { sections: SettingsNavSection[]; activeKey: string; onSelect: (key: string) => void; - /** Heading for the content pane — usually the active item's label. */ title: ReactNode; - /** Renders a close button at the top-right of the content header. */ onClose?: () => void; - /** Extra header controls (e.g. a search field) left of the close button. */ + /** Rendered left of the close button. */ headerActions?: ReactNode; - /** Sticky footer, e.g. Save / Cancel. */ + /** Sticky footer (e.g. Save / Cancel). */ footer?: ReactNode; - /** Active section content. */ children: ReactNode; className?: string; } -/** - * Two-pane settings layout: a grouped left navigation rail and a content pane - * with a sticky header (active title + actions) and an optional sticky footer. - * - * Layout chrome only — the caller owns section state and renders the active - * panel as `children`. Host it inside any modal/dialog frame (it fills its - * container's height and scrolls the two panes independently). Shared so the - * portal and the editor can present account settings the same way. - */ +/** Two-pane settings layout. Fills its container; both panes scroll independently. */ export function SettingsShell({ sections, activeKey, @@ -103,27 +91,31 @@ export function SettingsShell({
{headerActions} {onClose && ( - + leftSection={ + + + + + } + /> )}
diff --git a/frontend/editor/src/proprietary/ui/Skeleton.css b/frontend/editor/src/core/ui/Skeleton.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Skeleton.css rename to frontend/editor/src/core/ui/Skeleton.css diff --git a/frontend/editor/src/proprietary/ui/Skeleton.stories.tsx b/frontend/editor/src/core/ui/Skeleton.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Skeleton.stories.tsx rename to frontend/editor/src/core/ui/Skeleton.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/Skeleton.tsx b/frontend/editor/src/core/ui/Skeleton.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Skeleton.tsx rename to frontend/editor/src/core/ui/Skeleton.tsx diff --git a/frontend/editor/src/proprietary/ui/Slider.css b/frontend/editor/src/core/ui/Slider.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Slider.css rename to frontend/editor/src/core/ui/Slider.css diff --git a/frontend/editor/src/proprietary/ui/Slider.tsx b/frontend/editor/src/core/ui/Slider.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Slider.tsx rename to frontend/editor/src/core/ui/Slider.tsx diff --git a/frontend/editor/src/proprietary/ui/Spinner.css b/frontend/editor/src/core/ui/Spinner.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Spinner.css rename to frontend/editor/src/core/ui/Spinner.css diff --git a/frontend/editor/src/proprietary/ui/Spinner.stories.tsx b/frontend/editor/src/core/ui/Spinner.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Spinner.stories.tsx rename to frontend/editor/src/core/ui/Spinner.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/Spinner.tsx b/frontend/editor/src/core/ui/Spinner.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Spinner.tsx rename to frontend/editor/src/core/ui/Spinner.tsx diff --git a/frontend/editor/src/proprietary/ui/Stack.css b/frontend/editor/src/core/ui/Stack.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Stack.css rename to frontend/editor/src/core/ui/Stack.css diff --git a/frontend/editor/src/proprietary/ui/Stack.stories.tsx b/frontend/editor/src/core/ui/Stack.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Stack.stories.tsx rename to frontend/editor/src/core/ui/Stack.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/Stack.tsx b/frontend/editor/src/core/ui/Stack.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Stack.tsx rename to frontend/editor/src/core/ui/Stack.tsx diff --git a/frontend/editor/src/proprietary/ui/StatTile.css b/frontend/editor/src/core/ui/StatTile.css similarity index 100% rename from frontend/editor/src/proprietary/ui/StatTile.css rename to frontend/editor/src/core/ui/StatTile.css diff --git a/frontend/editor/src/proprietary/ui/StatTile.stories.tsx b/frontend/editor/src/core/ui/StatTile.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/StatTile.stories.tsx rename to frontend/editor/src/core/ui/StatTile.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/StatTile.tsx b/frontend/editor/src/core/ui/StatTile.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/StatTile.tsx rename to frontend/editor/src/core/ui/StatTile.tsx diff --git a/frontend/editor/src/proprietary/ui/StatusBadge.css b/frontend/editor/src/core/ui/StatusBadge.css similarity index 100% rename from frontend/editor/src/proprietary/ui/StatusBadge.css rename to frontend/editor/src/core/ui/StatusBadge.css diff --git a/frontend/editor/src/proprietary/ui/StatusBadge.stories.tsx b/frontend/editor/src/core/ui/StatusBadge.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/StatusBadge.stories.tsx rename to frontend/editor/src/core/ui/StatusBadge.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/StatusBadge.tsx b/frontend/editor/src/core/ui/StatusBadge.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/StatusBadge.tsx rename to frontend/editor/src/core/ui/StatusBadge.tsx diff --git a/frontend/editor/src/proprietary/ui/StepIndicator.css b/frontend/editor/src/core/ui/StepIndicator.css similarity index 100% rename from frontend/editor/src/proprietary/ui/StepIndicator.css rename to frontend/editor/src/core/ui/StepIndicator.css diff --git a/frontend/editor/src/proprietary/ui/StepIndicator.stories.tsx b/frontend/editor/src/core/ui/StepIndicator.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/StepIndicator.stories.tsx rename to frontend/editor/src/core/ui/StepIndicator.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/StepIndicator.tsx b/frontend/editor/src/core/ui/StepIndicator.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/StepIndicator.tsx rename to frontend/editor/src/core/ui/StepIndicator.tsx diff --git a/frontend/editor/src/proprietary/ui/Table.css b/frontend/editor/src/core/ui/Table.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Table.css rename to frontend/editor/src/core/ui/Table.css diff --git a/frontend/editor/src/proprietary/ui/Table.stories.tsx b/frontend/editor/src/core/ui/Table.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Table.stories.tsx rename to frontend/editor/src/core/ui/Table.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/Table.tsx b/frontend/editor/src/core/ui/Table.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Table.tsx rename to frontend/editor/src/core/ui/Table.tsx diff --git a/frontend/editor/src/proprietary/ui/Tabs.css b/frontend/editor/src/core/ui/Tabs.css similarity index 100% rename from frontend/editor/src/proprietary/ui/Tabs.css rename to frontend/editor/src/core/ui/Tabs.css diff --git a/frontend/editor/src/proprietary/ui/Tabs.stories.tsx b/frontend/editor/src/core/ui/Tabs.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Tabs.stories.tsx rename to frontend/editor/src/core/ui/Tabs.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/Tabs.tsx b/frontend/editor/src/core/ui/Tabs.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Tabs.tsx rename to frontend/editor/src/core/ui/Tabs.tsx diff --git a/frontend/editor/src/proprietary/ui/Toast.css b/frontend/editor/src/core/ui/Toast.css similarity index 81% rename from frontend/editor/src/proprietary/ui/Toast.css rename to frontend/editor/src/core/ui/Toast.css index ab52d37d3e..a1ca3a67bf 100644 --- a/frontend/editor/src/proprietary/ui/Toast.css +++ b/frontend/editor/src/core/ui/Toast.css @@ -52,19 +52,10 @@ line-height: 1.45; } +/* Sizing/background/hover are owned by the Button; this just keeps it from + * stretching and sizes the × glyph. */ .sui-toast__close { flex: 0 0 auto; - width: 1.25rem; - height: 1.25rem; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - color: var(--color-text-4); font-size: 1rem; line-height: 1; } -.sui-toast__close:hover { - background: var(--color-bg-hover); - color: var(--color-text-1); -} diff --git a/frontend/editor/src/proprietary/ui/Toast.stories.tsx b/frontend/editor/src/core/ui/Toast.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/Toast.stories.tsx rename to frontend/editor/src/core/ui/Toast.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/Toast.tsx b/frontend/editor/src/core/ui/Toast.tsx similarity index 90% rename from frontend/editor/src/proprietary/ui/Toast.tsx rename to frontend/editor/src/core/ui/Toast.tsx index 055f5ec08d..12dcc16990 100644 --- a/frontend/editor/src/proprietary/ui/Toast.tsx +++ b/frontend/editor/src/core/ui/Toast.tsx @@ -9,6 +9,7 @@ import { type ReactNode, } from "react"; import { createPortal } from "react-dom"; +import { Button } from "@app/ui/Button"; import "@app/ui/Toast.css"; export type ToastTone = "info" | "success" | "warning" | "danger"; @@ -84,8 +85,7 @@ function ToastViewport({ entries: ToastEntry[]; onDismiss: (id: number) => void; }) { - // Render through a portal so toasts always sit above any in-flow stacking - // context. SSR-safe by checking for document existence. + // Portal keeps toasts above any stacking context; document check is SSR safety. if (typeof document === "undefined") return null; return createPortal(
void; }) { useEffect(() => { - // Allow Escape to dismiss the most recently focused toast. + // Escape dismisses the focused toast. function onKey(e: KeyboardEvent) { if (e.key === "Escape") onDismiss(entry.id); } @@ -128,14 +128,16 @@ function ToastItem({
{entry.description}
)}
- + leftSection={×} + /> ); } diff --git a/frontend/editor/src/proprietary/ui/ToggleSwitch.css b/frontend/editor/src/core/ui/ToggleSwitch.css similarity index 100% rename from frontend/editor/src/proprietary/ui/ToggleSwitch.css rename to frontend/editor/src/core/ui/ToggleSwitch.css diff --git a/frontend/editor/src/proprietary/ui/ToggleSwitch.stories.tsx b/frontend/editor/src/core/ui/ToggleSwitch.stories.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/ToggleSwitch.stories.tsx rename to frontend/editor/src/core/ui/ToggleSwitch.stories.tsx diff --git a/frontend/editor/src/proprietary/ui/ToggleSwitch.tsx b/frontend/editor/src/core/ui/ToggleSwitch.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/ToggleSwitch.tsx rename to frontend/editor/src/core/ui/ToggleSwitch.tsx diff --git a/frontend/editor/src/core/ui/accents.css b/frontend/editor/src/core/ui/accents.css new file mode 100644 index 0000000000..75001f6777 --- /dev/null +++ b/frontend/editor/src/core/ui/accents.css @@ -0,0 +1,101 @@ +/* Shared accent palette for Button/ActionIcon/SegmentedControl/Chip; standard + * accents derive from --color-* tokens (auto dark), neutral/brand/ai are explicit. */ + +.sui-acc-default { + --_solid: var(--color-blue); + --_solid-hover: var(--color-blue-dark); + --_on: #ffffff; + --_text: var(--color-blue-dark); + --_bd: var(--color-blue-border); + --_tint: color-mix(in srgb, var(--color-blue) 12%, transparent); +} +.sui-acc-danger { + --_solid: var(--color-red); + --_solid-hover: var(--color-red-dark); + --_on: #ffffff; + --_text: var(--color-red-dark); + --_bd: var(--color-red-border); + --_tint: color-mix(in srgb, var(--color-red) 12%, transparent); +} +.sui-acc-success { + --_solid: var(--color-green); + --_solid-hover: var(--color-green-dark); + --_on: #ffffff; + --_text: var(--color-green-dark); + --_bd: var(--color-green-border); + --_tint: color-mix(in srgb, var(--color-green) 12%, transparent); +} +.sui-acc-warning { + --_solid: var(--color-amber); + --_solid-hover: var(--color-amber-dark); + --_on: #ffffff; + --_text: var(--color-amber-dark); + --_bd: var(--color-amber-border); + --_tint: color-mix(in srgb, var(--color-amber) 12%, transparent); +} +/* premium: purple upgrade CTA — gradient fill (token), violet text/border. */ +.sui-acc-premium { + --_solid: var(--grad-purple-btn); + --_solid-hover: var(--grad-purple-btn); + --_on: #ffffff; + --_text: var(--color-purple-dark); + --_bd: var(--color-purple-border); + --_tint: color-mix(in srgb, var(--color-purple) 12%, transparent); +} + +/* neutral: low-emphasis grey — no palette token, so explicit. */ +.sui-acc-neutral { + --_solid: #475569; + --_solid-hover: #334155; + --_on: #ffffff; + --_text: #475569; + --_bd: #cbd5e1; + --_tint: rgba(71, 85, 105, 0.1); +} +[data-theme="dark"] .sui-acc-neutral { + --_solid: #64748b; + --_solid-hover: #475569; + --_text: #cbd5e1; + --_bd: #334155; + --_tint: rgba(148, 163, 184, 0.16); +} +/* brand: Stirling red — a bespoke brand colour, not part of the token palette. */ +.sui-acc-brand { + --_solid: #8e3131; + --_solid-hover: #7a2929; + --_on: #ffffff; + --_text: #8e3131; + --_bd: #d9a8a8; + --_tint: rgba(142, 49, 49, 0.09); +} +[data-theme="dark"] .sui-acc-brand { + --_text: #d98a8a; + --_bd: #5a2424; + --_tint: rgba(217, 138, 138, 0.16); +} +/* ai: multi-hue gradient for AI features — no single-colour token. */ +.sui-acc-ai { + --_solid: linear-gradient( + 135deg, + #8b5cf6 0%, + #6366f1 38%, + #3b82f6 72%, + #22d3ee 100% + ); + --_solid-hover: linear-gradient( + 135deg, + #8b5cf6 0%, + #6366f1 38%, + #3b82f6 72%, + #22d3ee 100% + ); + --_on: #ffffff; + --_text: #6366f1; + --_bd: #c7d2fe; + --_tint: rgba(99, 102, 241, 0.1); +} +[data-theme="dark"] .sui-acc-ai { + --_text: #a5b4fc; + --_bd: #3730a3; + --_tint: rgba(129, 140, 248, 0.18); +} diff --git a/frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx b/frontend/editor/src/core/ui/ariaForwarding.test.tsx similarity index 100% rename from frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx rename to frontend/editor/src/core/ui/ariaForwarding.test.tsx diff --git a/frontend/editor/src/proprietary/ui/ariaForwarding.ts b/frontend/editor/src/core/ui/ariaForwarding.ts similarity index 100% rename from frontend/editor/src/proprietary/ui/ariaForwarding.ts rename to frontend/editor/src/core/ui/ariaForwarding.ts diff --git a/frontend/editor/src/core/ui/controlSizes.ts b/frontend/editor/src/core/ui/controlSizes.ts new file mode 100644 index 0000000000..44347ce99d --- /dev/null +++ b/frontend/editor/src/core/ui/controlSizes.ts @@ -0,0 +1,9 @@ +/** Shared control height scale (px) — Button, ActionIcon and SegmentedControl all use it. */ +export const CONTROL_HEIGHT = { + sm: "30px", + md: "36px", + lg: "42px", + xl: "48px", +} as const; + +export type ControlSize = keyof typeof CONTROL_HEIGHT; diff --git a/frontend/editor/src/proprietary/ui/index.ts b/frontend/editor/src/core/ui/index.ts similarity index 92% rename from frontend/editor/src/proprietary/ui/index.ts rename to frontend/editor/src/core/ui/index.ts index e6c4864c52..39b305f358 100644 --- a/frontend/editor/src/proprietary/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -1,4 +1,7 @@ export * from "@app/ui/Button"; +export * from "@app/ui/ActionIcon"; +export * from "@app/ui/FilePicker"; +export * from "@app/ui/SegmentedControl"; export * from "@app/ui/StatusBadge"; export * from "@app/ui/MethodBadge"; export * from "@app/ui/ToggleSwitch"; diff --git a/frontend/editor/src/desktop/components/ConnectionSettings.tsx b/frontend/editor/src/desktop/components/ConnectionSettings.tsx index aec2d2bad6..746af00b25 100644 --- a/frontend/editor/src/desktop/components/ConnectionSettings.tsx +++ b/frontend/editor/src/desktop/components/ConnectionSettings.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; -import { Stack, Card, Badge, Button, Text, Group } from "@mantine/core"; +import { Stack, Card, Badge, Text, Group } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { connectionModeService, @@ -153,14 +154,14 @@ export const ConnectionSettings: React.FC = () => { {config.mode === "local" ? ( - ) : ( diff --git a/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx b/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx index c9813d818e..6d4940e178 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx @@ -4,6 +4,7 @@ import { authService, UserInfo } from "@app/services/authService"; import { buildOAuthCallbackHtml } from "@app/utils/oauthCallbackHtml"; import { oauthIconUrl } from "@app/auth/ui/oauthIcons"; import { STIRLING_SAAS_URL } from "@app/constants/connection"; +import { Button } from "@app/ui/Button"; import "@app/components/SetupWizard/desktopOAuth.css"; type KnownProviderId = @@ -149,7 +150,8 @@ export const DesktopOAuthButtons: React.FC = ({ providerEntry.id.slice(1) : t("setup.login.sso", "Single Sign-On")); return ( - + ); })} {oauthLoading && ( diff --git a/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx index dc346ef9ac..a698611d52 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx @@ -7,6 +7,7 @@ import DividerWithText from "@app/components/shared/DividerWithText"; import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons"; import { SelfHostedLink } from "@app/components/SetupWizard/SelfHostedLink"; import { UserInfo } from "@app/services/authService"; +import { Button } from "@app/ui/Button"; import "@app/auth/ui/auth.css"; interface SaaSLoginScreenProps { @@ -107,8 +108,8 @@ export const SaaSLoginScreen: React.FC = ({ className="navigation-link-container" style={{ marginTop: "0.5rem", textAlign: "right" }} > - + @@ -127,14 +128,14 @@ export const SaaSLoginScreen: React.FC = ({ className="navigation-link-container" style={{ marginTop: "0.5rem", textAlign: "center" }} > - + )} diff --git a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx index 8d5c98fe6e..4d3f688179 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx @@ -1,5 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; import "@app/auth/ui/auth.css"; interface SelfHostedLinkProps { @@ -15,14 +16,14 @@ export const SelfHostedLink: React.FC = ({ return (
- +
); }; diff --git a/frontend/editor/src/desktop/components/SetupWizard/ServerSelection.tsx b/frontend/editor/src/desktop/components/SetupWizard/ServerSelection.tsx index 10746a66ea..cda4f2f4fc 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/ServerSelection.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/ServerSelection.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; -import { Stack, Button, TextInput, Alert, Text } from "@mantine/core"; +import { Stack, TextInput, Alert, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { ServerConfig, @@ -316,8 +317,8 @@ export const ServerSelection: React.FC = ({ {serverUrl && (
- +
)} @@ -339,9 +340,11 @@ export const ServerSelection: React.FC = ({ type="submit" loading={testing || loading} disabled={loading} - mt="md" fullWidth - color="#AF3434" + accent="danger" + style={{ + marginTop: "var(--mantine-spacing-md)", + }} > {testing ? t("setup.server.testing", "Testing connection...") diff --git a/frontend/editor/src/desktop/components/SetupWizard/index.tsx b/frontend/editor/src/desktop/components/SetupWizard/index.tsx index 2f6ddaba2b..b0779e6b78 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/index.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Stack, Text, Button, Alert, Loader, Center } from "@mantine/core"; +import { Stack, Text, Alert, Loader, Center } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { DesktopAuthLayout } from "@app/components/SetupWizard/DesktopAuthLayout"; import { SaaSLoginScreen } from "@app/components/SetupWizard/SaaSLoginScreen"; import { SaaSSignupScreen } from "@app/components/SetupWizard/SaaSSignupScreen"; @@ -476,8 +477,6 @@ export const SetupWizard: React.FC = ({ )} + )} @@ -565,13 +563,13 @@ export const SetupWizard: React.FC = ({ className="navigation-link-container" style={{ marginTop: "1.5rem" }} > - + )} diff --git a/frontend/editor/src/desktop/components/shared/SelfHostedOfflineBanner.tsx b/frontend/editor/src/desktop/components/shared/SelfHostedOfflineBanner.tsx index ca75b03ec9..9f11296b56 100644 --- a/frontend/editor/src/desktop/components/shared/SelfHostedOfflineBanner.tsx +++ b/frontend/editor/src/desktop/components/shared/SelfHostedOfflineBanner.tsx @@ -1,14 +1,7 @@ import { useState, useEffect, useMemo } from "react"; -import { - Paper, - Group, - Text, - ActionIcon, - UnstyledButton, - Popover, - List, - ScrollArea, -} from "@mantine/core"; +import { Paper, Group, Text, Popover, List, ScrollArea } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; @@ -260,7 +253,8 @@ export function SelfHostedOfflineBanner() { width={260} > - setExpanded((e) => !e)} style={{ color: BANNER_LINK, @@ -279,7 +273,7 @@ export function SelfHostedOfflineBanner() { "selfHosted.offline.showTools", "View unavailable tools ▾", )} - + @@ -293,8 +287,8 @@ export function SelfHostedOfflineBanner() { )} setDismissed(true)} aria-label={t("close", "Close")} style={{ color: BANNER_TEXT }} diff --git a/frontend/editor/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx b/frontend/editor/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx index f4ca397032..247fda74d3 100644 --- a/frontend/editor/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx +++ b/frontend/editor/src/desktop/components/shared/config/configSections/DefaultAppSettings.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Paper, Text, Button, Group } from "@mantine/core"; +import { Paper, Text, Group } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { useDefaultApp } from "@app/hooks/useDefaultApp"; @@ -29,8 +30,7 @@ export const DefaultAppSettings: React.FC = () => { + ); } diff --git a/frontend/editor/src/portal/components/AssistantPanel.tsx b/frontend/editor/src/portal/components/AssistantPanel.tsx index 521f46f621..b31e6bec86 100644 --- a/frontend/editor/src/portal/components/AssistantPanel.tsx +++ b/frontend/editor/src/portal/components/AssistantPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { ActionIcon, Button } from "@app/ui"; import { useTranslation } from "react-i18next"; import { useUI } from "@portal/contexts/UIContext"; import { useAsync } from "@portal/hooks/useAsync"; @@ -95,14 +96,14 @@ export function AssistantPanel() { {t("portal.assistant.title")} - +
@@ -113,15 +114,16 @@ export function AssistantPanel() {
{suggestions.map((s) => ( - + ))}
@@ -166,14 +168,14 @@ export function AssistantPanel() { className="portal-assistant__input" disabled={typing} /> - + ); diff --git a/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx b/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx index db9b006173..b6124bbd25 100644 --- a/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx +++ b/frontend/editor/src/portal/components/ChatFABWidget.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { useRef, useState } from "react"; +import { Button } from "@app/ui/Button"; import { ChatFABButton } from "@app/ui/ChatFABButton"; import { ChatFABWindow } from "@app/ui/ChatFABWindow"; @@ -59,21 +60,15 @@ function MockChatContent({ }} > Stirling - + {/* Messages */} @@ -158,8 +153,10 @@ function ChatFABWidgetDemo({ }} > {/* FAB button */} - + /> {/* Chat panel */}
))} - +
{/* FAB button */} - + /> {/* Chat panel */}
[0], + options?: Parameters[1], +) => baseRender(ui, { wrapper: MantineProvider, ...options }); + // Deterministic i18n: return the key so assertions don't depend on the async // TOML backend ever loading. Mirrors the editor's test setup convention. vi.mock("react-i18next", () => ({ diff --git a/frontend/editor/src/portal/components/Header.tsx b/frontend/editor/src/portal/components/Header.tsx index 7194094171..6ba062e577 100644 --- a/frontend/editor/src/portal/components/Header.tsx +++ b/frontend/editor/src/portal/components/Header.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Avatar, Dropdown } from "@app/ui"; +import { ActionIcon, Avatar, Button, Dropdown } from "@app/ui"; import { useAuth } from "@app/auth"; import { useTheme } from "@portal/contexts/ThemeContext"; import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext"; @@ -19,8 +19,9 @@ function ThemeToggle() { const { theme, toggle } = useTheme(); const { t } = useTranslation(); return ( - + ); } @@ -49,7 +50,11 @@ function TierSwitcher() { return ( - + {(Object.keys(TIER_INFO) as Tier[]).map((id) => ( @@ -89,14 +94,15 @@ function UserMenu() { return ( - + {name} @@ -120,8 +126,9 @@ export function Header() {
- +
diff --git a/frontend/editor/src/portal/components/MocksToggle.tsx b/frontend/editor/src/portal/components/MocksToggle.tsx index 83fce02c21..a0a12d062e 100644 --- a/frontend/editor/src/portal/components/MocksToggle.tsx +++ b/frontend/editor/src/portal/components/MocksToggle.tsx @@ -6,6 +6,7 @@ import { writeMocksPreference, } from "@portal/mocks/preference"; import "@portal/components/MocksToggle.css"; +import { Button } from "@app/ui/Button"; /** * Dev-only header chip that flips MSW interception on and off. Persists the @@ -32,8 +33,8 @@ export function MocksToggle() { } return ( - + ); } diff --git a/frontend/editor/src/portal/components/NotificationsDropdown.tsx b/frontend/editor/src/portal/components/NotificationsDropdown.tsx index 4a0d84d5c3..1401837e53 100644 --- a/frontend/editor/src/portal/components/NotificationsDropdown.tsx +++ b/frontend/editor/src/portal/components/NotificationsDropdown.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Dropdown, EmptyState, Skeleton } from "@app/ui"; +import { ActionIcon, Button, Dropdown, EmptyState, Skeleton } from "@app/ui"; import { BellIcon } from "@portal/components/icons"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; import { @@ -45,8 +45,9 @@ export function NotificationsDropdown() { return ( - +
@@ -115,17 +116,22 @@ export function NotificationsDropdown() { )}
- - + +
diff --git a/frontend/editor/src/portal/components/PipelineForkWizard.tsx b/frontend/editor/src/portal/components/PipelineForkWizard.tsx index 838afda3db..3a27b5984a 100644 --- a/frontend/editor/src/portal/components/PipelineForkWizard.tsx +++ b/frontend/editor/src/portal/components/PipelineForkWizard.tsx @@ -85,9 +85,9 @@ export function PipelineForkWizard() { {phase === "pick" && (
{PIPELINE_TEMPLATES.map((t) => ( - + ))}
)} @@ -139,17 +139,16 @@ export function PipelineForkWizard() {
- ); @@ -113,7 +113,7 @@ export function PolicySummary() { return ( +
{USE_CASES.map((uc) => ( @@ -70,7 +71,8 @@ export function PopularUseCases() {

{t(`portal.useCases.items.${uc.key}.blurb`)}

- + ))}
diff --git a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx b/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx index 9aa3025f98..1ef41f5e0f 100644 --- a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx +++ b/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx @@ -57,7 +57,7 @@ export function ProcessingStatusStrip() { nearCap ? ( + {isLoading && ( diff --git a/frontend/editor/src/portal/components/SearchModal.tsx b/frontend/editor/src/portal/components/SearchModal.tsx index ca66f9365e..6aac7e99d4 100644 --- a/frontend/editor/src/portal/components/SearchModal.tsx +++ b/frontend/editor/src/portal/components/SearchModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { Button, EmptyState, Modal, Skeleton } from "@app/ui"; import { useTranslation } from "react-i18next"; -import { EmptyState, Modal, Skeleton } from "@app/ui"; import { useUI } from "@portal/contexts/UIContext"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; import { fetchQuickActions, type QuickAction } from "@portal/api/search"; @@ -100,9 +100,11 @@ export function SearchModal() {
{group}
{items.map((item) => ( - + ))}
))} diff --git a/frontend/editor/src/portal/components/SettingsModal.tsx b/frontend/editor/src/portal/components/SettingsModal.tsx index c6a40f3389..3073d243f0 100644 --- a/frontend/editor/src/portal/components/SettingsModal.tsx +++ b/frontend/editor/src/portal/components/SettingsModal.tsx @@ -260,10 +260,10 @@ export function SettingsModal({ {t("portal.settings.footerNote")} - - @@ -401,7 +401,7 @@ function ProfilePanel({
{email}
-
@@ -458,9 +458,10 @@ function AppearancePanel({ aria-label={t("portal.settings.appearance.themeTitle")} > {THEME_OPTIONS.map((opt) => ( - + ))} @@ -639,7 +640,7 @@ function WorkspacePanel({ )} - @@ -811,7 +812,7 @@ function SessionsPanel({ ) : ( // TODO(backend): DELETE /v1/settings/sessions/{id} - )} diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 0deb3c0dda..675e48561f 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -1,5 +1,5 @@ +import { Button, Dropdown, NavItem } from "@app/ui"; import { useTranslation } from "react-i18next"; -import { Dropdown, NavItem } from "@app/ui"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useTier } from "@portal/contexts/TierContext"; import { useTheme } from "@portal/contexts/ThemeContext"; @@ -172,13 +172,13 @@ export function Sidebar() { - + )} - {phase === "done" ? ( <> - ) : ( +
@@ -269,9 +270,10 @@ export function SingleOpRunner({ /> )} {ops?.map((op) => ( - + ))}
diff --git a/frontend/editor/src/portal/components/WelcomeCarousel.tsx b/frontend/editor/src/portal/components/WelcomeCarousel.tsx index d0b01708e6..42af5a6abb 100644 --- a/frontend/editor/src/portal/components/WelcomeCarousel.tsx +++ b/frontend/editor/src/portal/components/WelcomeCarousel.tsx @@ -185,14 +185,13 @@ export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {

+ ))} ); diff --git a/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx b/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx index 24bf1205f7..a6b28688e6 100644 --- a/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx +++ b/frontend/editor/src/portal/components/agent-builder/BootstrapDialog.tsx @@ -37,7 +37,7 @@ export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) { subtitle={t("portal.agentBuilder.bootstrap.subtitle")} footer={
-
-
diff --git a/frontend/editor/src/portal/components/agent-builder/ScenariosPanel.tsx b/frontend/editor/src/portal/components/agent-builder/ScenariosPanel.tsx index f4057ea2b6..d3a2822838 100644 --- a/frontend/editor/src/portal/components/agent-builder/ScenariosPanel.tsx +++ b/frontend/editor/src/portal/components/agent-builder/ScenariosPanel.tsx @@ -68,7 +68,7 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) { } diff --git a/frontend/editor/src/portal/components/billing/PaymentMethodCard.tsx b/frontend/editor/src/portal/components/billing/PaymentMethodCard.tsx index cd3b094a5e..4904e8364b 100644 --- a/frontend/editor/src/portal/components/billing/PaymentMethodCard.tsx +++ b/frontend/editor/src/portal/components/billing/PaymentMethodCard.tsx @@ -96,7 +96,7 @@ export function PaymentMethodCard({ onManage, managing }: Props) { )} + )}
@@ -184,13 +185,13 @@ export function SpendLimitCard({
-
@@ -223,7 +224,7 @@ export function SpendLimitCard({
{isLeader && ( diff --git a/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx b/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx index 07d3af75c1..2e19223ce8 100644 --- a/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx +++ b/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx @@ -69,7 +69,7 @@ export function ComponentCard({
{component.frameworks.map((fw) => ( - + {fw} ))} diff --git a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx index 1959e10b1d..b2d8a3f173 100644 --- a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx +++ b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx @@ -98,7 +98,7 @@ export function ComponentDetailModal({ ) : ( + ); })} diff --git a/frontend/editor/src/portal/components/docs/GettingStartedSection.tsx b/frontend/editor/src/portal/components/docs/GettingStartedSection.tsx index 1f94361380..b28929005a 100644 --- a/frontend/editor/src/portal/components/docs/GettingStartedSection.tsx +++ b/frontend/editor/src/portal/components/docs/GettingStartedSection.tsx @@ -56,7 +56,7 @@ export function GettingStartedSection({ - + {t("portal.docs.quickstart.callout.label")}{" "} {t("portal.docs.quickstart.callout.bodyBeforeLink")}{" "} {t("portal.docs.quickstart.callout.link")}{" "} diff --git a/frontend/editor/src/portal/components/docs/PlaybooksSection.tsx b/frontend/editor/src/portal/components/docs/PlaybooksSection.tsx index f48c90a251..10c5cdc7f6 100644 --- a/frontend/editor/src/portal/components/docs/PlaybooksSection.tsx +++ b/frontend/editor/src/portal/components/docs/PlaybooksSection.tsx @@ -20,7 +20,7 @@ export function PlaybooksSection({ playbooks }: { playbooks: Playbook[] }) {
{p.steps.map((step, i) => ( - + {step} {i < p.steps.length - 1 && ( @@ -33,7 +33,7 @@ export function PlaybooksSection({ playbooks }: { playbooks: Playbook[] }) {
{/* TODO(backend): POST /v1/pipelines/clone-from-playbook to seed a draft pipeline from this recipe, then route to the composer. */} -
diff --git a/frontend/editor/src/portal/components/docs/WebhooksSection.tsx b/frontend/editor/src/portal/components/docs/WebhooksSection.tsx index ee50704a2f..84ec30a6e0 100644 --- a/frontend/editor/src/portal/components/docs/WebhooksSection.tsx +++ b/frontend/editor/src/portal/components/docs/WebhooksSection.tsx @@ -25,7 +25,7 @@ export function WebhooksSection() { } }`} /> - + {t("portal.docs.webhooks.callout.beforeSignature")}{" "} Stirling-Signature{" "} {t("portal.docs.webhooks.callout.beforeHelper")}{" "} diff --git a/frontend/editor/src/portal/components/editor-admin/CredentialRotationCard.tsx b/frontend/editor/src/portal/components/editor-admin/CredentialRotationCard.tsx index a3351b0d28..c4e80c1db1 100644 --- a/frontend/editor/src/portal/components/editor-admin/CredentialRotationCard.tsx +++ b/frontend/editor/src/portal/components/editor-admin/CredentialRotationCard.tsx @@ -64,8 +64,8 @@ export function CredentialRotationCard({ serviceToken }: Props) {
+ {open && (
@@ -71,7 +71,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
{t("portal.infrastructure.apiKeys.card.permissions")}
{apiKey.permissions.map((p) => ( - + {p} ))} @@ -86,7 +86,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) { ) : ( apiKey.allowedIps.map((ip) => ( - + {ip} )) diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx index a41dca0d20..a93b2c0c56 100644 --- a/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ApiKeysTab.tsx @@ -24,10 +24,9 @@ export function ApiKeysTab() { sub={t("portal.infrastructure.apiKeys.subheading")} /> diff --git a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx index 7e4fc0b723..613eb97377 100644 --- a/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx +++ b/frontend/editor/src/portal/components/infrastructure/CreateKeyModal.tsx @@ -72,16 +72,17 @@ export function CreateKeyModal({ } footer={ created ? ( - ) : (
-
- - {a.framework} - + {a.framework}

{a.detail}

{a.reportUrl ? ( + )} diff --git a/frontend/editor/src/portal/components/infrastructure/infraFormat.ts b/frontend/editor/src/portal/components/infrastructure/infraFormat.ts index 43980b4dba..2cf7c2e028 100644 --- a/frontend/editor/src/portal/components/infrastructure/infraFormat.ts +++ b/frontend/editor/src/portal/components/infrastructure/infraFormat.ts @@ -1,4 +1,4 @@ -import type { ChipTone, StatusTone } from "@app/ui"; +import type { ChipAccent, StatusTone } from "@app/ui"; import type { ApiKeyStatus, AttestationStatus, @@ -135,11 +135,11 @@ export const MODEL_TYPE_LABEL: Record = { llm: "LLM", }; -export const MODEL_TYPE_TONE: Record = { - extraction: "blue", - classification: "purple", - ocr: "green", - llm: "amber", +export const MODEL_TYPE_TONE: Record = { + extraction: "default", + classification: "premium", + ocr: "success", + llm: "warning", }; export const MODEL_PROVIDER_LABEL: Record = { diff --git a/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx b/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx index 5040a97ac0..fe0546a606 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { + ActionIcon, Banner, Button, Checkbox, @@ -280,7 +281,7 @@ export function PipelineComposer({ footer={
- - +
))} @@ -429,7 +430,7 @@ export function PipelineComposer({ {PIPELINE_OPERATIONS.map((op) => ( addStep(op.operation, op.parameters)} > diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDetailCard.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDetailCard.tsx index 6bfc39d510..ea72ddfc53 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineDetailCard.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineDetailCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Banner, Button, Chip } from "@app/ui"; +import { ActionIcon, Banner, Button, Chip } from "@app/ui"; import { errorMessage } from "@portal/api/http"; import { fetchRun, @@ -116,14 +116,14 @@ export function PipelineDetailCard({ })}
- +
@@ -138,7 +138,7 @@ export function PipelineDetailCard({ ) : (
{pipeline.steps.map((step, i) => ( - + {`${i + 1}. ${humanizeOperation(step)}`} ))} @@ -157,7 +157,7 @@ export function PipelineDetailCard({ ) : (
{pipeline.sources.map((source) => ( - + {source.name} ))} @@ -169,7 +169,7 @@ export function PipelineDetailCard({ {t("portal.pipelines.detail.output")} - + {t(`portal.pipelines.output.${pipeline.output}`, { defaultValue: pipeline.output, })} @@ -186,14 +186,14 @@ export function PipelineDetailCard({ {t("portal.pipelines.detail.run")}
) : ( - + {t("portal.policies.card.notSetUp")} )} diff --git a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx index fd10a0a8d6..d638cc4b7a 100644 --- a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx @@ -84,15 +84,16 @@ function ActivityError({ message }: { message: string }) { > {message} - + ); } @@ -139,8 +140,8 @@ export function PolicyDetailPanel({
{canDelete && ( + )}
))} diff --git a/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx index 4024cce5e3..bad56b0541 100644 --- a/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx @@ -45,7 +45,7 @@ export function PolicyFieldRow({ {(field.options ?? []).map((opt) => ( toggle(opt)} > diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index 96a9d377f2..13f0dab8ac 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -251,7 +251,7 @@ function PolicySetupWizardBody({ subtitle={config.summary} footer={
- {step === "workflow" ? ( @@ -265,7 +265,7 @@ function PolicySetupWizardBody({ ) : ( <> + )) )}
@@ -413,22 +414,23 @@ function PolicySetupWizardBody({ count: scopeTypes.length, })} - +
{scopeNarrow && (
{POLICY_DOC_TYPES.map((dt) => ( toggleScopeType(dt)} > diff --git a/frontend/editor/src/portal/components/procurement/ActionModal.tsx b/frontend/editor/src/portal/components/procurement/ActionModal.tsx index e05ee15fbf..0a74f71370 100644 --- a/frontend/editor/src/portal/components/procurement/ActionModal.tsx +++ b/frontend/editor/src/portal/components/procurement/ActionModal.tsx @@ -124,12 +124,12 @@ export function ActionModal({ subtitle={copy.subtitle} footer={
-
{!isTerminal && currentStep && ( } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx index bd00a93904..640fcdb2e3 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx @@ -112,8 +112,8 @@ export function ProcurementAgreement({
- +
), diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx index fdebad61f6..174ed91154 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx @@ -72,17 +72,17 @@ export function QuoteMilestoneCard({
- -
@@ -115,8 +115,8 @@ export function PaymentStageCard({
{invoiceUrl && ( )} {step === 0 && ( + ))}
)} diff --git a/frontend/editor/src/portal/components/sources/SourceDetailCard.tsx b/frontend/editor/src/portal/components/sources/SourceDetailCard.tsx index e1ab6a3503..ca659ec9d5 100644 --- a/frontend/editor/src/portal/components/sources/SourceDetailCard.tsx +++ b/frontend/editor/src/portal/components/sources/SourceDetailCard.tsx @@ -1,9 +1,10 @@ import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; +import { ActionIcon, Button } from "@app/ui"; import type { SourceView } from "@portal/api/sources"; import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel"; import { sourceTypeMeta } from "@portal/components/sources/sourceTypes"; import "@portal/views/Sources.css"; +import CloseIcon from "@mui/icons-material/Close"; interface SourceDetailCardProps { source: SourceView; @@ -33,7 +34,7 @@ export function SourceDetailCard({
{meta.icon} @@ -47,28 +48,28 @@ export function SourceDetailCard({ })}
- + +
@@ -188,7 +188,7 @@ export function AccessControls({ access }: AccessControlsProps) { title={t("portal.users.access.upgrade.title")} description={access.upgradeHint} action={ - } diff --git a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx index ad98077ba4..fe90105cf5 100644 --- a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx +++ b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx @@ -60,7 +60,7 @@ export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) { subtitle={t("portal.users.invite.subtitle")} footer={
- + {t("portal.users.table.changeRole")} diff --git a/frontend/editor/src/portal/components/users/RolesGrid.tsx b/frontend/editor/src/portal/components/users/RolesGrid.tsx index 522c264e82..24527f35be 100644 --- a/frontend/editor/src/portal/components/users/RolesGrid.tsx +++ b/frontend/editor/src/portal/components/users/RolesGrid.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import { Card, Chip } from "@app/ui"; import type { Role } from "@portal/api/users"; +import { chipAccentForRole } from "@portal/components/users/format"; import "@portal/views/Users.css"; interface RolesGridProps { @@ -24,7 +25,7 @@ export function RolesGrid({ roles }: RolesGridProps) { {roles.map((role) => (
- + {role.label}
diff --git a/frontend/editor/src/portal/components/users/format.ts b/frontend/editor/src/portal/components/users/format.ts index 4dff015986..3308a6441d 100644 --- a/frontend/editor/src/portal/components/users/format.ts +++ b/frontend/editor/src/portal/components/users/format.ts @@ -1,7 +1,24 @@ -import type { AvatarTone } from "@app/ui"; +import type { AvatarTone, ChipAccent } from "@app/ui"; import type { RoleId } from "@portal/api/users"; import { ROLE_TONE } from "@portal/api/users"; +/** Map the role palette onto the shared Chip accent set. */ +const ROLE_CHIP_ACCENT: Record< + "purple" | "blue" | "green" | "amber" | "neutral", + ChipAccent +> = { + purple: "premium", + blue: "default", + green: "success", + amber: "warning", + neutral: "neutral", +}; + +/** Chip accent for a role, derived from its palette tone. */ +export function chipAccentForRole(role: RoleId): ChipAccent { + return ROLE_CHIP_ACCENT[ROLE_TONE[role] ?? "neutral"]; +} + /** Seats used / limit as display copy; null limit → "Unlimited". */ export function seatsLabel(used: number, limit: number | null): string { return limit === null ? `${used} · Unlimited` : `${used} / ${limit}`; diff --git a/frontend/editor/src/portal/mocks/docs.ts b/frontend/editor/src/portal/mocks/docs.ts index 18a1084c06..2b54d0c17c 100644 --- a/frontend/editor/src/portal/mocks/docs.ts +++ b/frontend/editor/src/portal/mocks/docs.ts @@ -17,6 +17,7 @@ * fixtures can be deleted (or kept as test seeds). */ +import type { CardAccent } from "@app/ui"; import type { Tier } from "@portal/contexts/TierContext"; import type { CodeLang } from "@app/ui"; @@ -145,7 +146,7 @@ export interface Playbook { blurb: string; /** Ordered stages rendered as a chip flow. */ steps: string[]; - accent: "blue" | "purple" | "green"; + accent: CardAccent; } /** A bundled, named agent capability — a deterministic op chain. */ @@ -337,28 +338,28 @@ const PLAYBOOKS: Playbook[] = [ "Three-way match", "POST to ERP", ], - accent: "blue", + accent: "default", }, { title: "PII redaction at scale", blurb: "Sweep a document set for PII and write redacted copies to cold storage.", steps: ["S3 source", "Detect PII", "Redact", "Store to bucket"], - accent: "purple", + accent: "premium", }, { title: "Compliance evidence pack", blurb: "Bundle SOC 2 and audit reports into a verified, timestamped archive.", steps: ["Batch upload", "Classify", "Validate schema", "Sign & archive"], - accent: "green", + accent: "success", }, { title: "Agent document tool", blurb: "Expose extraction as an MCP tool your agent can call deterministically.", steps: ["Define tool", "Bind endpoint", "Run evals", "Ship to agent"], - accent: "purple", + accent: "premium", }, ]; diff --git a/frontend/editor/src/portal/views/AgentBuilder.tsx b/frontend/editor/src/portal/views/AgentBuilder.tsx index cd7638ee40..efa319c146 100644 --- a/frontend/editor/src/portal/views/AgentBuilder.tsx +++ b/frontend/editor/src/portal/views/AgentBuilder.tsx @@ -41,7 +41,7 @@ export function AgentBuilder() {
diff --git a/frontend/editor/src/portal/views/Home.tsx b/frontend/editor/src/portal/views/Home.tsx index 92d7f69318..fef96810e1 100644 --- a/frontend/editor/src/portal/views/Home.tsx +++ b/frontend/editor/src/portal/views/Home.tsx @@ -38,7 +38,7 @@ import "@portal/views/Home.css"; /* ──────────────────────────────────────────────────────────────────────── */ interface ProductCardProps { - accent: "blue" | "purple"; + accent: "default" | "premium"; badge?: string; title: string; blurb: string; @@ -60,18 +60,21 @@ function ProductCard({

{title}

{badge && ( - + {badge} )}

{blurb}

@@ -87,14 +90,14 @@ function ProductGrid() { aria-label={t("portal.home.productGrid.ariaLabel")} > void }) {
- - - - +
); @@ -243,7 +250,7 @@ function FreeOnboarding({ onTryOp }: { onTryOp: () => void }) { function renderCta(step: OnboardingStep) { if (step.done) { return ( - ); @@ -251,7 +258,7 @@ function FreeOnboarding({ onTryOp }: { onTryOp: () => void }) { if (!step.cta) return null; if (step.cta.kind === "try-op") { return ( - ); @@ -260,7 +267,7 @@ function FreeOnboarding({ onTryOp }: { onTryOp: () => void }) { return (
@@ -179,7 +179,7 @@ export function Pipelines() { footer={
} diff --git a/frontend/editor/src/portal/views/Sources.css b/frontend/editor/src/portal/views/Sources.css index d265a17135..d8cc6897af 100644 --- a/frontend/editor/src/portal/views/Sources.css +++ b/frontend/editor/src/portal/views/Sources.css @@ -74,23 +74,23 @@ background: var(--color-bg-subtle); color: var(--color-text-3); } -.portal-sources__type-dot--blue { +.portal-sources__type-dot--default { background: var(--color-blue-light); color: var(--color-blue); } -.portal-sources__type-dot--purple { +.portal-sources__type-dot--premium { background: var(--color-purple-light); color: var(--color-purple); } -.portal-sources__type-dot--green { +.portal-sources__type-dot--success { background: var(--color-green-light); color: var(--color-green-dark); } -.portal-sources__type-dot--amber { +.portal-sources__type-dot--warning { background: var(--color-amber-light); color: var(--color-amber-dark); } -.portal-sources__type-dot--red { +.portal-sources__type-dot--danger { background: var(--color-red-light); color: var(--color-red); } diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index a68399d588..7226c2e017 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -1,7 +1,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; import { MemoryRouter } from "react-router-dom"; import { HttpError } from "@portal/api/http"; + +const render = ( + ui: Parameters[0], + options?: Parameters[1], +) => baseRender(ui, { wrapper: MantineProvider, ...options }); import type { SourcesResponse } from "@portal/api/sources"; import { Sources } from "@portal/views/Sources"; diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index b44eacb2ec..91191805ab 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -127,13 +127,13 @@ export function Sources() {
-
@@ -200,7 +200,7 @@ export function Sources() { footer={
{wallet?.status === "subscribed" && ( diff --git a/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.tsx b/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.tsx index 7fa97ecc09..08236c12c4 100644 --- a/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.tsx +++ b/frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; import "@app/auth/ui/auth.css"; -import { TextInput, PasswordInput, Button } from "@mantine/core"; +import { TextInput, PasswordInput } from "@mantine/core"; // Force light mode styles for auth inputs const authInputStyles = { @@ -128,18 +129,11 @@ export default function EmailPasswordForm({ (showPasswordField && !password) || (requiresMfa && !mfaCode.trim()) } - className="auth-button" fullWidth loading={isSubmitting} - styles={{ - // Own the brand colour inline so the host app's Mantine primaryColor - // can't win over .auth-button (editor vs portal Mantine themes - // differ). The fallback keeps it red even if auth-theme.css is absent. - root: { - backgroundColor: "var(--auth-button-bg-light-only, #af3434)", - color: "var(--auth-button-text-light-only, #ffffff)", - }, - }} + // Stirling-red brand CTA; the brand accent sets the colour inline so the + // host app's Mantine primaryColor can't win (editor vs portal differ). + accent="brand" > {submitButtonText} diff --git a/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx b/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx index 7a2d76d129..f6ad17ec7e 100644 --- a/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx +++ b/frontend/editor/src/proprietary/auth/ui/LoginRightCarousel.tsx @@ -1,4 +1,5 @@ import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@app/ui/Button"; import bgDefault from "@app/assets/login/LoginBackgroundPanel.png"; export type ImageSlide = { @@ -208,13 +209,14 @@ function LoginRightCarousel({ }} > {Array.from({ length: totalSlides }).map((_, i) => ( - + ))} ); } - - if (layout === "fullwidth") { - // Mirrors the SaaS editor login: rounded pill buttons stacked vertically, - // each with the provider's icon + "Sign in with X" label. - return ( -
- {providers.map((p) => ( - - ))} -
- ); - } - if (layout === "grid") { return (
@@ -167,24 +133,55 @@ export default function OAuthButtons({ key={p.id} title={`${t("login.signInWith", "Sign in with")} ${p.label}`} > - +
))} ); } + if (layout === "fullwidth") { + // Mirrors the SaaS editor login: rounded pill buttons stacked vertically, + // each with the provider's icon + "Sign in with X" label. + return ( +
+ {providers.map((p) => ( + onProviderClick(p.id)} + disabled={isSubmitting} + className="oauth-button-fullwidth" + title={p.label} + aria-label={ctaPrefix ? `${ctaPrefix} ${p.label}` : p.label} + > + + {p.label} + + {ctaPrefix ? `${ctaPrefix} ${p.label}` : p.label} + + + + ))} +
+ ); + } return (
- +
))} diff --git a/frontend/editor/src/proprietary/auth/ui/SpringLoginForm.tsx b/frontend/editor/src/proprietary/auth/ui/SpringLoginForm.tsx index f6c616fd77..ca8deb3c32 100644 --- a/frontend/editor/src/proprietary/auth/ui/SpringLoginForm.tsx +++ b/frontend/editor/src/proprietary/auth/ui/SpringLoginForm.tsx @@ -102,11 +102,10 @@ export default function SpringLoginForm({ )} diff --git a/frontend/editor/src/proprietary/auth/ui/auth-theme.css b/frontend/editor/src/proprietary/auth/ui/auth-theme.css index 79c7ec6acd..f62691f518 100644 --- a/frontend/editor/src/proprietary/auth/ui/auth-theme.css +++ b/frontend/editor/src/proprietary/auth/ui/auth-theme.css @@ -39,8 +39,8 @@ --auth-magic-button-text-light-only: var(--text-primary); --auth-text-primary-light-only: var(--text-primary); --auth-text-secondary-light-only: var(--text-secondary); - --text-divider-rule-rgb-light: 58, 64, 71; - --text-divider-label-rgb-light: 107, 114, 128; + --text-divider-rule-rgb-light: 28, 35, 64; + --text-divider-label-rgb-light: 91, 98, 128; --tool-subcategory-rule-color-light: var(--border-default); --tool-subcategory-text-color-light: var(--text-secondary); } diff --git a/frontend/editor/src/proprietary/auth/ui/auth.css b/frontend/editor/src/proprietary/auth/ui/auth.css index 3fce26f7ce..249d6cdda5 100644 --- a/frontend/editor/src/proprietary/auth/ui/auth.css +++ b/frontend/editor/src/proprietary/auth/ui/auth.css @@ -707,6 +707,61 @@ min-height: 0; } +.oauth-container-fullwidth { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.oauth-button-fullwidth { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1rem; + border: 1px solid #d1d5db; + border-radius: 100px; + background-color: #ffffff; + font-size: 1rem; + font-weight: 600; + color: #000000; + cursor: pointer; + gap: 0.5rem; + box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); + transition: + background-color 150ms ease, + box-shadow 150ms ease, + border-color 150ms ease; +} + +.oauth-button-fullwidth:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.oauth-button-fullwidth:hover:not(:disabled) { + background-color: #fafafa; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +[data-mantine-color-scheme="dark"] .oauth-button-fullwidth { + background-color: var(--bg-surface); + color: var(--text-primary); + border-color: var(--border-default); + box-shadow: none; +} + +[data-mantine-color-scheme="dark"] + .oauth-button-fullwidth:hover:not(:disabled) { + background-color: var(--bg-raised); + box-shadow: none; +} + +/* ── Dark-mode icon inversion for monochrome OAuth logos ────────────── */ +[data-mantine-color-scheme="dark"] .oauth-icon--github { + filter: invert(1); +} + /* ── Icon+label group — keeps icons vertically aligned across buttons ── */ .oauth-btn-group { display: inline-flex; diff --git a/frontend/editor/src/proprietary/billing/SpendCapControl.tsx b/frontend/editor/src/proprietary/billing/SpendCapControl.tsx index c5589133f3..d6c267a099 100644 --- a/frontend/editor/src/proprietary/billing/SpendCapControl.tsx +++ b/frontend/editor/src/proprietary/billing/SpendCapControl.tsx @@ -123,8 +123,9 @@ export function SpendCapControl({
{presets.map((preset) => ( - + ))} - + {onSave && (
    {toolSteps.map((step, i) => { @@ -333,14 +336,16 @@ function ChatMessageBubble({ const actions = (
    - + {formatRelativeTime(timestamp, t)} @@ -551,9 +556,6 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
    handleSend()} disabled={!input.trim() || isLoading} diff --git a/frontend/editor/src/proprietary/components/chat/ChatQuickActions.tsx b/frontend/editor/src/proprietary/components/chat/ChatQuickActions.tsx index 1905433037..f650c953fd 100644 --- a/frontend/editor/src/proprietary/components/chat/ChatQuickActions.tsx +++ b/frontend/editor/src/proprietary/components/chat/ChatQuickActions.tsx @@ -1,6 +1,8 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Box, Group, Stack, Text, UnstyledButton } from "@mantine/core"; +import { Box, Group, Stack, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import CloseIcon from "@mui/icons-material/Close"; import CompressIcon from "@mui/icons-material/Compress"; import ContentCutIcon from "@mui/icons-material/ContentCut"; @@ -26,7 +28,10 @@ interface QuickAction { function QuickActionCard({ action }: { action: QuickAction }) { return ( - - + ); } @@ -81,23 +86,27 @@ function WorkbenchFilePills({ {stub.name} - + ))} {overflow > 0 && ( - {moreLabel(overflow)} - + )}
    ); diff --git a/frontend/editor/src/proprietary/components/policies/Policies.css b/frontend/editor/src/proprietary/components/policies/Policies.css index 7785702a98..37b92a5830 100644 --- a/frontend/editor/src/proprietary/components/policies/Policies.css +++ b/frontend/editor/src/proprietary/components/policies/Policies.css @@ -178,6 +178,11 @@ color: var(--color-text-4); text-decoration: none; white-space: nowrap; + cursor: pointer; + transition: color var(--motion-fast); +} +.pol-row-upgrade:hover { + color: var(--color-text-1); } .pol-row-upgrade:hover { text-decoration: underline; diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx index 2a3616bafd..15b494c3f6 100644 --- a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx @@ -33,6 +33,8 @@ import type { import type { WatchedFolder } from "@app/types/watchedFolders"; import { POLICIES_ENABLED } from "@app/constants/featureFlags"; import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { IconBadge } from "@app/ui/IconBadge"; import { deriveRowStatus, @@ -167,8 +169,9 @@ export function PoliciesSection({ sidebarTooltip pinOnClick > - +
    @@ -199,26 +202,34 @@ export function PoliciesSection({ {t(`policies.catalog.${cat.id}`, cat.label)} -
    + window.open( + "https://stirling.com/contact", + "_blank", + "noopener,noreferrer", + ) + } > {t( "policies.sidebar.upgradeToEnterprise", "Upgrade to enterprise", )} - +
); } const status = deriveRowStatus(pol.policies[cat.id]); return ( - + ); })}
@@ -508,8 +519,10 @@ export function PoliciesCollapsedButton({ arrow delay={300} > - + ); })} diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx index 0d40660d19..89d3dde57a 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyDeleteConfirmModal.tsx @@ -1,5 +1,6 @@ -import { Modal, Text, Button, Stack, Group } from "@mantine/core"; +import { Modal, Text, Stack, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; interface PolicyDeleteConfirmModalProps { opened: boolean; @@ -35,10 +36,10 @@ export function PolicyDeleteConfirmModal({ )} - - diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx index 1193111c8a..4afcf5891c 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx @@ -84,15 +84,15 @@ function ActivityError({ > {message} - + ); } @@ -221,7 +221,7 @@ export function PolicyDetailPanel({ trailing={ item.status === "flagged" && onRetry ? ( )} - -
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx index 4049533290..1210b997ca 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx @@ -51,12 +51,7 @@ export function PolicyFieldRow({
{(field.options ?? []).map((opt) => ( - toggle(opt)} - > + toggle(opt)}> {t(`policies.fieldOption.${field.key}.${opt}`, opt)} ))} diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx index abd1f6d8df..d096b8327e 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx @@ -630,7 +630,7 @@ export function PolicySetupWizard({ )} action={ +
{scopeNarrow && (
@@ -690,12 +691,11 @@ export function PolicySetupWizard({
- {step < TOTAL_STEPS ? ( ) : ( + )}
- + > + + @@ -425,7 +431,7 @@ export default function ChangeUserPasswordModal({ fullWidth size="md" disabled={disabled} - mt="md" + style={{ marginTop: "var(--mantine-spacing-md)" }} > {t("workspace.people.changePassword.submit", "Update password")} diff --git a/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx b/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx index 6e2f503fa3..e57f60395f 100644 --- a/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx +++ b/frontend/editor/src/proprietary/components/shared/InviteMembersModal.tsx @@ -5,18 +5,18 @@ import { Modal, Stack, Text, - Button, TextInput, Select, Paper, Checkbox, Textarea, - SegmentedControl, Tooltip, - CloseButton, Box, Group, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; import LocalIcon from "@app/components/shared/LocalIcon"; import { alert } from "@app/components/toast"; import { userManagementService } from "@app/services/userManagementService"; @@ -362,7 +362,9 @@ export default function InviteMembersModal({ withCloseButton={false} > - + > + + {/* Header with Icon */} @@ -421,7 +425,11 @@ export default function InviteMembersModal({ {licenseInfo.availableSlots === 0 && ( - )} @@ -455,7 +463,7 @@ export default function InviteMembersModal({ setInviteMode(value as "email" | "direct" | "link"); setGeneratedInviteLink(null); }} - data={[ + options={[ { label: t( "workspace.people.inviteMode.username", @@ -587,7 +595,7 @@ export default function InviteMembersModal({ style={{ flex: 1 }} /> ); diff --git a/frontend/editor/src/proprietary/components/shared/UpdateSeatsButton.tsx b/frontend/editor/src/proprietary/components/shared/UpdateSeatsButton.tsx index f950495a5d..d46b566a1f 100644 --- a/frontend/editor/src/proprietary/components/shared/UpdateSeatsButton.tsx +++ b/frontend/editor/src/proprietary/components/shared/UpdateSeatsButton.tsx @@ -1,11 +1,11 @@ import React from "react"; -import { Button, ButtonProps } from "@mantine/core"; +import { Button, type ButtonProps } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { useUpdateSeats } from "@app/contexts/UpdateSeatsContext"; interface UpdateSeatsButtonProps extends Omit< ButtonProps, - "onClick" | "loading" + "onClick" | "loading" | "onError" | "onSuccess" > { onSuccess?: () => void; onError?: (error: string) => void; @@ -28,7 +28,7 @@ export const UpdateSeatsButton: React.FC = ({ return (
{user && ( - )} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.tsx index 40d16cfc56..457a02fef1 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Alert, - Button, Box, Group, Modal, @@ -11,6 +10,7 @@ import { Text, TextInput, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import { alert as showToast } from "@app/components/toast"; @@ -391,7 +391,7 @@ const AccountSection: React.FC = () => { {!isSsoUser && ( ) : ( - @@ -760,7 +760,7 @@ const AccountSection: React.FC = () => { @@ -783,7 +782,11 @@ export default function PeopleSection() { withArrow zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10} > - + @@ -792,7 +795,14 @@ export default function PeopleSection() { {!isCurrentUser(user) && ( - + - + > + + {/* Header with Icon */} @@ -1077,7 +1091,7 @@ export default function PeopleSection() { loading={processing} fullWidth size="md" - mt="md" + style={{ marginTop: "var(--mantine-spacing-md)" }} > {t("workspace.people.editMember.submit")} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx index 90f4fda9e0..cabdfb642c 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx @@ -4,20 +4,19 @@ import { useTranslation } from "react-i18next"; import { Stack, Text, - Button, Table, - ActionIcon, Badge, Loader, Group, Modal, Select, - CloseButton, Tooltip, Menu, Avatar, Box, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import LocalIcon from "@app/components/shared/LocalIcon"; import { alert } from "@app/components/toast"; import { teamService, Team } from "@app/services/teamService"; @@ -354,7 +353,7 @@ export default function TeamDetailsSection({ {t("workspace.teams.teamNotFound", "Team not found")} - @@ -365,7 +364,11 @@ export default function TeamDetailsSection({ {/* Header with back button */} - +
@@ -562,7 +565,14 @@ export default function TeamDetailsSection({ withArrow zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10} > - + @@ -570,7 +580,13 @@ export default function TeamDetailsSection({ {/* Actions menu */} - +
- setAddMemberModalOpened(false)} size="lg" + variant="tertiary" + aria-label={t("common.close", "Close")} style={{ position: "absolute", top: -8, right: -8, zIndex: 1, }} - /> + > + + {/* Header with Icon */} @@ -755,7 +775,7 @@ export default function TeamDetailsSection({ loading={processing} fullWidth size="md" - mt="md" + style={{ marginTop: "var(--mantine-spacing-md)" }} > {t("workspace.teams.addMemberToTeam.submit")} @@ -774,16 +794,20 @@ export default function TeamDetailsSection({ withCloseButton={false} >
- setChangeTeamModalOpened(false)} size="lg" + variant="tertiary" + aria-label={t("common.close", "Close")} style={{ position: "absolute", top: -8, right: -8, zIndex: 1, }} - /> + > + + {/* Header with Icon */} @@ -828,7 +852,7 @@ export default function TeamDetailsSection({ loading={processing} fullWidth size="md" - mt="md" + style={{ marginTop: "var(--mantine-spacing-md)" }} > {t("workspace.teams.changeTeam.submit", "Change Team")} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx index e3cd79be02..4559296afe 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx @@ -4,19 +4,18 @@ import { useTranslation } from "react-i18next"; import { Stack, Text, - Button, TextInput, Table, - ActionIcon, Menu, Badge, Loader, Group, Modal, Select, - CloseButton, Tooltip, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import LocalIcon from "@app/components/shared/LocalIcon"; import { alert } from "@app/components/toast"; import { teamService, Team } from "@app/services/teamService"; @@ -380,9 +379,12 @@ export default function TeamsSection() {
- setCreateModalOpened(false)} size="lg" + variant="tertiary" + aria-label={t("close", "Close")} style={{ position: "absolute", top: -8, right: -8, zIndex: 1, }} - /> + > + + {/* Header with Icon */} @@ -493,7 +499,7 @@ export default function TeamsSection() { loading={processing} fullWidth size="md" - mt="md" + style={{ marginTop: "var(--mantine-spacing-md)" }} > {t("workspace.teams.createTeam.submit")} @@ -512,16 +518,20 @@ export default function TeamsSection() { withCloseButton={false} >
- setRenameModalOpened(false)} size="lg" + variant="tertiary" + aria-label={t("close", "Close")} style={{ position: "absolute", top: -8, right: -8, zIndex: 1, }} - /> + > + + {/* Header with Icon */} @@ -555,7 +565,7 @@ export default function TeamsSection() { loading={processing} fullWidth size="md" - mt="md" + style={{ marginTop: "var(--mantine-spacing-md)" }} > {t("workspace.teams.renameTeam.submit")} @@ -574,16 +584,20 @@ export default function TeamsSection() { withCloseButton={false} >
- setAddMemberModalOpened(false)} size="lg" + variant="tertiary" + aria-label={t("close", "Close")} style={{ position: "absolute", top: -8, right: -8, zIndex: 1, }} - /> + > + + {/* Header with Icon */} @@ -634,7 +648,7 @@ export default function TeamsSection() { loading={processing} fullWidth size="md" - mt="md" + style={{ marginTop: "var(--mantine-spacing-md)" }} > {t("workspace.teams.addMemberToTeam.submit")} diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx index 8fc8b6cdd4..f05817a957 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Box, Button, Group, Paper } from "@mantine/core"; +import { Box, Group, Paper } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import LocalIcon from "@app/components/shared/LocalIcon"; import FitText from "@app/components/shared/FitText"; import { useTranslation } from "react-i18next"; @@ -56,18 +57,14 @@ export default function ApiKeySection({ - diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.tsx index 742c0ceb21..cf205a9ccc 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.tsx @@ -4,12 +4,12 @@ import { Text, Group, Stack, - SegmentedControl, Loader, Alert, Box, SimpleGrid, } from "@mantine/core"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; import { AreaChart, Area, @@ -161,13 +161,24 @@ const AuditChartsSection: React.FC = ({ { - onTimePeriodChange?.(value as "day" | "week" | "month"); + onTimePeriodChange?.(value); }} - disabled={!loginEnabled} - data={[ - { label: t("audit.charts.day", "Day"), value: "day" }, - { label: t("audit.charts.week", "Week"), value: "week" }, - { label: t("audit.charts.month", "Month"), value: "month" }, + options={[ + { + label: t("audit.charts.day", "Day"), + value: "day", + disabled: !loginEnabled, + }, + { + label: t("audit.charts.week", "Week"), + value: "week", + disabled: !loginEnabled, + }, + { + label: t("audit.charts.month", "Month"), + value: "month", + disabled: !loginEnabled, + }, ]} /> diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.tsx index a2d26aecf0..c166e2fc5b 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.tsx @@ -4,12 +4,12 @@ import { Stack, Text, PasswordInput, - Button, Group, Alert, Code, Badge, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import auditService from "@app/services/auditService"; import LocalIcon from "@app/components/shared/LocalIcon"; @@ -172,11 +172,15 @@ const AuditClearDataSection: React.FC = ({ )} - = ({ }} fz="sm" > - toggleSort("eventType")} style={{ display: "flex", @@ -318,7 +323,7 @@ const AuditEventsTable: React.FC = ({ width="0.9rem" height="0.9rem" /> - + = ({ }} fz="sm" > - toggleSort("username")} style={{ display: "flex", @@ -344,7 +352,7 @@ const AuditEventsTable: React.FC = ({ width="0.9rem" height="0.9rem" /> - + = ({ )}
diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.tsx index da7c46ec59..5cc4313dbf 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.tsx @@ -1,12 +1,6 @@ import React from "react"; -import { - Group, - MultiSelect, - Button, - Stack, - SimpleGrid, - Text, -} from "@mantine/core"; +import { Group, MultiSelect, Stack, SimpleGrid, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { DateInput } from "@mantine/dates"; import { useTranslation } from "react-i18next"; import { AuditFilters } from "@app/services/auditService"; @@ -102,32 +96,32 @@ const AuditFiltersForm: React.FC = ({ - )} - + {licenseFile + ? licenseFile.name + : t( + "admin.settings.premium.file.choose", + "Choose License File", + )} + {licenseFile && ( {t( diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx index 84a8abe5e5..747448f913 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Button, Card, Text, Stack, Divider, Tooltip } from "@mantine/core"; +import { Card, Text, Stack, Divider, Tooltip } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { PlanTierGroup, LicenseInfo } from "@app/services/licenseService"; import { PricingBadge } from "@app/components/shared/stripeCheckout/components/PricingBadge"; @@ -78,7 +79,7 @@ const PlanCard: React.FC = ({
- diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.tsx index 3881ab25fa..fc31369079 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.tsx +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { Alert, Stack, Text, Button } from "@mantine/core"; +import { Alert, Stack, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; interface ErrorStageProps { @@ -14,7 +15,7 @@ export const ErrorStage: React.FC = ({ error, onClose }) => { {error} - diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx index 4928ee4fa8..d74afc4d99 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx @@ -1,13 +1,6 @@ import React from "react"; -import { - Stack, - Button, - Text, - Grid, - Paper, - Alert, - Divider, -} from "@mantine/core"; +import { Stack, Text, Grid, Paper, Alert, Divider } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { PlanTierGroup } from "@app/services/licenseService"; import { SavingsCalculation } from "@app/components/shared/stripeCheckout/types/checkout"; @@ -87,7 +80,7 @@ export const PlanSelectionStage: React.FC = ({ )}
-
@@ -200,7 +193,7 @@ export const PlanSelectionStage: React.FC = ({ )}
-
diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.tsx index 9f0bff06df..a25f2d9705 100644 --- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.tsx +++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.tsx @@ -1,14 +1,6 @@ import React from "react"; -import { - Alert, - Stack, - Text, - Paper, - Code, - Button, - Group, - Loader, -} from "@mantine/core"; +import { Alert, Stack, Text, Paper, Code, Group, Loader } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { PollingStatus } from "@app/components/shared/stripeCheckout/types/checkout"; @@ -63,7 +55,7 @@ export const SuccessStage: React.FC = ({ {licenseKey} diff --git a/frontend/editor/src/proprietary/components/watchedFolders/CardExpansionModal.tsx b/frontend/editor/src/proprietary/components/watchedFolders/CardExpansionModal.tsx index dd4bfa41fe..f5fc841689 100644 --- a/frontend/editor/src/proprietary/components/watchedFolders/CardExpansionModal.tsx +++ b/frontend/editor/src/proprietary/components/watchedFolders/CardExpansionModal.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { Text, ActionIcon, ScrollArea } from "@mantine/core"; +import { Text, ScrollArea } from "@mantine/core"; +import { ActionIcon } from "@app/ui/ActionIcon"; import CloseIcon from "@mui/icons-material/Close"; import { CardModalPhase, @@ -198,9 +199,8 @@ export function CardExpansionModal({
- - diff --git a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderCard.tsx b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderCard.tsx index 0e1612f447..44b2b0c657 100644 --- a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderCard.tsx +++ b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderCard.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; -import { Box, Button, Text, ActionIcon, Group, Loader } from "@mantine/core"; +import { Box, Text, Group, Loader } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -83,11 +85,17 @@ export function WatchedFolderCard({ } > diff --git a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderManagementModal.tsx b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderManagementModal.tsx index daba774bfb..362d4cf7b0 100644 --- a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderManagementModal.tsx +++ b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderManagementModal.tsx @@ -1,6 +1,5 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { - Button, Stack, Group, TextInput, @@ -15,6 +14,7 @@ import { Tooltip, Modal, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { WatchedFolder } from "@app/types/watchedFolders"; import { AutomationConfig, AutomationMode } from "@app/types/automation"; @@ -478,8 +478,8 @@ export function WatchedFolderManagementModal({
{inputDirName && ( + @@ -828,15 +826,11 @@ export function WatchedFolderManagementModal({ )} - ) : ( )} setSelectedActivityIds(new Set())} aria-label={t( "watchedFolders.workbench.clearSelection", @@ -1536,9 +1533,8 @@ export function WatchedFolderWorkbenchView({ } > { e.stopPropagation(); @@ -1666,9 +1662,8 @@ export function WatchedFolderWorkbenchView({ > {!isExpanded && primaryFile && ( { e.stopPropagation(); handleView(primaryFile); @@ -1685,9 +1680,8 @@ export function WatchedFolderWorkbenchView({ )} {!isExpanded && primaryFile && ( { e.stopPropagation(); void handleDownload( @@ -1706,9 +1700,8 @@ export function WatchedFolderWorkbenchView({ )} { e.stopPropagation(); void handleDeleteOne(fileId); @@ -1777,9 +1770,8 @@ export function WatchedFolderWorkbenchView({ {formatBytes(inputFile.size)} { e.stopPropagation(); handleView(inputFile); @@ -1794,9 +1786,8 @@ export function WatchedFolderWorkbenchView({ /> { e.stopPropagation(); handleDownload(inputFile, inputFile.name); @@ -1852,9 +1843,8 @@ export function WatchedFolderWorkbenchView({ {formatBytes(out.size)} { e.stopPropagation(); handleView(out); @@ -1869,9 +1859,8 @@ export function WatchedFolderWorkbenchView({ /> { e.stopPropagation(); handleDownload(out, out.name); @@ -1915,8 +1904,8 @@ export function WatchedFolderWorkbenchView({ )} {inputFile && ( @@ -489,7 +489,7 @@ const ParticipantView: React.FC = ({ token }) => { disabled={ isSubmitting || certValidation.status === "validating" } - color="green" + accent="success" data-testid="submit-signature-button" > {t("workflow.participant.submitSignature", "Submit Signature")} @@ -498,8 +498,8 @@ const ParticipantView: React.FC = ({ token }) => { +
); @@ -259,7 +259,7 @@ export default function InviteAccept() { />
-
diff --git a/frontend/editor/src/proprietary/routes/Landing.tsx b/frontend/editor/src/proprietary/routes/Landing.tsx index 91b79bbf8a..d22acbba56 100644 --- a/frontend/editor/src/proprietary/routes/Landing.tsx +++ b/frontend/editor/src/proprietary/routes/Landing.tsx @@ -7,6 +7,7 @@ import { useBackendProbe } from "@app/hooks/useBackendProbe"; import AuthLayout from "@app/routes/authShared/AuthLayout"; import LoginHeader from "@app/routes/login/LoginHeader"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; /** * Landing component - Smart router based on authentication status @@ -149,14 +150,14 @@ export default function Landing() { "The application cannot currently connect to the backend. Verify the backend status and network connectivity, then try again.", )}

- +
); diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index 0360da737c..7a232d0fde 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -326,13 +326,13 @@ describe("Login", () => { // Wait for OAuth button to appear await waitFor( () => { - const button = screen.queryByText("Authentik"); + const button = screen.queryByText(/Authentik/); expect(button).toBeTruthy(); }, { timeout: 3000 }, ); - const oauthButton = screen.getByText("Authentik"); + const oauthButton = screen.getByText(/Authentik/); await user.click(oauthButton); await waitFor(() => { @@ -372,13 +372,13 @@ describe("Login", () => { // Wait for OAuth button to appear (will show 'Mycompany' as label) await waitFor( () => { - const button = screen.queryByText("Mycompany"); + const button = screen.queryByText(/Mycompany/); expect(button).toBeTruthy(); }, { timeout: 3000 }, ); - const oauthButton = screen.getByText("Mycompany"); + const oauthButton = screen.getByText(/Mycompany/); await user.click(oauthButton); await waitFor(() => { @@ -419,13 +419,13 @@ describe("Login", () => { // Wait for OAuth button to appear await waitFor( () => { - const button = screen.queryByText("OIDC"); + const button = screen.queryByText(/OIDC/); expect(button).toBeTruthy(); }, { timeout: 3000 }, ); - const oauthButton = screen.getByText("OIDC"); + const oauthButton = screen.getByText(/OIDC/); await user.click(oauthButton); await waitFor(() => { @@ -761,12 +761,12 @@ describe("Login", () => { await waitFor( () => { - expect(screen.getByText("Authentik")).toBeTruthy(); + expect(screen.getByText(/Authentik/)).toBeTruthy(); }, { timeout: 3000 }, ); - await user.click(screen.getByText("Authentik")); + await user.click(screen.getByText(/Authentik/)); await waitFor(() => { expect(springAuth.signInWithOAuth).toHaveBeenCalled(); diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index 1e12f73e89..47920f6c62 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -6,6 +6,7 @@ import { useSearchParams, } from "react-router-dom"; import { Text, Stack, Alert } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; @@ -435,14 +436,14 @@ export default function Login() { "The application cannot currently connect to the backend. Verify the backend status and network connectivity, then try again.", )}

- +
); @@ -486,14 +487,16 @@ export default function Login() { beforeEmailForm={ hasSSOProviders && !showEmailForm && isUserPassAllowed ? (
- +
) : undefined } diff --git a/frontend/editor/src/proprietary/routes/ShareLinkPage.tsx b/frontend/editor/src/proprietary/routes/ShareLinkPage.tsx index 915ec3c07d..aef9b6f4f7 100644 --- a/frontend/editor/src/proprietary/routes/ShareLinkPage.tsx +++ b/frontend/editor/src/proprietary/routes/ShareLinkPage.tsx @@ -4,7 +4,6 @@ import { useNavigate, useParams } from "react-router-dom"; import { Alert, Badge, - Button, Group, Loader, Paper, @@ -13,6 +12,7 @@ import { Title, } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; import DownloadIcon from "@mui/icons-material/Download"; import LoginIcon from "@mui/icons-material/Login"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; @@ -232,7 +232,7 @@ export default function ShareLinkPage() { {t("storageShare.openInApp", "Open in Stirling PDF")} +
); diff --git a/frontend/editor/src/proprietary/routes/login/NavigationLink.tsx b/frontend/editor/src/proprietary/routes/login/NavigationLink.tsx index dcb5bd9380..0ca283af2d 100644 --- a/frontend/editor/src/proprietary/routes/login/NavigationLink.tsx +++ b/frontend/editor/src/proprietary/routes/login/NavigationLink.tsx @@ -1,5 +1,4 @@ -import { Button } from "@mantine/core"; - +import { Button } from "@app/ui/Button"; interface NavigationLinkProps { onClick: () => void; text: string; @@ -17,7 +16,7 @@ export default function NavigationLink({ onClick={onClick} disabled={isDisabled} className="navigation-link-button" - variant="subtle" + variant="tertiary" > {text} diff --git a/frontend/editor/src/proprietary/routes/login/OAuthButtons.stories.tsx b/frontend/editor/src/proprietary/routes/login/OAuthButtons.stories.tsx new file mode 100644 index 0000000000..c60ce89feb --- /dev/null +++ b/frontend/editor/src/proprietary/routes/login/OAuthButtons.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import OAuthButtons from "@app/auth/ui/OAuthButtons"; +import "@app/auth/ui/auth.css"; + +/** + * The OAuth provider buttons the login page renders + */ +const meta: Meta = { + title: "Auth/OAuth Buttons", + component: OAuthButtons, + parameters: { layout: "centered" }, + args: { + onProviderClick: () => {}, + isSubmitting: false, + enabledProviders: ["google", "github", "apple", "azure"], + }, +}; +export default meta; +type Story = StoryObj; + +export const Vertical: Story = { + render: (args) => ( +
+ +
+ ), +}; +export const Grid: Story = { + render: (args) => , +}; +export const Icons: Story = { + render: (args) => , +}; diff --git a/frontend/editor/src/proprietary/routes/signup/SignupForm.tsx b/frontend/editor/src/proprietary/routes/signup/SignupForm.tsx index a47d6e6600..8b6ef3d272 100644 --- a/frontend/editor/src/proprietary/routes/signup/SignupForm.tsx +++ b/frontend/editor/src/proprietary/routes/signup/SignupForm.tsx @@ -1,7 +1,8 @@ import { useEffect } from "react"; import "@app/auth/ui/auth.css"; import { useTranslation } from "react-i18next"; -import { Checkbox, TextInput, PasswordInput, Button } from "@mantine/core"; +import { Checkbox, TextInput, PasswordInput } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { SignupFieldErrors } from "@app/routes/signup/SignupFormValidation"; interface SignupFormProps { diff --git a/frontend/editor/src/proprietary/ui/Button.css b/frontend/editor/src/proprietary/ui/Button.css deleted file mode 100644 index 8b474e7f42..0000000000 --- a/frontend/editor/src/proprietary/ui/Button.css +++ /dev/null @@ -1,118 +0,0 @@ -.sui-btn { - display: inline-flex; - align-items: center; - gap: 0.5rem; - border: 1px solid transparent; - border-radius: var(--radius-md); - font-family: var(--font-sans); - font-weight: 500; - cursor: pointer; - transition: - transform var(--motion-fast), - box-shadow var(--motion-fast), - background var(--motion-fast), - color var(--motion-fast), - border-color var(--motion-fast); - white-space: nowrap; - user-select: none; -} -.sui-btn:disabled { - cursor: not-allowed; - opacity: 0.55; -} -.sui-btn:focus-visible { - outline: 2px solid var(--color-blue); - outline-offset: 2px; -} - -/* sizes */ -.sui-btn--md { - font-size: 0.8125rem; - padding: 0.4375rem 0.875rem; - min-height: 2rem; -} -.sui-btn--sm { - font-size: 0.75rem; - padding: 0.3125rem 0.625rem; - min-height: 1.625rem; - border-radius: var(--radius-sm); -} - -.sui-btn--full { - width: 100%; - justify-content: center; -} - -/* gradient (primary) */ -.sui-btn--gradient { - color: var(--color-text-on-accent); - background: var(--grad-blue-btn); - box-shadow: var(--shadow-blue); -} -.sui-btn--gradient.sui-btn--purple { - background: var(--grad-purple-btn); -} -.sui-btn--gradient.sui-btn--green { - background: var(--grad-green-btn); -} -.sui-btn--gradient:hover:not(:disabled) { - box-shadow: var(--shadow-blue-hover); - transform: translateY(-0.0625rem); -} -.sui-btn--gradient:active:not(:disabled) { - transform: translateY(0); -} - -/* outline (secondary) */ -.sui-btn--outline { - color: var(--color-text-2); - background: var(--color-surface); - border-color: var(--color-border); -} -.sui-btn--outline.sui-btn--blue { - color: var(--color-blue); - border-color: var(--color-blue-border); -} -.sui-btn--outline.sui-btn--purple { - color: var(--color-purple); - border-color: var(--color-purple-border); -} -.sui-btn--outline.sui-btn--green { - color: var(--color-green); - border-color: var(--color-green-border); -} -.sui-btn--outline.sui-btn--amber { - color: var(--color-amber); - border-color: var(--color-amber-border); -} -.sui-btn--outline.sui-btn--red { - color: var(--color-red); - border-color: var(--color-red-border); -} -.sui-btn--outline:hover:not(:disabled) { - background: var(--color-bg-hover); - border-color: var(--color-border-hover); -} - -/* ghost (tertiary) */ -.sui-btn--ghost { - color: var(--color-text-3); - background: transparent; -} -.sui-btn--ghost:hover:not(:disabled) { - color: var(--color-text-1); - background: var(--color-bg-hover); -} - -/* spinner */ -.sui-btn__spinner { - width: 0.875rem; - height: 0.875rem; - border-radius: 50%; - border: 2px solid currentColor; - border-right-color: transparent; - animation: spin 0.7s linear infinite; -} -.sui-btn__label { - display: inline-block; -} diff --git a/frontend/editor/src/proprietary/ui/Button.stories.tsx b/frontend/editor/src/proprietary/ui/Button.stories.tsx deleted file mode 100644 index 66b203ec3b..0000000000 --- a/frontend/editor/src/proprietary/ui/Button.stories.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { Button } from "@app/ui/Button"; - -const meta: Meta = { - title: "Primitives/Button", - component: Button, - parameters: { layout: "centered" }, - args: { children: "Connect agent" }, - argTypes: { - variant: { - control: "inline-radio", - options: ["gradient", "outline", "ghost"], - }, - accent: { - control: "inline-radio", - options: ["blue", "purple", "green", "amber", "red"], - }, - size: { control: "inline-radio", options: ["sm", "md"] }, - }, -}; -export default meta; -type Story = StoryObj; - -export const Gradient: Story = { args: { variant: "gradient" } }; -export const Outline: Story = { args: { variant: "outline" } }; -export const Ghost: Story = { args: { variant: "ghost" } }; - -export const WithTrailingArrow: Story = { - args: { - variant: "gradient", - children: "Build a pipeline", - trailingIcon: →, - }, -}; - -export const Loading: Story = { args: { variant: "gradient", loading: true } }; -export const Disabled: Story = { - args: { variant: "gradient", disabled: true }, -}; - -export const AccentMatrix: Story = { - render: () => ( -
- {(["gradient", "outline"] as const).flatMap((variant) => - (["blue", "purple", "green", "amber", "red"] as const).map((accent) => ( - - )), - )} -
- ), -}; diff --git a/frontend/editor/src/proprietary/ui/Button.tsx b/frontend/editor/src/proprietary/ui/Button.tsx deleted file mode 100644 index f3dc73e933..0000000000 --- a/frontend/editor/src/proprietary/ui/Button.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import type { ButtonHTMLAttributes, ReactNode } from "react"; -import "@app/ui/Button.css"; - -export type ButtonVariant = "gradient" | "outline" | "ghost"; -export type ButtonSize = "sm" | "md"; -export type ButtonAccent = "blue" | "purple" | "green" | "amber" | "red"; - -export interface ButtonProps extends ButtonHTMLAttributes { - /** Visual variant. `gradient` is the primary CTA; `outline` is secondary; `ghost` is tertiary/text. */ - variant?: ButtonVariant; - /** Optional accent colour. Currently affects `gradient` and `outline` variants. */ - accent?: ButtonAccent; - size?: ButtonSize; - /** Icon node rendered before the label. */ - leadingIcon?: ReactNode; - /** Icon node rendered after the label (arrow → for "next" CTAs). */ - trailingIcon?: ReactNode; - /** Show a spinner and disable interactivity. */ - loading?: boolean; - /** Stretches to fill its parent container. */ - fullWidth?: boolean; - children?: ReactNode; -} - -/** - * Stirling's two-button system: a gradient primary CTA and an outlined - * secondary action. Ghost is reserved for tertiary links inside a row. - * - * Buttons compose with the existing CSS variables — they re-skin cleanly in - * dark mode without per-variant overrides. - */ -export function Button({ - variant = "gradient", - accent = "blue", - size = "md", - leadingIcon, - trailingIcon, - loading = false, - fullWidth = false, - disabled, - className, - children, - ...rest -}: ButtonProps) { - const classes = [ - "sui-btn", - `sui-btn--${variant}`, - `sui-btn--${accent}`, - `sui-btn--${size}`, - fullWidth ? "sui-btn--full" : "", - loading ? "sui-btn--loading" : "", - className ?? "", - ] - .filter(Boolean) - .join(" "); - - return ( - - ); -} diff --git a/frontend/editor/src/proprietary/ui/Chip.css b/frontend/editor/src/proprietary/ui/Chip.css deleted file mode 100644 index 78c18800e8..0000000000 --- a/frontend/editor/src/proprietary/ui/Chip.css +++ /dev/null @@ -1,96 +0,0 @@ -.sui-chip { - display: inline-flex; - align-items: center; - gap: var(--space-1_5); - border-radius: var(--radius-pill); - font-family: var(--font-sans); - font-weight: 500; - white-space: nowrap; - border: 1px solid transparent; - transition: - background var(--motion-fast), - border-color var(--motion-fast), - transform var(--motion-fast); -} - -.sui-chip--sm { - padding: var(--space-0_5) var(--space-2); - font-size: 0.6875rem; -} -.sui-chip--md { - padding: var(--space-1) var(--space-2_5, 0.5rem); - font-size: 0.75rem; -} -.sui-chip--md { - padding: var(--space-1) 0.625rem; - font-size: 0.75rem; -} - -.sui-chip--neutral { - background: var(--color-bg-muted); - color: var(--color-text-2); - border-color: var(--color-border); -} -.sui-chip--blue { - background: var(--color-blue-light); - color: var(--color-blue); - border-color: var(--color-blue-border); -} -.sui-chip--purple { - background: var(--color-purple-light); - color: var(--color-purple); - border-color: var(--color-purple-border); -} -.sui-chip--green { - background: var(--color-green-light); - color: var(--color-green); - border-color: var(--color-green-border); -} -.sui-chip--amber { - background: var(--color-amber-light); - color: var(--color-amber-dark); - border-color: var(--color-amber-border); -} -.sui-chip--red { - background: var(--color-red-light); - color: var(--color-red); - border-color: var(--color-red-border); -} - -.sui-chip--interactive { - cursor: pointer; -} -.sui-chip--interactive:hover { - transform: translateY(-0.0625rem); - filter: brightness(1.05); -} - -.sui-chip__dot { - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; - background: currentColor; - opacity: 0.85; -} - -.sui-chip__icon { - display: inline-flex; - line-height: 1; -} - -.sui-chip__remove { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1rem; - height: 1rem; - border-radius: 50%; - color: inherit; - opacity: 0.6; - font-size: 0.875rem; - line-height: 1; -} -.sui-chip__remove:hover { - opacity: 1; - background: rgba(0, 0, 0, 0.08); -} diff --git a/frontend/editor/src/proprietary/ui/Chip.stories.tsx b/frontend/editor/src/proprietary/ui/Chip.stories.tsx deleted file mode 100644 index a19e0eff96..0000000000 --- a/frontend/editor/src/proprietary/ui/Chip.stories.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { Chip } from "@app/ui/Chip"; - -const meta: Meta = { - title: "Primitives/Chip", - component: Chip, - tags: ["autodocs"], - parameters: { layout: "centered" }, - args: { - children: "us-east-1", - tone: "neutral", - size: "md", - showDot: false, - }, - argTypes: { - tone: { - control: "inline-radio", - options: ["neutral", "blue", "purple", "green", "amber", "red"], - }, - size: { control: "inline-radio", options: ["sm", "md"] }, - showDot: { control: "boolean" }, - onClick: { action: "clicked" }, - onRemove: { action: "removed" }, - }, -}; -export default meta; -type Story = StoryObj; - -/** Flip tone / size / dot / interactive / removable in controls. */ -export const Playground: Story = {}; - -export const ToneRow: Story = { - render: () => ( -
- {(["neutral", "blue", "purple", "green", "amber", "red"] as const).map( - (t) => ( - - {t} - - ), - )} -
- ), -}; - -export const InContext_OpChain: Story = { - render: () => ( -
- - ocr - - - classify - - - extract - - - validate - - - redact - - - encrypt-rest - - - store-primary - -
- ), -}; diff --git a/frontend/editor/src/proprietary/ui/Chip.tsx b/frontend/editor/src/proprietary/ui/Chip.tsx deleted file mode 100644 index 340b4a493d..0000000000 --- a/frontend/editor/src/proprietary/ui/Chip.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { ReactNode } from "react"; -import "@app/ui/Chip.css"; - -export type ChipTone = - | "neutral" - | "blue" - | "purple" - | "green" - | "amber" - | "red"; - -export type ChipSize = "sm" | "md"; - -export interface ChipProps { - tone?: ChipTone; - size?: ChipSize; - leadingIcon?: ReactNode; - trailingIcon?: ReactNode; - /** Show a `×` button. Calls `onRemove` when clicked. */ - onRemove?: () => void; - /** Renders as a button when set. */ - onClick?: () => void; - /** Show the leading dot affordance. Defaults to false (set true for status-style chips). */ - showDot?: boolean; - children?: ReactNode; - className?: string; -} - -/** - * Generic chip / tag — `StatusBadge` has a fixed taxonomy (success/warning/…), - * `MethodBadge` is HTTP-method-only; this is the open-ended one for tag rows - * (selected ops, document regions, kbd hints, sort chips, etc). - */ -export function Chip({ - tone = "neutral", - size = "md", - leadingIcon, - trailingIcon, - onRemove, - onClick, - showDot, - children, - className, -}: ChipProps) { - const Tag = onClick ? "button" : "span"; - const classes = [ - "sui-chip", - `sui-chip--${tone}`, - `sui-chip--${size}`, - onClick ? "sui-chip--interactive" : "", - className ?? "", - ] - .filter(Boolean) - .join(" "); - return ( - - {showDot && } - {leadingIcon && ( - - {leadingIcon} - - )} - {children} - {trailingIcon && !onRemove && ( - - {trailingIcon} - - )} - {onRemove && ( - - )} - - ); -} diff --git a/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx index 43e388587d..b9028ce22b 100644 --- a/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx +++ b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, useMemo } from "react"; -import { Modal, Stack, Button, Text } from "@mantine/core"; +import { Modal, Stack, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import PersonAddIcon from "@mui/icons-material/PersonAdd"; import { useTranslation } from "react-i18next"; import { withBasePath } from "@app/constants/app"; @@ -112,7 +113,7 @@ export default function SignupRequiredBootstrap() { gap: "0.5rem", }} > -
- - +
diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index 0fa14721d7..b691244e68 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useMemo, useState, useEffect } from "react"; -import { Modal, Button, Text, ActionIcon } from "@mantine/core"; +import { Modal, Text } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; import { useMediaQuery } from "@mantine/hooks"; import { useAuth } from "@app/auth/UseSession"; import { isUserAnonymous } from "@app/auth/supabase"; @@ -253,9 +255,9 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { ) : null} @@ -276,11 +278,11 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => {
Are you sure you want to sign out?
-
- +
@@ -434,8 +434,8 @@ const Overview: React.FC = ({ onLogoutClick }) => { }, )} - + ) : ( @@ -464,24 +464,21 @@ const Overview: React.FC = ({ onLogoutClick }) => { }} > - - {(props) => ( - - )} - - + {t( @@ -559,8 +556,8 @@ const Overview: React.FC = ({ onLogoutClick }) => { key={provider.id} content={`${t("config.account.upgrade.linkWith", "Link with")} ${provider.label}`} > - + ))} @@ -624,9 +621,9 @@ const Overview: React.FC = ({ onLogoutClick }) => { )} style={{ flex: 1 }} /> - + @@ -644,13 +641,13 @@ const Overview: React.FC = ({ onLogoutClick }) => { borderTop: "1px solid var(--mantine-color-default-border)", }} > - + )} @@ -693,11 +690,16 @@ const Overview: React.FC = ({ onLogoutClick }) => { mb="md" /> - - + diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PasswordSecurity.tsx b/frontend/editor/src/saas/components/shared/config/configSections/PasswordSecurity.tsx index 8010b5482a..d38e46e1a7 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/PasswordSecurity.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/PasswordSecurity.tsx @@ -1,6 +1,5 @@ import React, { useState } from "react"; import { - Button, PasswordInput, Group, Alert, @@ -8,6 +7,7 @@ import { Modal, Divider, } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; import { useAuth } from "@app/auth/UseSession"; import { supabase } from "@app/auth/supabase"; @@ -116,7 +116,7 @@ const PasswordSecurity: React.FC = () => { )}

- @@ -166,7 +166,7 @@ const PasswordSecurity: React.FC = () => { ) : ( - + } > - - - {translate("mode.resume", "Resume placement")} - - + {translate("mode.resume", "Resume placement")} + ) ) : null; @@ -1217,7 +1200,7 @@ const SignSettings = ({ onChange={(value) => handleSignatureSourceChange(value as SignatureSource) } - data={sourceOptions} + options={sourceOptions} /> )} {renderSignatureBuilder()} @@ -1260,7 +1243,7 @@ const SignSettings = ({ /> {onSave && ( - )} diff --git a/frontend/editor/src/saas/routes/AuthCallback.tsx b/frontend/editor/src/saas/routes/AuthCallback.tsx index 1701163f9d..ee16d8ce36 100644 --- a/frontend/editor/src/saas/routes/AuthCallback.tsx +++ b/frontend/editor/src/saas/routes/AuthCallback.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { supabase } from "@app/auth/supabase"; -import { Button } from "@mantine/core"; +import { Button } from "@app/ui/Button"; import { withBasePath } from "@app/constants/app"; interface CallbackState { @@ -206,8 +206,8 @@ export default function AuthCallback() { if (state.status === "error") { return ( diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index 4395bef998..dcf7b454a2 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { supabase, signInAnonymously } from "@app/auth/supabase"; +import { Button } from "@app/ui/Button"; import { useAuth } from "@app/auth/UseSession"; import { useTranslation } from "@app/hooks/useTranslation"; import { useDocumentMeta } from "@app/hooks/useDocumentMeta"; @@ -299,8 +300,8 @@ export default function Login() { {/* Magic link button + its expandable form as one unit */}
- +
-
)}
@@ -370,8 +371,8 @@ export default function Login() { {/* Email & Password button */} - + {/* Email form — animated expand */}
- +
{/* Skip */}
- +
{/* Bottom */} @@ -441,8 +442,8 @@ export default function Login() { paddingTop: "1rem", }} > - + ); diff --git a/frontend/editor/src/saas/routes/OAuthConsent.tsx b/frontend/editor/src/saas/routes/OAuthConsent.tsx index e36f4d371b..d851a91a53 100644 --- a/frontend/editor/src/saas/routes/OAuthConsent.tsx +++ b/frontend/editor/src/saas/routes/OAuthConsent.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; +import { Button } from "@app/ui/Button"; import { useAuth } from "@app/auth/UseSession"; import { useTranslation } from "@app/hooks/useTranslation"; import { useDocumentMeta } from "@app/hooks/useDocumentMeta"; @@ -236,13 +237,13 @@ export default function OAuthConsent() { "Sign in to your Stirling PDF account to continue connecting the app.", )}

- + ); } @@ -358,8 +359,7 @@ export default function OAuthConsent() {
- - +
{displayName && ( diff --git a/frontend/editor/src/saas/routes/ResetPassword.tsx b/frontend/editor/src/saas/routes/ResetPassword.tsx index f17e82bd68..b0a1273199 100644 --- a/frontend/editor/src/saas/routes/ResetPassword.tsx +++ b/frontend/editor/src/saas/routes/ResetPassword.tsx @@ -9,6 +9,7 @@ import NavigationLink from "@app/routes/login/NavigationLink"; import { supabase } from "@app/auth/supabase"; import { absoluteWithBasePath } from "@app/constants/app"; import { useTranslation } from "@app/hooks/useTranslation"; +import { Button } from "@app/ui/Button"; export default function ResetPassword() { const { t } = useTranslation(); @@ -233,7 +234,7 @@ export default function ResetPassword() { /> - navigate("/login")} text={t("login.backToSignIn", "Back to sign in")} diff --git a/frontend/editor/src/saas/routes/Signup.tsx b/frontend/editor/src/saas/routes/Signup.tsx index 36d162a549..3836c60ac1 100644 --- a/frontend/editor/src/saas/routes/Signup.tsx +++ b/frontend/editor/src/saas/routes/Signup.tsx @@ -9,6 +9,7 @@ import AuthLayout from "@app/routes/authShared/AuthLayout"; import "@app/auth/ui/auth.css"; import "@app/routes/authShared/saas-auth.css"; import { alert } from "@app/components/toast"; +import { Button } from "@app/ui/Button"; // Import signup components import ErrorMessage from "@app/auth/ui/ErrorMessage"; @@ -209,8 +210,8 @@ export default function Signup() { {/* Email & Password button */} - + {/* Email form — animated expand */}
- +
{/* Bottom */} @@ -276,8 +277,8 @@ export default function Signup() { paddingTop: "1rem", }} > - + ); diff --git a/frontend/editor/src/saas/routes/authShared/GuestSignInButton.tsx b/frontend/editor/src/saas/routes/authShared/GuestSignInButton.tsx index 31bbc35ca0..273bb17dfd 100644 --- a/frontend/editor/src/saas/routes/authShared/GuestSignInButton.tsx +++ b/frontend/editor/src/saas/routes/authShared/GuestSignInButton.tsx @@ -1,4 +1,7 @@ import React from "react"; +import { Button } from "@app/ui/Button"; + +// TODO: add saas-auth.css to the same location as auth.css import "@app/auth/ui/auth.css"; import "@app/routes/authShared/saas-auth.css"; @@ -14,13 +17,13 @@ export default function GuestSignInButton({ disabled, }: GuestSignInButtonProps) { return ( - + ); } diff --git a/frontend/editor/src/saas/routes/authShared/saas-auth.css b/frontend/editor/src/saas/routes/authShared/saas-auth.css index 535d72d3dd..96d97f695c 100644 --- a/frontend/editor/src/saas/routes/authShared/saas-auth.css +++ b/frontend/editor/src/saas/routes/authShared/saas-auth.css @@ -129,6 +129,11 @@ text-align: center; } +/* ── Dark-mode icon inversion for monochrome OAuth logos ────────────── */ +[data-mantine-color-scheme="dark"] .oauth-icon--github { + filter: invert(1); +} + /* ── Icon helpers inside oauth-button-fullwidth ─────────────────────── */ .auth-at-icon { font-size: 1.25rem; diff --git a/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx b/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx index ecc8cbd209..a7435eb43b 100644 --- a/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx +++ b/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import "@app/auth/ui/auth.css"; import "@app/routes/authShared/saas-auth.css"; +import { Button } from "@app/ui/Button"; interface EmailPasswordFormProps { email: string; @@ -78,13 +79,14 @@ export default function EmailPasswordForm({ )} - + ); } diff --git a/frontend/editor/src/saas/routes/login/MagicLinkForm.tsx b/frontend/editor/src/saas/routes/login/MagicLinkForm.tsx index 9833928247..2a669595de 100644 --- a/frontend/editor/src/saas/routes/login/MagicLinkForm.tsx +++ b/frontend/editor/src/saas/routes/login/MagicLinkForm.tsx @@ -1,3 +1,4 @@ +import { Button } from "@app/ui/Button"; import { useTranslation } from "@app/hooks/useTranslation"; import "@app/auth/ui/auth.css"; import "@app/routes/authShared/saas-auth.css"; @@ -24,7 +25,8 @@ export default function MagicLinkForm({ if (!showMagicLink) { return (
- +
); } @@ -47,13 +49,13 @@ export default function MagicLinkForm({ onKeyPress={(e) => e.key === "Enter" && !isSubmitting && onSubmit()} className="auth-input" /> - + ); } diff --git a/frontend/editor/src/saas/routes/login/OAuthButtons.tsx b/frontend/editor/src/saas/routes/login/OAuthButtons.tsx index 21e76348f9..202a1c9d3c 100644 --- a/frontend/editor/src/saas/routes/login/OAuthButtons.tsx +++ b/frontend/editor/src/saas/routes/login/OAuthButtons.tsx @@ -1,6 +1,7 @@ import { oauthProviders } from "@app/constants/authProviders"; import { useTranslation } from "@app/hooks/useTranslation"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { Button } from "@app/ui/Button"; import { oauthIconUrl } from "@app/auth/ui/oauthIcons"; // Exports for compatibility with proprietary code @@ -38,7 +39,8 @@ export default function OAuthButtons({ content={`${t("login.signInWith", "Sign in with")} ${p.label}`} position="top" > - + ))} @@ -65,7 +67,8 @@ export default function OAuthButtons({ content={`${t("login.signInWith", "Sign in with")} ${p.label}`} position="top" > - + ))} @@ -87,8 +90,9 @@ export default function OAuthButtons({ return (
{oauthProviders.map((p) => ( - + ))}
); @@ -115,8 +119,9 @@ export default function OAuthButtons({ return (
{oauthProviders.map((p) => ( - + ))}
); diff --git a/frontend/editor/src/saas/styles/saas-theme.css b/frontend/editor/src/saas/styles/saas-theme.css index 3cbce19761..654fe5fe17 100644 --- a/frontend/editor/src/saas/styles/saas-theme.css +++ b/frontend/editor/src/saas/styles/saas-theme.css @@ -76,8 +76,6 @@ --api-keys-card-shadow: rgba(0, 0, 0, 0.06); --api-keys-input-bg: #f8f8f8; --api-keys-input-border: #e0e0e0; - --api-keys-button-bg: #f5f5f5; - --api-keys-button-color: #333333; } [data-mantine-color-scheme="dark"] { @@ -90,8 +88,8 @@ --spdf-compare-added-badge-fg: var(--color-green-500); --spdf-compare-inline-removed-bg: rgba(255, 59, 48, 0.25); --spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25); - --compare-page-label-bg: #2a2f36; - --compare-page-label-fg: #d0d6dc; + --compare-page-label-bg: #0d1020; + --compare-page-label-fg: #c2c8e0; /* Orange scale (dark mode mirrors light values to match UI) */ --color-orange-50: #fff4ed; @@ -122,29 +120,27 @@ --tool-subcategory-text-color-light: #9ca3af; /* API usage progress bar colors (dark mode) */ - --usage-weekly-active: #60a5fa; + --usage-weekly-active: #4f8ef5; --usage-bought-active: #34d399; - --usage-total-used: #ffffff; - --usage-inactive: #43464b; + --usage-total-used: #e8eaf6; + --usage-inactive: #1c2340; /* API Keys section colors (dark mode) */ - --api-keys-card-bg: #2a2f36; - --api-keys-card-border: #3a4047; + --api-keys-card-bg: #131729; + --api-keys-card-border: #1c2340; --api-keys-card-shadow: none; - --api-keys-input-bg: #1f2329; - --api-keys-input-border: #3a4047; - --api-keys-button-bg: #3a4047; - --api-keys-button-color: #d0d6dc; + --api-keys-input-bg: #0d1020; + --api-keys-input-border: #1c2340; --text-divider-rule-color: var(--tool-subcategory-rule-color); --text-divider-label-color: var(--text-muted); /* App Config Modal colors (dark mode) */ - --modal-nav-bg: #1f2329; - --modal-nav-section-title: #9ca3af; - --modal-nav-item: #d0d6dc; - --modal-nav-item-active: #0a8bff; - --modal-nav-item-active-bg: rgba(10, 139, 255, 0.15); - --modal-content-bg: #2a2f36; - --modal-header-border: rgba(255, 255, 255, 0.08); + --modal-nav-bg: #0d1020; + --modal-nav-section-title: #5b6280; + --modal-nav-item: #c2c8e0; + --modal-nav-item-active: #4f8ef5; + --modal-nav-item-active-bg: rgba(79, 142, 245, 0.15); + --modal-content-bg: #131729; + --modal-header-border: rgba(255, 255, 255, 0.05); } diff --git a/frontend/editor/tsconfig.portal.vite.json b/frontend/editor/tsconfig.portal.vite.json index 8a63033eb6..93337d1dcc 100644 --- a/frontend/editor/tsconfig.portal.vite.json +++ b/frontend/editor/tsconfig.portal.vite.json @@ -1,6 +1,6 @@ { "extends": "./tsconfig.json", - "comment": "Path resolution for the portal's vitest project (referenced by vitest.config.ts). Broad include ('src', via the base) so vite-tsconfig-paths rewrites @app/* in every editor/src file the portal tests pull in (e.g. proprietary/ui), not just the portal layer. The portal's own typecheck uses src/portal/tsconfig.json.", + "comment": "Path resolution for the portal's vitest project (referenced by vitest.config.ts). Broad include ('src', via the base) so vite-tsconfig-paths rewrites @app/* in every editor/src file the portal tests pull in (e.g. core/ui), not just the portal layer. The portal's own typecheck uses src/portal/tsconfig.json.", "compilerOptions": { "paths": { "@app/*": [ diff --git a/frontend/editor/vitest.config.ts b/frontend/editor/vitest.config.ts index 5a0fc50651..e4dfd76277 100644 --- a/frontend/editor/vitest.config.ts +++ b/frontend/editor/vitest.config.ts @@ -57,7 +57,7 @@ export default defineConfig({ react(), tsconfigPaths({ // Broad project so @app/@portal resolve in every editor file the - // portal tests pull in (proprietary/ui, core, ...). + // portal tests pull in (core/ui, core, ...). projects: ["./tsconfig.portal.vite.json"], }), ], diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 81698ddde6..02f1c68132 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -30,6 +30,42 @@ const baseRestrictedImportPatterns = [ }, ]; +// Button/SegmentedControl/Chip must come from the shared DS (@app/ui), not Mantine. +// If no variant fits, extend @app/ui — that layer (editor/src/core/ui) is exempt below. +const mantineComponentImportRestrictions = [ + { + selector: + "ImportDeclaration[source.value='@mantine/core'] > ImportSpecifier[imported.name=/^(Button|ActionIcon|UnstyledButton|CloseButton|FileButton)$/]", + message: + 'Use the shared Button (@app/ui/Button) instead of the Mantine button family. variant=primary|secondary|tertiary, accent=default|neutral|brand|ai|premium|danger|success|warning; an icon-only button is `
+ + +
+ + + +
+ +
+
+ + +
+ +
+
+

+
+
    +
    +
    +
    + +
    + + + + + From 8df49ac053dbc464b00d2dbd64774f6a69628f59 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:07:54 +0100 Subject: [PATCH 24/35] feat(portal): build portal for SaaS and self-hosted via file-override layer (#6900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Ground-work so the admin portal can build for the **SaaS flavor** alongside self-hosted, using the editor's existing **build-time file-override** mechanism — no runtime flavor flags. This PR demonstrates SaaS end-to-end (single-login + Usage page loading via the inherited Supabase session) without changing self-hosted behaviour. This is intentionally scoped as foundations, not the whole feature. ## How **Build hook** - `tsconfig.saas.vite.json`: `@portal/*` now cascades (`src/saas/portal/*` → `src/portal/*`); added `@portalCore/*` for the explicit base path. - New `src/portal-saas/` layer (sibling of `src/portal`) holds SaaS-only overrides, so `@app/*` resolves only editor layers and `@portal/*` only portal layers. Self-hosted builds never import it (tree-shaken). **Seams live in the api-client + composition layers — never in page components** - `saasApiBase` — base URL source (self-hosted: `VITE_SAAS_API_URL`; SaaS reuses the single `VITE_API_BASE_URL` backend). - `portalSaasSession` — flavor-agnostic token from the shared Supabase client. - `PortalAuthBoundary` — self-hosted: Spring `AuthProvider` + `AuthGate`; SaaS: Supabase `AuthProvider` + session-only gate (inherits the SaaS session, so no second login). **Link concept pulled out of the Usage page (one clean cut)** - `Usage` is now a link-free wallet renderer with generic `onWalletLoaded` / `onReauth` callbacks; it always loads the wallet and has zero flavor awareness. - `PortalBillingGate` is the single flavor seam: self-hosted gates on link (prompt when unlinked; wires the callbacks onto link/tier + re-auth), SaaS is a passthrough that renders `Usage` directly. - Keeps the flavor switch out of the page entirely (no per-flavor code in `Usage`). ## Testing Green locally and in CI (CI runs the umbrella `task frontend:check:all`): - `task frontend:typecheck:all` — clean across all 7 build variants - `task frontend:test` — vitest suites pass (portal + saas cover this change; 146 tests) - `task frontend:build:saas` and `task frontend:build:proprietary` — both green - `task frontend:lint` and `task frontend:format:check` — clean ## Also in this PR (added after the initial foundations) - **Tier from wallet + full link-layer excision on SaaS.** `TierContext` no longer reads `LinkContext` (via a `usePlanTier` seam: self-hosted from link state, SaaS from `wallet.status`), and the SaaS `PortalProviders` drops `LinkProvider` / `AccountLinkProvider` / `LinkModalHost` entirely — the link machinery is *absent* from the SaaS bundle, not mounted-but-inert. ## Deliberately out of scope (follow-ups) - SaaS-only read-only "connected servers" settings view. - Shared wallet source so the SaaS tier badge and the Usage page don't both fetch `/payg/wallet` (harmless double-fetch today). --- frontend/.storybook/main.ts | 5 + frontend/.storybook/preview.tsx | 8 +- frontend/editor/.env.proprietary | 7 - .../src/portal-saas/PortalProviders.tsx | 20 +++ .../src/portal-saas/api/saasApiBase.test.ts | 21 +++ .../editor/src/portal-saas/api/saasApiBase.ts | 13 ++ .../auth/PortalAuthBoundary.test.tsx | 60 ++++++++ .../portal-saas/auth/PortalAuthBoundary.tsx | 61 ++++++++ .../components/LinkAccountFooterItem.test.tsx | 10 ++ .../components/LinkAccountFooterItem.tsx | 7 + .../billing/PortalBillingGate.test.tsx | 15 ++ .../components/billing/PortalBillingGate.tsx | 20 +++ .../settings/accountLinkSettings.test.ts | 8 ++ .../settings/accountLinkSettings.tsx | 9 ++ .../portal-saas/contexts/usePlanTier.test.tsx | 35 +++++ .../src/portal-saas/contexts/usePlanTier.ts | 14 ++ .../contexts/usePortalLinked.test.tsx | 16 +++ .../portal-saas/contexts/usePortalLinked.ts | 9 ++ frontend/editor/src/portal/PortalApp.tsx | 133 ++---------------- .../editor/src/portal/PortalProviders.tsx | 63 +++++++++ frontend/editor/src/portal/ViewRouter.tsx | 4 +- frontend/editor/src/portal/api/http.ts | 32 ++--- frontend/editor/src/portal/api/saasApiBase.ts | 18 +++ .../src/portal/auth/PortalAuthBoundary.tsx | 19 +++ .../src/portal/auth/portalSaasSession.test.ts | 43 ++++++ .../src/portal/auth/portalSaasSession.ts | 19 +++ .../src/portal/auth/saasSupabase.test.ts | 45 ++++++ .../editor/src/portal/auth/saasSupabase.ts | 17 ++- frontend/editor/src/portal/billing/stripe.ts | 2 +- .../components/LinkAccountFooterItem.tsx | 27 ++++ .../src/portal/components/PortalChrome.tsx | 84 +++++++++++ .../src/portal/components/SettingsModal.tsx | 23 +-- .../editor/src/portal/components/Sidebar.tsx | 24 +--- .../account-link/LinkAccountCard.tsx | 2 +- .../account-link/LinkAccountModal.tsx | 4 +- .../billing/PortalBillingGate.test.tsx | 39 +++++ .../components/billing/PortalBillingGate.tsx | 32 +++++ .../procurement/ProcurementHome.tsx | 4 +- .../settings/accountLinkSettings.tsx | 26 ++++ .../src/portal/contexts/TierContext.tsx | 31 ++-- .../src/portal/contexts/usePlanTier.test.tsx | 28 ++++ .../editor/src/portal/contexts/usePlanTier.ts | 24 ++++ .../portal/contexts/usePortalLinked.test.tsx | 28 ++++ .../src/portal/contexts/usePortalLinked.ts | 12 ++ frontend/editor/src/portal/tsconfig.json | 1 - .../editor/src/portal/views/Usage.test.tsx | 67 +++++++++ frontend/editor/src/portal/views/Usage.tsx | 100 +++++++------ frontend/editor/src/portal/vite-env.d.ts | 8 +- frontend/editor/src/saas/auth/supabase.ts | 18 +-- frontend/editor/src/saas/tsconfig.json | 12 +- frontend/editor/tsconfig.portal.vite.json | 7 +- frontend/editor/tsconfig.saas.vite.json | 3 +- frontend/editor/vitest.config.ts | 8 +- frontend/eslint.config.mjs | 3 +- 54 files changed, 1045 insertions(+), 303 deletions(-) create mode 100644 frontend/editor/src/portal-saas/PortalProviders.tsx create mode 100644 frontend/editor/src/portal-saas/api/saasApiBase.test.ts create mode 100644 frontend/editor/src/portal-saas/api/saasApiBase.ts create mode 100644 frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx create mode 100644 frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx create mode 100644 frontend/editor/src/portal-saas/components/LinkAccountFooterItem.test.tsx create mode 100644 frontend/editor/src/portal-saas/components/LinkAccountFooterItem.tsx create mode 100644 frontend/editor/src/portal-saas/components/billing/PortalBillingGate.test.tsx create mode 100644 frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx create mode 100644 frontend/editor/src/portal-saas/components/settings/accountLinkSettings.test.ts create mode 100644 frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx create mode 100644 frontend/editor/src/portal-saas/contexts/usePlanTier.test.tsx create mode 100644 frontend/editor/src/portal-saas/contexts/usePlanTier.ts create mode 100644 frontend/editor/src/portal-saas/contexts/usePortalLinked.test.tsx create mode 100644 frontend/editor/src/portal-saas/contexts/usePortalLinked.ts create mode 100644 frontend/editor/src/portal/PortalProviders.tsx create mode 100644 frontend/editor/src/portal/api/saasApiBase.ts create mode 100644 frontend/editor/src/portal/auth/PortalAuthBoundary.tsx create mode 100644 frontend/editor/src/portal/auth/portalSaasSession.test.ts create mode 100644 frontend/editor/src/portal/auth/portalSaasSession.ts create mode 100644 frontend/editor/src/portal/auth/saasSupabase.test.ts create mode 100644 frontend/editor/src/portal/components/LinkAccountFooterItem.tsx create mode 100644 frontend/editor/src/portal/components/PortalChrome.tsx create mode 100644 frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx create mode 100644 frontend/editor/src/portal/components/billing/PortalBillingGate.tsx create mode 100644 frontend/editor/src/portal/components/settings/accountLinkSettings.tsx create mode 100644 frontend/editor/src/portal/contexts/usePlanTier.test.tsx create mode 100644 frontend/editor/src/portal/contexts/usePlanTier.ts create mode 100644 frontend/editor/src/portal/contexts/usePortalLinked.test.tsx create mode 100644 frontend/editor/src/portal/contexts/usePortalLinked.ts create mode 100644 frontend/editor/src/portal/views/Usage.test.tsx diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 4d4aadc8f8..8547e9092a 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -61,6 +61,11 @@ const config: StorybookConfig = { config.define = { ...(config.define ?? {}), "import.meta.env.VITE_SAAS_API_URL": JSON.stringify("http://saas.mock"), + // Keep the Supabase auth env empty so ensureSaasSupabase() is a no-op and + // never replaces the mock SaaS client stubbed in preview.tsx. + "import.meta.env.VITE_SUPABASE_URL": JSON.stringify(""), + "import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY": + JSON.stringify(""), }; return config; }, diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index bab413a0e8..76463001fe 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -29,10 +29,10 @@ initialize({ onUnhandledRequest: "bypass" }, handlers); // Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment // method, wallet) clear the session check and reach the MSW handlers instead of -// failing with "No SaaS session". VITE_SAAS_SUPABASE_URL/KEY are intentionally -// unset, so ensureSaasSupabase() is a no-op and never replaces this client; only -// VITE_SAAS_API_URL (a mock origin MSW matches) is configured — injected via -// .storybook/main.ts's viteFinal define, not a frontend/.env file. +// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see +// .storybook/main.ts), so ensureSaasSupabase() is a no-op and never replaces this +// client; only VITE_SAAS_API_URL (a mock origin MSW matches) is configured — +// injected via .storybook/main.ts's viteFinal define, not a frontend/.env file. const saasStub = configureSupabase({ url: "http://saas.mock", key: "storybook-anon-key", diff --git a/frontend/editor/.env.proprietary b/frontend/editor/.env.proprietary index ed5498ba3a..0ee7e66bc9 100644 --- a/frontend/editor/.env.proprietary +++ b/frontend/editor/.env.proprietary @@ -18,13 +18,6 @@ VITE_EDITOR_URL=/ # backend. VITE_PORTAL_MOCKS= -# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account -# linking (both values are public). Set per deploy; absent -> the account-link -# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase -# project the local backend links against. -VITE_SAAS_SUPABASE_URL= -VITE_SAAS_SUPABASE_ANON_KEY= - # Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for # ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the # admin's Supabase JWT. Distinct from the local backend (reached same-origin via diff --git a/frontend/editor/src/portal-saas/PortalProviders.tsx b/frontend/editor/src/portal-saas/PortalProviders.tsx new file mode 100644 index 0000000000..161b942582 --- /dev/null +++ b/frontend/editor/src/portal-saas/PortalProviders.tsx @@ -0,0 +1,20 @@ +import { TierProvider } from "@portal/contexts/TierContext"; +import { UIProvider } from "@portal/contexts/UIContext"; +import { PortalChrome } from "@portal/components/PortalChrome"; + +/** + * SaaS provider stack. There is no account-link layer: the signed-in account IS + * the SaaS account (auth is handled upstream by PortalAuthBoundary) and the tier + * comes from the wallet (see portal-saas/contexts/usePlanTier). Dropping + * LinkProvider / AccountLinkProvider / the login modal here keeps the link + * machinery out of the SaaS bundle entirely. + */ +export function PortalProviders() { + return ( + + + + + + ); +} diff --git a/frontend/editor/src/portal-saas/api/saasApiBase.test.ts b/frontend/editor/src/portal-saas/api/saasApiBase.test.ts new file mode 100644 index 0000000000..6330ea4755 --- /dev/null +++ b/frontend/editor/src/portal-saas/api/saasApiBase.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { saasApiBase } from "@portal/api/saasApiBase"; + +describe("saasApiBase — SaaS build (one backend, VITE_API_BASE_URL)", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("reuses the editor's single backend base", () => { + vi.stubEnv("VITE_API_BASE_URL", "http://localhost:8080"); + expect(saasApiBase()).toBe("http://localhost:8080"); + }); + + it("trims a trailing slash", () => { + vi.stubEnv("VITE_API_BASE_URL", "https://app.stirling.com/"); + expect(saasApiBase()).toBe("https://app.stirling.com"); + }); + + it("maps same-origin '/' to '' — a valid (non-null) base, never 'unconfigured'", () => { + vi.stubEnv("VITE_API_BASE_URL", "/"); + expect(saasApiBase()).toBe(""); + }); +}); diff --git a/frontend/editor/src/portal-saas/api/saasApiBase.ts b/frontend/editor/src/portal-saas/api/saasApiBase.ts new file mode 100644 index 0000000000..e669dbf898 --- /dev/null +++ b/frontend/editor/src/portal-saas/api/saasApiBase.ts @@ -0,0 +1,13 @@ +/** + * SaaS build: everything is the SaaS backend, so portal→SaaS reads target the + * SAME base URL the editor uses ({@code VITE_API_BASE_URL}) rather than a separate + * {@code VITE_SAAS_API_URL}. Default {@code "/"} → same-origin (the dev proxy / + * deployment forwards to the SaaS backend); the Supabase JWT is the one credential. + * + *

    Never null — same-origin is always a valid base — so {@code apiClient.saas} + * never enters the self-hosted "SaaS not configured" state. + */ +export function saasApiBase(): string { + const raw = import.meta.env.VITE_API_BASE_URL ?? "/"; + return raw.replace(/\/+$/, ""); +} diff --git a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx new file mode 100644 index 0000000000..271fbbfa50 --- /dev/null +++ b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.test.tsx @@ -0,0 +1,60 @@ +import { type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { allowConsole } from "@app/tests/failOnConsole"; + +// Controllable auth state for the mocked provider. +const authState: { session: unknown; loading: boolean } = { + session: null, + loading: false, +}; + +vi.mock("@app/auth", () => ({ + // Passthrough — we drive gating via the mocked useAuth below. + AuthProvider: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("@app/auth/context", () => ({ useAuth: () => authState })); +vi.mock("@app/ui", () => ({ Spinner: () => null })); +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); + +import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary"; + +describe("PortalAuthBoundary — SaaS", () => { + beforeEach(() => { + authState.session = null; + authState.loading = false; + }); + + it("renders the portal when a Supabase session is present (no login)", () => { + authState.session = { user: { id: "u1" }, access_token: "tok" }; + render( + +

    PORTAL
    + , + ); + expect(screen.getByTestId("portal")).toBeInTheDocument(); + }); + + it("gates (does not render the portal) when there is no session", () => { + authState.session = null; + // The gate bounces to /login; jsdom doesn't implement navigation, so absorb + // that incidental warning rather than fail the console guard. + allowConsole.error(/not implemented|navigation/i); + render( + +
    PORTAL
    +
    , + ); + expect(screen.queryByTestId("portal")).not.toBeInTheDocument(); + }); + + it("gates while the session is still resolving", () => { + authState.loading = true; + render( + +
    PORTAL
    +
    , + ); + expect(screen.queryByTestId("portal")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx new file mode 100644 index 0000000000..b97d57de4e --- /dev/null +++ b/frontend/editor/src/portal-saas/auth/PortalAuthBoundary.tsx @@ -0,0 +1,61 @@ +import { useEffect, type ReactNode } from "react"; +import { AuthProvider } from "@app/auth"; +import { useAuth } from "@app/auth/context"; +import { Spinner } from "@app/ui"; +import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; + +function FullScreen({ children }: { children: ReactNode }) { + return ( +
    + {children} +
    + ); +} + +/** + * SaaS gate: viewing your own usage is not admin-gated, so require only a session + * (not portalAccess). No session → bounce to the editor's Supabase login, which + * returns here signed in. This is deliberately laxer than the self-hosted + * RequirePortalAccess admin gate. + */ +function SaasPortalGate({ children }: { children: ReactNode }) { + const { session, loading } = useAuth(); + useEffect(() => { + if (!loading && !session) { + window.location.href = "/login"; + } + }, [loading, session]); + if (loading || !session) { + return ( + + + + ); + } + return <>{children}; +} + +/** + * SaaS override of the portal auth boundary: authenticate against the SaaS Supabase + * project (inheriting the editor's session) instead of the self-hosted Spring login. + */ +export function PortalAuthBoundary({ children }: { children: ReactNode }) { + // Configure the shared Supabase client (SaaS project) synchronously here, before + // the AuthProvider below reads it — a useEffect would run too late for the first + // render, leaving the provider with a null client. Idempotent; the portal mounts + // outside the editor's AppProviders but against the SAME project, so a user already + // signed into the editor is picked up from the persisted session (no second login). + ensureSaasSupabase(); + return ( + + {children} + + ); +} diff --git a/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.test.tsx b/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.test.tsx new file mode 100644 index 0000000000..6f324b4cd4 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.test.tsx @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; + +describe("LinkAccountFooterItem (SaaS)", () => { + it("renders nothing — SaaS has no account to link", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.tsx b/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.tsx new file mode 100644 index 0000000000..be05e58bd6 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/LinkAccountFooterItem.tsx @@ -0,0 +1,7 @@ +/** + * SaaS has no account-link concept — the signed-in account IS the SaaS account, + * so there is nothing to link and no footer CTA to show. + */ +export function LinkAccountFooterItem() { + return null; +} diff --git a/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.test.tsx b/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.test.tsx new file mode 100644 index 0000000000..fa88c342c2 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +vi.mock("@portal/views/Usage", () => ({ + Usage: () =>
    , +})); + +import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; + +describe("PortalBillingGate — SaaS", () => { + it("renders the Usage page directly, with no link concept", () => { + render(); + expect(screen.getByTestId("usage")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx b/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx new file mode 100644 index 0000000000..b02c485581 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/billing/PortalBillingGate.tsx @@ -0,0 +1,20 @@ +import { Usage } from "@portal/views/Usage"; + +/** + * SaaS billing gate: there is no link concept — the signed-in account IS the SaaS + * account (auth is handled upstream by PortalAuthBoundary), so render the Usage + * page directly. No link state, no prompt. + * + * onReauth sends the user back to the editor's Supabase login if the session + * lapses mid-view: PortalAuthBoundary only re-gates on mount / session change, so + * without this a "Session expired" notice would dead-end until a manual reload. + */ +export function PortalBillingGate() { + return ( + { + window.location.href = "/login"; + }} + /> + ); +} diff --git a/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.test.ts b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.test.ts new file mode 100644 index 0000000000..5cc64727a1 --- /dev/null +++ b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings"; + +describe("accountLinkSettings (SaaS)", () => { + it("is null — Settings has no account-link section on SaaS", () => { + expect(accountLinkSettings).toBeNull(); + }); +}); diff --git a/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx new file mode 100644 index 0000000000..6fc0af735b --- /dev/null +++ b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx @@ -0,0 +1,9 @@ +import type { AccountLinkSettingsSeam } from "@portal-proprietary/components/settings/accountLinkSettings"; + +/** + * SaaS has no account-link concept — the signed-in account IS the SaaS account. + * Null drops the "Account link" nav item and its panel from Settings (the shared + * SettingsModal treats the seam as optional), so the link-only AccountLinkPanel + * is never imported into the SaaS bundle. + */ +export const accountLinkSettings: AccountLinkSettingsSeam | null = null; diff --git a/frontend/editor/src/portal-saas/contexts/usePlanTier.test.tsx b/frontend/editor/src/portal-saas/contexts/usePlanTier.test.tsx new file mode 100644 index 0000000000..d2a9666bff --- /dev/null +++ b/frontend/editor/src/portal-saas/contexts/usePlanTier.test.tsx @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, waitFor } from "@testing-library/react"; + +const fetchWallet = vi.fn(); +vi.mock("@portal/api/billing", () => ({ + fetchWallet: () => fetchWallet(), +})); + +// Resolves to the SaaS override (src/portal-saas/contexts) via the @portal cascade. +import { usePlanTier } from "@portal/contexts/usePlanTier"; + +function Probe() { + return {usePlanTier()}; +} + +describe("usePlanTier (SaaS) — tier from wallet", () => { + beforeEach(() => { + fetchWallet.mockReset(); + }); + + it("subscribed wallet → pro", async () => { + fetchWallet.mockResolvedValue({ status: "subscribed" }); + const { getByTestId } = render(); + await waitFor(() => expect(getByTestId("tier").textContent).toBe("pro")); + }); + + it("free wallet → free (also the loading default)", async () => { + fetchWallet.mockResolvedValue({ status: "free" }); + const { getByTestId } = render(); + // Free before the fetch resolves and after — it never flips to pro. + expect(getByTestId("tier").textContent).toBe("free"); + await waitFor(() => expect(fetchWallet).toHaveBeenCalledTimes(1)); + expect(getByTestId("tier").textContent).toBe("free"); + }); +}); diff --git a/frontend/editor/src/portal-saas/contexts/usePlanTier.ts b/frontend/editor/src/portal-saas/contexts/usePlanTier.ts new file mode 100644 index 0000000000..6b877c3640 --- /dev/null +++ b/frontend/editor/src/portal-saas/contexts/usePlanTier.ts @@ -0,0 +1,14 @@ +import { useAsync } from "@portal/hooks/useAsync"; +import { fetchWallet } from "@portal/api/billing"; +import type { Tier } from "@portal/contexts/TierContext"; + +/** + * The plan tier the portal runs on, SaaS flavor: the signed-in account IS the + * SaaS account, so the tier comes straight from the wallet — no link concept. + * `subscribed` → the metered Processor tier; anything else (including while the + * wallet is still loading) → free. Enterprise stays a mocks-only tier. + */ +export function usePlanTier(): Tier { + const { data: wallet } = useAsync(() => fetchWallet(), []); + return wallet?.status === "subscribed" ? "pro" : "free"; +} diff --git a/frontend/editor/src/portal-saas/contexts/usePortalLinked.test.tsx b/frontend/editor/src/portal-saas/contexts/usePortalLinked.test.tsx new file mode 100644 index 0000000000..1368b27a4b --- /dev/null +++ b/frontend/editor/src/portal-saas/contexts/usePortalLinked.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +// Resolves to the SaaS override (src/portal-saas/contexts) via the @portal cascade. +import { usePortalLinked } from "@portal/contexts/usePortalLinked"; + +function Probe() { + return {String(usePortalLinked())}; +} + +describe("usePortalLinked (SaaS)", () => { + it("is always true — no account-link step, no LinkProvider needed", () => { + // Renders with no LinkProvider in the tree: the SaaS override must not read it. + const { getByTestId } = render(); + expect(getByTestId("linked").textContent).toBe("true"); + }); +}); diff --git a/frontend/editor/src/portal-saas/contexts/usePortalLinked.ts b/frontend/editor/src/portal-saas/contexts/usePortalLinked.ts new file mode 100644 index 0000000000..4a5355f1d9 --- /dev/null +++ b/frontend/editor/src/portal-saas/contexts/usePortalLinked.ts @@ -0,0 +1,9 @@ +/** + * SaaS flavor: the signed-in account IS the SaaS account, so the portal is always + * authorized — there is no account-link step. Procurement/checkout treats this as + * "linked" and proceeds straight to the flow. No LinkContext dependency, so the + * link machinery stays out of the SaaS bundle. + */ +export function usePortalLinked(): boolean { + return true; +} diff --git a/frontend/editor/src/portal/PortalApp.tsx b/frontend/editor/src/portal/PortalApp.tsx index 629a8ac57a..d14892363e 100644 --- a/frontend/editor/src/portal/PortalApp.tsx +++ b/frontend/editor/src/portal/PortalApp.tsx @@ -1,25 +1,8 @@ -import { useEffect, type ReactNode } from "react"; -import { useLocation } from "react-router-dom"; -import { AuthProvider } from "@app/auth"; -import { ErrorBoundary } from "@portal/components/ErrorBoundary"; +import { type ReactNode } from "react"; +import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; -import { TierProvider } from "@portal/contexts/TierContext"; -import { LinkProvider, useLink } from "@portal/contexts/LinkContext"; -import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin"; -import { UIProvider, useUI } from "@portal/contexts/UIContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; -import { AppShell } from "@portal/components/AppShell"; -import { AuthGate } from "@portal/components/AuthGate"; -import { AssistantButton } from "@portal/components/AssistantButton"; -import { AssistantPanel } from "@portal/components/AssistantPanel"; -import { SearchModal } from "@portal/components/SearchModal"; -import { SettingsModal } from "@portal/components/SettingsModal"; -import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; -import { - AccountLinkProvider, - useAccountLinkContext, -} from "@portal/contexts/AccountLinkContext"; -import { ViewRouter } from "@portal/ViewRouter"; +import { PortalProviders } from "@portal/PortalProviders"; // Reset + typography, scoped to .portal-scope below. import "@portal/theme/base.css"; @@ -33,93 +16,15 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) { return {children}; } -/** - * Global keyboard shortcuts. Lives below the UIProvider so it can dispatch - * into the overlay state. Currently just ⌘K / Ctrl+K to toggle the search - * palette. - */ -function GlobalShortcuts() { - const { toggleSearch, closeSearch } = useUI(); - - useEffect(() => { - function onKey(e: KeyboardEvent) { - const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k"; - if (isCmdK) { - e.preventDefault(); - toggleSearch(); - return; - } - if (e.key === "Escape") { - closeSearch(); - } - } - document.addEventListener("keydown", onKey); - return () => document.removeEventListener("keydown", onKey); - }, [toggleSearch, closeSearch]); - - return null; -} - -/** Bridges the Settings modal's open/close props to UIContext state. */ -function SettingsHost() { - const { settingsOpen, settingsInitialSection, closeSettings } = useUI(); - return ( - - ); -} - -/** - * The one and only account-link login modal. Mounted at the app root (never - * nested in another overlay) and driven by UIContext, so any "Link account" CTA - * — sidebar, billing prompt, feature gate, Settings panel — opens this exact - * instance. Linking is finished by the shared {@link useAccountLinkContext} - * orchestration. - */ -function LinkModalHost() { - const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); - const { markSaasSessionChanged } = useLink(); - const link = useAccountLinkContext(); - // "reauth" only refreshes the browser SaaS session for attended reads — the - // sign-in already applied it to the Supabase client, so we just signal a - // refetch. It must NOT call completeLink (that re-registers → duplicate row). - const onLinked = - linkModalMode === "reauth" - ? () => markSaasSessionChanged() - : (session: SupabaseLoginSession) => link.completeLink(session); - return ( - - ); -} - -/** - * The routed view, wrapped in an error boundary so a single view crashing can't - * white-screen the portal (the shell + nav stay alive). Keyed by route so - * navigating to another section clears any error from the previous one. - */ -function RoutedContent() { - const { pathname } = useLocation(); - return ( - - - - ); -} - /** * The portal, mounted as a route-set under /portal/* inside the editor app (via * the admin-route seam). It supplies its own providers and its own i18next * instance (the `portal` namespace), but NOT a router — the editor's * is the one and only router; the portal's routes are relative * to the /portal mount (see ViewRouter). + * + * The provider stack itself is a per-flavor seam (see {@link PortalProviders}): + * self-hosted mounts the account-link layer, SaaS does not. */ export function PortalApp() { return ( @@ -127,29 +32,9 @@ export function PortalApp() { {/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
    - - - {/* TierProvider sits INSIDE LinkProvider so it can derive the tier - from the real link/subscription state when MSW mocks are off. */} - - - - - - - - - - - - - - - - - - - + + +
    diff --git a/frontend/editor/src/portal/PortalProviders.tsx b/frontend/editor/src/portal/PortalProviders.tsx new file mode 100644 index 0000000000..f03092fde8 --- /dev/null +++ b/frontend/editor/src/portal/PortalProviders.tsx @@ -0,0 +1,63 @@ +import { TierProvider } from "@portal/contexts/TierContext"; +import { LinkProvider, useLink } from "@portal/contexts/LinkContext"; +import { UIProvider, useUI } from "@portal/contexts/UIContext"; +import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin"; +import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; +import { + AccountLinkProvider, + useAccountLinkContext, +} from "@portal/contexts/AccountLinkContext"; +import { PortalChrome } from "@portal/components/PortalChrome"; + +/** + * The one and only account-link login modal. Mounted at the app root (never + * nested in another overlay) and driven by UIContext, so any "Link account" CTA + * — sidebar, billing prompt, feature gate, Settings panel — opens this exact + * instance. Linking is finished by the shared {@link useAccountLinkContext} + * orchestration. + */ +function LinkModalHost() { + const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); + const { markSaasSessionChanged } = useLink(); + const link = useAccountLinkContext(); + // "reauth" only refreshes the browser SaaS session for attended reads — the + // sign-in already applied it to the Supabase client, so we just signal a + // refetch. It must NOT call completeLink (that re-registers → duplicate row). + const onLinked = + linkModalMode === "reauth" + ? () => markSaasSessionChanged() + : (session: SupabaseLoginSession) => link.completeLink(session); + return ( + + ); +} + +/** + * Self-hosted provider stack. The account-link layer (LinkProvider + + * AccountLinkProvider + the login modal) wraps the shared chrome; the tier is + * derived from the link/subscription state (see usePlanTier). TierProvider sits + * inside LinkProvider because the self-hosted usePlanTier reads useLink. + * + * The SaaS build shadows this file to drop the account-link layer entirely — the + * signed-in account IS the SaaS account, so there is nothing to link and the + * tier comes from the wallet. + */ +export function PortalProviders() { + return ( + + + + + + + + + + + ); +} diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 2b09a24507..62502e6c67 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -9,7 +9,7 @@ import { Policies } from "@portal/views/Policies"; import { Components } from "@portal/views/Components"; import { EditorAdmin } from "@portal/views/EditorAdmin"; import { Infrastructure } from "@portal/views/Infrastructure"; -import { Usage } from "@portal/views/Usage"; +import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; import { DeveloperDocs } from "@portal/views/DeveloperDocs"; import { Procurement } from "@portal/views/Procurement"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; @@ -39,7 +39,7 @@ export function ViewRouter() { path={rel(VIEW_PATHS.infrastructure)} element={} /> - } /> + } /> } /> } /> {/* Account-link is now a Settings panel; redirect legacy bookmarks home. */} diff --git a/frontend/editor/src/portal/api/http.ts b/frontend/editor/src/portal/api/http.ts index 9f7b28da03..2dfa914890 100644 --- a/frontend/editor/src/portal/api/http.ts +++ b/frontend/editor/src/portal/api/http.ts @@ -40,14 +40,17 @@ * admin and uses the Supabase JWT for SaaS reads. Don't add it here. */ import { clearStoredToken, getStoredToken } from "@app/auth"; -import { getSupabaseClient } from "@app/auth/supabase/supabaseClient"; -import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; +import { getPortalSaasToken } from "@portal/auth/portalSaasSession"; +import { saasApiBase } from "@portal/api/saasApiBase"; -/** Read the SaaS base URL at call time so tests can stub it via vi.stubEnv. */ +/** + * SaaS base URL via the flavor seam: self-hosted reads VITE_SAAS_API_URL (a + * separate cloud backend); the SaaS build reuses the editor's single + * VITE_API_BASE_URL (in SaaS everything is the SaaS backend). {@code null} means + * not configured — a self-hosted-only state; the SaaS seam never returns null. + */ function saasBaseUrl(): string | null { - const raw = import.meta.env.VITE_SAAS_API_URL; - if (!raw) return null; - return raw.replace(/\/+$/, ""); + return saasApiBase(); } export interface HttpRequestOptions { @@ -169,21 +172,14 @@ async function localJson( // saas — hosted SaaS Java, admin's Supabase JWT // ──────────────────────────────────────────────────────────────────────────── -async function getSaasAccessToken(): Promise { - ensureSaasSupabase(); - const supabase = getSupabaseClient(); - if (!supabase) return null; - const { data } = await supabase.auth.getSession(); - return data.session?.access_token ?? null; -} - async function saasJson( path: string, options: HttpRequestOptions = {}, ): Promise { const base = saasBaseUrl(); - if (!base) throw new SaasUnconfiguredError(); - const token = await getSaasAccessToken(); + // null = unset (self-hosted, no VITE_SAAS_API_URL). "" is same-origin (SaaS) — valid. + if (base === null) throw new SaasUnconfiguredError(); + const token = await getPortalSaasToken(); if (!token) throw new SaasNotLinkedError(); const res = await fetch(`${base}${path}`, { method: options.method ?? "GET", @@ -213,7 +209,7 @@ export const apiClient = { /** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */ saas: { json: saasJson, - /** True when VITE_SAAS_API_URL is set. Doesn't check session liveness. */ - isConfigured: (): boolean => Boolean(saasBaseUrl()), + /** True when a SaaS base URL is resolvable. Doesn't check session liveness. */ + isConfigured: (): boolean => saasBaseUrl() !== null, }, } as const; diff --git a/frontend/editor/src/portal/api/saasApiBase.ts b/frontend/editor/src/portal/api/saasApiBase.ts new file mode 100644 index 0000000000..3514ac3c79 --- /dev/null +++ b/frontend/editor/src/portal/api/saasApiBase.ts @@ -0,0 +1,18 @@ +/** + * Base URL for attended portal→SaaS reads ({@code apiClient.saas}) — the seam the + * SaaS build overrides. + * + *

    Self-hosted (this base): the SaaS cloud is a separate backend from + * this instance's local one, configured via {@code VITE_SAAS_API_URL}. Returns + * {@code null} when unset so {@code apiClient.saas} can surface a clear + * "configure" state. A set value has any trailing slash trimmed. + * + *

    The SaaS build shadows this to reuse the editor's single backend + * ({@code VITE_API_BASE_URL}) — in SaaS everything is the SaaS backend, so there + * is no separate SaaS URL. + */ +export function saasApiBase(): string | null { + const raw = import.meta.env.VITE_SAAS_API_URL; + if (!raw) return null; + return raw.replace(/\/+$/, ""); +} diff --git a/frontend/editor/src/portal/auth/PortalAuthBoundary.tsx b/frontend/editor/src/portal/auth/PortalAuthBoundary.tsx new file mode 100644 index 0000000000..df24718eda --- /dev/null +++ b/frontend/editor/src/portal/auth/PortalAuthBoundary.tsx @@ -0,0 +1,19 @@ +import { type ReactNode } from "react"; +import { AuthProvider } from "@app/auth"; +import { AuthGate } from "@portal/components/AuthGate"; + +/** + * Portal auth wiring — the seam the SaaS build overrides. + * + *

    Self-hosted (this base): the portal is its own standalone app, so it owns a + * Spring {@link AuthProvider} and a Spring login gate ({@link AuthGate}). The SaaS + * build shadows this file to authenticate against the SaaS Supabase project (the + * same session the editor uses) with no Spring login and no account-link step. + */ +export function PortalAuthBoundary({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/editor/src/portal/auth/portalSaasSession.test.ts b/frontend/editor/src/portal/auth/portalSaasSession.test.ts new file mode 100644 index 0000000000..1b12358b38 --- /dev/null +++ b/frontend/editor/src/portal/auth/portalSaasSession.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted so the vi.mock factories can reference it before the import runs. +const { state } = vi.hoisted(() => ({ + state: { client: null as { auth: { getSession: () => unknown } } | null }, +})); + +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => state.client, +})); +vi.mock("@portal/auth/saasSupabase", () => ({ + ensureSaasSupabase: vi.fn(), +})); + +import { getPortalSaasToken } from "@portal/auth/portalSaasSession"; + +describe("getPortalSaasToken — self-hosted (account-link login)", () => { + beforeEach(() => { + state.client = null; + }); + + it("returns null when the SaaS client isn't configured (not signed in)", async () => { + expect(await getPortalSaasToken()).toBeNull(); + }); + + it("returns the access token from the current session", async () => { + state.client = { + auth: { + getSession: async () => ({ + data: { session: { access_token: "tok-123" } }, + }), + }, + }; + expect(await getPortalSaasToken()).toBe("tok-123"); + }); + + it("returns null when there is a client but no active session", async () => { + state.client = { + auth: { getSession: async () => ({ data: { session: null } }) }, + }; + expect(await getPortalSaasToken()).toBeNull(); + }); +}); diff --git a/frontend/editor/src/portal/auth/portalSaasSession.ts b/frontend/editor/src/portal/auth/portalSaasSession.ts new file mode 100644 index 0000000000..eb75cc7650 --- /dev/null +++ b/frontend/editor/src/portal/auth/portalSaasSession.ts @@ -0,0 +1,19 @@ +import { getSupabaseClient } from "@app/auth/supabase/supabaseClient"; +import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; + +/** + * SaaS access token for attended portal→SaaS reads. Reads the shared Supabase + * client, which {@link ensureSaasSupabase} configures against the one Stirling + * Supabase project (VITE_SUPABASE_*) both flavors talk to: self-hosted mints a + * SaaS JWT in-app for account-linking (alongside its own Spring session); SaaS is + * already signed into that same project, so the editor session is inherited. + * Flavor-agnostic — no per-flavor override needed. Returns null until a session + * exists. + */ +export async function getPortalSaasToken(): Promise { + ensureSaasSupabase(); + const supabase = getSupabaseClient(); + if (!supabase) return null; + const { data } = await supabase.auth.getSession(); + return data.session?.access_token ?? null; +} diff --git a/frontend/editor/src/portal/auth/saasSupabase.test.ts b/frontend/editor/src/portal/auth/saasSupabase.test.ts new file mode 100644 index 0000000000..b748cdd150 --- /dev/null +++ b/frontend/editor/src/portal/auth/saasSupabase.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// Mock only the low-level client factory — NOT ensureSaasSupabase itself — so this +// exercises the real configurator and proves it wires the shared client from the +// one Stirling Supabase env (VITE_SUPABASE_*), shared by every flavor. +const configureSupabase = vi.fn(); +const getSupabaseClient = vi.fn(() => ({ __client: true })); +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + configureSupabase, + getSupabaseClient, +})); + +describe("ensureSaasSupabase — configures the shared client from VITE_SUPABASE_*", () => { + afterEach(() => { + vi.resetModules(); + vi.unstubAllEnvs(); + configureSupabase.mockClear(); + getSupabaseClient.mockClear(); + }); + + it("configures from VITE_SUPABASE_* and returns the client", async () => { + vi.stubEnv("VITE_SUPABASE_URL", "https://proj.supabase.co"); + vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY", "anon-key"); + // Dynamic import so the module reads the stubbed env at load. + const { ensureSaasSupabase, isSaasSupabaseConfigured } = + await import("@portal/auth/saasSupabase"); + expect(isSaasSupabaseConfigured).toBe(true); + const client = ensureSaasSupabase(); + expect(configureSupabase).toHaveBeenCalledWith({ + url: "https://proj.supabase.co", + key: "anon-key", + }); + expect(client).not.toBeNull(); + }); + + it("stays unconfigured (client null) when the Supabase env is absent", async () => { + vi.stubEnv("VITE_SUPABASE_URL", ""); + vi.stubEnv("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY", ""); + const { ensureSaasSupabase, isSaasSupabaseConfigured } = + await import("@portal/auth/saasSupabase"); + expect(isSaasSupabaseConfigured).toBe(false); + expect(ensureSaasSupabase()).toBeNull(); + expect(configureSupabase).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/auth/saasSupabase.ts b/frontend/editor/src/portal/auth/saasSupabase.ts index 813a2f7cad..ff3ad71b8b 100644 --- a/frontend/editor/src/portal/auth/saasSupabase.ts +++ b/frontend/editor/src/portal/auth/saasSupabase.ts @@ -4,18 +4,21 @@ import { } from "@app/auth/supabase/supabaseClient"; /** - * Configures the shared Supabase client against the hosted SaaS project so the - * portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a + * Configures the shared Supabase client against the Stirling Supabase project so + * the portal can mint a SaaS JWT IN-APP for account linking (no popup). This is a * separate, transient SaaS auth — the portal's own session stays Spring (the * local instance admin); calls to the local backend still carry the Spring * bearer, and the SaaS JWT is passed only in the link request body. * - * Config: VITE_SAAS_SUPABASE_URL + VITE_SAAS_SUPABASE_ANON_KEY (both public). - * Absent → {@link isSaasSupabaseConfigured} is false and the link UI degrades to - * a "configure the SaaS Supabase URL" state. + * Config: VITE_SUPABASE_URL + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY (both public) + * — the one Stirling Supabase project every flavor talks to (same vars the editor + * and proprietary billing client use; there is no separate SaaS project). SaaS + * needs no per-flavor override: the signed-in editor session is on this same + * project, so the client picks it up. Absent → {@link isSaasSupabaseConfigured} + * is false and the link UI degrades to a "configure Supabase" state. */ -const url = import.meta.env.VITE_SAAS_SUPABASE_URL; -const key = import.meta.env.VITE_SAAS_SUPABASE_ANON_KEY; +const url = import.meta.env.VITE_SUPABASE_URL; +const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; export const isSaasSupabaseConfigured = Boolean(url && key); diff --git a/frontend/editor/src/portal/billing/stripe.ts b/frontend/editor/src/portal/billing/stripe.ts index 91e0290d72..930ed3fef3 100644 --- a/frontend/editor/src/portal/billing/stripe.ts +++ b/frontend/editor/src/portal/billing/stripe.ts @@ -71,7 +71,7 @@ async function invoke( const supabase = getSupabaseClient(); if (!supabase) { throw new StripeFunctionError( - "SaaS Supabase not configured — set VITE_SAAS_SUPABASE_URL.", + "SaaS Supabase not configured — set VITE_SUPABASE_URL.", "unconfigured", ); } diff --git a/frontend/editor/src/portal/components/LinkAccountFooterItem.tsx b/frontend/editor/src/portal/components/LinkAccountFooterItem.tsx new file mode 100644 index 0000000000..fd3e9d96a0 --- /dev/null +++ b/frontend/editor/src/portal/components/LinkAccountFooterItem.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import { NavItem } from "@app/ui"; +import { useUI } from "@portal/contexts/UIContext"; +import { useLink } from "@portal/contexts/LinkContext"; +import { LinkIcon } from "@portal/components/icons"; + +/** + * Sidebar-footer link-account CTA. Only visible when the org is unlinked — once + * linked, the linked-instances row + plan badge already communicate the state, + * so a permanent footer button would be noise. Click → opens the login modal + * directly. The SaaS build shadows this file with a no-op: the signed-in account + * IS the SaaS account, so there is nothing to link. + */ +export function LinkAccountFooterItem() { + const { t } = useTranslation(); + const { openLinkModal } = useUI(); + const { linkState } = useLink(); + if (linkState !== "unlinked") return null; + return ( + } + onClick={() => openLinkModal()} + /> + ); +} diff --git a/frontend/editor/src/portal/components/PortalChrome.tsx b/frontend/editor/src/portal/components/PortalChrome.tsx new file mode 100644 index 0000000000..9e2ed8d359 --- /dev/null +++ b/frontend/editor/src/portal/components/PortalChrome.tsx @@ -0,0 +1,84 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { ErrorBoundary } from "@portal/components/ErrorBoundary"; +import { useUI } from "@portal/contexts/UIContext"; +import { AppShell } from "@portal/components/AppShell"; +import { AssistantButton } from "@portal/components/AssistantButton"; +import { AssistantPanel } from "@portal/components/AssistantPanel"; +import { SearchModal } from "@portal/components/SearchModal"; +import { SettingsModal } from "@portal/components/SettingsModal"; +import { ViewRouter } from "@portal/ViewRouter"; + +/** + * Global keyboard shortcuts. Lives below the UIProvider so it can dispatch into + * the overlay state. Currently just ⌘K / Ctrl+K to toggle the search palette. + */ +function GlobalShortcuts() { + const { toggleSearch, closeSearch } = useUI(); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k"; + if (isCmdK) { + e.preventDefault(); + toggleSearch(); + return; + } + if (e.key === "Escape") { + closeSearch(); + } + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [toggleSearch, closeSearch]); + + return null; +} + +/** Bridges the Settings modal's open/close props to UIContext state. */ +function SettingsHost() { + const { settingsOpen, settingsInitialSection, closeSettings } = useUI(); + return ( + + ); +} + +/** + * The routed view, wrapped in an error boundary so a single view crashing can't + * white-screen the portal (the shell + nav stay alive). Keyed by route so + * navigating to another section clears any error from the previous one. + */ +function RoutedContent() { + const { pathname } = useLocation(); + return ( + + + + ); +} + +/** + * The flavor-agnostic portal chrome: the shell (sidebar + header + routed view) + * plus the global overlays that every flavor shares. Requires only the Tier and + * UI contexts above it — both flavors provide those. Flavor-specific overlays + * (e.g. the self-hosted account-link modal) are mounted by PortalProviders, not + * here. + */ +export function PortalChrome() { + return ( + <> + + + + + + + + + + ); +} diff --git a/frontend/editor/src/portal/components/SettingsModal.tsx b/frontend/editor/src/portal/components/SettingsModal.tsx index 3073d243f0..eb2380a9a9 100644 --- a/frontend/editor/src/portal/components/SettingsModal.tsx +++ b/frontend/editor/src/portal/components/SettingsModal.tsx @@ -31,9 +31,8 @@ import { PoliciesIcon, InfrastructureIcon, SparklesIcon, - LinkIcon, } from "@portal/components/icons"; -import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel"; +import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings"; import "@portal/components/SettingsModal.css"; type SettingsSection = @@ -150,11 +149,17 @@ export function SettingsModal({ { title: t("portal.settings.groups.admin"), items: [ - { - key: "account-link", - label: t("portal.settings.sections.account-link"), - icon: , - }, + // Account-link is a self-hosted-only section; the SaaS build shadows + // the seam to null, dropping the item entirely. + ...(accountLinkSettings + ? [ + { + key: accountLinkSettings.navKey, + label: t(accountLinkSettings.labelKey), + icon: accountLinkSettings.icon, + }, + ] + : []), { key: "authentication", label: t("portal.settings.sections.authentication"), @@ -337,7 +342,9 @@ export function SettingsModal({ /> )} - {section === "account-link" && } + {section === "account-link" && accountLinkSettings && ( + + )} ); diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 675e48561f..486611248b 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -4,7 +4,7 @@ import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useTier } from "@portal/contexts/TierContext"; import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; -import { useLink } from "@portal/contexts/LinkContext"; +import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { useAsync } from "@portal/hooks/useAsync"; import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; import { EDITOR_URL } from "@portal/auth/editorUrl"; @@ -20,7 +20,6 @@ import { ComponentsIcon, InfrastructureIcon, UsageIcon, - LinkIcon, DocsIcon, SettingsIcon, ChevronDownIcon, @@ -49,27 +48,6 @@ const GROUP_PLATFORM: NavEntry[] = [ { id: "docs", icon: }, ]; -/** - * Sidebar-footer link-account CTA. Only visible when the org is unlinked — once - * linked, the linked-instances row + plan badge already communicate the state, - * so a permanent footer button would be noise. Click → opens the login modal - * directly. - */ -function LinkAccountFooterItem() { - const { t } = useTranslation(); - const { openLinkModal } = useUI(); - const { linkState } = useLink(); - if (linkState !== "unlinked") return null; - return ( - } - onClick={() => openLinkModal()} - /> - ); -} - function UsageFooter() { const { tier } = useTier(); const { t } = useTranslation(); diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountCard.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountCard.tsx index fa79ed2c72..af43640905 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountCard.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountCard.tsx @@ -49,7 +49,7 @@ export function LinkAccountCard({ link }: Props) { )} > {t("portal.accountLink.card.loginNotConfigured.before", "Set")}{" "} - VITE_SAAS_SUPABASE_URL{" "} + VITE_SUPABASE_URL{" "} {t( "portal.accountLink.card.loginNotConfigured.after", "to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog.", diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx index 3f8da872c8..e46504d309 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx @@ -96,9 +96,9 @@ export function LinkAccountModal({ )} > {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} - VITE_SAAS_SUPABASE_URL{" "} + VITE_SUPABASE_URL{" "} {t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "} - VITE_SAAS_SUPABASE_ANON_KEY{" "} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} {t( "portal.accountLink.modal.loginNotConfigured.after", "to enable in-app linking against the hosted Stirling account.", diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx new file mode 100644 index 0000000000..f15d8ade51 --- /dev/null +++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +const linkState = { isLinked: false }; +vi.mock("@portal/contexts/LinkContext", () => ({ + useLink: () => linkState, + useApplyLinkFacts: () => vi.fn(), +})); +vi.mock("@portal/contexts/UIContext", () => ({ + useUI: () => ({ openLinkModal: vi.fn() }), +})); +vi.mock("@portal/components/billing/LinkAccountPrompt", () => ({ + LinkAccountPrompt: () =>

    , +})); +vi.mock("@portal/views/Usage", () => ({ + Usage: () =>
    , +})); + +import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; + +describe("PortalBillingGate — self-hosted", () => { + beforeEach(() => { + linkState.isLinked = false; + }); + + it("shows the link prompt when unlinked (billing gated on link)", () => { + linkState.isLinked = false; + render(); + expect(screen.getByTestId("link-prompt")).toBeInTheDocument(); + expect(screen.queryByTestId("usage")).not.toBeInTheDocument(); + }); + + it("renders the Usage page once linked", () => { + linkState.isLinked = true; + render(); + expect(screen.getByTestId("usage")).toBeInTheDocument(); + expect(screen.queryByTestId("link-prompt")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx new file mode 100644 index 0000000000..e448cfb93a --- /dev/null +++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx @@ -0,0 +1,32 @@ +import { useCallback } from "react"; +import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; +import { Usage } from "@portal/views/Usage"; +import type { Wallet } from "@portal/api/billing"; + +/** + * Billing access gate — the seam the SaaS build overrides. + * + *

    Self-hosted (this base): billing only makes sense once the instance has + * linked its SaaS account, so gate on link state — unlinked shows the link prompt; + * linked renders the (flavor-agnostic) Usage page and maps its callbacks onto the + * link/tier dimension: the wallet's subscription status refines the plan/tier + * badge, and a lapsed SaaS session re-opens the account-link re-auth. This keeps + * the "link" concept entirely out of the Usage page. The SaaS build shadows this + * with a passthrough — there is no linking there. + */ +export function PortalBillingGate() { + const { isLinked } = useLink(); + const applyLinkFacts = useApplyLinkFacts(); + const { openLinkModal } = useUI(); + + const onWalletLoaded = useCallback( + (w: Wallet) => applyLinkFacts(true, w.status === "subscribed"), + [applyLinkFacts], + ); + const onReauth = useCallback(() => openLinkModal("reauth"), [openLinkModal]); + + if (!isLinked) return ; + return ; +} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx index 3c0498b71b..641bd55599 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, Card, EmptyState, Skeleton } from "@app/ui"; -import { useLink } from "@portal/contexts/LinkContext"; +import { usePortalLinked } from "@portal/contexts/usePortalLinked"; import { useUI } from "@portal/contexts/UIContext"; import { useView } from "@portal/contexts/ViewContext"; import { useAsync } from "@portal/hooks/useAsync"; @@ -46,7 +46,7 @@ import "@portal/views/Procurement.css"; */ export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) { const { t } = useTranslation(); - const { isLinked } = useLink(); + const isLinked = usePortalLinked(); const { openLinkModal } = useUI(); const { setActiveView } = useView(); diff --git a/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx b/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx new file mode 100644 index 0000000000..8e769672d7 --- /dev/null +++ b/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx @@ -0,0 +1,26 @@ +import type { ComponentType, ReactNode } from "react"; +import { LinkIcon } from "@portal/components/icons"; +import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel"; + +export interface AccountLinkSettingsSeam { + /** Section key in the Settings nav + body switch. */ + navKey: string; + /** i18n key for the nav label; resolved with `t()` at the call site. */ + labelKey: string; + icon: ReactNode; + /** The section body — the account-link panel. */ + Body: ComponentType; +} + +/** + * The admin "Account link" section of Settings (self-hosted only). The SaaS + * build shadows this file with `null`: the signed-in account IS the SaaS + * account, so there is no instance to link — the nav item and its panel both + * drop out, and nothing imports the link-only AccountLinkPanel. + */ +export const accountLinkSettings: AccountLinkSettingsSeam | null = { + navKey: "account-link", + labelKey: "portal.settings.sections.account-link", + icon: , + Body: AccountLinkPanel, +}; diff --git a/frontend/editor/src/portal/contexts/TierContext.tsx b/frontend/editor/src/portal/contexts/TierContext.tsx index 5f83159758..1b4742447b 100644 --- a/frontend/editor/src/portal/contexts/TierContext.tsx +++ b/frontend/editor/src/portal/contexts/TierContext.tsx @@ -7,7 +7,7 @@ import { type ReactNode, } from "react"; import { readMocksPreference } from "@portal/mocks/preference"; -import { useLink, type LinkState } from "@portal/contexts/LinkContext"; +import { usePlanTier } from "@portal/contexts/usePlanTier"; export type Tier = "free" | "pro" | "enterprise"; @@ -34,17 +34,6 @@ interface TierContextValue { const TierContext = createContext(null); -/** Maps the real link/subscription state onto the tier the rest of the portal reads. */ -function tierFromLinkState(linkState: LinkState): Tier { - switch (linkState) { - case "linked-subscribed": - return "pro"; - case "linked-free": - case "unlinked": - return "free"; - } -} - export function TierProvider({ children, initialTier = "pro", @@ -55,28 +44,30 @@ export function TierProvider({ // Mocks toggling reloads the page (see MocksToggle), so a single read at mount // is correct — the preference can't change without us remounting. const mocksOn = useMemo(() => readMocksPreference(), []); - const { linkState } = useLink(); + // Real derived tier. Its source is a per-flavor seam: self-hosted derives it + // from the link/subscription state, SaaS from the wallet (see usePlanTier). + const derivedTier = usePlanTier(); const [mockTier, setMockTier] = useState(initialTier); - // When mocks are off, mirror the real link state into the tier so any - // component still keyed on `tier` (sidebar plan badge, gated panels) stays - // consistent with the wallet. When mocks are on, the dropdown wins. + // When mocks are off, mirror the derived tier so any component keyed on `tier` + // (sidebar plan badge, gated panels) stays consistent. When mocks are on, the + // dropdown wins. useEffect(() => { if (!mocksOn) { - setMockTier(tierFromLinkState(linkState)); + setMockTier(derivedTier); } - }, [mocksOn, linkState]); + }, [mocksOn, derivedTier]); const value = useMemo( () => ({ - tier: mocksOn ? mockTier : tierFromLinkState(linkState), + tier: mocksOn ? mockTier : derivedTier, // Setter is a no-op when mocks are off — UI controls can disable themselves // via `isDerived`, but even if one slips through, it has no effect. setTier: mocksOn ? setMockTier : () => {}, isDerived: !mocksOn, }), - [mocksOn, mockTier, linkState], + [mocksOn, mockTier, derivedTier], ); return {children}; diff --git a/frontend/editor/src/portal/contexts/usePlanTier.test.tsx b/frontend/editor/src/portal/contexts/usePlanTier.test.tsx new file mode 100644 index 0000000000..b89022ff92 --- /dev/null +++ b/frontend/editor/src/portal/contexts/usePlanTier.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { usePlanTier } from "@portal/contexts/usePlanTier"; + +function Probe() { + return {usePlanTier()}; +} + +function renderTierFor(initialState: LinkState) { + return render( + + + , + ).getByTestId("tier").textContent; +} + +describe("usePlanTier (self-hosted) — tier from link state", () => { + it("unlinked → free", () => { + expect(renderTierFor("unlinked")).toBe("free"); + }); + it("linked-free → free", () => { + expect(renderTierFor("linked-free")).toBe("free"); + }); + it("linked-subscribed → pro", () => { + expect(renderTierFor("linked-subscribed")).toBe("pro"); + }); +}); diff --git a/frontend/editor/src/portal/contexts/usePlanTier.ts b/frontend/editor/src/portal/contexts/usePlanTier.ts new file mode 100644 index 0000000000..47598f12c3 --- /dev/null +++ b/frontend/editor/src/portal/contexts/usePlanTier.ts @@ -0,0 +1,24 @@ +import { useLink, type LinkState } from "@portal/contexts/LinkContext"; +import type { Tier } from "@portal/contexts/TierContext"; + +/** Maps the self-hosted link/subscription state onto the portal tier. */ +function tierFromLinkState(linkState: LinkState): Tier { + switch (linkState) { + case "linked-subscribed": + return "pro"; + case "linked-free": + case "unlinked": + return "free"; + } +} + +/** + * The plan tier the portal runs on, self-hosted flavor: derived from whether the + * org has linked its SaaS account and carries a live subscription. The SaaS + * build shadows this file to derive the tier from the wallet instead — there is + * no link concept there (see portal-saas/contexts/usePlanTier). + */ +export function usePlanTier(): Tier { + const { linkState } = useLink(); + return tierFromLinkState(linkState); +} diff --git a/frontend/editor/src/portal/contexts/usePortalLinked.test.tsx b/frontend/editor/src/portal/contexts/usePortalLinked.test.tsx new file mode 100644 index 0000000000..37f7f799b3 --- /dev/null +++ b/frontend/editor/src/portal/contexts/usePortalLinked.test.tsx @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { render } from "@testing-library/react"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { usePortalLinked } from "@portal/contexts/usePortalLinked"; + +function Probe() { + return {String(usePortalLinked())}; +} + +function renderLinkedFor(initialState: LinkState) { + return render( + + + , + ).getByTestId("linked").textContent; +} + +describe("usePortalLinked (self-hosted) — gated on the account link", () => { + it("unlinked → false", () => { + expect(renderLinkedFor("unlinked")).toBe("false"); + }); + it("linked-free → true", () => { + expect(renderLinkedFor("linked-free")).toBe("true"); + }); + it("linked-subscribed → true", () => { + expect(renderLinkedFor("linked-subscribed")).toBe("true"); + }); +}); diff --git a/frontend/editor/src/portal/contexts/usePortalLinked.ts b/frontend/editor/src/portal/contexts/usePortalLinked.ts new file mode 100644 index 0000000000..b7501ad128 --- /dev/null +++ b/frontend/editor/src/portal/contexts/usePortalLinked.ts @@ -0,0 +1,12 @@ +import { useLink } from "@portal/contexts/LinkContext"; + +/** + * Whether the portal is authorized against a SaaS billing account — the gate the + * procurement/checkout flow uses. Self-hosted flavor: true once the instance has + * linked its SaaS account. The SaaS build shadows this file to return true + * unconditionally: the signed-in account IS the SaaS account, so there is no link + * step and nothing to gate on. + */ +export function usePortalLinked(): boolean { + return useLink().isLinked; +} diff --git a/frontend/editor/src/portal/tsconfig.json b/frontend/editor/src/portal/tsconfig.json index 2eaab73a90..64fef64f42 100644 --- a/frontend/editor/src/portal/tsconfig.json +++ b/frontend/editor/src/portal/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "paths": { "@app/*": [ - "../../src/portal/*", "../../src/cloud/*", "../../src/proprietary/*", "../../src/core/*" diff --git a/frontend/editor/src/portal/views/Usage.test.tsx b/frontend/editor/src/portal/views/Usage.test.tsx new file mode 100644 index 0000000000..1df8b0525e --- /dev/null +++ b/frontend/editor/src/portal/views/Usage.test.tsx @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import type { ReactElement } from "react"; + +// Usage renders Mantine-backed @app/ui components (e.g. the "Manage Payment" +// Button in the subscribed header), which need a MantineProvider in the tree. +const renderUsage = (ui: ReactElement) => + render({ui}); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, def?: string) => def ?? key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchWallet = vi.fn(); +const refreshWalletCache = vi.fn(); +vi.mock("@portal/api/billing", () => ({ + fetchWallet: () => fetchWallet(), + refreshWalletCache: () => refreshWalletCache(), +})); +vi.mock("@portal/api/link", () => ({ + fetchLocalUsage: () => Promise.resolve(null), + triggerLocalSync: () => Promise.resolve(), +})); +vi.mock("@portal/hooks/useStripePortal", () => ({ + useStripePortal: () => ({ opening: false, open: vi.fn(), error: null }), +})); +// Stub the plan views so the test doesn't depend on the full wallet shape. +vi.mock("@portal/components/billing/FreePlanView", () => ({ + FreePlanView: () => null, +})); +vi.mock("@portal/components/billing/SubscribedPlanView", () => ({ + SubscribedPlanView: () => null, +})); + +import { Usage } from "@portal/views/Usage"; + +describe("Usage — link-free wallet renderer", () => { + beforeEach(() => { + fetchWallet.mockReset(); + refreshWalletCache.mockReset(); + }); + + it("loads the wallet on mount and reports it via onWalletLoaded (no link gate)", async () => { + fetchWallet.mockResolvedValue({ status: "free" }); + const onWalletLoaded = vi.fn(); + + renderUsage(); + + // Renders immediately (no link prompt / login) and loads unconditionally. + expect(screen.getByText("Usage & billing")).toBeInTheDocument(); + await waitFor(() => + expect(onWalletLoaded).toHaveBeenCalledWith({ status: "free" }), + ); + }); + + it("works with no callbacks (SaaS passes none)", async () => { + fetchWallet.mockResolvedValue({ status: "subscribed" }); + + renderUsage(); + + await waitFor(() => expect(fetchWallet).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/frontend/editor/src/portal/views/Usage.tsx b/frontend/editor/src/portal/views/Usage.tsx index 4a4493aa2c..776b210951 100644 --- a/frontend/editor/src/portal/views/Usage.tsx +++ b/frontend/editor/src/portal/views/Usage.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Banner, Button, Skeleton } from "@app/ui"; -import { useLink } from "@portal/contexts/LinkContext"; -import { useUI } from "@portal/contexts/UIContext"; import { fetchWallet, refreshWalletCache, @@ -14,7 +12,6 @@ import { type LocalUsage, } from "@portal/api/link"; import { useStripePortal } from "@portal/hooks/useStripePortal"; -import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; import { FreePlanView } from "@portal/components/billing/FreePlanView"; import { SubscribedPlanView } from "@portal/components/billing/SubscribedPlanView"; import { @@ -25,33 +22,44 @@ import { import "@portal/views/Usage.css"; import "@portal/components/billing/billing.css"; +export interface UsageProps { + /** + * Called with the wallet whenever it loads (initial fetch + post-checkout + * flip). A flavor-agnostic hook the composition uses for cross-cutting state — + * self-hosted maps it onto the link/tier dimension; SaaS ignores it. + */ + onWalletLoaded?: (wallet: Wallet) => void; + /** + * Invoked when the SaaS session has lapsed and the user chooses to re-sign-in. + * When omitted, the "session expired" notice shows without a sign-in action. + * Self-hosted wires this to its re-auth flow; SaaS leaves it unset (its session + * is owned by the app, so this path never triggers). + */ + onReauth?: () => void; +} + /** - * Billing & usage page. State-driven by the link/subscription dimension — - * NOT by the legacy {@code tier} prop: + * Billing & usage page — a flavor-agnostic wallet renderer. Whether it should be + * shown at all (self-hosted only renders it once the instance is linked) is + * decided upstream by the billing gate; this component always loads the wallet + * and dispatches on {@code wallet.status}: * - * unlinked → LinkAccountPrompt - * linked-free → FreePlanView (free meter + PAYG explainer) - * linked-subscribed → SubscribedPlanView (period meter, cap, members, - * invoices, Stripe portal) + * free → FreePlanView (free meter + PAYG explainer) + * subscribed → SubscribedPlanView (period meter, cap, members, invoices) * * Wallet comes from {@code GET /api/v1/payg/wallet} (apiClient.saas). After a - * subscription flip via Stripe checkout / cancel via the portal, the - * onWalletChange refresh re-reads and the view re-dispatches on the new - * status. + * checkout / cancel, the refresh re-reads and the view re-dispatches on status. */ -export function Usage() { +export function Usage({ onWalletLoaded, onReauth }: UsageProps = {}) { const { t } = useTranslation(); - const { isLinked, setLinkState, saasSessionNonce } = useLink(); - const { openLinkModal } = useUI(); const [wallet, setWallet] = useState(null); // Locally-accrued usage SaaS hasn't billed yet; added to the synced figure so // "current usage" reflects work since the last daily sync. Best-effort. const [localUsage, setLocalUsage] = useState(null); - const [loading, setLoading] = useState(isLinked); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // The instance is linked but the browser's SaaS session has lapsed — needs a - // re-sign-in, NOT a re-link. - const [needsReauth, setNeedsReauth] = useState(false); + // The SaaS session has lapsed and needs a re-sign-in (self-hosted only). + const [sessionExpired, setSessionExpired] = useState(false); const [refreshKey, setRefreshKey] = useState(0); // Stripe customer portal — the subscribed header's "Manage Payment" action. const portal = useStripePortal(wallet); @@ -65,20 +73,10 @@ export function Usage() { }, []); useEffect(() => { - // Only fetch the wallet when the instance is linked. Unlinked → render the - // link prompt; no SaaS call needed. - if (!isLinked) { - setWallet(null); - setLocalUsage(null); - setLoading(false); - setError(null); - setNeedsReauth(false); - return; - } let cancelled = false; setLoading(true); setError(null); - setNeedsReauth(false); + setSessionExpired(false); // Independent of the wallet load — a local-usage failure must not break the // page; it just means no unsynced delta is shown. fetchLocalUsage() @@ -92,18 +90,13 @@ export function Usage() { .then((w) => { if (cancelled) return; setWallet(w); - // Derive the linked-free / linked-subscribed dimension from the live - // wallet. Only refines a `linked-*` state; never flips unlinked → linked. - setLinkState( - w.status === "subscribed" ? "linked-subscribed" : "linked-free", - ); + onWalletLoaded?.(w); }) .catch((e) => { if (cancelled) return; if (e instanceof SaasNotLinkedError) { - // Reached only when the instance IS linked (we don't fetch otherwise), - // so this means the attended SaaS session expired — prompt re-sign-in. - setNeedsReauth(true); + // The attended SaaS session expired — offer a re-sign-in. + setSessionExpired(true); } else if (e instanceof SaasUnconfiguredError) { setError(e.message); } else if (e instanceof HttpError) { @@ -127,7 +120,7 @@ export function Usage() { return () => { cancelled = true; }; - }, [isLinked, refreshKey, saasSessionNonce, setLinkState]); + }, [refreshKey, onWalletLoaded, t]); const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); @@ -146,9 +139,10 @@ export function Usage() { if (!mounted.current) return false; if (w.status === "subscribed") { setWallet(w); - setLinkState("linked-subscribed"); + onWalletLoaded?.(w); // Nudge the local instance to refresh its gate now so billable work - // unblocks immediately rather than on its next poll. Fire-and-forget. + // unblocks immediately rather than on its next poll. Fire-and-forget; + // a no-op on SaaS (no local instance to sync). triggerLocalSync().catch(() => {}); return true; } @@ -162,7 +156,7 @@ export function Usage() { // shows its "almost there" notice rather than the page silently self-healing. setRefreshKey((k) => k + 1); return false; - }, [setLinkState]); + }, [onWalletLoaded]); return (

    @@ -193,23 +187,23 @@ export function Usage() {
    - {!isLinked && } - - {isLinked && loading && ( + {loading && (
    )} - {isLinked && needsReauth && ( + {sessionExpired && ( openLinkModal("reauth")}> - {t("portal.usage.sessionExpired.action", "Sign in again")} - + onReauth ? ( + + ) : undefined } > {t( @@ -219,7 +213,7 @@ export function Usage() { )} - {isLinked && error && ( + {error && ( )} - {isLinked && portal.error && ( + {portal.error && ( )} - {isLinked && wallet && wallet.status === "free" && ( + {wallet && wallet.status === "free" && ( )} - {isLinked && wallet && wallet.status === "subscribed" && ( + {wallet && wallet.status === "subscribed" && ( interface ImportMetaEnv { - /** Hosted SaaS Supabase project URL — in-app account-link login. Empty → link UI shows a configure state. */ - readonly VITE_SAAS_SUPABASE_URL: string; - /** Hosted SaaS Supabase anon/publishable key (public). */ - readonly VITE_SAAS_SUPABASE_ANON_KEY: string; + /** Stirling Supabase project URL — in-app account-link login (same project every flavor uses). Empty → link UI shows a configure state. */ + readonly VITE_SUPABASE_URL: string; + /** Stirling Supabase publishable/anon key (public). */ + readonly VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: string; /** Hosted SaaS Java backend base URL — attended portal→SaaS reads (wallet, invoices, …) via apiClient.saas with the admin's JWT. */ readonly VITE_SAAS_API_URL: string; /** Stripe publishable key (pk_live_… / pk_test_…) used by embedded Checkout. */ diff --git a/frontend/editor/src/saas/auth/supabase.ts b/frontend/editor/src/saas/auth/supabase.ts index ad32cfa58d..1ea68e077e 100644 --- a/frontend/editor/src/saas/auth/supabase.ts +++ b/frontend/editor/src/saas/auth/supabase.ts @@ -1,22 +1,10 @@ import { createClient } from "@supabase/supabase-js"; -// Debug helper to log Supabase configuration -const debugConfig = () => { - const url = import.meta.env.VITE_SUPABASE_URL; - const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY; - - console.log("[Supabase Debug] Configuration:", { - url: url ? "✓ URL configured" : "✗ URL missing", - key: key ? "✓ Key configured" : "✗ Key missing", - urlValue: url || "undefined", - keyValue: key ? `${key.substring(0, 20)}...` : "undefined", - }); - - return { url, key }; +const config = { + url: import.meta.env.VITE_SUPABASE_URL, + key: import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY, }; -const config = debugConfig(); - if (!config.url) { throw new Error("Missing VITE_SUPABASE_URL environment variable"); } diff --git a/frontend/editor/src/saas/tsconfig.json b/frontend/editor/src/saas/tsconfig.json index 08beefbb77..a887c3c5c5 100644 --- a/frontend/editor/src/saas/tsconfig.json +++ b/frontend/editor/src/saas/tsconfig.json @@ -8,11 +8,19 @@ "../../src/proprietary/*", "../../src/core/*" ], - "@portal/*": ["../../src/portal/*"], + "@portal/*": ["../../src/portal-saas/*", "../../src/portal/*"], + "@portal-proprietary/*": ["../../src/portal/*"], "@cloud/*": ["../../src/cloud/*"], "@proprietary/*": ["../../src/proprietary/*"], "@core/*": ["../../src/core/*"] } }, - "include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."] + "include": [ + "../global.d.ts", + "../*.js", + "../*.ts", + "../*.tsx", + ".", + "../portal-saas" + ] } diff --git a/frontend/editor/tsconfig.portal.vite.json b/frontend/editor/tsconfig.portal.vite.json index 93337d1dcc..924d616019 100644 --- a/frontend/editor/tsconfig.portal.vite.json +++ b/frontend/editor/tsconfig.portal.vite.json @@ -3,12 +3,7 @@ "comment": "Path resolution for the portal's vitest project (referenced by vitest.config.ts). Broad include ('src', via the base) so vite-tsconfig-paths rewrites @app/* in every editor/src file the portal tests pull in (e.g. core/ui), not just the portal layer. The portal's own typecheck uses src/portal/tsconfig.json.", "compilerOptions": { "paths": { - "@app/*": [ - "./src/portal/*", - "./src/cloud/*", - "./src/proprietary/*", - "./src/core/*" - ], + "@app/*": ["./src/cloud/*", "./src/proprietary/*", "./src/core/*"], "@portal/*": ["./src/portal/*"], "@cloud/*": ["./src/cloud/*"], "@proprietary/*": ["./src/proprietary/*"], diff --git a/frontend/editor/tsconfig.saas.vite.json b/frontend/editor/tsconfig.saas.vite.json index c7d2c03eb2..f31279fac3 100644 --- a/frontend/editor/tsconfig.saas.vite.json +++ b/frontend/editor/tsconfig.saas.vite.json @@ -8,7 +8,8 @@ "./src/proprietary/*", "./src/core/*" ], - "@portal/*": ["./src/portal/*"], + "@portal/*": ["./src/portal-saas/*", "./src/portal/*"], + "@portal-proprietary/*": ["./src/portal/*"], "@cloud/*": ["./src/cloud/*"], "@proprietary/*": ["./src/proprietary/*"], "@core/*": ["./src/core/*"] diff --git a/frontend/editor/vitest.config.ts b/frontend/editor/vitest.config.ts index e4dfd76277..5d7a00bccf 100644 --- a/frontend/editor/vitest.config.ts +++ b/frontend/editor/vitest.config.ts @@ -104,7 +104,13 @@ export default defineConfig({ { test: { name: "saas", - include: ["src/saas/**/*.test.{ts,tsx}"], + // src/saas = editor-saas layer; src/portal-saas = the portal's saas + // overrides (sibling to src/portal). Both build under the saas flavor, + // so both resolve @portal via the saas cascade (tsconfig.saas.vite.json). + include: [ + "src/saas/**/*.test.{ts,tsx}", + "src/portal-saas/**/*.test.{ts,tsx}", + ], environment: "jsdom", globals: true, setupFiles: ["./src/saas/setupTests.ts"], diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 02f1c68132..707c411d14 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -6,7 +6,8 @@ import { defineConfig } from "eslint/config"; import tseslint from "typescript-eslint"; const srcGlobs = [ - // The portal layer lives under editor/src/portal, so editor/src/** covers it. + // The portal layers live under editor/src/portal (base) and + // editor/src/portal-saas (saas override), so editor/src/** covers them. "editor/src/**/*.{js,mjs,jsx,ts,tsx}", ]; const nodeGlobs = [ From c8f238ae601f71cd3bea194aed93d3818e225e3e Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:17:00 +0100 Subject: [PATCH 25/35] Portal/editor switcher (#6907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the portal's top-left app switcher to the editor sidebar, so you can jump between the two apps from either side. - Both sidebars render the same shared `AppSwitch` component (sui dropdown). - Switching is client-side (no page reload); portal→editor no longer breaks when `VITE_EDITOR_URL` is unset. - Editor side is admin-gated (`portalAccess`) and only exists in flavors that ship the portal — core/desktop stub it out, same seam pattern as the portal routes. - Fixes en route: dropdown menu stacking in the editor sidebar, sui dropdown item button reset, stale `dist-portal` ESLint ignore. --- .../src/core/components/shared/AppSwitch.css | 27 ++++++ .../src/core/components/shared/AppSwitch.tsx | 82 +++++++++++++++++++ .../core/components/shared/AppSwitcher.tsx | 8 ++ .../core/components/shared/FileSidebar.css | 12 +++ .../core/components/shared/FileSidebar.tsx | 12 +++ .../editor/src/core/routes/portalBasename.ts | 7 ++ frontend/editor/src/core/ui/Dropdown.css | 6 ++ .../desktop/components/shared/AppSwitcher.tsx | 9 ++ frontend/editor/src/portal/auth/editorUrl.ts | 15 +++- .../editor/src/portal/components/Sidebar.css | 27 +----- .../editor/src/portal/components/Sidebar.tsx | 61 +++++--------- .../src/portal/contexts/ViewContext.tsx | 6 +- .../components/shared/AppSwitcher.tsx | 28 +++++++ frontend/eslint.config.mjs | 1 + 14 files changed, 228 insertions(+), 73 deletions(-) create mode 100644 frontend/editor/src/core/components/shared/AppSwitch.css create mode 100644 frontend/editor/src/core/components/shared/AppSwitch.tsx create mode 100644 frontend/editor/src/core/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/core/routes/portalBasename.ts create mode 100644 frontend/editor/src/desktop/components/shared/AppSwitcher.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/AppSwitcher.tsx diff --git a/frontend/editor/src/core/components/shared/AppSwitch.css b/frontend/editor/src/core/components/shared/AppSwitch.css new file mode 100644 index 0000000000..c40080b1e9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppSwitch.css @@ -0,0 +1,27 @@ +/* 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(--color-text-4); + transition: + background var(--motion-fast), + color var(--motion-fast); +} + +.app-switch-btn:hover { + background: var(--color-bg-hover); + color: var(--color-text-2); +} + +.app-switch-icon { + width: 1rem; + height: 1.0625rem; + display: block; +} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx new file mode 100644 index 0000000000..b713e75e7c --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx @@ -0,0 +1,82 @@ +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"; + +export type AppSwitchTarget = "editor" | "processor"; + +function ChevronDownIcon() { + return ( + + + + ); +} + +interface AppSwitchProps { + /** 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. + */ +export function AppSwitch({ + current, + theme, + onSwitch, + className, +}: AppSwitchProps) { + const { t } = useTranslation(); + const mark = theme === "dark" ? markDark : markLight; + const apps: Array<{ id: AppSwitchTarget; label: string }> = [ + { + id: "processor", + label: t("portal.shell.sidebar.appProcessor", "Processor"), + }, + { id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") }, + ]; + return ( + + + + + + {apps.map((app) => ( + onSwitch(app.id)} + leading={} + > + {app.label} + + ))} + + + ); +} diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx new file mode 100644 index 0000000000..298a46e1ac --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppSwitcher.tsx @@ -0,0 +1,8 @@ +/** + * 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. + */ +export function AppSwitcher() { + return null; +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index d06982179e..0b495d723b 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -102,6 +102,18 @@ 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; diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index db3820350e..f6fb7f0ce9 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -29,6 +29,7 @@ import { 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 type { StirlingFileStub } from "@app/types/fileContext"; import MenuIcon from "@mui/icons-material/Menu"; import SearchIcon from "@mui/icons-material/Search"; @@ -679,6 +680,17 @@ const FileSidebar = forwardRef( 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. + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + + )}
    diff --git a/frontend/editor/src/core/routes/portalBasename.ts b/frontend/editor/src/core/routes/portalBasename.ts new file mode 100644 index 0000000000..70f05f6a27 --- /dev/null +++ b/frontend/editor/src/core/routes/portalBasename.ts @@ -0,0 +1,7 @@ +/** + * Base path where the admin portal route-set mounts inside the editor app + * (see adminRouteExtensions). Lives in core so any layer can reference the + * mount point without importing portal code — build flavors that ship no + * portal (core, desktop, prototypes) must never resolve @portal. + */ +export const PORTAL_BASENAME = "/portal"; diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css index c96aec1672..736371e6ae 100644 --- a/frontend/editor/src/core/ui/Dropdown.css +++ b/frontend/editor/src/core/ui/Dropdown.css @@ -36,6 +36,12 @@ font-size: 0.8125rem; color: var(--color-text-2); border-radius: var(--radius-sm); + /* Explicit button reset: hosts without a global button reset (e.g. the + editor) would otherwise show the UA's buttonface background and border. */ + background: none; + border: none; + font-family: inherit; + cursor: pointer; transition: background var(--motion-fast), color var(--motion-fast); diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx new file mode 100644 index 0000000000..9d7c2c095e --- /dev/null +++ b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx @@ -0,0 +1,9 @@ +/** + * 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. + */ +export function AppSwitcher() { + return null; +} diff --git a/frontend/editor/src/portal/auth/editorUrl.ts b/frontend/editor/src/portal/auth/editorUrl.ts index 43eb466cb7..8f60292331 100644 --- a/frontend/editor/src/portal/auth/editorUrl.ts +++ b/frontend/editor/src/portal/auth/editorUrl.ts @@ -3,8 +3,15 @@ * bouncing non-admins out). * * Sourced from VITE_EDITOR_URL so it's configurable per deploy rather than - * hardcoded. The committed default is "/" (production serves the editor at the - * root on the same origin as the portal). For dev cross-app navigation to a - * separately-running editor, set VITE_EDITOR_URL in editor/.env.local. + * hardcoded, falling back to "/" (the editor serves the portal at /portal on + * the same origin, so the root is the editor). For dev cross-app navigation to + * a separately-running editor, set VITE_EDITOR_URL in editor/.env.local. */ -export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL; +export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL || "/"; + +/** + * When the editor is at the root of this origin, the portal and editor are + * route-sets of one SPA — switching apps can be a client-side navigation + * instead of a full page load. + */ +export const EDITOR_IS_SAME_APP = EDITOR_URL === "/"; diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index c12b6debb0..fcc8c7e6a8 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -40,35 +40,12 @@ white-space: nowrap; } -/* App switcher (down-arrow → Portal / Editor) */ +/* 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; } -.portal-sidebar__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(--color-text-4); - transition: - background var(--motion-fast), - color var(--motion-fast); -} -.portal-sidebar__app-switch-btn:hover { - background: var(--color-bg-hover); - color: var(--color-text-2); -} -.portal-sidebar__app-icon { - width: 1rem; - height: 1.0625rem; - display: block; -} /* Nav body */ .portal-sidebar__nav { diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 486611248b..fed5d7f68e 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -1,5 +1,7 @@ -import { Button, Dropdown, NavItem } from "@app/ui"; +import { NavItem } from "@app/ui"; +import { AppSwitch } from "@app/components/shared/AppSwitch"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useTier } from "@portal/contexts/TierContext"; import { useTheme } from "@portal/contexts/ThemeContext"; @@ -7,7 +9,7 @@ import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { useAsync } from "@portal/hooks/useAsync"; import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; -import { EDITOR_URL } from "@portal/auth/editorUrl"; +import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; import { @@ -22,7 +24,6 @@ import { UsageIcon, DocsIcon, SettingsIcon, - ChevronDownIcon, } from "@portal/components/icons"; import "@portal/components/Sidebar.css"; @@ -114,6 +115,15 @@ export function Sidebar() { const { theme } = useTheme(); const { openSettings } = useUI(); const { t } = useTranslation(); + const navigate = useNavigate(); + + // 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. + const goToEditor = () => { + if (EDITOR_IS_SAME_APP) navigate("/"); + else window.location.href = EDITOR_URL; + }; // Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into // a takeover modal (matching the marketing prototype). @@ -148,45 +158,12 @@ export function Sidebar() { - - - - - - - } - > - {t("portal.shell.sidebar.appProcessor")} - - { - window.location.href = EDITOR_URL; - }} - leading={ - - } - > - {t("portal.shell.sidebar.appEditor")} - - - +