From f881828cd8135bbe8ad303a95d4dae84b906b2d8 Mon Sep 17 00:00:00 2001
From: James Brunton
Date: Mon, 6 Jul 2026 22:37:21 +0100
Subject: [PATCH 01/43] Fix intermittently failing Playwright tests (#6886)
# Description of Changes
Fixes intermittently failing tests (and replaces one that wasn't useful
in its previous state) and also adds a CI check to warn if there are any
Playwright tests which failed on their first go and succeeded on
retries, to hopefully help find intermittently failing tests more
quickly and avoid them being merged in the first place.
---
.github/workflows/build-enterprise.yml | 19 +++
.github/workflows/e2e-live.yml | 10 ++
.github/workflows/e2e-stubbed.yml | 11 ++
frontend/editor/playwright.config.ts | 13 +-
.../editor/scripts/report-flaky-tests.mts | 127 ++++++++++++++++++
.../stubbed/file-state-across-tools.spec.ts | 38 ++++++
.../src/core/tests/stubbed/files-page.spec.ts | 9 +-
.../stubbed/unsaved-changes-guard.spec.ts | 43 ------
8 files changed, 221 insertions(+), 49 deletions(-)
create mode 100644 frontend/editor/scripts/report-flaky-tests.mts
delete mode 100644 frontend/editor/src/core/tests/stubbed/unsaved-changes-guard.spec.ts
diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml
index 99b3b127a4..9e1a4efb83 100644
--- a/.github/workflows/build-enterprise.yml
+++ b/.github/workflows/build-enterprise.yml
@@ -167,6 +167,8 @@ jobs:
wait_for_backend
- name: Run enterprise OAuth Playwright tests
id: oauth-tests
+ env:
+ PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json
run: task e2e:enterprise -- --grep "OAuth"
- name: Stop backend + tear down OAuth Keycloak
if: always()
@@ -240,6 +242,8 @@ jobs:
wait_for_backend
- name: Run enterprise SAML Playwright tests
id: saml-tests
+ env:
+ PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json
run: task e2e:enterprise -- --grep "SAML"
- name: Stop backend + tear down SAML Keycloak
if: always()
@@ -270,6 +274,8 @@ jobs:
wait_for_backend
- name: Run enterprise feature Playwright tests
id: feature-tests
+ env:
+ PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json
run: task e2e:enterprise -- --grep "Enterprise license"
- name: Print backend log on failure
if: failure()
@@ -282,6 +288,19 @@ jobs:
run: |
source /tmp/helpers.sh
stop_backend
+ - name: Flag flaky tests
+ # Runs regardless of the test outcomes: a flaky test (passed on retry)
+ # leaves its step green, so this is the only place it surfaces. Merges
+ # all three phase reports (some may be absent if an earlier phase hard-
+ # failed and skipped the rest). Emits ::warning:: annotations + a job
+ # summary; never fails the job.
+ if: always()
+ working-directory: frontend
+ run: >
+ npx tsx editor/scripts/report-flaky-tests.mts
+ "${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
+ "${{ github.workspace }}/frontend/playwright-report/results-saml.json"
+ "${{ github.workspace }}/frontend/playwright-report/results-feature.json"
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml
index 44d5443a73..57a4357dad 100644
--- a/.github/workflows/e2e-live.yml
+++ b/.github/workflows/e2e-live.yml
@@ -62,7 +62,17 @@ jobs:
# .test-state/playwright/coverage-pw/ for the post-process step
# to aggregate. Chromium-only - other engines silently skip.
PW_COVERAGE: "1"
+ PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:live
+ - name: Flag flaky tests
+ # Runs regardless of the test outcome: a flaky test (passed on retry)
+ # leaves the step green, so this is the only place it surfaces. Emits
+ # ::warning:: annotations + a job summary; never fails the job.
+ if: always()
+ working-directory: frontend
+ run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
+ env:
+ PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Generate JaCoCo report from e2e:live .exec
if: always()
id: live-coverage
diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml
index 33bb24ee4c..ccfdf0052f 100644
--- a/.github/workflows/e2e-stubbed.yml
+++ b/.github/workflows/e2e-stubbed.yml
@@ -44,7 +44,18 @@ jobs:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (chromium)
+ env:
+ PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:stubbed -- --workers=3
+ - name: Flag flaky tests
+ # Runs regardless of the test outcome: a flaky test (passed on retry)
+ # leaves the step green, so this is the only place it surfaces. Emits
+ # ::warning:: annotations + a job summary; never fails the job.
+ if: always()
+ working-directory: frontend
+ run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
+ env:
+ PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
diff --git a/frontend/editor/playwright.config.ts b/frontend/editor/playwright.config.ts
index 7a049e2632..93e6572392 100644
--- a/frontend/editor/playwright.config.ts
+++ b/frontend/editor/playwright.config.ts
@@ -30,7 +30,18 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : "50%",
- reporter: [["html", { open: "never" }], ["list"]],
+ // In CI, add a JSON report alongside the HTML/list output so the workflow
+ // can flag flaky tests (passed only on retry) as warnings without failing
+ // the job. Path is pinned via PLAYWRIGHT_JSON_OUTPUT_FILE in the workflow;
+ // the outputFile here is just a sane default. Omitted locally to keep dev
+ // runs' terminal output clean.
+ reporter: process.env.CI
+ ? [
+ ["html", { open: "never" }],
+ ["list"],
+ ["json", { outputFile: "playwright-report/results.json" }],
+ ]
+ : [["html", { open: "never" }], ["list"]],
timeout: 60_000,
expect: { timeout: 10_000 },
diff --git a/frontend/editor/scripts/report-flaky-tests.mts b/frontend/editor/scripts/report-flaky-tests.mts
new file mode 100644
index 0000000000..1b1039b3ae
--- /dev/null
+++ b/frontend/editor/scripts/report-flaky-tests.mts
@@ -0,0 +1,127 @@
+// Reads a Playwright JSON report and surfaces "flaky" tests (tests that
+// failed at least once, then passed on retry) in GitHub Actions WITHOUT
+// failing the job:
+// - emits one ::warning:: workflow command per flaky test, so the run and
+// PR show a yellow warning triangle + count, and the annotation links to
+// the test's source line
+// - appends a summary table to the job summary ($GITHUB_STEP_SUMMARY)
+//
+// A green-but-flaky job is otherwise invisible (Playwright exits 0 once a
+// retry passes), which lets flakes accrete unnoticed. This makes them visible
+// without turning them into hard failures.
+//
+// Run: `npx tsx editor/scripts/report-flaky-tests.mts [more.json...]`
+// (a single path is also read from PLAYWRIGHT_JSON_OUTPUT_FILE). Multiple
+// reports are merged + de-duplicated, so a job that runs Playwright in
+// several segments (e.g. the enterprise OAuth/SAML/license phases) can
+// pass one report per phase. A missing report or zero flaky tests is a
+// silent no-op, so it is safe to run with `if: always()` after any
+// Playwright step.
+
+import { appendFileSync, existsSync, readFileSync } from "fs";
+import { isAbsolute, join, relative } from "path";
+import type { JSONReport, JSONReportSuite } from "@playwright/test/reporter";
+
+interface FlakyTest {
+ file: string;
+ line: number;
+ title: string;
+}
+
+// Playwright records each test's outcome as expected|unexpected|flaky|skipped.
+// "flaky" means it needed a retry to pass, which is exactly what we surface.
+function collectFlaky(
+ report: JSONReport,
+ workspace: string,
+ rootDir: string,
+): FlakyTest[] {
+ const flaky: FlakyTest[] = [];
+ const walk = (suite: JSONReportSuite, trail: string[], depth: number) => {
+ // The outermost suite per file has title === the file path; skip it so the
+ // human-readable title is just "describe > test" (the path is shown
+ // separately as the location). Nested suites are the describe() blocks.
+ const titles = depth > 0 && suite.title ? [...trail, suite.title] : trail;
+ for (const spec of suite.specs ?? []) {
+ if ((spec.tests ?? []).some((t) => t.status === "flaky")) {
+ const abs = spec.file
+ ? isAbsolute(spec.file)
+ ? spec.file
+ : join(rootDir, spec.file)
+ : "";
+ const rel = abs ? relative(workspace, abs) : "";
+ flaky.push({
+ // Drop the path from the annotation if it escapes the workspace, so
+ // we never emit a broken file= link (the warning still shows).
+ file: rel && !rel.startsWith("..") ? rel : "",
+ line: spec.line || 0,
+ title: [...titles, spec.title].filter(Boolean).join(" > "),
+ });
+ }
+ }
+ for (const child of suite.suites ?? []) walk(child, titles, depth + 1);
+ };
+ for (const suite of report.suites ?? []) walk(suite, [], 0);
+ return flaky;
+}
+
+// Deliberately no process.exit() calls: every path falls through to a natural
+// exit(0). This step must never fail the job, and it keeps CI green even when
+// the report is missing or clean.
+function main(): void {
+ // Accept one or more report paths: a job may run Playwright in several
+ // segments, each writing its own report (the enterprise job does this for
+ // OAuth / SAML / license phases). Fall back to the env var when no paths are
+ // passed. Missing files are skipped, not fatal.
+ const reportPaths = process.argv.slice(2);
+ const envPath = process.env.PLAYWRIGHT_JSON_OUTPUT_FILE;
+ if (reportPaths.length === 0 && envPath) {
+ reportPaths.push(envPath);
+ }
+
+ const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
+ const seen = new Set();
+ const flaky: FlakyTest[] = [];
+ for (const reportPath of reportPaths) {
+ if (!reportPath || !existsSync(reportPath)) {
+ // No report (e.g. the build failed before this segment ran).
+ continue;
+ }
+ const report = JSON.parse(readFileSync(reportPath, "utf8")) as JSONReport;
+ const rootDir = report.config?.rootDir || process.cwd();
+ for (const test of collectFlaky(report, workspace, rootDir)) {
+ const key = `${test.file}:${test.line}:${test.title}`;
+ if (!seen.has(key)) {
+ seen.add(key);
+ flaky.push(test);
+ }
+ }
+ }
+ if (flaky.length === 0) {
+ return;
+ }
+
+ for (const f of flaky) {
+ const loc = f.file ? `file=${f.file},line=${f.line},` : "";
+ process.stdout.write(
+ `::warning ${loc}title=Flaky test::${f.title} passed only on retry\n`,
+ );
+ }
+
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
+ if (summaryPath) {
+ const plural = flaky.length === 1 ? "" : "s";
+ const lines = [
+ `### :warning: ${flaky.length} flaky test${plural} (passed on retry)`,
+ "",
+ "These passed, but not on the first attempt. Worth fixing before they turn into hard failures.",
+ "",
+ "| Test | Location |",
+ "| --- | --- |",
+ ...flaky.map((f) => `| ${f.title} | \`${f.file || "?"}:${f.line}\` |`),
+ "",
+ ];
+ appendFileSync(summaryPath, lines.join("\n") + "\n");
+ }
+}
+
+main();
diff --git a/frontend/editor/src/core/tests/stubbed/file-state-across-tools.spec.ts b/frontend/editor/src/core/tests/stubbed/file-state-across-tools.spec.ts
index e4b9a30c3e..6c84f6020a 100644
--- a/frontend/editor/src/core/tests/stubbed/file-state-across-tools.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/file-state-across-tools.spec.ts
@@ -9,6 +9,11 @@ const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
* Files uploaded on one tool page should remain in the workbench when the
* user navigates to a different tool. This is FileContext behaviour and
* easy to break with a stale-effect or unmount-clear bug.
+ *
+ * Covered for both navigation mechanisms, which take different code paths:
+ * - a full page reload (page.goto) -> FileContext re-hydrates from IndexedDB
+ * - an in-app tool-link click -> client-side nav, FileContext stays
+ * in memory
*/
test.describe("File state persists across tool navigation", () => {
test("file uploaded on /merge survives navigation to /split", async ({
@@ -32,4 +37,37 @@ test.describe("File state persists across tool navigation", () => {
timeout: 5_000,
});
});
+
+ test("file uploaded on /merge survives an in-app tool-link navigation", async ({
+ page,
+ }) => {
+ await page.goto("/merge");
+ await page.waitForLoadState("domcontentloaded");
+ await uploadFiles(page, SAMPLE_PDF);
+
+ // Navigate via the in-app tool link (client-side React Router nav) rather
+ // than a full reload, so this exercises the in-memory FileContext path the
+ // page.goto test above doesn't. Fall back to a direct visit if the nav
+ // rail isn't showing the link yet.
+ const splitNav = page.getByRole("link", { name: /^Split$/i }).first();
+ if (await splitNav.isVisible({ timeout: 1_000 }).catch(() => false)) {
+ await splitNav.click();
+ } else {
+ await page.goto("/split");
+ }
+
+ // A client-side nav has no document load event, so waitForLoadState is a
+ // no-op here. Wait for the route to actually commit before opening the
+ // file manager; otherwise the my-files click fires mid-transition and
+ // opens it against a not-yet-settled workbench, which renders a permanent
+ // empty state (the flaky "0 items" that then passes on retry).
+ await expect(page).toHaveURL(/\/split(?:$|[/?#])/);
+
+ // The upload must still be listed after the tool switch. A "no files"
+ // empty state here would mean the client-side nav silently dropped it.
+ await page.getByTestId("my-files-button").click();
+ await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
+ timeout: 10_000,
+ });
+ });
});
diff --git a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts
index 7c08d943bb..56687b2217 100644
--- a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts
@@ -825,11 +825,10 @@ test.describe("Files page", () => {
// and direct user shares.)
await page.locator("#filesPage-tab-sharedByMe").click();
const sharedByMeCards = page.locator(".files-page-card:not(.is-folder)");
- await expect(sharedByMeCards).toHaveCount(2, { timeout: 3_000 });
- await expect(sharedByMeCards).toContainText([
- "link-shared.pdf",
- "user-shared.pdf",
- ]);
+ await expect(sharedByMeCards).toHaveCount(2, { timeout: 5_000 });
+ for (const name of ["link-shared.pdf", "user-shared.pdf"]) {
+ await expect(sharedByMeCards.filter({ hasText: name })).toHaveCount(1);
+ }
// "Shared with me" -> only from-someone-else.pdf
await page.locator("#filesPage-tab-shared").click();
diff --git a/frontend/editor/src/core/tests/stubbed/unsaved-changes-guard.spec.ts b/frontend/editor/src/core/tests/stubbed/unsaved-changes-guard.spec.ts
deleted file mode 100644
index fc3a1240d1..0000000000
--- a/frontend/editor/src/core/tests/stubbed/unsaved-changes-guard.spec.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { test, expect } from "@app/tests/helpers/stub-test-base";
-import { uploadFiles } from "@app/tests/helpers/ui-helpers";
-import path from "path";
-
-const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
-
-/**
- * The NavigationGuard context warns the user when they have unsaved work
- * (uploaded files or modified config) and try to navigate away. The guard
- * surface is a Mantine modal asking to confirm or cancel the navigation.
- *
- * Today the guard logic exists but is silently bypassed by tests that go
- * through the workbench. This spec asserts the modal appears and that
- * cancelling keeps the user on the current tool.
- */
-test.describe("Unsaved changes navigation guard", () => {
- test("uploading then navigating away surfaces the guard prompt", async ({
- page,
- }) => {
- await page.goto("/merge");
- await page.waitForLoadState("domcontentloaded");
- await uploadFiles(page, SAMPLE_PDF);
-
- // Triggering a tool-level navigation while files are loaded should
- // either prompt or clear-and-navigate cleanly. A regression that
- // discards files silently is the failure we want to catch.
- const splitNav = page.getByRole("link", { name: /^Split$/i }).first();
- if (await splitNav.isVisible({ timeout: 1_000 }).catch(() => false)) {
- await splitNav.click();
- } else {
- await page.goto("/split");
- }
-
- // After arriving at /split the My Files page should still list the
- // previously uploaded sample (NavigationGuard either kept us on
- // /merge or moved us with state intact). A "no files" empty state
- // here would indicate the guard silently dropped the workbench.
- await page.getByTestId("my-files-button").click();
- await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
- timeout: 5_000,
- });
- });
-});
From b4f7b1d8a92670a1abbf65c1d13941db6b81b607 Mon Sep 17 00:00:00 2001
From: James Brunton
Date: Tue, 7 Jul 2026 08:47:07 +0100
Subject: [PATCH 02/43] Add bidirectional API types to frontend (#6867)
# Description of Changes
Fix https://github.com/Stirling-Tools/Stirling-PDF-SaaS/issues/281. Add
generated backend API mappings to the frontend code, and the logic to
convert from a backend API to frontend parameters objects.
Previously, it was impossible to tell if changing the backend API would
require a change to the frontend to support it because the frontend had
no static type information about the backend API. This PR adds
autogenerated tool API types to the frontend (in `toolApiTypes.ts`) and
adds explicit typed mappings between the frontend parameter types and
the backend API types, so theoretically the type checker should be able
to catch issues when changing one puts us in an invalid state with the
other. During development, it pointed out several inconsistencies that
we have between the frontend and backend types, some of which were
genuine bugs, and others were only happening to work because the backend
is more permissive than its API claims to be.
This also unlocks the ability for us to render the frontend settings on
saved backend API structures, which we've previously had to avoid doing
because we had no reverse mapping.
---
.github/config/.files.yaml | 15 +
.github/workflows/ai-engine.yml | 103 +-
.github/workflows/build.yml | 17 +
.github/workflows/check-generated-models.yml | 148 ++
.taskfiles/frontend.yml | 17 +
Taskfile.yml | 10 +
.../model/api/general/PosterPdfRequest.java | 46 +-
.../api/security/AddPasswordRequest.java | 3 +-
engine/src/stirling/models/tool_models.py | 6 +-
.../scripts/generate-tool-api-types.mts | 400 +++++
.../useAddPageNumbersOperation.ts | 78 +-
.../tools/addStamp/useAddStampOperation.ts | 106 +-
.../useAddAttachmentsOperation.ts | 58 +-
.../useAddPasswordOperation.test.ts | 51 +-
.../addPassword/useAddPasswordOperation.ts | 79 +-
.../useAddWatermarkOperation.test.ts | 26 +
.../addWatermark/useAddWatermarkOperation.ts | 108 +-
.../adjustPageScaleFormData.ts | 43 +-
.../useAdjustPageScaleOperation.test.ts | 25 +-
.../useAdjustPageScaleOperation.ts | 17 +-
.../autoRename/useAutoRenameOperation.ts | 48 +-
.../useBookletImpositionOperation.ts | 61 +-
.../tools/certSign/useCertSignOperation.ts | 188 +-
.../useChangePermissionsOperation.test.ts | 34 +-
.../useChangePermissionsOperation.ts | 75 +-
.../compress/useCompressOperation.test.ts | 127 ++
.../tools/compress/useCompressOperation.ts | 104 +-
.../compress/useCompressParameters.test.ts | 42 +
.../tools/compress/useCompressParameters.ts | 11 +-
.../hooks/tools/crop/useCropOperation.test.ts | 32 +
.../core/hooks/tools/crop/useCropOperation.ts | 65 +-
.../useEditTableOfContentsOperation.test.ts | 46 +
.../useEditTableOfContentsOperation.ts | 68 +-
.../useExtractImagesOperation.ts | 35 +-
.../tools/flatten/useFlattenOperation.ts | 57 +-
.../tools/merge/useMergeOperation.test.ts | 60 +
.../hooks/tools/merge/useMergeOperation.ts | 52 +-
.../core/hooks/tools/ocr/useOCROperation.ts | 69 +-
.../overlayPdfs/useOverlayPdfsOperation.ts | 76 +-
.../pageLayout/usePageLayoutOperation.test.ts | 24 +
.../pageLayout/usePageLayoutOperation.ts | 77 +-
.../hooks/tools/redact/useRedactOperation.ts | 77 +-
.../removeBlanks/useRemoveBlanksOperation.ts | 37 +-
.../useRemoveCertificateSignOperation.ts | 21 +-
.../removeImage/useRemoveImageOperation.ts | 20 +-
.../removePages/useRemovePagesOperation.ts | 38 +-
.../buildRemovePasswordFormData.ts | 41 +-
.../useRemovePasswordOperation.ts | 11 +-
.../useReorganizePagesOperation.ts | 61 +-
.../hooks/tools/repair/useRepairOperation.ts | 21 +-
.../replaceColor/useReplaceColorOperation.ts | 71 +-
.../tools/rotate/useRotateOperation.test.ts | 31 +
.../hooks/tools/rotate/useRotateOperation.ts | 38 +-
.../tools/sanitize/useSanitizeOperation.ts | 70 +-
.../useScannerImageSplitOperation.ts | 51 +-
.../tools/shared/migratedToolMappers.test.ts | 163 ++
.../hooks/tools/shared/toolApiMapping.test.ts | 85 +
.../core/hooks/tools/shared/toolApiMapping.ts | 88 +
.../hooks/tools/shared/toolOperationTypes.ts | 36 +-
.../hooks/tools/shared/useToolApiCalls.ts | 19 +-
.../hooks/tools/shared/useToolOperation.ts | 5 +
.../core/hooks/tools/sign/useSignOperation.ts | 5 +-
.../useSingleLargePageOperation.ts | 20 +-
.../tools/split/useSplitOperation.test.ts | 212 +++
.../hooks/tools/split/useSplitOperation.ts | 211 ++-
.../timestampPdf/useTimestampPdfOperation.ts | 34 +-
.../useUnlockPdfFormsOperation.ts | 20 +-
.../editor/src/core/types/toolApiTypes.ts | 1594 +++++++++++++++++
.../core/utils/automationConverter.test.ts | 95 +
.../src/core/utils/automationConverter.ts | 2 +-
.../src/core/utils/automationExecutor.ts | 20 +-
frontend/package-lock.json | 64 +
frontend/package.json | 1 +
73 files changed, 5195 insertions(+), 774 deletions(-)
create mode 100644 .github/workflows/check-generated-models.yml
create mode 100644 frontend/editor/scripts/generate-tool-api-types.mts
create mode 100644 frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.test.ts
create mode 100644 frontend/editor/src/core/hooks/tools/compress/useCompressOperation.test.ts
create mode 100644 frontend/editor/src/core/hooks/tools/compress/useCompressParameters.test.ts
create mode 100644 frontend/editor/src/core/hooks/tools/crop/useCropOperation.test.ts
create mode 100644 frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.test.ts
create mode 100644 frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.test.ts
create mode 100644 frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts
create mode 100644 frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts
create mode 100644 frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts
create mode 100644 frontend/editor/src/core/hooks/tools/split/useSplitOperation.test.ts
create mode 100644 frontend/editor/src/core/types/toolApiTypes.ts
diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml
index 2d617f8f20..5e85976937 100644
--- a/.github/config/.files.yaml
+++ b/.github/config/.files.yaml
@@ -87,6 +87,21 @@ engine: &engine
- Taskfile.yml
- .taskfiles/engine.yml
+# Files that can make the committed generated API models (frontend tool API
+# types + engine tool models) go stale: the Java tool surfaces they derive from,
+# the generators, the generated files themselves (to catch a hand-edit), and the
+# tasks that drive generation. Deliberately excludes the broad frontend/docker/
+# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
+generated-models: &generated-models
+ - *openapi
+ - frontend/editor/scripts/generate-tool-api-types.mts
+ - frontend/editor/src/core/types/toolApiTypes.ts
+ - engine/scripts/generate_tool_models.py
+ - engine/src/stirling/models/tool_models.py
+ - .taskfiles/frontend.yml
+ - .taskfiles/engine.yml
+ - .github/workflows/check-generated-models.yml
+
licenses-frontend: &licenses-frontend
- ".github/workflows/frontend-backend-licenses-update.yml"
- "frontend/package.json"
diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml
index 223490f45f..554100f535 100644
--- a/.github/workflows/ai-engine.yml
+++ b/.github/workflows/ai-engine.yml
@@ -1,9 +1,9 @@
name: AI Engine CI
-# Validates the Python AI engine: regenerates tool models and runs the
-# engine quality gate (lint, type-check, format-check, tests). Called from
-# build.yml on PRs and merge_group; also runs directly on push to main as
-# a post-merge safety net.
+# Runs the engine quality gate (lint, type-check, format-check, tests). Called
+# from build.yml on PRs and merge_group; also runs directly on push to main as
+# a post-merge safety net. Freshness of the generated tool_models.py is checked
+# by the shared check-generated-models workflow.
on:
workflow_call:
push:
@@ -34,104 +34,9 @@ jobs:
with:
enable-cache: true
- - name: Set up JDK 25
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
- with:
- java-version: "25"
- distribution: "temurin"
-
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
- with:
- gradle-version: 9.6.0
-
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- - name: Regenerate tool models
- run: task engine:tool-models
-
- - name: Verify tool models are up to date
- id: tool-models-check
- continue-on-error: true
- run: git diff --exit-code engine/src/stirling/models/tool_models.py
-
- - name: Comment on tool models check failure
- # Only post a comment on PRs. github-script's PR helpers need an
- # issue/PR number, which doesn't exist on merge_group runs.
- if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const marker = '';
- const body = [
- marker,
- '### Tool Models Check Failed',
- '',
- 'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
- '',
- 'Run `task engine:tool-models` to regenerate, then commit the updated file.',
- ].join('\n');
- const { data: comments } = await github.rest.issues.listComments({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- });
- const existing = comments.find(c => c.body.includes(marker));
- if (existing) {
- await github.rest.issues.updateComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: existing.id,
- body,
- });
- } else {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- body,
- });
- }
-
- - name: Fail if tool models check failed
- if: steps.tool-models-check.outcome == 'failure'
- run: |
- echo "============================================"
- echo " Tool Models Check Failed"
- echo "============================================"
- echo ""
- echo "The generated engine/src/stirling/models/tool_models.py"
- echo "is out of date with the Java OpenAPI spec and will"
- echo "need to be regenerated before it can be merged in."
- echo ""
- echo "Run 'task engine:tool-models' to regenerate, then"
- echo "commit the updated file."
- echo "============================================"
- exit 1
-
- - name: Remove tool models check comment on success
- if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
- continue-on-error: true
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- const marker = '';
- const { data: comments } = await github.rest.issues.listComments({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.issue.number,
- });
- const existing = comments.find(c => c.body.includes(marker));
- if (existing) {
- await github.rest.issues.deleteComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: existing.id,
- });
- }
-
- name: Quality-check engine
id: engine-check
run: task engine:check
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 0e0a702cc2..8142c37fca 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -43,6 +43,7 @@ jobs:
docker-base: ${{ steps.changes.outputs.docker-base }}
tauri: ${{ steps.changes.outputs.tauri }}
engine: ${{ steps.changes.outputs.engine }}
+ generated-models: ${{ steps.changes.outputs.generated-models }}
proprietary: ${{ steps.changes.outputs.proprietary }}
steps:
- name: Harden the runner (Audit all outbound calls)
@@ -171,6 +172,20 @@ jobs:
uses: ./.github/workflows/ai-engine.yml
secrets: inherit
+ # The generated frontend types and engine tool models are both derived from
+ # the Java OpenAPI spec. This job regenerates and diffs them; it boots the
+ # backend, so it is gated on the narrow generated-models filter (spec source,
+ # generators, generated files, generation tasks) rather than the broad
+ # frontend filter, so a CSS-only PR does not pay for a backend build.
+ generated-models:
+ if: needs.files-changed.outputs.generated-models == 'true'
+ needs: [files-changed]
+ permissions:
+ contents: read
+ pull-requests: write
+ uses: ./.github/workflows/check-generated-models.yml
+ secrets: inherit
+
pre-commit:
needs: [files-changed]
permissions:
@@ -228,6 +243,7 @@ jobs:
- test-build-docker-images
- tauri-build
- ai-engine
+ - generated-models
- pre-commit
- dependency-review
runs-on: ubuntu-latest
@@ -253,6 +269,7 @@ jobs:
test-build-docker-images=${{ needs.test-build-docker-images.result }}
tauri-build=${{ needs.tauri-build.result }}
ai-engine=${{ needs.ai-engine.result }}
+ generated-models=${{ needs.generated-models.result }}
pre-commit=${{ needs.pre-commit.result }}
dependency-review=${{ needs.dependency-review.result }}
run: |
diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml
new file mode 100644
index 0000000000..db9c49fba2
--- /dev/null
+++ b/.github/workflows/check-generated-models.yml
@@ -0,0 +1,148 @@
+name: Check generated models
+
+# Verifies the committed generated API models are still in sync with the Java
+# OpenAPI spec: the frontend tool API types
+# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool
+# models (engine/src/stirling/models/tool_models.py). Regenerates both with the
+# single top-level `task tool-models` and fails if either committed file is
+# out of date. Called from build.yml when the backend Java, frontend, or engine
+# changes; also runs on push to main as a post-merge safety net.
+on:
+ workflow_call:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ generated-models:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+ env:
+ DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
+ steps:
+ - name: Harden the runner (Audit all outbound calls)
+ uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
+ with:
+ egress-policy: audit
+
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
+ with:
+ enable-cache: true
+
+ - name: Set up JDK 25
+ uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+ with:
+ java-version: "25"
+ distribution: "temurin"
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
+ with:
+ gradle-version: 9.6.0
+
+ - name: Set up Node
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+ with:
+ node-version: "22"
+ cache: "npm"
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install Task
+ uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+
+ # Rebuilds the OpenAPI spec from the current Java and regenerates both the
+ # frontend types and the engine tool models from it.
+ - name: Regenerate generated models
+ run: task tool-models
+
+ - name: Verify generated models are up to date
+ id: models-check
+ continue-on-error: true
+ run: |
+ git diff --exit-code \
+ frontend/editor/src/core/types/toolApiTypes.ts \
+ engine/src/stirling/models/tool_models.py
+
+ - name: Comment on generated models check failure
+ # Only post a comment on PRs. github-script's PR helpers need an
+ # issue/PR number, which doesn't exist on merge_group runs.
+ if: steps.models-check.outcome == 'failure' && github.event_name == 'pull_request'
+ continue-on-error: true
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const marker = '';
+ const body = [
+ marker,
+ '### Generated Models Check Failed',
+ '',
+ 'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
+ '',
+ 'Run `task tool-models` to regenerate both, then commit the updated files.',
+ ].join('\n');
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+ const existing = comments.find(c => c.body.includes(marker));
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body,
+ });
+ }
+
+ - name: Fail if generated models check failed
+ if: steps.models-check.outcome == 'failure'
+ run: |
+ echo "============================================"
+ echo " Generated Models Check Failed"
+ echo "============================================"
+ echo ""
+ echo "The generated frontend API types and/or engine tool"
+ echo "models are out of date with the Java OpenAPI spec and"
+ echo "will need to be regenerated before they can be merged in."
+ echo ""
+ echo "Run 'task tool-models' to regenerate both, then"
+ echo "commit the updated files."
+ echo "============================================"
+ exit 1
+
+ - name: Remove generated models check comment on success
+ if: steps.models-check.outcome == 'success' && github.event_name == 'pull_request'
+ continue-on-error: true
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const marker = '';
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+ const existing = comments.find(c => c.body.includes(marker));
+ if (existing) {
+ await github.rest.issues.deleteComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ });
+ }
diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml
index 52277b5b04..4f87226f93 100644
--- a/.taskfiles/frontend.yml
+++ b/.taskfiles/frontend.yml
@@ -396,6 +396,23 @@ tasks:
# Code Generation
# ============================================================
+ tool-models:
+ desc: "Generate tool API types from the Java OpenAPI spec"
+ deps: [install, ":backend:swagger"]
+ cmds:
+ - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts
+ sources:
+ - editor/scripts/generate-tool-api-types.mts
+ - ../SwaggerDoc.json
+ generates:
+ - editor/src/core/types/toolApiTypes.ts
+
+ tool-models:check:
+ desc: "Fail if committed tool API types are out of date"
+ deps: [install, ":backend:swagger"]
+ cmds:
+ - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check
+
licenses:generate:
desc: "Generate frontend license report"
deps: [install]
diff --git a/Taskfile.yml b/Taskfile.yml
index 26895723d0..705ad4a1db 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -185,6 +185,16 @@ tasks:
- task: frontend:format:check
- task: engine:format:check
+ # ============================================================
+ # Code generation
+ # ============================================================
+
+ tool-models:
+ desc: "Generate all API models from the Java OpenAPI spec"
+ cmds:
+ - task: frontend:tool-models
+ - task: engine:tool-models
+
# ============================================================
# Quality Gate
# ============================================================
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java
index 20d7eaf70b..222b89022a 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java
@@ -1,5 +1,7 @@
package stirling.software.SPDF.model.api.general;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -17,20 +19,8 @@ public class PosterPdfRequest extends PDFFile {
allowableValues = {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"})
private String pageSize = "A4";
- @Schema(
- description = "Horizontal decimation factor (how many columns to split into)",
- requiredMode = Schema.RequiredMode.NOT_REQUIRED,
- defaultValue = "2",
- minimum = "1",
- maximum = "10")
private int xFactor = 2;
- @Schema(
- description = "Vertical decimation factor (how many rows to split into)",
- requiredMode = Schema.RequiredMode.NOT_REQUIRED,
- defaultValue = "2",
- minimum = "1",
- maximum = "10")
private int yFactor = 2;
@Schema(
@@ -38,4 +28,36 @@ public class PosterPdfRequest extends PDFFile {
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean rightToLeft = false;
+
+ @JsonProperty("xFactor")
+ @Schema(
+ description = "Horizontal decimation factor (how many columns to split into)",
+ requiredMode = Schema.RequiredMode.NOT_REQUIRED,
+ defaultValue = "2",
+ minimum = "1",
+ maximum = "10")
+ public int getXFactor() {
+ return xFactor;
+ }
+
+ @JsonProperty("xFactor")
+ public void setXFactor(int xFactor) {
+ this.xFactor = xFactor;
+ }
+
+ @JsonProperty("yFactor")
+ @Schema(
+ description = "Vertical decimation factor (how many rows to split into)",
+ requiredMode = Schema.RequiredMode.NOT_REQUIRED,
+ defaultValue = "2",
+ minimum = "1",
+ maximum = "10")
+ public int getYFactor() {
+ return yFactor;
+ }
+
+ @JsonProperty("yFactor")
+ public void setYFactor(int yFactor) {
+ this.yFactor = yFactor;
+ }
}
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java
index 541a8717f3..2227803372 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java
@@ -29,7 +29,8 @@ public class AddPasswordRequest extends PDFFile {
description = "The length of the encryption key",
type = "integer",
allowableValues = {"40", "128", "256"},
- requiredMode = Schema.RequiredMode.REQUIRED)
+ requiredMode = Schema.RequiredMode.NOT_REQUIRED,
+ defaultValue = "256")
private int keyLength = 256;
@Schema(description = "Whether document assembly is prevented", defaultValue = "false")
diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py
index 3e179eaf0c..2591d947a8 100644
--- a/engine/src/stirling/models/tool_models.py
+++ b/engine/src/stirling/models/tool_models.py
@@ -107,7 +107,7 @@ class KeyLength(IntEnum):
class AddPasswordParams(ApiModel):
- key_length: KeyLength = Field(..., description="The length of the encryption key")
+ key_length: KeyLength = Field(KeyLength.integer_256, description="The length of the encryption key")
owner_password: SecretStr | None = Field(
None,
description="The owner password to be added to the PDF file (Restricts what can be done with the document once it is opened)",
@@ -1283,8 +1283,8 @@ class PageSize1(StrEnum):
class SplitForPosterPrintParams(ApiModel):
page_size: PageSize1 = Field(..., description="Target page size for output chunks (e.g., 'A4', 'Letter', 'A3')")
right_to_left: bool = Field(False, description="Split right-to-left instead of left-to-right")
- xfactor: int | None = None
- yfactor: int | None = None
+ x_factor: int = Field(2, description="Horizontal decimation factor (how many columns to split into)", ge=1, le=10)
+ y_factor: int = Field(2, description="Vertical decimation factor (how many rows to split into)", ge=1, le=10)
class SplitPagesParams(ApiModel):
diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts
new file mode 100644
index 0000000000..f12572620a
--- /dev/null
+++ b/frontend/editor/scripts/generate-tool-api-types.mts
@@ -0,0 +1,400 @@
+/**
+ * Generates the committed frontend tool API types (toolApiTypes.ts) from the
+ * Java backend's OpenAPI spec, so the frontend's request shapes stay in step
+ * with the backend.
+ */
+
+import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { parseArgs } from "node:util";
+import { compile, type JSONSchema } from "json-schema-to-typescript";
+import * as prettier from "prettier";
+
+// The API namespaces whose endpoints are real, callable tools. `/api/v1/filter/`
+// (pipeline-only) and `/api/v1/ai/tools/` (not in the spec) are intentionally
+// excluded. Extend this list when other namespaces become tools.
+const ALLOWED_PATH_PREFIXES = [
+ "/api/v1/general/",
+ "/api/v1/misc/",
+ "/api/v1/security/",
+ "/api/v1/convert/",
+];
+
+// File plumbing, not user parameters: `fileInput` is the uploaded document and
+// `fileId` a server-side handle. Stripped from every generated request model.
+// Named file fields (stampImage, attachments, ...) are real parameters and kept.
+const BASE_FILE_FIELDS = new Set(["fileInput", "fileId"]);
+
+// The shared "upload a file or provide a file ID" wrapper schema and its two
+// branches. An endpoint whose body is exactly this has no parameters, so it must
+// resolve to an empty model. It needs separate handling because the wrapper is a
+// `oneOf`, which survives the flat-field stripping above and would otherwise leak
+// the file fields into the output.
+const FILE_WRAPPER_COMPONENTS = new Set([
+ "PDFFile",
+ "PDFFileUpload",
+ "PDFFileRef",
+]);
+
+const COMPONENT_REF_PREFIX = "#/components/schemas/";
+
+const FILE_HEADER = [
+ "// AUTO-GENERATED FILE. DO NOT EDIT.",
+ "// Generated by editor/scripts/generate-tool-api-types.mts from the Java OpenAPI spec",
+ "// (SwaggerDoc.json). Regenerate with: task frontend:tool-models",
+ "// Tools that take only a file input have no parameters; their model is Record.",
+].join("\n");
+
+type Json = Record;
+
+interface DiscoveredTool {
+ path: string;
+ className: string;
+}
+
+function isObject(value: unknown): value is Json {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/**
+ * Recursively sort object keys so the output is byte-stable regardless of the
+ * key ordering springdoc happens to emit.
+ */
+function deepSortKeys(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(deepSortKeys);
+ if (isObject(value)) {
+ const sorted: Json = {};
+ for (const key of Object.keys(value).sort()) {
+ sorted[key] = deepSortKeys(value[key]);
+ }
+ return sorted;
+ }
+ return value;
+}
+
+function pascalCase(segment: string): string {
+ return segment
+ .split(/[-_/]/)
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join("");
+}
+
+/** Fallback class name for an inline request body (no $ref to name it after). */
+function pathToClassName(path: string): string {
+ const relevant = path.replace(/^\/api\/v1\//, "");
+ return `${pascalCase(relevant)}Request`;
+}
+
+function dedupe(name: string, used: Set): string {
+ let candidate = name;
+ let n = 2;
+ while (used.has(candidate)) candidate = `${name}${n++}`;
+ used.add(candidate);
+ return candidate;
+}
+
+/** The request body schema for a POST endpoint (multipart wins, then JSON), or null. */
+function requestBodySchema(pathItem: Json): Json | null {
+ const post = pathItem.post;
+ if (!isObject(post)) return null;
+ const requestBody = post.requestBody;
+ if (!isObject(requestBody)) return null;
+ const content = requestBody.content;
+ if (!isObject(content)) return null;
+ for (const mediaType of ["multipart/form-data", "application/json"]) {
+ const entry = content[mediaType];
+ if (isObject(entry) && isObject(entry.schema)) return entry.schema;
+ }
+ return null;
+}
+
+/**
+ * A POST endpoint's query parameters as a property map plus the required ones.
+ * Some tools take inputs on the query string alongside the multipart body (e.g.
+ * merge-pdfs' `fileOrder`), so a complete model has to fold them in. Ref-valued
+ * param schemas are inlined later by rewriteRefs.
+ */
+function queryParameters(pathItem: Json): { props: Json; required: string[] } {
+ const props: Json = {};
+ const required: string[] = [];
+ const post = pathItem.post;
+ if (!isObject(post) || !Array.isArray(post.parameters))
+ return { props, required };
+ for (const param of post.parameters) {
+ if (
+ !isObject(param) ||
+ param.in !== "query" ||
+ typeof param.name !== "string"
+ )
+ continue;
+ if (!isObject(param.schema)) continue;
+ const schema = structuredClone(param.schema) as Json;
+ if (!("description" in schema) && typeof param.description === "string") {
+ schema.description = param.description;
+ }
+ props[param.name] = schema;
+ if (param.required === true) required.push(param.name);
+ }
+ return { props, required };
+}
+
+/**
+ * Rewrite every `#/components/schemas/X` ref to `#/definitions/X` in place (the
+ * form json-schema-to-typescript expects) and collect the referenced component
+ * names so the caller can inline them.
+ */
+function rewriteRefs(node: unknown, found: Set): void {
+ if (Array.isArray(node)) {
+ for (const item of node) rewriteRefs(item, found);
+ return;
+ }
+ if (!isObject(node)) return;
+ const ref = node.$ref;
+ if (typeof ref === "string" && ref.startsWith(COMPONENT_REF_PREFIX)) {
+ const name = ref.slice(COMPONENT_REF_PREFIX.length);
+ node.$ref = `#/definitions/${name}`;
+ found.add(name);
+ }
+ for (const value of Object.values(node)) rewriteRefs(value, found);
+}
+
+/** Keep only fields the client must send: drop those with a default or already stripped. */
+function computeRequired(schema: Json, properties: Json): string[] {
+ const required = Array.isArray(schema.required)
+ ? (schema.required as string[])
+ : [];
+ return required.filter((name) => {
+ const prop = properties[name];
+ return name in properties && !(isObject(prop) && "default" in prop);
+ });
+}
+
+async function main(): Promise {
+ const { values } = parseArgs({
+ options: {
+ spec: { type: "string" },
+ output: { type: "string" },
+ check: { type: "boolean", default: false },
+ },
+ });
+ if (!values.spec || !values.output) {
+ throw new Error(
+ "Usage: generate-tool-api-types.mts --spec --output [--check]",
+ );
+ }
+ const specPath = resolve(values.spec);
+ const outputPath = resolve(values.output);
+
+ const spec = JSON.parse(readFileSync(specPath, "utf-8")) as Json;
+ const paths = isObject(spec.paths) ? spec.paths : {};
+ const components =
+ isObject(spec.components) && isObject(spec.components.schemas)
+ ? spec.components.schemas
+ : {};
+
+ const tools: DiscoveredTool[] = [];
+ const definitions: Record = {};
+ const usedClassNames = new Set();
+ const pendingComponents = new Set();
+ const skipped: string[] = [];
+
+ for (const path of Object.keys(paths).sort()) {
+ if (
+ path.includes("{") ||
+ !ALLOWED_PATH_PREFIXES.some((p) => path.startsWith(p))
+ )
+ continue;
+ const pathItem = paths[path];
+ if (!isObject(pathItem)) continue;
+ const bodySchema = requestBodySchema(pathItem);
+ if (!bodySchema) {
+ if (isObject(pathItem.post)) skipped.push(path);
+ continue;
+ }
+
+ // Resolve the request model into a fresh, mutable clone so we never mutate the shared spec.
+ const ref = bodySchema.$ref;
+ const refComponent =
+ typeof ref === "string" && ref.startsWith(COMPONENT_REF_PREFIX)
+ ? ref.slice(COMPONENT_REF_PREFIX.length)
+ : null;
+ let className: string;
+ let modelSchema: Json;
+ if (refComponent && FILE_WRAPPER_COMPONENTS.has(refComponent)) {
+ // File-only endpoint: model it as an empty object so it becomes
+ // Record rather than the wrapper's file union. Named after
+ // the path since the wrapper schema is shared. Query params still fold in
+ // below.
+ className = pathToClassName(path);
+ modelSchema = { type: "object", properties: {} };
+ } else if (refComponent) {
+ const component = components[refComponent];
+ if (!isObject(component)) continue;
+ className = refComponent;
+ modelSchema = structuredClone(component) as Json;
+ } else {
+ className = pathToClassName(path);
+ modelSchema = structuredClone(bodySchema) as Json;
+ }
+
+ // A component shared by several endpoints (e.g. GeneralFile) is only defined once.
+ if (!(className in definitions)) {
+ const uniqueName = dedupe(className, usedClassNames);
+ className = uniqueName;
+ const bodyProps: Json = isObject(modelSchema.properties)
+ ? (structuredClone(modelSchema.properties) as Json)
+ : {};
+ const query = queryParameters(pathItem);
+ // Body wins over query on a name collision.
+ const properties: Json = { ...query.props, ...bodyProps };
+ for (const field of BASE_FILE_FIELDS) delete properties[field];
+ modelSchema.properties = properties;
+ const required = new Set(computeRequired(modelSchema, properties));
+ for (const name of query.required) {
+ const prop = properties[name];
+ if (name in properties && !(isObject(prop) && "default" in prop)) {
+ required.add(name);
+ }
+ }
+ if (required.size > 0) modelSchema.required = [...required];
+ else delete modelSchema.required;
+ modelSchema.title = className;
+ rewriteRefs(modelSchema, pendingComponents);
+ definitions[className] = modelSchema;
+ }
+
+ tools.push({ path, className });
+ }
+
+ // Transitively inline every referenced component into `definitions`, rewriting its refs too.
+ const queue = [...pendingComponents];
+ while (queue.length > 0) {
+ const name = queue.pop() as string;
+ if (name in definitions) continue;
+ const component = components[name];
+ if (!isObject(component)) continue;
+ const cloned = structuredClone(component) as Json;
+ cloned.title = name;
+ const nested = new Set();
+ rewriteRefs(cloned, nested);
+ definitions[name] = cloned;
+ for (const next of nested) if (!(next in definitions)) queue.push(next);
+ }
+
+ await compileAndWrite(
+ tools,
+ definitions,
+ outputPath,
+ values.check ?? false,
+ skipped,
+ );
+}
+
+async function compileAndWrite(
+ tools: DiscoveredTool[],
+ definitions: Record,
+ outputPath: string,
+ check: boolean,
+ skipped: string[],
+): Promise {
+ // json-schema-to-typescript only emits a named, exported interface per schema
+ // if something references it, so wrap every model in one root object. The root
+ // interface itself is stripped from the output afterwards.
+ const rootName = "__ToolApiRootAutogen";
+ const uniqueClassNames = [...new Set(tools.map((t) => t.className))];
+ const rootSchema: JSONSchema = {
+ title: rootName,
+ type: "object",
+ additionalProperties: false,
+ properties: Object.fromEntries(
+ uniqueClassNames.map((name) => [name, { $ref: `#/definitions/${name}` }]),
+ ),
+ definitions: definitions as Record,
+ };
+
+ // Canonicalize key order so a reordering in SwaggerDoc.json can never change
+ // the generated file (which would flake the committed-types CI check).
+ const canonicalRoot = deepSortKeys(rootSchema) as JSONSchema;
+
+ const compiled = await compile(canonicalRoot, rootName, {
+ bannerComment: "",
+ additionalProperties: false,
+ declareExternallyReferenced: true,
+ unreachableDefinitions: false,
+ strictIndexSignatures: true,
+ format: false,
+ });
+
+ // Drop the root wrapper interface, then rewrite empty models (file-only tools)
+ // to `Record` - the precise, lint-clean type for an object with
+ // no properties (json-schema-to-typescript always emits `{}` interfaces here).
+ const models = compiled
+ .replace(new RegExp(`export interface ${rootName} \\{[^}]*\\}`), "")
+ .replace(
+ /export interface (\w+) \{\s*\}/g,
+ "export type $1 = Record;",
+ )
+ .trim();
+
+ const endpointUnion = tools
+ .map((t) => ` | ${JSON.stringify(t.path)}`)
+ .join("\n");
+ const paramsEntries = tools
+ .map((t) => ` ${JSON.stringify(t.path)}: ${t.className};`)
+ .join("\n");
+ const endpointList = tools
+ .map((t) => ` ${JSON.stringify(t.path)},`)
+ .join("\n");
+
+ const footer = [
+ "/** Endpoint path for a generated tool operation (the operation identity across languages). */",
+ `export type ToolEndpoint =\n${endpointUnion};`,
+ "",
+ "/** Backend request-parameter model for each tool endpoint. */",
+ `export interface ToolApiParams {\n${paramsEntries}\n}`,
+ "",
+ "/** Every generated tool endpoint, for iteration. */",
+ `export const TOOL_ENDPOINTS = [\n${endpointList}\n] as const satisfies readonly ToolEndpoint[];`,
+ "",
+ "/** Union of every generated tool request model. */",
+ `export type ToolApiRequest = ToolApiParams[ToolEndpoint];`,
+ ].join("\n");
+
+ const body = `${FILE_HEADER}\n\n${models}\n\n${footer}\n`;
+ const prettierConfig = await prettier.resolveConfig(outputPath);
+ const formatted = await prettier.format(body, {
+ ...prettierConfig,
+ parser: "typescript",
+ });
+
+ if (check) {
+ let current = "";
+ try {
+ current = readFileSync(outputPath, "utf-8");
+ } catch {
+ // Missing file counts as out of date.
+ }
+ if (current !== formatted) {
+ throw new Error(
+ `${outputPath} is out of date. Run 'task frontend:tool-models' and commit the result.`,
+ );
+ }
+ console.log(`Up to date: ${tools.length} tool endpoints.`);
+ return;
+ }
+
+ mkdirSync(dirname(outputPath), { recursive: true });
+ writeFileSync(outputPath, formatted, "utf-8");
+ console.log(`Generated ${tools.length} tool endpoints -> ${outputPath}`);
+ if (skipped.length > 0) {
+ console.log(
+ `Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`,
+ );
+ }
+}
+
+main().catch((error: unknown) => {
+ console.error(error instanceof Error ? error.message : error);
+ process.exit(1);
+});
diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts b/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts
index f9bf73f4ce..cb5624bce2 100644
--- a/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts
+++ b/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts
@@ -3,35 +3,83 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AddPageNumbersParameters,
defaultParameters,
} from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters";
+const ENDPOINT = "/api/v1/misc/add-page-numbers" satisfies ToolEndpoint;
+type AddPageNumbersApiParams = ToolApiParams[typeof ENDPOINT];
+
+// The UI labels fonts capitalized while the backend model uses lowercase; these
+// maps translate between them so both mappers type-check without casting.
+const FONT_TYPE_TO_API = {
+ Times: "times",
+ Helvetica: "helvetica",
+ Courier: "courier",
+} as const satisfies Record<
+ AddPageNumbersParameters["fontType"],
+ AddPageNumbersApiParams["fontType"]
+>;
+const FONT_TYPE_FROM_API = {
+ times: "Times",
+ helvetica: "Helvetica",
+ courier: "Courier",
+} as const satisfies Record<
+ AddPageNumbersApiParams["fontType"],
+ AddPageNumbersParameters["fontType"]
+>;
+
+// Convert the tool's UI parameters into the add-page-numbers request body. The
+// return type is the generated backend model, so a spec change that renames or
+// drops a field breaks the build here.
+export const addPageNumbersToApiParams = (
+ parameters: AddPageNumbersParameters,
+): AddPageNumbersApiParams => ({
+ customMargin: parameters.customMargin,
+ position: parameters.position,
+ fontSize: parameters.fontSize,
+ fontType: FONT_TYPE_TO_API[parameters.fontType],
+ startingNumber: parameters.startingNumber,
+ pagesToNumber: parameters.pagesToNumber,
+ customText: parameters.customText,
+ zeroPad: parameters.zeroPad,
+});
+
+// Reconstruct the tool's UI parameters from an add-page-numbers request body,
+// so a stored or AI-authored step can be re-rendered in the settings UI.
+export const addPageNumbersFromApiParams = (
+ apiParams: AddPageNumbersApiParams,
+): Partial => ({
+ customMargin: apiParams.customMargin,
+ position: apiParams.position,
+ fontSize: apiParams.fontSize,
+ fontType: FONT_TYPE_FROM_API[apiParams.fontType],
+ startingNumber: apiParams.startingNumber,
+ pagesToNumber: apiParams.pagesToNumber,
+ customText: apiParams.customText,
+ zeroPad: apiParams.zeroPad,
+});
+
export const buildAddPageNumbersFormData = (
parameters: AddPageNumbersParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("customMargin", parameters.customMargin);
- formData.append("position", String(parameters.position));
- formData.append("fontSize", String(parameters.fontSize));
- formData.append("fontType", parameters.fontType);
- formData.append("startingNumber", String(parameters.startingNumber));
- formData.append("pagesToNumber", parameters.pagesToNumber);
- formData.append("customText", parameters.customText);
- formData.append("zeroPad", String(parameters.zeroPad));
-
- return formData;
-};
+): FormData =>
+ objectToFormData(addPageNumbersToApiParams(parameters), { fileInput: file });
export const addPageNumbersOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddPageNumbersFormData,
+ toApiParams: addPageNumbersToApiParams,
+ fromApiParams: addPageNumbersFromApiParams,
operationType: "addPageNumbers",
- endpoint: "/api/v1/misc/add-page-numbers",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts b/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts
index dc34c5d27a..bdceee49f1 100644
--- a/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts
+++ b/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts
@@ -3,51 +3,97 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AddStampParameters,
defaultParameters,
} from "@app/components/tools/addStamp/useAddStampParameters";
+const ENDPOINT = "/api/v1/misc/add-stamp" satisfies ToolEndpoint;
+type AddStampApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the add-stamp request body. The stamp
+// image itself is a File and is passed via the `files` argument, not here.
+export const addStampToApiParams = (
+ parameters: AddStampParameters,
+): AddStampApiParams => {
+ const stampType = parameters.stampType || "text";
+ const apiParams: AddStampApiParams = {
+ stampType,
+ pageNumbers: parameters.pageNumbers,
+ customMargin: parameters.customMargin || "medium",
+ position: parameters.position,
+ fontSize: parameters.fontSize,
+ rotation: parameters.rotation,
+ // The UI stores opacity as a 0-100 percentage; the backend expects 0.0-1.0.
+ opacity: parameters.opacity / 100,
+ overrideX: parameters.overrideX,
+ overrideY: parameters.overrideY,
+ customColor: parameters.customColor.startsWith("#")
+ ? parameters.customColor
+ : `#${parameters.customColor}`,
+ alphabet: parameters.alphabet,
+ };
+
+ if (stampType === "text") {
+ apiParams.stampText = parameters.stampText;
+ }
+
+ return apiParams;
+};
+
+// Reconstruct the tool's UI parameters from an add-stamp request body, so a
+// stored or AI-authored step can be re-rendered in the settings UI. The stamp
+// image File cannot be recovered from the request model.
+export const addStampFromApiParams = (
+ apiParams: AddStampApiParams,
+): Partial => {
+ const result: Partial = {
+ stampType: apiParams.stampType,
+ pageNumbers: apiParams.pageNumbers,
+ customMargin: apiParams.customMargin,
+ position: apiParams.position,
+ fontSize: apiParams.fontSize,
+ rotation: apiParams.rotation,
+ overrideX: apiParams.overrideX,
+ overrideY: apiParams.overrideY,
+ customColor: apiParams.customColor,
+ alphabet: apiParams.alphabet,
+ };
+
+ if (apiParams.opacity !== undefined) {
+ result.opacity = apiParams.opacity * 100;
+ }
+ if (apiParams.stampText !== undefined) {
+ result.stampText = apiParams.stampText;
+ }
+
+ return result;
+};
+
export const buildAddStampFormData = (
parameters: AddStampParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("pageNumbers", parameters.pageNumbers);
- formData.append("customMargin", parameters.customMargin || "medium");
- formData.append("position", String(parameters.position));
- const effectiveFontSize = parameters.fontSize;
- formData.append("fontSize", String(effectiveFontSize));
- formData.append("rotation", String(parameters.rotation));
- formData.append("opacity", String(parameters.opacity / 100));
- formData.append("overrideX", String(parameters.overrideX));
- formData.append("overrideY", String(parameters.overrideY));
- formData.append(
- "customColor",
- parameters.customColor.startsWith("#")
- ? parameters.customColor
- : `#${parameters.customColor}`,
+): FormData =>
+ objectToFormData(
+ addStampToApiParams(parameters),
+ parameters.stampType === "image" && parameters.stampImage
+ ? { fileInput: file, stampImage: parameters.stampImage }
+ : { fileInput: file },
);
- formData.append("alphabet", parameters.alphabet);
-
- // Stamp type and payload
- formData.append("stampType", parameters.stampType || "text");
- if (parameters.stampType === "text") {
- formData.append("stampText", parameters.stampText);
- } else if (parameters.stampType === "image" && parameters.stampImage) {
- formData.append("stampImage", parameters.stampImage);
- }
-
- return formData;
-};
export const addStampOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddStampFormData,
+ toApiParams: addStampToApiParams,
+ fromApiParams: addStampFromApiParams,
operationType: "addStamp",
- endpoint: "/api/v1/misc/add-stamp",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts b/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts
index 2b66bbdc34..bc9fb738bc 100644
--- a/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts
@@ -4,37 +4,59 @@ import {
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
-import { AddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters";
+import {
+ AddAttachmentsParameters,
+ DEFAULT_ADD_ATTACHMENTS_PARAMETERS,
+} from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters";
+
+const ENDPOINT = "/api/v1/misc/add-attachments" satisfies ToolEndpoint;
+type AddAttachmentsApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the add-attachments request body. The
+// attachment files are uploaded via the named "attachments" field (see
+// buildFormData); the model lists them but they are not scalar parameters.
+export const addAttachmentsToApiParams = (
+ parameters: AddAttachmentsParameters,
+): AddAttachmentsApiParams => ({
+ attachments: [],
+ convertToPdfA3b: parameters.convertToPdfA3b,
+});
+
+// Reconstruct the tool's UI parameters from an add-attachments request body (the
+// attachment files themselves are not recoverable from stored parameters).
+export const addAttachmentsFromApiParams = (
+ apiParams: AddAttachmentsApiParams,
+): Partial => ({
+ convertToPdfA3b:
+ apiParams.convertToPdfA3b ??
+ DEFAULT_ADD_ATTACHMENTS_PARAMETERS.convertToPdfA3b,
+});
const buildFormData = (
parameters: AddAttachmentsParameters,
file: File,
-): FormData => {
- const formData = new FormData();
-
- // Add the main PDF file (single file per request in singleFile mode)
- if (file) {
- formData.append("fileInput", file);
- }
-
- // Add attachment files
- (parameters.attachments || []).forEach((attachment) => {
- if (attachment) formData.append("attachments", attachment);
+): FormData =>
+ objectToFormData(addAttachmentsToApiParams(parameters), {
+ fileInput: file,
+ attachments: (parameters.attachments || []).filter(Boolean),
});
- formData.append("convertToPdfA3b", String(parameters.convertToPdfA3b));
-
- return formData;
-};
-
// Operation configuration for automation
export const addAttachmentsOperationConfig: ToolOperationConfig =
{
toolType: ToolType.singleFile,
buildFormData,
+ toApiParams: addAttachmentsToApiParams,
+ fromApiParams: addAttachmentsFromApiParams,
operationType: "addAttachments",
- endpoint: "/api/v1/misc/add-attachments",
+ endpoint: ENDPOINT,
+ defaultParameters: DEFAULT_ADD_ATTACHMENTS_PARAMETERS,
};
export const useAddAttachmentsOperation = () => {
diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts
index 3045c69450..77958629b0 100644
--- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts
+++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
-import { useAddPasswordOperation } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
+import {
+ addPasswordFromApiParams,
+ addPasswordToApiParams,
+ useAddPasswordOperation,
+} from "@app/hooks/tools/addPassword/useAddPasswordOperation";
import type { AddPasswordFullParameters } from "@app/hooks/tools/addPassword/useAddPasswordParameters";
// Mock the useToolOperation hook
@@ -141,3 +145,48 @@ describe("useAddPasswordOperation", () => {
expect(callArgs[property]).toBe(expectedValue);
});
});
+
+describe("addPassword mappers", () => {
+ test("round-trips backend params, including the flattened permissions", () => {
+ // Baseline differs from the configured values so the round trip fails if
+ // fromApiParams drops a field instead of reconstructing it.
+ const baseline: AddPasswordFullParameters = {
+ password: "",
+ ownerPassword: "",
+ keyLength: 40,
+ permissions: {
+ preventAssembly: false,
+ preventExtractContent: false,
+ preventExtractForAccessibility: false,
+ preventFillInForm: false,
+ preventModify: false,
+ preventModifyAnnotations: false,
+ preventPrinting: false,
+ preventPrintingFaithful: false,
+ },
+ };
+ const configured: AddPasswordFullParameters = {
+ password: "user-pw",
+ ownerPassword: "owner-pw",
+ keyLength: 128,
+ permissions: {
+ preventAssembly: true,
+ preventExtractContent: false,
+ preventExtractForAccessibility: true,
+ preventFillInForm: false,
+ preventModify: true,
+ preventModifyAnnotations: false,
+ preventPrinting: true,
+ preventPrintingFaithful: false,
+ },
+ };
+
+ const api = addPasswordToApiParams(configured);
+ const roundTripped = addPasswordToApiParams({
+ ...baseline,
+ ...addPasswordFromApiParams(api),
+ });
+
+ expect(roundTripped).toEqual(api);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts
index c09a740250..0bc3c1b396 100644
--- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts
@@ -3,29 +3,80 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AddPasswordFullParameters,
defaultParameters,
} from "@app/hooks/tools/addPassword/useAddPasswordParameters";
import { defaultParameters as permissionsDefaults } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
-import { getFormData } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
+
+const ENDPOINT = "/api/v1/security/add-password" satisfies ToolEndpoint;
+type AddPasswordApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the add-password request body. The
+// permissions sub-object is flattened into the request's prevent* fields.
+export const addPasswordToApiParams = (
+ parameters: AddPasswordFullParameters,
+): AddPasswordApiParams => ({
+ password: parameters.password,
+ ownerPassword: parameters.ownerPassword,
+ // The UI stores keyLength as a number; narrow it to the model's allowed sizes.
+ keyLength: parameters.keyLength as AddPasswordApiParams["keyLength"],
+ preventAssembly: parameters.permissions.preventAssembly ?? false,
+ preventExtractContent: parameters.permissions.preventExtractContent ?? false,
+ preventExtractForAccessibility:
+ parameters.permissions.preventExtractForAccessibility ?? false,
+ preventFillInForm: parameters.permissions.preventFillInForm ?? false,
+ preventModify: parameters.permissions.preventModify ?? false,
+ preventModifyAnnotations:
+ parameters.permissions.preventModifyAnnotations ?? false,
+ preventPrinting: parameters.permissions.preventPrinting ?? false,
+ preventPrintingFaithful:
+ parameters.permissions.preventPrintingFaithful ?? false,
+});
+
+// Reconstruct the tool's UI parameters from an add-password request body, so a
+// stored or AI-authored step can be re-rendered in the settings UI.
+export const addPasswordFromApiParams = (
+ apiParams: AddPasswordApiParams,
+): Partial => ({
+ password: apiParams.password ?? defaultParameters.password,
+ ownerPassword: apiParams.ownerPassword ?? defaultParameters.ownerPassword,
+ keyLength: apiParams.keyLength,
+ permissions: {
+ preventAssembly:
+ apiParams.preventAssembly ?? permissionsDefaults.preventAssembly,
+ preventExtractContent:
+ apiParams.preventExtractContent ??
+ permissionsDefaults.preventExtractContent,
+ preventExtractForAccessibility:
+ apiParams.preventExtractForAccessibility ??
+ permissionsDefaults.preventExtractForAccessibility,
+ preventFillInForm:
+ apiParams.preventFillInForm ?? permissionsDefaults.preventFillInForm,
+ preventModify: apiParams.preventModify ?? permissionsDefaults.preventModify,
+ preventModifyAnnotations:
+ apiParams.preventModifyAnnotations ??
+ permissionsDefaults.preventModifyAnnotations,
+ preventPrinting:
+ apiParams.preventPrinting ?? permissionsDefaults.preventPrinting,
+ preventPrintingFaithful:
+ apiParams.preventPrintingFaithful ??
+ permissionsDefaults.preventPrintingFaithful,
+ },
+});
// Static function that can be used by both the hook and automation executor
export const buildAddPasswordFormData = (
parameters: AddPasswordFullParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("password", parameters.password);
- formData.append("ownerPassword", parameters.ownerPassword);
- formData.append("keyLength", parameters.keyLength.toString());
- getFormData(parameters.permissions).forEach(([key, value]) => {
- formData.append(key, value);
- });
- return formData;
-};
+): FormData =>
+ objectToFormData(addPasswordToApiParams(parameters), { fileInput: file });
// Full default parameters including permissions for automation
const fullDefaultParameters: AddPasswordFullParameters = {
@@ -37,8 +88,10 @@ const fullDefaultParameters: AddPasswordFullParameters = {
export const addPasswordOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddPasswordFormData,
+ toApiParams: addPasswordToApiParams,
+ fromApiParams: addPasswordFromApiParams,
operationType: "addPassword",
- endpoint: "/api/v1/security/add-password",
+ endpoint: ENDPOINT,
defaultParameters: fullDefaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.test.ts b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.test.ts
new file mode 100644
index 0000000000..e00753e41c
--- /dev/null
+++ b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "vitest";
+import {
+ addWatermarkFromApiParams,
+ addWatermarkToApiParams,
+} from "@app/hooks/tools/addWatermark/useAddWatermarkOperation";
+import {
+ AddWatermarkParameters,
+ defaultParameters,
+} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
+
+describe("addWatermark mappers", () => {
+ // opacity 33 exercises the percentage <-> fraction conversion (/100, *100),
+ // which must survive the round trip without drifting on floating point.
+ test.each>([
+ { watermarkType: "text", watermarkText: "DRAFT", opacity: 33 },
+ { watermarkType: "image", opacity: 33 },
+ ])("round-trips backend params for %o", (overrides) => {
+ const api = addWatermarkToApiParams({ ...defaultParameters, ...overrides });
+ const roundTripped = addWatermarkToApiParams({
+ ...defaultParameters,
+ ...addWatermarkFromApiParams(api),
+ });
+
+ expect(roundTripped).toEqual(api);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts
index 19700f43e7..75651f7bcd 100644
--- a/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts
@@ -3,57 +3,97 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AddWatermarkParameters,
defaultParameters,
} from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
+const ENDPOINT = "/api/v1/security/add-watermark" satisfies ToolEndpoint;
+type AddWatermarkApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the add-watermark request body. The
+// watermark image itself is a File and is passed via the `files` argument.
+export const addWatermarkToApiParams = (
+ parameters: AddWatermarkParameters,
+): AddWatermarkApiParams => {
+ const watermarkType = parameters.watermarkType || "text";
+ const apiParams: AddWatermarkApiParams = {
+ watermarkType,
+ fontSize: parameters.fontSize,
+ rotation: parameters.rotation,
+ // The UI stores opacity as a 0-100 percentage; the backend expects 0.0-1.0.
+ opacity: parameters.opacity / 100,
+ widthSpacer: parameters.widthSpacer,
+ heightSpacer: parameters.heightSpacer,
+ // The UI types alphabet as a free string; the wire always sends it (empty
+ // string when unset) so the value is passed through and cast to the model
+ // enum to preserve existing behaviour.
+ alphabet: (parameters.alphabet || "") as AddWatermarkApiParams["alphabet"],
+ customColor: parameters.customColor || "",
+ convertPDFToImage: parameters.convertPDFToImage ?? false,
+ };
+
+ if (watermarkType === "text") {
+ apiParams.watermarkText = parameters.watermarkText;
+ }
+
+ return apiParams;
+};
+
+// Reconstruct the tool's UI parameters from an add-watermark request body, so a
+// stored or AI-authored step can be re-rendered in the settings UI. The
+// watermark image File cannot be recovered from the request model.
+export const addWatermarkFromApiParams = (
+ apiParams: AddWatermarkApiParams,
+): Partial => {
+ const result: Partial = {
+ watermarkType: apiParams.watermarkType,
+ fontSize: apiParams.fontSize,
+ rotation: apiParams.rotation,
+ widthSpacer: apiParams.widthSpacer,
+ heightSpacer: apiParams.heightSpacer,
+ alphabet: apiParams.alphabet ?? defaultParameters.alphabet,
+ customColor: apiParams.customColor ?? defaultParameters.customColor,
+ convertPDFToImage:
+ apiParams.convertPDFToImage ?? defaultParameters.convertPDFToImage,
+ };
+
+ if (apiParams.opacity !== undefined) {
+ result.opacity = apiParams.opacity * 100;
+ }
+ if (apiParams.watermarkText !== undefined) {
+ result.watermarkText = apiParams.watermarkText;
+ }
+
+ return result;
+};
+
// Static function that can be used by both the hook and automation executor
export const buildAddWatermarkFormData = (
parameters: AddWatermarkParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
-
- // Required: watermarkType as string
- formData.append("watermarkType", parameters.watermarkType || "text");
-
- // Add watermark content based on type
- if (parameters.watermarkType === "text") {
- formData.append("watermarkText", parameters.watermarkText);
- } else if (
- parameters.watermarkType === "image" &&
- parameters.watermarkImage
- ) {
- formData.append("watermarkImage", parameters.watermarkImage);
- }
-
- // Required parameters with correct formatting (defaults merged in automationExecutor)
- formData.append("fontSize", parameters.fontSize.toString());
- formData.append("rotation", parameters.rotation.toString());
- formData.append("opacity", (parameters.opacity / 100).toString()); // Convert percentage to decimal
- formData.append("widthSpacer", parameters.widthSpacer.toString());
- formData.append("heightSpacer", parameters.heightSpacer.toString());
-
- // Backend-expected parameters from user input
- formData.append("alphabet", parameters.alphabet || "");
- formData.append("customColor", parameters.customColor || "");
- formData.append(
- "convertPDFToImage",
- (parameters.convertPDFToImage ?? false).toString(),
+): FormData =>
+ objectToFormData(
+ addWatermarkToApiParams(parameters),
+ parameters.watermarkType === "image" && parameters.watermarkImage
+ ? { fileInput: file, watermarkImage: parameters.watermarkImage }
+ : { fileInput: file },
);
- return formData;
-};
-
// Static configuration object
export const addWatermarkOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddWatermarkFormData,
+ toApiParams: addWatermarkToApiParams,
+ fromApiParams: addWatermarkFromApiParams,
operationType: "watermark",
- endpoint: "/api/v1/security/add-watermark",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts b/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts
index 95ee6f3293..e278097508 100644
--- a/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts
+++ b/frontend/editor/src/core/hooks/tools/adjustPageScale/adjustPageScaleFormData.ts
@@ -1,13 +1,38 @@
-import { AdjustPageScaleParameters } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
+import {
+ AdjustPageScaleParameters,
+ PageSize,
+} from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
+
+export const ADJUST_PAGE_SCALE_ENDPOINT =
+ "/api/v1/general/scale-pages" satisfies ToolEndpoint;
+type AdjustPageScaleApiParams =
+ ToolApiParams[typeof ADJUST_PAGE_SCALE_ENDPOINT];
+
+export const adjustPageScaleToApiParams = (
+ parameters: AdjustPageScaleParameters,
+): AdjustPageScaleApiParams => ({
+ scaleFactor: parameters.scaleFactor,
+ pageSize: parameters.pageSize,
+ orientation: parameters.orientation,
+});
+
+export const adjustPageScaleFromApiParams = (
+ apiParams: AdjustPageScaleApiParams,
+): Partial => ({
+ scaleFactor: apiParams.scaleFactor,
+ pageSize: apiParams.pageSize as PageSize,
+ orientation: apiParams.orientation,
+});
export const buildAdjustPageScaleFormData = (
parameters: AdjustPageScaleParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("scaleFactor", parameters.scaleFactor.toString());
- formData.append("pageSize", parameters.pageSize);
- formData.append("orientation", parameters.orientation);
- return formData;
-};
+): FormData =>
+ objectToFormData(adjustPageScaleToApiParams(parameters), {
+ fileInput: file,
+ });
diff --git a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts
index 264c71c940..7453ecfb3e 100644
--- a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts
+++ b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.test.ts
@@ -1,5 +1,9 @@
-import { describe, expect, it } from "vitest";
-import { buildAdjustPageScaleFormData } from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData";
+import { describe, expect, it, test } from "vitest";
+import {
+ adjustPageScaleFromApiParams,
+ adjustPageScaleToApiParams,
+ buildAdjustPageScaleFormData,
+} from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData";
import {
defaultParameters,
PageSize,
@@ -49,3 +53,20 @@ describe("buildAdjustPageScaleFormData", () => {
expect(formData.get("fileInput")).toBe(file);
});
});
+
+describe("adjustPageScale mappers", () => {
+ test("round-trips backend params", () => {
+ const api = adjustPageScaleToApiParams({
+ ...defaultParameters,
+ scaleFactor: 1.5,
+ pageSize: PageSize.A4,
+ orientation: "LANDSCAPE",
+ });
+ const roundTripped = adjustPageScaleToApiParams({
+ ...defaultParameters,
+ ...adjustPageScaleFromApiParams(api),
+ });
+
+ expect(roundTripped).toEqual(api);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts
index c710737722..c25e12e3a9 100644
--- a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts
@@ -8,15 +8,26 @@ import {
AdjustPageScaleParameters,
defaultParameters,
} from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
-import { buildAdjustPageScaleFormData } from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData";
+import {
+ buildAdjustPageScaleFormData,
+ adjustPageScaleToApiParams,
+ adjustPageScaleFromApiParams,
+ ADJUST_PAGE_SCALE_ENDPOINT,
+} from "@app/hooks/tools/adjustPageScale/adjustPageScaleFormData";
-export { buildAdjustPageScaleFormData };
+export {
+ buildAdjustPageScaleFormData,
+ adjustPageScaleToApiParams,
+ adjustPageScaleFromApiParams,
+};
export const adjustPageScaleOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAdjustPageScaleFormData,
+ toApiParams: adjustPageScaleToApiParams,
+ fromApiParams: adjustPageScaleFromApiParams,
operationType: "scalePages",
- endpoint: "/api/v1/general/scale-pages",
+ endpoint: ADJUST_PAGE_SCALE_ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts b/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts
index 26c900df66..faca5b0ae7 100644
--- a/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts
@@ -3,40 +3,54 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
AutoRenameParameters,
defaultParameters,
} from "@app/hooks/tools/autoRename/useAutoRenameParameters";
-export const getFormData = (parameters: AutoRenameParameters) =>
- Object.entries(parameters).map(([key, value]) => [
- key,
- value.toString(),
- ]) as string[][];
+const ENDPOINT = "/api/v1/misc/auto-rename" satisfies ToolEndpoint;
+type AutoRenameApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the auto-rename request body. The return
+// type is the generated backend model, so a spec change that renames or drops a
+// field breaks the build here.
+export const autoRenameToApiParams = (
+ parameters: AutoRenameParameters,
+): AutoRenameApiParams => ({
+ useFirstTextAsFallback: parameters.useFirstTextAsFallback,
+});
+
+// Reconstruct the tool's UI parameters from an auto-rename request body, so a
+// stored or AI-authored step can be re-rendered in the settings UI.
+export const autoRenameFromApiParams = (
+ apiParams: AutoRenameApiParams,
+): Partial => ({
+ useFirstTextAsFallback:
+ apiParams.useFirstTextAsFallback ??
+ defaultParameters.useFirstTextAsFallback,
+});
// Static function that can be used by both the hook and automation executor
export const buildAutoRenameFormData = (
parameters: AutoRenameParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
-
- // Add all permission parameters
- getFormData(parameters).forEach(([key, value]) => {
- formData.append(key, value);
- });
-
- return formData;
-};
+): FormData =>
+ objectToFormData(autoRenameToApiParams(parameters), { fileInput: file });
// Static configuration object
export const autoRenameOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAutoRenameFormData,
+ toApiParams: autoRenameToApiParams,
+ fromApiParams: autoRenameFromApiParams,
operationType: "autoRename",
- endpoint: "/api/v1/misc/auto-rename",
+ endpoint: ENDPOINT,
preserveBackendFilename: true, // Use filename from backend response headers
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts b/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts
index 6da2b66320..f5a213d48c 100644
--- a/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts
@@ -3,36 +3,69 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
BookletImpositionParameters,
defaultParameters,
} from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters";
+const ENDPOINT = "/api/v1/general/booklet-imposition" satisfies ToolEndpoint;
+type BookletImpositionApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the booklet-imposition request body. The
+// return type is the generated backend model, so a spec change that renames or
+// drops a field breaks the build here.
+export const bookletImpositionToApiParams = (
+ parameters: BookletImpositionParameters,
+): BookletImpositionApiParams => ({
+ pagesPerSheet: parameters.pagesPerSheet,
+ addBorder: parameters.addBorder,
+ spineLocation: parameters.spineLocation,
+ addGutter: parameters.addGutter,
+ gutterSize: parameters.gutterSize,
+ doubleSided: parameters.doubleSided,
+ duplexPass: parameters.duplexPass,
+ flipOnShortEdge: parameters.flipOnShortEdge,
+});
+
+// Reconstruct the tool's UI parameters from a booklet-imposition request body,
+// so a stored or AI-authored step can be re-rendered in the settings UI.
+export const bookletImpositionFromApiParams = (
+ apiParams: BookletImpositionApiParams,
+): Partial => ({
+ pagesPerSheet: apiParams.pagesPerSheet ?? defaultParameters.pagesPerSheet,
+ addBorder: apiParams.addBorder ?? defaultParameters.addBorder,
+ spineLocation: apiParams.spineLocation ?? defaultParameters.spineLocation,
+ addGutter: apiParams.addGutter ?? defaultParameters.addGutter,
+ gutterSize: apiParams.gutterSize ?? defaultParameters.gutterSize,
+ doubleSided: apiParams.doubleSided ?? defaultParameters.doubleSided,
+ duplexPass: apiParams.duplexPass ?? defaultParameters.duplexPass,
+ flipOnShortEdge:
+ apiParams.flipOnShortEdge ?? defaultParameters.flipOnShortEdge,
+});
+
// Static configuration that can be used by both the hook and automation executor
export const buildBookletImpositionFormData = (
parameters: BookletImpositionParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("pagesPerSheet", parameters.pagesPerSheet.toString());
- formData.append("addBorder", parameters.addBorder.toString());
- formData.append("spineLocation", parameters.spineLocation);
- formData.append("addGutter", parameters.addGutter.toString());
- formData.append("gutterSize", parameters.gutterSize.toString());
- formData.append("doubleSided", parameters.doubleSided.toString());
- formData.append("duplexPass", parameters.duplexPass);
- formData.append("flipOnShortEdge", parameters.flipOnShortEdge.toString());
- return formData;
-};
+): FormData =>
+ objectToFormData(bookletImpositionToApiParams(parameters), {
+ fileInput: file,
+ });
// Static configuration object
export const bookletImpositionOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildBookletImpositionFormData,
+ toApiParams: bookletImpositionToApiParams,
+ fromApiParams: bookletImpositionFromApiParams,
operationType: "bookletImposition",
- endpoint: "/api/v1/general/booklet-imposition",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts b/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts
index 8488dec77a..9f4767044e 100644
--- a/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts
@@ -3,86 +3,146 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type FormDataFiles,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
CertSignParameters,
defaultParameters,
} from "@app/hooks/tools/certSign/useCertSignParameters";
+const ENDPOINT = "/api/v1/security/cert-sign" satisfies ToolEndpoint;
+type CertSignApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the cert-sign request body. The keystore
+// uploads (privateKeyFile, certFile, p12File, jksFile) are actual File uploads
+// and are appended separately (see buildCertSignFormData); only the scalar
+// fields are serialized here.
+export const certSignToApiParams = (
+ parameters: CertSignParameters,
+): CertSignApiParams => {
+ // AUTO mode signs with the server certificate; no keystore/password is sent.
+ if (parameters.signMode === "AUTO") {
+ return withSignatureAppearance({ certType: "SERVER" }, parameters);
+ }
+
+ const apiParams: CertSignApiParams = {
+ certType: parameters.certType as CertSignApiParams["certType"],
+ password: parameters.password,
+ };
+
+ // Non-file identifiers depend on the chosen certificate type.
+ switch (parameters.certType) {
+ case "WINDOWS_STORE":
+ if (parameters.alias) apiParams.alias = parameters.alias;
+ break;
+ case "PKCS11":
+ if (parameters.pkcs11LibraryPath) {
+ apiParams.pkcs11LibraryPath = parameters.pkcs11LibraryPath;
+ }
+ if (parameters.pkcs11Slot != null) {
+ apiParams.pkcs11Slot = parameters.pkcs11Slot;
+ }
+ if (parameters.alias) apiParams.alias = parameters.alias;
+ break;
+ }
+
+ return withSignatureAppearance(apiParams, parameters);
+};
+
+// Signature appearance fields are only sent when the visible signature is
+// enabled, matching the original form behaviour.
+const withSignatureAppearance = (
+ apiParams: CertSignApiParams,
+ parameters: CertSignParameters,
+): CertSignApiParams => {
+ if (parameters.showSignature) {
+ apiParams.showSignature = true;
+ apiParams.reason = parameters.reason;
+ apiParams.location = parameters.location;
+ apiParams.name = parameters.name;
+ apiParams.pageNumber = parameters.pageNumber;
+ apiParams.showLogo = parameters.showLogo;
+ }
+ return apiParams;
+};
+
+// Select the keystore File uploads for the chosen certificate type. AUTO mode
+// (server certificate) uploads no keystore.
+const certSignFiles = (parameters: CertSignParameters): FormDataFiles => {
+ if (parameters.signMode === "AUTO") return {};
+
+ switch (parameters.certType) {
+ case "PEM":
+ return {
+ privateKeyFile: parameters.privateKeyFile,
+ certFile: parameters.certFile,
+ };
+ case "PKCS12":
+ case "PFX":
+ return { p12File: parameters.p12File };
+ case "JKS":
+ return { jksFile: parameters.jksFile };
+ default:
+ return {};
+ }
+};
+
+// Reconstruct the tool's UI parameters from a cert-sign request body, so a stored
+// or AI-authored step can be re-rendered in the settings UI. Uploaded keystore
+// files cannot be recovered from the request model.
+export const certSignFromApiParams = (
+ apiParams: CertSignApiParams,
+): Partial => {
+ const result: Partial = {
+ signMode: apiParams.certType === "SERVER" ? "AUTO" : "MANUAL",
+ showSignature: apiParams.showSignature ?? defaultParameters.showSignature,
+ };
+
+ if (apiParams.certType !== "SERVER") {
+ result.certType = apiParams.certType;
+ result.password = apiParams.password ?? defaultParameters.password;
+ }
+ if (apiParams.alias !== undefined) result.alias = apiParams.alias;
+ if (apiParams.pkcs11LibraryPath !== undefined) {
+ result.pkcs11LibraryPath = apiParams.pkcs11LibraryPath;
+ }
+ if (apiParams.pkcs11Slot !== undefined) {
+ result.pkcs11Slot = apiParams.pkcs11Slot;
+ }
+ if (apiParams.reason !== undefined) result.reason = apiParams.reason;
+ if (apiParams.location !== undefined) result.location = apiParams.location;
+ if (apiParams.name !== undefined) result.name = apiParams.name;
+ if (apiParams.pageNumber !== undefined) {
+ result.pageNumber = apiParams.pageNumber;
+ }
+ if (apiParams.showLogo !== undefined) result.showLogo = apiParams.showLogo;
+
+ return result;
+};
+
// Build form data for signing
export const buildCertSignFormData = (
parameters: CertSignParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
-
- // Handle sign mode
- if (parameters.signMode === "AUTO") {
- formData.append("certType", "SERVER");
- } else {
- formData.append("certType", parameters.certType);
- formData.append("password", parameters.password);
-
- // Add certificate files based on type (only for manual mode)
- switch (parameters.certType) {
- case "PEM":
- if (parameters.privateKeyFile) {
- formData.append("privateKeyFile", parameters.privateKeyFile);
- }
- if (parameters.certFile) {
- formData.append("certFile", parameters.certFile);
- }
- break;
- case "PKCS12":
- case "PFX":
- if (parameters.p12File) {
- formData.append("p12File", parameters.p12File);
- }
- break;
- case "JKS":
- if (parameters.jksFile) {
- formData.append("jksFile", parameters.jksFile);
- }
- break;
- case "WINDOWS_STORE":
- if (parameters.alias) {
- formData.append("alias", parameters.alias);
- }
- break;
- case "PKCS11":
- if (parameters.pkcs11LibraryPath) {
- formData.append("pkcs11LibraryPath", parameters.pkcs11LibraryPath);
- }
- if (parameters.pkcs11Slot != null) {
- formData.append("pkcs11Slot", parameters.pkcs11Slot.toString());
- }
- if (parameters.alias) {
- formData.append("alias", parameters.alias);
- }
- break;
- }
- }
-
- // Add signature appearance options if enabled
- if (parameters.showSignature) {
- formData.append("showSignature", "true");
- formData.append("reason", parameters.reason);
- formData.append("location", parameters.location);
- formData.append("name", parameters.name);
- formData.append("pageNumber", parameters.pageNumber.toString());
- formData.append("showLogo", parameters.showLogo.toString());
- }
-
- return formData;
-};
+): FormData =>
+ objectToFormData(certSignToApiParams(parameters), {
+ fileInput: file,
+ ...certSignFiles(parameters),
+ });
// Static configuration object
export const certSignOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCertSignFormData,
+ toApiParams: certSignToApiParams,
+ fromApiParams: certSignFromApiParams,
operationType: "certSign",
- endpoint: "/api/v1/security/cert-sign",
+ endpoint: ENDPOINT,
multiFileEndpoint: false,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts
index 2be13c3466..bad85097e2 100644
--- a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts
+++ b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.test.ts
@@ -1,7 +1,14 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
-import { useChangePermissionsOperation } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
-import type { ChangePermissionsParameters } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
+import {
+ changePermissionsFromApiParams,
+ changePermissionsToApiParams,
+ useChangePermissionsOperation,
+} from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
+import {
+ type ChangePermissionsParameters,
+ defaultParameters,
+} from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
// Mock the useToolOperation hook
vi.mock("../shared/useToolOperation", async () => {
@@ -141,3 +148,26 @@ describe("useChangePermissionsOperation", () => {
expect(callArgs[property]).toBe(expectedValue);
});
});
+
+describe("changePermissions mappers", () => {
+ test("round-trips backend params", () => {
+ const configured: ChangePermissionsParameters = {
+ preventAssembly: true,
+ preventExtractContent: false,
+ preventExtractForAccessibility: true,
+ preventFillInForm: false,
+ preventModify: true,
+ preventModifyAnnotations: false,
+ preventPrinting: true,
+ preventPrintingFaithful: false,
+ };
+
+ const api = changePermissionsToApiParams(configured);
+ const roundTripped = changePermissionsToApiParams({
+ ...defaultParameters,
+ ...changePermissionsFromApiParams(api),
+ });
+
+ expect(roundTripped).toEqual(api);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts
index 0500d86417..dd0c532706 100644
--- a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts
@@ -3,42 +3,81 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ChangePermissionsParameters,
defaultParameters,
} from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
-export const getFormData = (parameters: ChangePermissionsParameters) => {
- if (!parameters) return [];
- return Object.entries(parameters).map(([key, value]) => [
- key,
- (value ?? false).toString(),
- ]) as string[][];
-};
+// Change Permissions reuses the Add Password endpoint but sends only the
+// prevent* subset of the request model (no password or keyLength).
+const ENDPOINT = "/api/v1/security/add-password" satisfies ToolEndpoint;
+type AddPasswordApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the add-password request body. Only the
+// prevent* permission flags are sent; password and keyLength are optional on the
+// model and left unset, so the endpoint changes permissions without encrypting.
+export const changePermissionsToApiParams = (
+ parameters: ChangePermissionsParameters,
+): AddPasswordApiParams => ({
+ preventAssembly: parameters.preventAssembly ?? false,
+ preventExtractContent: parameters.preventExtractContent ?? false,
+ preventExtractForAccessibility:
+ parameters.preventExtractForAccessibility ?? false,
+ preventFillInForm: parameters.preventFillInForm ?? false,
+ preventModify: parameters.preventModify ?? false,
+ preventModifyAnnotations: parameters.preventModifyAnnotations ?? false,
+ preventPrinting: parameters.preventPrinting ?? false,
+ preventPrintingFaithful: parameters.preventPrintingFaithful ?? false,
+});
+
+// Reconstruct the tool's UI parameters from an add-password request body, so a
+// stored or AI-authored step can be re-rendered in the settings UI.
+export const changePermissionsFromApiParams = (
+ apiParams: AddPasswordApiParams,
+): Partial => ({
+ preventAssembly:
+ apiParams.preventAssembly ?? defaultParameters.preventAssembly,
+ preventExtractContent:
+ apiParams.preventExtractContent ?? defaultParameters.preventExtractContent,
+ preventExtractForAccessibility:
+ apiParams.preventExtractForAccessibility ??
+ defaultParameters.preventExtractForAccessibility,
+ preventFillInForm:
+ apiParams.preventFillInForm ?? defaultParameters.preventFillInForm,
+ preventModify: apiParams.preventModify ?? defaultParameters.preventModify,
+ preventModifyAnnotations:
+ apiParams.preventModifyAnnotations ??
+ defaultParameters.preventModifyAnnotations,
+ preventPrinting:
+ apiParams.preventPrinting ?? defaultParameters.preventPrinting,
+ preventPrintingFaithful:
+ apiParams.preventPrintingFaithful ??
+ defaultParameters.preventPrintingFaithful,
+});
// Static function that can be used by both the hook and automation executor
export const buildChangePermissionsFormData = (
parameters: ChangePermissionsParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
-
- // Add all permission parameters
- getFormData(parameters).forEach(([key, value]) => {
- formData.append(key, value);
+): FormData =>
+ objectToFormData(changePermissionsToApiParams(parameters), {
+ fileInput: file,
});
- return formData;
-};
-
// Static configuration object
export const changePermissionsOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildChangePermissionsFormData,
+ toApiParams: changePermissionsToApiParams,
+ fromApiParams: changePermissionsFromApiParams,
operationType: "changePermissions",
- endpoint: "/api/v1/security/add-password", // Change Permissions is a fake endpoint for the Add Password tool
+ endpoint: ENDPOINT, // Change Permissions is a fake endpoint for the Add Password tool
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.test.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.test.ts
new file mode 100644
index 0000000000..4118eaf7d6
--- /dev/null
+++ b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, test } from "vitest";
+import {
+ buildCompressFormData,
+ compressFromApiParams,
+ compressToApiParams,
+} from "@app/hooks/tools/compress/useCompressOperation";
+import {
+ CompressParameters,
+ defaultParameters,
+} from "@app/hooks/tools/compress/useCompressParameters";
+
+const params = (
+ overrides: Partial,
+): CompressParameters => ({
+ ...defaultParameters,
+ ...overrides,
+});
+
+describe("compressToApiParams", () => {
+ test("quality mode sends optimizeLevel and no expectedOutputSize", () => {
+ const api = compressToApiParams(
+ params({ compressionMethod: "quality", compressionLevel: 7 }),
+ );
+
+ expect(api.optimizeLevel).toBe(7);
+ expect(api.expectedOutputSize).toBeUndefined();
+ });
+
+ test("file-size mode sends expectedOutputSize (level still present for the spec)", () => {
+ const api = compressToApiParams(
+ params({
+ compressionMethod: "filesize",
+ fileSizeValue: "100",
+ fileSizeUnit: "MB",
+ }),
+ );
+
+ // optimizeLevel is required by the backend model; the backend recomputes it
+ // from the target size, so its presence is harmless.
+ expect(api.optimizeLevel).toBeDefined();
+ expect(api.expectedOutputSize).toBe("100MB");
+ });
+
+ test("omits expectedOutputSize when file-size value is empty", () => {
+ const api = compressToApiParams(
+ params({ compressionMethod: "filesize", fileSizeValue: "" }),
+ );
+
+ expect(api.expectedOutputSize).toBeUndefined();
+ });
+
+ test("line-art thresholds only included when line art is enabled", () => {
+ const off = compressToApiParams(params({ lineArt: false }));
+ expect(off.lineArtThreshold).toBeUndefined();
+ expect(off.lineArtEdgeLevel).toBeUndefined();
+
+ const on = compressToApiParams(
+ params({ lineArt: true, lineArtThreshold: 40, lineArtEdgeLevel: 2 }),
+ );
+ expect(on.lineArtThreshold).toBe(40);
+ expect(on.lineArtEdgeLevel).toBe(2);
+ });
+
+ test("defaults produce the required optimizeLevel field", () => {
+ const api = compressToApiParams(defaultParameters);
+ expect(api.optimizeLevel).toBe(defaultParameters.compressionLevel);
+ });
+});
+
+describe("compressFromApiParams", () => {
+ test("expectedOutputSize maps back to file-size mode and its value/unit", () => {
+ const ui = compressFromApiParams({
+ optimizeLevel: 5,
+ expectedOutputSize: "25KB",
+ });
+
+ expect(ui.compressionMethod).toBe("filesize");
+ expect(ui.fileSizeValue).toBe("25");
+ expect(ui.fileSizeUnit).toBe("KB");
+ });
+
+ test("no expectedOutputSize maps back to quality mode", () => {
+ const ui = compressFromApiParams({ optimizeLevel: 8 });
+
+ expect(ui.compressionMethod).toBe("quality");
+ expect(ui.compressionLevel).toBe(8);
+ });
+});
+
+describe("compress round-trip", () => {
+ test.each>([
+ { compressionMethod: "quality", compressionLevel: 3, grayscale: true },
+ {
+ compressionMethod: "filesize",
+ fileSizeValue: "10",
+ fileSizeUnit: "MB",
+ linearize: true,
+ },
+ {
+ compressionMethod: "quality",
+ lineArt: true,
+ lineArtThreshold: 60,
+ lineArtEdgeLevel: 3,
+ },
+ ])("toApiParams(fromApiParams(x)) reproduces x %o", (overrides) => {
+ const api = compressToApiParams(params(overrides));
+ const roundTripped = compressToApiParams(
+ params(compressFromApiParams(api)),
+ );
+
+ expect(roundTripped).toEqual(api);
+ });
+});
+
+describe("buildCompressFormData", () => {
+ test("appends the file and serialized parameters", () => {
+ const file = new File(["x"], "test.pdf", { type: "application/pdf" });
+ const formData = buildCompressFormData(
+ params({ compressionMethod: "quality", compressionLevel: 6 }),
+ file,
+ );
+
+ expect(formData.get("fileInput")).toBe(file);
+ expect(formData.get("optimizeLevel")).toBe("6");
+ expect(formData.get("grayscale")).toBe("false");
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts
index 6efa24e5d3..f62c981879 100644
--- a/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts
@@ -3,48 +3,100 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
CompressParameters,
defaultParameters,
} from "@app/hooks/tools/compress/useCompressParameters";
+const ENDPOINT = "/api/v1/misc/compress-pdf" satisfies ToolEndpoint;
+type CompressApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the compress-pdf request body. The
+// return type is the generated backend model, so a spec change that renames or
+// drops a field breaks the build here.
+export const compressToApiParams = (
+ parameters: CompressParameters,
+): CompressApiParams => {
+ const apiParams: CompressApiParams = {
+ // compressionLevel is validated to 1-9 by the parameters hook. It is always
+ // sent: in file-size mode the backend recomputes the level from the target
+ // size (autoMode in CompressController), so this value only takes effect in
+ // quality mode.
+ optimizeLevel:
+ parameters.compressionLevel as CompressApiParams["optimizeLevel"],
+ grayscale: parameters.grayscale ?? false,
+ lineArt: parameters.lineArt,
+ linearize: parameters.linearize,
+ };
+
+ if (parameters.compressionMethod === "filesize" && parameters.fileSizeValue) {
+ apiParams.expectedOutputSize = `${parameters.fileSizeValue}${parameters.fileSizeUnit}`;
+ }
+
+ if (parameters.lineArt) {
+ apiParams.lineArtThreshold = parameters.lineArtThreshold;
+ apiParams.lineArtEdgeLevel = parameters.lineArtEdgeLevel;
+ }
+
+ return apiParams;
+};
+
+// Reconstruct the tool's UI parameters from a compress-pdf request body, so a
+// stored or AI-authored step can be re-rendered in the settings UI.
+export const compressFromApiParams = (
+ apiParams: CompressApiParams,
+): Partial => {
+ const result: Partial = {
+ compressionLevel: apiParams.optimizeLevel,
+ grayscale: apiParams.grayscale ?? defaultParameters.grayscale,
+ lineArt: apiParams.lineArt ?? defaultParameters.lineArt,
+ linearize: apiParams.linearize ?? defaultParameters.linearize,
+ };
+
+ if (apiParams.lineArtThreshold !== undefined) {
+ result.lineArtThreshold = apiParams.lineArtThreshold;
+ }
+ if (apiParams.lineArtEdgeLevel !== undefined) {
+ result.lineArtEdgeLevel = apiParams.lineArtEdgeLevel;
+ }
+
+ if (apiParams.expectedOutputSize) {
+ result.compressionMethod = "filesize";
+ const match = /^(\d+(?:\.\d+)?)(KB|MB)$/i.exec(
+ apiParams.expectedOutputSize,
+ );
+ if (match) {
+ result.fileSizeValue = match[1];
+ result.fileSizeUnit = match[2].toUpperCase() as "KB" | "MB";
+ }
+ } else {
+ result.compressionMethod = "quality";
+ }
+
+ return result;
+};
+
// Static configuration that can be used by both the hook and automation executor
export const buildCompressFormData = (
parameters: CompressParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
-
- if (parameters.compressionMethod === "quality") {
- formData.append("optimizeLevel", parameters.compressionLevel.toString());
- } else {
- // File size method
- const fileSize = parameters.fileSizeValue
- ? `${parameters.fileSizeValue}${parameters.fileSizeUnit}`
- : "";
- if (fileSize) {
- formData.append("expectedOutputSize", fileSize);
- }
- }
-
- formData.append("grayscale", (parameters.grayscale ?? false).toString());
- formData.append("lineArt", parameters.lineArt.toString());
- formData.append("linearize", parameters.linearize.toString());
- if (parameters.lineArt) {
- formData.append("lineArtThreshold", parameters.lineArtThreshold.toString());
- formData.append("lineArtEdgeLevel", parameters.lineArtEdgeLevel.toString());
- }
- return formData;
-};
+): FormData =>
+ objectToFormData(compressToApiParams(parameters), { fileInput: file });
// Static configuration object
export const compressOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCompressFormData,
+ toApiParams: compressToApiParams,
+ fromApiParams: compressFromApiParams,
operationType: "compress",
- endpoint: "/api/v1/misc/compress-pdf",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.test.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.test.ts
new file mode 100644
index 0000000000..eb03607d54
--- /dev/null
+++ b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useCompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
+
+describe("useCompressParameters", () => {
+ test("defaults (quality mode) validate", () => {
+ const { result } = renderHook(() => useCompressParameters());
+
+ expect(result.current.validateParameters()).toBe(true);
+ });
+
+ test("compressionLevel outside 1-9 is invalid", () => {
+ const { result } = renderHook(() => useCompressParameters());
+
+ act(() => {
+ result.current.updateParameter("compressionLevel", 0);
+ });
+ expect(result.current.validateParameters()).toBe(false);
+
+ act(() => {
+ result.current.updateParameter("compressionLevel", 10);
+ });
+ expect(result.current.validateParameters()).toBe(false);
+ });
+
+ test("filesize mode requires a target size", () => {
+ const { result } = renderHook(() => useCompressParameters());
+
+ // Filesize mode with no size entered must not validate: otherwise the
+ // request omits expectedOutputSize and the backend silently falls back to a
+ // quality compression.
+ act(() => {
+ result.current.updateParameter("compressionMethod", "filesize");
+ });
+ expect(result.current.validateParameters()).toBe(false);
+
+ act(() => {
+ result.current.updateParameter("fileSizeValue", "5");
+ });
+ expect(result.current.validateParameters()).toBe(true);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts
index a6e9bfe631..0500b8c77c 100644
--- a/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts
+++ b/frontend/editor/src/core/hooks/tools/compress/useCompressParameters.ts
@@ -37,8 +37,15 @@ export const useCompressParameters = (): CompressParametersHook => {
defaultParameters,
endpointName: "compress-pdf",
validateFn: (params) => {
- // For compression, we only need to validate that compression level is within range
- return params.compressionLevel >= 1 && params.compressionLevel <= 9;
+ if (params.compressionLevel < 1 || params.compressionLevel > 9) {
+ return false;
+ }
+ // Filesize mode needs a target size; without one the request omits
+ // expectedOutputSize and the backend silently does a quality compression.
+ if (params.compressionMethod === "filesize") {
+ return params.fileSizeValue.trim() !== "";
+ }
+ return true;
},
});
};
diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.test.ts b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.test.ts
new file mode 100644
index 0000000000..806ad40af0
--- /dev/null
+++ b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, test } from "vitest";
+import {
+ cropFromApiParams,
+ cropToApiParams,
+} from "@app/hooks/tools/crop/useCropOperation";
+import {
+ CropParameters,
+ defaultParameters,
+} from "@app/hooks/tools/crop/useCropParameters";
+
+describe("crop mappers", () => {
+ // With autoCrop on the coordinates aren't sent, so they must not resurface on
+ // the round trip; with autoCrop off the rectangle must survive intact.
+ test.each<{ label: string; overrides: Partial }>([
+ { label: "autoCrop on", overrides: { autoCrop: true } },
+ {
+ label: "autoCrop off with a rectangle",
+ overrides: {
+ autoCrop: false,
+ cropArea: { x: 10, y: 20, width: 300, height: 400 },
+ },
+ },
+ ])("round-trips backend params ($label)", ({ overrides }) => {
+ const api = cropToApiParams({ ...defaultParameters, ...overrides });
+ const roundTripped = cropToApiParams({
+ ...defaultParameters,
+ ...cropFromApiParams(api),
+ });
+
+ expect(roundTripped).toEqual(api);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts
index 67d72c8df0..24dc35daa3 100644
--- a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts
@@ -3,40 +3,69 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
CropParameters,
defaultParameters,
} from "@app/hooks/tools/crop/useCropParameters";
+import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants";
+
+const ENDPOINT = "/api/v1/general/crop" satisfies ToolEndpoint;
+type CropApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the crop request body. The return type
+// is the generated backend model, so a spec change that renames or drops a
+// field breaks the build here.
+export const cropToApiParams = (parameters: CropParameters): CropApiParams => {
+ const apiParams: CropApiParams = {
+ autoCrop: parameters.autoCrop,
+ };
+
+ if (!parameters.autoCrop) {
+ const cropArea = parameters.cropArea;
+ apiParams.x = cropArea.x;
+ apiParams.y = cropArea.y;
+ apiParams.width = cropArea.width;
+ apiParams.height = cropArea.height;
+ }
+
+ return apiParams;
+};
+
+// Reconstruct the tool's UI parameters from a crop request body, so a stored or
+// AI-authored step can be re-rendered in the settings UI.
+export const cropFromApiParams = (
+ apiParams: CropApiParams,
+): Partial => ({
+ autoCrop: apiParams.autoCrop ?? defaultParameters.autoCrop,
+ cropArea: {
+ x: apiParams.x ?? DEFAULT_CROP_AREA.x,
+ y: apiParams.y ?? DEFAULT_CROP_AREA.y,
+ width: apiParams.width ?? DEFAULT_CROP_AREA.width,
+ height: apiParams.height ?? DEFAULT_CROP_AREA.height,
+ },
+});
// Static configuration that can be used by both the hook and automation executor
export const buildCropFormData = (
parameters: CropParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
-
- if (!parameters.autoCrop) {
- const cropArea = parameters.cropArea;
-
- formData.append("x", cropArea.x.toString());
- formData.append("y", cropArea.y.toString());
- formData.append("width", cropArea.width.toString());
- formData.append("height", cropArea.height.toString());
- }
-
- formData.append("autoCrop", parameters.autoCrop.toString());
-
- return formData;
-};
+): FormData =>
+ objectToFormData(cropToApiParams(parameters), { fileInput: file });
// Static configuration object
export const cropOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildCropFormData,
+ toApiParams: cropToApiParams,
+ fromApiParams: cropFromApiParams,
operationType: "crop",
- endpoint: "/api/v1/general/crop",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.test.ts b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.test.ts
new file mode 100644
index 0000000000..288beed847
--- /dev/null
+++ b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.test.ts
@@ -0,0 +1,46 @@
+import { describe, test, expect } from "vitest";
+import { expectConsole } from "@app/tests/failOnConsole";
+import { editTableOfContentsFromApiParams } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation";
+
+describe("editTableOfContentsFromApiParams", () => {
+ test("passes replaceExisting through", () => {
+ expect(editTableOfContentsFromApiParams({ replaceExisting: true })).toEqual(
+ {
+ replaceExisting: true,
+ },
+ );
+ });
+
+ test("hydrates a valid (empty) bookmark array", () => {
+ expect(
+ editTableOfContentsFromApiParams({
+ replaceExisting: false,
+ bookmarkData: "[]",
+ }),
+ ).toEqual({ replaceExisting: false, bookmarks: [] });
+ });
+
+ test.each(["", "not json", "{truncated"])(
+ "does not throw on malformed bookmarkData (%j); leaves bookmarks unset",
+ (bookmarkData) => {
+ expectConsole.warn(/could not parse bookmarkData/);
+ const result = editTableOfContentsFromApiParams({
+ replaceExisting: true,
+ bookmarkData,
+ });
+ expect(result).toEqual({ replaceExisting: true });
+ expect(result).not.toHaveProperty("bookmarks");
+ },
+ );
+
+ test.each(["{}", "null", "42"])(
+ "ignores non-array bookmarkData (%j) without throwing",
+ (bookmarkData) => {
+ const result = editTableOfContentsFromApiParams({
+ replaceExisting: false,
+ bookmarkData,
+ });
+ expect(result).not.toHaveProperty("bookmarks");
+ },
+ );
+});
diff --git a/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts
index 7f94008503..c395ed0521 100644
--- a/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts
@@ -4,30 +4,74 @@ import {
type ToolOperationConfig,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { EditTableOfContentsParameters } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters";
-import { serializeBookmarkNodes } from "@app/utils/editTableOfContents";
+import {
+ hydrateBookmarkPayload,
+ serializeBookmarkNodes,
+ type BookmarkPayload,
+} from "@app/utils/editTableOfContents";
+
+const ENDPOINT =
+ "/api/v1/general/edit-table-of-contents" satisfies ToolEndpoint;
+type EditTableOfContentsApiParams = ToolApiParams[typeof ENDPOINT];
+
+// bookmarkData is a string in the backend model even though it carries JSON, so
+// the serialized bookmark tree is JSON-encoded into that string here.
+export const editTableOfContentsToApiParams = (
+ parameters: EditTableOfContentsParameters,
+): EditTableOfContentsApiParams => ({
+ replaceExisting: parameters.replaceExisting,
+ bookmarkData: JSON.stringify(serializeBookmarkNodes(parameters.bookmarks)),
+});
+
+export const editTableOfContentsFromApiParams = (
+ apiParams: EditTableOfContentsApiParams,
+): Partial => {
+ const result: Partial = {
+ replaceExisting: apiParams.replaceExisting,
+ };
+
+ // bookmarkData carries JSON in a string field, so a stored step
+ // could hold malformed or non-array content. Degrade to leaving bookmarks unset.
+ if (apiParams.bookmarkData !== undefined) {
+ try {
+ const payload = JSON.parse(apiParams.bookmarkData) as BookmarkPayload[];
+ if (Array.isArray(payload)) {
+ result.bookmarks = hydrateBookmarkPayload(payload);
+ }
+ } catch (error) {
+ console.warn(
+ `editTableOfContents: could not parse bookmarkData; ` +
+ `leaving bookmarks unset. Error: ${error}`,
+ );
+ }
+ }
+
+ return result;
+};
const buildFormData = (
parameters: EditTableOfContentsParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("replaceExisting", String(parameters.replaceExisting));
- formData.append(
- "bookmarkData",
- JSON.stringify(serializeBookmarkNodes(parameters.bookmarks)),
- );
- return formData;
-};
+): FormData =>
+ objectToFormData(editTableOfContentsToApiParams(parameters), {
+ fileInput: file,
+ });
export const editTableOfContentsOperationConfig: ToolOperationConfig =
{
toolType: ToolType.singleFile,
operationType: "editTableOfContents",
- endpoint: "/api/v1/general/edit-table-of-contents",
+ endpoint: ENDPOINT,
buildFormData,
+ toApiParams: editTableOfContentsToApiParams,
+ fromApiParams: editTableOfContentsFromApiParams,
};
export const useEditTableOfContentsOperation = () => {
diff --git a/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts b/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts
index e52a9d6f2e..4f43ddf3ec 100644
--- a/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts
@@ -4,6 +4,11 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ExtractImagesParameters,
@@ -11,24 +16,38 @@ import {
} from "@app/hooks/tools/extractImages/useExtractImagesParameters";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
+const ENDPOINT = "/api/v1/misc/extract-images" satisfies ToolEndpoint;
+type ExtractImagesApiParams = ToolApiParams[typeof ENDPOINT];
+
+// The frontend param type uses "jpg" while the backend model uses "jpeg"; the
+// wire value is preserved verbatim (as the pre-mapper code did) via the cast.
+export const extractImagesToApiParams = (
+ parameters: ExtractImagesParameters,
+): ExtractImagesApiParams => ({
+ format: parameters.format as ExtractImagesApiParams["format"],
+});
+
+export const extractImagesFromApiParams = (
+ apiParams: ExtractImagesApiParams,
+): Partial => ({
+ format: apiParams.format as ExtractImagesParameters["format"],
+});
+
// Static configuration that can be used by both the hook and automation executor
export const buildExtractImagesFormData = (
parameters: ExtractImagesParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("format", parameters.format);
- // formData.append("allowDuplicates", parameters.allowDuplicates.toString());
- return formData;
-};
+): FormData =>
+ objectToFormData(extractImagesToApiParams(parameters), { fileInput: file });
// Static configuration object (without response handler - will be added in hook)
export const extractImagesOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildExtractImagesFormData,
+ toApiParams: extractImagesToApiParams,
+ fromApiParams: extractImagesFromApiParams,
operationType: "extractImages",
- endpoint: "/api/v1/misc/extract-images",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts b/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts
index 9138b824ed..7e01678e73 100644
--- a/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts
@@ -3,32 +3,69 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
FlattenParameters,
defaultParameters,
} from "@app/hooks/tools/flatten/useFlattenParameters";
+const ENDPOINT = "/api/v1/misc/flatten" satisfies ToolEndpoint;
+type FlattenApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the flatten request body. The return
+// type is the generated backend model, so a spec change that renames or drops a
+// field breaks the build here.
+export const flattenToApiParams = (
+ parameters: FlattenParameters,
+): FlattenApiParams => {
+ const apiParams: FlattenApiParams = {
+ flattenOnlyForms: parameters.flattenOnlyForms,
+ };
+
+ if (parameters.renderDpi != null) {
+ apiParams.renderDpi = parameters.renderDpi;
+ }
+
+ return apiParams;
+};
+
+// Reconstruct the tool's UI parameters from a flatten request body, so a stored
+// or AI-authored step can be re-rendered in the settings UI.
+export const flattenFromApiParams = (
+ apiParams: FlattenApiParams,
+): Partial => {
+ const result: Partial = {
+ flattenOnlyForms:
+ apiParams.flattenOnlyForms ?? defaultParameters.flattenOnlyForms,
+ };
+
+ if (apiParams.renderDpi != null) {
+ result.renderDpi = apiParams.renderDpi;
+ }
+
+ return result;
+};
+
// Static function that can be used by both the hook and automation executor
export const buildFlattenFormData = (
parameters: FlattenParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("flattenOnlyForms", parameters.flattenOnlyForms.toString());
- if (parameters.renderDpi != null) {
- formData.append("renderDpi", parameters.renderDpi.toString());
- }
- return formData;
-};
+): FormData =>
+ objectToFormData(flattenToApiParams(parameters), { fileInput: file });
// Static configuration object
export const flattenOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildFlattenFormData,
+ toApiParams: flattenToApiParams,
+ fromApiParams: flattenFromApiParams,
operationType: "flatten",
- endpoint: "/api/v1/misc/flatten",
+ endpoint: ENDPOINT,
multiFileEndpoint: false,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts
index c637f2fe53..21817ddfcd 100644
--- a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts
+++ b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.test.ts
@@ -29,6 +29,10 @@ import {
ToolOperationHook,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ mergeFromApiParams,
+ mergeToApiParams,
+} from "@app/hooks/tools/merge/useMergeOperation";
describe("useMergeOperation", () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
@@ -128,4 +132,60 @@ describe("useMergeOperation", () => {
expect(formData2.get("removeCertSign")).toBe("true");
expect(formData2.get("generateToc")).toBe("true");
});
+
+ test("should include client file IDs derived from the files", () => {
+ renderHook(() => useMergeOperation());
+
+ const config = getToolConfig();
+ const mockFiles = [
+ new File(["a"], "a.pdf", { type: "application/pdf" }),
+ new File(["b"], "b.pdf", { type: "application/pdf" }),
+ ];
+ const formData = config.buildFormData(
+ { removeDigitalSignature: false, generateTableOfContents: false },
+ mockFiles,
+ );
+
+ expect(formData.get("clientFileIds")).toBe(
+ JSON.stringify(["a.pdf", "b.pdf"]),
+ );
+ });
+});
+
+describe("merge mappers", () => {
+ test("toApiParams renames UI fields to the backend request model", () => {
+ expect(
+ mergeToApiParams({
+ removeDigitalSignature: true,
+ generateTableOfContents: false,
+ }),
+ ).toEqual({
+ sortType: "orderProvided",
+ removeCertSign: true,
+ generateToc: false,
+ });
+ });
+
+ test("fromApiParams maps the backend request model back to UI fields", () => {
+ expect(
+ mergeFromApiParams({ removeCertSign: false, generateToc: true }),
+ ).toEqual({
+ removeDigitalSignature: false,
+ generateTableOfContents: true,
+ });
+ });
+
+ test("round-trips backend params", () => {
+ const api = mergeToApiParams({
+ removeDigitalSignature: true,
+ generateTableOfContents: true,
+ });
+ const roundTripped = mergeToApiParams({
+ removeDigitalSignature: false,
+ generateTableOfContents: false,
+ ...mergeFromApiParams(api),
+ });
+
+ expect(roundTripped).toEqual(api);
+ });
});
diff --git a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts
index 44cee9e134..9b43f35fcd 100644
--- a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts
@@ -4,36 +4,54 @@ import {
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
MergeParameters,
defaultParameters,
} from "@app/hooks/tools/merge/useMergeParameters";
+const ENDPOINT = "/api/v1/general/merge-pdfs" satisfies ToolEndpoint;
+type MergeApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the merge-pdfs request body. File-derived
+// fields (clientFileIds) are appended by buildFormData, not here.
+export const mergeToApiParams = (
+ parameters: MergeParameters,
+): MergeApiParams => ({
+ // The UI owns file ordering, so the backend is always told to keep it.
+ sortType: "orderProvided",
+ removeCertSign: parameters.removeDigitalSignature ?? false,
+ generateToc: parameters.generateTableOfContents ?? false,
+});
+
+// Reconstruct the tool's UI parameters from a merge-pdfs request body.
+export const mergeFromApiParams = (
+ apiParams: MergeApiParams,
+): Partial => ({
+ removeDigitalSignature:
+ apiParams.removeCertSign ?? defaultParameters.removeDigitalSignature,
+ generateTableOfContents:
+ apiParams.generateToc ?? defaultParameters.generateTableOfContents,
+});
+
const buildFormData = (
parameters: MergeParameters,
files: File[],
): FormData => {
- const formData = new FormData();
-
- files.forEach((file) => {
- formData.append("fileInput", file);
+ const formData = objectToFormData(mergeToApiParams(parameters), {
+ fileInput: files,
});
- // Provide stable client file IDs (align with files order)
+ // Stable client file IDs, aligned with the fileInput order. Derived from the
+ // files themselves, so it belongs to the file-appending step.
const clientIds: string[] = files.map((f) =>
String((f as { fileId?: string }).fileId || f.name),
);
formData.append("clientFileIds", JSON.stringify(clientIds));
- formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
- formData.append(
- "removeCertSign",
- (parameters.removeDigitalSignature ?? false).toString(),
- );
- formData.append(
- "generateToc",
- (parameters.generateTableOfContents ?? false).toString(),
- );
-
return formData;
};
@@ -41,8 +59,10 @@ const buildFormData = (
export const mergeOperationConfig: ToolOperationConfig = {
toolType: ToolType.multiFile,
buildFormData,
+ toApiParams: mergeToApiParams,
+ fromApiParams: mergeFromApiParams,
operationType: "merge",
- endpoint: "/api/v1/general/merge-pdfs",
+ endpoint: ENDPOINT,
filePrefix: "merged_",
defaultParameters,
};
diff --git a/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts b/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts
index b7d6d56564..e9b1e92b2d 100644
--- a/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts
+++ b/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts
@@ -9,9 +9,17 @@ import {
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
+const ENDPOINT = "/api/v1/misc/ocr-pdf" satisfies ToolEndpoint;
+type OCRApiParams = ToolApiParams[typeof ENDPOINT];
+
// Helper: get MIME type based on file extension
function getMimeType(filename: string): string {
const ext = filename.toLowerCase().split(".").pop();
@@ -48,28 +56,49 @@ function stripExt(name: string): string {
return i > 0 ? name.slice(0, i) : name;
}
+// Convert the tool's UI parameters into the ocr-pdf request body. The return
+// type is the generated backend model, so a spec change that renames or drops a
+// field breaks the build here.
+export const ocrToApiParams = (parameters: OCRParameters): OCRApiParams => {
+ const options = parameters.additionalOptions || [];
+ return {
+ languages: parameters.languages,
+ ocrType: parameters.ocrType as OCRApiParams["ocrType"],
+ ocrRenderType: parameters.ocrRenderType as OCRApiParams["ocrRenderType"],
+ sidecar: options.includes("sidecar"),
+ deskew: options.includes("deskew"),
+ clean: options.includes("clean"),
+ cleanFinal: options.includes("cleanFinal"),
+ removeImagesAfter: options.includes("removeImagesAfter"),
+ };
+};
+
+// Reconstruct the tool's UI parameters from an ocr-pdf request body, so a stored
+// or AI-authored step can be re-rendered in the settings UI.
+export const ocrFromApiParams = (
+ apiParams: OCRApiParams,
+): Partial => {
+ const additionalOptions: string[] = [];
+ if (apiParams.sidecar) additionalOptions.push("sidecar");
+ if (apiParams.deskew) additionalOptions.push("deskew");
+ if (apiParams.clean) additionalOptions.push("clean");
+ if (apiParams.cleanFinal) additionalOptions.push("cleanFinal");
+ if (apiParams.removeImagesAfter) additionalOptions.push("removeImagesAfter");
+
+ return {
+ languages: apiParams.languages ?? defaultParameters.languages,
+ ocrType: apiParams.ocrType,
+ ocrRenderType: apiParams.ocrRenderType ?? defaultParameters.ocrRenderType,
+ additionalOptions,
+ };
+};
+
// Static function that can be used by both the hook and automation executor
export const buildOCRFormData = (
parameters: OCRParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- parameters.languages.forEach((lang) => formData.append("languages", lang));
- formData.append("ocrType", parameters.ocrType);
- formData.append("ocrRenderType", parameters.ocrRenderType);
-
- const options = parameters.additionalOptions || [];
- formData.append("sidecar", options.includes("sidecar").toString());
- formData.append("deskew", options.includes("deskew").toString());
- formData.append("clean", options.includes("clean").toString());
- formData.append("cleanFinal", options.includes("cleanFinal").toString());
- formData.append(
- "removeImagesAfter",
- options.includes("removeImagesAfter").toString(),
- );
- return formData;
-};
+): FormData =>
+ objectToFormData(ocrToApiParams(parameters), { fileInput: file });
// Static response handler for OCR - can be used by automation executor
export const ocrResponseHandler = async (
@@ -125,8 +154,10 @@ export const ocrResponseHandler = async (
export const ocrOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildOCRFormData,
+ toApiParams: ocrToApiParams,
+ fromApiParams: ocrFromApiParams,
operationType: "ocr",
- endpoint: "/api/v1/misc/ocr-pdf",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts b/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts
index 02b72e9e1d..448d70346d 100644
--- a/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts
@@ -4,41 +4,69 @@ import {
ToolType,
type ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
-import { type OverlayPdfsParameters } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
+import {
+ type OverlayPdfsParameters,
+ defaultParameters,
+} from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
+
+const ENDPOINT = "/api/v1/general/overlay-pdfs" satisfies ToolEndpoint;
+type OverlayPdfsApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the overlay-pdfs request body. The
+// overlay documents are actual File uploads sent as repeated `overlayFiles`
+// fields (see buildFormData), so `overlayFiles` here is an empty array: the real
+// uploads are appended separately and an empty array serializes to no fields.
+export const overlayPdfsToApiParams = (
+ parameters: OverlayPdfsParameters,
+): OverlayPdfsApiParams => {
+ const apiParams: OverlayPdfsApiParams = {
+ overlayFiles: [],
+ overlayMode: parameters.overlayMode,
+ overlayPosition: parameters.overlayPosition,
+ };
+
+ // Counts are only relevant for FixedRepeatOverlay; the server accepts repeated
+ // 'counts' fields.
+ if (parameters.overlayMode === "FixedRepeatOverlay") {
+ apiParams.counts = parameters.counts || [];
+ }
+
+ return apiParams;
+};
+
+// Reconstruct the tool's UI parameters from an overlay-pdfs request body. The
+// overlay File uploads cannot be recovered from the request model.
+export const overlayPdfsFromApiParams = (
+ apiParams: OverlayPdfsApiParams,
+): Partial => ({
+ overlayMode: apiParams.overlayMode,
+ overlayPosition: apiParams.overlayPosition,
+ counts: apiParams.counts ?? defaultParameters.counts,
+});
const buildFormData = (
parameters: OverlayPdfsParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
-
- // Overlay files
- for (const overlay of parameters.overlayFiles || []) {
- formData.append("overlayFiles", overlay);
- }
-
- // Mode and position
- formData.append("overlayMode", parameters.overlayMode);
- formData.append("overlayPosition", String(parameters.overlayPosition));
-
- // Counts (only relevant for FixedRepeatOverlay, server accepts repeated 'counts' fields)
- if (parameters.overlayMode === "FixedRepeatOverlay") {
- for (const count of parameters.counts || []) {
- formData.append("counts", String(count));
- }
- }
-
- return formData;
-};
+): FormData =>
+ objectToFormData(overlayPdfsToApiParams(parameters), {
+ fileInput: file,
+ overlayFiles: parameters.overlayFiles || [],
+ });
export const overlayPdfsOperationConfig: ToolOperationConfig =
{
toolType: ToolType.singleFile,
buildFormData,
+ toApiParams: overlayPdfsToApiParams,
+ fromApiParams: overlayPdfsFromApiParams,
operationType: "overlayPdfs",
- endpoint: "/api/v1/general/overlay-pdfs",
+ endpoint: ENDPOINT,
};
export const useOverlayPdfsOperation = () => {
diff --git a/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.test.ts b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.test.ts
new file mode 100644
index 0000000000..874fdff811
--- /dev/null
+++ b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, test } from "vitest";
+import {
+ pageLayoutFromApiParams,
+ pageLayoutToApiParams,
+} from "@app/hooks/tools/pageLayout/usePageLayoutOperation";
+import {
+ PageLayoutParameters,
+ defaultParameters,
+} from "@app/hooks/tools/pageLayout/usePageLayoutParameters";
+
+describe("pageLayout mappers", () => {
+ test.each>([
+ {},
+ { addBorder: true, borderWidth: 3, innerMargin: 5, topMargin: 2 },
+ ])("round-trips backend params for %o", (overrides) => {
+ const api = pageLayoutToApiParams({ ...defaultParameters, ...overrides });
+ const roundTripped = pageLayoutToApiParams({
+ ...defaultParameters,
+ ...pageLayoutFromApiParams(api),
+ });
+
+ expect(roundTripped).toEqual(api);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts
index b168f15f6f..dce2101cd4 100644
--- a/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts
@@ -3,40 +3,77 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
PageLayoutParameters,
defaultParameters,
} from "@app/hooks/tools/pageLayout/usePageLayoutParameters";
+const ENDPOINT = "/api/v1/general/multi-page-layout" satisfies ToolEndpoint;
+type PageLayoutApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the multi-page-layout request body. The
+// return type is the generated backend model, so a spec change that renames or
+// drops a field breaks the build here.
+export const pageLayoutToApiParams = (
+ parameters: PageLayoutParameters,
+): PageLayoutApiParams => ({
+ mode: parameters.mode,
+ pagesPerSheet:
+ parameters.pagesPerSheet as PageLayoutApiParams["pagesPerSheet"],
+ rows: parameters.rows,
+ cols: parameters.cols,
+ orientation: parameters.orientation,
+ arrangement: parameters.arrangement,
+ readingDirection: parameters.readingDirection,
+ innerMargin: parameters.innerMargin ?? 0,
+ topMargin: parameters.topMargin ?? 0,
+ bottomMargin: parameters.bottomMargin ?? 0,
+ leftMargin: parameters.leftMargin ?? 0,
+ rightMargin: parameters.rightMargin ?? 0,
+ addBorder: parameters.addBorder,
+ borderWidth: parameters.borderWidth ?? 1,
+});
+
+// Reconstruct the tool's UI parameters from a multi-page-layout request body, so
+// a stored or AI-authored step can be re-rendered in the settings UI.
+export const pageLayoutFromApiParams = (
+ apiParams: PageLayoutApiParams,
+): Partial => ({
+ mode: apiParams.mode,
+ pagesPerSheet: apiParams.pagesPerSheet,
+ rows: apiParams.rows,
+ cols: apiParams.cols,
+ orientation: apiParams.orientation,
+ arrangement: apiParams.arrangement,
+ readingDirection: apiParams.readingDirection,
+ innerMargin: apiParams.innerMargin,
+ topMargin: apiParams.topMargin,
+ bottomMargin: apiParams.bottomMargin,
+ leftMargin: apiParams.leftMargin,
+ rightMargin: apiParams.rightMargin,
+ addBorder: apiParams.addBorder,
+ borderWidth: apiParams.borderWidth,
+});
+
export const buildPageLayoutFormData = (
parameters: PageLayoutParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("mode", String(parameters.mode));
- formData.append("pagesPerSheet", String(parameters.pagesPerSheet));
- formData.append("rows", String(parameters.rows));
- formData.append("cols", String(parameters.cols));
- formData.append("orientation", String(parameters.orientation));
- formData.append("arrangement", String(parameters.arrangement));
- formData.append("readingDirection", String(parameters.readingDirection));
- formData.append("innerMargin", String(parameters.innerMargin ?? 0));
- formData.append("topMargin", String(parameters.topMargin ?? 0));
- formData.append("bottomMargin", String(parameters.bottomMargin ?? 0));
- formData.append("leftMargin", String(parameters.leftMargin ?? 0));
- formData.append("rightMargin", String(parameters.rightMargin ?? 0));
- formData.append("addBorder", String(parameters.addBorder));
- formData.append("borderWidth", String(parameters.borderWidth ?? 1));
- return formData;
-};
+): FormData =>
+ objectToFormData(pageLayoutToApiParams(parameters), { fileInput: file });
export const pageLayoutOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildPageLayoutFormData,
+ toApiParams: pageLayoutToApiParams,
+ fromApiParams: pageLayoutFromApiParams,
operationType: "pageLayout",
- endpoint: "/api/v1/general/multi-page-layout",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts b/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts
index bc4db5b6c3..c7cefe1f43 100644
--- a/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts
@@ -3,52 +3,75 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RedactParameters,
defaultParameters,
} from "@app/hooks/tools/redact/useRedactParameters";
+// Automatic redaction is the only mode that calls the backend; manual redaction
+// is handled client-side by EmbedPDF in the viewer.
+const AUTO_ENDPOINT = "/api/v1/security/auto-redact" satisfies ToolEndpoint;
+type RedactApiParams = ToolApiParams[typeof AUTO_ENDPOINT];
+
+// Convert the tool's UI parameters into the auto-redact request body.
+export const redactToApiParams = (
+ parameters: RedactParameters,
+): RedactApiParams => ({
+ // The backend takes the search terms as a single newline-separated string.
+ listOfText: parameters.wordsToRedact.join("\n"),
+ useRegex: parameters.useRegex,
+ wholeWordSearch: parameters.wholeWordSearch,
+ // The backend expects the hex colour without the leading '#'.
+ redactColor: parameters.redactColor.replace("#", ""),
+ customPadding: parameters.customPadding,
+ convertPDFToImage: parameters.convertPDFToImage,
+});
+
+// Reconstruct the tool's UI parameters from an auto-redact request body.
+export const redactFromApiParams = (
+ apiParams: RedactApiParams,
+): Partial => ({
+ mode: "automatic",
+ wordsToRedact: apiParams.listOfText ? apiParams.listOfText.split("\n") : [],
+ useRegex: apiParams.useRegex ?? defaultParameters.useRegex,
+ wholeWordSearch:
+ apiParams.wholeWordSearch ?? defaultParameters.wholeWordSearch,
+ redactColor: apiParams.redactColor
+ ? `#${apiParams.redactColor}`
+ : defaultParameters.redactColor,
+ customPadding: apiParams.customPadding,
+ convertPDFToImage:
+ apiParams.convertPDFToImage ?? defaultParameters.convertPDFToImage,
+});
+
// Static configuration that can be used by both the hook and automation executor
export const buildRedactFormData = (
parameters: RedactParameters,
file: File,
): FormData => {
- const formData = new FormData();
-
- // For automatic mode we hit the backend and need full payload
- if (parameters.mode === "automatic") {
- formData.append("fileInput", file);
- // Convert array to newline-separated string as expected by backend
- formData.append("listOfText", parameters.wordsToRedact.join("\n"));
- formData.append("useRegex", parameters.useRegex.toString());
- formData.append("wholeWordSearch", parameters.wholeWordSearch.toString());
- formData.append("redactColor", parameters.redactColor.replace("#", ""));
- formData.append("customPadding", parameters.customPadding.toString());
- formData.append(
- "convertPDFToImage",
- parameters.convertPDFToImage.toString(),
- );
- } else {
- // Manual redaction uses EmbedPDF in-viewer; we don't call the API.
- // Return an empty formData to satisfy shared interfaces without throwing.
+ // Manual redaction uses EmbedPDF in-viewer and makes no API call; return an
+ // empty payload to satisfy the shared interface without throwing.
+ if (parameters.mode !== "automatic") {
+ return new FormData();
}
-
- return formData;
+ return objectToFormData(redactToApiParams(parameters), { fileInput: file });
};
// Static configuration object
export const redactOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRedactFormData,
+ toApiParams: redactToApiParams,
+ fromApiParams: redactFromApiParams,
operationType: "redact",
- endpoint: (parameters: RedactParameters) => {
- if (parameters.mode === "automatic") {
- return "/api/v1/security/auto-redact";
- }
- // Manual redaction is handled by EmbedPDF in the viewer; no endpoint call.
- return "";
- },
+ endpoint: (parameters: RedactParameters) =>
+ parameters.mode === "automatic" ? AUTO_ENDPOINT : null,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts b/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts
index 5f24a28dd8..2cde7832e4 100644
--- a/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts
@@ -5,6 +5,11 @@ import {
useToolOperation,
ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RemoveBlanksParameters,
@@ -12,23 +17,37 @@ import {
} from "@app/hooks/tools/removeBlanks/useRemoveBlanksParameters";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
+const ENDPOINT = "/api/v1/misc/remove-blanks" satisfies ToolEndpoint;
+type RemoveBlanksApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Note: includeBlankPages is not sent to backend as it always returns both files in a ZIP
+export const removeBlanksToApiParams = (
+ parameters: RemoveBlanksParameters,
+): RemoveBlanksApiParams => ({
+ threshold: parameters.threshold,
+ whitePercent: parameters.whitePercent,
+});
+
+export const removeBlanksFromApiParams = (
+ apiParams: RemoveBlanksApiParams,
+): Partial => ({
+ threshold: apiParams.threshold,
+ whitePercent: apiParams.whitePercent,
+});
+
export const buildRemoveBlanksFormData = (
parameters: RemoveBlanksParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("threshold", String(parameters.threshold));
- formData.append("whitePercent", String(parameters.whitePercent));
- // Note: includeBlankPages is not sent to backend as it always returns both files in a ZIP
- return formData;
-};
+): FormData =>
+ objectToFormData(removeBlanksToApiParams(parameters), { fileInput: file });
export const removeBlanksOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveBlanksFormData,
+ toApiParams: removeBlanksToApiParams,
+ fromApiParams: removeBlanksFromApiParams,
operationType: "removeBlanks",
- endpoint: "/api/v1/misc/remove-blanks",
+ endpoint: ENDPOINT,
defaultParameters,
} as const satisfies ToolOperationConfig;
diff --git a/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts b/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts
index 1d6604f0ef..55d053fe09 100644
--- a/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts
@@ -3,28 +3,35 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ fileOnlyMapping,
+ objectToFormData,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RemoveCertificateSignParameters,
defaultParameters,
} from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters";
-// Static function that can be used by both the hook and automation executor
+const ENDPOINT = "/api/v1/security/remove-cert-sign" satisfies ToolEndpoint;
+
+// Removing certificate signatures takes only a file; no parameters to map.
+const { toApiParams, fromApiParams } = fileOnlyMapping();
+
export const buildRemoveCertificateSignFormData = (
_parameters: RemoveCertificateSignParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- return formData;
-};
+): FormData => objectToFormData(toApiParams(), { fileInput: file });
// Static configuration object
export const removeCertificateSignOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemoveCertificateSignFormData,
+ toApiParams,
+ fromApiParams,
operationType: "removeCertSign",
- endpoint: "/api/v1/security/remove-cert-sign",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts b/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts
index 3441623cf6..2ba770c822 100644
--- a/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts
@@ -4,24 +4,32 @@ import {
ToolOperationConfig,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ fileOnlyMapping,
+ objectToFormData,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import type { RemoveImageParameters } from "@app/hooks/tools/removeImage/useRemoveImageParameters";
+const ENDPOINT = "/api/v1/general/remove-image-pdf" satisfies ToolEndpoint;
+
+// Remove-image takes only a file; there are no request parameters to map.
+const { toApiParams, fromApiParams } = fileOnlyMapping();
+
export const buildRemoveImageFormData = (
_params: RemoveImageParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- return formData;
-};
+): FormData => objectToFormData(toApiParams(), { fileInput: file });
export const removeImageOperationConfig: ToolOperationConfig =
{
toolType: ToolType.singleFile,
buildFormData: buildRemoveImageFormData,
+ toApiParams,
+ fromApiParams,
operationType: "removeImage",
- endpoint: "/api/v1/general/remove-image-pdf",
+ endpoint: ENDPOINT,
};
export const useRemoveImageOperation = () => {
diff --git a/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts b/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts
index 9ff5e6fb62..8d8f14f3b4 100644
--- a/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts
@@ -4,6 +4,11 @@ import {
useToolOperation,
ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RemovePagesParameters,
@@ -11,22 +16,39 @@ import {
} from "@app/hooks/tools/removePages/useRemovePagesParameters";
// import { useToolResources } from '@app/hooks/tools/shared/useToolResources';
+const ENDPOINT = "/api/v1/general/remove-pages" satisfies ToolEndpoint;
+type RemovePagesApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the remove-pages request body. The
+// return type is the generated backend model, so a spec change that renames or
+// drops a field breaks the build here.
+export const removePagesToApiParams = (
+ parameters: RemovePagesParameters,
+): RemovePagesApiParams => ({
+ pageNumbers: parameters.pageNumbers.replace(/\s+/g, ""),
+});
+
+// Reconstruct the tool's UI parameters from a remove-pages request body, so a
+// stored or AI-authored step can be re-rendered in the settings UI.
+export const removePagesFromApiParams = (
+ apiParams: RemovePagesApiParams,
+): Partial => ({
+ pageNumbers: apiParams.pageNumbers ?? defaultParameters.pageNumbers,
+});
+
export const buildRemovePagesFormData = (
parameters: RemovePagesParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- const cleaned = parameters.pageNumbers.replace(/\s+/g, "");
- formData.append("pageNumbers", cleaned);
- return formData;
-};
+): FormData =>
+ objectToFormData(removePagesToApiParams(parameters), { fileInput: file });
export const removePagesOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemovePagesFormData,
+ toApiParams: removePagesToApiParams,
+ fromApiParams: removePagesFromApiParams,
operationType: "removePages",
- endpoint: "/api/v1/general/remove-pages",
+ endpoint: ENDPOINT,
defaultParameters,
} as const satisfies ToolOperationConfig;
diff --git a/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts b/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts
index 8f04c4806f..c55040c7f2 100644
--- a/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts
+++ b/frontend/editor/src/core/hooks/tools/removePassword/buildRemovePasswordFormData.ts
@@ -1,4 +1,35 @@
-import { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
+import {
+ RemovePasswordParameters,
+ defaultParameters,
+} from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
+
+// Defined here (not in the operation config) so both the mappers and the config
+// share one endpoint constant without a circular import via FileContext.
+export const REMOVE_PASSWORD_ENDPOINT =
+ "/api/v1/security/remove-password" satisfies ToolEndpoint;
+type RemovePasswordApiParams = ToolApiParams[typeof REMOVE_PASSWORD_ENDPOINT];
+
+// Convert the tool's UI parameters into the remove-password request body. The
+// return type is the generated backend model, so a spec change that renames or
+// drops a field breaks the build here.
+export const removePasswordToApiParams = (
+ parameters: RemovePasswordParameters,
+): RemovePasswordApiParams => ({
+ password: parameters.password,
+});
+
+// Reconstruct the tool's UI parameters from a remove-password request body, so a
+// stored or AI-authored step can be re-rendered in the settings UI.
+export const removePasswordFromApiParams = (
+ apiParams: RemovePasswordApiParams,
+): Partial => ({
+ password: apiParams.password ?? defaultParameters.password,
+});
/**
* Builds FormData for remove password API request.
@@ -7,9 +38,5 @@ import { RemovePasswordParameters } from "@app/hooks/tools/removePassword/useRem
export const buildRemovePasswordFormData = (
parameters: RemovePasswordParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("password", parameters.password);
- return formData;
-};
+): FormData =>
+ objectToFormData(removePasswordToApiParams(parameters), { fileInput: file });
diff --git a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts
index aa71a5befb..2d516b861b 100644
--- a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts
@@ -8,7 +8,12 @@ import {
RemovePasswordParameters,
defaultParameters,
} from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
-import { buildRemovePasswordFormData } from "@app/hooks/tools/removePassword/buildRemovePasswordFormData";
+import {
+ buildRemovePasswordFormData,
+ removePasswordToApiParams,
+ removePasswordFromApiParams,
+ REMOVE_PASSWORD_ENDPOINT,
+} from "@app/hooks/tools/removePassword/buildRemovePasswordFormData";
// Re-export for backwards compatibility with any other imports
export { buildRemovePasswordFormData };
@@ -17,8 +22,10 @@ export { buildRemovePasswordFormData };
export const removePasswordOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRemovePasswordFormData,
+ toApiParams: removePasswordToApiParams,
+ fromApiParams: removePasswordFromApiParams,
operationType: "removePassword",
- endpoint: "/api/v1/security/remove-password",
+ endpoint: REMOVE_PASSWORD_ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts b/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts
index de5aaf5c53..2bdb75f31c 100644
--- a/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts
@@ -4,31 +4,64 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
-import { ReorganizePagesParameters } from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters";
+import {
+ ReorganizePagesParameters,
+ defaultReorganizePagesParameters,
+} from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters";
+
+const ENDPOINT = "/api/v1/general/rearrange-pages" satisfies ToolEndpoint;
+type ReorganizePagesApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the rearrange-pages request body. The
+// return type is the generated backend model, so a spec change that renames or
+// drops a field breaks the build here.
+export const reorganizePagesToApiParams = (
+ parameters: ReorganizePagesParameters,
+): ReorganizePagesApiParams => {
+ const apiParams: ReorganizePagesApiParams = {};
+ if (parameters.customMode) {
+ apiParams.customMode =
+ parameters.customMode as ReorganizePagesApiParams["customMode"];
+ }
+ if (parameters.pageNumbers) {
+ apiParams.pageNumbers = parameters.pageNumbers.replace(/\s+/g, "");
+ }
+ return apiParams;
+};
+
+// Reconstruct the tool's UI parameters from a rearrange-pages request body, so a
+// stored or AI-authored step can be re-rendered in the settings UI.
+export const reorganizePagesFromApiParams = (
+ apiParams: ReorganizePagesApiParams,
+): Partial => ({
+ customMode:
+ apiParams.customMode ?? defaultReorganizePagesParameters.customMode,
+ pageNumbers:
+ apiParams.pageNumbers ?? defaultReorganizePagesParameters.pageNumbers,
+});
const buildFormData = (
parameters: ReorganizePagesParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- if (parameters.customMode) {
- formData.append("customMode", parameters.customMode);
- }
- if (parameters.pageNumbers) {
- const cleaned = parameters.pageNumbers.replace(/\s+/g, "");
- formData.append("pageNumbers", cleaned);
- }
- return formData;
-};
+): FormData =>
+ objectToFormData(reorganizePagesToApiParams(parameters), {
+ fileInput: file,
+ });
export const reorganizePagesOperationConfig: ToolOperationConfig =
{
toolType: ToolType.singleFile,
buildFormData,
+ toApiParams: reorganizePagesToApiParams,
+ fromApiParams: reorganizePagesFromApiParams,
operationType: "reorganizePages",
- endpoint: "/api/v1/general/rearrange-pages",
+ endpoint: ENDPOINT,
};
export const useReorganizePagesOperation = () => {
diff --git a/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts b/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts
index a62f448bac..ef60151d07 100644
--- a/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts
@@ -3,28 +3,35 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ fileOnlyMapping,
+ objectToFormData,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RepairParameters,
defaultParameters,
} from "@app/hooks/tools/repair/useRepairParameters";
-// Static function that can be used by both the hook and automation executor
+const ENDPOINT = "/api/v1/misc/repair" satisfies ToolEndpoint;
+
+// Repair takes only a file; there are no request parameters to map.
+const { toApiParams, fromApiParams } = fileOnlyMapping();
+
export const buildRepairFormData = (
_parameters: RepairParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- return formData;
-};
+): FormData => objectToFormData(toApiParams(), { fileInput: file });
// Static configuration object
export const repairOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRepairFormData,
+ toApiParams,
+ fromApiParams,
operationType: "repair",
- endpoint: "/api/v1/misc/repair",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts b/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts
index dd24c10e22..94bbc32d9e 100644
--- a/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts
@@ -3,39 +3,72 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ReplaceColorParameters,
defaultParameters,
} from "@app/hooks/tools/replaceColor/useReplaceColorParameters";
+const ENDPOINT = "/api/v1/misc/replace-invert-pdf" satisfies ToolEndpoint;
+type ReplaceColorApiParams = ToolApiParams[typeof ENDPOINT];
+
+export const replaceColorToApiParams = (
+ parameters: ReplaceColorParameters,
+): ReplaceColorApiParams => {
+ const apiParams: ReplaceColorApiParams = {
+ replaceAndInvertOption: parameters.replaceAndInvertOption,
+ };
+
+ if (parameters.replaceAndInvertOption === "HIGH_CONTRAST_COLOR") {
+ apiParams.highContrastColorCombination =
+ parameters.highContrastColorCombination;
+ } else if (parameters.replaceAndInvertOption === "CUSTOM_COLOR") {
+ apiParams.textColor = parameters.textColor;
+ apiParams.backGroundColor = parameters.backGroundColor;
+ }
+
+ return apiParams;
+};
+
+export const replaceColorFromApiParams = (
+ apiParams: ReplaceColorApiParams,
+): Partial => {
+ const result: Partial = {
+ replaceAndInvertOption: apiParams.replaceAndInvertOption,
+ };
+
+ if (apiParams.highContrastColorCombination !== undefined) {
+ result.highContrastColorCombination =
+ apiParams.highContrastColorCombination;
+ }
+ if (apiParams.textColor !== undefined) {
+ result.textColor = apiParams.textColor;
+ }
+ if (apiParams.backGroundColor !== undefined) {
+ result.backGroundColor = apiParams.backGroundColor;
+ }
+
+ return result;
+};
+
export const buildReplaceColorFormData = (
parameters: ReplaceColorParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
-
- formData.append("replaceAndInvertOption", parameters.replaceAndInvertOption);
-
- if (parameters.replaceAndInvertOption === "HIGH_CONTRAST_COLOR") {
- formData.append(
- "highContrastColorCombination",
- parameters.highContrastColorCombination,
- );
- } else if (parameters.replaceAndInvertOption === "CUSTOM_COLOR") {
- formData.append("textColor", parameters.textColor);
- formData.append("backGroundColor", parameters.backGroundColor);
- }
-
- return formData;
-};
+): FormData =>
+ objectToFormData(replaceColorToApiParams(parameters), { fileInput: file });
export const replaceColorOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildReplaceColorFormData,
+ toApiParams: replaceColorToApiParams,
+ fromApiParams: replaceColorFromApiParams,
operationType: "replaceColor",
- endpoint: "/api/v1/misc/replace-invert-pdf",
+ endpoint: ENDPOINT,
multiFileEndpoint: false,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts
index 5db2b30788..96973db125 100644
--- a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts
+++ b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.test.ts
@@ -30,6 +30,10 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ rotateFromApiParams,
+ rotateToApiParams,
+} from "@app/hooks/tools/rotate/useRotateOperation";
describe("useRotateOperation", () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
@@ -114,3 +118,30 @@ describe("useRotateOperation", () => {
expect(callArgs[property]).toBe(expectedValue);
});
});
+
+describe("rotate mappers", () => {
+ test.each([
+ { angle: 0, expected: 0 },
+ { angle: 90, expected: 90 },
+ { angle: -90, expected: 270 },
+ { angle: 450, expected: 90 },
+ ])(
+ "toApiParams normalizes angle $angle to $expected",
+ ({ angle, expected }) => {
+ expect(rotateToApiParams({ angle }).angle).toBe(expected);
+ },
+ );
+
+ test("fromApiParams maps the backend angle back to the UI parameter", () => {
+ expect(rotateFromApiParams({ angle: 180 })).toEqual({ angle: 180 });
+ });
+
+ test.each([0, 90, 180, 270] as const)(
+ "round-trips a normalized angle %i",
+ (angle) => {
+ const ui = rotateFromApiParams({ angle });
+ const api = rotateToApiParams({ angle: ui.angle ?? 0 });
+ expect(api).toEqual({ angle });
+ },
+ );
+});
diff --git a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts
index e675610eb9..7e73aab99f 100644
--- a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts
@@ -3,6 +3,11 @@ import {
useToolOperation,
ToolType,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
RotateParameters,
@@ -10,24 +15,41 @@ import {
normalizeAngle,
} from "@app/hooks/tools/rotate/useRotateParameters";
+const ENDPOINT = "/api/v1/general/rotate-pdf" satisfies ToolEndpoint;
+type RotateApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the rotate-pdf request body. The return
+// type is the generated backend model, so a spec change breaks the build here.
+export const rotateToApiParams = (
+ parameters: RotateParameters,
+): RotateApiParams => ({
+ // The UI angle can be any multiple of 90 (including negatives or values above
+ // 360); normalize to the four values the backend accepts.
+ angle: normalizeAngle(parameters.angle) as RotateApiParams["angle"],
+});
+
+// Reconstruct the tool's UI parameters from a rotate-pdf request body.
+export const rotateFromApiParams = (
+ apiParams: RotateApiParams,
+): Partial => ({
+ angle: apiParams.angle,
+});
+
// Static configuration that can be used by both the hook and automation executor
export const buildRotateFormData = (
parameters: RotateParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- // Normalize angle for backend (0, 90, 180, 270)
- formData.append("angle", normalizeAngle(parameters.angle).toString());
- return formData;
-};
+): FormData =>
+ objectToFormData(rotateToApiParams(parameters), { fileInput: file });
// Static configuration object
export const rotateOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildRotateFormData,
+ toApiParams: rotateToApiParams,
+ fromApiParams: rotateFromApiParams,
operationType: "rotate",
- endpoint: "/api/v1/general/rotate-pdf",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts
index 9c98b7d57f..93fd64240d 100644
--- a/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts
@@ -3,49 +3,65 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
SanitizeParameters,
defaultParameters,
} from "@app/hooks/tools/sanitize/useSanitizeParameters";
+const ENDPOINT = "/api/v1/security/sanitize-pdf" satisfies ToolEndpoint;
+type SanitizeApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the sanitize-pdf request body. The
+// return type is the generated backend model, so a spec change that renames or
+// drops a field breaks the build here.
+export const sanitizeToApiParams = (
+ parameters: SanitizeParameters,
+): SanitizeApiParams => ({
+ removeJavaScript: parameters.removeJavaScript ?? false,
+ removeEmbeddedFiles: parameters.removeEmbeddedFiles ?? false,
+ removeXMPMetadata: parameters.removeXMPMetadata ?? false,
+ removeMetadata: parameters.removeMetadata ?? false,
+ removeLinks: parameters.removeLinks ?? false,
+ removeFonts: parameters.removeFonts ?? false,
+});
+
+// Reconstruct the tool's UI parameters from a sanitize-pdf request body, so a
+// stored or AI-authored step can be re-rendered in the settings UI.
+export const sanitizeFromApiParams = (
+ apiParams: SanitizeApiParams,
+): Partial => ({
+ removeJavaScript:
+ apiParams.removeJavaScript ?? defaultParameters.removeJavaScript,
+ removeEmbeddedFiles:
+ apiParams.removeEmbeddedFiles ?? defaultParameters.removeEmbeddedFiles,
+ removeXMPMetadata:
+ apiParams.removeXMPMetadata ?? defaultParameters.removeXMPMetadata,
+ removeMetadata: apiParams.removeMetadata ?? defaultParameters.removeMetadata,
+ removeLinks: apiParams.removeLinks ?? defaultParameters.removeLinks,
+ removeFonts: apiParams.removeFonts ?? defaultParameters.removeFonts,
+});
+
// Static function that can be used by both the hook and automation executor
export const buildSanitizeFormData = (
parameters: SanitizeParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
-
- // Add parameters
- formData.append(
- "removeJavaScript",
- (parameters.removeJavaScript ?? false).toString(),
- );
- formData.append(
- "removeEmbeddedFiles",
- (parameters.removeEmbeddedFiles ?? false).toString(),
- );
- formData.append(
- "removeXMPMetadata",
- (parameters.removeXMPMetadata ?? false).toString(),
- );
- formData.append(
- "removeMetadata",
- (parameters.removeMetadata ?? false).toString(),
- );
- formData.append("removeLinks", (parameters.removeLinks ?? false).toString());
- formData.append("removeFonts", (parameters.removeFonts ?? false).toString());
-
- return formData;
-};
+): FormData =>
+ objectToFormData(sanitizeToApiParams(parameters), { fileInput: file });
// Static configuration object
export const sanitizeOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSanitizeFormData,
+ toApiParams: sanitizeToApiParams,
+ fromApiParams: sanitizeFromApiParams,
operationType: "sanitize",
- endpoint: "/api/v1/security/sanitize-pdf",
+ endpoint: ENDPOINT,
multiFileEndpoint: false,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts b/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts
index e035c90ce5..27aadeb98b 100644
--- a/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts
@@ -5,6 +5,11 @@ import {
useToolOperation,
ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
ScannerImageSplitParameters,
@@ -12,26 +17,50 @@ import {
} from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitParameters";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
+const ENDPOINT = "/api/v1/misc/extract-image-scans" satisfies ToolEndpoint;
+type ScannerImageSplitApiParams = ToolApiParams[typeof ENDPOINT];
+
+// Convert the tool's UI parameters into the extract-image-scans request body.
+// The frontend uses snake_case field names, but the backend model (the contract)
+// uses camelCase, so the keys are renamed here.
+export const scannerImageSplitToApiParams = (
+ parameters: ScannerImageSplitParameters,
+): ScannerImageSplitApiParams => ({
+ angleThreshold: parameters.angle_threshold,
+ tolerance: parameters.tolerance,
+ minArea: parameters.min_area,
+ minContourArea: parameters.min_contour_area,
+ borderSize: parameters.border_size,
+});
+
+// Reconstruct the tool's UI parameters from an extract-image-scans request body,
+// so a stored or AI-authored step can be re-rendered in the settings UI.
+export const scannerImageSplitFromApiParams = (
+ apiParams: ScannerImageSplitApiParams,
+): Partial => ({
+ angle_threshold: apiParams.angleThreshold,
+ tolerance: apiParams.tolerance,
+ min_area: apiParams.minArea,
+ min_contour_area: apiParams.minContourArea,
+ border_size: apiParams.borderSize,
+});
+
export const buildScannerImageSplitFormData = (
parameters: ScannerImageSplitParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- formData.append("angle_threshold", parameters.angle_threshold.toString());
- formData.append("tolerance", parameters.tolerance.toString());
- formData.append("min_area", parameters.min_area.toString());
- formData.append("min_contour_area", parameters.min_contour_area.toString());
- formData.append("border_size", parameters.border_size.toString());
- return formData;
-};
+): FormData =>
+ objectToFormData(scannerImageSplitToApiParams(parameters), {
+ fileInput: file,
+ });
// Static configuration object
export const scannerImageSplitOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildScannerImageSplitFormData,
+ toApiParams: scannerImageSplitToApiParams,
+ fromApiParams: scannerImageSplitFromApiParams,
operationType: "scannerImageSplit",
- endpoint: "/api/v1/misc/extract-image-scans",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts b/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts
new file mode 100644
index 0000000000..a809ae9604
--- /dev/null
+++ b/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, test } from "vitest";
+import { type RegistryToolOperationConfig } from "@app/hooks/tools/shared/toolOperationTypes";
+import { objectToFormData } from "@app/hooks/tools/shared/toolApiMapping";
+
+// Pilot tools.
+import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation";
+import { rotateOperationConfig } from "@app/hooks/tools/rotate/useRotateOperation";
+import { mergeOperationConfig } from "@app/hooks/tools/merge/useMergeOperation";
+import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation";
+// Rolled out in Phase 3.
+import { addAttachmentsOperationConfig } from "@app/hooks/tools/addAttachments/useAddAttachmentsOperation";
+import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
+import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
+import { addStampOperationConfig } from "@app/components/tools/addStamp/useAddStampOperation";
+import { addWatermarkOperationConfig } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation";
+import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
+import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
+import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
+import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
+import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
+import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation";
+import { editTableOfContentsOperationConfig } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation";
+import { extractImagesOperationConfig } from "@app/hooks/tools/extractImages/useExtractImagesOperation";
+import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
+import { ocrOperationConfig } from "@app/hooks/tools/ocr/useOCROperation";
+import { overlayPdfsOperationConfig } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsOperation";
+import { pageLayoutOperationConfig } from "@app/hooks/tools/pageLayout/usePageLayoutOperation";
+import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
+import { removeBlanksOperationConfig } from "@app/hooks/tools/removeBlanks/useRemoveBlanksOperation";
+import { removeCertificateSignOperationConfig } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
+import { removeImageOperationConfig } from "@app/hooks/tools/removeImage/useRemoveImageOperation";
+import { removePagesOperationConfig } from "@app/hooks/tools/removePages/useRemovePagesOperation";
+import { removePasswordOperationConfig } from "@app/hooks/tools/removePassword/useRemovePasswordOperation";
+import { reorganizePagesOperationConfig } from "@app/hooks/tools/reorganizePages/useReorganizePagesOperation";
+import { repairOperationConfig } from "@app/hooks/tools/repair/useRepairOperation";
+import { replaceColorOperationConfig } from "@app/hooks/tools/replaceColor/useReplaceColorOperation";
+import { sanitizeOperationConfig } from "@app/hooks/tools/sanitize/useSanitizeOperation";
+import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
+import { singleLargePageOperationConfig } from "@app/hooks/tools/singleLargePage/useSingleLargePageOperation";
+import { timestampPdfOperationConfig } from "@app/hooks/tools/timestampPdf/useTimestampPdfOperation";
+import { unlockPdfFormsOperationConfig } from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
+
+// Every tool migrated to the mapper seam. Erased to the registry shape so one
+// loop can invoke toApiParams(defaultParameters) uniformly regardless of the
+// tool's own parameter type.
+const MIGRATED_CONFIGS = [
+ compressOperationConfig,
+ rotateOperationConfig,
+ mergeOperationConfig,
+ splitOperationConfig,
+ addAttachmentsOperationConfig,
+ addPageNumbersOperationConfig,
+ addPasswordOperationConfig,
+ addStampOperationConfig,
+ addWatermarkOperationConfig,
+ adjustPageScaleOperationConfig,
+ autoRenameOperationConfig,
+ bookletImpositionOperationConfig,
+ certSignOperationConfig,
+ changePermissionsOperationConfig,
+ cropOperationConfig,
+ editTableOfContentsOperationConfig,
+ extractImagesOperationConfig,
+ flattenOperationConfig,
+ ocrOperationConfig,
+ overlayPdfsOperationConfig,
+ pageLayoutOperationConfig,
+ redactOperationConfig,
+ removeBlanksOperationConfig,
+ removeCertificateSignOperationConfig,
+ removeImageOperationConfig,
+ removePagesOperationConfig,
+ removePasswordOperationConfig,
+ reorganizePagesOperationConfig,
+ repairOperationConfig,
+ replaceColorOperationConfig,
+ sanitizeOperationConfig,
+ scannerImageSplitOperationConfig,
+ singleLargePageOperationConfig,
+ timestampPdfOperationConfig,
+ unlockPdfFormsOperationConfig,
+ // Erase each tool's own TParams to the shared registry shape (the same
+ // existential boundary asRegistryConfig applies) so one loop can call
+ // toApiParams(defaultParameters) uniformly.
+] as unknown as RegistryToolOperationConfig[];
+
+// A few tools have no static defaultParameters (the UI always supplies a value);
+// give the sweep a minimal valid parameter set for those.
+const FALLBACK_PARAMS: Record> = {
+ editTableOfContents: { bookmarks: [], replaceExisting: false },
+};
+
+describe("migrated tool mappers (sweep)", () => {
+ const file = new File(["x"], "test.pdf", { type: "application/pdf" });
+
+ test.each(
+ MIGRATED_CONFIGS.map((config) => [config.operationType, config] as const),
+ )(
+ "%s: exposes both mappers and serializes its default parameters cleanly",
+ (_name, config) => {
+ // Every migrated tool authors both directions of the mapping.
+ expect(config.toApiParams).toBeDefined();
+ expect(config.fromApiParams).toBeDefined();
+
+ // toApiParams(defaults) must produce a body objectToFormData can serialize
+ // (i.e. only primitives / arrays of primitives). A mapper that leaked a
+ // structured value would throw here.
+ const params =
+ config.defaultParameters ?? FALLBACK_PARAMS[config.operationType] ?? {};
+ const apiParams = config.toApiParams!(params);
+ expect(() =>
+ objectToFormData(apiParams, { fileInput: file }),
+ ).not.toThrow();
+ },
+ );
+});
+
+describe("redact mappers", () => {
+ test("toApiParams builds the auto-redact body from UI parameters", () => {
+ const api = redactOperationConfig.toApiParams({
+ mode: "automatic",
+ wordsToRedact: ["foo", "bar"],
+ useRegex: true,
+ wholeWordSearch: false,
+ redactColor: "#ff0000",
+ customPadding: 0.2,
+ convertPDFToImage: false,
+ });
+
+ expect(api).toEqual({
+ listOfText: "foo\nbar",
+ useRegex: true,
+ wholeWordSearch: false,
+ redactColor: "ff0000", // '#' stripped for the backend
+ customPadding: 0.2,
+ convertPDFToImage: false,
+ });
+ });
+
+ test("round-trips through fromApiParams", () => {
+ const api = redactOperationConfig.toApiParams({
+ mode: "automatic",
+ wordsToRedact: ["secret"],
+ useRegex: false,
+ wholeWordSearch: true,
+ redactColor: "#123456",
+ customPadding: 0.1,
+ convertPDFToImage: true,
+ });
+ const roundTripped = redactOperationConfig.toApiParams({
+ mode: "automatic",
+ wordsToRedact: [],
+ useRegex: false,
+ wholeWordSearch: false,
+ redactColor: "#000000",
+ customPadding: 0,
+ convertPDFToImage: false,
+ ...redactOperationConfig.fromApiParams(api),
+ });
+
+ expect(roundTripped).toEqual(api);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts
new file mode 100644
index 0000000000..6ed02a49ae
--- /dev/null
+++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, test } from "vitest";
+import {
+ objectToFormData,
+ type ToolApiParams,
+} from "@app/hooks/tools/shared/toolApiMapping";
+
+describe("objectToFormData", () => {
+ test("serializes primitive fields to string form values", () => {
+ const request: ToolApiParams["/api/v1/misc/compress-pdf"] = {
+ optimizeLevel: 3,
+ grayscale: true,
+ linearize: false,
+ expectedOutputSize: "25KB",
+ };
+ const formData = objectToFormData(request);
+
+ expect(formData.get("optimizeLevel")).toBe("3");
+ expect(formData.get("grayscale")).toBe("true");
+ expect(formData.get("linearize")).toBe("false");
+ expect(formData.get("expectedOutputSize")).toBe("25KB");
+ });
+
+ test("omits fields whose value is undefined", () => {
+ const request: ToolApiParams["/api/v1/misc/compress-pdf"] = {
+ optimizeLevel: 5,
+ expectedOutputSize: undefined,
+ };
+ const formData = objectToFormData(request);
+
+ expect(formData.has("optimizeLevel")).toBe(true);
+ expect(formData.has("expectedOutputSize")).toBe(false);
+ });
+
+ test("expands arrays into repeated fields", () => {
+ const request: ToolApiParams["/api/v1/misc/add-attachments"] = {
+ attachments: ["a.png", "b.png", "c.png"],
+ };
+ const formData = objectToFormData(request);
+
+ expect(formData.getAll("attachments")).toEqual(["a.png", "b.png", "c.png"]);
+ });
+
+ test("throws on a non-primitive field value rather than dropping it", () => {
+ // A redact request whose structured field was left un-encoded: the array
+ // items are objects, which cannot be sent as form fields.
+ const request: ToolApiParams["/api/v1/security/redact"] = {
+ redactions: [{ x: 1, y: 2 }],
+ };
+
+ expect(() => objectToFormData(request)).toThrow(/field "redactions"/);
+ });
+
+ test("appends a single file under its field name", () => {
+ const file = new File(["x"], "doc.pdf", { type: "application/pdf" });
+ const request: ToolApiParams["/api/v1/misc/compress-pdf"] = {
+ optimizeLevel: 5,
+ };
+ const formData = objectToFormData(request, { fileInput: file });
+
+ expect(formData.get("fileInput")).toBe(file);
+ expect(formData.get("optimizeLevel")).toBe("5");
+ });
+
+ test("appends multiple files under the same field name", () => {
+ const files = [
+ new File(["1"], "a.pdf", { type: "application/pdf" }),
+ new File(["2"], "b.pdf", { type: "application/pdf" }),
+ ];
+ const formData = objectToFormData({}, { fileInput: files });
+
+ expect(formData.getAll("fileInput")).toEqual(files);
+ });
+
+ test("appends named file fields alongside fileInput", () => {
+ const doc = new File(["d"], "doc.pdf", { type: "application/pdf" });
+ const stamp = new File(["s"], "stamp.png", { type: "image/png" });
+ const formData = objectToFormData(
+ {},
+ { fileInput: doc, stampImage: stamp },
+ );
+
+ expect(formData.get("fileInput")).toBe(doc);
+ expect(formData.get("stampImage")).toBe(stamp);
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts
new file mode 100644
index 0000000000..760df9ce5e
--- /dev/null
+++ b/frontend/editor/src/core/hooks/tools/shared/toolApiMapping.ts
@@ -0,0 +1,88 @@
+import {
+ type ToolApiParams,
+ type ToolApiRequest,
+ type ToolEndpoint,
+} from "@app/types/toolApiTypes";
+
+export type { ToolApiParams, ToolApiRequest, ToolEndpoint };
+
+/**
+ * Mapping for tools that take only a file and have no request parameters (their
+ * generated model is `Record`). Both directions are empty; the
+ * tool's buildFormData just appends the file.
+ */
+export function fileOnlyMapping(): {
+ toApiParams: () => Record;
+ fromApiParams: () => Record;
+} {
+ return { toApiParams: () => ({}), fromApiParams: () => ({}) };
+}
+
+/** Named file fields to append alongside the serialized parameters. */
+export interface FormDataFiles {
+ /** Primary document input(s); appended under the `fileInput` field. */
+ fileInput?: File | File[];
+ /** Any other named file field the endpoint accepts. */
+ [field: string]: File | File[] | undefined;
+}
+
+function appendPrimitive(
+ formData: FormData,
+ key: string,
+ value: unknown,
+): void {
+ if (value === undefined || value === null) return;
+ if (typeof value === "string") {
+ formData.append(key, value);
+ } else if (typeof value === "number" || typeof value === "boolean") {
+ formData.append(key, `${value}`);
+ } else {
+ // A non-primitive here means a mapper produced a value the backend cannot
+ // receive as a form field. Fail loudly rather than silently drop it:
+ // structured fields must be JSON-encoded in the mapper, and Files passed via
+ // the `files` argument.
+ throw new Error(
+ `objectToFormData: field "${key}" has an unsupported value of type ` +
+ `"${typeof value}"; expected a string, number, or boolean.`,
+ );
+ }
+}
+
+/**
+ * Serialize a backend request model (the output of a `toApiParams` function)
+ * into multipart FormData: primitives become string fields, arrays become
+ * repeated fields, and `undefined`/`null` are omitted. Files are appended
+ * separately via `files`, keeping file plumbing out of the parameter mapper.
+ *
+ * Throws if a field holds a non-primitive value, since that cannot be sent as a
+ * form field: structured fields must be JSON-encoded by the mapper.
+ */
+export function objectToFormData(
+ params: ToolApiRequest,
+ files?: FormDataFiles,
+): FormData {
+ const formData = new FormData();
+
+ for (const [key, value] of Object.entries(params)) {
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ appendPrimitive(formData, key, item);
+ }
+ } else {
+ appendPrimitive(formData, key, value);
+ }
+ }
+
+ if (files) {
+ for (const [field, value] of Object.entries(files)) {
+ if (value === undefined) continue;
+ if (Array.isArray(value)) {
+ value.forEach((file) => formData.append(field, file));
+ } else {
+ formData.append(field, value);
+ }
+ }
+ }
+
+ return formData;
+}
diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts
index 0d48ce6d09..93ade85368 100644
--- a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts
@@ -3,9 +3,16 @@ import { StirlingFile } from "@app/types/fileContext";
import type { ResponseHandler } from "@app/utils/toolResponseProcessor";
import { ToolId } from "@app/types/toolId";
import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState";
+import type { ToolApiRequest, ToolEndpoint } from "@app/types/toolApiTypes";
export type { ProcessingProgress, ResponseHandler };
+/**
+ * A tool operation's backend endpoint, checked against the generated ToolEndpoint
+ * set, or `null` when the operation has no backend endpoint.
+ */
+export type ToolOperationEndpoint = ToolEndpoint | null;
+
export enum ToolType {
singleFile,
multiFile,
@@ -72,6 +79,19 @@ interface BaseToolOperationConfig {
/** Default parameter values for automation */
defaultParameters?: TParams;
+ /**
+ * Typed frontend params -> backend request model. When a tool provides this,
+ * it is the spec-checked source of truth for the request body and its
+ * buildFormData is derived from it via objectToFormData.
+ */
+ toApiParams?(params: TParams): ToolApiRequest;
+
+ /**
+ * Backend request model -> partial frontend params, so a stored API call
+ * can be re-hydrated into this tool's settings UI.
+ */
+ fromApiParams?(apiParams: ToolApiRequest): Partial;
+
/**
* For custom tools: if true, success implies all input files were successfully processed.
* Use this for tools like Automate or Merge where Many-to-One relationships exist
@@ -89,8 +109,12 @@ export interface SingleFileToolOperationConfig<
/** Builds FormData for API request. */
buildFormData: (params: TParams, file: File) => FormData;
- /** API endpoint for the operation. Can be static string or function for dynamic routing. */
- endpoint: string | ((params: TParams) => string);
+ /**
+ * API endpoint for the operation. Can be static or a function for dynamic routing.
+ */
+ endpoint:
+ | ToolOperationEndpoint
+ | ((params: TParams) => ToolOperationEndpoint);
customProcessor?: undefined;
}
@@ -107,8 +131,12 @@ export interface MultiFileToolOperationConfig<
/** Builds FormData for API request. */
buildFormData: (params: TParams, files: File[]) => FormData;
- /** API endpoint for the operation. Can be static string or function for dynamic routing. */
- endpoint: string | ((params: TParams) => string);
+ /**
+ * API endpoint for the operation. Can be static or a function for dynamic routing.
+ */
+ endpoint:
+ | ToolOperationEndpoint
+ | ((params: TParams) => ToolOperationEndpoint);
customProcessor?: undefined;
}
diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts b/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts
index 28da62484a..cca1f5e563 100644
--- a/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/useToolApiCalls.ts
@@ -10,7 +10,7 @@ import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState";
import type { StirlingFile, FileId } from "@app/types/fileContext";
export interface ApiCallsConfig {
- endpoint: string | ((params: TParams) => string);
+ endpoint: string | null | ((params: TParams) => string | null);
buildFormData: (params: TParams, file: File) => FormData;
filePrefix?: string;
responseHandler?: ResponseHandler;
@@ -37,6 +37,19 @@ export const useToolApiCalls = () => {
// Create cancel token for this operation
cancelTokenRef.current = axios.CancelToken.source();
+ // Params are the same for every file, so resolve the endpoint once. A null
+ // endpoint means the tool has no backend call (e.g. client-side tools) and
+ // should never reach here, so fail loudly rather than POST to null.
+ const endpoint =
+ typeof config.endpoint === "function"
+ ? config.endpoint(params)
+ : config.endpoint;
+ if (!endpoint) {
+ throw new Error(
+ "This operation has no backend endpoint and cannot be executed directly.",
+ );
+ }
+
for (let i = 0; i < validFiles.length; i++) {
const file = validFiles[i];
@@ -51,10 +64,6 @@ export const useToolApiCalls = () => {
try {
const formData = config.buildFormData(params, file);
- const endpoint =
- typeof config.endpoint === "function"
- ? config.endpoint(params)
- : config.endpoint;
console.debug("[processFiles] POST", { endpoint, name: file.name });
const response = await apiClient.post(endpoint, formData, {
responseType: "blob",
diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts
index 5629dfe54b..17e4c47442 100644
--- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts
@@ -266,6 +266,11 @@ export const useToolOperation = (
typeof config.endpoint === "function"
? config.endpoint(params)
: config.endpoint;
+ if (!endpoint) {
+ throw new Error(
+ "This operation has no backend endpoint and cannot be executed directly.",
+ );
+ }
const response = await apiClient.post(endpoint, formData, {
responseType: "blob",
diff --git a/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts b/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts
index bd92fdd758..175fa744ab 100644
--- a/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts
@@ -54,7 +54,10 @@ export const signOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSignFormData,
operationType: "sign",
- endpoint: "/api/v1/security/add-signature",
+ // Signing is applied client-side in the viewer (see createStampTool ->
+ // flattenSignatures); there is no backend endpoint and the standard execute
+ // path is never used.
+ endpoint: null,
filePrefix: "signed_",
defaultParameters: DEFAULT_PARAMETERS,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts b/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts
index a57a26a7dc..835959c484 100644
--- a/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts
@@ -3,28 +3,36 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ fileOnlyMapping,
+ objectToFormData,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
SingleLargePageParameters,
defaultParameters,
} from "@app/hooks/tools/singleLargePage/useSingleLargePageParameters";
+const ENDPOINT = "/api/v1/general/pdf-to-single-page" satisfies ToolEndpoint;
+
+// Single large page takes only a file; there are no request parameters to map.
+const { toApiParams, fromApiParams } = fileOnlyMapping();
+
// Static function that can be used by both the hook and automation executor
export const buildSingleLargePageFormData = (
_parameters: SingleLargePageParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- return formData;
-};
+): FormData => objectToFormData(toApiParams(), { fileInput: file });
// Static configuration object
export const singleLargePageOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSingleLargePageFormData,
+ toApiParams,
+ fromApiParams,
operationType: "pdfToSinglePage",
- endpoint: "/api/v1/general/pdf-to-single-page",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.test.ts b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.test.ts
new file mode 100644
index 0000000000..4cf8509c5d
--- /dev/null
+++ b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.test.ts
@@ -0,0 +1,212 @@
+import { describe, expect, test } from "vitest";
+import {
+ buildSplitFormData,
+ getSplitEndpoint,
+ splitFromApiParams,
+ splitToApiParams,
+} from "@app/hooks/tools/split/useSplitOperation";
+import {
+ SplitParameters,
+ defaultParameters,
+} from "@app/hooks/tools/split/useSplitParameters";
+import { SPLIT_METHODS } from "@app/constants/splitConstants";
+
+const params = (overrides: Partial): SplitParameters => ({
+ ...defaultParameters,
+ ...overrides,
+});
+
+describe("splitToApiParams", () => {
+ test("byPages sends pageNumbers", () => {
+ expect(
+ splitToApiParams(
+ params({ method: SPLIT_METHODS.BY_PAGES, pages: "2,5" }),
+ ),
+ ).toEqual({ pageNumbers: "2,5" });
+ });
+
+ test("bySections sends divisions and split mode without custom pages", () => {
+ expect(
+ splitToApiParams(
+ params({
+ method: SPLIT_METHODS.BY_SECTIONS,
+ hDiv: "3",
+ vDiv: "2",
+ merge: true,
+ splitMode: "SPLIT_ALL",
+ }),
+ ),
+ ).toEqual({
+ horizontalDivisions: 3,
+ verticalDivisions: 2,
+ merge: true,
+ splitMode: "SPLIT_ALL",
+ });
+ });
+
+ test("bySections includes pageNumbers only for CUSTOM mode", () => {
+ expect(
+ splitToApiParams(
+ params({
+ method: SPLIT_METHODS.BY_SECTIONS,
+ splitMode: "CUSTOM",
+ customPages: "1,2",
+ }),
+ ),
+ ).toMatchObject({ splitMode: "CUSTOM", pageNumbers: "1,2" });
+ });
+
+ test.each([
+ { method: SPLIT_METHODS.BY_SIZE, splitType: 0 },
+ { method: SPLIT_METHODS.BY_PAGE_COUNT, splitType: 1 },
+ { method: SPLIT_METHODS.BY_DOC_COUNT, splitType: 2 },
+ ])("$method maps to splitType $splitType", ({ method, splitType }) => {
+ expect(splitToApiParams(params({ method, splitValue: "5" }))).toEqual({
+ splitType,
+ splitValue: "5",
+ });
+ });
+
+ test("byChapters converts bookmarkLevel to a number", () => {
+ expect(
+ splitToApiParams(
+ params({
+ method: SPLIT_METHODS.BY_CHAPTERS,
+ bookmarkLevel: "2",
+ includeMetadata: true,
+ }),
+ ),
+ ).toEqual({
+ bookmarkLevel: 2,
+ includeMetadata: true,
+ allowDuplicates: false,
+ });
+ });
+
+ test("byPageDivider sends duplexMode", () => {
+ expect(
+ splitToApiParams(
+ params({ method: SPLIT_METHODS.BY_PAGE_DIVIDER, duplexMode: true }),
+ ),
+ ).toEqual({ duplexMode: true });
+ });
+
+ test("byPoster maps the factors to the spec's xFactor/yFactor fields", () => {
+ expect(
+ splitToApiParams(
+ params({
+ method: SPLIT_METHODS.BY_POSTER,
+ pageSize: "A4",
+ xFactor: "3",
+ yFactor: "2",
+ rightToLeft: true,
+ }),
+ ),
+ ).toEqual({ pageSize: "A4", xFactor: 3, yFactor: 2, rightToLeft: true });
+ });
+
+ // A cleared numeric field arrives as "". It must fall back to the default,
+ // not Number("") === 0, which the backend turns into an empty/degenerate PDF.
+ test("byPoster falls back to the default factor for empty fields", () => {
+ expect(
+ splitToApiParams(
+ params({ method: SPLIT_METHODS.BY_POSTER, xFactor: "", yFactor: "" }),
+ ),
+ ).toMatchObject({ xFactor: 2, yFactor: 2 });
+ });
+
+ test("bySections falls back to the default divisions for empty fields", () => {
+ expect(
+ splitToApiParams(
+ params({ method: SPLIT_METHODS.BY_SECTIONS, hDiv: "", vDiv: "" }),
+ ),
+ ).toMatchObject({ horizontalDivisions: 2, verticalDivisions: 2 });
+ });
+
+ test("byChapters falls back to the default bookmark level for an empty field", () => {
+ expect(
+ splitToApiParams(
+ params({ method: SPLIT_METHODS.BY_CHAPTERS, bookmarkLevel: "" }),
+ ),
+ ).toMatchObject({ bookmarkLevel: 1 });
+ });
+});
+
+describe("split round-trip", () => {
+ test.each>([
+ { method: SPLIT_METHODS.BY_PAGES, pages: "2,5" },
+ {
+ method: SPLIT_METHODS.BY_SECTIONS,
+ hDiv: "3",
+ vDiv: "2",
+ merge: true,
+ splitMode: "SPLIT_ALL",
+ },
+ {
+ method: SPLIT_METHODS.BY_SECTIONS,
+ splitMode: "CUSTOM",
+ customPages: "1,2",
+ },
+ { method: SPLIT_METHODS.BY_SIZE, splitValue: "10MB" },
+ { method: SPLIT_METHODS.BY_PAGE_COUNT, splitValue: "5" },
+ { method: SPLIT_METHODS.BY_DOC_COUNT, splitValue: "3" },
+ {
+ method: SPLIT_METHODS.BY_CHAPTERS,
+ bookmarkLevel: "2",
+ includeMetadata: true,
+ },
+ { method: SPLIT_METHODS.BY_PAGE_DIVIDER, duplexMode: true },
+ {
+ method: SPLIT_METHODS.BY_POSTER,
+ pageSize: "A4",
+ xFactor: "3",
+ yFactor: "2",
+ },
+ ])("toApiParams(fromApiParams(x)) reproduces x for %o", (overrides) => {
+ const api = splitToApiParams(params(overrides));
+ const roundTripped = splitToApiParams(params(splitFromApiParams(api)));
+
+ expect(roundTripped).toEqual(api);
+ });
+});
+
+describe("getSplitEndpoint", () => {
+ test.each([
+ { method: SPLIT_METHODS.BY_PAGES, endpoint: "/api/v1/general/split-pages" },
+ {
+ method: SPLIT_METHODS.BY_SECTIONS,
+ endpoint: "/api/v1/general/split-pdf-by-sections",
+ },
+ {
+ method: SPLIT_METHODS.BY_SIZE,
+ endpoint: "/api/v1/general/split-by-size-or-count",
+ },
+ {
+ method: SPLIT_METHODS.BY_CHAPTERS,
+ endpoint: "/api/v1/general/split-pdf-by-chapters",
+ },
+ {
+ method: SPLIT_METHODS.BY_PAGE_DIVIDER,
+ endpoint: "/api/v1/misc/auto-split-pdf",
+ },
+ {
+ method: SPLIT_METHODS.BY_POSTER,
+ endpoint: "/api/v1/general/split-for-poster-print",
+ },
+ ])("$method routes to $endpoint", ({ method, endpoint }) => {
+ expect(getSplitEndpoint(params({ method }))).toBe(endpoint);
+ });
+});
+
+describe("buildSplitFormData", () => {
+ test("appends the file and the serialized parameters", () => {
+ const file = new File(["x"], "test.pdf", { type: "application/pdf" });
+ const formData = buildSplitFormData(
+ params({ method: SPLIT_METHODS.BY_PAGES, pages: "3" }),
+ file,
+ );
+
+ expect(formData.get("fileInput")).toBe(file);
+ expect(formData.get("pageNumbers")).toBe("3");
+ });
+});
diff --git a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts
index bedf83d09e..adeb65546a 100644
--- a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts
@@ -5,115 +5,176 @@ import {
useToolOperation,
ToolOperationConfig,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
SplitParameters,
defaultParameters,
} from "@app/hooks/tools/split/useSplitParameters";
-import { SPLIT_METHODS } from "@app/constants/splitConstants";
+import { SPLIT_METHODS, type SplitMethod } from "@app/constants/splitConstants";
import { useToolResources } from "@app/hooks/tools/shared/useToolResources";
-// Static functions that can be used by both the hook and automation executor
-export const buildSplitFormData = (
+// Split routes to a different endpoint per method. This map is the single source
+// of truth: getSplitEndpoint returns from it, and the mapper types below are
+// derived from it, so the endpoint posted and the request shape checked can
+// never point at different endpoints.
+const SPLIT_ENDPOINTS = {
+ [SPLIT_METHODS.BY_PAGES]: "/api/v1/general/split-pages",
+ [SPLIT_METHODS.BY_SECTIONS]: "/api/v1/general/split-pdf-by-sections",
+ [SPLIT_METHODS.BY_SIZE]: "/api/v1/general/split-by-size-or-count",
+ [SPLIT_METHODS.BY_PAGE_COUNT]: "/api/v1/general/split-by-size-or-count",
+ [SPLIT_METHODS.BY_DOC_COUNT]: "/api/v1/general/split-by-size-or-count",
+ [SPLIT_METHODS.BY_CHAPTERS]: "/api/v1/general/split-pdf-by-chapters",
+ [SPLIT_METHODS.BY_PAGE_DIVIDER]: "/api/v1/misc/auto-split-pdf",
+ [SPLIT_METHODS.BY_POSTER]: "/api/v1/general/split-for-poster-print",
+} as const satisfies Record;
+
+type SplitApiParams = ToolApiParams[(typeof SPLIT_ENDPOINTS)[SplitMethod]];
+type SectionsApiParams =
+ ToolApiParams[(typeof SPLIT_ENDPOINTS)[typeof SPLIT_METHODS.BY_SECTIONS]];
+type PosterApiParams =
+ ToolApiParams[(typeof SPLIT_ENDPOINTS)[typeof SPLIT_METHODS.BY_POSTER]];
+
+// Convert the tool's UI parameters into the request body for the routed endpoint.
+export const splitToApiParams = (
parameters: SplitParameters,
- file: File,
-): FormData => {
- const formData = new FormData();
-
- formData.append("fileInput", file);
-
+): SplitApiParams => {
// Use BY_PAGES as default if no method is selected
const method = parameters.method || SPLIT_METHODS.BY_PAGES;
switch (method) {
case SPLIT_METHODS.BY_PAGES:
- formData.append("pageNumbers", parameters.pages);
- break;
- case SPLIT_METHODS.BY_SECTIONS:
- formData.append("horizontalDivisions", parameters.hDiv);
- formData.append("verticalDivisions", parameters.vDiv);
- formData.append("merge", (parameters.merge ?? false).toString());
- formData.append("splitMode", parameters.splitMode || "SPLIT_ALL");
+ return { pageNumbers: parameters.pages };
+ case SPLIT_METHODS.BY_SECTIONS: {
+ const sections: SectionsApiParams = {
+ horizontalDivisions: Number(parameters.hDiv || "2"),
+ verticalDivisions: Number(parameters.vDiv || "2"),
+ merge: parameters.merge ?? false,
+ splitMode: (parameters.splitMode ||
+ "SPLIT_ALL") as SectionsApiParams["splitMode"],
+ };
if (parameters.splitMode === "CUSTOM" && parameters.customPages) {
- formData.append("pageNumbers", parameters.customPages);
+ sections.pageNumbers = parameters.customPages;
}
- break;
+ return sections;
+ }
case SPLIT_METHODS.BY_SIZE:
- formData.append("splitType", "0");
- formData.append("splitValue", parameters.splitValue);
- break;
+ return { splitType: 0, splitValue: parameters.splitValue };
case SPLIT_METHODS.BY_PAGE_COUNT:
- formData.append("splitType", "1");
- formData.append("splitValue", parameters.splitValue);
- break;
+ return { splitType: 1, splitValue: parameters.splitValue };
case SPLIT_METHODS.BY_DOC_COUNT:
- formData.append("splitType", "2");
- formData.append("splitValue", parameters.splitValue);
- break;
+ return { splitType: 2, splitValue: parameters.splitValue };
case SPLIT_METHODS.BY_CHAPTERS:
- formData.append("bookmarkLevel", parameters.bookmarkLevel);
- formData.append(
- "includeMetadata",
- (parameters.includeMetadata ?? false).toString(),
- );
- formData.append(
- "allowDuplicates",
- (parameters.allowDuplicates ?? false).toString(),
- );
- break;
+ return {
+ bookmarkLevel: Number(parameters.bookmarkLevel || "1"),
+ includeMetadata: parameters.includeMetadata ?? false,
+ allowDuplicates: parameters.allowDuplicates ?? false,
+ };
case SPLIT_METHODS.BY_PAGE_DIVIDER:
- formData.append(
- "duplexMode",
- (parameters.duplexMode ?? false).toString(),
- );
- break;
+ return { duplexMode: parameters.duplexMode ?? false };
case SPLIT_METHODS.BY_POSTER:
- formData.append("pageSize", parameters.pageSize || "A4");
- formData.append("xFactor", parameters.xFactor || "2");
- formData.append("yFactor", parameters.yFactor || "2");
- formData.append(
- "rightToLeft",
- (parameters.rightToLeft ?? false).toString(),
- );
- break;
+ return {
+ pageSize: (parameters.pageSize || "A4") as PosterApiParams["pageSize"],
+ xFactor: Number(parameters.xFactor || "2"),
+ yFactor: Number(parameters.yFactor || "2"),
+ rightToLeft: parameters.rightToLeft ?? false,
+ };
default:
throw new Error(`Unknown split method: ${method}`);
}
-
- return formData;
};
-export const getSplitEndpoint = (parameters: SplitParameters): string => {
- // Default to BY_PAGES endpoint if no method selected yet
- if (!parameters.method) {
- return "/api/v1/general/split-pages";
+// Reconstruct the tool's UI parameters from a stored request body. The step
+// carries no explicit method, so it is inferred from the fields present.
+export const splitFromApiParams = (
+ apiParams: SplitApiParams,
+): Partial => {
+ if ("pageSize" in apiParams) {
+ return {
+ method: SPLIT_METHODS.BY_POSTER,
+ pageSize: apiParams.pageSize,
+ xFactor:
+ apiParams.xFactor !== undefined ? `${apiParams.xFactor}` : undefined,
+ yFactor:
+ apiParams.yFactor !== undefined ? `${apiParams.yFactor}` : undefined,
+ rightToLeft: apiParams.rightToLeft ?? defaultParameters.rightToLeft,
+ };
}
-
- switch (parameters.method) {
- case null:
- case SPLIT_METHODS.BY_PAGES:
- return "/api/v1/general/split-pages";
- case SPLIT_METHODS.BY_SECTIONS:
- return "/api/v1/general/split-pdf-by-sections";
- case SPLIT_METHODS.BY_SIZE:
- case SPLIT_METHODS.BY_PAGE_COUNT:
- case SPLIT_METHODS.BY_DOC_COUNT:
- return "/api/v1/general/split-by-size-or-count";
- case SPLIT_METHODS.BY_CHAPTERS:
- return "/api/v1/general/split-pdf-by-chapters";
- case SPLIT_METHODS.BY_PAGE_DIVIDER:
- return "/api/v1/misc/auto-split-pdf";
- case SPLIT_METHODS.BY_POSTER:
- return "/api/v1/general/split-for-poster-print";
- default:
- throw new Error(`Unknown split method: ${parameters.method}`);
+ if ("horizontalDivisions" in apiParams || "verticalDivisions" in apiParams) {
+ return {
+ method: SPLIT_METHODS.BY_SECTIONS,
+ hDiv:
+ apiParams.horizontalDivisions !== undefined
+ ? `${apiParams.horizontalDivisions}`
+ : undefined,
+ vDiv:
+ apiParams.verticalDivisions !== undefined
+ ? `${apiParams.verticalDivisions}`
+ : undefined,
+ merge: apiParams.merge ?? false,
+ splitMode: apiParams.splitMode ?? "SPLIT_ALL",
+ customPages:
+ apiParams.splitMode === "CUSTOM"
+ ? apiParams.pageNumbers
+ : defaultParameters.customPages,
+ };
}
+ if ("bookmarkLevel" in apiParams) {
+ return {
+ method: SPLIT_METHODS.BY_CHAPTERS,
+ bookmarkLevel:
+ apiParams.bookmarkLevel !== undefined
+ ? `${apiParams.bookmarkLevel}`
+ : "",
+ includeMetadata: apiParams.includeMetadata ?? false,
+ allowDuplicates: apiParams.allowDuplicates ?? false,
+ };
+ }
+ if ("splitType" in apiParams) {
+ const methodBySplitType = {
+ 0: SPLIT_METHODS.BY_SIZE,
+ 1: SPLIT_METHODS.BY_PAGE_COUNT,
+ 2: SPLIT_METHODS.BY_DOC_COUNT,
+ } as const;
+ return {
+ method: methodBySplitType[apiParams.splitType as 0 | 1 | 2],
+ splitValue: apiParams.splitValue ?? "",
+ };
+ }
+ if ("duplexMode" in apiParams) {
+ return {
+ method: SPLIT_METHODS.BY_PAGE_DIVIDER,
+ duplexMode: apiParams.duplexMode ?? false,
+ };
+ }
+ const pages = "pageNumbers" in apiParams ? apiParams.pageNumbers : undefined;
+ return {
+ method: SPLIT_METHODS.BY_PAGES,
+ pages: pages ?? defaultParameters.pages,
+ };
};
+// Static functions that can be used by both the hook and automation executor
+export const buildSplitFormData = (
+ parameters: SplitParameters,
+ file: File,
+): FormData =>
+ objectToFormData(splitToApiParams(parameters), { fileInput: file });
+
+export const getSplitEndpoint = (parameters: SplitParameters): ToolEndpoint =>
+ // Default to BY_PAGES when no method is selected yet.
+ SPLIT_ENDPOINTS[parameters.method ?? SPLIT_METHODS.BY_PAGES];
+
// Static configuration object
export const splitOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildSplitFormData,
+ toApiParams: splitToApiParams,
+ fromApiParams: splitFromApiParams,
operationType: "split",
endpoint: getSplitEndpoint,
defaultParameters,
diff --git a/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts b/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts
index b14f785574..f607a727f0 100644
--- a/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts
@@ -3,29 +3,45 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ objectToFormData,
+ type ToolApiParams,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
TimestampPdfParameters,
defaultParameters,
} from "@app/hooks/tools/timestampPdf/useTimestampPdfParameters";
+const ENDPOINT = "/api/v1/security/timestamp-pdf" satisfies ToolEndpoint;
+type TimestampPdfApiParams = ToolApiParams[typeof ENDPOINT];
+
+export const timestampPdfToApiParams = (
+ parameters: TimestampPdfParameters,
+): TimestampPdfApiParams => ({
+ tsaUrl: parameters.tsaUrl,
+});
+
+export const timestampPdfFromApiParams = (
+ apiParams: TimestampPdfApiParams,
+): Partial => ({
+ tsaUrl: apiParams.tsaUrl,
+});
+
export const buildTimestampPdfFormData = (
parameters: TimestampPdfParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
-
- formData.append("tsaUrl", parameters.tsaUrl);
-
- return formData;
-};
+): FormData =>
+ objectToFormData(timestampPdfToApiParams(parameters), { fileInput: file });
export const timestampPdfOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildTimestampPdfFormData,
+ toApiParams: timestampPdfToApiParams,
+ fromApiParams: timestampPdfFromApiParams,
operationType: "timestampPdf",
- endpoint: "/api/v1/security/timestamp-pdf",
+ endpoint: ENDPOINT,
multiFileEndpoint: false,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts b/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts
index 1e78d2d407..2d3e22fb06 100644
--- a/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts
@@ -3,28 +3,36 @@ import {
ToolType,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
+import {
+ fileOnlyMapping,
+ objectToFormData,
+ type ToolEndpoint,
+} from "@app/hooks/tools/shared/toolApiMapping";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
UnlockPdfFormsParameters,
defaultParameters,
} from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters";
+const ENDPOINT = "/api/v1/misc/unlock-pdf-forms" satisfies ToolEndpoint;
+
+// Unlock PDF forms takes only a file; there are no request parameters to map.
+const { toApiParams, fromApiParams } = fileOnlyMapping();
+
// Static function that can be used by both the hook and automation executor
export const buildUnlockPdfFormsFormData = (
_parameters: UnlockPdfFormsParameters,
file: File,
-): FormData => {
- const formData = new FormData();
- formData.append("fileInput", file);
- return formData;
-};
+): FormData => objectToFormData(toApiParams(), { fileInput: file });
// Static configuration object
export const unlockPdfFormsOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildUnlockPdfFormsFormData,
+ toApiParams,
+ fromApiParams,
operationType: "unlockPDFForms",
- endpoint: "/api/v1/misc/unlock-pdf-forms",
+ endpoint: ENDPOINT,
defaultParameters,
} as const;
diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts
new file mode 100644
index 0000000000..d62e13b20f
--- /dev/null
+++ b/frontend/editor/src/core/types/toolApiTypes.ts
@@ -0,0 +1,1594 @@
+// AUTO-GENERATED FILE. DO NOT EDIT.
+// Generated by editor/scripts/generate-tool-api-types.mts from the Java OpenAPI spec
+// (SwaggerDoc.json). Regenerate with: task frontend:tool-models
+// Tools that take only a file input have no parameters; their model is Record.
+
+export interface AddAttachmentRequest {
+ /**
+ * The image file to be overlaid onto the PDF.
+ */
+ attachments: string[];
+ /**
+ * Convert the resulting PDF to PDF/A-3b format after adding attachments
+ */
+ convertToPdfA3b?: boolean;
+}
+export interface AddCommentsRequest {
+ /**
+ * JSON array of comment specs. Each element has: {pageIndex, x, y, width, height, text, author?, subject?}. Coordinates are PDF user-space with origin at the page's bottom-left.
+ */
+ comments: string;
+}
+export interface AddPageNumbersRequest {
+ /**
+ * Custom margin: small/medium/large/x-large
+ */
+ customMargin?: "small" | "medium" | "large" | "x-large";
+ /**
+ * Custom text pattern. Available variables: {n}=current page number, {total}=total pages, {filename}=original filename
+ */
+ customText?: string;
+ /**
+ * Hex colour for page numbers (e.g. #FF0000)
+ */
+ fontColor?: string;
+ /**
+ * Font size for page numbers
+ */
+ fontSize?: number;
+ /**
+ * Font type for page numbers
+ */
+ fontType: "helvetica" | "courier" | "times";
+ /**
+ * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')
+ */
+ pageNumbers?: string;
+ /**
+ * Which pages to number (e.g. '1,3-5,7' or 'all')
+ */
+ pagesToNumber?: string;
+ /**
+ * Position: 1-9 representing positions on the page (1=top-left, 2=top-center, 3=top-right, 4=middle-left, 5=middle-center, 6=middle-right, 7=bottom-left, 8=bottom-center, 9=bottom-right)
+ */
+ position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
+ /**
+ * Starting number for page numbering
+ */
+ startingNumber?: number;
+ /**
+ * Zero-padding width for page numbers (Bates Stamping). Set to 0 to disable padding
+ */
+ zeroPad?: number;
+}
+export interface AddPasswordRequest {
+ /**
+ * The length of the encryption key
+ */
+ keyLength?: 40 | 128 | 256;
+ /**
+ * The owner password to be added to the PDF file (Restricts what can be done with the document once it is opened)
+ */
+ ownerPassword?: string;
+ /**
+ * The password to be added to the PDF file (Restricts the opening of the document itself.)
+ */
+ password?: string;
+ /**
+ * Whether document assembly is prevented
+ */
+ preventAssembly?: boolean;
+ /**
+ * Whether content extraction is prevented
+ */
+ preventExtractContent?: boolean;
+ /**
+ * Whether content extraction for accessibility is prevented
+ */
+ preventExtractForAccessibility?: boolean;
+ /**
+ * Whether form filling is prevented
+ */
+ preventFillInForm?: boolean;
+ /**
+ * Whether document modification is prevented
+ */
+ preventModify?: boolean;
+ /**
+ * Whether modification of annotations is prevented
+ */
+ preventModifyAnnotations?: boolean;
+ /**
+ * Whether printing of the document is prevented
+ */
+ preventPrinting?: boolean;
+ /**
+ * Whether faithful printing is prevented
+ */
+ preventPrintingFaithful?: boolean;
+}
+export interface AddStampRequest {
+ /**
+ * The selected alphabet of the stamp text
+ */
+ alphabet?: "roman" | "arabic" | "japanese" | "korean" | "chinese" | "thai";
+ /**
+ * The color of the stamp text
+ */
+ customColor?: string;
+ /**
+ * Specifies the margin size for the stamp.
+ */
+ customMargin?: "small" | "medium" | "large" | "x-large";
+ /**
+ * The font size of the stamp text and image in points.
+ */
+ fontSize?: number;
+ /**
+ * The opacity of the stamp (0.0 - 1.0)
+ */
+ opacity?: number;
+ /**
+ * Override X coordinate for stamp placement. If set, it will override the position-based calculation. Negative value means no override.
+ */
+ overrideX?: number;
+ /**
+ * Override Y coordinate for stamp placement. If set, it will override the position-based calculation. Negative value means no override.
+ */
+ overrideY?: number;
+ /**
+ * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')
+ */
+ pageNumbers?: string;
+ /**
+ * Position for stamp placement based on a 1-9 grid (1: bottom-left, 2: bottom-center, 3: bottom-right, 4: middle-left, 5: middle-center, 6: middle-right, 7: top-left, 8: top-center, 9: top-right)
+ */
+ position?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
+ /**
+ * The rotation of the stamp in degrees
+ */
+ rotation?: number;
+ stampImage?: string;
+ /**
+ * The stamp text
+ */
+ stampText?: string;
+ /**
+ * The stamp type (text or image)
+ */
+ stampType: "text" | "image";
+}
+export interface AddWatermarkRequest {
+ /**
+ * The selected alphabet
+ */
+ alphabet?: "roman" | "arabic" | "japanese" | "korean" | "chinese" | "thai";
+ /**
+ * Convert the redacted PDF to an image
+ */
+ convertPDFToImage?: boolean;
+ /**
+ * The color for watermark
+ */
+ customColor?: string;
+ /**
+ * The font size of the watermark text
+ */
+ fontSize?: number;
+ /**
+ * The height spacer between watermark elements
+ */
+ heightSpacer?: number;
+ /**
+ * The opacity of the watermark (0.0 - 1.0)
+ */
+ opacity?: number;
+ /**
+ * The rotation of the watermark in degrees
+ */
+ rotation?: number;
+ watermarkImage?: string;
+ /**
+ * The watermark text
+ */
+ watermarkText?: string;
+ /**
+ * The watermark type (text or image)
+ */
+ watermarkType: "text" | "image";
+ /**
+ * The width spacer between watermark elements
+ */
+ widthSpacer?: number;
+}
+export interface AutoSplitPdfRequest {
+ /**
+ * Flag indicating if the duplex mode is active, where the page after the divider also gets removed.
+ */
+ duplexMode?: boolean;
+}
+export interface BookletImpositionRequest {
+ /**
+ * Boolean for if you wish to add border around the pages
+ */
+ addBorder?: boolean;
+ /**
+ * Add gutter margin (inner margin for binding)
+ */
+ addGutter?: boolean;
+ /**
+ * Generate both front and back sides (double-sided printing)
+ */
+ doubleSided?: boolean;
+ /**
+ * For manual duplex: which pass to generate
+ */
+ duplexPass?: "BOTH" | "FIRST" | "SECOND";
+ /**
+ * Flip back sides for short-edge duplex printing (default is long-edge)
+ */
+ flipOnShortEdge?: boolean;
+ /**
+ * Gutter margin size in points (used when addGutter is true)
+ */
+ gutterSize?: number;
+ /**
+ * The number of pages per side for booklet printing (always 2 for proper booklet).
+ */
+ pagesPerSheet?: 2;
+ /**
+ * The spine location for the booklet.
+ */
+ spineLocation?: "LEFT" | "RIGHT";
+}
+export interface ConvertCbrToPdfRequest {
+ /**
+ * Optimize the output PDF for ebook reading using Ghostscript
+ */
+ optimizeForEbook?: boolean;
+}
+export interface ConvertCbzToPdfRequest {
+ /**
+ * Optimize the output PDF for ebook reading using Ghostscript
+ */
+ optimizeForEbook?: boolean;
+}
+export interface ConvertEbookToPdfRequest {
+ /**
+ * Embed all fonts from the eBook into the generated PDF
+ */
+ embedAllFonts?: true | false;
+ /**
+ * Add page numbers to the generated PDF
+ */
+ includePageNumbers?: true | false;
+ /**
+ * Add a generated table of contents to the resulting PDF
+ */
+ includeTableOfContents?: true | false;
+ /**
+ * Optimize the PDF for eBook reading (smaller file size, better rendering on eInk devices)
+ */
+ optimizeForEbook?: true | false;
+}
+export type ConvertPdfHtmlRequest = Record;
+export type ConvertPdfMarkdownRequest = Record;
+export type ConvertPdfTextEditorMetadataRequest = Record;
+export interface ConvertPdfTextEditorRequest {
+ lightweight?: boolean;
+}
+export interface ConvertPdfToCbrRequest {
+ /**
+ * The DPI (Dots Per Inch) for rendering PDF pages as images
+ */
+ dpi: number;
+}
+export interface ConvertPdfToCbzRequest {
+ /**
+ * The DPI (Dots Per Inch) for rendering PDF pages as images
+ */
+ dpi: number;
+}
+export interface ConvertPdfToEpubRequest {
+ /**
+ * Detect headings that look like chapters and insert EPUB page breaks.
+ */
+ detectChapters?: true | false;
+ /**
+ * Choose the output format for the ebook.
+ */
+ outputFormat?: "EPUB" | "AZW3";
+ /**
+ * Choose an output profile optimized for the reader device.
+ */
+ targetDevice?: "TABLET_PHONE_IMAGES" | "KINDLE_EINK_TEXT";
+}
+export type ConvertPdfXmlRequest = Record;
+export interface ConvertToImageRequest {
+ /**
+ * The color type of the output image(s)
+ */
+ colorType?: "color" | "greyscale" | "blackwhite";
+ /**
+ * The DPI (dots per inch) for the output image(s)
+ */
+ dpi?: number;
+ /**
+ * The output image format
+ */
+ imageFormat?: "png" | "jpeg" | "jpg" | "gif" | "webp";
+ /**
+ * Include annotations such as comments in the output image(s)
+ */
+ includeAnnotations?: boolean;
+ /**
+ * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')
+ */
+ pageNumbers?: string;
+ /**
+ * Choose between a single image containing all pages or separate images for each page
+ */
+ singleOrMultiple?: "single" | "multiple";
+}
+export interface ConvertToPdfRequest {
+ /**
+ * Whether to automatically rotate the images to better fit the PDF page
+ */
+ autoRotate?: boolean;
+ /**
+ * The color type of the output image(s)
+ */
+ colorType?: "color" | "greyscale" | "blackwhite";
+ /**
+ * Option to determine how the image will fit onto the page
+ */
+ fitOption?: "fillPage" | "fitDocumentToImage" | "maintainAspectRatio";
+}
+export interface CropPdfForm {
+ /**
+ * Enable auto-crop to detect and remove white space
+ */
+ autoCrop?: boolean;
+ /**
+ * The height of the crop area
+ */
+ height?: number;
+ /**
+ * Whether to remove text outside the crop area (keeps images)
+ */
+ removeDataOutsideCrop?: boolean;
+ /**
+ * The width of the crop area
+ */
+ width?: number;
+ /**
+ * The x-coordinate of the top-left corner of the crop area
+ */
+ x?: number;
+ /**
+ * The y-coordinate of the top-left corner of the crop area
+ */
+ y?: number;
+}
+export interface DeleteAttachmentRequest {
+ /**
+ * The name of the attachment to delete
+ */
+ attachmentName: string;
+}
+export interface EditTableOfContentsRequest {
+ /**
+ * Bookmark structure in JSON format
+ */
+ bookmarkData?: string;
+ /**
+ * Whether to replace existing bookmarks or append to them
+ */
+ replaceExisting?: boolean;
+}
+export interface EditTextRequest {
+ /**
+ * Ordered list of find/replace operations. Each replaces every occurrence on the selected pages, in order; later operations see the result of earlier ones (so 'foo'->'foos' then 'foos'->'bars' turns 'foo' into 'bars').
+ */
+ edits: EditTextOperation[];
+ /**
+ * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')
+ */
+ pageNumbers?: string;
+ /**
+ * Whether matches must be whole words (boundaries determined by non-word characters)
+ */
+ wholeWordSearch?: boolean;
+}
+/**
+ * Ordered list of find/replace operations. Each replaces every occurrence on the selected pages, in order; later operations see the result of earlier ones (so 'foo'->'foos' then 'foos'->'bars' turns 'foo' into 'bars').
+ */
+export interface EditTextOperation {
+ /**
+ * The literal text to find.
+ */
+ find: string;
+ /**
+ * The replacement text. May be empty to delete the matched text.
+ */
+ replace: string;
+}
+export interface EmlToPdfRequest {
+ /**
+ * Download HTML intermediate file instead of PDF
+ */
+ downloadHtml?: boolean;
+ /**
+ * Include CC and BCC recipients in header (if available)
+ */
+ includeAllRecipients?: boolean;
+ /**
+ * Include email attachments in the PDF output
+ */
+ includeAttachments?: boolean;
+ /**
+ * Maximum attachment size in MB to include (default 10MB, range: 1-100)
+ */
+ maxAttachmentSizeMB?: number;
+}
+export type ExtractAttachmentsRequest = Record;
+export interface ExtractHeaderRequest {
+ /**
+ * Flag indicating whether to use the first text as a fallback if no suitable title is found. Defaults to false.
+ */
+ useFirstTextAsFallback?: boolean;
+}
+export interface ExtractImageScansRequest {
+ /**
+ * The angle threshold for the image scan extraction
+ */
+ angleThreshold?: number;
+ /**
+ * The border size for the image scan extraction
+ */
+ borderSize?: number;
+ /**
+ * The minimum area for the image scan extraction
+ */
+ minArea?: number;
+ /**
+ * The minimum contour area for the image scan extraction
+ */
+ minContourArea?: number;
+ /**
+ * The tolerance for the image scan extraction
+ */
+ tolerance?: number;
+}
+export interface FlattenRequest {
+ /**
+ * True to flatten only the forms, false to flatten full PDF (Convert page to image)
+ */
+ flattenOnlyForms?: boolean;
+ /**
+ * Optional DPI for page rendering when flattening the full document.
+ */
+ renderDpi?: number;
+}
+export interface GeneralExtractBookmarksRequest {
+ file: string;
+}
+export type GeneralFile = Record;
+export type GeneralPdfToSinglePageRequest = Record;
+export type GeneralRemoveImagePdfRequest = Record;
+export interface HTMLToPdfRequest {
+ /**
+ * Zoom level for displaying the website. Default is '1'.
+ */
+ zoom?: number;
+}
+export type ListAttachmentsRequest = Record;
+export interface ManualRedactPdfRequest {
+ /**
+ * Convert the redacted PDF to an image
+ */
+ convertPDFToImage?: boolean;
+ /**
+ * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')
+ */
+ pageNumbers?: string;
+ /**
+ * The color used to fully redact certain pages
+ */
+ pageRedactionColor?: string;
+ /**
+ * A list of areas that should be redacted
+ */
+ redactions: RedactionArea[];
+}
+/**
+ * A list of areas that should be redacted
+ */
+export interface RedactionArea {
+ /**
+ * The color used to redact the specified area.
+ */
+ color?: string;
+ /**
+ * The height of the area to be redacted.
+ */
+ height?: number;
+ /**
+ * The page on which the area should be redacted.
+ */
+ page?: number;
+ /**
+ * The width of the area to be redacted.
+ */
+ width?: number;
+ /**
+ * The left edge point of the area to be redacted.
+ */
+ x?: number;
+ /**
+ * The top edge point of the area to be redacted.
+ */
+ y?: number;
+}
+export interface MergeMultiplePagesRequest {
+ /**
+ * Boolean for if you wish to add border around the pages
+ */
+ addBorder?: boolean;
+ /**
+ * The arrangement of pages on the sheet: BY_ROWS fills pages row by row, while BY_COLUMNS fills pages column by column.
+ */
+ arrangement?: "BY_ROWS" | "BY_COLUMNS";
+ /**
+ * Border width (in points) to apply around each page when merging
+ */
+ borderWidth?: number;
+ /**
+ * Bottom margin (in points) to apply to the output pages when merging
+ */
+ bottomMargin?: number;
+ /**
+ * Number of columns
+ */
+ cols?: number;
+ /**
+ * Inner margin (in points) to apply around each page when merging
+ */
+ innerMargin?: number;
+ /**
+ * Left margin (in points) to apply to the output pages when merging
+ */
+ leftMargin?: number;
+ /**
+ * Input mode: DEFAULT uses pagesPerSheet; CUSTOM uses explicit cols x rows.
+ */
+ mode?: "DEFAULT" | "CUSTOM";
+ /**
+ * The orientation of the output PDF pages
+ */
+ orientation?: "PORTRAIT" | "LANDSCAPE";
+ /**
+ * The number of pages to fit onto a single sheet in the output PDF.
+ */
+ pagesPerSheet?: 2 | 4 | 9 | 16;
+ /**
+ * The direction in which pages are arranged on the sheet: LTR (left-to-right) or RTL (right-to-left).
+ */
+ readingDirection?: "LTR" | "RTL";
+ /**
+ * Right margin (in points) to apply to the output pages when merging
+ */
+ rightMargin?: number;
+ /**
+ * Number of rows
+ */
+ rows?: number;
+ /**
+ * Top margin (in points) to apply to the output pages when merging
+ */
+ topMargin?: number;
+}
+export interface MergePdfsRequest {
+ /**
+ * JSON array of client-provided IDs for each uploaded file (same order as fileInput)
+ */
+ clientFileIds?: string;
+ fileOrder?: string;
+ /**
+ * Flag indicating whether to generate a table of contents for the merged PDF. If true, a table of contents will be created using the input filenames as chapter names.
+ */
+ generateToc?: boolean;
+ /**
+ * Flag indicating whether to remove certification signatures from the merged PDF. If true, all certification signatures will be removed from the final merged document.
+ */
+ removeCertSign?: boolean;
+ /**
+ * The type of sorting to be applied on the input files before merging.
+ */
+ sortType?:
+ | "orderProvided"
+ | "byFileName"
+ | "byDateModified"
+ | "byDateCreated"
+ | "byPDFTitle";
+}
+export interface MetadataRequest {
+ /**
+ * Map list of key and value of custom parameters. Note these must start with customKey and customValue if they are non-standard
+ */
+ allRequestParams?: {
+ /**
+ * Map list of key and value of custom parameters. Note these must start with customKey and customValue if they are non-standard
+ */
+ [k: string]: string | undefined;
+ };
+ /**
+ * The author of the document
+ */
+ author?: string;
+ /**
+ * The creation date of the document (format: yyyy/MM/dd HH:mm:ss)
+ */
+ creationDate?: string;
+ /**
+ * The creator of the document
+ */
+ creator?: string;
+ /**
+ * Delete all metadata if set to true
+ */
+ deleteAll?: boolean;
+ /**
+ * The keywords for the document
+ */
+ keywords?: string;
+ /**
+ * The modification date of the document (format: yyyy/MM/dd HH:mm:ss)
+ */
+ modificationDate?: string;
+ /**
+ * The producer of the document
+ */
+ producer?: string;
+ /**
+ * The subject of the document
+ */
+ subject?: string;
+ /**
+ * The title of the document
+ */
+ title?: string;
+ /**
+ * The trapped status of the document
+ */
+ trapped?: "True" | "False" | "Unknown";
+}
+export type MiscDecompressPdfRequest = Record;
+export type MiscRepairRequest = Record;
+export type MiscShowJavascriptRequest = Record;
+export type MiscUnlockPdfFormsRequest = Record;
+export interface OptimizePdfRequest {
+ /**
+ * The expected output size, e.g. '100MB', '25KB', etc.
+ */
+ expectedOutputSize?: string;
+ /**
+ * Whether to convert the PDF to grayscale. Default is false.
+ */
+ grayscale?: boolean;
+ /**
+ * Whether to convert images to high-contrast line art using ImageMagick. Default is false.
+ */
+ lineArt?: boolean;
+ /**
+ * Edge detection strength to use for line art conversion (1-3). This maps to ImageMagick's -edge radius.
+ */
+ lineArtEdgeLevel?: 1 | 2 | 3;
+ /**
+ * Threshold to use for line art conversion (0-100).
+ */
+ lineArtThreshold?: number;
+ /**
+ * Whether to linearize the PDF for faster web viewing. Default is false.
+ */
+ linearize?: boolean;
+ /**
+ * Whether to normalize the PDF content for better compatibility. Default is false.
+ */
+ normalize?: boolean;
+ /**
+ * The level of optimization to apply to the PDF file. Higher values indicate greater compression but may reduce quality.
+ */
+ optimizeLevel: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
+}
+export interface OverlayImageRequest {
+ /**
+ * Whether to overlay the image onto every page of the PDF.
+ */
+ everyPage?: boolean;
+ imageFile: string;
+ /**
+ * The x-coordinate at which to place the top-left corner of the image.
+ */
+ x?: number;
+ /**
+ * The y-coordinate at which to place the top-left corner of the image.
+ */
+ y?: number;
+}
+export interface OverlayPdfsRequest {
+ /**
+ * An array of integers specifying the number of times each corresponding overlay file should be applied in the 'FixedRepeatOverlay' mode. This should match the length of the overlayFiles array.
+ */
+ counts?: number[];
+ /**
+ * An array of PDF files to be used as overlays on the base PDF. The order in these files is applied based on the selected mode.
+ */
+ overlayFiles: string[];
+ /**
+ * The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts
+ */
+ overlayMode:
+ | "SequentialOverlay"
+ | "InterleavedOverlay"
+ | "FixedRepeatOverlay";
+ /**
+ * Overlay position 0 is Foregound, 1 is Background
+ */
+ overlayPosition: 0 | 1;
+}
+export interface PDFExtractImagesRequest {
+ /**
+ * The output image format e.g., 'png', 'jpeg', or 'gif'
+ */
+ format?: "png" | "jpeg" | "gif";
+}
+export interface PDFPasswordRequest {
+ /**
+ * The password of the PDF file
+ */
+ password?: string;
+}
+export type PDFVerificationRequest = Record;
+export interface PDFWithPageNums {
+ /**
+ * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')
+ */
+ pageNumbers?: string;
+}
+export interface PdfToPdfARequest {
+ /**
+ * The output format type (PDF/A or PDF/X)
+ */
+ outputFormat:
+ | "pdfa"
+ | "pdfa-1"
+ | "pdfa-2"
+ | "pdfa-2b"
+ | "pdfa-3"
+ | "pdfa-3b"
+ | "pdfx";
+ /**
+ * If true, the conversion will fail if the output is not perfectly compliant
+ */
+ strict?: boolean;
+}
+export interface PdfToPresentationRequest {
+ /**
+ * The output Presentation format
+ */
+ outputFormat: "ppt" | "pptx" | "odp";
+}
+export interface PdfToTextOrRTFRequest {
+ /**
+ * The output Text or RTF format
+ */
+ outputFormat: "rtf" | "txt";
+}
+export interface PdfToWordRequest {
+ /**
+ * The output Word document format
+ */
+ outputFormat: "doc" | "docx" | "odt";
+}
+export interface PdfVectorExportRequest {
+ /**
+ * Target vector format extension
+ */
+ outputFormat?: "eps" | "ps" | "pcl" | "xps";
+ /**
+ * Apply Ghostscript prepress settings
+ */
+ prepress?: true | false;
+}
+export interface Pkcs11CertificatesRequest {
+ libraryPath?: string;
+ pin?: string;
+ slot?: number;
+}
+export interface PosterPdfRequest {
+ /**
+ * Target page size for output chunks (e.g., 'A4', 'Letter', 'A3')
+ */
+ pageSize: "A4" | "Letter" | "A3" | "A5" | "Legal" | "Tabloid";
+ /**
+ * Split right-to-left instead of left-to-right
+ */
+ rightToLeft?: boolean;
+ /**
+ * Horizontal decimation factor (how many columns to split into)
+ */
+ xFactor?: number;
+ /**
+ * Vertical decimation factor (how many rows to split into)
+ */
+ yFactor?: number;
+}
+export interface ProcessPdfWithOcrRequest {
+ /**
+ * Clean the input file if set to true
+ */
+ clean?: boolean;
+ /**
+ * Clean the final output if set to true
+ */
+ cleanFinal?: boolean;
+ /**
+ * Deskew the input file if set to true
+ */
+ deskew?: boolean;
+ /**
+ * List of languages to use in OCR processing, e.g., 'eng', 'deu'
+ */
+ languages?: string[];
+ /**
+ * Specify the OCR render type, either 'hocr' or 'sandwich'
+ */
+ ocrRenderType?: "hocr" | "sandwich";
+ /**
+ * Specify the OCR type, e.g., 'skip-text', 'force-ocr', or 'Normal'
+ */
+ ocrType: "skip-text" | "force-ocr" | "Normal";
+ /**
+ * Remove images from the output PDF if set to true
+ */
+ removeImagesAfter?: boolean;
+ /**
+ * Include OCR text in a sidecar text file if set to true
+ */
+ sidecar?: boolean;
+}
+export interface RearrangePagesRequest {
+ /**
+ * The custom mode for page rearrangement. Valid values are:
+ * CUSTOM: Uses order defined in PageNums DUPLICATE: Duplicate pages n times (if Page order defined as 4, then duplicates each page 4 times)REVERSE_ORDER: Reverses the order of all pages.
+ * DUPLEX_SORT: Sorts pages as if all fronts were scanned then all backs in reverse (1, n, 2, n-1, ...). BOOKLET_SORT: Arranges pages for booklet printing (last, first, second, second last, ...).
+ * ODD_EVEN_SPLIT: Splits and arranges pages into odd and even numbered pages.
+ * REMOVE_FIRST: Removes the first page.
+ * REMOVE_LAST: Removes the last page.
+ * REMOVE_FIRST_AND_LAST: Removes both the first and the last pages.
+ *
+ */
+ customMode?:
+ | "CUSTOM"
+ | "REVERSE_ORDER"
+ | "DUPLEX_SORT"
+ | "BOOKLET_SORT"
+ | "SIDE_STITCH_BOOKLET_SORT"
+ | "ODD_EVEN_SPLIT"
+ | "REMOVE_FIRST"
+ | "REMOVE_LAST"
+ | "REMOVE_FIRST_AND_LAST"
+ | "DUPLICATE";
+ /**
+ * The pages to select, Supports ranges (e.g., '1,3,5-9'), or 'all' or functions in the format 'an+b' where 'a' is the multiplier of the page number 'n', and 'b' is a constant (e.g., '2n+1', '3n', '6n-5')
+ */
+ pageNumbers?: string;
+}
+export interface RedactExecuteRequest {
+ /**
+ * Rectangular areas to black out, each defined by a page number and bounding box coordinates.
+ */
+ imageBoxes?: ImageBox[];
+ /**
+ * Text ranges to redact by specifying a start and end anchor phrase. All content between the two phrases (inclusive) is redacted. Anchors work best when short and unique. They must appear verbatim in the document.
+ */
+ ranges?: TextRange[];
+ /**
+ * 1-indexed page numbers to redact all detected images from. Pass an empty list to redact images from every page. Omit or pass null to skip image redaction entirely.
+ */
+ redactImagePages?: number[];
+ /**
+ * Regex patterns to match and redact. Each match anywhere in the document is blacked out. Uses Java/PCRE regex syntax. Well-suited for strings that follow known patterns, like phone numbers, email addresses, national ID numbers, or dates (which can appear with different separators, optional country codes, etc.). For fixed known strings such as names, use textValues instead.
+ */
+ regexPatterns?: string[];
+ style?: RedactStyle;
+ /**
+ * Exact strings to find and black out. One entry per phrase to redact. Best for known names, identifiers, and specific text found in the document.
+ */
+ textValues?: string[];
+ /**
+ * 1-indexed page numbers to wipe entirely (all content removed from those pages).
+ */
+ wipePages?: number[];
+}
+/**
+ * Rectangular areas to black out, each defined by a page number and bounding box coordinates.
+ */
+export interface ImageBox {
+ /**
+ * 0-indexed page number (first page = 0).
+ */
+ pageIndex: number;
+ /**
+ * Left x coordinate of the redaction rectangle in PDF user-space points.
+ */
+ x1: number;
+ /**
+ * Right x coordinate of the redaction rectangle in PDF user-space points.
+ */
+ x2: number;
+ /**
+ * Top y coordinate of the redaction rectangle in PDF user-space points.
+ */
+ y1: number;
+ /**
+ * Bottom y coordinate of the redaction rectangle in PDF user-space points.
+ */
+ y2: number;
+}
+/**
+ * Text ranges to redact by specifying a start and end anchor phrase. All content between the two phrases (inclusive) is redacted. Anchors work best when short and unique. They must appear verbatim in the document.
+ */
+export interface TextRange {
+ /**
+ * A short, distinctive phrase (5–15 words) that marks where redaction ends (inclusive). Must appear verbatim in the document. Shorter phrases match more reliably.
+ */
+ endString: string;
+ /**
+ * A short, distinctive phrase (5–15 words) that marks where redaction begins (inclusive). Must appear verbatim in the document — e.g. a section heading or a unique sentence fragment.
+ */
+ startString: string;
+}
+/**
+ * Redaction style options
+ */
+export interface RedactStyle {
+ /**
+ * Hex redaction box color
+ */
+ color?: string;
+ /**
+ * Rasterize output to prevent text extraction
+ */
+ convertToImage?: boolean;
+ /**
+ * Extra padding around each box in points
+ */
+ padding?: number;
+ /**
+ * Execution strategy hint for the redaction pipeline
+ */
+ strategy?: "AUTO" | "OVERLAY_ONLY" | "IMAGE_FINALIZE";
+}
+export interface RedactPdfRequest {
+ /**
+ * Convert the redacted PDF to an image
+ */
+ convertPDFToImage?: boolean;
+ /**
+ * Custom padding for redaction
+ */
+ customPadding: number;
+ /**
+ * List of text to redact from the PDF
+ */
+ listOfText?: string;
+ /**
+ * The color for redaction
+ */
+ redactColor?: string;
+ /**
+ * Whether to use regex for the listOfText
+ */
+ useRegex?: boolean;
+ /**
+ * Whether to use whole word search
+ */
+ wholeWordSearch?: boolean;
+}
+export interface RemoveBlankPagesRequest {
+ /**
+ * The threshold value to determine blank pages
+ */
+ threshold?: number;
+ /**
+ * The percentage of white color on a page to consider it as blank
+ */
+ whitePercent?: number;
+}
+export interface RenameAttachmentRequest {
+ /**
+ * The current name of the attachment to rename
+ */
+ attachmentName: string;
+ /**
+ * The new name for the attachment
+ */
+ newName: string;
+}
+export interface ReplaceAndInvertColorRequest {
+ /**
+ * If CUSTOM_COLOR option selected, then pick the custom color for background. Expected color value should be 24bit decimal value of a color
+ */
+ backGroundColor?: string;
+ /**
+ * If HIGH_CONTRAST_COLOR option selected, then pick the default color option for text and background.
+ */
+ highContrastColorCombination?:
+ | "WHITE_TEXT_ON_BLACK"
+ | "BLACK_TEXT_ON_WHITE"
+ | "YELLOW_TEXT_ON_BLACK"
+ | "GREEN_TEXT_ON_BLACK";
+ /**
+ * Replace and Invert color options of a pdf.
+ */
+ replaceAndInvertOption?:
+ | "HIGH_CONTRAST_COLOR"
+ | "CUSTOM_COLOR"
+ | "FULL_INVERSION"
+ | "COLOR_SPACE_CONVERSION";
+ /**
+ * If CUSTOM_COLOR option selected, then pick the custom color for text. Expected color value should be 24bit decimal value of a color
+ */
+ textColor?: string;
+}
+export interface RotatePDFRequest {
+ /**
+ * The clockwise angle by which to rotate all pages in the PDF file. Must be a multiple of 90.
+ */
+ angle: 0 | 90 | 180 | 270;
+}
+export interface SanitizePdfRequest {
+ /**
+ * Remove embedded files from the PDF
+ */
+ removeEmbeddedFiles?: boolean;
+ /**
+ * Remove fonts from the PDF
+ */
+ removeFonts?: boolean;
+ /**
+ * Remove JavaScript actions from the PDF
+ */
+ removeJavaScript?: boolean;
+ /**
+ * Remove links from the PDF
+ */
+ removeLinks?: boolean;
+ /**
+ * Remove document info metadata from the PDF
+ */
+ removeMetadata?: boolean;
+ /**
+ * Remove XMP metadata from the PDF
+ */
+ removeXMPMetadata?: boolean;
+}
+export interface ScalePagesRequest {
+ /**
+ * Orientation to apply to the target page size. Ignored when pageSize is KEEP.
+ */
+ orientation?: "PORTRAIT" | "LANDSCAPE";
+ /**
+ * The scale of pages in the output PDF. Acceptable values are A0-A6, LETTER, LEGAL, KEEP.
+ */
+ pageSize:
+ | "A0"
+ | "A1"
+ | "A2"
+ | "A3"
+ | "A4"
+ | "A5"
+ | "A6"
+ | "LETTER"
+ | "LEGAL"
+ | "KEEP";
+ /**
+ * The scale of the content on the pages of the output PDF. Acceptable values are floats.
+ */
+ scaleFactor?: number;
+}
+export interface ScannerEffectRequest {
+ /**
+ * Whether advanced settings are enabled
+ */
+ advancedEnabled?: boolean;
+ /**
+ * Blur amount (0 = none, higher = more blur)
+ */
+ blur?: number;
+ /**
+ * Border thickness in pixels
+ */
+ border?: number;
+ /**
+ * Brightness multiplier (1.0 = no change)
+ */
+ brightness?: number;
+ /**
+ * Colorspace for output image
+ */
+ colorspace?: "grayscale" | "color";
+ /**
+ * Contrast multiplier (1.0 = no change)
+ */
+ contrast?: number;
+ /**
+ * Noise amount (0 = none, higher = more noise)
+ */
+ noise?: number;
+ /**
+ * Scan quality preset
+ */
+ quality: "low" | "medium" | "high";
+ /**
+ * Rendering resolution in DPI
+ */
+ resolution?: number;
+ /**
+ * Base rotation in degrees
+ */
+ rotate?: number;
+ /**
+ * Random rotation variance in degrees
+ */
+ rotateVariance?: number;
+ /**
+ * Rotation preset
+ */
+ rotation: "none" | "slight" | "moderate" | "severe";
+ rotationValue?: number;
+ /**
+ * Simulate yellowed paper
+ */
+ yellowish?: boolean;
+}
+export interface SecurityCertSignSessionsRequest {
+ file: string;
+ request?: WorkflowCreationRequest;
+}
+export interface WorkflowCreationRequest {
+ documentName?: string;
+ dueDate?: string;
+ message?: string;
+ ownerEmail?: string;
+ participantEmails?: string[];
+ participantUserIds?: number[];
+ workflowMetadata?: string;
+ workflowType?: "SIGNING" | "REVIEW" | "APPROVAL";
+}
+export interface SecurityCertSignValidateCertificateRequest {
+ certType: string;
+ jksFile?: string;
+ p12File?: string;
+ password?: string;
+}
+export type SecurityGetInfoOnPdfRequest = Record;
+export type SecurityRemoveCertSignRequest = Record;
+export interface SignPDFWithCertRequest {
+ /**
+ * The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates.
+ */
+ alias?: string;
+ certFile?: string;
+ /**
+ * The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.
+ */
+ certType:
+ | "PEM"
+ | "PKCS12"
+ | "PFX"
+ | "JKS"
+ | "SERVER"
+ | "WINDOWS_STORE"
+ | "PKCS11";
+ jksFile?: string;
+ /**
+ * The location where the PDF is signed
+ */
+ location?: string;
+ /**
+ * The name of the signer
+ */
+ name?: string;
+ p12File?: string;
+ /**
+ * The page number where the signature should be visible. This is required if showSignature is set to true
+ */
+ pageNumber?: number;
+ /**
+ * The password for the keystore / private key, or the token PIN for PKCS11
+ */
+ password?: string;
+ /**
+ * Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must be an allowed driver - a detected one or configured via STIRLING_PKCS11_LIBRARIES.
+ */
+ pkcs11LibraryPath?: string;
+ /**
+ * Optional PKCS#11 slot index. When omitted the first slot with a token is used.
+ */
+ pkcs11Slot?: number;
+ privateKeyFile?: string;
+ /**
+ * The reason for signing the PDF
+ */
+ reason?: string;
+ /**
+ * Whether to visually show a signature logo along with the signature
+ */
+ showLogo?: boolean;
+ /**
+ * Whether to visually show the signature in the PDF file
+ */
+ showSignature?: boolean;
+}
+export interface SignatureValidationRequest {
+ certFile?: string;
+}
+export interface SplitPagesRequest {
+ /**
+ * Split points - page numbers after which the PDF will be cut. For example, `"2"` produces two documents (pages 1-2 and pages 3+); `"2,5"` produces three (pages 1-2, 3-5, 6+). Supports ranges (e.g. `"1,3,5-9"` splits after pages 1, 3, 5, 6, 7, 8, 9, yielding 8 documents), `"all"` (split after every page), or functions like `"2n+1"`, `"3n"`, `"6n-5"`.
+ */
+ pageNumbers?: string;
+}
+export interface SplitPdfByChaptersRequest {
+ /**
+ * Whether to allow duplicates or not
+ */
+ allowDuplicates?: boolean;
+ /**
+ * Maximum bookmark level required
+ */
+ bookmarkLevel?: number;
+ /**
+ * Whether to include Metadata or not
+ */
+ includeMetadata?: boolean;
+}
+export interface SplitPdfBySectionsRequest {
+ /**
+ * Number of horizontal divisions for each PDF page
+ */
+ horizontalDivisions?: number;
+ /**
+ * Merge the split documents into a single PDF
+ */
+ merge?: boolean;
+ /**
+ * Pages to be split by section
+ */
+ pageNumbers?: string;
+ /**
+ * Modes for page split. Valid values are:
+ * SPLIT_ALL_EXCEPT_FIRST_AND_LAST: Splits all except the first and the last pages.
+ * SPLIT_ALL_EXCEPT_FIRST: Splits all except the first page.
+ * SPLIT_ALL_EXCEPT_LAST: Splits all except the last page.
+ * SPLIT_ALL: Splits all pages.
+ * CUSTOM: Custom split.
+ *
+ */
+ splitMode?:
+ | "CUSTOM"
+ | "SPLIT_ALL_EXCEPT_FIRST_AND_LAST"
+ | "SPLIT_ALL_EXCEPT_FIRST"
+ | "SPLIT_ALL_EXCEPT_LAST"
+ | "SPLIT_ALL";
+ /**
+ * Number of vertical divisions for each PDF page
+ */
+ verticalDivisions?: number;
+}
+export interface SplitPdfBySizeOrCountRequest {
+ /**
+ * Determines the type of split: 0 for size, 1 for page count, 2 for document count
+ */
+ splitType?: number;
+ /**
+ * Value for split: size in MB (e.g., '10MB') or number of pages (e.g., '5')
+ */
+ splitValue?: string;
+}
+export interface SvgToPdfRequest {
+ /**
+ * Whether to combine all SVG files into a single PDF (each SVG as a separate page) or create separate PDF files for each SVG.
+ */
+ combineIntoSinglePdf?: boolean;
+}
+export interface TimestampPdfRequest {
+ /**
+ * URL of the RFC 3161 Time Stamp Authority (TSA) server. Must be one of the built-in presets (DigiCert, Sectigo, SSL.com, FreeTSA, MeSign) or an admin-configured URL in settings.yml (security.timestamp.customTsaUrls). If omitted, the server default is used.
+ */
+ tsaUrl?: string;
+}
+export interface UrlToPdfRequest {
+ /**
+ * The input URL to be converted to a PDF file
+ */
+ urlInput: string;
+}
+
+/** Endpoint path for a generated tool operation (the operation identity across languages). */
+export type ToolEndpoint =
+ | "/api/v1/convert/cbr/pdf"
+ | "/api/v1/convert/cbz/pdf"
+ | "/api/v1/convert/ebook/pdf"
+ | "/api/v1/convert/eml/pdf"
+ | "/api/v1/convert/file/pdf"
+ | "/api/v1/convert/html/pdf"
+ | "/api/v1/convert/img/pdf"
+ | "/api/v1/convert/markdown/pdf"
+ | "/api/v1/convert/pdf/cbr"
+ | "/api/v1/convert/pdf/cbz"
+ | "/api/v1/convert/pdf/csv"
+ | "/api/v1/convert/pdf/epub"
+ | "/api/v1/convert/pdf/html"
+ | "/api/v1/convert/pdf/img"
+ | "/api/v1/convert/pdf/markdown"
+ | "/api/v1/convert/pdf/pdfa"
+ | "/api/v1/convert/pdf/presentation"
+ | "/api/v1/convert/pdf/text"
+ | "/api/v1/convert/pdf/text-editor"
+ | "/api/v1/convert/pdf/text-editor/metadata"
+ | "/api/v1/convert/pdf/vector"
+ | "/api/v1/convert/pdf/word"
+ | "/api/v1/convert/pdf/xlsx"
+ | "/api/v1/convert/pdf/xml"
+ | "/api/v1/convert/svg/pdf"
+ | "/api/v1/convert/text-editor/pdf"
+ | "/api/v1/convert/url/pdf"
+ | "/api/v1/convert/vector/pdf"
+ | "/api/v1/general/booklet-imposition"
+ | "/api/v1/general/crop"
+ | "/api/v1/general/edit-table-of-contents"
+ | "/api/v1/general/edit-text"
+ | "/api/v1/general/extract-bookmarks"
+ | "/api/v1/general/merge-pdfs"
+ | "/api/v1/general/multi-page-layout"
+ | "/api/v1/general/overlay-pdfs"
+ | "/api/v1/general/pdf-to-single-page"
+ | "/api/v1/general/rearrange-pages"
+ | "/api/v1/general/remove-image-pdf"
+ | "/api/v1/general/remove-pages"
+ | "/api/v1/general/rotate-pdf"
+ | "/api/v1/general/scale-pages"
+ | "/api/v1/general/split-by-size-or-count"
+ | "/api/v1/general/split-for-poster-print"
+ | "/api/v1/general/split-pages"
+ | "/api/v1/general/split-pdf-by-chapters"
+ | "/api/v1/general/split-pdf-by-sections"
+ | "/api/v1/misc/add-attachments"
+ | "/api/v1/misc/add-comments"
+ | "/api/v1/misc/add-image"
+ | "/api/v1/misc/add-page-numbers"
+ | "/api/v1/misc/add-stamp"
+ | "/api/v1/misc/auto-rename"
+ | "/api/v1/misc/auto-split-pdf"
+ | "/api/v1/misc/compress-pdf"
+ | "/api/v1/misc/decompress-pdf"
+ | "/api/v1/misc/delete-attachment"
+ | "/api/v1/misc/extract-attachments"
+ | "/api/v1/misc/extract-image-scans"
+ | "/api/v1/misc/extract-images"
+ | "/api/v1/misc/flatten"
+ | "/api/v1/misc/list-attachments"
+ | "/api/v1/misc/ocr-pdf"
+ | "/api/v1/misc/remove-blanks"
+ | "/api/v1/misc/rename-attachment"
+ | "/api/v1/misc/repair"
+ | "/api/v1/misc/replace-invert-pdf"
+ | "/api/v1/misc/scanner-effect"
+ | "/api/v1/misc/show-javascript"
+ | "/api/v1/misc/unlock-pdf-forms"
+ | "/api/v1/misc/update-metadata"
+ | "/api/v1/security/add-password"
+ | "/api/v1/security/add-watermark"
+ | "/api/v1/security/auto-redact"
+ | "/api/v1/security/cert-sign"
+ | "/api/v1/security/cert-sign/hardware/pkcs11-certificates"
+ | "/api/v1/security/cert-sign/sessions"
+ | "/api/v1/security/cert-sign/validate-certificate"
+ | "/api/v1/security/get-info-on-pdf"
+ | "/api/v1/security/redact"
+ | "/api/v1/security/redact-execute"
+ | "/api/v1/security/remove-cert-sign"
+ | "/api/v1/security/remove-password"
+ | "/api/v1/security/sanitize-pdf"
+ | "/api/v1/security/timestamp-pdf"
+ | "/api/v1/security/validate-signature"
+ | "/api/v1/security/verify-pdf";
+
+/** Backend request-parameter model for each tool endpoint. */
+export interface ToolApiParams {
+ "/api/v1/convert/cbr/pdf": ConvertCbrToPdfRequest;
+ "/api/v1/convert/cbz/pdf": ConvertCbzToPdfRequest;
+ "/api/v1/convert/ebook/pdf": ConvertEbookToPdfRequest;
+ "/api/v1/convert/eml/pdf": EmlToPdfRequest;
+ "/api/v1/convert/file/pdf": GeneralFile;
+ "/api/v1/convert/html/pdf": HTMLToPdfRequest;
+ "/api/v1/convert/img/pdf": ConvertToPdfRequest;
+ "/api/v1/convert/markdown/pdf": GeneralFile;
+ "/api/v1/convert/pdf/cbr": ConvertPdfToCbrRequest;
+ "/api/v1/convert/pdf/cbz": ConvertPdfToCbzRequest;
+ "/api/v1/convert/pdf/csv": PDFWithPageNums;
+ "/api/v1/convert/pdf/epub": ConvertPdfToEpubRequest;
+ "/api/v1/convert/pdf/html": ConvertPdfHtmlRequest;
+ "/api/v1/convert/pdf/img": ConvertToImageRequest;
+ "/api/v1/convert/pdf/markdown": ConvertPdfMarkdownRequest;
+ "/api/v1/convert/pdf/pdfa": PdfToPdfARequest;
+ "/api/v1/convert/pdf/presentation": PdfToPresentationRequest;
+ "/api/v1/convert/pdf/text": PdfToTextOrRTFRequest;
+ "/api/v1/convert/pdf/text-editor": ConvertPdfTextEditorRequest;
+ "/api/v1/convert/pdf/text-editor/metadata": ConvertPdfTextEditorMetadataRequest;
+ "/api/v1/convert/pdf/vector": PdfVectorExportRequest;
+ "/api/v1/convert/pdf/word": PdfToWordRequest;
+ "/api/v1/convert/pdf/xlsx": PDFWithPageNums;
+ "/api/v1/convert/pdf/xml": ConvertPdfXmlRequest;
+ "/api/v1/convert/svg/pdf": SvgToPdfRequest;
+ "/api/v1/convert/text-editor/pdf": GeneralFile;
+ "/api/v1/convert/url/pdf": UrlToPdfRequest;
+ "/api/v1/convert/vector/pdf": PdfVectorExportRequest;
+ "/api/v1/general/booklet-imposition": BookletImpositionRequest;
+ "/api/v1/general/crop": CropPdfForm;
+ "/api/v1/general/edit-table-of-contents": EditTableOfContentsRequest;
+ "/api/v1/general/edit-text": EditTextRequest;
+ "/api/v1/general/extract-bookmarks": GeneralExtractBookmarksRequest;
+ "/api/v1/general/merge-pdfs": MergePdfsRequest;
+ "/api/v1/general/multi-page-layout": MergeMultiplePagesRequest;
+ "/api/v1/general/overlay-pdfs": OverlayPdfsRequest;
+ "/api/v1/general/pdf-to-single-page": GeneralPdfToSinglePageRequest;
+ "/api/v1/general/rearrange-pages": RearrangePagesRequest;
+ "/api/v1/general/remove-image-pdf": GeneralRemoveImagePdfRequest;
+ "/api/v1/general/remove-pages": PDFWithPageNums;
+ "/api/v1/general/rotate-pdf": RotatePDFRequest;
+ "/api/v1/general/scale-pages": ScalePagesRequest;
+ "/api/v1/general/split-by-size-or-count": SplitPdfBySizeOrCountRequest;
+ "/api/v1/general/split-for-poster-print": PosterPdfRequest;
+ "/api/v1/general/split-pages": SplitPagesRequest;
+ "/api/v1/general/split-pdf-by-chapters": SplitPdfByChaptersRequest;
+ "/api/v1/general/split-pdf-by-sections": SplitPdfBySectionsRequest;
+ "/api/v1/misc/add-attachments": AddAttachmentRequest;
+ "/api/v1/misc/add-comments": AddCommentsRequest;
+ "/api/v1/misc/add-image": OverlayImageRequest;
+ "/api/v1/misc/add-page-numbers": AddPageNumbersRequest;
+ "/api/v1/misc/add-stamp": AddStampRequest;
+ "/api/v1/misc/auto-rename": ExtractHeaderRequest;
+ "/api/v1/misc/auto-split-pdf": AutoSplitPdfRequest;
+ "/api/v1/misc/compress-pdf": OptimizePdfRequest;
+ "/api/v1/misc/decompress-pdf": MiscDecompressPdfRequest;
+ "/api/v1/misc/delete-attachment": DeleteAttachmentRequest;
+ "/api/v1/misc/extract-attachments": ExtractAttachmentsRequest;
+ "/api/v1/misc/extract-image-scans": ExtractImageScansRequest;
+ "/api/v1/misc/extract-images": PDFExtractImagesRequest;
+ "/api/v1/misc/flatten": FlattenRequest;
+ "/api/v1/misc/list-attachments": ListAttachmentsRequest;
+ "/api/v1/misc/ocr-pdf": ProcessPdfWithOcrRequest;
+ "/api/v1/misc/remove-blanks": RemoveBlankPagesRequest;
+ "/api/v1/misc/rename-attachment": RenameAttachmentRequest;
+ "/api/v1/misc/repair": MiscRepairRequest;
+ "/api/v1/misc/replace-invert-pdf": ReplaceAndInvertColorRequest;
+ "/api/v1/misc/scanner-effect": ScannerEffectRequest;
+ "/api/v1/misc/show-javascript": MiscShowJavascriptRequest;
+ "/api/v1/misc/unlock-pdf-forms": MiscUnlockPdfFormsRequest;
+ "/api/v1/misc/update-metadata": MetadataRequest;
+ "/api/v1/security/add-password": AddPasswordRequest;
+ "/api/v1/security/add-watermark": AddWatermarkRequest;
+ "/api/v1/security/auto-redact": RedactPdfRequest;
+ "/api/v1/security/cert-sign": SignPDFWithCertRequest;
+ "/api/v1/security/cert-sign/hardware/pkcs11-certificates": Pkcs11CertificatesRequest;
+ "/api/v1/security/cert-sign/sessions": SecurityCertSignSessionsRequest;
+ "/api/v1/security/cert-sign/validate-certificate": SecurityCertSignValidateCertificateRequest;
+ "/api/v1/security/get-info-on-pdf": SecurityGetInfoOnPdfRequest;
+ "/api/v1/security/redact": ManualRedactPdfRequest;
+ "/api/v1/security/redact-execute": RedactExecuteRequest;
+ "/api/v1/security/remove-cert-sign": SecurityRemoveCertSignRequest;
+ "/api/v1/security/remove-password": PDFPasswordRequest;
+ "/api/v1/security/sanitize-pdf": SanitizePdfRequest;
+ "/api/v1/security/timestamp-pdf": TimestampPdfRequest;
+ "/api/v1/security/validate-signature": SignatureValidationRequest;
+ "/api/v1/security/verify-pdf": PDFVerificationRequest;
+}
+
+/** Every generated tool endpoint, for iteration. */
+export const TOOL_ENDPOINTS = [
+ "/api/v1/convert/cbr/pdf",
+ "/api/v1/convert/cbz/pdf",
+ "/api/v1/convert/ebook/pdf",
+ "/api/v1/convert/eml/pdf",
+ "/api/v1/convert/file/pdf",
+ "/api/v1/convert/html/pdf",
+ "/api/v1/convert/img/pdf",
+ "/api/v1/convert/markdown/pdf",
+ "/api/v1/convert/pdf/cbr",
+ "/api/v1/convert/pdf/cbz",
+ "/api/v1/convert/pdf/csv",
+ "/api/v1/convert/pdf/epub",
+ "/api/v1/convert/pdf/html",
+ "/api/v1/convert/pdf/img",
+ "/api/v1/convert/pdf/markdown",
+ "/api/v1/convert/pdf/pdfa",
+ "/api/v1/convert/pdf/presentation",
+ "/api/v1/convert/pdf/text",
+ "/api/v1/convert/pdf/text-editor",
+ "/api/v1/convert/pdf/text-editor/metadata",
+ "/api/v1/convert/pdf/vector",
+ "/api/v1/convert/pdf/word",
+ "/api/v1/convert/pdf/xlsx",
+ "/api/v1/convert/pdf/xml",
+ "/api/v1/convert/svg/pdf",
+ "/api/v1/convert/text-editor/pdf",
+ "/api/v1/convert/url/pdf",
+ "/api/v1/convert/vector/pdf",
+ "/api/v1/general/booklet-imposition",
+ "/api/v1/general/crop",
+ "/api/v1/general/edit-table-of-contents",
+ "/api/v1/general/edit-text",
+ "/api/v1/general/extract-bookmarks",
+ "/api/v1/general/merge-pdfs",
+ "/api/v1/general/multi-page-layout",
+ "/api/v1/general/overlay-pdfs",
+ "/api/v1/general/pdf-to-single-page",
+ "/api/v1/general/rearrange-pages",
+ "/api/v1/general/remove-image-pdf",
+ "/api/v1/general/remove-pages",
+ "/api/v1/general/rotate-pdf",
+ "/api/v1/general/scale-pages",
+ "/api/v1/general/split-by-size-or-count",
+ "/api/v1/general/split-for-poster-print",
+ "/api/v1/general/split-pages",
+ "/api/v1/general/split-pdf-by-chapters",
+ "/api/v1/general/split-pdf-by-sections",
+ "/api/v1/misc/add-attachments",
+ "/api/v1/misc/add-comments",
+ "/api/v1/misc/add-image",
+ "/api/v1/misc/add-page-numbers",
+ "/api/v1/misc/add-stamp",
+ "/api/v1/misc/auto-rename",
+ "/api/v1/misc/auto-split-pdf",
+ "/api/v1/misc/compress-pdf",
+ "/api/v1/misc/decompress-pdf",
+ "/api/v1/misc/delete-attachment",
+ "/api/v1/misc/extract-attachments",
+ "/api/v1/misc/extract-image-scans",
+ "/api/v1/misc/extract-images",
+ "/api/v1/misc/flatten",
+ "/api/v1/misc/list-attachments",
+ "/api/v1/misc/ocr-pdf",
+ "/api/v1/misc/remove-blanks",
+ "/api/v1/misc/rename-attachment",
+ "/api/v1/misc/repair",
+ "/api/v1/misc/replace-invert-pdf",
+ "/api/v1/misc/scanner-effect",
+ "/api/v1/misc/show-javascript",
+ "/api/v1/misc/unlock-pdf-forms",
+ "/api/v1/misc/update-metadata",
+ "/api/v1/security/add-password",
+ "/api/v1/security/add-watermark",
+ "/api/v1/security/auto-redact",
+ "/api/v1/security/cert-sign",
+ "/api/v1/security/cert-sign/hardware/pkcs11-certificates",
+ "/api/v1/security/cert-sign/sessions",
+ "/api/v1/security/cert-sign/validate-certificate",
+ "/api/v1/security/get-info-on-pdf",
+ "/api/v1/security/redact",
+ "/api/v1/security/redact-execute",
+ "/api/v1/security/remove-cert-sign",
+ "/api/v1/security/remove-password",
+ "/api/v1/security/sanitize-pdf",
+ "/api/v1/security/timestamp-pdf",
+ "/api/v1/security/validate-signature",
+ "/api/v1/security/verify-pdf",
+] as const satisfies readonly ToolEndpoint[];
+
+/** Union of every generated tool request model. */
+export type ToolApiRequest = ToolApiParams[ToolEndpoint];
diff --git a/frontend/editor/src/core/utils/automationConverter.test.ts b/frontend/editor/src/core/utils/automationConverter.test.ts
index 88e313c604..d61d61f08e 100644
--- a/frontend/editor/src/core/utils/automationConverter.test.ts
+++ b/frontend/editor/src/core/utils/automationConverter.test.ts
@@ -120,6 +120,35 @@ describe("automationConverter", () => {
const config = convertToFolderScanningConfig(automation, registry);
expect(config.pipeline[0].operation).toBe("unknownTool");
});
+
+ test("preserves frontend params on export, even for a tool with a toApiParams mapper", () => {
+ // The folder-scan export keeps frontend param shape; toApiParams runs at
+ // execution time, not here. A tool with a mapper still exports its UI
+ // field name (compressionLevel), not the backend one (optimizeLevel).
+ const withMapper = {
+ ...registry,
+ compress: {
+ operationConfig: {
+ endpoint: "/api/v1/misc/compress-pdf",
+ toApiParams: (p: Record) => ({
+ optimizeLevel: p.compressionLevel,
+ }),
+ },
+ },
+ } as unknown as Partial;
+ const automation: AutomationConfig = {
+ ...sampleAutomation,
+ operations: [
+ { operation: "compress", parameters: { compressionLevel: 9 } },
+ ],
+ };
+ const config = convertToFolderScanningConfig(automation, withMapper);
+ // The UI field name and value are preserved as-is (not optimizeLevel).
+ expect(config.pipeline[0].parameters).toEqual({
+ compressionLevel: 9,
+ fileInput: "automated",
+ });
+ });
});
describe("detectAutomationFormat", () => {
@@ -212,6 +241,72 @@ describe("automationConverter", () => {
});
});
+ test("round-trips frontend params losslessly, even for a tool with a mapper", () => {
+ // A value set in the UI survives an export then import unchanged. Because
+ // the export keeps frontend shape, a tool with a toApiParams mapper
+ // round-trips just like one without.
+ const withMapper = {
+ ...registry,
+ compress: {
+ operationConfig: {
+ endpoint: "/api/v1/misc/compress-pdf",
+ toApiParams: (p: Record) => ({
+ optimizeLevel: p.compressionLevel,
+ }),
+ },
+ },
+ } as unknown as Partial;
+ const automation: AutomationConfig = {
+ ...sampleAutomation,
+ operations: [
+ { operation: "compress", parameters: { compressionLevel: 9 } },
+ ],
+ };
+ const exported = convertToFolderScanningConfig(automation, withMapper);
+ const parsed = parseFolderScanningConfig(exported, withMapper);
+ expect(parsed.automation.operations[0]).toEqual({
+ operation: "compress",
+ parameters: { compressionLevel: 9 },
+ });
+ });
+
+ test("round-trips a tool whose endpoint depends on a frontend-only field", () => {
+ // Split-style tool: the endpoint is chosen from a frontend-only `method`
+ // field. Keeping frontend shape on export lets import replay the endpoint
+ // and resolve the tool.
+ const splitLike = {
+ ...registry,
+ splitLike: {
+ operationConfig: {
+ endpoint: (p: Record) =>
+ p.method === "size"
+ ? "/api/v1/general/split-by-size"
+ : "/api/v1/general/split-pages",
+ },
+ },
+ } as unknown as Partial;
+ const automation: AutomationConfig = {
+ ...sampleAutomation,
+ operations: [
+ {
+ operation: "splitLike",
+ parameters: { method: "size", value: "10MB" },
+ },
+ ],
+ };
+ const exported = convertToFolderScanningConfig(automation, splitLike);
+ expect(exported.pipeline[0]).toEqual({
+ operation: "/api/v1/general/split-by-size",
+ parameters: { method: "size", value: "10MB", fileInput: "automated" },
+ });
+ const parsed = parseFolderScanningConfig(exported, splitLike);
+ expect(parsed.unresolvedOperations).toEqual([]);
+ expect(parsed.automation.operations[0]).toEqual({
+ operation: "splitLike",
+ parameters: { method: "size", value: "10MB" },
+ });
+ });
+
test("keeps unmappable endpoints verbatim and reports them", () => {
const config = {
name: "Mystery",
diff --git a/frontend/editor/src/core/utils/automationConverter.ts b/frontend/editor/src/core/utils/automationConverter.ts
index ce29f71bd0..05acbed8a6 100644
--- a/frontend/editor/src/core/utils/automationConverter.ts
+++ b/frontend/editor/src/core/utils/automationConverter.ts
@@ -83,7 +83,7 @@ export function convertToFolderScanningConfig(
endpoint = endpointConfig;
} else if (typeof endpointConfig === "function") {
try {
- endpoint = endpointConfig(op.parameters);
+ endpoint = endpointConfig(op.parameters) ?? undefined;
} catch (error) {
console.warn(
`Failed to resolve dynamic endpoint for operation "${op.operation}". ` +
diff --git a/frontend/editor/src/core/utils/automationExecutor.ts b/frontend/editor/src/core/utils/automationExecutor.ts
index a29ffbcb83..f47778deda 100644
--- a/frontend/editor/src/core/utils/automationExecutor.ts
+++ b/frontend/editor/src/core/utils/automationExecutor.ts
@@ -88,12 +88,17 @@ const executeSingleFileOperation = async (
): Promise => {
const resultFiles: File[] = [];
- for (const file of files) {
- const endpoint =
- typeof config.endpoint === "function"
- ? config.endpoint(parameters)
- : config.endpoint;
+ const endpoint =
+ typeof config.endpoint === "function"
+ ? config.endpoint(parameters)
+ : config.endpoint;
+ if (!endpoint) {
+ throw new Error(
+ "This operation has no backend endpoint and cannot be executed directly.",
+ );
+ }
+ for (const file of files) {
const formData = config.buildFormData(parameters, file);
const processedFiles = await executeApiRequest(
@@ -122,6 +127,11 @@ const executeMultiFileOperation = async (
typeof config.endpoint === "function"
? config.endpoint(parameters)
: config.endpoint;
+ if (!endpoint) {
+ throw new Error(
+ "This operation has no backend endpoint and cannot be executed directly.",
+ );
+ }
const formData = config.buildFormData(parameters, files);
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3a9db8016c..3d0ff68cd2 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -117,6 +117,7 @@
"eslint": "^10.0.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.0.0",
+ "json-schema-to-typescript": "^15.0.4",
"license-checker": "^25.0.1",
"msw": "^2.14.6",
"msw-storybook-addon": "^2.0.7",
@@ -192,6 +193,24 @@
"url": "https://github.com/sponsors/antfu"
}
},
+ "node_modules/@apidevtools/json-schema-ref-parser": {
+ "version": "11.9.3",
+ "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz",
+ "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jsdevtools/ono": "^7.1.3",
+ "@types/json-schema": "^7.0.15",
+ "js-yaml": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/philsturgeon"
+ }
+ },
"node_modules/@asamuzakjp/css-color": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
@@ -2237,6 +2256,13 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@jsdevtools/ono": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
+ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@kessler/tableify": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@kessler/tableify/-/tableify-1.0.2.tgz",
@@ -5663,6 +5689,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -10581,6 +10614,30 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
+ "node_modules/json-schema-to-typescript": {
+ "version": "15.0.4",
+ "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz",
+ "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@apidevtools/json-schema-ref-parser": "^11.5.5",
+ "@types/json-schema": "^7.0.15",
+ "@types/lodash": "^4.17.7",
+ "is-glob": "^4.0.3",
+ "js-yaml": "^4.1.0",
+ "lodash": "^4.17.21",
+ "minimist": "^1.2.8",
+ "prettier": "^3.2.5",
+ "tinyglobby": "^0.2.9"
+ },
+ "bin": {
+ "json2ts": "dist/src/cli.js"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -11102,6 +11159,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/log-symbols": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index dca6e68c43..e9d57208e2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -137,6 +137,7 @@
"eslint": "^10.0.2",
"fake-indexeddb": "^6.2.5",
"jsdom": "^27.0.0",
+ "json-schema-to-typescript": "^15.0.4",
"license-checker": "^25.0.1",
"msw": "^2.14.6",
"msw-storybook-addon": "^2.0.7",
From 1df6a1759ce6d11390d89661e81fa879c1949306 Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Tue, 7 Jul 2026 10:37:35 +0100
Subject: [PATCH 03/43] Set App version to v2.14.1 (#6891)
Upped version in build.gradle then ran build so version falls through
---
.github/aur/stirling-pdf-desktop/PKGBUILD | 2 +-
.github/aur/stirling-pdf-server-bin/PKGBUILD | 2 +-
build.gradle | 2 +-
frontend/editor/src-tauri/tauri.conf.json | 2 +-
frontend/editor/src/core/testing/serverExperienceSimulations.ts | 2 +-
.../src/proprietary/testing/serverExperienceSimulations.ts | 2 +-
6 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD
index c02bde345e..5d92425b19 100644
--- a/.github/aur/stirling-pdf-desktop/PKGBUILD
+++ b/.github/aur/stirling-pdf-desktop/PKGBUILD
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc
pkgname=stirling-pdf-desktop
-pkgver=2.14.0
+pkgver=2.14.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD
index c73b1c5087..f5a2bf3c6c 100644
--- a/.github/aur/stirling-pdf-server-bin/PKGBUILD
+++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc
pkgname=stirling-pdf-server-bin
-pkgver=2.14.0
+pkgver=2.14.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
diff --git a/build.gradle b/build.gradle
index 3946b32449..5c89fd7af2 100644
--- a/build.gradle
+++ b/build.gradle
@@ -91,7 +91,7 @@ springBoot {
allprojects {
group = 'stirling.software'
- version = '2.14.0'
+ version = '2.14.1'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json
index 87f3a48a1d..6cfe33cbe6 100644
--- a/frontend/editor/src-tauri/tauri.conf.json
+++ b/frontend/editor/src-tauri/tauri.conf.json
@@ -2,7 +2,7 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling PDF",
"mainBinaryName": "Stirling-PDF",
- "version": "2.14.0",
+ "version": "2.14.1",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
diff --git a/frontend/editor/src/core/testing/serverExperienceSimulations.ts b/frontend/editor/src/core/testing/serverExperienceSimulations.ts
index 09344f9234..cd7ed8f2ba 100644
--- a/frontend/editor/src/core/testing/serverExperienceSimulations.ts
+++ b/frontend/editor/src/core/testing/serverExperienceSimulations.ts
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
- appVersion: "2.14.0",
+ appVersion: "2.14.1",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
diff --git a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts
index 50bf5eb230..1aaabfeb33 100644
--- a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts
+++ b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
- appVersion: "2.14.0",
+ appVersion: "2.14.1",
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,
From 20204f0ddcecd9d80dc5c13c802cd878d1ddc3e9 Mon Sep 17 00:00:00 2001
From: James Brunton
Date: Tue, 7 Jul 2026 12:01:18 +0100
Subject: [PATCH 04/43] Improve consistency and reliability of tools in
Stirling Engine (#6855)
# Description of Changes
A few changes to improve things in the engine:
- Changed the PDF to Markdown code to be a real tool in Java, to remove
the need for the `pdf_ingest` code, which looked a bit like an agent but
wasn't behaving as an agent. It's now just covered automatically by the
edit agent.
- Noticed that 0-parameter-endpoints were previously being ignored by
the `tool_models` generator, so some tools which require no params were
being mistakenly excluded.
- Removed tools which currently never succeed like Add Stamp, Cert Sign,
and Overlay, because they require the supporting files to be sent in a
different location in the API call, which we don't currently do.
Ideally, we'd add proper support for this, but we're better off now
removing support for these tools rather than just have them crash. We
can re-add these tools in a future PR properly.
---
.../model/api/ai/AiWorkflowOutcome.java | 3 +-
.../service/AiWorkflowService.java | 68 -----
.../service/AiWorkflowServiceMoreTest.java | 28 --
.../service/AiWorkflowServiceTest.java | 28 +-
engine/scripts/generate_tool_models.py | 36 ++-
engine/src/stirling/agents/orchestrator.py | 21 +-
engine/src/stirling/contracts/__init__.py | 2 -
engine/src/stirling/contracts/common.py | 14 -
engine/src/stirling/contracts/orchestrator.py | 2 -
engine/src/stirling/models/tool_models.py | 273 +++++++-----------
10 files changed, 157 insertions(+), 318 deletions(-)
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java
index 2bed56f0f0..a7239e8f90 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java
@@ -21,8 +21,7 @@ public enum AiWorkflowOutcome {
COMPLETED("completed"),
UNSUPPORTED_CAPABILITY("unsupported_capability"),
CANNOT_CONTINUE("cannot_continue"),
- GENERATE_FILE("generate_file"),
- CONVERT_MARKDOWN("convert_markdown");
+ GENERATE_FILE("generate_file");
private final String value;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java
index 4e6c515318..d3b2d21a00 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java
@@ -68,7 +68,6 @@ import tools.jackson.databind.ObjectMapper;
public class AiWorkflowService {
private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents";
- private static final String PDF_TO_MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final AiEngineClient aiEngineClient;
@@ -196,7 +195,6 @@ public class AiWorkflowService {
return switch (response.getOutcome()) {
case NEED_CONTENT -> onNeedContent(response, filesById, request, listener);
case NEED_INGEST -> onNeedIngest(response, filesById, request, listener);
- case CONVERT_MARKDOWN -> onConvertMarkdown(response, filesById, listener);
case TOOL_CALL -> onToolCall(response, filesById, listener);
case PLAN -> onPlan(response, filesById, request, listener);
case ANSWER -> onAnswer(response, filesById, request, listener);
@@ -333,72 +331,6 @@ public class AiWorkflowService {
return new WorkflowState.Pending(nextRequest);
}
- /**
- * Deterministically convert each requested PDF to Markdown via the {@code
- * /convert/pdf/markdown} endpoint (backed by {@code PdfMarkdownConverter}) and return the
- * {@code .md} file(s) as a completed result. No AI resume — the conversion output is the final
- * answer.
- */
- private WorkflowState onConvertMarkdown(
- AiWorkflowResponse response,
- Map filesById,
- ProgressListener listener) {
- List filesToConvert = response.getFilesToIngest();
- if (filesToConvert == null || filesToConvert.isEmpty()) {
- return new WorkflowState.Terminal(
- cannotContinue(
- "AI engine requested markdown conversion without listing any files."));
- }
-
- try {
- List resultFiles = new ArrayList<>();
- List inputNames = new ArrayList<>();
- for (int i = 0; i < filesToConvert.size(); i++) {
- AiFile file = filesToConvert.get(i);
- MultipartFile multipartFile = filesById.get(file.getId());
- if (multipartFile == null) {
- return new WorkflowState.Terminal(
- cannotContinue(
- "AI engine requested markdown conversion for unknown file: "
- + file.getName()));
- }
- listener.onProgress(
- AiWorkflowProgressEvent.executingTool(
- PDF_TO_MARKDOWN_ENDPOINT, i + 1, filesToConvert.size()));
- Resource input = toResource(multipartFile);
- PipelineDefinition definition =
- new PipelineDefinition(
- "convert-markdown",
- List.of(new PipelineStep(PDF_TO_MARKDOWN_ENDPOINT, Map.of())),
- null);
- PolicyExecutionResult result =
- policyExecutor.execute(
- definition,
- PolicyInputs.of(List.of(input)),
- PolicyProgressListener.NOOP);
- resultFiles.addAll(result.files());
- inputNames.add(multipartFile.getOriginalFilename());
- }
- return new WorkflowState.Terminal(
- buildCompletedResponse(null, resultFiles, inputNames, null));
- } catch (InternalApiTimeoutException e) {
- log.error("PDF to Markdown conversion timed out: {}", e.getMessage());
- return new WorkflowState.Terminal(
- cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
- } catch (Exception e) {
- AiWorkflowResponse limit = paygLimitResponseOrNull(e);
- if (limit != null) {
- log.info(
- "AI markdown conversion blocked by downstream entitlement gate ({})",
- limit.getErrorCode());
- return new WorkflowState.Terminal(limit);
- }
- log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
- return new WorkflowState.Terminal(
- cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
- }
- }
-
private Resource toResource(MultipartFile file) throws IOException {
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
file.transferTo(tempFile.getPath());
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java
index cc8388b8c2..7c44a40a5d 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java
@@ -221,34 +221,6 @@ class AiWorkflowServiceMoreTest {
}
}
- @Nested
- @DisplayName("convert_markdown guards")
- class ConvertMarkdownGuards {
-
- @Test
- @DisplayName("no files listed yields CANNOT_CONTINUE")
- void noFiles() throws IOException {
- stubOrchestrator("{\"outcome\":\"convert_markdown\",\"filesToIngest\":[]}");
- AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "to md"));
- assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE);
- }
-
- @Test
- @DisplayName("unknown file id yields CANNOT_CONTINUE")
- void unknownFile() throws IOException {
- when(fileIdStrategy.idFor(any())).thenReturn("real-id");
- stubOrchestrator(
- """
- {"outcome":"convert_markdown",
- "filesToIngest":[{"id":"other-id","name":"other.pdf"}]}
- """);
- AiWorkflowResponse result =
- service.orchestrate(requestFor(pdf("real.pdf", "x"), "to md"));
- assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE);
- assertThat(result.getReason()).contains("other.pdf");
- }
- }
-
@Nested
@DisplayName("plan guards and errors")
class PlanGuardsAndErrors {
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java
index 9e611cf32e..7dc174c041 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java
@@ -78,6 +78,7 @@ class AiWorkflowServiceTest {
private static final String SPLIT_ENDPOINT = "/api/v1/general/split-pages";
private static final String MERGE_ENDPOINT = "/api/v1/general/merge-pdfs";
private static final String COMPRESS_ENDPOINT = "/api/v1/misc/compress-pdf";
+ private static final String MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private AiEngineClient aiEngineClient;
@@ -440,23 +441,23 @@ class AiWorkflowServiceTest {
}
@Test
- void convertMarkdownRunsDeterministicConversionAndReturnsMdFile() throws IOException {
+ void planWithMarkdownStepReturnsMdFile() throws IOException {
+ // PDF→Markdown is a normal tool the edit agent emits as a plan step (no bespoke
+ // outcome); the plan executor runs the converter and returns the .md file.
MockMultipartFile input = pdf("multi-column-test_lorem.pdf", "pdf-bytes");
- when(fileIdStrategy.idFor(any())).thenReturn("doc-1");
stubOrchestrator(
"""
{
- "outcome":"convert_markdown",
- "reason":"PDF to Markdown requested.",
- "filesToIngest":[{"id":"doc-1","name":"multi-column-test_lorem.pdf"}]
+ "outcome":"plan",
+ "summary":"Convert to Markdown",
+ "steps":[{"tool":"%s","parameters":{}}]
}
- """);
- when(toolMetadataService.shouldUnpackZipResponse("/api/v1/convert/pdf/markdown"))
- .thenReturn(false);
- stubEndpoint(
- "/api/v1/convert/pdf/markdown",
- pdfResource("# Title", "multi-column-test_lorem.md"));
- AtomicInteger ids = stubFileStorage();
+ """
+ .formatted(MARKDOWN_ENDPOINT));
+ when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
+ when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
+ stubEndpoint(MARKDOWN_ENDPOINT, pdfResource("# Title", "multi-column-test_lorem.md"));
+ stubFileStorage();
AiWorkflowResponse result = service.orchestrate(requestFor(input, "convert to markdown"));
@@ -464,8 +465,7 @@ class AiWorkflowServiceTest {
assertEquals(1, result.getResultFiles().size());
// Extension changes (pdf -> md), so the converter's response filename wins.
assertEquals("multi-column-test_lorem.md", result.getResultFiles().get(0).getFileName());
- assertEquals(1, ids.get());
- verify(internalApiClient, times(1)).post(eq("/api/v1/convert/pdf/markdown"), any());
+ verify(internalApiClient, times(1)).post(eq(MARKDOWN_ENDPOINT), any());
}
@Test
diff --git a/engine/scripts/generate_tool_models.py b/engine/scripts/generate_tool_models.py
index 9bc1458fea..dfe461fcca 100644
--- a/engine/scripts/generate_tool_models.py
+++ b/engine/scripts/generate_tool_models.py
@@ -59,6 +59,36 @@ class ToolDiscovery:
"/api/v1/convert/",
)
+ # Endpoints under the allowed prefixes that are NOT edit-agent operations. A listed
+ # path and everything nested under it is dropped. Several kinds live here:
+ EXCLUDED_PATHS = (
+ # 1. Cert-signing family: needs certificate/key files the agent can't supply, plus
+ # interactive session and hardware-token management. The whole subtree is dropped.
+ "/api/v1/security/cert-sign",
+ # 2. Interactive PDF text-editor endpoints, not one-shot operations.
+ "/api/v1/convert/pdf/text-editor",
+ "/api/v1/convert/text-editor/pdf",
+ # 3. Introspection / query endpoints that return metadata, a listing, or a
+ # verification verdict rather than a transformed document, so they belong to
+ # the question path, not the edit agent. (decompress is a dev-only stream op.)
+ "/api/v1/security/get-info-on-pdf",
+ "/api/v1/security/verify-pdf",
+ "/api/v1/security/validate-signature",
+ "/api/v1/misc/list-attachments",
+ "/api/v1/misc/show-javascript",
+ "/api/v1/misc/decompress-pdf",
+ "/api/v1/general/extract-bookmarks",
+ # 4. Require a secondary file (image, overlay PDF, attachments) on top of the input
+ # PDF. The agent only ever supplies the input PDF(s), so these can never run.
+ # (add-stamp / add-watermark stay: their text mode needs no extra file.)
+ "/api/v1/misc/add-image",
+ "/api/v1/misc/add-attachments",
+ "/api/v1/general/overlay-pdfs",
+ )
+
+ def _is_excluded(self, path: str) -> bool:
+ return any(path == p or path.startswith(p + "/") for p in self.EXCLUDED_PATHS)
+
def __init__(self, spec: dict[str, Any]):
resource = Resource.from_contents(spec, default_specification=DRAFT202012)
self.resolver = Registry().with_resource("", resource).resolver()
@@ -73,17 +103,15 @@ class ToolDiscovery:
for path, path_item in sorted(self.spec.get("paths", {}).items()):
if "{" in path or not any(path.startswith(p) for p in self.ALLOWED_PATH_PREFIXES):
continue
+ if self._is_excluded(path):
+ continue
body_schema = self._get_request_body_schema(path_item) or {}
query_props = self._get_query_parameters(path_item)
body_props = body_schema.get("properties") or {}
# Body properties win on name collision — body is the canonical param source
# for the existing tools; query params are additive.
properties = {**query_props, **body_props}
- if not properties:
- continue
clean_props = self._filter_properties(properties)
- if not clean_props:
- continue
enum_name = _deduplicate(_path_to_enum_name(path), used_enum)
class_name = _deduplicate(_path_to_class_name(path), used_class)
diff --git a/engine/src/stirling/agents/orchestrator.py b/engine/src/stirling/agents/orchestrator.py
index c73d9dab32..e07e771e34 100644
--- a/engine/src/stirling/agents/orchestrator.py
+++ b/engine/src/stirling/agents/orchestrator.py
@@ -15,7 +15,6 @@ from stirling.agents.pdf_review import PdfReviewAgent
from stirling.agents.user_spec import UserSpecAgent
from stirling.contracts import (
AgentDraftWorkflowResponse,
- ConvertMarkdownResponse,
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
@@ -48,7 +47,7 @@ class OrchestratorAgent:
ToolOutput(
self.delegate_pdf_edit,
name="delegate_pdf_edit",
- description="Delegate requests for PDF modifications and return the PDF edit result.",
+ description="Delegate requests to modify or convert PDFs and return the PDF edit result.",
),
ToolOutput(
self.delegate_pdf_question,
@@ -71,13 +70,6 @@ class OrchestratorAgent:
" feedback')."
),
),
- ToolOutput(
- self.delegate_pdf_ingest,
- name="delegate_pdf_ingest",
- description=(
- "Delegate requests to convert a PDF to Markdown or extract its content as readable text."
- ),
- ),
ToolOutput(
self.delegate_pdf_create,
name="delegate_pdf_create",
@@ -98,7 +90,7 @@ class OrchestratorAgent:
system_prompt=(
"You are the top-level orchestrator. "
"Choose exactly one output function that best handles the request. "
- "Use delegate_pdf_edit for any requested modification of one or more PDFs. "
+ "Use delegate_pdf_edit for any request to modify or convert one or more PDFs. "
"Use delegate_pdf_question for questions about the contents of the attached PDFs. "
"Use delegate_user_spec for requests to create or define an agent spec. "
"Use delegate_pdf_review when the user wants the PDF returned with review"
@@ -106,8 +98,6 @@ class OrchestratorAgent:
" 'leave feedback on the PDF'. "
"Use delegate_pdf_create when the user wants to generate a new document from"
" scratch with no input file — invoices, reports, letters, contracts, etc. "
- "Use delegate_pdf_ingest for any request to convert a PDF to Markdown "
- "or extract its content as readable text. "
"Use unsupported_capability when the user asks about the assistant itself "
"or when none of the other outputs fit; supply a helpful message."
),
@@ -177,13 +167,6 @@ class OrchestratorAgent:
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
return await UserSpecAgent(self.runtime).orchestrate(request)
- async def delegate_pdf_ingest(self, ctx: RunContext[OrchestratorDeps]) -> ConvertMarkdownResponse:
- request = ctx.deps.request
- return ConvertMarkdownResponse(
- reason="PDF to Markdown requested — Java converts deterministically.",
- files_to_ingest=request.files,
- )
-
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> PdfReviewOrchestrateResponse:
return await self._run_pdf_review(ctx.deps.request)
diff --git a/engine/src/stirling/contracts/__init__.py b/engine/src/stirling/contracts/__init__.py
index 6c8d99d120..77ce40301e 100644
--- a/engine/src/stirling/contracts/__init__.py
+++ b/engine/src/stirling/contracts/__init__.py
@@ -13,7 +13,6 @@ from .common import (
AiFile,
ArtifactKind,
ConversationMessage,
- ConvertMarkdownResponse,
ExtractedFileText,
GenerateFileResponse,
MathAuditorToolReportArtifact,
@@ -163,7 +162,6 @@ __all__ = [
"NeedContentFileRequest",
"NeedContentResponse",
"NeedIngestResponse",
- "ConvertMarkdownResponse",
"NextExecutionAction",
"OrchestratorRequest",
"OrchestratorResponse",
diff --git a/engine/src/stirling/contracts/common.py b/engine/src/stirling/contracts/common.py
index 8f35c9ceb4..d9105bad4e 100644
--- a/engine/src/stirling/contracts/common.py
+++ b/engine/src/stirling/contracts/common.py
@@ -62,7 +62,6 @@ class WorkflowOutcome(StrEnum):
CANNOT_CONTINUE = "cannot_continue"
UNSUPPORTED_CAPABILITY = "unsupported_capability"
GENERATE_FILE = "generate_file"
- CONVERT_MARKDOWN = "convert_markdown"
class ArtifactKind(StrEnum):
@@ -184,19 +183,6 @@ class NeedIngestResponse(ApiModel):
content_types: list[PdfContentType] = Field(default_factory=list)
-class ConvertMarkdownResponse(ApiModel):
- """Terminal signal: convert the listed files to Markdown deterministically.
-
- This is a deterministic, non-AI conversion. Java runs the PDF→Markdown converter
- (``PdfMarkdownConverter``) on each file and returns the resulting ``.md`` file(s) as a
- completed result. There is no resume turn — the conversion output is the final answer.
- """
-
- outcome: Literal[WorkflowOutcome.CONVERT_MARKDOWN] = WorkflowOutcome.CONVERT_MARKDOWN
- reason: str
- files_to_ingest: list[AiFile]
-
-
class ToolOperationStep(ApiModel):
kind: Literal[StepKind.TOOL] = StepKind.TOOL
tool: AnyToolId
diff --git a/engine/src/stirling/contracts/orchestrator.py b/engine/src/stirling/contracts/orchestrator.py
index 8b916ccaff..2d85853c51 100644
--- a/engine/src/stirling/contracts/orchestrator.py
+++ b/engine/src/stirling/contracts/orchestrator.py
@@ -11,7 +11,6 @@ from .common import (
AiFile,
ArtifactKind,
ConversationMessage,
- ConvertMarkdownResponse,
ExtractedFileText,
GenerateFileResponse,
NeedContentResponse,
@@ -61,7 +60,6 @@ type OrchestratorResponse = Annotated[
| GenerateFileResponse
| NeedContentResponse
| NeedIngestResponse
- | ConvertMarkdownResponse
| AgentDraftResponse
| NextExecutionAction
| UnsupportedCapabilityResponse,
diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py
index 2591d947a8..1827730283 100644
--- a/engine/src/stirling/models/tool_models.py
+++ b/engine/src/stirling/models/tool_models.py
@@ -11,13 +11,6 @@ from pydantic import Field, RootModel, SecretStr
from stirling.models.base import ApiModel
-class AddAttachmentsParams(ApiModel):
- attachments: list[bytes] = Field(..., description="The image file to be overlaid onto the PDF.")
- convert_to_pdf_a3b: bool = Field(
- False, description="Convert the resulting PDF to PDF/A-3b format after adding attachments"
- )
-
-
class AddCommentsParams(ApiModel):
comments: str = Field(
...,
@@ -28,12 +21,6 @@ class AddCommentsParams(ApiModel):
)
-class AddImageParams(ApiModel):
- every_page: bool = Field(False, description="Whether to overlay the image onto every page of the PDF.")
- x: float = Field(0, description="The x-coordinate at which to place the top-left corner of the image.")
- y: float = Field(0, description="The y-coordinate at which to place the top-left corner of the image.")
-
-
class CustomMargin(StrEnum):
"""
Custom margin: small/medium/large/x-large
@@ -312,50 +299,6 @@ class CbzToPdfParams(ApiModel):
optimize_for_ebook: bool = Field(False, description="Optimize the output PDF for ebook reading using Ghostscript")
-class CertType(StrEnum):
- """
- The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.
- """
-
- pem = "PEM"
- pkcs12 = "PKCS12"
- pfx = "PFX"
- jks = "JKS"
- server = "SERVER"
- windows_store = "WINDOWS_STORE"
- pkcs11 = "PKCS11"
-
-
-class CertSignParams(ApiModel):
- alias: str | None = Field(
- None,
- description="The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates.",
- )
- cert_type: CertType = Field(
- ...,
- description="The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.",
- )
- location: str = Field("SPDF", description="The location where the PDF is signed")
- name: str = Field("SPDF", description="The name of the signer")
- page_number: int = Field(
- 1,
- description="The page number where the signature should be visible. This is required if showSignature is set to true",
- )
- password: SecretStr | None = Field(
- None, description="The password for the keystore / private key, or the token PIN for PKCS11"
- )
- pkcs11_library_path: str | None = Field(
- None,
- description="Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must be an allowed driver - a detected one or configured via STIRLING_PKCS11_LIBRARIES.",
- )
- pkcs11_slot: int | None = Field(
- None, description="Optional PKCS#11 slot index. When omitted the first slot with a token is used."
- )
- reason: str = Field("Signed by SPDF", description="The reason for signing the PDF")
- show_logo: bool = Field(True, description="Whether to visually show a signature logo along with the signature")
- show_signature: bool = Field(False, description="Whether to visually show the signature in the PDF file")
-
-
class LineArtEdgeLevel(IntEnum):
"""
Edge detection strength to use for line art conversion (1-3). This maps to ImageMagick's -edge radius.
@@ -525,6 +468,10 @@ class EmlToPdfParams(ApiModel):
)
+class ExtractAttachmentsParams(ApiModel):
+ pass
+
+
class ExtractImageScansParams(ApiModel):
angle_threshold: int = Field(5, description="The angle threshold for the image scan extraction")
border_size: int = Field(1, description="The border size for the image scan extraction")
@@ -547,6 +494,10 @@ class ExtractImagesParams(ApiModel):
format: Format = Field(Format.png, description="The output image format e.g., 'png', 'jpeg', or 'gif'")
+class FileToPdfParams(ApiModel):
+ pass
+
+
class FlattenParams(ApiModel):
flatten_only_forms: bool = Field(
False, description="True to flatten only the forms, false to flatten full PDF (Convert page to image)"
@@ -602,6 +553,10 @@ class ImgToPdfParams(ApiModel):
)
+class MarkdownToPdfParams(ApiModel):
+ pass
+
+
class SortType(StrEnum):
"""
The type of sorting to be applied on the input files before merging.
@@ -750,41 +705,6 @@ class OcrPdfParams(ApiModel):
sidecar: bool | None = Field(None, description="Include OCR text in a sidecar text file if set to true")
-class OverlayMode(StrEnum):
- """
- The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts
- """
-
- sequential_overlay = "SequentialOverlay"
- interleaved_overlay = "InterleavedOverlay"
- fixed_repeat_overlay = "FixedRepeatOverlay"
-
-
-class OverlayPosition(Enum):
- """
- Overlay position 0 is Foregound, 1 is Background
- """
-
- number_0 = 0
- number_1 = 1
-
-
-class OverlayPdfsParams(ApiModel):
- counts: list[int] | None = Field(
- None,
- description="An array of integers specifying the number of times each corresponding overlay file should be applied in the 'FixedRepeatOverlay' mode. This should match the length of the overlayFiles array.",
- )
- overlay_files: list[bytes] = Field(
- ...,
- description="An array of PDF files to be used as overlays on the base PDF. The order in these files is applied based on the selected mode.",
- )
- overlay_mode: OverlayMode = Field(
- ...,
- description="The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts",
- )
- overlay_position: OverlayPosition = Field(..., description="Overlay position 0 is Foregound, 1 is Background")
-
-
class PdfToCbrParams(ApiModel):
dpi: int = Field(..., description="The DPI (Dots Per Inch) for rendering PDF pages as images", examples=[150])
@@ -841,6 +761,12 @@ class PdfToEpubParams(ApiModel):
)
+class PdfToHtmlParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
class ImageFormat(StrEnum):
"""
The output image format
@@ -877,6 +803,12 @@ class PdfToImgParams(ApiModel):
)
+class PdfToMarkdownParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
class OutputFormat1(StrEnum):
"""
The output format type (PDF/A or PDF/X)
@@ -912,13 +844,11 @@ class PdfToPresentationParams(ApiModel):
output_format: OutputFormat2 = Field(..., description="The output Presentation format")
-class PdfToTextEditorParams(ApiModel):
+class PdfToSinglePageParams(ApiModel):
"""
Either upload a file or provide a server-side file ID
"""
- lightweight: bool = False
-
class OutputFormat3(StrEnum):
"""
@@ -979,10 +909,10 @@ class PdfToXlsxParams(ApiModel):
)
-class Pkcs11CertificatesParams(ApiModel):
- library_path: str | None = None
- pin: str | None = None
- slot: int | None = None
+class PdfToXmlParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
class CustomMode(StrEnum):
@@ -1061,6 +991,18 @@ class RemoveBlanksParams(ApiModel):
)
+class RemoveCertSignParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
+class RemoveImagePdfParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
class RemovePagesParams(ApiModel):
page_numbers: str = Field(
"all",
@@ -1077,6 +1019,12 @@ class RenameAttachmentParams(ApiModel):
new_name: str = Field(..., description="The new name for the attachment")
+class RepairParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
class HighContrastColorCombination(StrEnum):
"""
If HIGH_CONTRAST_COLOR option selected, then pick the default color option for text and background.
@@ -1237,27 +1185,6 @@ class ScannerEffectParams(ApiModel):
yellowish: bool | None = Field(None, description="Simulate yellowed paper", examples=[False])
-class WorkflowType(StrEnum):
- signing = "SIGNING"
- review = "REVIEW"
- approval = "APPROVAL"
-
-
-class Request(ApiModel):
- document_name: str | None = None
- due_date: str | None = None
- message: str | None = None
- owner_email: str | None = None
- participant_emails: list[str] | None = None
- participant_user_ids: list[int] | None = None
- workflow_metadata: str | None = None
- workflow_type: WorkflowType | None = None
-
-
-class SessionsParams(ApiModel):
- request: Request | None = None
-
-
class SplitBySizeOrCountParams(ApiModel):
split_type: int = Field(
0, description="Determines the type of split: 0 for size, 1 for page count, 2 for document count"
@@ -1360,6 +1287,12 @@ class TimestampPdfParams(ApiModel):
)
+class UnlockPdfFormsParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
class Trapped(StrEnum):
"""
The trapped status of the document
@@ -1399,11 +1332,6 @@ class UrlToPdfParams(ApiModel):
url_input: str = Field(..., description="The input URL to be converted to a PDF file")
-class ValidateCertificateParams(ApiModel):
- cert_type: str | None = None
- password: str | None = None
-
-
class OutputFormat6(StrEnum):
"""
Target vector format extension
@@ -1462,20 +1390,24 @@ class Model(
| CbzToPdfParams
| EbookToPdfParams
| EmlToPdfParams
+ | FileToPdfParams
| HtmlToPdfParams
| ImgToPdfParams
+ | MarkdownToPdfParams
| PdfToCbrParams
| PdfToCbzParams
| PdfToCsvParams
| PdfToEpubParams
+ | PdfToHtmlParams
| PdfToImgParams
+ | PdfToMarkdownParams
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
- | PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
+ | PdfToXmlParams
| SvgToPdfParams
| UrlToPdfParams
| VectorToPdfParams
@@ -1485,8 +1417,9 @@ class Model(
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
- | OverlayPdfsParams
+ | PdfToSinglePageParams
| RearrangePagesParams
+ | RemoveImagePdfParams
| RemovePagesParams
| RotatePdfParams
| ScalePagesParams
@@ -1495,33 +1428,31 @@ class Model(
| SplitPagesParams
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
- | AddAttachmentsParams
| AddCommentsParams
- | AddImageParams
| AddPageNumbersParams
| AddStampParams
| AutoRenameParams
| AutoSplitPdfParams
| CompressPdfParams
| DeleteAttachmentParams
+ | ExtractAttachmentsParams
| ExtractImageScansParams
| ExtractImagesParams
| FlattenParams
| OcrPdfParams
| RemoveBlanksParams
| RenameAttachmentParams
+ | RepairParams
| ReplaceInvertPdfParams
| ScannerEffectParams
+ | UnlockPdfFormsParams
| UpdateMetadataParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
- | CertSignParams
- | Pkcs11CertificatesParams
- | SessionsParams
- | ValidateCertificateParams
| RedactParams
| RedactExecuteParams
+ | RemoveCertSignParams
| RemovePasswordParams
| SanitizePdfParams
| TimestampPdfParams
@@ -1532,20 +1463,24 @@ class Model(
| CbzToPdfParams
| EbookToPdfParams
| EmlToPdfParams
+ | FileToPdfParams
| HtmlToPdfParams
| ImgToPdfParams
+ | MarkdownToPdfParams
| PdfToCbrParams
| PdfToCbzParams
| PdfToCsvParams
| PdfToEpubParams
+ | PdfToHtmlParams
| PdfToImgParams
+ | PdfToMarkdownParams
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
- | PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
+ | PdfToXmlParams
| SvgToPdfParams
| UrlToPdfParams
| VectorToPdfParams
@@ -1555,8 +1490,9 @@ class Model(
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
- | OverlayPdfsParams
+ | PdfToSinglePageParams
| RearrangePagesParams
+ | RemoveImagePdfParams
| RemovePagesParams
| RotatePdfParams
| ScalePagesParams
@@ -1565,33 +1501,31 @@ class Model(
| SplitPagesParams
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
- | AddAttachmentsParams
| AddCommentsParams
- | AddImageParams
| AddPageNumbersParams
| AddStampParams
| AutoRenameParams
| AutoSplitPdfParams
| CompressPdfParams
| DeleteAttachmentParams
+ | ExtractAttachmentsParams
| ExtractImageScansParams
| ExtractImagesParams
| FlattenParams
| OcrPdfParams
| RemoveBlanksParams
| RenameAttachmentParams
+ | RepairParams
| ReplaceInvertPdfParams
| ScannerEffectParams
+ | UnlockPdfFormsParams
| UpdateMetadataParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
- | CertSignParams
- | Pkcs11CertificatesParams
- | SessionsParams
- | ValidateCertificateParams
| RedactParams
| RedactExecuteParams
+ | RemoveCertSignParams
| RemovePasswordParams
| SanitizePdfParams
| TimestampPdfParams
@@ -1603,20 +1537,24 @@ type ParamToolModel = (
| CbzToPdfParams
| EbookToPdfParams
| EmlToPdfParams
+ | FileToPdfParams
| HtmlToPdfParams
| ImgToPdfParams
+ | MarkdownToPdfParams
| PdfToCbrParams
| PdfToCbzParams
| PdfToCsvParams
| PdfToEpubParams
+ | PdfToHtmlParams
| PdfToImgParams
+ | PdfToMarkdownParams
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
- | PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
+ | PdfToXmlParams
| SvgToPdfParams
| UrlToPdfParams
| VectorToPdfParams
@@ -1626,8 +1564,9 @@ type ParamToolModel = (
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
- | OverlayPdfsParams
+ | PdfToSinglePageParams
| RearrangePagesParams
+ | RemoveImagePdfParams
| RemovePagesParams
| RotatePdfParams
| ScalePagesParams
@@ -1636,33 +1575,31 @@ type ParamToolModel = (
| SplitPagesParams
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
- | AddAttachmentsParams
| AddCommentsParams
- | AddImageParams
| AddPageNumbersParams
| AddStampParams
| AutoRenameParams
| AutoSplitPdfParams
| CompressPdfParams
| DeleteAttachmentParams
+ | ExtractAttachmentsParams
| ExtractImageScansParams
| ExtractImagesParams
| FlattenParams
| OcrPdfParams
| RemoveBlanksParams
| RenameAttachmentParams
+ | RepairParams
| ReplaceInvertPdfParams
| ScannerEffectParams
+ | UnlockPdfFormsParams
| UpdateMetadataParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
- | CertSignParams
- | Pkcs11CertificatesParams
- | SessionsParams
- | ValidateCertificateParams
| RedactParams
| RedactExecuteParams
+ | RemoveCertSignParams
| RemovePasswordParams
| SanitizePdfParams
| TimestampPdfParams
@@ -1675,20 +1612,24 @@ class ToolEndpoint(StrEnum):
CBZ_TO_PDF = "/api/v1/convert/cbz/pdf"
EBOOK_TO_PDF = "/api/v1/convert/ebook/pdf"
EML_TO_PDF = "/api/v1/convert/eml/pdf"
+ FILE_TO_PDF = "/api/v1/convert/file/pdf"
HTML_TO_PDF = "/api/v1/convert/html/pdf"
IMG_TO_PDF = "/api/v1/convert/img/pdf"
+ MARKDOWN_TO_PDF = "/api/v1/convert/markdown/pdf"
PDF_TO_CBR = "/api/v1/convert/pdf/cbr"
PDF_TO_CBZ = "/api/v1/convert/pdf/cbz"
PDF_TO_CSV = "/api/v1/convert/pdf/csv"
PDF_TO_EPUB = "/api/v1/convert/pdf/epub"
+ PDF_TO_HTML = "/api/v1/convert/pdf/html"
PDF_TO_IMG = "/api/v1/convert/pdf/img"
+ PDF_TO_MARKDOWN = "/api/v1/convert/pdf/markdown"
PDF_TO_PDFA = "/api/v1/convert/pdf/pdfa"
PDF_TO_PRESENTATION = "/api/v1/convert/pdf/presentation"
PDF_TO_TEXT = "/api/v1/convert/pdf/text"
- PDF_TO_TEXT_EDITOR = "/api/v1/convert/pdf/text-editor"
PDF_TO_VECTOR = "/api/v1/convert/pdf/vector"
PDF_TO_WORD = "/api/v1/convert/pdf/word"
PDF_TO_XLSX = "/api/v1/convert/pdf/xlsx"
+ PDF_TO_XML = "/api/v1/convert/pdf/xml"
SVG_TO_PDF = "/api/v1/convert/svg/pdf"
URL_TO_PDF = "/api/v1/convert/url/pdf"
VECTOR_TO_PDF = "/api/v1/convert/vector/pdf"
@@ -1698,8 +1639,9 @@ class ToolEndpoint(StrEnum):
EDIT_TEXT = "/api/v1/general/edit-text"
MERGE_PDFS = "/api/v1/general/merge-pdfs"
MULTI_PAGE_LAYOUT = "/api/v1/general/multi-page-layout"
- OVERLAY_PDFS = "/api/v1/general/overlay-pdfs"
+ PDF_TO_SINGLE_PAGE = "/api/v1/general/pdf-to-single-page"
REARRANGE_PAGES = "/api/v1/general/rearrange-pages"
+ REMOVE_IMAGE_PDF = "/api/v1/general/remove-image-pdf"
REMOVE_PAGES = "/api/v1/general/remove-pages"
ROTATE_PDF = "/api/v1/general/rotate-pdf"
SCALE_PAGES = "/api/v1/general/scale-pages"
@@ -1708,33 +1650,31 @@ class ToolEndpoint(StrEnum):
SPLIT_PAGES = "/api/v1/general/split-pages"
SPLIT_PDF_BY_CHAPTERS = "/api/v1/general/split-pdf-by-chapters"
SPLIT_PDF_BY_SECTIONS = "/api/v1/general/split-pdf-by-sections"
- ADD_ATTACHMENTS = "/api/v1/misc/add-attachments"
ADD_COMMENTS = "/api/v1/misc/add-comments"
- ADD_IMAGE = "/api/v1/misc/add-image"
ADD_PAGE_NUMBERS = "/api/v1/misc/add-page-numbers"
ADD_STAMP = "/api/v1/misc/add-stamp"
AUTO_RENAME = "/api/v1/misc/auto-rename"
AUTO_SPLIT_PDF = "/api/v1/misc/auto-split-pdf"
COMPRESS_PDF = "/api/v1/misc/compress-pdf"
DELETE_ATTACHMENT = "/api/v1/misc/delete-attachment"
+ EXTRACT_ATTACHMENTS = "/api/v1/misc/extract-attachments"
EXTRACT_IMAGE_SCANS = "/api/v1/misc/extract-image-scans"
EXTRACT_IMAGES = "/api/v1/misc/extract-images"
FLATTEN = "/api/v1/misc/flatten"
OCR_PDF = "/api/v1/misc/ocr-pdf"
REMOVE_BLANKS = "/api/v1/misc/remove-blanks"
RENAME_ATTACHMENT = "/api/v1/misc/rename-attachment"
+ REPAIR = "/api/v1/misc/repair"
REPLACE_INVERT_PDF = "/api/v1/misc/replace-invert-pdf"
SCANNER_EFFECT = "/api/v1/misc/scanner-effect"
+ UNLOCK_PDF_FORMS = "/api/v1/misc/unlock-pdf-forms"
UPDATE_METADATA = "/api/v1/misc/update-metadata"
ADD_PASSWORD = "/api/v1/security/add-password"
ADD_WATERMARK = "/api/v1/security/add-watermark"
AUTO_REDACT = "/api/v1/security/auto-redact"
- CERT_SIGN = "/api/v1/security/cert-sign"
- PKCS11_CERTIFICATES = "/api/v1/security/cert-sign/hardware/pkcs11-certificates"
- SESSIONS = "/api/v1/security/cert-sign/sessions"
- VALIDATE_CERTIFICATE = "/api/v1/security/cert-sign/validate-certificate"
REDACT = "/api/v1/security/redact"
REDACT_EXECUTE = "/api/v1/security/redact-execute"
+ REMOVE_CERT_SIGN = "/api/v1/security/remove-cert-sign"
REMOVE_PASSWORD = "/api/v1/security/remove-password"
SANITIZE_PDF = "/api/v1/security/sanitize-pdf"
TIMESTAMP_PDF = "/api/v1/security/timestamp-pdf"
@@ -1745,20 +1685,24 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.CBZ_TO_PDF: CbzToPdfParams,
ToolEndpoint.EBOOK_TO_PDF: EbookToPdfParams,
ToolEndpoint.EML_TO_PDF: EmlToPdfParams,
+ ToolEndpoint.FILE_TO_PDF: FileToPdfParams,
ToolEndpoint.HTML_TO_PDF: HtmlToPdfParams,
ToolEndpoint.IMG_TO_PDF: ImgToPdfParams,
+ ToolEndpoint.MARKDOWN_TO_PDF: MarkdownToPdfParams,
ToolEndpoint.PDF_TO_CBR: PdfToCbrParams,
ToolEndpoint.PDF_TO_CBZ: PdfToCbzParams,
ToolEndpoint.PDF_TO_CSV: PdfToCsvParams,
ToolEndpoint.PDF_TO_EPUB: PdfToEpubParams,
+ ToolEndpoint.PDF_TO_HTML: PdfToHtmlParams,
ToolEndpoint.PDF_TO_IMG: PdfToImgParams,
+ ToolEndpoint.PDF_TO_MARKDOWN: PdfToMarkdownParams,
ToolEndpoint.PDF_TO_PDFA: PdfToPdfaParams,
ToolEndpoint.PDF_TO_PRESENTATION: PdfToPresentationParams,
ToolEndpoint.PDF_TO_TEXT: PdfToTextParams,
- ToolEndpoint.PDF_TO_TEXT_EDITOR: PdfToTextEditorParams,
ToolEndpoint.PDF_TO_VECTOR: PdfToVectorParams,
ToolEndpoint.PDF_TO_WORD: PdfToWordParams,
ToolEndpoint.PDF_TO_XLSX: PdfToXlsxParams,
+ ToolEndpoint.PDF_TO_XML: PdfToXmlParams,
ToolEndpoint.SVG_TO_PDF: SvgToPdfParams,
ToolEndpoint.URL_TO_PDF: UrlToPdfParams,
ToolEndpoint.VECTOR_TO_PDF: VectorToPdfParams,
@@ -1768,8 +1712,9 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.EDIT_TEXT: EditTextParams,
ToolEndpoint.MERGE_PDFS: MergePdfsParams,
ToolEndpoint.MULTI_PAGE_LAYOUT: MultiPageLayoutParams,
- ToolEndpoint.OVERLAY_PDFS: OverlayPdfsParams,
+ ToolEndpoint.PDF_TO_SINGLE_PAGE: PdfToSinglePageParams,
ToolEndpoint.REARRANGE_PAGES: RearrangePagesParams,
+ ToolEndpoint.REMOVE_IMAGE_PDF: RemoveImagePdfParams,
ToolEndpoint.REMOVE_PAGES: RemovePagesParams,
ToolEndpoint.ROTATE_PDF: RotatePdfParams,
ToolEndpoint.SCALE_PAGES: ScalePagesParams,
@@ -1778,33 +1723,31 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.SPLIT_PAGES: SplitPagesParams,
ToolEndpoint.SPLIT_PDF_BY_CHAPTERS: SplitPdfByChaptersParams,
ToolEndpoint.SPLIT_PDF_BY_SECTIONS: SplitPdfBySectionsParams,
- ToolEndpoint.ADD_ATTACHMENTS: AddAttachmentsParams,
ToolEndpoint.ADD_COMMENTS: AddCommentsParams,
- ToolEndpoint.ADD_IMAGE: AddImageParams,
ToolEndpoint.ADD_PAGE_NUMBERS: AddPageNumbersParams,
ToolEndpoint.ADD_STAMP: AddStampParams,
ToolEndpoint.AUTO_RENAME: AutoRenameParams,
ToolEndpoint.AUTO_SPLIT_PDF: AutoSplitPdfParams,
ToolEndpoint.COMPRESS_PDF: CompressPdfParams,
ToolEndpoint.DELETE_ATTACHMENT: DeleteAttachmentParams,
+ ToolEndpoint.EXTRACT_ATTACHMENTS: ExtractAttachmentsParams,
ToolEndpoint.EXTRACT_IMAGE_SCANS: ExtractImageScansParams,
ToolEndpoint.EXTRACT_IMAGES: ExtractImagesParams,
ToolEndpoint.FLATTEN: FlattenParams,
ToolEndpoint.OCR_PDF: OcrPdfParams,
ToolEndpoint.REMOVE_BLANKS: RemoveBlanksParams,
ToolEndpoint.RENAME_ATTACHMENT: RenameAttachmentParams,
+ ToolEndpoint.REPAIR: RepairParams,
ToolEndpoint.REPLACE_INVERT_PDF: ReplaceInvertPdfParams,
ToolEndpoint.SCANNER_EFFECT: ScannerEffectParams,
+ ToolEndpoint.UNLOCK_PDF_FORMS: UnlockPdfFormsParams,
ToolEndpoint.UPDATE_METADATA: UpdateMetadataParams,
ToolEndpoint.ADD_PASSWORD: AddPasswordParams,
ToolEndpoint.ADD_WATERMARK: AddWatermarkParams,
ToolEndpoint.AUTO_REDACT: AutoRedactParams,
- ToolEndpoint.CERT_SIGN: CertSignParams,
- ToolEndpoint.PKCS11_CERTIFICATES: Pkcs11CertificatesParams,
- ToolEndpoint.SESSIONS: SessionsParams,
- ToolEndpoint.VALIDATE_CERTIFICATE: ValidateCertificateParams,
ToolEndpoint.REDACT: RedactParams,
ToolEndpoint.REDACT_EXECUTE: RedactExecuteParams,
+ ToolEndpoint.REMOVE_CERT_SIGN: RemoveCertSignParams,
ToolEndpoint.REMOVE_PASSWORD: RemovePasswordParams,
ToolEndpoint.SANITIZE_PDF: SanitizePdfParams,
ToolEndpoint.TIMESTAMP_PDF: TimestampPdfParams,
From 17aa71850ca94b67cac5e94d4f76e9d07d408522 Mon Sep 17 00:00:00 2001
From: James Brunton
Date: Tue, 7 Jul 2026 12:11:24 +0100
Subject: [PATCH 05/43] Convert to consistently use JS modules (#6854)
# Description of Changes
Modernises the codebase and gets rid of warnings where Node complains
that it doesn't know what type of JS it's supposed to be reading on
`.js` files. We might as well update everything to just use correct JS
syntax instead of keeping with some files having Node-specific imports.
---
frontend/editor/postcss.config.js | 7 ++-
frontend/editor/scripts/generate-icons.js | 12 ++---
frontend/editor/scripts/generate-licenses.js | 21 +++------
frontend/editor/scripts/generate-og-image.mjs | 47 ++++++++++---------
.../live/encrypted-unlock-then-tool.spec.ts | 2 +-
.../stubbed/add-page-numbers-tool.spec.ts | 5 +-
.../core/tests/stubbed/add-stamp-tool.spec.ts | 5 +-
.../tests/stubbed/cert-sign-wizard.spec.ts | 2 +-
.../stubbed/certificate-validation.spec.ts | 2 +-
.../stubbed/comments-sidebar-order.spec.ts | 2 +-
.../src/core/tests/stubbed/compare.spec.ts | 2 +-
.../src/core/tests/stubbed/convert.spec.ts | 2 +-
.../stubbed/encrypted-pdf-unlock.spec.ts | 2 +-
.../stubbed/file-state-across-tools.spec.ts | 2 +-
.../stubbed/merge-response-handling.spec.ts | 2 +-
.../stubbed/page-editor-rotation.spec.ts | 5 +-
.../tests/stubbed/pdf-text-search.spec.ts | 5 +-
.../src/core/tests/stubbed/seed.spec.ts | 9 +++-
.../tests/stubbed/tour-onboarding.spec.ts | 5 +-
.../stubbed/validate-signature-trust.spec.ts | 2 +-
.../viewer-sidebar-add-buttons.spec.ts | 5 +-
.../stubbed/viewer-text-selection.spec.ts | 2 +-
.../core/tests/stubbed/watermark-tool.spec.ts | 5 +-
frontend/editor/tailwind.config.js | 2 +-
frontend/eslint.config.mjs | 1 -
frontend/package.json | 1 +
frontend/scripts/update-minor.js | 2 +-
27 files changed, 93 insertions(+), 66 deletions(-)
diff --git a/frontend/editor/postcss.config.js b/frontend/editor/postcss.config.js
index 7b8895cce8..5bb7b5d6ef 100644
--- a/frontend/editor/postcss.config.js
+++ b/frontend/editor/postcss.config.js
@@ -1,3 +1,6 @@
-module.exports = {
- plugins: [require("@tailwindcss/postcss"), require("autoprefixer")],
+import tailwindcssPostcss from "@tailwindcss/postcss";
+import autoprefixer from "autoprefixer";
+
+export default {
+ plugins: [tailwindcssPostcss, autoprefixer],
};
diff --git a/frontend/editor/scripts/generate-icons.js b/frontend/editor/scripts/generate-icons.js
index 7e6da2740e..70b4a091d2 100644
--- a/frontend/editor/scripts/generate-icons.js
+++ b/frontend/editor/scripts/generate-icons.js
@@ -1,8 +1,8 @@
#!/usr/bin/env node
-const { icons } = require("@iconify-json/material-symbols");
-const fs = require("fs");
-const path = require("path");
+import { icons } from "@iconify-json/material-symbols";
+import fs from "node:fs";
+import path from "node:path";
// Check for verbose flag
const isVerbose =
@@ -19,7 +19,7 @@ const debug = (message) => {
// Function to scan codebase for LocalIcon usage
function scanForUsedIcons() {
const usedIcons = new Set();
- const srcDir = path.join(__dirname, "..", "src");
+ const srcDir = path.join(import.meta.dirname, "..", "src");
info("🔍 Scanning codebase for LocalIcon usage...");
@@ -140,7 +140,7 @@ async function main() {
// Check if we need to regenerate (compare with existing)
const outputPath = path.join(
- __dirname,
+ import.meta.dirname,
"..",
"src",
"assets",
@@ -200,7 +200,7 @@ async function main() {
}
// Create output directory
- const outputDir = path.join(__dirname, "..", "src", "assets");
+ const outputDir = path.join(import.meta.dirname, "..", "src", "assets");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
diff --git a/frontend/editor/scripts/generate-licenses.js b/frontend/editor/scripts/generate-licenses.js
index a82ec9a2bd..72da16c6a9 100644
--- a/frontend/editor/scripts/generate-licenses.js
+++ b/frontend/editor/scripts/generate-licenses.js
@@ -1,28 +1,21 @@
#!/usr/bin/env node
-const { execSync } = require("node:child_process");
-const {
- existsSync,
- mkdirSync,
- writeFileSync,
- readFileSync,
-} = require("node:fs");
-const path = require("node:path");
+import { execSync } from "node:child_process";
+import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { argv } from "node:process";
-const { argv } = require("node:process");
const inputIdx = argv.indexOf("--input");
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
const POSTPROCESS_ONLY = !!INPUT_FILE;
-// __dirname is available in CommonJS by default
-
/**
* Generate 3rd party licenses for frontend dependencies
* This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json
*/
const OUTPUT_FILE = path.join(
- __dirname,
+ import.meta.dirname,
"..",
"src",
"assets",
@@ -30,7 +23,7 @@ const OUTPUT_FILE = path.join(
);
// package.json lives at the workspace root (frontend/), not editor/. The
// script is at frontend/editor/scripts/, so walk up two levels.
-const PACKAGE_JSON = path.join(__dirname, "..", "..", "package.json");
+const PACKAGE_JSON = path.join(import.meta.dirname, "..", "..", "package.json");
// Ensure the output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
@@ -192,7 +185,7 @@ try {
// Write license warnings to a separate file for CI/CD
const warningsFile = path.join(
- __dirname,
+ import.meta.dirname,
"..",
"src",
"assets",
diff --git a/frontend/editor/scripts/generate-og-image.mjs b/frontend/editor/scripts/generate-og-image.mjs
index 7cef32c9b0..cf617bf7e8 100644
--- a/frontend/editor/scripts/generate-og-image.mjs
+++ b/frontend/editor/scripts/generate-og-image.mjs
@@ -16,11 +16,10 @@
/* global document, getComputedStyle */ // used inside page.evaluate (browser context)
import fs from "node:fs/promises";
+import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
-import { createRequire } from "node:module";
-const require = createRequire(import.meta.url);
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..");
@@ -69,11 +68,14 @@ export const THEME = {
};
// ---- icon resolution (material-symbols via iconify) ------------------------
-function resolveIcon(icon) {
+async function resolveIcon(icon) {
if (!icon) return "";
if (icon.trim().startsWith("
let _browser = null;
async function getBrowser() {
if (_browser) return _browser;
- const puppeteer = require("puppeteer");
+ const { default: puppeteer } = await import("puppeteer");
_browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox"],
@@ -163,7 +165,7 @@ export async function renderOgCard({
outFile,
theme = THEME,
}) {
- const iconSvg = resolveIcon(icon);
+ const iconSvg = await resolveIcon(icon);
const html = await buildHtml({ name, description, iconSvg, theme });
const browser = await getBrowser();
const page = await browser.newPage();
@@ -230,7 +232,7 @@ const kebab = (id) => id.replace(/([A-Z])/g, "-$1").toLowerCase();
// English name/description live next to each tool as the `t(key, fallback)` default.
function readRegistryStrings() {
- const src = require("node:fs").readFileSync(
+ const src = readFileSync(
path.join(ROOT, "src/core/data/useTranslatedToolRegistry.tsx"),
"utf8",
);
@@ -268,7 +270,7 @@ export async function generateMissing(theme = THEME) {
// Each tool's app icon lives as `icon=""` just before its
// `name: t("home..title", …)`. Pair each title with the closest preceding icon.
function readRegistryIcons() {
- const src = require("node:fs").readFileSync(
+ const src = readFileSync(
path.join(ROOT, "src/core/data/useTranslatedToolRegistry.tsx"),
"utf8",
);
@@ -288,25 +290,26 @@ function readRegistryIcons() {
return byId;
}
-function iconExists(name) {
+async function iconExists(name) {
if (!name) return false;
try {
- const { getIconData } = require("@iconify/utils");
- return !!getIconData(
- require("@iconify-json/material-symbols/icons.json"),
- name,
+ const { getIconData } = await import("@iconify/utils");
+ const { default: set } = await import(
+ "@iconify-json/material-symbols/icons.json",
+ { with: { type: "json" } }
);
+ return !!getIconData(set, name);
} catch {
return false;
}
}
// First candidate that resolves; also tries dropping a "-rounded" suffix.
-function firstResolvableIcon(candidates) {
+async function firstResolvableIcon(candidates) {
for (const c of candidates) {
- if (iconExists(c)) return c;
+ if (await iconExists(c)) return c;
const alt = c && c.replace(/-rounded$/, "");
- if (alt && alt !== c && iconExists(alt)) return alt;
+ if (alt && alt !== c && (await iconExists(alt))) return alt;
}
return "description-outline";
}
@@ -324,14 +327,14 @@ export async function generateAll(theme = THEME) {
const { titles, descs } = readRegistryStrings();
const regIcons = readRegistryIcons();
const ogMap = JSON.parse(
- require("node:fs").readFileSync(
- path.join(ROOT, "src/core/data/ogImageMap.json"),
- "utf8",
- ),
+ readFileSync(path.join(ROOT, "src/core/data/ogImageMap.json"), "utf8"),
);
const results = [];
for (const [id, basename] of Object.entries(ogMap)) {
- const icon = firstResolvableIcon([regIcons[id], MISSING_TOOL_ICONS[id]]);
+ const icon = await firstResolvableIcon([
+ regIcons[id],
+ MISSING_TOOL_ICONS[id],
+ ]);
await renderOgCard({
name: titles[id] || humanizeId(id),
description: descs[id] || "",
diff --git a/frontend/editor/src/core/tests/live/encrypted-unlock-then-tool.spec.ts b/frontend/editor/src/core/tests/live/encrypted-unlock-then-tool.spec.ts
index de9d803d25..d1b3641962 100644
--- a/frontend/editor/src/core/tests/live/encrypted-unlock-then-tool.spec.ts
+++ b/frontend/editor/src/core/tests/live/encrypted-unlock-then-tool.spec.ts
@@ -7,7 +7,7 @@ import {
} from "@app/tests/helpers/ui-helpers";
import path from "path";
-const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
+const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const ENCRYPTED_PDF = path.join(FIXTURES_DIR, "encrypted.pdf");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
diff --git a/frontend/editor/src/core/tests/stubbed/add-page-numbers-tool.spec.ts b/frontend/editor/src/core/tests/stubbed/add-page-numbers-tool.spec.ts
index ecb6f6ce0b..8fa6a1940c 100644
--- a/frontend/editor/src/core/tests/stubbed/add-page-numbers-tool.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/add-page-numbers-tool.spec.ts
@@ -2,7 +2,10 @@ import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import path from "path";
-const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
+const SAMPLE_PDF = path.join(
+ import.meta.dirname,
+ "../test-fixtures/sample.pdf",
+);
/**
* Add Page Numbers walks the user through a multi-step config: position
diff --git a/frontend/editor/src/core/tests/stubbed/add-stamp-tool.spec.ts b/frontend/editor/src/core/tests/stubbed/add-stamp-tool.spec.ts
index 56236d5bb4..6c6adbb4ac 100644
--- a/frontend/editor/src/core/tests/stubbed/add-stamp-tool.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/add-stamp-tool.spec.ts
@@ -2,7 +2,10 @@ import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import path from "path";
-const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
+const SAMPLE_PDF = path.join(
+ import.meta.dirname,
+ "../test-fixtures/sample.pdf",
+);
/**
* AddStamp loads, accepts a PDF upload, and remains interactive.
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 807e4231d4..f72d642a8e 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
@@ -3,7 +3,7 @@ import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import type { Page, Route } from "@playwright/test";
import path from "path";
-const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
+const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
// app-config the desktop bundle would return: hardware signing is offered only there.
diff --git a/frontend/editor/src/core/tests/stubbed/certificate-validation.spec.ts b/frontend/editor/src/core/tests/stubbed/certificate-validation.spec.ts
index 075464c587..a00a91c6ba 100644
--- a/frontend/editor/src/core/tests/stubbed/certificate-validation.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/certificate-validation.spec.ts
@@ -5,7 +5,7 @@ import { suppressNativeFilePicker } from "@app/tests/helpers/ui-helpers";
// ---------------------------------------------------------------------------
// Test fixtures — pre-generated keystores in test-fixtures/certs/
// ---------------------------------------------------------------------------
-const CERTS_DIR = path.join(__dirname, "../test-fixtures/certs");
+const CERTS_DIR = path.join(import.meta.dirname, "../test-fixtures/certs");
const VALID_P12 = path.join(CERTS_DIR, "valid-test.p12");
const EXPIRED_P12 = path.join(CERTS_DIR, "expired-test.p12");
const NOT_YET_VALID_P12 = path.join(CERTS_DIR, "not-yet-valid-test.p12");
diff --git a/frontend/editor/src/core/tests/stubbed/comments-sidebar-order.spec.ts b/frontend/editor/src/core/tests/stubbed/comments-sidebar-order.spec.ts
index 097b162c85..91206b1222 100644
--- a/frontend/editor/src/core/tests/stubbed/comments-sidebar-order.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/comments-sidebar-order.spec.ts
@@ -2,7 +2,7 @@ import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
const ANNOTATED_PDF = path.join(
- __dirname,
+ import.meta.dirname,
"../test-fixtures/annotations_out_of_order.pdf",
);
diff --git a/frontend/editor/src/core/tests/stubbed/compare.spec.ts b/frontend/editor/src/core/tests/stubbed/compare.spec.ts
index 6bcaaee538..6893721465 100644
--- a/frontend/editor/src/core/tests/stubbed/compare.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/compare.spec.ts
@@ -22,7 +22,7 @@ import { test, expect, type Page } from "@playwright/test";
import path from "path";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
-const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
+const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const PDF_A = path.join(FIXTURES_DIR, "compare_sample_a.pdf");
const PDF_B = path.join(FIXTURES_DIR, "compare_sample_b.pdf");
diff --git a/frontend/editor/src/core/tests/stubbed/convert.spec.ts b/frontend/editor/src/core/tests/stubbed/convert.spec.ts
index de8fa17a9c..52feefcb98 100644
--- a/frontend/editor/src/core/tests/stubbed/convert.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/convert.spec.ts
@@ -10,7 +10,7 @@ import path from "path";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
import { suppressNativeFilePicker } from "@app/tests/helpers/ui-helpers";
-const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
+const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
// ---------------------------------------------------------------------------
diff --git a/frontend/editor/src/core/tests/stubbed/encrypted-pdf-unlock.spec.ts b/frontend/editor/src/core/tests/stubbed/encrypted-pdf-unlock.spec.ts
index 2a202b6cfe..430f5e4f24 100644
--- a/frontend/editor/src/core/tests/stubbed/encrypted-pdf-unlock.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/encrypted-pdf-unlock.spec.ts
@@ -23,7 +23,7 @@ import fs from "fs";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
import { suppressNativeFilePicker } from "@app/tests/helpers/ui-helpers";
-const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
+const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const ENCRYPTED_PDF = path.join(FIXTURES_DIR, "encrypted.pdf");
const FAKE_UNLOCKED_PDF = Buffer.from(
diff --git a/frontend/editor/src/core/tests/stubbed/file-state-across-tools.spec.ts b/frontend/editor/src/core/tests/stubbed/file-state-across-tools.spec.ts
index 6c84f6020a..09708bbb0a 100644
--- a/frontend/editor/src/core/tests/stubbed/file-state-across-tools.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/file-state-across-tools.spec.ts
@@ -2,7 +2,7 @@ import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import path from "path";
-const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
+const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
/**
diff --git a/frontend/editor/src/core/tests/stubbed/merge-response-handling.spec.ts b/frontend/editor/src/core/tests/stubbed/merge-response-handling.spec.ts
index 8dd5662c3d..20a96e32f4 100644
--- a/frontend/editor/src/core/tests/stubbed/merge-response-handling.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/merge-response-handling.spec.ts
@@ -13,7 +13,7 @@ import fs from "fs";
// `result.zip` instead of the merged file. The UI fix uses signature-based
// detection - %PDF wins regardless of Content-Type.
-const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
+const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
const SAMPLE_PDF_BYTES = fs.readFileSync(SAMPLE_PDF);
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 5912c89211..aeb6eb0de2 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
@@ -10,7 +10,10 @@ import { uploadFiles, dismissTourTooltip } from "@app/tests/helpers/ui-helpers";
// Fixture: 4 portrait pages whose intrinsic /Rotate is 0, 90, 270, 180.
// Page 3 (index 2) is 270 so a single rotate-right lands on a net-0 target -
// the exact case the export used to drop, leaving the source rotation behind.
-const ROTATED_PDF = path.join(__dirname, "../test-fixtures/rotated-pages.pdf");
+const ROTATED_PDF = path.join(
+ import.meta.dirname,
+ "../test-fixtures/rotated-pages.pdf",
+);
const SOURCE_ROTATIONS = [0, 90, 270, 180];
/** Read the rotation each thumbnail is currently displaying (= page.rotation). */
diff --git a/frontend/editor/src/core/tests/stubbed/pdf-text-search.spec.ts b/frontend/editor/src/core/tests/stubbed/pdf-text-search.spec.ts
index ce4bbce081..89d2fcfcce 100644
--- a/frontend/editor/src/core/tests/stubbed/pdf-text-search.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/pdf-text-search.spec.ts
@@ -1,7 +1,10 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
-const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
+const SAMPLE_PDF = path.join(
+ import.meta.dirname,
+ "../test-fixtures/sample.pdf",
+);
/**
* The reader/viewer exposes an in-PDF text search via CustomSearchLayer.
diff --git a/frontend/editor/src/core/tests/stubbed/seed.spec.ts b/frontend/editor/src/core/tests/stubbed/seed.spec.ts
index e85e7a723f..6b50e198aa 100644
--- a/frontend/editor/src/core/tests/stubbed/seed.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/seed.spec.ts
@@ -22,7 +22,14 @@ function resolveFixturePath(filename: string): string {
filename,
),
path.join(process.cwd(), "src", "core", "tests", "test-fixtures", filename),
- path.join(__dirname, "..", "core", "tests", "test-fixtures", filename),
+ path.join(
+ import.meta.dirname,
+ "..",
+ "core",
+ "tests",
+ "test-fixtures",
+ filename,
+ ),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
diff --git a/frontend/editor/src/core/tests/stubbed/tour-onboarding.spec.ts b/frontend/editor/src/core/tests/stubbed/tour-onboarding.spec.ts
index 658407e263..8d4d07cd8c 100644
--- a/frontend/editor/src/core/tests/stubbed/tour-onboarding.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/tour-onboarding.spec.ts
@@ -17,7 +17,10 @@ import { uploadFiles, openSettings } from "@app/tests/helpers/ui-helpers";
* - whatsNewStepsConfig.ts
*/
-const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
+const SAMPLE_PDF = path.join(
+ import.meta.dirname,
+ "../test-fixtures/sample.pdf",
+);
// ---------------------------------------------------------------------------
// 15.1 Static layout - always visible on the main page
diff --git a/frontend/editor/src/core/tests/stubbed/validate-signature-trust.spec.ts b/frontend/editor/src/core/tests/stubbed/validate-signature-trust.spec.ts
index e699451d9e..322b7cc88f 100644
--- a/frontend/editor/src/core/tests/stubbed/validate-signature-trust.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/validate-signature-trust.spec.ts
@@ -3,7 +3,7 @@ import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import type { Page, Route } from "@playwright/test";
import path from "path";
-const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
+const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
// Base backend SignatureValidationResult; tests override the trust-related fields.
diff --git a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts
index ad3527c9c3..67df3865e1 100644
--- a/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/viewer-sidebar-add-buttons.spec.ts
@@ -17,7 +17,10 @@ import path from "path";
* Backend-free spec.
*/
-const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
+const SAMPLE_PDF = path.join(
+ import.meta.dirname,
+ "../test-fixtures/sample.pdf",
+);
async function openViewerWithSample(page: import("@playwright/test").Page) {
await page.goto("/read");
diff --git a/frontend/editor/src/core/tests/stubbed/viewer-text-selection.spec.ts b/frontend/editor/src/core/tests/stubbed/viewer-text-selection.spec.ts
index 1a7486e37a..d9a08ae9e7 100644
--- a/frontend/editor/src/core/tests/stubbed/viewer-text-selection.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/viewer-text-selection.spec.ts
@@ -1,7 +1,7 @@
import path from "path";
import { test, expect } from "@app/tests/helpers/stub-test-base";
-const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
+const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
const MULTIPAGE_PDF = path.join(FIXTURES_DIR, "annotations_out_of_order.pdf");
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 47ad9cd57c..e2d3ffcb71 100644
--- a/frontend/editor/src/core/tests/stubbed/watermark-tool.spec.ts
+++ b/frontend/editor/src/core/tests/stubbed/watermark-tool.spec.ts
@@ -2,7 +2,10 @@ import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import path from "path";
-const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
+const SAMPLE_PDF = path.join(
+ import.meta.dirname,
+ "../test-fixtures/sample.pdf",
+);
/**
* Watermark has three modes — text / image / file overlay — selected via
diff --git a/frontend/editor/tailwind.config.js b/frontend/editor/tailwind.config.js
index b38eea76ee..37af74b68a 100644
--- a/frontend/editor/tailwind.config.js
+++ b/frontend/editor/tailwind.config.js
@@ -1,5 +1,5 @@
/** @type {import('tailwindcss').Config} */
-module.exports = {
+export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: ["class", '[data-mantine-color-scheme="dark"]'],
theme: {
diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs
index 4a3df8ad10..81698ddde6 100644
--- a/frontend/eslint.config.mjs
+++ b/frontend/eslint.config.mjs
@@ -64,7 +64,6 @@ export default defineConfig(
},
],
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
- "@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-unused-vars": [
"error",
{
diff --git a/frontend/package.json b/frontend/package.json
index e9d57208e2..f9f035fa0d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -2,6 +2,7 @@
"name": "frontend",
"version": "0.1.0",
"private": true,
+ "type": "module",
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"proxy": "http://localhost:8080",
"dependencies": {
diff --git a/frontend/scripts/update-minor.js b/frontend/scripts/update-minor.js
index 3765265656..c0bb9467c2 100644
--- a/frontend/scripts/update-minor.js
+++ b/frontend/scripts/update-minor.js
@@ -5,7 +5,7 @@
* Calculates date from 7 days ago and runs npm update/audit with that date
*/
-const { spawn } = require("child_process");
+import { spawn } from "node:child_process";
// Calculate date from 7 days ago in YYYY-MM-DD format
const date = new Date();
From 7bd38261788be970c4cd32eadeec6e8b83945133 Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Tue, 7 Jul 2026 13:02:02 +0100
Subject: [PATCH 06/43] Portal procurement: real pricing/trial/quote spine +
linked-gated checkout (vertical slice) (#6861)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this is
The enterprise procurement flow, built into the customer portal as a
**vertical slice** — one linked account can go the whole way from trial
to a paid, committed subscription, using real Stripe under the hood.
Procurement no longer lives as a nav tab. It sits on **Home** as a
deal-status hero and expands into a full-screen takeover, matching the
marketing prototype.
## The journey (what a customer does)
- **Start a trial** in one click — the deadline and next steps show on
the Home hero (no card, mock licence).
- **Build a quote** — a short form (volume → commitment & service →
details); pricing is computed server-side.
- **Generate the quote** — this creates a real **Stripe Quote** with a
proper **PDF** you can download and share, and it becomes a milestone
you can come back to.
- **Review & sign the agreement** — one combined agreement (MSA + Order
Form + EULA + DPA) with an itemised order form and an "I agree" (no
e-signature yet).
- **Accept** — Stripe creates the committed annual **subscription** and
its **first invoice**, which you can **pay or download right in the
app** (no waiting on email).
- Edit a quote any time — it remembers your inputs and company name; the
old Stripe quote is cancelled so it can't still be accepted.
- The hero also has quick actions: **key documents**, **invite
teammates**, **schedule a call**, and a **trial countdown** you can
extend.
## Architecture — Supabase vs Java
Pricing, deal/quote state, and the commercial journey live in **Java
(`:saas`)**. Everything that touches **Stripe** (writes + PDFs) lives in
**Supabase edge functions** — Java has no Stripe SDK and only reads
Stripe via the sync mirror. The portal calls both.
```mermaid
flowchart LR
Portal["Portal (React · editor/src/portal)"]
subgraph JAVA["Java :saas backend (trusted cloud)"]
Pricing["Pricing engine (volume bands, SLA, term, add-ons)"]
Deal["Deal + quote state, journey, snapshot"]
Trial["Trial (mock Keygen licence seam)"]
Authz["Auth: team resolve + leader gating"]
Mirror["Reads Stripe via sync mirror (stripe.* tables)"]
end
subgraph SUPA["Supabase edge functions (own Stripe)"]
Issue["issue-procurement-quote → create + finalize Stripe Quote"]
Accept["accept-procurement-quote → subscription + finalize invoice"]
Pdf["get-procurement-quote-pdf → proxy the quote PDF"]
RPC["SECURITY DEFINER RPCs (read/write stirling_pdf, enforce team/leader)"]
end
Stripe["Stripe (Quotes · Subscription · Invoice)"]
Portal -->|"price / build / trial / agreement / snapshot"| JAVA
Portal -->|"issue / accept / download PDF"| SUPA
SUPA --> Stripe
SUPA --- RPC
Mirror -. reads .-> Stripe
```
| Top-level feature | Handled in |
|---|---|
| Quote pricing (bands, SLA, term, add-ons) | **Java** |
| Deal + quote state, journey, snapshot | **Java** |
| Trial start / extend (mock licence) | **Java** |
| AuthN/Z (team resolve, leader gating) | **Java** |
| Issue quote → Stripe Quote + PDF | **Supabase edge fn** |
| Accept → subscription + invoice | **Supabase edge fn** |
| Quote PDF download | **Supabase edge fn** |
| Reading Stripe state | **Java** (sync mirror) |
| `stirling_pdf` writes from edge | **SECURITY DEFINER RPCs**
(service-role only) |
## Screenshots
**Home deal-status hero (trial)**
**Issued quote milestone (with breakdown)**
**Agreement step (itemised order form)**
**Key documents**
**Subscription created (pay / download invoice)**
## Mocked for now (scaffolding, not wired to real backends)
- **Key documents** ledger — static demo list.
- **Schedule a call** — static solutions-engineer + time slots.
- **Invite teammates** — routes to the existing Users view.
- **Simulate payment received** / **Reset procurement** — demo controls,
**off by default** in prod (flag-gated), 404 unless enabled.
## Deferred (separate follow-up PRs)
- **Real `invoice.paid` webhook** → go-live (today a demo button stands
in).
- **Keygen licence controller** — real licensing (currently a mock
seam).
- **Document sharing**.
- **Stirling admin / Deal Desk** view.
- **Minimum ACV floor** — pending a number from marketing (server-side
enforcement is a one-liner once decided).
## How to test
- **Frontend, no backend:** runs against MSW mocks (Storybook + mocks-on
dev) — the whole journey is clickable.
- **Real end-to-end:** apply the migrations (Flyway `V27–V29` / Supabase
`20260701–20260707`), deploy the three edge functions, ensure
**Invoicing Plus** is enabled on Stripe, and set
`STIRLING_PROCUREMENT_DEMO_CONTROLS_ENABLED=true` if you want the demo
controls.
- Paired SaaS PR: **Stirling-Tools/Stirling-PDF-SaaS#318**.
## Notes for reviewers
- Pricing is server-authoritative (client sends config, never amounts).
- Security review done: edge functions validate the JWT and enforce
**team membership** (and **leader** for issue/accept) via the RPC; demo
endpoints are flag-gated off. Only open item is the ACV floor (policy).
---
.../software/saas/config/SaasJpaConfig.java | 6 +-
.../api/ProcurementController.java | 321 ++++++
.../ProcurementConfigurationProperties.java | 33 +
.../license/EnterpriseLicenseService.java | 25 +
.../license/MockEnterpriseLicenseService.java | 54 +
.../procurement/model/ProcurementDeal.java | 87 ++
.../procurement/model/ProcurementQuote.java | 119 ++
.../procurement/pricing/PricingRates.java | 53 +
.../pricing/ProcurementPricingService.java | 125 ++
.../procurement/pricing/QuoteBreakdown.java | 12 +
.../saas/procurement/pricing/QuoteConfig.java | 26 +
.../procurement/pricing/QuoteLineItem.java | 16 +
.../repository/ProcurementDealRepository.java | 17 +
.../ProcurementQuoteRepository.java | 12 +
.../service/ProcurementService.java | 244 ++++
.../db/migration/saas/V27__procurement.sql | 72 ++
.../saas/V28__procurement_stripe_quote.sql | 8 +
.../saas/V29__procurement_business_name.sql | 5 +
.../ProcurementPricingServiceTest.java | 87 ++
frontend/.gitignore | 1 +
frontend/.storybook/preview.tsx | 6 +-
.../public/locales/en-US/translation.toml | 129 ++-
frontend/editor/src/portal/api/procurement.ts | 163 +++
.../editor/src/portal/components/Sidebar.tsx | 12 +-
.../components/billing/EnterpriseUpsell.tsx | 12 +-
.../procurement/DealStatusHero.stories.tsx | 47 +
.../components/procurement/DealStatusHero.tsx | 162 +++
.../procurement/ProcurementAgreement.tsx | 126 +++
.../procurement/ProcurementExtras.tsx | 299 +++++
.../procurement/ProcurementHome.stories.tsx | 18 +
.../procurement/ProcurementHome.tsx | 296 +++++
.../procurement/ProcurementModal.stories.tsx | 42 +
.../procurement/ProcurementModal.tsx | 102 ++
.../procurement/ProcurementStages.tsx | 160 +++
.../procurement/QuoteBuilder.stories.tsx | 20 +
.../components/procurement/QuoteBuilder.tsx | 436 +++++++
.../portal/components/procurement/format.ts | 9 +
.../editor/src/portal/mocks/handlers/index.ts | 2 +
.../portal/mocks/handlers/procurementSaas.ts | 241 ++++
frontend/editor/src/portal/views/Home.tsx | 3 +
.../editor/src/portal/views/Procurement.css | 1007 +++++++++++++++++
.../editor/src/portal/views/Procurement.tsx | 106 +-
42 files changed, 4603 insertions(+), 118 deletions(-)
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java
create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java
create mode 100644 app/saas/src/main/resources/db/migration/saas/V27__procurement.sql
create mode 100644 app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql
create mode 100644 app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql
create mode 100644 app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java
create mode 100644 frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx
create mode 100644 frontend/editor/src/portal/components/procurement/DealStatusHero.tsx
create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx
create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx
create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx
create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementHome.tsx
create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx
create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementModal.tsx
create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementStages.tsx
create mode 100644 frontend/editor/src/portal/components/procurement/QuoteBuilder.stories.tsx
create mode 100644 frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx
create mode 100644 frontend/editor/src/portal/mocks/handlers/procurementSaas.ts
diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
index 5e90df6bf5..53a5f34c15 100644
--- a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
+++ b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
@@ -18,13 +18,15 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
"stirling.software.saas.repository",
"stirling.software.saas.billing.repository",
"stirling.software.saas.ai.repository",
- "stirling.software.saas.payg.repository"
+ "stirling.software.saas.payg.repository",
+ "stirling.software.saas.procurement.repository"
})
@EntityScan({
"stirling.software.saas.accountlink",
"stirling.software.saas.model",
"stirling.software.saas.billing.model",
"stirling.software.saas.ai.model",
- "stirling.software.saas.payg"
+ "stirling.software.saas.payg",
+ "stirling.software.saas.procurement.model"
})
public class SaasJpaConfig {}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java
new file mode 100644
index 0000000000..1f27896b87
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java
@@ -0,0 +1,321 @@
+package stirling.software.saas.procurement.api;
+
+import java.util.List;
+import java.util.Objects;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.swagger.v3.oas.annotations.Hidden;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.model.enumeration.TeamRole;
+import stirling.software.proprietary.security.database.repository.UserRepository;
+import stirling.software.proprietary.security.model.User;
+import stirling.software.saas.model.TeamMembership;
+import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
+import stirling.software.saas.procurement.model.ProcurementDeal;
+import stirling.software.saas.procurement.model.ProcurementQuote;
+import stirling.software.saas.procurement.pricing.QuoteConfig;
+import stirling.software.saas.procurement.pricing.QuoteLineItem;
+import stirling.software.saas.procurement.service.ProcurementService;
+import stirling.software.saas.repository.TeamMembershipRepository;
+import stirling.software.saas.util.AuthenticationUtils;
+
+/**
+ * The enterprise procurement journey for a linked team: read the deal snapshot, start/extend a
+ * (mock-licensed) trial, build a server-priced quote, and accept it. Stripe checkout itself is a
+ * Supabase edge function the portal calls with the accepted quote — this controller never touches
+ * Stripe. The caller's team is resolved from the authenticated principal; a team id is never
+ * trusted from the request. Mutations require the team leader.
+ */
+@Slf4j
+@Hidden
+@RestController
+@RequestMapping("/api/v1/procurement")
+@Profile("saas")
+public class ProcurementController {
+
+ // Local mapper to parse the stored line-items JSON; the saas context exposes no injectable
+ // ObjectMapper bean.
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private final ProcurementService procurement;
+ private final TeamMembershipRepository memberRepo;
+ private final UserRepository userRepository;
+ private final ProcurementConfigurationProperties config;
+
+ public ProcurementController(
+ ProcurementService procurement,
+ TeamMembershipRepository memberRepo,
+ UserRepository userRepository,
+ ProcurementConfigurationProperties config) {
+ this.procurement = Objects.requireNonNull(procurement);
+ this.memberRepo = Objects.requireNonNull(memberRepo);
+ this.userRepository = Objects.requireNonNull(userRepository);
+ this.config = Objects.requireNonNull(config);
+ }
+
+ // ---- request / response DTOs -------------------------------------------
+
+ public record QuoteRequest(
+ long volume,
+ int users,
+ String deployment,
+ int termYears,
+ String serviceLevel,
+ boolean indemnification,
+ boolean training,
+ boolean qbr,
+ String currency,
+ String businessName) {
+ QuoteConfig toConfig() {
+ return new QuoteConfig(
+ volume,
+ users,
+ deployment,
+ termYears,
+ serviceLevel,
+ indemnification,
+ training,
+ qbr,
+ currency);
+ }
+ }
+
+ public record QuoteResponse(
+ Long quoteId,
+ String quoteNumber,
+ String status,
+ String currency,
+ long annualNetMinor,
+ long tcvMinor,
+ List lineItems,
+ String validUntil,
+ String stripeQuoteId,
+ String invoiceUrl,
+ QuoteConfigEcho config) {}
+
+ /**
+ * The inputs the quote was priced from, echoed back so the builder can seed itself when the
+ * buyer re-edits an existing quote. {@code users} is not persisted (only the resulting volume
+ * is), so it is always 0 here; the builder treats the seeded volume as manually set.
+ */
+ public record QuoteConfigEcho(
+ long volume,
+ int users,
+ String deployment,
+ int termYears,
+ String serviceLevel,
+ boolean indemnification,
+ boolean training,
+ boolean qbr,
+ String currency,
+ String businessName) {}
+
+ public record SnapshotResponse(
+ Long dealId,
+ String stage,
+ String trialStartedAt,
+ String trialEndsAt,
+ int trialExtensionsUsed,
+ boolean licensed,
+ QuoteResponse latestQuote) {}
+
+ // ---- endpoints ----------------------------------------------------------
+
+ /**
+ * The team's deal snapshot. Always 200 with a single shape; an unstarted procurement returns an
+ * empty snapshot ({@code dealId == null}) so the portal can render the "start" state without
+ * special-casing an empty body.
+ */
+ @GetMapping
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity snapshot(Authentication auth) {
+ Long teamId = resolveTeam(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ return ResponseEntity.ok(
+ procurement.getDeal(teamId).map(this::toSnapshot).orElse(EMPTY_SNAPSHOT));
+ }
+
+ private static final SnapshotResponse EMPTY_SNAPSHOT =
+ new SnapshotResponse(null, null, null, null, 0, false, null);
+
+ @PostMapping("/trial/start")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity startTrial(Authentication auth) {
+ Long teamId = requireLeader(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId)));
+ }
+
+ @PostMapping("/trial/extend")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity extendTrial(Authentication auth) {
+ Long teamId = requireLeader(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ try {
+ return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId)));
+ } catch (IllegalStateException e) {
+ return ResponseEntity.status(HttpStatus.CONFLICT).build();
+ }
+ }
+
+ @PostMapping("/quote")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity buildQuote(
+ @RequestBody QuoteRequest request, Authentication auth) {
+ Long teamId = requireLeader(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ return ResponseEntity.ok(
+ toQuote(
+ procurement.buildQuote(
+ teamId, request.toConfig(), request.businessName())));
+ }
+
+ // Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
+ // draft into a finalized Stripe Quote; accept-procurement-quote accepts it into a subscription.
+ // Both persist their results via SECURITY DEFINER RPCs; the snapshot above reflects them.
+
+ /**
+ * Advance an issued quote to the agreement (security) stage, where the buyer reviews + agrees.
+ */
+ @PostMapping("/agreement")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity startAgreement(Authentication auth) {
+ Long teamId = requireLeader(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ try {
+ return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId)));
+ } catch (IllegalStateException e) {
+ return ResponseEntity.status(HttpStatus.CONFLICT).build();
+ }
+ }
+
+ /**
+ * Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
+ * annual licence, advance to active). The real go-live is webhook-driven once payment settles.
+ */
+ @PostMapping("/go-live")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity goLive(Authentication auth) {
+ if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
+ Long teamId = requireLeader(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ try {
+ return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId)));
+ } catch (IllegalStateException e) {
+ return ResponseEntity.status(HttpStatus.CONFLICT).build();
+ }
+ }
+
+ /** Reset the team's procurement (delete the deal + quotes); returns the empty snapshot. */
+ @PostMapping("/reset")
+ @PreAuthorize("isAuthenticated()")
+ public ResponseEntity reset(Authentication auth) {
+ if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
+ Long teamId = requireLeader(auth);
+ if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
+ procurement.resetDeal(teamId);
+ return ResponseEntity.ok(EMPTY_SNAPSHOT);
+ }
+
+ // ---- helpers ------------------------------------------------------------
+
+ /**
+ * Resolve the caller's team from their primary membership; null when unauthenticated/teamless.
+ */
+ private Long resolveTeam(Authentication auth) {
+ User user;
+ try {
+ user = AuthenticationUtils.getCurrentUser(auth, userRepository);
+ } catch (SecurityException e) {
+ return null;
+ }
+ List rows = memberRepo.findPrimaryMembership(user.getId());
+ return rows.isEmpty() ? null : rows.get(0).getTeam().getId();
+ }
+
+ /** Team id only when the caller is the team leader; null otherwise (commercial actions). */
+ private Long requireLeader(Authentication auth) {
+ User user;
+ try {
+ user = AuthenticationUtils.getCurrentUser(auth, userRepository);
+ } catch (SecurityException e) {
+ return null;
+ }
+ List rows = memberRepo.findPrimaryMembership(user.getId());
+ if (rows.isEmpty() || rows.get(0).getRole() != TeamRole.LEADER) return null;
+ return rows.get(0).getTeam().getId();
+ }
+
+ private SnapshotResponse toSnapshot(ProcurementDeal deal) {
+ QuoteResponse latest =
+ procurement.quotesForDeal(deal.getDealId()).stream()
+ .findFirst()
+ .map(this::toQuote)
+ .orElse(null);
+ return new SnapshotResponse(
+ deal.getDealId(),
+ deal.getStage(),
+ str(deal.getTrialStartedAt()),
+ str(deal.getTrialEndsAt()),
+ deal.getTrialExtensionsUsed(),
+ deal.getLicenseRef() != null,
+ latest);
+ }
+
+ private QuoteResponse toQuote(ProcurementQuote q) {
+ return new QuoteResponse(
+ q.getQuoteId(),
+ q.getQuoteNumber(),
+ q.getStatus(),
+ q.getCurrency(),
+ q.getAnnualNetMinor(),
+ q.getTcvMinor(),
+ parseLineItems(q.getLineItemsJson()),
+ q.getValidUntil() == null ? null : q.getValidUntil().toString(),
+ q.getStripeQuoteId(),
+ q.getStripeInvoiceUrl(),
+ new QuoteConfigEcho(
+ q.getVolume(),
+ 0,
+ q.getDeployment(),
+ q.getTermYears(),
+ q.getServiceLevel(),
+ q.isIndemnification(),
+ q.isTraining(),
+ q.isQbr(),
+ q.getCurrency(),
+ q.getBusinessName()));
+ }
+
+ private List parseLineItems(String json) {
+ if (json == null || json.isBlank()) return List.of();
+ try {
+ return OBJECT_MAPPER.readValue(
+ json,
+ OBJECT_MAPPER
+ .getTypeFactory()
+ .constructCollectionType(List.class, QuoteLineItem.class));
+ } catch (Exception e) {
+ log.warn("[procurement] failed to parse line items", e);
+ return List.of();
+ }
+ }
+
+ private static String str(Object o) {
+ return o == null ? null : o.toString();
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java b/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java
new file mode 100644
index 0000000000..98ec0cd573
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java
@@ -0,0 +1,33 @@
+package stirling.software.saas.procurement.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/** Tunables for the enterprise procurement flow. Prefix {@code stirling.procurement}. */
+@Getter
+@Setter
+@Component
+@Profile("saas")
+@ConfigurationProperties(prefix = "stirling.procurement")
+public class ProcurementConfigurationProperties {
+
+ /** Free trial length, in days (no card). */
+ private int trialDurationDays = 14;
+
+ /** Days added per trial extension. */
+ private int trialExtensionDays = 7;
+
+ /** Maximum number of trial extensions a buyer may take. */
+ private int maxTrialExtensions = 2;
+
+ /**
+ * Enables the demo-only endpoints (POST /reset, POST /go-live) that reset a team's procurement
+ * or mark it live without payment. Off by default; turn on ONLY in demo/dev environments —
+ * /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid.
+ */
+ private boolean demoControlsEnabled = false;
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java
new file mode 100644
index 0000000000..0f0c242601
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java
@@ -0,0 +1,25 @@
+package stirling.software.saas.procurement.license;
+
+import java.time.LocalDateTime;
+
+/**
+ * Issues and modifies the customer-facing entitlement that actually unlocks the product for an
+ * enterprise deal — a Keygen licence (trial or annual, connected or air-gapped). This is the seam
+ * the real Keygen management client plugs into; today {@link MockEnterpriseLicenseService} records
+ * intent without calling Keygen. Distinct from the EE {@code KeygenLicenseVerifier}, which only
+ * verifies this instance's own licence.
+ */
+public interface EnterpriseLicenseService {
+
+ /** Issue a time-boxed trial licence for the team; returns the licence reference. */
+ String issueTrialLicense(Long teamId, LocalDateTime expiresAt);
+
+ /** Move a licence's expiry out (trial extension). */
+ void extendLicense(String licenseRef, LocalDateTime newExpiry);
+
+ /** Issue/upgrade to a committed annual licence with the quote's entitlements. */
+ String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt);
+
+ /** Suspend a licence (e.g. payment failed, deal lost). */
+ void suspendLicense(String licenseRef);
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java
new file mode 100644
index 0000000000..bde31a4119
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java
@@ -0,0 +1,54 @@
+package stirling.software.saas.procurement.license;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Mock implementation of {@link EnterpriseLicenseService}: records the intended licence action and
+ * returns a synthetic reference, without calling Keygen. Lets the whole procurement journey run
+ * end-to-end while the real Keygen management client is a later drop-in — the seam and the stored
+ * {@code license_ref} on the deal stay identical.
+ */
+@Slf4j
+@Service
+@Profile("saas")
+public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
+
+ @Override
+ public String issueTrialLicense(Long teamId, LocalDateTime expiresAt) {
+ String ref = "mock-trial-" + UUID.randomUUID();
+ log.info(
+ "[procurement][mock-license] issue trial team={} expires={} ref={}",
+ teamId,
+ expiresAt,
+ ref);
+ return ref;
+ }
+
+ @Override
+ public void extendLicense(String licenseRef, LocalDateTime newExpiry) {
+ log.info("[procurement][mock-license] extend ref={} newExpiry={}", licenseRef, newExpiry);
+ }
+
+ @Override
+ public String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt) {
+ String ref = "mock-annual-" + UUID.randomUUID();
+ log.info(
+ "[procurement][mock-license] issue annual team={} deployment={} expires={} ref={}",
+ teamId,
+ deployment,
+ expiresAt,
+ ref);
+ return ref;
+ }
+
+ @Override
+ public void suspendLicense(String licenseRef) {
+ log.info("[procurement][mock-license] suspend ref={}", licenseRef);
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java
new file mode 100644
index 0000000000..f97d66f365
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java
@@ -0,0 +1,87 @@
+package stirling.software.saas.procurement.model;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.Version;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * A linked team's enterprise commercial journey (one per team). Stage mirrors the buyer journey the
+ * portal renders (trial -> quote -> agreement -> payment -> live). The entitlement that
+ * actually unlocks the product is the Keygen licence in {@code licenseRef}; the paid subscription,
+ * once commercial, is mirrored in {@code billing_subscriptions} and referenced by {@code
+ * subscriptionId}.
+ */
+@Entity
+@Table(name = "procurement_deal")
+@NoArgsConstructor
+@Getter
+@Setter
+public class ProcurementDeal implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final String STAGE_TRIAL = "trial";
+ public static final String STAGE_QUOTE = "quote";
+ public static final String STAGE_AGREEMENT = "security";
+ public static final String STAGE_PAYMENT = "procurement";
+ public static final String STAGE_LIVE = "active";
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "deal_id")
+ private Long dealId;
+
+ @Column(name = "team_id", nullable = false, unique = true)
+ private Long teamId;
+
+ @Column(name = "stage", nullable = false, length = 32)
+ private String stage = STAGE_TRIAL;
+
+ @Column(name = "trial_started_at")
+ private LocalDateTime trialStartedAt;
+
+ @Column(name = "trial_ends_at")
+ private LocalDateTime trialEndsAt;
+
+ @Column(name = "trial_extensions_used", nullable = false)
+ private int trialExtensionsUsed;
+
+ @Column(name = "license_ref", length = 128)
+ private String licenseRef;
+
+ @Column(name = "subscription_id", length = 255)
+ private String subscriptionId;
+
+ @Column(name = "accepted_quote_id")
+ private Long acceptedQuoteId;
+
+ @CreationTimestamp
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @UpdateTimestamp
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ @Version
+ @Column(name = "version", nullable = false)
+ private Long version;
+
+ public ProcurementDeal(Long teamId) {
+ this.teamId = teamId;
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java
new file mode 100644
index 0000000000..d8f4c3edb7
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java
@@ -0,0 +1,119 @@
+package stirling.software.saas.procurement.model;
+
+import java.io.Serializable;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.Version;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+/**
+ * A priced, itemised offer built against a {@link ProcurementDeal}. The config columns are the
+ * buyer's choices; {@code annualNetMinor}/{@code tcvMinor} and {@code lineItemsJson} are the
+ * server-computed result (never trusted from the client). Stripe fields are populated when the
+ * accepted quote is turned into a checkout.
+ */
+@Entity
+@Table(name = "procurement_quote")
+@NoArgsConstructor
+@Getter
+@Setter
+public class ProcurementQuote implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final String STATUS_DRAFT = "draft";
+ public static final String STATUS_SENT = "sent";
+ public static final String STATUS_ACCEPTED = "accepted";
+ public static final String STATUS_EXPIRED = "expired";
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "quote_id")
+ private Long quoteId;
+
+ @Column(name = "deal_id", nullable = false)
+ private Long dealId;
+
+ @Column(name = "quote_number", nullable = false, length = 64)
+ private String quoteNumber;
+
+ @Column(name = "status", nullable = false, length = 24)
+ private String status = STATUS_DRAFT;
+
+ @Column(name = "currency", nullable = false, length = 8)
+ private String currency = "USD";
+
+ @Column(name = "volume", nullable = false)
+ private long volume;
+
+ @Column(name = "seats")
+ private Integer seats;
+
+ @Column(name = "deployment", length = 24)
+ private String deployment;
+
+ @Column(name = "term_years", nullable = false)
+ private int termYears;
+
+ @Column(name = "service_level", nullable = false, length = 24)
+ private String serviceLevel;
+
+ @Column(name = "indemnification", nullable = false)
+ private boolean indemnification;
+
+ @Column(name = "training", nullable = false)
+ private boolean training;
+
+ @Column(name = "qbr", nullable = false)
+ private boolean qbr;
+
+ @Column(name = "annual_net_minor", nullable = false)
+ private long annualNetMinor;
+
+ @Column(name = "tcv_minor", nullable = false)
+ private long tcvMinor;
+
+ @Column(name = "line_items", columnDefinition = "text")
+ private String lineItemsJson;
+
+ // The Stripe Quote this was issued as (finalized → has a number + PDF). Set by the edge fn.
+ @Column(name = "stripe_quote_id", length = 128)
+ private String stripeQuoteId;
+
+ // Hosted Stripe invoice URL for the subscription's first invoice, set once the quote is
+ // accepted.
+ @Column(name = "stripe_invoice_url", columnDefinition = "text")
+ private String stripeInvoiceUrl;
+
+ // Buyer's company name (shown on the quote/agreement); echoed back so an edit remembers it.
+ @Column(name = "business_name", length = 255)
+ private String businessName;
+
+ @Column(name = "valid_until")
+ private LocalDate validUntil;
+
+ @CreationTimestamp
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private LocalDateTime createdAt;
+
+ @UpdateTimestamp
+ @Column(name = "updated_at", nullable = false)
+ private LocalDateTime updatedAt;
+
+ @Version
+ @Column(name = "version", nullable = false)
+ private Long version;
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java
new file mode 100644
index 0000000000..19a3ab759d
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java
@@ -0,0 +1,53 @@
+package stirling.software.saas.procurement.pricing;
+
+/**
+ * The enterprise rate card: the inputs pricing multiplies against. In production these are read
+ * from the Stripe price mirror (see {@code StripeMirrorPriceCatalog}); {@link #defaults()} is the
+ * fallback used when the {@code stripe} schema isn't synced (dev / tests) and is the single source
+ * of the numbers the marketing prototype encodes.
+ *
+ * Per-PDF rates are in minor units (cents) per document. Multipliers are fractions (e.g. 0.15 =
+ * +15%). Flat/one-time fees are in minor units.
+ */
+public record PricingRates(
+ long perPdfMinorUnder1M,
+ long perPdfMinorUnder5M,
+ long perPdfMinor5MPlus,
+ double priorityUplift,
+ double dedicatedUplift,
+ double indemnificationUplift,
+ double[] termDiscountByYear, // index 0 = 1yr … index 4 = 5yr
+ long qbrAnnualMinor,
+ long trainingOneTimeMinor) {
+
+ public static PricingRates defaults() {
+ return new PricingRates(
+ 5, // $0.05 / PDF under 1M/yr
+ 4, // $0.04 / PDF at 1M–5M/yr
+ 3, // $0.03 / PDF at 5M+/yr
+ 0.15, // priority +15%
+ 0.30, // dedicated +30%
+ 0.05, // IP indemnification +5%
+ new double[] {0.0, 0.05, 0.10, 0.12, 0.15},
+ 800_000, // QBRs $8,000 / yr
+ 750_000); // onboarding & training $7,500 one-time
+ }
+
+ /** Volume-banded per-PDF rate for an annual volume, in minor units. */
+ public long perPdfMinor(long annualVolume) {
+ if (annualVolume >= 5_000_000) return perPdfMinor5MPlus;
+ if (annualVolume >= 1_000_000) return perPdfMinorUnder5M;
+ return perPdfMinorUnder1M;
+ }
+
+ public double termDiscount(int termYears) {
+ int idx = Math.max(1, Math.min(termYears, 5)) - 1;
+ return termDiscountByYear[idx];
+ }
+
+ public double serviceLevelUplift(String serviceLevel) {
+ if ("priority".equalsIgnoreCase(serviceLevel)) return priorityUplift;
+ if ("dedicated".equalsIgnoreCase(serviceLevel)) return dedicatedUplift;
+ return 0.0;
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java
new file mode 100644
index 0000000000..62d5baed9d
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java
@@ -0,0 +1,125 @@
+package stirling.software.saas.procurement.pricing;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+
+/**
+ * The canonical enterprise pricing engine — the single server-side definition the quote builder,
+ * the order form, and the Stripe checkout all derive from. A faithful port of the marketing
+ * prototype's {@code quotePricing}:
+ *
+ *
+ * annual = volume x perPdfRate x serviceLevelMult x (indemnification ? 1.05 : 1)
+ * annualNet = round(annual x (1 - termDiscount)) + qbr
+ * tcv = annualNet x termYears + training
+ *
+ *
+ * The multi-year discount applies to usage + service level + indemnification, but NOT to the flat
+ * QBR fee (added after), and one-time training sits outside the recurring total. All money is in
+ * minor units (cents). Rates come from {@link PricingRates} (Stripe-backed in prod).
+ */
+@Service
+public class ProcurementPricingService {
+
+ /** Bespoke/committed deals don't price below this ACV; the builder floors against it. */
+ public static final long MIN_ACV_MINOR = 5_000_000L; // $50,000
+
+ /**
+ * Estimated annual PDF volume from seat count (~2,012.5 PDFs/user/yr = 5 docs/day x 230 working
+ * days x 1.75), used to prefill the builder's volume step. Rounded, matching the prototype's
+ * {@code users x 5 x 230 x 1.75}.
+ */
+ public long estimateAnnualVolume(int users) {
+ return Math.round(Math.max(0, users) * 5.0 * 230.0 * 1.75);
+ }
+
+ public QuoteBreakdown price(QuoteConfig cfg) {
+ return price(cfg, PricingRates.defaults());
+ }
+
+ public QuoteBreakdown price(QuoteConfig cfg, PricingRates rates) {
+ // Never trust the client's volume: clamp to non-negative so a tampered request can't drive
+ // a
+ // negative amount. The rate card and formula are server-side, so the browser can't lower
+ // the
+ // price — only pick a smaller, legitimate config. (See MIN_ACV_MINOR for the committed
+ // floor,
+ // a policy decision that is intentionally not force-applied here — see the review notes.)
+ long volume = Math.max(0, cfg.volume());
+ long perPdf = rates.perPdfMinor(volume);
+ long usage = Math.round(volume * (double) perPdf); // base, pre-service-level
+ double slaUplift = rates.serviceLevelUplift(cfg.serviceLevel());
+ long withSla = Math.round(usage * (1.0 + slaUplift));
+ long withIndemnity =
+ cfg.indemnification()
+ ? Math.round(withSla * (1.0 + rates.indemnificationUplift()))
+ : withSla;
+
+ double termDiscount = rates.termDiscount(cfg.termYears());
+ long discount = Math.round(withIndemnity * termDiscount);
+ long qbr = cfg.qbr() ? rates.qbrAnnualMinor() : 0L;
+ long training = cfg.training() ? rates.trainingOneTimeMinor() : 0L;
+
+ long annualNet = (withIndemnity - discount) + qbr;
+ long tcv = annualNet * cfg.termYears() + training;
+
+ List lines = new ArrayList<>();
+ lines.add(
+ new QuoteLineItem("usage", "PDF processing", QuoteLineItem.Kind.RECURRING, usage));
+ lines.add(
+ new QuoteLineItem(
+ "seats",
+ "Unlimited users + SSO / SCIM / RBAC",
+ QuoteLineItem.Kind.INCLUDED,
+ 0L));
+ if (withSla != usage) {
+ lines.add(
+ new QuoteLineItem(
+ "service-level",
+ serviceLevelLabel(cfg.serviceLevel()),
+ QuoteLineItem.Kind.RECURRING,
+ withSla - usage));
+ }
+ if (withIndemnity != withSla) {
+ lines.add(
+ new QuoteLineItem(
+ "indemnification",
+ "IP indemnification",
+ QuoteLineItem.Kind.RECURRING,
+ withIndemnity - withSla));
+ }
+ if (qbr > 0) {
+ lines.add(
+ new QuoteLineItem(
+ "qbr",
+ "Quarterly business reviews",
+ QuoteLineItem.Kind.RECURRING,
+ qbr));
+ }
+ if (discount > 0) {
+ lines.add(
+ new QuoteLineItem(
+ "multi-year",
+ cfg.termYears() + "-year commitment",
+ QuoteLineItem.Kind.DISCOUNT,
+ -discount));
+ }
+ if (training > 0) {
+ lines.add(
+ new QuoteLineItem(
+ "training",
+ "Onboarding & training",
+ QuoteLineItem.Kind.ONE_TIME,
+ training));
+ }
+ return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency());
+ }
+
+ private static String serviceLevelLabel(String serviceLevel) {
+ if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority service level";
+ if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated service level";
+ return "Standard service level";
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java
new file mode 100644
index 0000000000..10ffde5271
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java
@@ -0,0 +1,12 @@
+package stirling.software.saas.procurement.pricing;
+
+import java.util.List;
+
+/**
+ * The priced result of a {@link QuoteConfig}: the itemised lines plus the two headline figures the
+ * order form and Stripe checkout are built from. {@code annualNetMinor} is the recurring annual fee
+ * after the multi-year discount; {@code tcvMinor} is total contract value across the term including
+ * one-time fees. Minor units (cents).
+ */
+public record QuoteBreakdown(
+ List lineItems, long annualNetMinor, long tcvMinor, String currency) {}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java
new file mode 100644
index 0000000000..30917a73a6
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java
@@ -0,0 +1,26 @@
+package stirling.software.saas.procurement.pricing;
+
+/**
+ * The buyer-configurable inputs to an enterprise quote. Mirrors the quote builder's four steps
+ * (volume, commitment & service, add-ons) and is the sole input to {@link
+ * ProcurementPricingService}. Amounts are never carried here — the service derives them from these
+ * choices and the rate card.
+ */
+public record QuoteConfig(
+ long volume, // committed PDFs per year
+ int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden)
+ String deployment, // cloud | selfhost | airgap (inherited from the trial; not priced)
+ int termYears, // 1..5
+ String serviceLevel, // standard | priority | dedicated
+ boolean indemnification,
+ boolean training,
+ boolean qbr,
+ String currency) { // USD | EUR | GBP
+
+ public QuoteConfig {
+ if (termYears < 1) termYears = 1;
+ if (termYears > 5) termYears = 5;
+ if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard";
+ if (currency == null || currency.isBlank()) currency = "USD";
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java
new file mode 100644
index 0000000000..a8b0d29352
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java
@@ -0,0 +1,16 @@
+package stirling.software.saas.procurement.pricing;
+
+/**
+ * One line on the itemised quote. {@code amountMinor} is in the currency's minor unit (cents);
+ * discounts are negative. {@code kind} drives how the portal groups it (recurring annual vs a
+ * one-time fee vs a discount line).
+ */
+public record QuoteLineItem(String key, String label, Kind kind, long amountMinor) {
+
+ public enum Kind {
+ RECURRING,
+ ONE_TIME,
+ DISCOUNT,
+ INCLUDED
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java
new file mode 100644
index 0000000000..14247ba1ba
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java
@@ -0,0 +1,17 @@
+package stirling.software.saas.procurement.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import stirling.software.saas.procurement.model.ProcurementDeal;
+
+public interface ProcurementDealRepository extends JpaRepository {
+
+ Optional findByTeamId(Long teamId);
+
+ boolean existsByTeamId(Long teamId);
+
+ /** Reset: drop the team's deal (quotes + activity cascade via FK). */
+ void deleteByTeamId(Long teamId);
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java
new file mode 100644
index 0000000000..45f7af8225
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java
@@ -0,0 +1,12 @@
+package stirling.software.saas.procurement.repository;
+
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import stirling.software.saas.procurement.model.ProcurementQuote;
+
+public interface ProcurementQuoteRepository extends JpaRepository {
+
+ List findByDealIdOrderByCreatedAtDesc(Long dealId);
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java
new file mode 100644
index 0000000000..52e19f751e
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java
@@ -0,0 +1,244 @@
+package stirling.software.saas.procurement.service;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
+import stirling.software.saas.procurement.license.EnterpriseLicenseService;
+import stirling.software.saas.procurement.model.ProcurementDeal;
+import stirling.software.saas.procurement.model.ProcurementQuote;
+import stirling.software.saas.procurement.pricing.ProcurementPricingService;
+import stirling.software.saas.procurement.pricing.QuoteBreakdown;
+import stirling.software.saas.procurement.pricing.QuoteConfig;
+import stirling.software.saas.procurement.repository.ProcurementDealRepository;
+import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
+
+/**
+ * Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
+ * server-priced quote, and accept it. Stripe checkout itself lives in a Supabase edge function the
+ * portal calls with the accepted quote; on payment the webhook seeds {@code billing_subscriptions}
+ * and this service issues the annual licence. All amounts are minor units (cents).
+ */
+@Slf4j
+@Service
+@Profile("saas")
+public class ProcurementService {
+
+ // Local mapper for the line-items JSON snapshot; the saas context exposes no injectable
+ // ObjectMapper bean, and this (de)serialisation doesn't need Spring's configured one.
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private final ProcurementDealRepository dealRepo;
+ private final ProcurementQuoteRepository quoteRepo;
+ private final ProcurementPricingService pricing;
+ private final EnterpriseLicenseService licenses;
+ private final ProcurementConfigurationProperties config;
+
+ public ProcurementService(
+ ProcurementDealRepository dealRepo,
+ ProcurementQuoteRepository quoteRepo,
+ ProcurementPricingService pricing,
+ EnterpriseLicenseService licenses,
+ ProcurementConfigurationProperties config) {
+ this.dealRepo = dealRepo;
+ this.quoteRepo = quoteRepo;
+ this.pricing = pricing;
+ this.licenses = licenses;
+ this.config = config;
+ }
+
+ @Transactional(readOnly = true)
+ public Optional getDeal(Long teamId) {
+ return dealRepo.findByTeamId(teamId);
+ }
+
+ @Transactional(readOnly = true)
+ public List quotesForDeal(Long dealId) {
+ return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId);
+ }
+
+ /**
+ * Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial
+ * window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the
+ * Keygen licence, and the deal row is the journey state.
+ */
+ @Transactional
+ public ProcurementDeal startTrial(Long teamId) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime ends = now.plusDays(config.getTrialDurationDays());
+ deal.setStage(ProcurementDeal.STAGE_TRIAL);
+ deal.setTrialStartedAt(now);
+ deal.setTrialEndsAt(ends);
+ deal.setTrialExtensionsUsed(0);
+ deal.setLicenseRef(licenses.issueTrialLicense(teamId, ends));
+ deal = dealRepo.save(deal);
+ log.info(
+ "[procurement] trial started team={} deal={} ends={}",
+ teamId,
+ deal.getDealId(),
+ ends);
+ return deal;
+ }
+
+ /** Extend the current trial by the configured increment, up to the cap. */
+ @Transactional
+ public ProcurementDeal extendTrial(Long teamId) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId)
+ .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
+ // Only extend while still in the trial. Past the trial (e.g. active), licenseRef points at
+ // the committed annual licence — extending would rewind its expiry.
+ if (!ProcurementDeal.STAGE_TRIAL.equals(deal.getStage())) {
+ throw new IllegalStateException("Trial extension only allowed during the trial stage");
+ }
+ if (deal.getTrialExtensionsUsed() >= config.getMaxTrialExtensions()) {
+ throw new IllegalStateException("Trial extension cap reached");
+ }
+ LocalDateTime base =
+ deal.getTrialEndsAt() != null ? deal.getTrialEndsAt() : LocalDateTime.now();
+ LocalDateTime newEnd = base.plusDays(config.getTrialExtensionDays());
+ deal.setTrialEndsAt(newEnd);
+ deal.setTrialExtensionsUsed(deal.getTrialExtensionsUsed() + 1);
+ if (deal.getLicenseRef() != null) {
+ licenses.extendLicense(deal.getLicenseRef(), newEnd);
+ }
+ return dealRepo.save(deal);
+ }
+
+ /** Price a quote config server-side and persist it as a draft against the team's deal. */
+ @Transactional
+ public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, String businessName) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
+ if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) {
+ throw new IllegalStateException("Cannot rebuild a quote on a live deal");
+ }
+ // (Re)building a quote returns the deal to the quote stage and drops any prior acceptance,
+ // so a rebuild from security/payment can't leave a stale stage or accepted-quote pointer.
+ deal.setStage(ProcurementDeal.STAGE_QUOTE);
+ deal.setAcceptedQuoteId(null);
+ deal = dealRepo.save(deal);
+
+ QuoteBreakdown breakdown = pricing.price(cfg);
+
+ ProcurementQuote quote = new ProcurementQuote();
+ quote.setDealId(deal.getDealId());
+ quote.setQuoteNumber(nextQuoteNumber(deal.getDealId()));
+ // Priced but not yet issued: the edge fn creates the Stripe Quote and flips this to SENT.
+ quote.setStatus(ProcurementQuote.STATUS_DRAFT);
+ quote.setCurrency(cfg.currency());
+ quote.setVolume(cfg.volume());
+ quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
+ quote.setDeployment(cfg.deployment());
+ quote.setTermYears(cfg.termYears());
+ quote.setServiceLevel(cfg.serviceLevel());
+ quote.setIndemnification(cfg.indemnification());
+ quote.setTraining(cfg.training());
+ quote.setQbr(cfg.qbr());
+ quote.setBusinessName(businessName);
+ quote.setAnnualNetMinor(breakdown.annualNetMinor());
+ quote.setTcvMinor(breakdown.tcvMinor());
+ quote.setLineItemsJson(writeLineItems(breakdown));
+ quote.setValidUntil(LocalDate.now().plusDays(30));
+ quote = quoteRepo.save(quote);
+ log.info(
+ "[procurement] quote built team={} quote={} annualNet={} tcv={}",
+ teamId,
+ quote.getQuoteNumber(),
+ quote.getAnnualNetMinor(),
+ quote.getTcvMinor());
+ return quote;
+ }
+
+ /**
+ * Advance the deal to the agreement (security) stage: the buyer has an issued quote and is
+ * reviewing the enterprise agreement before it's accepted into a subscription. Requires an
+ * issued quote on the deal.
+ */
+ @Transactional
+ public ProcurementDeal startAgreement(Long teamId) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId)
+ .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
+ boolean hasIssuedQuote =
+ quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream()
+ .anyMatch(q -> ProcurementQuote.STATUS_SENT.equals(q.getStatus()));
+ if (!hasIssuedQuote) {
+ throw new IllegalStateException("No issued quote for team " + teamId);
+ }
+ deal.setStage(ProcurementDeal.STAGE_AGREEMENT);
+ deal = dealRepo.save(deal);
+ log.info("[procurement] agreement stage team={} deal={}", teamId, deal.getDealId());
+ return deal;
+ }
+
+ /**
+ * Mark the deal live: issue the annual licence and advance to the active stage. In production
+ * this is driven by the {@code invoice.paid} webhook once the first invoice is settled; this
+ * method is the demo/manual stand-in until that webhook lands.
+ */
+ @Transactional
+ public ProcurementDeal markLive(Long teamId) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId)
+ .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
+ int term = 1;
+ String deployment = "cloud";
+ if (deal.getAcceptedQuoteId() != null) {
+ ProcurementQuote q = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
+ if (q != null) {
+ term = Math.max(1, q.getTermYears());
+ if (q.getDeployment() != null && !q.getDeployment().isBlank()) {
+ deployment = q.getDeployment();
+ }
+ }
+ }
+ deal.setLicenseRef(
+ licenses.issueAnnualLicense(
+ teamId, deployment, LocalDateTime.now().plusYears(term)));
+ deal.setStage(ProcurementDeal.STAGE_LIVE);
+ deal = dealRepo.save(deal);
+ log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
+ return deal;
+ }
+
+ /**
+ * Reset a team's procurement: delete the deal (quotes + activity cascade). For
+ * re-demos/testing.
+ */
+ @Transactional
+ public void resetDeal(Long teamId) {
+ dealRepo.deleteByTeamId(teamId);
+ log.info("[procurement] deal reset team={}", teamId);
+ }
+
+ private String nextQuoteNumber(Long dealId) {
+ int seq = quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId).size() + 1;
+ String token = UUID.randomUUID().toString().substring(0, 4).toUpperCase(Locale.ROOT);
+ return String.format(Locale.ROOT, "QT-%s-%04d", token, seq);
+ }
+
+ private String writeLineItems(QuoteBreakdown breakdown) {
+ try {
+ return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems());
+ } catch (JsonProcessingException e) {
+ log.warn("[procurement] failed to serialise line items", e);
+ return "[]";
+ }
+ }
+}
diff --git a/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql
new file mode 100644
index 0000000000..0048f999c1
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql
@@ -0,0 +1,72 @@
+-- Enterprise procurement: the tables that track a linked team's journey from trial to live.
+--
+-- One deal per team (the commercial journey: trial -> quote -> agreement -> payment -> live), the
+-- quotes built against it (the itemised, priced offers), and an append-only activity log for the
+-- money/licence-touching actions. The resulting subscription is mirrored in billing_subscriptions
+-- (seeded on trial start / payment); the entitlement that unlocks the product is a Keygen licence
+-- referenced by procurement_deal.license_ref. Prices are computed server-side (ProcurementPricingService).
+--
+-- Additive and idempotent (IF NOT EXISTS) — safe on the shared dev branch.
+
+CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_deal (
+ deal_id BIGSERIAL PRIMARY KEY,
+ team_id BIGINT NOT NULL UNIQUE REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
+ -- one active deal per team; the journey lives on this row.
+ stage VARCHAR(32) NOT NULL DEFAULT 'trial',
+ -- trial | quote | security (agreement) | procurement (payment) | active (live)
+ trial_started_at TIMESTAMP,
+ trial_ends_at TIMESTAMP,
+ trial_extensions_used INT NOT NULL DEFAULT 0,
+ license_ref VARCHAR(128),
+ -- Keygen licence id issued for this deal (trial or annual). Mocked until Keygen mgmt lands.
+ subscription_id VARCHAR(255),
+ -- Stripe subscription id, mirrored into billing_subscriptions once commercial.
+ accepted_quote_id BIGINT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ version BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_quote (
+ quote_id BIGSERIAL PRIMARY KEY,
+ deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
+ quote_number VARCHAR(64) NOT NULL,
+ status VARCHAR(24) NOT NULL DEFAULT 'draft',
+ -- draft | sent | accepted | expired
+ currency VARCHAR(8) NOT NULL DEFAULT 'USD',
+ volume BIGINT NOT NULL,
+ seats INT,
+ deployment VARCHAR(24),
+ term_years INT NOT NULL,
+ service_level VARCHAR(24) NOT NULL,
+ indemnification BOOLEAN NOT NULL DEFAULT FALSE,
+ training BOOLEAN NOT NULL DEFAULT FALSE,
+ qbr BOOLEAN NOT NULL DEFAULT FALSE,
+ annual_net_minor BIGINT NOT NULL,
+ -- recurring annual fee after the multi-year discount, in minor units (cents).
+ tcv_minor BIGINT NOT NULL,
+ -- total contract value across the term incl. one-time fees, minor units.
+ line_items TEXT,
+ -- JSON snapshot of the itemised lines the order form renders.
+ stripe_price_id VARCHAR(128),
+ checkout_session_id VARCHAR(255),
+ checkout_url TEXT,
+ valid_until DATE,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ version BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_activity (
+ activity_id BIGSERIAL PRIMARY KEY,
+ deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
+ actor_user_id BIGINT,
+ -- the internal/portal user who took the action; informational (no FK).
+ action VARCHAR(48) NOT NULL,
+ -- trial_started | trial_extended | quote_built | quote_accepted | checkout_created | went_live ...
+ detail TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_procurement_quote_deal ON stirling_pdf.procurement_quote (deal_id);
+CREATE INDEX IF NOT EXISTS idx_procurement_activity_deal ON stirling_pdf.procurement_activity (deal_id);
diff --git a/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql
new file mode 100644
index 0000000000..5d83ee35f8
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql
@@ -0,0 +1,8 @@
+-- Stripe Quote support: a procurement quote is issued as a real Stripe Quote (finalized → PDF +
+-- shareable), and on acceptance Stripe creates the committed subscription + first invoice. The
+-- Stripe operations live in Supabase edge functions; these columns hold the references they write
+-- back. Twin of Supabase migration 20260703000000_procurement_stripe_quote.sql.
+
+ALTER TABLE stirling_pdf.procurement_quote
+ ADD COLUMN IF NOT EXISTS stripe_quote_id VARCHAR(128),
+ ADD COLUMN IF NOT EXISTS stripe_invoice_url TEXT;
diff --git a/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql
new file mode 100644
index 0000000000..ba9c74dde8
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql
@@ -0,0 +1,5 @@
+-- Persist the buyer's company name on the quote so re-editing remembers it and it can be shown on
+-- the quote/agreement. Twin of Supabase migration 20260705000000_procurement_business_name.sql.
+
+ALTER TABLE stirling_pdf.procurement_quote
+ ADD COLUMN IF NOT EXISTS business_name VARCHAR(255);
diff --git a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java
new file mode 100644
index 0000000000..1f903a1ee2
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java
@@ -0,0 +1,87 @@
+package stirling.software.saas.procurement.pricing;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+import stirling.software.saas.procurement.pricing.QuoteLineItem.Kind;
+
+/**
+ * Locks the pricing engine to the numbers the marketing prototype encodes — most importantly the
+ * canonical quote QT-AC9F-0001 (1M PDFs, priority, 3-year) = $41,400/yr, $124,200 TCV.
+ */
+class ProcurementPricingServiceTest {
+
+ private final ProcurementPricingService pricing = new ProcurementPricingService();
+
+ private static QuoteConfig cfg(long volume, String sla, int term) {
+ return new QuoteConfig(volume, 0, "cloud", term, sla, false, false, false, "USD");
+ }
+
+ @Test
+ void canonicalQuoteMatchesPrototype() {
+ QuoteBreakdown q = pricing.price(cfg(1_000_000, "priority", 3));
+
+ assertThat(q.annualNetMinor()).isEqualTo(4_140_000L); // $41,400
+ assertThat(q.tcvMinor()).isEqualTo(12_420_000L); // $124,200
+ assertThat(lineAmount(q, "usage")).isEqualTo(4_000_000L); // $40,000 @ $0.04
+ assertThat(lineAmount(q, "service-level")).isEqualTo(600_000L); // +15%
+ assertThat(lineAmount(q, "multi-year")).isEqualTo(-460_000L); // -10%
+ }
+
+ @Test
+ void volumeBandsPickTheRightPerPdfRate() {
+ assertThat(lineAmount(pricing.price(cfg(500_000, "standard", 1)), "usage"))
+ .isEqualTo(2_500_000L); // 500k @ $0.05
+ assertThat(lineAmount(pricing.price(cfg(1_000_000, "standard", 1)), "usage"))
+ .isEqualTo(4_000_000L); // 1M @ $0.04
+ assertThat(lineAmount(pricing.price(cfg(5_000_000, "standard", 1)), "usage"))
+ .isEqualTo(15_000_000L); // 5M @ $0.03
+ }
+
+ @Test
+ void addOnsAndTermStack() {
+ QuoteConfig c =
+ new QuoteConfig(1_000_000, 0, "cloud", 5, "dedicated", true, true, true, "USD");
+ QuoteBreakdown q = pricing.price(c);
+
+ long usage = 4_000_000L;
+ long withSla = Math.round(usage * 1.30); // 5,200,000
+ long withIndemnity = Math.round(withSla * 1.05); // 5,460,000
+ long discount = Math.round(withIndemnity * 0.15); // 819,000
+ long qbr = 800_000L;
+ long expectedAnnual = (withIndemnity - discount) + qbr;
+ long expectedTcv = expectedAnnual * 5 + 750_000L; // + training one-time
+
+ assertThat(q.annualNetMinor()).isEqualTo(expectedAnnual);
+ assertThat(q.tcvMinor()).isEqualTo(expectedTcv);
+ assertThat(q.lineItems())
+ .anyMatch(l -> l.key().equals("training") && l.kind() == Kind.ONE_TIME);
+ assertThat(q.lineItems()).anyMatch(l -> l.key().equals("qbr"));
+ assertThat(q.lineItems()).anyMatch(l -> l.key().equals("indemnification"));
+ }
+
+ @Test
+ void standardSingleYearHasNoUpliftOrDiscountLines() {
+ QuoteBreakdown q = pricing.price(cfg(1_000_000, "standard", 1));
+ assertThat(q.annualNetMinor()).isEqualTo(4_000_000L);
+ assertThat(q.tcvMinor()).isEqualTo(4_000_000L);
+ assertThat(q.lineItems()).noneMatch(l -> l.key().equals("service-level"));
+ assertThat(q.lineItems()).noneMatch(l -> l.key().equals("multi-year"));
+ }
+
+ @Test
+ void volumeEstimateFromSeats() {
+ // ~2,013 PDFs/user/yr
+ assertThat(pricing.estimateAnnualVolume(100)).isEqualTo(201_250L);
+ assertThat(pricing.estimateAnnualVolume(0)).isZero();
+ }
+
+ private static long lineAmount(QuoteBreakdown q, String key) {
+ return q.lineItems().stream()
+ .filter(l -> l.key().equals(key))
+ .mapToLong(QuoteLineItem::amountMinor)
+ .findFirst()
+ .orElseThrow();
+ }
+}
diff --git a/frontend/.gitignore b/frontend/.gitignore
index e07dce196a..0605e6e79f 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -11,6 +11,7 @@
# production
/build
/dist
+/dist-portal
/storybook-static
/editor/build
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
index d086613f44..dd8e379a8a 100644
--- a/frontend/.storybook/preview.tsx
+++ b/frontend/.storybook/preview.tsx
@@ -2,7 +2,7 @@
// the decorators below transpiles to React.createElement and needs React in
// scope. (The app + story files use the automatic runtime via the portal vite
// config; this import is specifically for the preview config file.)
-import React, { useEffect } from "react";
+import React, { Suspense, useEffect } from "react";
import type { Decorator, Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";
import { MemoryRouter } from "react-router-dom";
@@ -109,7 +109,9 @@ const withProviders: Decorator = (Story, context) => {
-
+
+
+
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 346ece1523..241289d1db 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -7430,7 +7430,7 @@ upgrade = "Upgrade"
volumeSuffix = "PDFs processed · last 30 days"
[portal.procurement]
-enterpriseBadge = "Enterprise"
+reset = "Reset procurement (demo)"
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
title = "Procurement"
@@ -7441,6 +7441,63 @@ request = "Request"
sign = "Review & sign"
upload = "Upload"
+[portal.procurement.agreement]
+agreeCta = "Agree & subscribe"
+confirm = "I have read and agree to the Stirling Enterprise Agreement."
+eyebrow = "Agreement"
+intro = "One combined agreement covers your deal: Master Service Agreement, Order Form, EULA, and Data Processing Agreement. Review it, then agree to accept the quote into a committed subscription."
+title = "Review your enterprise agreement"
+
+[portal.procurement.builder]
+addons = "Add-ons"
+back = "Back"
+businessName = "Business name"
+businessNamePlaceholder = "Your company"
+continue = "Continue"
+country = "Country"
+countryEuro = "Eurozone (EUR €)"
+countryUK = "United Kingdom (GBP £)"
+countryUS = "United States (USD $)"
+eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote."
+generate = "Generate quote"
+included = "Included"
+indemnification = "IP indemnification"
+indemnificationSub = "We defend qualifying IP claims, per the EULA"
+qbr = "Quarterly business reviews"
+qbrSub = "Your SE reviews usage and roadmap each quarter"
+running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
+s1Sub = "Your team, and the PDFs you expect to run each year."
+# Step 1 — volume
+s1Title = "How much will you process?"
+s2Sub = "Longer terms discount the rate; your service level sets support."
+# Step 2 — commitment & service
+s2Title = "Commitment and service"
+s3Sub = "For the quote and the agreement it generates."
+# Step 3 — details
+s3Title = "Your details"
+serviceLevel = "Service level"
+slDedicated = "Dedicated"
+slDedicatedSub = "4 business hours · dedicated account manager · +30%"
+slPriority = "Priority"
+slPrioritySub = "Same business day · named CSM · +15%"
+slStandard = "Standard"
+slStandardSub = "Next business day · shared CSM · included"
+stepOf = "Step {{n}} of {{total}}"
+term = "Term"
+termDiscount = "{{pct}}% multi-year commitment discount applied"
+title = "Build your quote"
+training = "Onboarding & training"
+trainingSub = "Live sessions to get your team running"
+users = "Total users"
+usersPlaceholder = "e.g. 250"
+volEstimated = "Estimated from {{count}} users (~2,000 PDFs each, including automation). Edit if you know better."
+volManual = "Using your figure. Re-estimate from your team size any time."
+volNoUsers = "Not sure? Enter your team size and we'll estimate it."
+volume = "Annual PDF volume"
+volumePlaceholder = "e.g. 1,000,000"
+years_one = "{{count}} year"
+years_other = "{{count}} years"
+
[portal.procurement.docs]
count_one = "{{count}} doc"
count_other = "{{count}} docs"
@@ -7456,6 +7513,30 @@ supportingTitle = "Supporting your evaluation"
title = "Documents"
upcoming = "Upcoming"
+[portal.procurement.error]
+title = "Something went wrong"
+
+[portal.procurement.hero]
+company = "Your enterprise deal"
+ctaAgreement = "Review & sign agreement"
+ctaLive = "You're live"
+ctaPayment = "Add payment"
+ctaQuote = "Review your quote"
+ctaTrial = "Build your quote"
+eyebrow = "Enterprise procurement"
+inviteTeammates = "Invite teammates"
+keyDocs = "Key documents"
+nextStep = "Next step: {{action}}"
+notStarted = "Not started"
+open = "Open procurement"
+scheduleCall = "Schedule a call"
+setup1Sub = "Invite your teammates"
+setup1Title = "Deploy the PDF Editor"
+setup2Sub = "Turn on processing across editors and other sources"
+setup2Title = "Connect the PDF Processor"
+setup3Sub = "Turn on Security, Compliance, Routing, or Retention when you need them"
+setup3Title = "Add recommended policies"
+
[portal.procurement.journey]
daysLeft_one = "{{count}} day left"
daysLeft_other = "{{count}} days left"
@@ -7467,15 +7548,39 @@ subtitle = "Your solutions engineer is on every step. One next action at a time;
title = "From trial to live, one guided path"
trialTitle = "Enterprise trial"
+[portal.procurement.link]
+cta = "Link account"
+description = "Procurement runs on your linked Stirling account: it's how we provision the trial, price your quote, and start billing. Link an account to start."
+eyebrow = "Enterprise"
+title = "Link your account to begin"
+
+[portal.procurement.live]
+description = "Your subscription is active and your licence is issued. Your team is provisioned and billing has started."
+eyebrow = "Live"
+title = "You're live on Stirling Enterprise"
+
[portal.procurement.locked]
description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation."
eyebrow = "Enterprise only"
talkToSales = "Talk to sales"
title = "The procurement track opens with Enterprise"
+[portal.procurement.milestone]
+accept = "Accept & continue"
+description = "Download the PDF to share it with your team, come back to accept when you're ready, or make changes."
+download = "Download PDF"
+downloadError = "Could not download the quote PDF just yet — please try again in a moment."
+edit = "Edit quote"
+eyebrow = "Quote {{number}}"
+perYear = " / yr"
+preparedFor = "Prepared for {{company}}"
+tcv = "{{value}} total contract value"
+title = "Your quote is ready"
+
[portal.procurement.modal]
cancel = "Cancel"
chooseFile = "Choose file"
+close = "Close"
downloadBody = "Your download will begin shortly."
downloadCta = "Download"
downloadTitle = "Download"
@@ -7494,6 +7599,13 @@ uploadBody = "Send us your PO and we invoice against it on your terms. Drag in t
uploadCta = "Upload purchase order"
uploadTitle = "Upload your purchase order"
+[portal.procurement.payment]
+description = "Your quote is accepted and a committed annual subscription has been created. Pay the first invoice to go live — you can pay or download it right here, no email needed."
+downloadInvoice = "Download invoice"
+simulate = "Simulate payment received (demo)"
+title = "Subscription created"
+viewInvoice = "View & pay invoice"
+
[portal.procurement.status]
action = "Action needed"
available = "Available"
@@ -7501,6 +7613,21 @@ complete = "Complete"
pending = "Pending"
request = "On request"
+[portal.procurement.trial]
+body = "Extending adds 7 days and notifies your solutions engineer."
+bodyMaxed = "You have used all your extensions — talk to your solutions engineer if you need more time."
+cancel = "Cancel trial"
+extend = "Extend 7 days"
+maxed = "Maxed out"
+subtitle = "Your free trial runs through {{date}}. No card required."
+title = "Enterprise trial"
+
+[portal.procurement.upsell]
+homeBadge = "Enterprise"
+homeBody = "Committed volume pricing, org-wide SSO + SCIM + RBAC, 90-day immutable audit, and a dedicated SE."
+homeCta = "Start Trial →"
+homeHeadline = "Process millions of PDFs."
+
[portal.recentActivity]
title = "Recent activity"
viewAll = "View all"
diff --git a/frontend/editor/src/portal/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts
index 3a2b9fe0f2..9327179c03 100644
--- a/frontend/editor/src/portal/api/procurement.ts
+++ b/frontend/editor/src/portal/api/procurement.ts
@@ -1,4 +1,5 @@
import { apiClient } from "@portal/api/http";
+import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
import type { Tier } from "@portal/contexts/TierContext";
import type {
DealStage,
@@ -92,3 +93,165 @@ export async function requestDocument(
{ method: "POST", body: { action } },
);
}
+
+// ============================================================================
+// Enterprise procurement — real SaaS backend (/api/v1/procurement).
+//
+// The journey/ledger visuals above still ride the MSW mock; the commercial spine
+// below (trial, server-priced quote, accept -> Stripe checkout) is the real thing,
+// served by the saas Java backend and gated on a linked account.
+// ============================================================================
+
+export type QuoteLineItemKind =
+ | "RECURRING"
+ | "ONE_TIME"
+ | "DISCOUNT"
+ | "INCLUDED";
+
+export interface QuoteLineItem {
+ key: string;
+ label: string;
+ kind: QuoteLineItemKind;
+ amountMinor: number;
+}
+
+export interface QuoteResult {
+ quoteId: number;
+ quoteNumber: string;
+ /** draft (priced, editable) | sent (issued Stripe quote — PDF + shareable) | accepted | expired. */
+ status: string;
+ currency: string;
+ annualNetMinor: number;
+ tcvMinor: number;
+ lineItems: QuoteLineItem[];
+ validUntil: string | null;
+ /** The Stripe Quote id once issued; null while still a local draft. */
+ stripeQuoteId: string | null;
+ /** Hosted Stripe invoice URL, present once the quote is accepted and the subscription invoice exists. */
+ invoiceUrl: string | null;
+ /** The inputs this quote was priced from, so the builder can seed itself on re-edit. */
+ config: QuoteConfigInput;
+}
+
+/** Outcome of accepting an issued quote: Stripe creates the subscription + first invoice. */
+export interface AcceptResult {
+ status: string;
+ subscriptionId: string | null;
+ invoiceUrl: string | null;
+ invoicePdf: string | null;
+}
+
+/** One shape for every state; an unstarted procurement has {@link ProcurementSnapshot.dealId} null. */
+export interface ProcurementSnapshot {
+ dealId: number | null;
+ stage: DealStage | null;
+ trialStartedAt: string | null;
+ trialEndsAt: string | null;
+ trialExtensionsUsed: number;
+ licensed: boolean;
+ latestQuote: QuoteResult | null;
+}
+
+export interface QuoteConfigInput {
+ volume: number;
+ users: number;
+ deployment: string;
+ termYears: number;
+ serviceLevel: string;
+ indemnification: boolean;
+ training: boolean;
+ qbr: boolean;
+ currency: string;
+ /** Buyer's company name — shown on the quote/agreement and remembered when re-editing. */
+ businessName: string;
+}
+
+export function fetchSnapshot(): Promise {
+ return apiClient.saas.json("/api/v1/procurement");
+}
+
+export function startTrial(): Promise {
+ return apiClient.saas.json(
+ "/api/v1/procurement/trial/start",
+ { method: "POST" },
+ );
+}
+
+export function extendTrial(): Promise {
+ return apiClient.saas.json(
+ "/api/v1/procurement/trial/extend",
+ { method: "POST" },
+ );
+}
+
+/** Advance an issued quote to the agreement (security) stage for review + agree. */
+export function startAgreement(): Promise {
+ return apiClient.saas.json(
+ "/api/v1/procurement/agreement",
+ { method: "POST" },
+ );
+}
+
+/** Price a config server-side and persist it as a local DRAFT (no Stripe object yet). */
+export function buildQuote(cfg: QuoteConfigInput): Promise {
+ return apiClient.saas.json("/api/v1/procurement/quote", {
+ method: "POST",
+ body: cfg,
+ });
+}
+
+// ---- Stripe Quote operations (Supabase edge functions) ---------------------
+// Java has no Stripe SDK, so issuing/accepting the quote and fetching its PDF run in edge functions
+// that own Stripe; they persist results back through SECURITY DEFINER RPCs. The portal invokes them
+// directly (same pattern the PAYG checkout uses).
+
+async function invokeEdge(fn: string, quoteId: number): Promise {
+ const supabase = getSupabaseClient();
+ if (!supabase) throw new Error("No SaaS session");
+ const { data, error } = await supabase.functions.invoke(fn, {
+ body: { quote_id: quoteId },
+ });
+ if (error) throw error;
+ if (data == null) throw new Error(`${fn} returned no data`);
+ return data;
+}
+
+/** Turn a draft into an issued Stripe Quote (finalized → gets a number + PDF, shareable). */
+export function issueQuote(quoteId: number): Promise {
+ return invokeEdge("issue-procurement-quote", quoteId);
+}
+
+/** Accept an issued quote → Stripe creates the committed subscription + first invoice. */
+export function acceptQuote(quoteId: number): Promise {
+ return invokeEdge("accept-procurement-quote", quoteId);
+}
+
+/** Fetch the Stripe-generated quote PDF as a blob (for download / share). */
+export async function fetchQuotePdf(quoteId: number): Promise {
+ const supabase = getSupabaseClient();
+ if (!supabase) throw new Error("No SaaS session");
+ const { data, error } = await supabase.functions.invoke(
+ "get-procurement-quote-pdf",
+ { body: { quote_id: quoteId } },
+ );
+ if (error) throw error;
+ if (!data) throw new Error("No PDF returned");
+ return data;
+}
+
+/**
+ * Demo/manual stand-in for the invoice.paid webhook: mark the deal live (issue licence, go active).
+ */
+export function goLive(): Promise {
+ return apiClient.saas.json(
+ "/api/v1/procurement/go-live",
+ { method: "POST" },
+ );
+}
+
+/** Reset the team's procurement (delete the deal) and get the fresh empty snapshot. */
+export function resetProcurement(): Promise {
+ return apiClient.saas.json("/api/v1/procurement/reset", {
+ method: "POST",
+ });
+}
diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx
index d9ced4ae65..0deb3c0dda 100644
--- a/frontend/editor/src/portal/components/Sidebar.tsx
+++ b/frontend/editor/src/portal/components/Sidebar.tsx
@@ -22,7 +22,6 @@ import {
UsageIcon,
LinkIcon,
DocsIcon,
- ProcurementIcon,
SettingsIcon,
ChevronDownIcon,
} from "@portal/components/icons";
@@ -136,15 +135,10 @@ export function Sidebar() {
const { activeView, setActiveView } = useView();
const { theme } = useTheme();
const { openSettings } = useUI();
- const { tier } = useTier();
const { t } = useTranslation();
- // Procurement is the enterprise buyer's commercial journey — surfaced only to
- // enterprise tenants (it has no free/pro equivalent).
- const platformGroup: NavEntry[] =
- tier === "enterprise"
- ? [{ id: "procurement", icon: }, ...GROUP_PLATFORM]
- : GROUP_PLATFORM;
+ // 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).
function renderGroup(entries: NavEntry[]) {
return entries.map((entry) => (
@@ -227,7 +221,7 @@ export function Sidebar() {
- {renderGroup(platformGroup)}
+ {renderGroup(GROUP_PLATFORM)}
diff --git a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx
index b303c530ff..6525e66970 100644
--- a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx
+++ b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { Button, Card } from "@app/ui";
+import { useView } from "@portal/contexts/ViewContext";
interface Props {
/** Render without the Card wrapper, to embed inside another card's column. */
@@ -8,10 +9,12 @@ interface Props {
/**
* Volume-discount / Enterprise upsell, shared by the free and subscribed billing
- * views. The CTA is intentionally inert until the sales/quote URL is confirmed.
+ * views. The CTA opens the procurement journey (/procurement auto-opens the quote
+ * builder in the takeover modal).
*/
export function EnterpriseUpsell({ bare = false }: Props) {
const { t } = useTranslation();
+ const { setActiveView } = useView();
const body = (
<>
@@ -32,8 +35,11 @@ export function EnterpriseUpsell({ bare = false }: Props) {
)}
- {/* Destination wired when the enterprise/sales URL is confirmed. */}
-
+ setActiveView("procurement")}
+ >
{t(
"portal.billing.enterpriseUpsell.cta",
"Build your Enterprise quote",
diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx
new file mode 100644
index 0000000000..dc4d7cb368
--- /dev/null
+++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx
@@ -0,0 +1,47 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { DealStatusHero } from "@portal/components/procurement/DealStatusHero";
+import type { ProcurementSnapshot } from "@portal/api/procurement";
+
+const base: ProcurementSnapshot = {
+ dealId: 1,
+ stage: "trial",
+ trialStartedAt: "2026-06-25T00:00:00Z",
+ trialEndsAt: "2026-07-09T00:00:00Z",
+ trialExtensionsUsed: 0,
+ licensed: false,
+ latestQuote: null,
+};
+
+/** The Home deal-status hero across the commercial stages; the CTA expands the takeover modal. */
+const meta: Meta = {
+ title: "Portal/Procurement/DealStatusHero",
+ component: DealStatusHero,
+ parameters: { layout: "padded" },
+ args: {
+ onExpand: () => {},
+ onKeyDocs: () => {},
+ onInvite: () => {},
+ onSchedule: () => {},
+ onManageTrial: () => {},
+ onNavigate: () => {},
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Trial: Story = { args: { snapshot: base } };
+export const Quote: Story = {
+ args: { snapshot: { ...base, stage: "quote", trialEndsAt: null } },
+};
+export const Agreement: Story = {
+ args: { snapshot: { ...base, stage: "security", trialEndsAt: null } },
+};
+export const Payment: Story = {
+ args: { snapshot: { ...base, stage: "procurement", trialEndsAt: null } },
+};
+export const Live: Story = {
+ args: {
+ snapshot: { ...base, stage: "active", licensed: true, trialEndsAt: null },
+ },
+};
diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx
new file mode 100644
index 0000000000..296ccc1a52
--- /dev/null
+++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx
@@ -0,0 +1,162 @@
+import { useTranslation } from "react-i18next";
+import { Button } from "@app/ui";
+import type { ViewId } from "@portal/contexts/ViewContext";
+import { JOURNEY, type ProcurementSnapshot } from "@portal/api/procurement";
+import { StageStepper } from "@portal/components/procurement/StageStepper";
+import "@portal/views/Procurement.css";
+
+/**
+ * The enterprise deal-status hero on Home (procurement lives here, not as a nav tab). Adapts to the
+ * deal stage: quick-action chips (trial countdown → manage, key documents, invite teammates,
+ * schedule a call), a rollout checklist during the trial, and a stage-specific primary CTA that
+ * expands the flow into the takeover modal. Matches the marketing prototype.
+ */
+export function DealStatusHero({
+ snapshot,
+ busy = false,
+ onExpand,
+ onKeyDocs,
+ onInvite,
+ onSchedule,
+ onManageTrial,
+ onNavigate,
+}: {
+ snapshot: ProcurementSnapshot;
+ busy?: boolean;
+ onExpand: () => void;
+ onKeyDocs: () => void;
+ onInvite: () => void;
+ onSchedule: () => void;
+ onManageTrial: () => void;
+ onNavigate: (view: ViewId) => void;
+}) {
+ const { t } = useTranslation();
+ const stage = snapshot.stage ?? "trial";
+ const inTrial = stage === "trial";
+ const cta =
+ stage === "trial"
+ ? t("portal.procurement.hero.ctaTrial")
+ : stage === "quote"
+ ? t("portal.procurement.hero.ctaQuote")
+ : stage === "security"
+ ? t("portal.procurement.hero.ctaAgreement")
+ : stage === "procurement"
+ ? t("portal.procurement.hero.ctaPayment")
+ : t("portal.procurement.hero.ctaLive");
+
+ const setupSteps: { title: string; sub: string; view: ViewId }[] = [
+ {
+ title: t("portal.procurement.hero.setup1Title"),
+ sub: t("portal.procurement.hero.setup1Sub"),
+ view: "users",
+ },
+ {
+ title: t("portal.procurement.hero.setup2Title"),
+ sub: t("portal.procurement.hero.setup2Sub"),
+ view: "sources",
+ },
+ {
+ title: t("portal.procurement.hero.setup3Title"),
+ sub: t("portal.procurement.hero.setup3Sub"),
+ view: "policies",
+ },
+ ];
+
+ return (
+
+
+
+
+ {t("portal.procurement.hero.eyebrow")}
+
+
+ {t("portal.procurement.hero.company")}
+
+
+
+ {inTrial && snapshot.trialEndsAt && (
+
+ {t("portal.procurement.journey.daysLeft", {
+ count: daysLeft(snapshot.trialEndsAt),
+ })}
+
+ )}
+ {stage !== "active" && (
+
+ {t("portal.procurement.hero.keyDocs")}
+
+ )}
+ {stage !== "active" && (
+
+ {t("portal.procurement.hero.inviteTeammates")}
+
+ )}
+
+ {t("portal.procurement.hero.scheduleCall")}
+
+
+
+
+
+
+
+
+ {inTrial && (
+
+ {setupSteps.map((s) => (
+
+ onNavigate(s.view)}>
+
+
+ {s.title}
+ {s.sub}
+
+
+ {t("portal.procurement.hero.notStarted")}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {t("portal.procurement.hero.nextStep", { action: cta })}
+
+
+
+ {stage === "active" ? t("portal.procurement.hero.open") : cta}
+
+
+
+
+ );
+}
+
+function daysLeft(iso: string): number {
+ const end = new Date(iso).getTime();
+ return Math.max(0, Math.ceil((end - Date.now()) / 86_400_000));
+}
diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx
new file mode 100644
index 0000000000..bd00a93904
--- /dev/null
+++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx
@@ -0,0 +1,126 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Button, Card } from "@app/ui";
+import type { QuoteResult } from "@portal/api/procurement";
+import { money } from "@portal/components/procurement/format";
+import "@portal/views/Procurement.css";
+
+/**
+ * The agreement (security) step: a single combined Stirling Enterprise Agreement — Master Service
+ * Agreement + Order Form (from the issued quote) + EULA + Data Processing Agreement — that the buyer
+ * reviews and agrees to before it's accepted into a subscription. No e-signature for now: an explicit
+ * "I agree" click stands in (the terms reference the accepted quote). Document body is static legal
+ * copy; the surrounding UI is translated.
+ */
+export function ProcurementAgreement({
+ quote,
+ busy,
+ onAgree,
+}: {
+ quote: QuoteResult;
+ busy: boolean;
+ onAgree: () => void;
+}) {
+ const { t } = useTranslation();
+ const [checked, setChecked] = useState(false);
+ const annual = money(quote.annualNetMinor, quote.currency);
+ const tcv = money(quote.tcvMinor, quote.currency);
+ const years = quote.config.termYears;
+
+ return (
+
+
+ {t("portal.procurement.agreement.eyebrow")}
+
+
+ {t("portal.procurement.agreement.title")}
+
+
+ {t("portal.procurement.agreement.intro")}
+
+
+
+
1. Master Service Agreement
+
+ This Stirling Enterprise Agreement ("Agreement") is entered into
+ between Stirling PDF Inc. ("Stirling") and the customer identified on
+ the Order Form ("Customer"). It governs Customer's access to and use
+ of the Stirling enterprise platform and related services (the
+ "Service"). Stirling will provide the Service with commercially
+ reasonable skill and care and in accordance with the service levels
+ set out in the Order Form.
+
+
+
2. Order Form
+
+ Quote {quote.quoteNumber} forms the Order Form for
+ this Agreement. Customer commits to a {years}-year term at{" "}
+ {annual} per year (total contract value{" "}
+ {tcv} ), billed annually in advance by invoice. Fees
+ are exclusive of taxes. The committed volume, service level, and
+ add-ons are itemised below:
+
+
+ {quote.lineItems.map((li) => (
+
+ {li.label}
+
+ {li.kind === "INCLUDED"
+ ? t("portal.procurement.builder.included")
+ : money(li.amountMinor, quote.currency)}
+
+
+ ))}
+
+
+
3. End-User License Agreement
+
+ Subject to the terms of this Agreement, Stirling grants Customer a
+ non-exclusive, non-transferable right to use the Service for its
+ internal business purposes during the term. Customer is responsible
+ for its users' compliance and for the content it processes. The
+ Service, and all intellectual property in it, remains Stirling's.
+
+
+
4. Data Processing Agreement
+
+ Where Stirling processes personal data on Customer's behalf, it does
+ so only on Customer's documented instructions and applies appropriate
+ technical and organisational measures. Sub-processors, international
+ transfers, and security commitments are as described in Stirling's
+ Data Processing Agreement and Trust Center, incorporated here by
+ reference.
+
+
+
5. Acceptance
+
+ By agreeing below, Customer accepts this Agreement and the Order Form.
+ On acceptance, Stirling will issue the committed annual subscription
+ and its first invoice. This preview stands in for e-signature during
+ the pilot.
+
+
+
+
+ setChecked(e.target.checked)}
+ />
+ {t("portal.procurement.agreement.confirm")}
+
+
+
+
+ {t("portal.procurement.agreement.agreeCta")}
+
+
+
+ );
+}
diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx
new file mode 100644
index 0000000000..ce6620da78
--- /dev/null
+++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx
@@ -0,0 +1,299 @@
+import { useEffect } from "react";
+import { createPortal } from "react-dom";
+import { useTranslation } from "react-i18next";
+import { Button } from "@app/ui";
+import type { ProcurementSnapshot } from "@portal/api/procurement";
+import { useFocusTrap } from "@portal/components/procurement/ProcurementModal";
+import "@portal/views/Procurement.css";
+
+/**
+ * Small centred dialogs that hang off the deal-status hero's quick actions — Key documents, Schedule
+ * a call, and trial management. Content is mocked for the pilot (static demo data); the shells and
+ * wiring are real so the hero behaves like the marketing prototype.
+ */
+
+function SideModal({
+ open,
+ onClose,
+ title,
+ subtitle,
+ children,
+ footer,
+}: {
+ open: boolean;
+ onClose: () => void;
+ title: string;
+ subtitle?: string;
+ children: React.ReactNode;
+ footer?: React.ReactNode;
+}) {
+ const { t } = useTranslation();
+ const trapRef = useFocusTrap(open);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
+
+ if (!open) return null;
+ return createPortal(
+ e.target === e.currentTarget && onClose()}
+ >
+
+
+ ✕
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+
{children}
+ {footer &&
{footer}
}
+
+
,
+ document.body,
+ );
+}
+
+// ── Key documents ────────────────────────────────────────────────────────────
+type DocStatus = "available" | "action" | "request";
+interface DocRow {
+ name: string;
+ sub: string;
+ status: DocStatus;
+ fee?: number;
+}
+const STAGE_DOCS: { group: string; docs: DocRow[] }[] = [
+ {
+ group: "Your deal",
+ docs: [
+ {
+ name: "Formal quote",
+ sub: "Built to your volume, term, and service level",
+ status: "available",
+ },
+ {
+ name: "Master Services Agreement",
+ sub: "One signature — MSA, order form, EULA, and DPA combined",
+ status: "action",
+ },
+ {
+ name: "Bank transfer instructions",
+ sub: "Wire details for your AP team",
+ status: "available",
+ },
+ {
+ name: "Purchase order",
+ sub: "Issuing a PO? Upload it and we invoice against it",
+ status: "request",
+ },
+ ],
+ },
+ {
+ group: "Supporting your evaluation",
+ docs: [
+ {
+ name: "SOC 2 Type II report",
+ sub: "Audited · NDA-gated",
+ status: "available",
+ },
+ {
+ name: "Custom security review",
+ sub: "We complete your questionnaire and join your review call",
+ status: "request",
+ fee: 5000,
+ },
+ {
+ name: "Business Associate Agreement",
+ sub: "HIPAA · available on request",
+ status: "request",
+ fee: 2500,
+ },
+ { name: "IRS Form W-9", sub: "Stirling PDF Inc.", status: "available" },
+ {
+ name: "Certificate of Insurance",
+ sub: "Cyber + E&O · current policy",
+ status: "available",
+ },
+ ],
+ },
+];
+const STATUS_LABEL: Record = {
+ available: "Download",
+ action: "Action needed",
+ request: "Request",
+};
+
+export function KeyDocumentsModal({
+ open,
+ onClose,
+}: {
+ open: boolean;
+ onClose: () => void;
+}) {
+ return (
+
+ {STAGE_DOCS.map((g) => (
+
+ ))}
+
+ );
+}
+
+// ── Schedule a call ──────────────────────────────────────────────────────────
+const SLOTS = [
+ "Tomorrow · 10:00",
+ "Tomorrow · 15:30",
+ "Thursday · 11:00",
+ "Friday · 09:30",
+];
+
+export function ScheduleCallModal({
+ open,
+ onClose,
+}: {
+ open: boolean;
+ onClose: () => void;
+}) {
+ return (
+
+
+
+ SE
+
+
+
Your solutions engineer
+
+ Dedicated to your evaluation and rollout
+
+
+
+
+ {SLOTS.map((s) => (
+
+ {s}
+
+ ))}
+
+
+ );
+}
+
+// ── Trial management ─────────────────────────────────────────────────────────
+export function TrialManageModal({
+ open,
+ onClose,
+ snapshot,
+ busy,
+ onExtend,
+ onCancel,
+}: {
+ open: boolean;
+ onClose: () => void;
+ snapshot: ProcurementSnapshot;
+ busy: boolean;
+ onExtend: () => void;
+ onCancel: () => void;
+}) {
+ const { t } = useTranslation();
+ const ends = snapshot.trialEndsAt
+ ? new Date(snapshot.trialEndsAt).toLocaleDateString(undefined, {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ })
+ : "";
+ const maxed = snapshot.trialExtensionsUsed >= 2;
+ return (
+
+
+ {t("portal.procurement.trial.cancel")}
+
+
+ {maxed
+ ? t("portal.procurement.trial.maxed")
+ : t("portal.procurement.trial.extend")}
+
+ >
+ }
+ >
+
+ {maxed
+ ? t("portal.procurement.trial.bodyMaxed")
+ : t("portal.procurement.trial.body")}
+
+
+ );
+}
diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx
new file mode 100644
index 0000000000..ae424922ad
--- /dev/null
+++ b/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx
@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { ProcurementHome } from "@portal/components/procurement/ProcurementHome";
+
+/**
+ * The end-to-end procurement experience (Home hero + takeover modal), driven by the `procurementSaas`
+ * MSW handlers: start trial → build quote → generate (issue Stripe Quote) → milestone (download PDF /
+ * accept). `autoOpen` opens the modal so the flow is immediately clickable.
+ */
+const meta: Meta = {
+ title: "Portal/Procurement/ProcurementHome",
+ component: ProcurementHome,
+ parameters: { layout: "fullscreen" },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = { args: { autoOpen: true } };
diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx
new file mode 100644
index 0000000000..a10057a3d8
--- /dev/null
+++ b/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx
@@ -0,0 +1,296 @@
+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 { useUI } from "@portal/contexts/UIContext";
+import { useView } from "@portal/contexts/ViewContext";
+import { useAsync } from "@portal/hooks/useAsync";
+import {
+ acceptQuote,
+ extendTrial,
+ fetchQuotePdf,
+ fetchSnapshot,
+ goLive,
+ issueQuote,
+ JOURNEY,
+ resetProcurement,
+ startAgreement,
+ startTrial,
+ type ProcurementSnapshot,
+ type QuoteResult,
+} from "@portal/api/procurement";
+import { DealStatusHero } from "@portal/components/procurement/DealStatusHero";
+import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
+import {
+ KeyDocumentsModal,
+ ScheduleCallModal,
+ TrialManageModal,
+} from "@portal/components/procurement/ProcurementExtras";
+import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
+import {
+ LiveStageCard,
+ PaymentStageCard,
+ QuoteMilestoneCard,
+} from "@portal/components/procurement/ProcurementStages";
+import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
+import { StageStepper } from "@portal/components/procurement/StageStepper";
+import "@portal/views/Procurement.css";
+
+/**
+ * The procurement experience on Home: a compact deal-status hero once a trial is running (or the
+ * enterprise upsell on-ramp before it), both expanding into the full-screen takeover modal that
+ * holds the journey — build + issue a quote (a Stripe Quote with a real PDF, the milestone the buyer
+ * can share and return to), review + agree to the enterprise agreement, then accept into a committed
+ * subscription. Starting a trial is a single click (no "start a trial" prompt); the deadline + next
+ * steps then show on the hero. Rendered on Home and at /procurement (autoOpen). Gated on a link.
+ */
+export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) {
+ const { t } = useTranslation();
+ const { isLinked } = useLink();
+ const { openLinkModal } = useUI();
+ const { setActiveView } = useView();
+
+ const state = useAsync(
+ () => (isLinked ? fetchSnapshot() : Promise.resolve(null)),
+ [isLinked],
+ );
+ const [snap, setSnap] = useState(null);
+ const [open, setOpen] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [editing, setEditing] = useState(false);
+ const [downloading, setDownloading] = useState(false);
+ const [invoicePdf, setInvoicePdf] = useState(null);
+ const [error, setError] = useState(null);
+ const [extra, setExtra] = useState(
+ null,
+ );
+
+ const data = snap ?? (state.loading ? null : state.data);
+ const started = data?.dealId != null;
+ const stage = data?.stage;
+ const latest = data?.latestQuote ?? null;
+ const isIssued = latest?.status === "sent" || latest?.status === "open";
+ // No live quote to act on (none yet, still a draft, or expired/canceled) → the buyer (re)builds.
+ const isDraft =
+ !latest ||
+ ["draft", "expired", "canceled", "cancelled"].includes(latest.status);
+
+ async function run(fn: () => Promise) {
+ setBusy(true);
+ setError(null);
+ try {
+ await fn();
+ setSnap(await fetchSnapshot());
+ } catch (e) {
+ console.error("[procurement] action failed", e);
+ setError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ const onStartTrial = () => run(startTrial);
+ const onExtendTrial = () => run(extendTrial);
+ const onReset = () =>
+ run(async () => {
+ await resetProcurement();
+ setEditing(false);
+ setInvoicePdf(null);
+ });
+ const onGenerate = (draft: QuoteResult) =>
+ run(async () => {
+ await issueQuote(draft.quoteId);
+ setEditing(false);
+ });
+ // Milestone → agreement (security) stage; then agreeing accepts into a subscription.
+ const onAcceptQuote = () => run(startAgreement);
+ const onAgree = () =>
+ run(async () => {
+ if (!latest) return;
+ const res = await acceptQuote(latest.quoteId);
+ setInvoicePdf(res.invoicePdf);
+ });
+
+ async function onDownloadPdf() {
+ if (!latest) return;
+ setDownloading(true);
+ try {
+ const blob = await fetchQuotePdf(latest.quoteId);
+ // A same-gesture click is reliable; window.open after an await is often
+ // popup-blocked (which is what made this take "a few goes").
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${latest.quoteNumber || "quote"}.pdf`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
+ } catch (e) {
+ console.error("[procurement] quote PDF download failed", e);
+ setError(t("portal.procurement.milestone.downloadError"));
+ } finally {
+ setDownloading(false);
+ }
+ }
+
+ // A deep link (/procurement) opens the flow when a deal is already underway; if there's no deal
+ // yet it must NOT silently start a trial — leave the modal closed so the Start-trial CTA shows.
+ useEffect(() => {
+ if (autoOpen && started) setOpen(true);
+ }, [autoOpen, started]);
+
+ const banner =
+ isLinked && started && data ? (
+ setOpen(true)}
+ onKeyDocs={() => setExtra("docs")}
+ onInvite={() => setActiveView("users")}
+ onSchedule={() => setExtra("schedule")}
+ onManageTrial={() => setExtra("trial")}
+ onNavigate={setActiveView}
+ />
+ ) : (
+
+
+
+ {t("portal.procurement.upsell.homeBadge")}
+
+
+ {t("portal.procurement.upsell.homeHeadline")}
+ {t("portal.procurement.upsell.homeBody")}
+
+
+
+ {t("portal.procurement.upsell.homeCta")}
+
+
+ );
+
+ return (
+ <>
+ {banner}
+ setOpen(false)}
+ title={t("portal.procurement.title")}
+ subtitle={t("portal.procurement.subtitle")}
+ >
+ {error && (
+ setError(null)}
+ >
+ {error}
+
+ )}
+
+ {!isLinked && (
+ openLinkModal()}
+ >
+ {t("portal.procurement.link.cta")}
+
+ }
+ />
+ )}
+
+ {isLinked && (state.loading || !started) && }
+
+ {isLinked && started && (
+ <>
+
+
+
+
+ {(editing ||
+ (isDraft && (stage === "trial" || stage === "quote"))) && (
+
+ )}
+
+ {!editing && isIssued && stage === "quote" && latest && (
+ setEditing(true)}
+ />
+ )}
+
+ {!editing && stage === "security" && latest && (
+
+ )}
+
+ {!editing && stage === "procurement" && latest && (
+ run(goLive)}
+ />
+ )}
+
+ {!editing && stage === "active" && }
+
+
+
+ {t("portal.procurement.reset")}
+
+
+ >
+ )}
+
+
+ setExtra(null)}
+ />
+ setExtra(null)}
+ />
+ {data && (
+ setExtra(null)}
+ snapshot={data}
+ busy={busy}
+ onExtend={async () => {
+ await onExtendTrial();
+ setExtra(null);
+ }}
+ onCancel={async () => {
+ await onReset();
+ setExtra(null);
+ }}
+ />
+ )}
+ >
+ );
+}
diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx
new file mode 100644
index 0000000000..7074e390e7
--- /dev/null
+++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx
@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Button, Card } from "@app/ui";
+import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
+
+/**
+ * The full-screen takeover modal shell. Rendered always-open here to verify the panel has a solid
+ * (not see-through) surface over the dimmed, blurred backdrop.
+ */
+const meta: Meta = {
+ title: "Portal/Procurement/ProcurementModal",
+ component: ProcurementModal,
+ parameters: { layout: "fullscreen" },
+ args: {
+ open: true,
+ onClose: () => {},
+ title: "Enterprise procurement",
+ subtitle: "Get your team evaluated, contracted, and onboarded.",
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Open: Story = {
+ args: {
+ children: (
+
+ Ready for payment
+
+ Your quote is accepted. Continue to checkout to pay your committed
+ contract and go live.
+
+
+
+ Continue to checkout
+
+ Edit quote
+
+
+ ),
+ },
+};
diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx
new file mode 100644
index 0000000000..bf3f463585
--- /dev/null
+++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx
@@ -0,0 +1,102 @@
+import { useEffect, useRef } from "react";
+import { createPortal } from "react-dom";
+import "@portal/views/Procurement.css";
+
+/** Keep keyboard focus inside an open dialog: focus it on open and wrap Tab at the edges. */
+export function useFocusTrap(open: boolean) {
+ const ref = useRef(null);
+ useEffect(() => {
+ if (!open) return;
+ const panel = ref.current;
+ if (!panel) return;
+ const prev = document.activeElement as HTMLElement | null;
+ const focusables = () =>
+ Array.from(
+ panel.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
+ ),
+ ).filter((el) => !el.hasAttribute("disabled"));
+ (focusables()[0] ?? panel).focus();
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== "Tab") return;
+ const items = focusables();
+ if (items.length === 0) return;
+ const first = items[0];
+ const last = items[items.length - 1];
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
+ panel.addEventListener("keydown", onKey);
+ return () => {
+ panel.removeEventListener("keydown", onKey);
+ prev?.focus?.();
+ };
+ }, [open]);
+ return ref;
+}
+
+/**
+ * Full-screen takeover modal for the procurement flow, copying the prototype's modal design
+ * (portaled to body, dimmed + blurred backdrop, rounded panel, close button). The Home deal-status
+ * hero expands into this.
+ */
+export function ProcurementModal({
+ open,
+ onClose,
+ title,
+ subtitle,
+ children,
+}: {
+ open: boolean;
+ onClose: () => void;
+ title: string;
+ subtitle?: string;
+ children: React.ReactNode;
+}) {
+ const trapRef = useFocusTrap(open);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [open, onClose]);
+
+ if (!open) return null;
+
+ return createPortal(
+ e.target === e.currentTarget && onClose()}
+ >
+
+
+ ✕
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+
{children}
+
+
,
+ document.body,
+ );
+}
diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx
new file mode 100644
index 0000000000..fdebad61f6
--- /dev/null
+++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx
@@ -0,0 +1,160 @@
+import { useTranslation } from "react-i18next";
+import { Button, Card } from "@app/ui";
+import type { QuoteResult } from "@portal/api/procurement";
+import { money } from "@portal/components/procurement/format";
+import "@portal/views/Procurement.css";
+
+/**
+ * The stage-specific cards shown inside the procurement takeover modal once a quote exists: the
+ * issued-quote milestone, the subscription-created payment step, and the live confirmation. Each is
+ * a pure presentational view driven by props; ProcurementHome owns the state and the actions.
+ */
+
+/** The issued Stripe Quote as a shareable milestone: itemised, with accept / download / edit. */
+export function QuoteMilestoneCard({
+ quote,
+ busy,
+ downloading,
+ onAccept,
+ onDownload,
+ onEdit,
+}: {
+ quote: QuoteResult;
+ busy: boolean;
+ downloading: boolean;
+ onAccept: () => void;
+ onDownload: () => void;
+ onEdit: () => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("portal.procurement.milestone.eyebrow", {
+ number: quote.quoteNumber,
+ })}
+
+
+ {t("portal.procurement.milestone.title")}
+
+ {quote.config.businessName && (
+
+ {t("portal.procurement.milestone.preparedFor", {
+ company: quote.config.businessName,
+ })}
+
+ )}
+
+ {t("portal.procurement.milestone.description")}
+
+
+ {quote.lineItems.map((li) => (
+
+ {li.label}
+
+ {li.kind === "INCLUDED"
+ ? t("portal.procurement.builder.included")
+ : money(li.amountMinor, quote.currency)}
+
+
+ ))}
+
+
+
+ {money(quote.annualNetMinor, quote.currency)}
+ {t("portal.procurement.milestone.perYear")}
+
+
+ {t("portal.procurement.milestone.tcv", {
+ value: money(quote.tcvMinor, quote.currency),
+ })}
+
+
+
+
+ {t("portal.procurement.milestone.accept")}
+
+
+ {t("portal.procurement.milestone.download")}
+
+
+ {t("portal.procurement.milestone.edit")}
+
+
+
+ );
+}
+
+/** The subscription-created step: pay/download the first invoice, or (demo) simulate payment. */
+export function PaymentStageCard({
+ invoiceUrl,
+ invoicePdf,
+ busy,
+ onSimulate,
+}: {
+ invoiceUrl?: string | null;
+ invoicePdf?: string | null;
+ busy: boolean;
+ onSimulate: () => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("portal.procurement.payment.title")}
+
+
+ {t("portal.procurement.payment.description")}
+
+ {(invoiceUrl || invoicePdf) && (
+
+ {invoiceUrl && (
+ window.open(invoiceUrl, "_blank", "noopener")}
+ >
+ {t("portal.procurement.payment.viewInvoice")}
+
+ )}
+ {invoicePdf && (
+ window.open(invoicePdf, "_blank", "noopener")}
+ >
+ {t("portal.procurement.payment.downloadInvoice")}
+
+ )}
+
+ )}
+
+
+ {t("portal.procurement.payment.simulate")}
+
+
+
+ );
+}
+
+/** The live confirmation once the deal is active. */
+export function LiveStageCard() {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("portal.procurement.live.eyebrow")}
+
+
+ {t("portal.procurement.live.title")}
+
+
+ {t("portal.procurement.live.description")}
+
+
+ );
+}
diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.stories.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.stories.tsx
new file mode 100644
index 0000000000..430bcf23cf
--- /dev/null
+++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.stories.tsx
@@ -0,0 +1,20 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
+import "@portal/views/Procurement.css";
+
+/**
+ * The enterprise quote builder. The "Review quote" step calls the SaaS backend, answered here by
+ * the `procurementSaas` MSW handler, so all four steps (incl. the itemised quote paper) work in
+ * Storybook. Click through Volume → Commitment & service → Details → Quote.
+ */
+const meta: Meta = {
+ title: "Portal/Procurement/QuoteBuilder",
+ component: QuoteBuilder,
+ parameters: { layout: "padded" },
+ args: { deployment: "cloud", onGenerate: () => {} },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx
new file mode 100644
index 0000000000..b180c986bf
--- /dev/null
+++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx
@@ -0,0 +1,436 @@
+import { useEffect, useState, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { Button } from "@app/ui";
+import {
+ DocumentsIcon,
+ PoliciesIcon,
+ UsersIcon,
+} from "@portal/components/icons";
+import { money } from "@portal/components/procurement/format";
+import {
+ buildQuote,
+ type QuoteConfigInput,
+ type QuoteResult,
+} from "@portal/api/procurement";
+import "@portal/views/Procurement.css";
+
+const STEPS = ["volume", "plan", "details"] as const;
+const TERM_DISCOUNT = [0, 0.05, 0.1, 0.12, 0.15]; // 1..5 years
+const SLA_UPLIFT: Record = {
+ standard: 0,
+ priority: 0.15,
+ dedicated: 0.3,
+};
+
+/**
+ * The enterprise quote builder — volume → commitment & service → details. A client-side preview
+ * drives the live footer total; the backend is authoritative. Completing the form generates the
+ * quote directly (build + issue in one step) — the issued quote is then shown as the milestone, so
+ * there's no redundant in-builder preview.
+ */
+export function QuoteBuilder({
+ deployment,
+ initial,
+ onGenerate,
+}: {
+ deployment: string;
+ /** Seed the builder from an existing quote's config (re-editing a quote). */
+ initial?: QuoteConfigInput;
+ /** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */
+ onGenerate: (quote: QuoteResult) => void;
+}) {
+ const { t } = useTranslation();
+ const [step, setStep] = useState(0);
+ const [cfg, setCfg] = useState(
+ initial ?? {
+ volume: 1_000_000,
+ users: 0,
+ deployment,
+ termYears: 3,
+ serviceLevel: "priority",
+ indemnification: false,
+ training: false,
+ qbr: false,
+ currency: "USD",
+ businessName: "",
+ },
+ );
+ // A seeded quote carries a volume but no user count, so treat it as manually set.
+ const [manualVolume, setManualVolume] = useState(initial != null);
+ const [eula, setEula] = useState(initial != null);
+ const [busy, setBusy] = useState(false);
+
+ function set(k: K, v: QuoteConfigInput[K]) {
+ setCfg((c) => ({ ...c, [k]: v }));
+ }
+
+ // Re-editing an existing quote: everything is seeded, so jump to the last step (details) with the
+ // agreement pre-accepted — one click re-generates, or Back to change a field. No walking from step 1.
+ // Mount-only: seed the step from `initial` once (deliberately no deps).
+ useEffect(() => {
+ if (initial) setStep(STEPS.length - 1);
+ }, []);
+
+ const preview = previewAnnualMinor(cfg);
+ const tcvPreview = preview * cfg.termYears + (cfg.training ? 750_000 : 0);
+
+ // Fully filled → price + hand the draft to the parent to issue as a Stripe Quote (which then shows
+ // as the milestone). No separate in-builder preview step.
+ async function generate() {
+ setBusy(true);
+ try {
+ onGenerate(await buildQuote(cfg));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+ {t("portal.procurement.builder.title")}
+
+
+ {t("portal.procurement.builder.stepOf", {
+ n: step + 1,
+ total: STEPS.length,
+ })}
+
+
+
+ {STEPS.map((s, i) => (
+
+ ))}
+
+
+
+ {step === 0 && (
+
}
+ title={t("portal.procurement.builder.s1Title")}
+ sub={t("portal.procurement.builder.s1Sub")}
+ >
+
+
+ {
+ const users = Number(e.target.value);
+ set("users", users);
+ if (!manualVolume) set("volume", estimateVolume(users));
+ }}
+ />
+
+
+ {
+ setManualVolume(true);
+ set("volume", Number(e.target.value));
+ }}
+ />
+
+
+
+ {cfg.users > 0 && !manualVolume
+ ? t("portal.procurement.builder.volEstimated", {
+ count: cfg.users,
+ })
+ : cfg.users > 0
+ ? t("portal.procurement.builder.volManual")
+ : t("portal.procurement.builder.volNoUsers")}
+
+
+ )}
+
+ {step === 1 && (
+
}
+ title={t("portal.procurement.builder.s2Title")}
+ sub={t("portal.procurement.builder.s2Sub")}
+ >
+
+
+ {[1, 2, 3, 4, 5].map((y) => (
+ set("termYears", y)}
+ >
+ {t("portal.procurement.builder.years", { count: y })}
+
+ ))}
+
+ {TERM_DISCOUNT[cfg.termYears - 1] > 0 && (
+
+ {t("portal.procurement.builder.termDiscount", {
+ pct: Math.round(TERM_DISCOUNT[cfg.termYears - 1] * 100),
+ })}
+
+ )}
+
+
+
+
+ set("serviceLevel", "standard")}
+ />
+ set("serviceLevel", "priority")}
+ />
+ set("serviceLevel", "dedicated")}
+ />
+
+
+
+
+
+
set("indemnification", !cfg.indemnification)}
+ />
+ set("training", !cfg.training)}
+ />
+ set("qbr", !cfg.qbr)}
+ />
+
+
+
+ )}
+
+ {step === 2 && (
+
}
+ title={t("portal.procurement.builder.s3Title")}
+ sub={t("portal.procurement.builder.s3Sub")}
+ >
+
+ set("businessName", e.target.value)}
+ />
+
+
+
+ set("currency", e.target.value)}
+ >
+
+ {t("portal.procurement.builder.countryUS")}
+
+
+ {t("portal.procurement.builder.countryUK")}
+
+
+ {t("portal.procurement.builder.countryEuro")}
+
+
+
+
+
+ setEula(e.target.checked)}
+ />
+ {t("portal.procurement.builder.eula")}
+
+
+ )}
+
+
+
+
+ {t("portal.procurement.builder.running", {
+ annual: money(preview, cfg.currency),
+ years: cfg.termYears,
+ tcv: money(tcvPreview, cfg.currency),
+ })}
+
+
+ {step > 0 && (
+ setStep(step - 1)}>
+ {t("portal.procurement.builder.back")}
+
+ )}
+ {step === 0 && (
+ setStep(1)}
+ >
+ {t("portal.procurement.builder.continue")}
+
+ )}
+ {step === 1 && (
+ setStep(2)}
+ >
+ {t("portal.procurement.builder.continue")}
+
+ )}
+ {step === 2 && (
+
+ {t("portal.procurement.builder.generate")}
+
+ )}
+
+
+
+ );
+}
+
+function Step({
+ icon,
+ title,
+ sub,
+ children,
+}: {
+ icon: ReactNode;
+ title: string;
+ sub: string;
+ children: React.ReactNode;
+}) {
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
+
+function Field({
+ label,
+ children,
+}: {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function OptCard({
+ on,
+ title,
+ sub,
+ onClick,
+}: {
+ on: boolean;
+ title: string;
+ sub: string;
+ onClick: () => void;
+}) {
+ return (
+
+ {title}
+ {sub}
+
+ );
+}
+
+function AddOn({
+ on,
+ title,
+ sub,
+ onClick,
+}: {
+ on: boolean;
+ title: string;
+ sub: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+ {on ? "✓" : ""}
+
+
+ {title}
+ {sub}
+
+
+ );
+}
+
+function estimateVolume(users: number): number {
+ const raw = Math.max(0, users) * 5 * 230 * 1.75;
+ const stepSize = raw >= 1_000_000 ? 50_000 : raw >= 100_000 ? 25_000 : 5_000;
+ return Math.round(raw / stepSize) * stepSize;
+}
+
+function previewAnnualMinor(cfg: QuoteConfigInput): number {
+ const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5;
+ const usage = Math.round(cfg.volume * perPdf);
+ const withSla = Math.round(usage * (1 + (SLA_UPLIFT[cfg.serviceLevel] ?? 0)));
+ const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla;
+ const disc = Math.round(
+ withInd * TERM_DISCOUNT[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
+ );
+ return withInd - disc + (cfg.qbr ? 800_000 : 0);
+}
diff --git a/frontend/editor/src/portal/components/procurement/format.ts b/frontend/editor/src/portal/components/procurement/format.ts
index bba7e63393..75f5b2fb16 100644
--- a/frontend/editor/src/portal/components/procurement/format.ts
+++ b/frontend/editor/src/portal/components/procurement/format.ts
@@ -9,6 +9,15 @@ export const USD = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 0,
});
+/** Format a minor-unit (cents) amount in the given currency, whole units (no decimals). */
+export function money(minor: number, currency: string): string {
+ return new Intl.NumberFormat(undefined, {
+ style: "currency",
+ currency: currency || "USD",
+ maximumFractionDigits: 0,
+ }).format(minor / 100);
+}
+
/** Document status → badge tone. Action items lean amber to pull the eye. */
export const STATUS_TONE: Record = {
available: "success",
diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts
index bd1d471988..9341ce6304 100644
--- a/frontend/editor/src/portal/mocks/handlers/index.ts
+++ b/frontend/editor/src/portal/mocks/handlers/index.ts
@@ -8,6 +8,7 @@ import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
import { procurementHandlers } from "@portal/mocks/handlers/procurement";
+import { procurementSaasHandlers } from "@portal/mocks/handlers/procurementSaas";
import { docsHandlers } from "@portal/mocks/handlers/docs";
import { settingsHandlers } from "@portal/mocks/handlers/settings";
import { usersHandlers } from "@portal/mocks/handlers/users";
@@ -30,6 +31,7 @@ export const handlers = [
...infrastructureHandlers,
...docsHandlers,
...procurementHandlers,
+ ...procurementSaasHandlers,
...settingsHandlers,
...usersHandlers,
...agentsHandlers,
diff --git a/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts
new file mode 100644
index 0000000000..4a4cc4bcb8
--- /dev/null
+++ b/frontend/editor/src/portal/mocks/handlers/procurementSaas.ts
@@ -0,0 +1,241 @@
+import { http, HttpResponse } from "msw";
+
+/**
+ * MSW mock for the real SaaS procurement endpoints (`apiClient.saas` → VITE_SAAS_API_URL, which is
+ * `http://saas.mock` in dev/Storybook). Lets the trial → quote → accept flow run without the Java
+ * backend (Storybook + mocks-on dev). Pricing mirrors ProcurementPricingService.
+ */
+const SAAS = "http://saas.mock";
+
+const EMPTY = {
+ dealId: null,
+ stage: null,
+ trialStartedAt: null,
+ trialEndsAt: null,
+ trialExtensionsUsed: 0,
+ licensed: false,
+ latestQuote: null,
+};
+
+interface Cfg {
+ volume: number;
+ serviceLevel: string;
+ termYears: number;
+ indemnification: boolean;
+ training: boolean;
+ qbr: boolean;
+ currency: string;
+ businessName?: string;
+}
+
+let deal: typeof EMPTY | (Record & { latestQuote: unknown }) =
+ EMPTY;
+let seq = 0;
+
+const SLA: Record = {
+ standard: 0,
+ priority: 0.15,
+ dedicated: 0.3,
+};
+const TERM = [0, 0.05, 0.1, 0.12, 0.15];
+
+function priceQuote(cfg: Cfg) {
+ const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5;
+ const usage = Math.round(cfg.volume * perPdf);
+ const withSla = Math.round(usage * (1 + (SLA[cfg.serviceLevel] ?? 0)));
+ const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla;
+ const disc = Math.round(
+ withInd * TERM[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
+ );
+ const qbr = cfg.qbr ? 800_000 : 0;
+ const training = cfg.training ? 750_000 : 0;
+ const annualNetMinor = withInd - disc + qbr;
+ const tcvMinor = annualNetMinor * cfg.termYears + training;
+
+ type Kind = "RECURRING" | "ONE_TIME" | "DISCOUNT" | "INCLUDED";
+ const lines: {
+ key: string;
+ label: string;
+ kind: Kind;
+ amountMinor: number;
+ }[] = [
+ {
+ key: "usage",
+ label: "PDF processing",
+ kind: "RECURRING",
+ amountMinor: usage,
+ },
+ {
+ key: "seats",
+ label: "Unlimited users + SSO / SCIM / RBAC",
+ kind: "INCLUDED",
+ amountMinor: 0,
+ },
+ ];
+ if (withSla !== usage)
+ lines.push({
+ key: "service-level",
+ label:
+ cfg.serviceLevel === "dedicated"
+ ? "Dedicated service level"
+ : "Priority service level",
+ kind: "RECURRING",
+ amountMinor: withSla - usage,
+ });
+ if (withInd !== withSla)
+ lines.push({
+ key: "indemnification",
+ label: "IP indemnification",
+ kind: "RECURRING",
+ amountMinor: withInd - withSla,
+ });
+ if (qbr > 0)
+ lines.push({
+ key: "qbr",
+ label: "Quarterly business reviews",
+ kind: "RECURRING",
+ amountMinor: qbr,
+ });
+ if (disc > 0)
+ lines.push({
+ key: "multi-year",
+ label: `${cfg.termYears}-year commitment`,
+ kind: "DISCOUNT",
+ amountMinor: -disc,
+ });
+ if (training > 0)
+ lines.push({
+ key: "training",
+ label: "Onboarding & training",
+ kind: "ONE_TIME",
+ amountMinor: training,
+ });
+
+ seq += 1;
+ return {
+ quoteId: seq,
+ quoteNumber: `QT-DEMO-${String(seq).padStart(4, "0")}`,
+ status: "draft",
+ currency: cfg.currency || "USD",
+ annualNetMinor,
+ tcvMinor,
+ lineItems: lines,
+ validUntil: "2026-07-31",
+ stripeQuoteId: null,
+ invoiceUrl: null,
+ config: {
+ volume: cfg.volume,
+ users: 0,
+ deployment: "cloud",
+ termYears: cfg.termYears,
+ serviceLevel: cfg.serviceLevel,
+ indemnification: cfg.indemnification,
+ training: cfg.training,
+ qbr: cfg.qbr,
+ currency: cfg.currency || "USD",
+ businessName: cfg.businessName ?? "",
+ },
+ };
+}
+
+export function resetProcurementSaasStore() {
+ deal = EMPTY;
+ seq = 0;
+}
+
+export const procurementSaasHandlers = [
+ http.get(`${SAAS}/api/v1/procurement`, () => HttpResponse.json(deal)),
+ http.post(`${SAAS}/api/v1/procurement/trial/start`, () => {
+ const now = Date.now();
+ deal = {
+ dealId: 1,
+ stage: "trial",
+ trialStartedAt: new Date(now).toISOString(),
+ trialEndsAt: new Date(now + 14 * 86_400_000).toISOString(),
+ trialExtensionsUsed: 0,
+ licensed: true,
+ latestQuote: null,
+ };
+ return HttpResponse.json(deal);
+ }),
+ http.post(`${SAAS}/api/v1/procurement/quote`, async ({ request }) => {
+ const cfg = (await request.json()) as Cfg;
+ const quote = priceQuote(cfg);
+ const d = (
+ deal.dealId ? deal : { dealId: 1, trialExtensionsUsed: 0 }
+ ) as Record;
+ deal = {
+ ...d,
+ stage: "quote",
+ licensed: true,
+ latestQuote: quote,
+ } as never;
+ return HttpResponse.json(quote);
+ }),
+ http.post(`${SAAS}/api/v1/procurement/trial/extend`, () => {
+ const d = deal as Record;
+ if (d.dealId) {
+ const base = d.trialEndsAt
+ ? Date.parse(d.trialEndsAt as string)
+ : Date.now();
+ d.trialEndsAt = new Date(base + 7 * 86_400_000).toISOString();
+ d.trialExtensionsUsed = ((d.trialExtensionsUsed as number) ?? 0) + 1;
+ }
+ return HttpResponse.json(deal);
+ }),
+ http.post(`${SAAS}/api/v1/procurement/agreement`, () => {
+ (deal as Record).stage = "security";
+ return HttpResponse.json(deal);
+ }),
+ http.post(`${SAAS}/api/v1/procurement/go-live`, () => {
+ const d = deal as Record;
+ if (d.dealId) {
+ d.stage = "active";
+ d.licensed = true;
+ }
+ return HttpResponse.json(deal);
+ }),
+ http.post(`${SAAS}/api/v1/procurement/reset`, () => {
+ resetProcurementSaasStore();
+ return HttpResponse.json(EMPTY);
+ }),
+
+ // Stripe Quote edge functions (supabase.functions.invoke → ${url}/functions/v1/{name}).
+ http.post(`${SAAS}/functions/v1/issue-procurement-quote`, () => {
+ const q = (deal as { latestQuote: Record | null })
+ .latestQuote;
+ if (q) {
+ q.status = "sent";
+ q.stripeQuoteId = `qt_mock_${q.quoteId}`;
+ }
+ return HttpResponse.json(q);
+ }),
+ http.post(`${SAAS}/functions/v1/accept-procurement-quote`, () => {
+ const q = (deal as { latestQuote: Record | null })
+ .latestQuote;
+ const invoiceUrl = "https://invoice.stripe.com/i/mock_procurement";
+ if (q) {
+ q.status = "accepted";
+ q.invoiceUrl = invoiceUrl;
+ (deal as Record).stage = "procurement";
+ }
+ return HttpResponse.json({
+ status: "accepted",
+ subscriptionId: "sub_mock_procurement",
+ invoiceUrl,
+ invoicePdf: "https://invoice.stripe.com/i/mock_procurement/pdf",
+ });
+ }),
+ http.post(`${SAAS}/functions/v1/get-procurement-quote-pdf`, () => {
+ // A minimal valid PDF so the download opens something in Storybook / mock dev.
+ const pdf = `%PDF-1.1
+1 0 obj<>endobj
+2 0 obj<>endobj
+3 0 obj<>endobj
+trailer<>
+%%EOF`;
+ return new HttpResponse(pdf, {
+ headers: { "Content-Type": "application/pdf" },
+ });
+ }),
+];
diff --git a/frontend/editor/src/portal/views/Home.tsx b/frontend/editor/src/portal/views/Home.tsx
index b9478ead32..92d7f69318 100644
--- a/frontend/editor/src/portal/views/Home.tsx
+++ b/frontend/editor/src/portal/views/Home.tsx
@@ -28,6 +28,7 @@ import { UsageAreaChart } from "@portal/components/UsageAreaChart";
import { RecentActivity } from "@portal/components/RecentActivity";
import { SingleOpRunner } from "@portal/components/SingleOpRunner";
import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip";
+import { ProcurementHome } from "@portal/components/procurement/ProcurementHome";
import { PolicySummary } from "@portal/components/PolicySummary";
import { PipelineForkWizard } from "@portal/components/PipelineForkWizard";
import "@portal/views/Home.css";
@@ -538,6 +539,8 @@ export function Home() {
setRunnerOpen(true)} />
+
+
{tier === "free" && (
<>
diff --git a/frontend/editor/src/portal/views/Procurement.css b/frontend/editor/src/portal/views/Procurement.css
index 90da816a53..8294709e5d 100644
--- a/frontend/editor/src/portal/views/Procurement.css
+++ b/frontend/editor/src/portal/views/Procurement.css
@@ -451,3 +451,1010 @@
overflow-x: auto;
}
}
+
+/* ── Enterprise upsell CTA (Home / Usage on-ramp) ─────────────────────────── */
+.portal-proc__upsell {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+.portal-proc__upsell-badge {
+ display: inline-block;
+ font-size: 0.625rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-primary, #2383e2);
+ background: var(--color-primary-light, #eaf2fb);
+ padding: 0.15rem 0.5rem;
+ border-radius: 0.375rem;
+ margin-bottom: 0.4rem;
+}
+.portal-proc__upsell-copy {
+ margin: 0;
+ font-size: 0.8125rem;
+ line-height: 1.45;
+ color: var(--color-text-3);
+ max-width: 44rem;
+}
+.portal-proc__upsell-copy strong {
+ color: var(--color-text-1);
+}
+
+/* ── Quote builder ────────────────────────────────────────────────────────── */
+.portal-proc__builder-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+}
+.portal-proc__builder-title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 650;
+ color: var(--color-text-1);
+}
+.portal-proc__builder-step {
+ font-size: 0.75rem;
+ color: var(--color-text-5);
+}
+.portal-proc__builder-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+}
+.portal-proc__field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ font-size: 0.8125rem;
+ color: var(--color-text-3);
+}
+.portal-proc__field input,
+.portal-proc__field select {
+ padding: 0.45rem 0.6rem;
+ border: 1px solid var(--color-border, #eae8e3);
+ border-radius: 0.5rem;
+ font-size: 0.875rem;
+ background: var(--color-bg-elevated, #fff);
+ color: var(--color-text-1);
+}
+.portal-proc__builder-addons {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ font-size: 0.8125rem;
+ color: var(--color-text-2);
+}
+.portal-proc__builder-addons label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.portal-proc__builder-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.6rem;
+ margin-top: 0.5rem;
+}
+.portal-proc__quote-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--color-border, #eae8e3);
+ padding-bottom: 0.5rem;
+}
+.portal-proc__quote-number {
+ font-weight: 650;
+ color: var(--color-text-1);
+}
+.portal-proc__quote-valid {
+ font-size: 0.75rem;
+ color: var(--color-text-5);
+}
+.portal-proc__quote-lines {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.portal-proc__quote-lines li {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.4rem 0;
+ font-size: 0.8125rem;
+ color: var(--color-text-2);
+ border-bottom: 1px solid var(--color-border-light, #f0eee9);
+}
+.portal-proc__quote-lines li[data-kind="DISCOUNT"] {
+ color: var(--color-success, #0f7b6c);
+}
+.portal-proc__quote-total {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ padding: 0.6rem 0 0.2rem;
+ font-size: 0.9375rem;
+}
+.portal-proc__quote-total strong {
+ font-size: 1.25rem;
+ color: var(--color-text-1);
+}
+.portal-proc__quote-tcv {
+ font-size: 0.75rem;
+ color: var(--color-text-5);
+}
+
+/* ══ Quote builder — copied from the marketing prototype (tight density) ═════ */
+.portal-qb {
+ background: #ffffff;
+ border: 1px solid #e3e1dc;
+ border-radius: 14px;
+ box-shadow: inset 0 0 0 1px #eae8e3;
+ overflow: hidden;
+}
+.portal-qb__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 18px 24px 14px;
+ border-bottom: 1px solid #f0eee9;
+ background: linear-gradient(
+ 180deg,
+ rgba(35, 131, 226, 0.055) 0%,
+ transparent 100%
+ );
+}
+.portal-qb__title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 700;
+ color: #37352f;
+}
+.portal-qb__stepchip {
+ font-size: 11px;
+ font-weight: 700;
+ color: #9b9a97;
+ background: #f5f4f1;
+ padding: 3px 10px;
+ border-radius: 999px;
+}
+.portal-qb__progress {
+ display: flex;
+ gap: 6px;
+ padding: 12px 24px 0;
+}
+.portal-qb__progress span {
+ flex: 1;
+ height: 6px;
+ border-radius: 999px;
+ background: #f0eee9;
+ transition: background 0.3s;
+}
+.portal-qb__progress span[data-on] {
+ background: #2383e2;
+}
+.portal-qb__body {
+ padding: 20px 24px;
+ max-height: 56vh;
+ overflow-y: auto;
+}
+.portal-qb__intro {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 18px;
+}
+.portal-qb__intro-icon {
+ width: 38px;
+ height: 38px;
+ border-radius: 10px;
+ background: #eaf2fb;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ flex-shrink: 0;
+}
+.portal-qb__intro-title {
+ font-size: 15px;
+ font-weight: 700;
+ color: #37352f;
+}
+.portal-qb__intro-sub {
+ font-size: 12.5px;
+ color: #9b9a97;
+ margin-top: 1px;
+}
+.portal-qb__field {
+ display: block;
+ margin-bottom: 18px;
+}
+.portal-qb__field-label {
+ display: block;
+ font-size: 12px;
+ font-weight: 600;
+ color: #787774;
+ margin-bottom: 6px;
+}
+.portal-qb__field input,
+.portal-qb__field select {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 9px 11px;
+ font-size: 13.5px;
+ border-radius: 8px;
+ border: 1px solid #e3e1dc;
+ font-family: inherit;
+ outline: none;
+ background: #fff;
+ color: #37352f;
+}
+.portal-qb__row {
+ display: flex;
+ gap: 14px;
+ flex-wrap: wrap;
+}
+.portal-qb__row .portal-qb__field {
+ flex: 1;
+ min-width: 190px;
+}
+.portal-qb__hint {
+ margin: 7px 0 0;
+ font-size: 11.5px;
+ color: #9b9a97;
+ line-height: 1.4;
+}
+.portal-qb__pills {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.portal-qb__pills button {
+ padding: 8px 16px;
+ font-size: 13px;
+ font-weight: 600;
+ border-radius: 8px;
+ border: 1px solid #e3e1dc;
+ background: #fff;
+ color: #37352f;
+ cursor: pointer;
+}
+.portal-qb__pills button[data-on] {
+ background: #2383e2;
+ border-color: #2383e2;
+ color: #fff;
+}
+.portal-qb__discount {
+ margin: 6px 0 0;
+ font-size: 11.5px;
+ color: #0f7b6c;
+}
+.portal-qb__opts {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+.portal-qb__opt {
+ text-align: left;
+ flex: 1;
+ min-width: 150px;
+ padding: 12px 14px;
+ border-radius: 9px;
+ border: 1px solid #e3e1dc;
+ background: #fff;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.portal-qb__opt[data-on] {
+ border-color: #2383e2;
+ background: #eaf2fb;
+}
+.portal-qb__opt-title {
+ font-size: 13px;
+ font-weight: 700;
+ color: #37352f;
+}
+.portal-qb__opt[data-on] .portal-qb__opt-title {
+ color: #1b6ec2;
+}
+.portal-qb__opt-sub {
+ font-size: 11.5px;
+ color: #9b9a97;
+ line-height: 1.4;
+}
+.portal-qb__addons {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.portal-qb__addon {
+ display: flex;
+ align-items: flex-start;
+ gap: 11px;
+ padding: 11px 13px;
+ border-radius: 9px;
+ border: 1px solid #e3e1dc;
+ background: #fff;
+ cursor: pointer;
+ text-align: left;
+}
+.portal-qb__addon[data-on] {
+ border-color: #2383e2;
+ background: #eaf2fb;
+}
+.portal-qb__addon-box {
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+ border-radius: 5px;
+ border: 1px solid #d3d1cb;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ color: transparent;
+ background: #fff;
+}
+.portal-qb__addon[data-on] .portal-qb__addon-box {
+ background: #2383e2;
+ border-color: #2383e2;
+ color: #fff;
+}
+.portal-qb__addon-title {
+ display: block;
+ font-size: 13px;
+ font-weight: 600;
+ color: #37352f;
+}
+.portal-qb__addon-sub {
+ display: block;
+ font-size: 11.5px;
+ color: #9b9a97;
+ margin-top: 1px;
+}
+.portal-qb__eula {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 12px;
+ border-radius: 9px;
+ border: 1px solid #f0eee9;
+ background: #f5f4f1;
+ font-size: 12.5px;
+ color: #37352f;
+ line-height: 1.5;
+ cursor: pointer;
+}
+.portal-qb__foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 24px;
+ border-top: 1px solid #f0eee9;
+ flex-wrap: wrap;
+}
+.portal-qb__running {
+ font-size: 11.5px;
+ color: #9b9a97;
+}
+.portal-qb__foot-btns {
+ display: flex;
+ gap: 10px;
+}
+/* Step 4 — the itemised quote paper */
+.portal-qb__papertray {
+ background: #f5f4f1;
+ padding: 18px;
+ max-height: 56vh;
+ overflow-y: auto;
+ margin: -20px -24px;
+}
+.portal-qb__paper {
+ background: #fff;
+ border: 1px solid #f0eee9;
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
+}
+.portal-qb__paper-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding: 20px 24px;
+ border-bottom: 1px solid #f0eee9;
+}
+.portal-qb__paper-brand {
+ font-size: 13.5px;
+ font-weight: 800;
+ color: #37352f;
+}
+.portal-qb__paper-eyebrow {
+ font-size: 11px;
+ color: #9b9a97;
+}
+.portal-qb__paper-meta {
+ text-align: right;
+}
+.portal-qb__quote-number {
+ font-size: 12.5px;
+ font-weight: 700;
+ color: #37352f;
+}
+.portal-qb__paper-meta div:not(.portal-qb__quote-number) {
+ font-size: 11px;
+ color: #9b9a97;
+}
+.portal-qb__paper-for {
+ padding: 18px 24px 0;
+}
+.portal-qb__paper-company {
+ font-size: 14px;
+ font-weight: 700;
+ color: #37352f;
+}
+.portal-qb__lines {
+ list-style: none;
+ margin: 0;
+ padding: 12px 24px 0;
+}
+.portal-qb__lines li {
+ display: flex;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 10px 0;
+ border-bottom: 1px solid #f0eee9;
+ font-size: 13px;
+ font-weight: 600;
+ color: #37352f;
+}
+.portal-qb__lines li[data-kind="DISCOUNT"] {
+ color: #0f7b6c;
+}
+.portal-qb__lines li[data-kind="INCLUDED"] span:last-child {
+ color: #9b9a97;
+ font-weight: 500;
+}
+.portal-qb__total {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 16px 24px 24px;
+ padding: 16px 18px;
+ border-radius: 10px;
+ background: #eaf2fb;
+ border: 1px solid #b8d5f2;
+}
+.portal-qb__total-label {
+ font-size: 13px;
+ font-weight: 700;
+ color: #37352f;
+}
+.portal-qb__total-tcv {
+ font-size: 11.5px;
+ color: #787774;
+ margin-top: 2px;
+}
+.portal-qb__total-num {
+ text-align: right;
+}
+.portal-qb__total-num strong {
+ display: block;
+ font-size: 23px;
+ font-weight: 800;
+ color: #37352f;
+ line-height: 1;
+}
+.portal-qb__total-num span {
+ font-size: 11px;
+ color: #9b9a97;
+ margin-top: 3px;
+}
+
+/* ── Enterprise upsell text wrapper (Home on-ramp) ────────────────────────── */
+.portal-proc__upsell-text {
+ flex: 1 1 20rem;
+}
+
+/* ── Deal-status hero (Home, active deal) ─────────────────────────────────── */
+.portal-hero {
+ border: 1px solid var(--color-border);
+ border-radius: 12px;
+ padding: 1.1rem 1.25rem;
+ background:
+ radial-gradient(
+ 120% 140% at 100% 0%,
+ rgba(124, 58, 237, 0.08),
+ transparent 55%
+ ),
+ var(--color-surface);
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+.portal-hero__top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+.portal-hero__eyebrow {
+ display: block;
+ font-size: 0.6875rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--color-primary, #2383e2);
+}
+.portal-hero__company {
+ display: block;
+ font-size: 1.0625rem;
+ font-weight: 650;
+ color: var(--color-text-1);
+ margin-top: 0.2rem;
+}
+.portal-hero__chips {
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+}
+.portal-hero__chip {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--color-text-3);
+ background: var(--color-border-light);
+ border-radius: 999px;
+ padding: 0.2rem 0.6rem;
+}
+.portal-hero__stepper {
+ overflow-x: auto;
+}
+.portal-hero__next {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ padding-top: 0.85rem;
+ border-top: 1px solid var(--color-border-light);
+}
+.portal-hero__next-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--color-text-2);
+}
+.portal-hero__next-dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 999px;
+ background: var(--color-primary, #2383e2);
+ box-shadow: 0 0 0 3px rgba(35, 131, 226, 0.18);
+}
+
+/* ── Full-screen takeover modal (procurement flow) ────────────────────────── */
+.portal-procmodal {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: clamp(0.5rem, 4vh, 3rem) 1rem;
+ overflow-y: auto;
+ background: rgba(15, 23, 42, 0.55);
+ backdrop-filter: blur(6px) saturate(160%);
+ -webkit-backdrop-filter: blur(6px) saturate(160%);
+ animation: portal-procmodal-fade 0.15s ease-out;
+}
+@keyframes portal-procmodal-fade {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+.portal-procmodal__panel {
+ position: relative;
+ width: 100%;
+ max-width: 62rem;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: 14px;
+ box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28);
+ padding: 1.5rem 1.5rem 1.75rem;
+}
+.portal-procmodal__close {
+ position: absolute;
+ top: 0.85rem;
+ right: 0.85rem;
+ border: none;
+ background: var(--color-border-light);
+ color: var(--color-text-3);
+ width: 1.9rem;
+ height: 1.9rem;
+ border-radius: 8px;
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+.portal-procmodal__close:hover {
+ background: var(--color-border);
+ color: var(--color-text-1);
+}
+.portal-procmodal__header {
+ margin-bottom: 1.25rem;
+ padding-right: 2.5rem;
+}
+.portal-procmodal__title {
+ margin: 0;
+ font-size: 1.35rem;
+ font-weight: 700;
+ color: var(--color-text-1);
+}
+.portal-procmodal__sub {
+ margin: 0.3rem 0 0;
+ font-size: 0.875rem;
+ color: var(--color-text-3);
+}
+.portal-procmodal__body {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+.portal-proc__modal-stepper {
+ overflow-x: auto;
+}
+.portal-proc__payment-actions {
+ display: flex;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+ margin-top: 1rem;
+}
+.portal-proc__milestone-for {
+ margin: 0.15rem 0 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--color-text-2);
+}
+.portal-proc__milestone-lines {
+ margin: 0.85rem 0 0.5rem;
+}
+.portal-proc__milestone-totals {
+ display: flex;
+ align-items: baseline;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin: 0.75rem 0 0.25rem;
+}
+.portal-proc__milestone-annual {
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: var(--color-text-1);
+}
+.portal-proc__milestone-annual small {
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--color-text-4);
+}
+.portal-proc__milestone-tcv {
+ font-size: 0.8125rem;
+ color: var(--color-text-3);
+}
+
+/* Hero next-step action row (primary CTA + optional extend-trial). */
+.portal-hero__next-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+/* Hero quick-action chips (clickable pills next to the company name). */
+.portal-hero__chip--action {
+ border: 1px solid var(--color-border);
+ cursor: pointer;
+ transition:
+ background 0.12s,
+ box-shadow 0.12s,
+ transform 0.12s;
+}
+.portal-hero__chip--action:hover {
+ background: var(--color-surface);
+ box-shadow: 0 2px 5px rgba(15, 23, 42, 0.1);
+ transform: translateY(-1px);
+}
+
+/* Hero rollout checklist (trial): the "do this now" setup steps. */
+.portal-hero__checklist {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid var(--color-border-light);
+}
+.portal-hero__checklist li {
+ border-bottom: 1px solid var(--color-border-light);
+}
+.portal-hero__checklist button {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+ width: 100%;
+ padding: 0.7rem 0.25rem;
+ background: none;
+ border: none;
+ cursor: pointer;
+ text-align: left;
+}
+.portal-hero__checklist button:hover {
+ background: var(--color-bg-hover, var(--color-border-light));
+}
+.portal-hero__check-dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 999px;
+ background: var(--color-border);
+ flex-shrink: 0;
+}
+.portal-hero__check-text {
+ flex: 1;
+ min-width: 0;
+}
+.portal-hero__check-title {
+ display: block;
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+.portal-hero__check-sub {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+ margin-top: 0.05rem;
+}
+.portal-hero__check-pill {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--color-text-5);
+ background: var(--color-border-light);
+ border-radius: 999px;
+ padding: 0.15rem 0.55rem;
+ flex-shrink: 0;
+}
+
+/* ── Side dialogs (Key documents / Schedule a call / Trial) ───────────────── */
+.portal-sidemodal {
+ position: fixed;
+ inset: 0;
+ z-index: 1100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ overflow-y: auto;
+ background: rgba(15, 23, 42, 0.55);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ animation: portal-procmodal-fade 0.15s ease-out;
+}
+.portal-sidemodal__panel {
+ position: relative;
+ width: 100%;
+ max-width: 30rem;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: 14px;
+ box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28);
+ padding: 1.35rem 1.4rem 1.4rem;
+ max-height: 86vh;
+ overflow-y: auto;
+}
+.portal-sidemodal__header {
+ margin-bottom: 1rem;
+ padding-right: 2rem;
+}
+.portal-sidemodal__title {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 700;
+ color: var(--color-text-1);
+}
+.portal-sidemodal__sub {
+ margin: 0.25rem 0 0;
+ font-size: 0.8125rem;
+ color: var(--color-text-4);
+ line-height: 1.5;
+}
+.portal-sidemodal__text {
+ margin: 0;
+ font-size: 0.8125rem;
+ color: var(--color-text-3);
+ line-height: 1.55;
+}
+.portal-sidemodal__footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-top: 1.1rem;
+ padding-top: 0.9rem;
+ border-top: 1px solid var(--color-border-light);
+}
+.portal-sidemodal__ghost {
+ border: none;
+ background: none;
+ font-size: 0.8125rem;
+ color: var(--color-text-4);
+ cursor: pointer;
+}
+.portal-sidemodal__ghost:hover:not(:disabled) {
+ color: var(--color-text-2);
+}
+
+/* Key documents ledger. */
+.portal-docs__group + .portal-docs__group {
+ margin-top: 1rem;
+}
+.portal-docs__group-title {
+ font-size: 0.6875rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-5);
+ margin-bottom: 0.4rem;
+}
+.portal-docs__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.portal-docs__row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.55rem 0;
+ border-top: 1px solid var(--color-border-light);
+}
+.portal-docs__row-text {
+ flex: 1;
+ min-width: 0;
+}
+.portal-docs__row-name {
+ display: block;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--color-text-1);
+}
+.portal-docs__row-sub {
+ display: block;
+ font-size: 0.72rem;
+ color: var(--color-text-4);
+ margin-top: 0.05rem;
+}
+.portal-docs__row-action {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ border-radius: 999px;
+ padding: 0.2rem 0.6rem;
+ flex-shrink: 0;
+ color: var(--color-text-3);
+ background: var(--color-border-light);
+}
+.portal-docs__row-action[data-status="action"] {
+ color: var(--color-primary, #2383e2);
+ background: var(--color-primary-light, #eaf2fb);
+}
+.portal-docs__row-action[data-status="request"] {
+ color: var(--color-text-5);
+}
+
+/* Solutions-engineer + time slots (Schedule a call). */
+.portal-se {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+.portal-se__avatar {
+ width: 2.4rem;
+ height: 2.4rem;
+ border-radius: 999px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--color-primary, #2383e2);
+ background: var(--color-primary-light, #eaf2fb);
+ flex-shrink: 0;
+}
+.portal-se__name {
+ font-size: 0.875rem;
+ font-weight: 650;
+ color: var(--color-text-1);
+}
+.portal-se__role {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+}
+.portal-slots {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.5rem;
+}
+.portal-slots__slot {
+ padding: 0.6rem 0.75rem;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--color-text-2);
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: 9px;
+ cursor: pointer;
+}
+.portal-slots__slot:hover {
+ border-color: var(--color-primary, #2383e2);
+ color: var(--color-primary, #2383e2);
+}
+
+/* ── Agreement (security) step ────────────────────────────────────────────── */
+.portal-agreement__doc {
+ margin: 1rem 0;
+ max-height: 22rem;
+ overflow-y: auto;
+ padding: 1rem 1.1rem;
+ border: 1px solid var(--color-border);
+ border-radius: 10px;
+ background: var(--color-bg-subtle, var(--color-bg));
+ font-size: 0.8125rem;
+ line-height: 1.55;
+ color: var(--color-text-3);
+}
+.portal-agreement__doc h4 {
+ margin: 1rem 0 0.35rem;
+ font-size: 0.8125rem;
+ font-weight: 650;
+ color: var(--color-text-1);
+}
+.portal-agreement__doc h4:first-child {
+ margin-top: 0;
+}
+.portal-agreement__doc p {
+ margin: 0 0 0.5rem;
+}
+.portal-agreement__doc strong {
+ color: var(--color-text-1);
+}
+.portal-agreement__accept {
+ margin-top: 0.25rem;
+}
+.portal-agreement__lines {
+ margin: 0.4rem 0 0.6rem;
+}
+.portal-proc__reset {
+ display: flex;
+ justify-content: center;
+ padding-top: 0.5rem;
+}
+.portal-proc__reset button {
+ border: none;
+ background: none;
+ font-size: 0.75rem;
+ color: var(--color-text-5);
+ cursor: pointer;
+ text-decoration: underline;
+}
+.portal-proc__reset button:hover:not(:disabled) {
+ color: var(--color-text-3);
+}
+.portal-proc__reset button:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
diff --git a/frontend/editor/src/portal/views/Procurement.tsx b/frontend/editor/src/portal/views/Procurement.tsx
index f4fa25178a..1f6e2329a9 100644
--- a/frontend/editor/src/portal/views/Procurement.tsx
+++ b/frontend/editor/src/portal/views/Procurement.tsx
@@ -1,111 +1,15 @@
-import { useEffect, useState } from "react";
-import { useTranslation } from "react-i18next";
-import { Card, Skeleton, StatusBadge } from "@app/ui";
-import { useTier } from "@portal/contexts/TierContext";
-import { useAsync } from "@portal/hooks/useAsync";
-import {
- advanceStage,
- fetchProcurement,
- type DealStage,
- type LedgerDoc,
- type ProcurementResponse,
-} from "@portal/api/procurement";
-import { DealJourney } from "@portal/components/procurement/DealJourney";
-import { DocumentLedger } from "@portal/components/procurement/DocumentLedger";
-import { ActionModal } from "@portal/components/procurement/ActionModal";
-import { LockedState } from "@portal/components/procurement/LockedState";
+import { ProcurementHome } from "@portal/components/procurement/ProcurementHome";
import "@portal/views/Procurement.css";
/**
- * Procurement: the enterprise commercial journey (trial to live) plus the
- * document ledger. Enterprise-only: free/pro buyers see a locked upgrade state.
+ * /procurement — procurement is no longer a nav tab; it lives on Home as the deal-status hero.
+ * This route is kept for deep links (and the Usage "Build your quote" CTA): it renders the same
+ * surface, opening the takeover modal once a deal is underway.
*/
export function Procurement() {
- const { t } = useTranslation();
- const { tier } = useTier();
- const [activeDoc, setActiveDoc] = useState(null);
- const [advancing, setAdvancing] = useState(false);
-
- const state = useAsync(
- () => fetchProcurement(tier),
- [tier],
- );
-
- // Write actions return the new canonical state; we hold it here so the
- // journey reflects every action immediately. Cleared when the tier (and so
- // the deal) changes, falling back to whatever the GET loaded.
- const [applied, setApplied] = useState(null);
- useEffect(() => setApplied(null), [tier]);
- const data = applied ?? (state.loading ? null : state.data);
-
- async function onAdvance(stage: DealStage) {
- setAdvancing(true);
- try {
- setApplied(await advanceStage(stage));
- } finally {
- setAdvancing(false);
- }
- }
-
return (
-
-
- {state.loading && (
-
-
-
-
- )}
-
- {data && !data.unlocked && (
-
{
- // TODO(backend): POST /v1/procurement/sales-contact, for now this
- // is the sidebar's upgrade path; hand off to the account team.
- }}
- />
- )}
-
- {data && data.unlocked && data.deal && (
- <>
-
-
- >
- )}
-
- setActiveDoc(null)}
- onDone={(next) => {
- setApplied(next);
- setActiveDoc(null);
- }}
- />
+
);
}
From be97268a7ccb24691cdf8e042a7589d8c68d5d25 Mon Sep 17 00:00:00 2001
From: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
Date: Tue, 7 Jul 2026 15:29:55 +0100
Subject: [PATCH 07/43] SUI - setting up mantine backed SUI components (#6890)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Converts SUI's existing Select and Slider to Mantine-backed
implementations, and adds three new Mantine-backed SUI components:
MultiSelect, NumberInput, ColorInput.
All five components follow the same contract as the rest of the SUI
catalogue:
- Imported from `@app/ui` — Mantine is an implementation detail
- Explicit prop allowlists: appearance props (color, variant, radius,
classNames, styles) are locked internally to SUI tokens; only
behavioural props are exposed
- Labels and error messages stripped from the interface — callers use
`` for both. The components take an `invalid` flag that
applies error styling only; Mantine never renders its own message
element, so the text can't appear twice
- `aria-label` / `aria-invalid` / `aria-describedby` and `FormField`'s
injected `required` are forwarded, so the injected accessibility wiring
reaches the underlying input. Mantine drops some of this wiring
internally (`aria-describedby` on inputs, all aria props on Slider's
thumb, `required` on MultiSelect's field), so `ariaForwarding.ts`
re-applies it to the DOM node and `ariaForwarding.test.tsx` locks the
contract in
- Typed escape hatches (`comboboxProps`, `popoverProps`, `rightSection`)
documented for the z-index-in-modal use case
**Select** — rebuilt from native `` to Mantine combobox. Gains
searchable/clearable. `onChange` now receives the value string directly,
not a DOM event — callers updated.
**Slider** — rebuilt from native ` ` to Mantine
Slider. Gains accessible keyboard navigation and `marks` support.
**MultiSelect, NumberInput, ColorInput** — new components. The behaviour
(multi-select combobox, number stepper, colour picker) is too complex to
hand-build correctly; Mantine provides it for free behind a locked SUI
interface.
Also wires `suiCssVariablesResolver` into the Storybook
`MantineProvider` so Mantine combobox/popover dropdowns follow the SUI
palette in dark mode, and adds `"neutral"` accent variant to
`IconBadge`.
## Usage
```tsx
import { Select, Slider, MultiSelect, NumberInput, ColorInput } from "@app/ui";
import { FormField } from "@app/ui/FormField";
// Select — onChange receives string | null, not a DOM event
// Slider — same external API as before, now with marks support
// New components
```
## Notes
- **Select `onChange` is a breaking change** — receives `string | null`
instead of a DOM event. All existing callers in this repo are updated.
- The policy PR (`main` WIP) depends on this merging first.
- Stories for all five components are under **Primitives / Forms** in
Storybook.
---
frontend/.storybook/preview.tsx | 7 +-
frontend/editor/src/portal/PortalApp.tsx | 19 +-
frontend/editor/src/portal/UI_CONVENTIONS.md | 96 +--
.../src/portal/components/SettingsModal.tsx | 6 +-
.../components/infrastructure/StorageTab.tsx | 4 +-
.../components/pipelines/PipelineComposer.tsx | 4 +-
.../components/policies/PolicyFieldRow.tsx | 2 +-
.../components/policies/PolicySetupWizard.tsx | 17 +-
.../components/sources/ConnectWizard.test.tsx | 15 +-
.../components/sources/ConnectWizard.tsx | 4 +-
.../components/users/InviteMemberModal.tsx | 2 +-
.../editor/src/portal/theme/SuiProvider.tsx | 34 ++
.../editor/src/portal/theme/mantineTheme.ts | 39 +-
.../components/policies/PolicyFieldRow.tsx | 2 +-
.../components/policies/PolicySetupWizard.tsx | 15 +-
.../editor/src/proprietary/ui/ColorInput.tsx | 146 +++++
.../src/proprietary/ui/Forms.stories.tsx | 19 +-
.../editor/src/proprietary/ui/IconBadge.css | 4 +
.../editor/src/proprietary/ui/IconBadge.tsx | 8 +-
.../src/proprietary/ui/MantineForms.css | 114 ++++
.../proprietary/ui/MantineForms.stories.tsx | 568 ++++++++++++++++++
.../editor/src/proprietary/ui/MultiSelect.tsx | 184 ++++++
.../editor/src/proprietary/ui/NumberInput.tsx | 190 ++++++
frontend/editor/src/proprietary/ui/Select.tsx | 195 ++++--
frontend/editor/src/proprietary/ui/Slider.tsx | 150 +++--
.../proprietary/ui/ariaForwarding.test.tsx | 106 ++++
.../src/proprietary/ui/ariaForwarding.ts | 57 ++
frontend/editor/src/proprietary/ui/index.ts | 5 +
28 files changed, 1811 insertions(+), 201 deletions(-)
create mode 100644 frontend/editor/src/portal/theme/SuiProvider.tsx
create mode 100644 frontend/editor/src/proprietary/ui/ColorInput.tsx
create mode 100644 frontend/editor/src/proprietary/ui/MantineForms.css
create mode 100644 frontend/editor/src/proprietary/ui/MantineForms.stories.tsx
create mode 100644 frontend/editor/src/proprietary/ui/MultiSelect.tsx
create mode 100644 frontend/editor/src/proprietary/ui/NumberInput.tsx
create mode 100644 frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx
create mode 100644 frontend/editor/src/proprietary/ui/ariaForwarding.ts
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
index dd8e379a8a..cd601cf525 100644
--- a/frontend/.storybook/preview.tsx
+++ b/frontend/.storybook/preview.tsx
@@ -7,7 +7,6 @@ import type { Decorator, Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";
import { MemoryRouter } from "react-router-dom";
import { withThemeByDataAttribute } from "@storybook/addon-themes";
-import { MantineProvider } from "@mantine/core";
// Reference React so the import isn't dropped as unused by the bundler — the
// classic runtime needs it present even though it's not named in the JSX.
@@ -17,7 +16,7 @@ import { TierProvider, type Tier } from "@portal/contexts/TierContext";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { ThemeProvider } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
-import { mantineTheme } from "@portal/theme/mantineTheme";
+import { SuiProvider } from "@portal/theme/SuiProvider";
import { handlers } from "@portal/mocks/handlers";
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
@@ -102,7 +101,7 @@ const withProviders: Decorator = (Story, context) => {
return (
-
+
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
from useLink() (matches App.tsx's nesting). */}
@@ -115,7 +114,7 @@ const withProviders: Decorator = (Story, context) => {
-
+
);
diff --git a/frontend/editor/src/portal/PortalApp.tsx b/frontend/editor/src/portal/PortalApp.tsx
index 1494e23cdf..629a8ac57a 100644
--- a/frontend/editor/src/portal/PortalApp.tsx
+++ b/frontend/editor/src/portal/PortalApp.tsx
@@ -1,6 +1,5 @@
import { useEffect, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
-import { MantineProvider } from "@mantine/core";
import { AuthProvider } from "@app/auth";
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
@@ -8,7 +7,7 @@ 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 { mantineTheme } from "@portal/theme/mantineTheme";
+import { SuiProvider } from "@portal/theme/SuiProvider";
import { AppShell } from "@portal/components/AppShell";
import { AuthGate } from "@portal/components/AuthGate";
import { AssistantButton } from "@portal/components/AssistantButton";
@@ -25,17 +24,13 @@ import { ViewRouter } from "@portal/ViewRouter";
import "@portal/theme/base.css";
/**
- * Binds Mantine's colour scheme to the portal's own ThemeProvider so Mantine
- * components follow the same light/dark switch as the SUI primitives. Must sit
+ * Binds the SUI design system to the portal's own ThemeProvider so the SUI
+ * components follow the same light/dark switch as the CSS tokens. Must sit
* inside to read useTheme().
*/
-function PortalMantineProvider({ children }: { children: ReactNode }) {
+function ThemedSuiProvider({ children }: { children: ReactNode }) {
const { theme } = useTheme();
- return (
-
- {children}
-
- );
+ return {children} ;
}
/**
@@ -129,7 +124,7 @@ function RoutedContent() {
export function PortalApp() {
return (
-
+
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
@@ -156,7 +151,7 @@ export function PortalApp() {
-
+
);
}
diff --git a/frontend/editor/src/portal/UI_CONVENTIONS.md b/frontend/editor/src/portal/UI_CONVENTIONS.md
index 40879e2199..5487672b7b 100644
--- a/frontend/editor/src/portal/UI_CONVENTIONS.md
+++ b/frontend/editor/src/portal/UI_CONVENTIONS.md
@@ -1,19 +1,23 @@
# Portal UI conventions — SUI vs Mantine
-The portal has two component sources. The rule:
+The portal has one component source from the caller's point of view: **SUI
+(`@app/ui`)**. Under the hood there are two kinds of SUI component. The rule:
-> **Simple, presentational, brand-defining UI → our SUI design system
-> (`@app/ui`). Complex, stateful, or accessibility-hard widgets →
-> Mantine.** Don't reinvent what Mantine already does well; do own the look of
-> the simple, high-frequency pieces.
+> **Simple, presentational, brand-defining UI → hand-rolled SUI components.
+> Complex, stateful, or accessibility-hard widgets → Mantine, wrapped behind a
+> locked SUI interface.** Either way, callers import from `@app/ui`. Mantine is
+> an implementation detail of the design system — feature code never imports
+> `@mantine/core` directly.
-Both are theme-bound: `MantineProvider` in `App.tsx` is wired to the portal's
-`ThemeProvider` (`mantineTheme.ts` maps the brand palette), so Mantine widgets
-follow the same light/dark switch and brand colours as SUI. **The provider is
-intentional** — it exists precisely so we can drop Mantine widgets in where they
-earn their keep.
+Theme wiring lives in one place: `SuiProvider` (`@portal/theme/SuiProvider`)
+applies the SUI-token Mantine theme, remaps Mantine's neutral palette
+(dropdown/popover surfaces, borders, text) onto SUI tokens via
+`suiCssVariablesResolver`, and takes the resolved light/dark scheme so Mantine
+chrome and the SUI CSS variables switch together. The app and Storybook both
+render through it.
+
+## Hand-rolled SUI — our own style
-## Use SUI (`@app/ui`) — our own style
Layout and presentational primitives we want full brand control over and that
are cheap to own:
@@ -23,40 +27,64 @@ are cheap to own:
`Stack` / `Inline` · `Table` (static/presentational) · `CodeBlock` ·
`FormField` (label/help/error layout) · simple `Tabs`.
-## Use Mantine — don't reinvent
-Anything that needs portals, focus traps, ARIA keyboard patterns, or is just a
-solved hard problem:
+## Mantine-backed SUI — don't reinvent, but do own the interface
-- **Overlays**: `Modal`, `Drawer`, `Popover` (focus trap, scroll lock, escape, focus restore)
-- **Menus**: `Menu` (roving arrow-key navigation)
-- **Selects**: `Select` / `MultiSelect` / `Combobox` / `Autocomplete` (keyboard + filtering)
-- **Dates**: `@mantine/dates` `DatePicker` / `DatePickerInput` (e.g. billing period range)
-- **Files**: `@mantine/dropzone` `Dropzone` (connect-source upload, op-runner sample drop)
-- **Progress UX**: `Stepper` (multi-step wizards), `Notifications`, `Tooltip`
-- Hooks: prefer `@mantine/hooks` (`useDisclosure`, `useClickOutside`, `useHotkeys`, …) over hand-rolling.
+Anything that needs portals, focus traps, ARIA keyboard patterns, or is just a
+solved hard problem gets a Mantine implementation behind a SUI wrapper.
+Shipped today: `Select` · `MultiSelect` · `NumberInput` · `ColorInput` ·
+`Slider`.
+
+Every wrapper follows the same contract (use the existing ones as the
+template):
+
+- **Explicit prop allowlist.** Only behavioural props are exposed; appearance
+ props (`color`, `variant`, `radius`, `classNames`, `styles`) are locked
+ internally to SUI tokens.
+- **No labels or error text.** Callers use `` for both. Wrappers
+ take an `invalid` flag that applies error styling only; Mantine never
+ renders its own message element.
+- **Accessibility props forwarded.** `id`, `aria-label`, `aria-invalid`, and
+ `aria-describedby` pass through so `FormField`'s injected wiring reaches the
+ underlying input.
+- **Typed escape hatches** (`comboboxProps`, `popoverProps`, `rightSection`)
+ for the z-index-in-modal case, documented on the component.
+
+When a feature needs a Mantine widget that has no wrapper yet (`Modal`,
+`Drawer`, `Menu`, `Stepper`, `Tooltip`, `@mantine/dates`,
+`@mantine/dropzone`, …), add the wrapper to `@app/ui` following this contract
+rather than importing Mantine in feature code. Hooks are the exception:
+prefer `@mantine/hooks` (`useDisclosure`, `useClickOutside`, `useHotkeys`, …)
+over hand-rolling, imported directly.
## Why
+
Mantine is mature and battle-tested for accessibility. A review of the
hand-rolled SUI overlays found real gaps — `Dropdown` has no arrow-key
navigation, `Modal`/`Drawer` mishandle focus when there are no focusable
children, `Toast` uses `role="alert"` for every tone — exactly the things
Mantine gets right. Owning those is wasted effort and an a11y liability.
-## Known migrations (hand-rolled today → should be Mantine)
-These shipped as SUI primitives during the initial build and should move to
-Mantine equivalents (fixes the a11y findings above):
+The wrapper (rather than direct Mantine use) is what keeps the door open to
+swapping the implementation later: callers depend on the SUI contract, not on
+Mantine's API surface.
-| Today (SUI) | → Mantine |
-|---|---|
-| `Dropdown` (menus: tier switcher, app switcher, notifications) | `Menu` |
-| `Modal` (composer, wizards, settings, create-key) | `Modal` |
-| `Drawer` (pipeline detail) | `Drawer` |
-| `Toast` | `notifications` |
-| _new need:_ billing date range | `@mantine/dates` |
-| _new need:_ file upload | `@mantine/dropzone` |
+## Known migrations (hand-rolled today → Mantine-backed SUI)
-Keep `Tabs` SUI for the simple in-page switchers; only reach for more if a true
-tabpanel/roving-focus contract is needed.
+These shipped as hand-rolled primitives during the initial build and should
+move to Mantine-backed wrappers (fixes the a11y findings above). `Select` and
+`Slider` have already made this move.
+
+| Today (hand-rolled) | → Mantine-backed SUI wrapper |
+| -------------------------------------------------------------- | ---------------------------- |
+| `Dropdown` (menus: tier switcher, app switcher, notifications) | wraps `Menu` |
+| `Modal` (composer, wizards, settings, create-key) | wraps `Modal` |
+| `Drawer` (pipeline detail) | wraps `Drawer` |
+| `Toast` | wraps `notifications` |
+| _new need:_ billing date range | wraps `@mantine/dates` |
+| _new need:_ file upload | wraps `@mantine/dropzone` |
+
+Keep `Tabs` hand-rolled for the simple in-page switchers; only reach for more
+if a true tabpanel/roving-focus contract is needed.
> Migrating overlays touches visible chrome and behaviour, so do it deliberately
> (with eyes on the result), not as a blind sweep.
diff --git a/frontend/editor/src/portal/components/SettingsModal.tsx b/frontend/editor/src/portal/components/SettingsModal.tsx
index aa01c2ba0e..c6a40f3389 100644
--- a/frontend/editor/src/portal/components/SettingsModal.tsx
+++ b/frontend/editor/src/portal/components/SettingsModal.tsx
@@ -612,7 +612,7 @@ function WorkspacePanel({
>
onRegion(e.target.value)}
+ onChange={(value) => onRegion(value ?? "")}
options={regionOptions}
/>
@@ -750,8 +750,8 @@ function AuthenticationPanel({
>
- onSecurity({ sessionTimeoutMins: Number(e.target.value) })
+ onChange={(value) =>
+ onSecurity({ sessionTimeoutMins: Number(value ?? "0") })
}
options={SESSION_TIMEOUT_VALUES.map((value) => ({
value,
diff --git a/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx b/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx
index 5f944465d8..7b2ecaa441 100644
--- a/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx
@@ -186,7 +186,9 @@ export function StorageTab() {
setRetention(e.target.value as RetentionWindow)}
+ onChange={(value) =>
+ setRetention((value ?? "") as RetentionWindow)
+ }
/>
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx b/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx
index 1d35e8d0f7..5040a97ac0 100644
--- a/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx
+++ b/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx
@@ -362,8 +362,8 @@ export function PipelineComposer({
- setScheduleUnit(e.target.value as ScheduleUnit)
+ onChange={(value) =>
+ setScheduleUnit((value ?? "") as ScheduleUnit)
}
options={SCHEDULE_UNITS.map((unit) => ({
value: unit,
diff --git a/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx
index cb1893cc66..4024cce5e3 100644
--- a/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx
+++ b/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx
@@ -64,7 +64,7 @@ export function PolicyFieldRow({
inputSize="sm"
value={typeof value === "string" ? value : ""}
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
- onChange={(e) => onChange(e.target.value)}
+ onChange={(value) => onChange(value ?? "")}
/>
);
diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx
index 30b1d05403..96a9d377f2 100644
--- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx
+++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx
@@ -453,8 +453,8 @@ function PolicySetupWizardBody({
- setRunOn(e.target.value as "upload" | "export")
+ onChange={(value) =>
+ setRunOn((value ?? "upload") as "upload" | "export")
}
options={[
{
@@ -474,8 +474,10 @@ function PolicySetupWizardBody({
{
- const mode = e.target.value as "new_file" | "new_version";
+ onChange={(value) => {
+ const mode = (value ?? "new_file") as
+ | "new_file"
+ | "new_version";
setOutputMode(mode);
// Auto-number only applies to separate new files.
if (
@@ -508,9 +510,12 @@ function PolicySetupWizardBody({
+ onChange={(value) =>
setOutputNamePosition(
- e.target.value as "prefix" | "suffix" | "auto-number",
+ (value ?? "suffix") as
+ | "prefix"
+ | "suffix"
+ | "auto-number",
)
}
options={[
diff --git a/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx b/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx
index c6874effcd..d354da1d40 100644
--- a/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx
+++ b/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx
@@ -1,8 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
import { HttpError } from "@portal/api/http";
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
+function renderWithMantine(ui: React.ReactElement) {
+ return render({ui} );
+}
+
// Deterministic i18n: keys come back verbatim so the test never waits on the
// async TOML backend.
vi.mock("react-i18next", () => ({
@@ -41,7 +46,9 @@ describe("ConnectWizard", () => {
const onCreated = vi.fn();
const onClose = vi.fn();
- render( );
+ renderWithMantine(
+ ,
+ );
stepToReview();
@@ -66,7 +73,7 @@ describe("ConnectWizard", () => {
createSource.mockResolvedValue({ id: "s1" });
const onCreated = vi.fn();
- render(
+ renderWithMantine(
{
}),
);
- render( );
+ renderWithMantine(
+ ,
+ );
stepToReview();
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
diff --git a/frontend/editor/src/portal/components/sources/ConnectWizard.tsx b/frontend/editor/src/portal/components/sources/ConnectWizard.tsx
index 8683873316..2c6e9263b7 100644
--- a/frontend/editor/src/portal/components/sources/ConnectWizard.tsx
+++ b/frontend/editor/src/portal/components/sources/ConnectWizard.tsx
@@ -245,8 +245,8 @@ export function ConnectWizard({
value: o.value,
label: t(o.labelKey),
}))}
- onChange={(e) =>
- setOptions((o) => ({ ...o, [field.key]: e.target.value }))
+ onChange={(value) =>
+ setOptions((o) => ({ ...o, [field.key]: value ?? "" }))
}
/>
) : (
diff --git a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx
index 5031b6427c..ad98077ba4 100644
--- a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx
+++ b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx
@@ -91,7 +91,7 @@ export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) {
setRole(e.target.value as RoleId)}
+ onChange={(value) => setRole((value ?? "") as RoleId)}
/>
diff --git a/frontend/editor/src/portal/theme/SuiProvider.tsx b/frontend/editor/src/portal/theme/SuiProvider.tsx
new file mode 100644
index 0000000000..3af449d0e1
--- /dev/null
+++ b/frontend/editor/src/portal/theme/SuiProvider.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from "react";
+import { MantineProvider } from "@mantine/core";
+import {
+ mantineTheme,
+ suiCssVariablesResolver,
+} from "@portal/theme/mantineTheme";
+
+export interface SuiProviderProps {
+ /**
+ * Resolved colour scheme. Must match whatever drives [data-theme] so the
+ * Mantine chrome and the SUI CSS tokens switch together.
+ */
+ colorScheme: "light" | "dark";
+ children: ReactNode;
+}
+
+/**
+ * Sets up the SUI design system for a subtree. Mantine is an implementation
+ * detail of the SUI components (@app/ui); this provider applies the SUI-token
+ * theme and remaps Mantine's neutral palette (dropdown/popover surfaces,
+ * borders, text) onto SUI tokens so floating elements follow the SUI palette
+ * in both colour schemes.
+ */
+export function SuiProvider({ colorScheme, children }: SuiProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/editor/src/portal/theme/mantineTheme.ts b/frontend/editor/src/portal/theme/mantineTheme.ts
index fa8e6d5297..01953245f9 100644
--- a/frontend/editor/src/portal/theme/mantineTheme.ts
+++ b/frontend/editor/src/portal/theme/mantineTheme.ts
@@ -1,4 +1,8 @@
-import { createTheme, type MantineColorsTuple } from "@mantine/core";
+import {
+ createTheme,
+ type CSSVariablesResolver,
+ type MantineColorsTuple,
+} from "@mantine/core";
/**
* Mantine theme for the portal, bound to the SUI design tokens in
@@ -69,6 +73,39 @@ const purple = tuple(
"--color-purple-dark",
);
+/**
+ * Maps Mantine's neutral CSS variables to SUI tokens so dropdowns, popovers,
+ * and other floating elements follow the SUI surface/border/text palette rather
+ * than Mantine's default white/gray-* / dark-* scale.
+ *
+ * SuiProvider syncs Mantine's color scheme to SUI's light/dark toggle
+ * (forceColorScheme), so the light/dark buckets here align with
+ * [data-theme="light/dark"] and the SUI token values are correct.
+ */
+export const suiCssVariablesResolver: CSSVariablesResolver = () => ({
+ variables: {
+ "--mantine-color-text": "var(--color-text-1)",
+ "--mantine-color-placeholder": "var(--color-text-placeholder)",
+ "--mantine-color-body": "var(--color-bg)",
+ },
+ light: {
+ // Popover/dropdown background + combobox search input
+ "--mantine-color-white": "var(--color-surface)",
+ // Option hover background
+ "--mantine-color-gray-0": "var(--color-bg-hover)",
+ // Dropdown border
+ "--mantine-color-gray-2": "var(--color-border)",
+ },
+ dark: {
+ // Popover/dropdown background (dark-6 is the floating surface in dark mode)
+ "--mantine-color-dark-6": "var(--color-surface)",
+ // Deeper background used for option hover + combobox search input
+ "--mantine-color-dark-7": "var(--color-bg)",
+ // Border in dark mode
+ "--mantine-color-dark-4": "var(--color-border)",
+ },
+});
+
export const mantineTheme = createTheme({
primaryColor: "blue",
// Mantine uses index 6 of the tuple for filled components by default, which
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx
index 2559a0e522..4049533290 100644
--- a/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx
+++ b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx
@@ -81,7 +81,7 @@ export function PolicyFieldRow({
label: t(`policies.fieldOption.${field.key}.${o}`, o),
}))}
value={typeof value === "string" ? value : ""}
- onChange={(e) => onChange(e.target.value)}
+ onChange={(value) => onChange(value ?? "")}
aria-label={fieldLabel}
/>
) : (
diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx
index e63ee4e61f..abd1f6d8df 100644
--- a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx
+++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx
@@ -409,8 +409,8 @@ export function PolicySetupWizard({
- setRunOn(e.target.value as "upload" | "export")
+ onChange={(value) =>
+ setRunOn((value ?? "upload") as "upload" | "export")
}
aria-label={t("policies.wizard.runOnLabel", "Run on")}
options={[
@@ -437,8 +437,8 @@ export function PolicySetupWizard({
{
- const mode = e.target.value as
+ onChange={(value) => {
+ const mode = (value ?? "new_file") as
| "new_file"
| "new_version";
setOutputMode(mode);
@@ -481,9 +481,12 @@ export function PolicySetupWizard({
+ onChange={(value) =>
setOutputNamePosition(
- e.target.value as "prefix" | "suffix" | "auto-number",
+ (value ?? "suffix") as
+ | "prefix"
+ | "suffix"
+ | "auto-number",
)
}
aria-label={t(
diff --git a/frontend/editor/src/proprietary/ui/ColorInput.tsx b/frontend/editor/src/proprietary/ui/ColorInput.tsx
new file mode 100644
index 0000000000..6063ecf688
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/ColorInput.tsx
@@ -0,0 +1,146 @@
+import type React from "react";
+import {
+ ColorInput as MantineColorInput,
+ type ColorInputProps as MantineColorInputProps,
+} from "@mantine/core";
+import { useInputAria } from "@app/ui/ariaForwarding";
+import "@app/ui/MantineForms.css";
+
+const SUI_INPUT_VARS = {
+ "--input-bg": "var(--color-surface)",
+ "--input-bd": "var(--color-border-input)",
+ "--input-bd-focus": "var(--color-blue)",
+ "--input-radius": "var(--radius-md)",
+ "--input-color": "var(--color-text-1)",
+ "--input-placeholder-color": "var(--color-text-placeholder)",
+ "--input-height-sm": "1.75rem",
+ "--input-height-md": "2.25rem",
+} as React.CSSProperties;
+
+export type ColorInputSize = "sm" | "md";
+
+export interface ColorInputProps {
+ // Value
+ value?: string;
+ onChange?: (value: string) => void;
+ defaultValue?: string;
+
+ // Behaviour
+ format?: "hex" | "hexa" | "rgb" | "rgba" | "hsl" | "hsla";
+ swatches?: string[];
+ swatchesPerRow?: number;
+ withPicker?: boolean;
+
+ // Popover escape hatch — only for zIndex / offset overrides in modals
+ popoverProps?: MantineColorInputProps["popoverProps"];
+
+ // Form
+ placeholder?: string;
+ id?: string;
+ name?: string;
+ "aria-label"?: string;
+ "aria-invalid"?: boolean;
+ "aria-describedby"?: string;
+ required?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+
+ // SUI — invalid applies error styling; FormField renders the message itself.
+ inputSize?: ColorInputSize;
+ invalid?: boolean;
+}
+
+type PassthroughProps = Omit<
+ Pick<
+ MantineColorInputProps,
+ | "value"
+ | "onChange"
+ | "defaultValue"
+ | "format"
+ | "swatches"
+ | "swatchesPerRow"
+ | "withPicker"
+ | "popoverProps"
+ | "placeholder"
+ | "id"
+ | "name"
+ | "aria-label"
+ | "aria-describedby"
+ | "required"
+ | "disabled"
+ | "readOnly"
+ | "onFocus"
+ | "onBlur"
+ >,
+ never
+>;
+
+/**
+ * SUI colour picker input with swatch preview and popover picker. Use with
+ * for labels and error display. Appearance is locked to SUI tokens.
+ *
+ * Defaults to hex format. Pass `popoverProps={{ withinPortal: true, zIndex: Z }}` when
+ * rendering inside a modal.
+ */
+export function ColorInput({
+ inputSize = "md",
+ invalid,
+ format = "hex",
+ value,
+ onChange,
+ defaultValue,
+ swatches,
+ swatchesPerRow,
+ withPicker,
+ popoverProps,
+ placeholder,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-invalid": ariaInvalid,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+}: ColorInputProps) {
+ const inputRef = useInputAria({ describedBy: ariaDescribedBy });
+ const passthroughProps: PassthroughProps = {
+ value,
+ onChange,
+ defaultValue,
+ format,
+ swatches,
+ swatchesPerRow,
+ withPicker,
+ popoverProps,
+ placeholder,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/ui/Forms.stories.tsx b/frontend/editor/src/proprietary/ui/Forms.stories.tsx
index 10f4e0b1c3..0d14cf3392 100644
--- a/frontend/editor/src/proprietary/ui/Forms.stories.tsx
+++ b/frontend/editor/src/proprietary/ui/Forms.stories.tsx
@@ -73,23 +73,6 @@ export const Input_Error: Story = {
),
};
-export const Select_Default: Story = {
- render: () => (
-
-
-
- ),
-};
-
export const Checkbox_Single: Story = {
render: () => (
@@ -251,7 +234,7 @@ export const FullForm: Story = {
setRetention(e.target.value)}
+ onChange={(value) => setRetention(value ?? "90")}
options={[
{ value: "30", label: "30 days" },
{ value: "90", label: "90 days" },
diff --git a/frontend/editor/src/proprietary/ui/IconBadge.css b/frontend/editor/src/proprietary/ui/IconBadge.css
index 10e2d28915..43d4eb0af9 100644
--- a/frontend/editor/src/proprietary/ui/IconBadge.css
+++ b/frontend/editor/src/proprietary/ui/IconBadge.css
@@ -34,3 +34,7 @@
.sui-iconbadge--red {
--ib-base: var(--color-red);
}
+.sui-iconbadge--neutral {
+ --ib-base: var(--color-text-1);
+ background: none;
+}
diff --git a/frontend/editor/src/proprietary/ui/IconBadge.tsx b/frontend/editor/src/proprietary/ui/IconBadge.tsx
index 9b421d47c5..ae6c5536d1 100644
--- a/frontend/editor/src/proprietary/ui/IconBadge.tsx
+++ b/frontend/editor/src/proprietary/ui/IconBadge.tsx
@@ -1,7 +1,13 @@
import type { ReactNode } from "react";
import "@app/ui/IconBadge.css";
-export type IconBadgeAccent = "blue" | "purple" | "green" | "amber" | "red";
+export type IconBadgeAccent =
+ | "blue"
+ | "purple"
+ | "green"
+ | "amber"
+ | "red"
+ | "neutral";
export interface IconBadgeProps {
children: ReactNode;
diff --git a/frontend/editor/src/proprietary/ui/MantineForms.css b/frontend/editor/src/proprietary/ui/MantineForms.css
new file mode 100644
index 0000000000..66551aedf5
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/MantineForms.css
@@ -0,0 +1,114 @@
+/* ===================================================================
+ * MantineForms.css — SUI design tokens injected into Mantine inputs
+ *
+ * Applied via classNames.wrapper="sui-mantine-wrapper" on each
+ * Mantine-backed SUI component (MultiSelect, NumberInput, ColorInput).
+ * The inline `styles.wrapper` in each component sets the base CSS
+ * variables; this file adds focus ring + pill styling that require
+ * CSS pseudo-selectors or attribute selectors.
+ * =================================================================== */
+
+/* CSS custom properties for Mantine's input system, mapped to SUI tokens.
+ * These are also set as inline styles (higher specificity); the declarations
+ * here act as a typed reference and as a fallback for any slot Mantine reads
+ * before the inline vars are applied. */
+.sui-mantine-wrapper {
+ --input-bg: var(--color-surface);
+ --input-bd: var(--color-border-input);
+ --input-bd-focus: var(--color-blue);
+ --input-radius: var(--radius-md);
+ --input-color: var(--color-text-1);
+ --input-placeholder-color: var(--color-text-placeholder);
+ --input-height-sm: 1.75rem;
+ --input-height-md: 2.25rem;
+ font-size: 0.875rem;
+}
+
+/* SUI focus ring — matches .sui-input:focus-within */
+.sui-mantine-wrapper[data-focused],
+.sui-mantine-wrapper:focus-within {
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
+}
+
+/* Error state */
+.sui-mantine-wrapper[data-invalid] {
+ --input-bd: var(--color-red);
+ --input-bd-focus: var(--color-red);
+}
+
+.sui-mantine-wrapper[data-invalid][data-focused],
+.sui-mantine-wrapper[data-invalid]:focus-within {
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-red) 16%, transparent);
+}
+
+/* Disabled state */
+.sui-mantine-wrapper[data-disabled] {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ---- MultiSelect pills ---- */
+/* Pills match SUI's Chip component: small rounded tags. */
+.sui-mantine-pill {
+ background: var(--color-blue-light) !important;
+ color: var(--color-blue-dark) !important;
+ border: 1px solid var(--color-blue-border) !important;
+ border-radius: var(--radius-sm) !important;
+ font-size: 0.75rem !important;
+ font-weight: 500 !important;
+}
+
+/* Pills list needs a min-height to not collapse when empty */
+.sui-mantine-pills-list {
+ min-height: var(--input-height-sm, 1.75rem);
+}
+
+/* ---- NumberInput controls (increment/decrement buttons) ---- */
+.sui-mantine-control {
+ border-color: var(--color-border-input) !important;
+ color: var(--color-text-3) !important;
+}
+
+.sui-mantine-control:hover {
+ background: var(--color-bg-hover) !important;
+ color: var(--color-text-1) !important;
+}
+
+/* ---- Select: hide Mantine's right-section clear button border ---- */
+.sui-mantine-wrapper .mantine-Select-section {
+ color: var(--color-text-3);
+}
+
+/* ---- Slider ---- */
+.sui-mantine-slider {
+ --slider-color: var(--color-blue);
+ --slider-track-bg: var(--color-border);
+ --slider-thumb-color: var(--color-surface);
+ --slider-thumb-bd: var(--color-blue);
+}
+
+.sui-mantine-slider:focus-within {
+ outline: none;
+}
+
+/* Thumb focus ring matches SUI */
+.sui-mantine-slider .mantine-Slider-thumb:focus-visible {
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
+ outline: none;
+}
+
+/* Mark labels use SUI text tokens */
+.sui-mantine-slider .mantine-Slider-markLabel {
+ color: var(--color-text-3);
+ font-size: 0.75rem;
+}
+
+/* ---- Dark mode: Mantine's own dark-mode vars beat ours when Mantine's
+ * color-scheme is "dark". Reassert SUI tokens so the two systems stay in sync.
+ * SuiProvider syncs forceColorScheme to SUI theme, so
+ * [data-mantine-color-scheme="dark"] === [data-theme="dark"] in practice. ---- */
+[data-mantine-color-scheme="dark"] .sui-mantine-wrapper {
+ --input-bg: var(--color-surface);
+ --input-bd: var(--color-border-input);
+ --input-color: var(--color-text-1);
+}
diff --git a/frontend/editor/src/proprietary/ui/MantineForms.stories.tsx b/frontend/editor/src/proprietary/ui/MantineForms.stories.tsx
new file mode 100644
index 0000000000..dfa40644dd
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/MantineForms.stories.tsx
@@ -0,0 +1,568 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { FormField } from "@app/ui/FormField";
+import { Stack } from "@app/ui/Stack";
+import { MultiSelect } from "@app/ui/MultiSelect";
+import { NumberInput } from "@app/ui/NumberInput";
+import { ColorInput } from "@app/ui/ColorInput";
+import { Select } from "@app/ui/Select";
+import { Slider } from "@app/ui/Slider";
+
+const PII_OPTIONS = [
+ { value: "ssn", label: "Social Security Number" },
+ { value: "dob", label: "Date of Birth" },
+ { value: "account", label: "Account Number" },
+ { value: "email", label: "Email Address" },
+ { value: "phone", label: "Phone Number" },
+ { value: "address", label: "Postal Address" },
+ { value: "passport", label: "Passport Number" },
+ { value: "license", label: "Driver's License" },
+];
+
+const meta: Meta = {
+ title: "Primitives/Forms",
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+// ─── MultiSelect ─────────────────────────────────────────────────────────────
+
+export const MultiSelect_Default: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState([]);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const MultiSelect_WithValues: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState(["ssn", "dob", "email"]);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const MultiSelect_SmSize: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState(["ssn", "email"]);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const MultiSelect_Error: Story = {
+ render: () => (
+
+ {}}
+ placeholder="Choose types…"
+ invalid
+ />
+
+ ),
+};
+
+export const MultiSelect_Disabled: Story = {
+ render: () => (
+
+ {}}
+ disabled
+ />
+
+ ),
+};
+
+// ─── NumberInput ─────────────────────────────────────────────────────────────
+
+export const NumberInput_Default: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState(100);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const NumberInput_Decimal: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState(0.85);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const NumberInput_WithUnit: Story = {
+ render: () => {
+ function Bound() {
+ const [opacity, setOpacity] = useState(80);
+ const [fontSize, setFontSize] = useState(24);
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const NumberInput_SmSize: Story = {
+ render: () => {
+ function Bound() {
+ const [v, setV] = useState(12);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const NumberInput_Error: Story = {
+ render: () => (
+
+ {}} invalid />
+
+ ),
+};
+
+export const NumberInput_Disabled: Story = {
+ render: () => (
+
+ {}} disabled />
+
+ ),
+};
+
+// ─── ColorInput ──────────────────────────────────────────────────────────────
+
+export const ColorInput_Default: Story = {
+ render: () => {
+ function Bound() {
+ const [color, setColor] = useState("");
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const ColorInput_Preselected: Story = {
+ render: () => {
+ function Bound() {
+ const [color, setColor] = useState("#3B82F6");
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const ColorInput_SmSize: Story = {
+ render: () => {
+ function Bound() {
+ const [color, setColor] = useState("#EF4444");
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const ColorInput_Error: Story = {
+ render: () => (
+
+ {}} invalid />
+
+ ),
+};
+
+export const ColorInput_Disabled: Story = {
+ render: () => (
+
+ {}} disabled />
+
+ ),
+};
+
+// ─── Select ──────────────────────────────────────────────────────────────────
+
+const RETENTION_OPTIONS = [
+ { value: "30", label: "30 days" },
+ { value: "60", label: "60 days" },
+ { value: "90", label: "90 days (default)" },
+ { value: "180", label: "180 days" },
+ { value: "never", label: "Never expire" },
+];
+
+export const Select_Default: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState("90");
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Select_Searchable: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState(null);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Select_SmSize: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState("upload");
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Select_Error: Story = {
+ render: () => (
+
+ {}}
+ placeholder="Choose…"
+ invalid
+ />
+
+ ),
+};
+
+export const Select_Disabled: Story = {
+ render: () => (
+
+ {}}
+ disabled
+ />
+
+ ),
+};
+
+// ─── Slider ───────────────────────────────────────────────────────────────────
+
+export const Slider_Default: Story = {
+ render: () => {
+ function Bound() {
+ const [v, setV] = useState(0.85);
+ return (
+
+ x.toFixed(2)}
+ />
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Slider_WithMarks: Story = {
+ render: () => {
+ function Bound() {
+ const [days, setDays] = useState(90);
+ return (
+
+ `${d}d`}
+ marks={[
+ { value: 30, label: "30d" },
+ { value: 90, label: "90d" },
+ { value: 180, label: "180d" },
+ { value: 365, label: "1y" },
+ ]}
+ />
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Slider_NoLabel: Story = {
+ render: () => {
+ function Bound() {
+ const [v, setV] = useState(50);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Slider_Disabled: Story = {
+ render: () => (
+
+ x.toFixed(2)}
+ disabled
+ />
+
+ ),
+};
+
+// ─── Combined ────────────────────────────────────────────────────────────────
+
+export const WatermarkForm: Story = {
+ render: () => {
+ function Form() {
+ const [color, setColor] = useState("#000000");
+ const [opacity, setOpacity] = useState(50);
+ const [fontSize, setFontSize] = useState(24);
+ const [piiFields, setPiiFields] = useState([]);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ return ;
+ },
+};
diff --git a/frontend/editor/src/proprietary/ui/MultiSelect.tsx b/frontend/editor/src/proprietary/ui/MultiSelect.tsx
new file mode 100644
index 0000000000..31ac9544f8
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/MultiSelect.tsx
@@ -0,0 +1,184 @@
+import type React from "react";
+import {
+ MultiSelect as MantineMultiSelect,
+ type MultiSelectProps as MantineMultiSelectProps,
+ type ComboboxData,
+} from "@mantine/core";
+import { useInputAria } from "@app/ui/ariaForwarding";
+import "@app/ui/MantineForms.css";
+
+const SUI_INPUT_VARS = {
+ "--input-bg": "var(--color-surface)",
+ "--input-bd": "var(--color-border-input)",
+ "--input-bd-focus": "var(--color-blue)",
+ "--input-radius": "var(--radius-md)",
+ "--input-color": "var(--color-text-1)",
+ "--input-placeholder-color": "var(--color-text-placeholder)",
+ "--input-height-sm": "1.75rem",
+ "--input-height-md": "2.25rem",
+} as React.CSSProperties;
+
+export type MultiSelectSize = "sm" | "md";
+
+export interface MultiSelectProps {
+ // Data
+ data: ComboboxData;
+ value?: string[];
+ onChange?: (value: string[]) => void;
+ defaultValue?: string[];
+
+ // Behaviour
+ searchable?: boolean;
+ clearable?: boolean;
+ limit?: number;
+ maxValues?: number;
+ searchValue?: string;
+ onSearchChange?: (value: string) => void;
+ nothingFoundMessage?: React.ReactNode;
+ maxDropdownHeight?: number | string;
+ filter?: MantineMultiSelectProps["filter"];
+
+ // Dropdown escape hatch — only for zIndex / offset overrides in modals
+ comboboxProps?: MantineMultiSelectProps["comboboxProps"];
+
+ // Form
+ placeholder?: string;
+ id?: string;
+ name?: string;
+ "aria-label"?: string;
+ "aria-invalid"?: boolean;
+ "aria-describedby"?: string;
+ required?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+ onDropdownOpen?: () => void;
+ onDropdownClose?: () => void;
+
+ // SUI — invalid applies error styling; FormField renders the message itself.
+ inputSize?: MultiSelectSize;
+ invalid?: boolean;
+}
+
+type PassthroughProps = Omit<
+ Pick<
+ MantineMultiSelectProps,
+ | "data"
+ | "value"
+ | "onChange"
+ | "defaultValue"
+ | "searchable"
+ | "clearable"
+ | "limit"
+ | "maxValues"
+ | "searchValue"
+ | "onSearchChange"
+ | "nothingFoundMessage"
+ | "maxDropdownHeight"
+ | "filter"
+ | "comboboxProps"
+ | "placeholder"
+ | "id"
+ | "name"
+ | "aria-label"
+ | "aria-describedby"
+ | "required"
+ | "disabled"
+ | "readOnly"
+ | "onFocus"
+ | "onBlur"
+ | "onDropdownOpen"
+ | "onDropdownClose"
+ >,
+ never
+>;
+
+/**
+ * SUI multi-select with pill display and optional search. Use with
+ * for labels and error display. Appearance is locked to SUI tokens.
+ *
+ * Pass `comboboxProps={{ zIndex: Z_INDEX_MODAL }}` when rendering inside a modal.
+ */
+export function MultiSelect({
+ inputSize = "md",
+ invalid,
+ data,
+ value,
+ onChange,
+ defaultValue,
+ searchable,
+ clearable,
+ limit,
+ maxValues,
+ searchValue,
+ onSearchChange,
+ nothingFoundMessage,
+ maxDropdownHeight,
+ filter,
+ comboboxProps,
+ placeholder,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-invalid": ariaInvalid,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+ onDropdownOpen,
+ onDropdownClose,
+}: MultiSelectProps) {
+ const inputRef = useInputAria({ describedBy: ariaDescribedBy, required });
+ const passthroughProps: PassthroughProps = {
+ data,
+ value,
+ onChange,
+ defaultValue,
+ searchable,
+ clearable,
+ limit,
+ maxValues,
+ searchValue,
+ onSearchChange,
+ nothingFoundMessage,
+ maxDropdownHeight,
+ filter,
+ comboboxProps,
+ placeholder,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+ onDropdownOpen,
+ onDropdownClose,
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/ui/NumberInput.tsx b/frontend/editor/src/proprietary/ui/NumberInput.tsx
new file mode 100644
index 0000000000..751c902636
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/NumberInput.tsx
@@ -0,0 +1,190 @@
+import type React from "react";
+import {
+ NumberInput as MantineNumberInput,
+ type NumberInputProps as MantineNumberInputProps,
+} from "@mantine/core";
+import { useInputAria } from "@app/ui/ariaForwarding";
+import "@app/ui/MantineForms.css";
+
+const SUI_INPUT_VARS = {
+ "--input-bg": "var(--color-surface)",
+ "--input-bd": "var(--color-border-input)",
+ "--input-bd-focus": "var(--color-blue)",
+ "--input-radius": "var(--radius-md)",
+ "--input-color": "var(--color-text-1)",
+ "--input-placeholder-color": "var(--color-text-placeholder)",
+ "--input-height-sm": "1.75rem",
+ "--input-height-md": "2.25rem",
+} as React.CSSProperties;
+
+export type NumberInputSize = "sm" | "md";
+
+export interface NumberInputProps {
+ // Value
+ value?: number | string;
+ onChange?: (value: number | string) => void;
+ defaultValue?: number | string;
+
+ // Constraints
+ min?: number;
+ max?: number;
+ step?: number;
+ decimalScale?: number;
+ fixedDecimalScale?: boolean;
+ allowNegative?: boolean;
+ allowDecimal?: boolean;
+ clampBehavior?: "strict" | "blur" | "none";
+
+ // Display
+ placeholder?: string;
+ suffix?: string;
+ prefix?: string;
+ hideControls?: boolean;
+
+ // Right section — escape hatch for inline unit labels
+ rightSection?: React.ReactNode;
+ rightSectionWidth?: React.CSSProperties["width"];
+
+ // Form
+ id?: string;
+ name?: string;
+ "aria-label"?: string;
+ "aria-invalid"?: boolean;
+ "aria-describedby"?: string;
+ required?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ autoFocus?: boolean;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+ onKeyDown?: React.KeyboardEventHandler;
+
+ // SUI — invalid applies error styling; FormField renders the message itself.
+ inputSize?: NumberInputSize;
+ invalid?: boolean;
+}
+
+// Narrows MantineNumberInputProps to only what our interface exposes so the
+// spread below stays type-safe without manually listing every prop.
+type PassthroughProps = Omit<
+ Pick<
+ MantineNumberInputProps,
+ | "value"
+ | "onChange"
+ | "defaultValue"
+ | "min"
+ | "max"
+ | "step"
+ | "decimalScale"
+ | "fixedDecimalScale"
+ | "allowNegative"
+ | "allowDecimal"
+ | "clampBehavior"
+ | "placeholder"
+ | "suffix"
+ | "prefix"
+ | "hideControls"
+ | "rightSection"
+ | "rightSectionWidth"
+ | "id"
+ | "name"
+ | "aria-label"
+ | "aria-describedby"
+ | "required"
+ | "disabled"
+ | "readOnly"
+ | "autoFocus"
+ | "onFocus"
+ | "onBlur"
+ | "onKeyDown"
+ >,
+ never
+>;
+
+/**
+ * SUI number input with increment/decrement controls. Use with
+ * for labels and error display. Appearance is locked to SUI tokens.
+ */
+export function NumberInput({
+ inputSize = "md",
+ invalid,
+ value,
+ onChange,
+ defaultValue,
+ min,
+ max,
+ step,
+ decimalScale,
+ fixedDecimalScale,
+ allowNegative,
+ allowDecimal,
+ clampBehavior,
+ placeholder,
+ suffix,
+ prefix,
+ hideControls,
+ rightSection,
+ rightSectionWidth,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-invalid": ariaInvalid,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ autoFocus,
+ onFocus,
+ onBlur,
+ onKeyDown,
+}: NumberInputProps) {
+ const inputRef = useInputAria({ describedBy: ariaDescribedBy });
+ const passthroughProps: PassthroughProps = {
+ value,
+ onChange,
+ defaultValue,
+ min,
+ max,
+ step,
+ decimalScale,
+ fixedDecimalScale,
+ allowNegative,
+ allowDecimal,
+ clampBehavior,
+ placeholder,
+ suffix,
+ prefix,
+ hideControls,
+ rightSection,
+ rightSectionWidth,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ autoFocus,
+ onFocus,
+ onBlur,
+ onKeyDown,
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/ui/Select.tsx b/frontend/editor/src/proprietary/ui/Select.tsx
index b970cc1815..fa2d5346b7 100644
--- a/frontend/editor/src/proprietary/ui/Select.tsx
+++ b/frontend/editor/src/proprietary/ui/Select.tsx
@@ -1,5 +1,21 @@
-import { forwardRef, type SelectHTMLAttributes } from "react";
-import "@app/ui/Select.css";
+import type React from "react";
+import {
+ Select as MantineSelect,
+ type SelectProps as MantineSelectProps,
+} from "@mantine/core";
+import { useInputAria } from "@app/ui/ariaForwarding";
+import "@app/ui/MantineForms.css";
+
+const SUI_INPUT_VARS = {
+ "--input-bg": "var(--color-surface)",
+ "--input-bd": "var(--color-border-input)",
+ "--input-bd-focus": "var(--color-blue)",
+ "--input-radius": "var(--radius-md)",
+ "--input-color": "var(--color-text-1)",
+ "--input-placeholder-color": "var(--color-text-placeholder)",
+ "--input-height-sm": "1.75rem",
+ "--input-height-md": "2.25rem",
+} as React.CSSProperties;
export interface SelectOption {
value: string;
@@ -9,61 +25,130 @@ export interface SelectOption {
export type SelectSize = "sm" | "md";
-export interface SelectProps extends Omit<
- SelectHTMLAttributes,
- "size"
-> {
- inputSize?: SelectSize;
+export interface SelectProps {
+ // Data
options: SelectOption[];
- /** Optional placeholder rendered as a disabled first option. */
+ value?: string | null;
+ onChange?: (value: string | null) => void;
+ defaultValue?: string;
+
+ // Behaviour
+ searchable?: boolean;
+ clearable?: boolean;
placeholder?: string;
+ nothingFoundMessage?: React.ReactNode;
+ maxDropdownHeight?: number | string;
+
+ // Dropdown escape hatch — for zIndex / offset overrides in modals
+ comboboxProps?: MantineSelectProps["comboboxProps"];
+
+ // Form
+ id?: string;
+ name?: string;
+ "aria-label"?: string;
+ "aria-invalid"?: boolean;
+ "aria-describedby"?: string;
+ required?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+
+ // SUI — invalid applies error styling; FormField renders the message itself.
+ inputSize?: SelectSize;
invalid?: boolean;
}
-export const Select = forwardRef(
- function Select(
- { inputSize = "md", options, placeholder, invalid, className, ...rest },
- ref,
- ) {
- return (
-
-
- {placeholder && (
-
- {placeholder}
-
- )}
- {options.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
-
-
-
-
-
- );
- },
-);
+type PassthroughProps = Omit<
+ Pick<
+ MantineSelectProps,
+ | "value"
+ | "onChange"
+ | "defaultValue"
+ | "searchable"
+ | "clearable"
+ | "placeholder"
+ | "nothingFoundMessage"
+ | "maxDropdownHeight"
+ | "comboboxProps"
+ | "id"
+ | "name"
+ | "aria-label"
+ | "aria-describedby"
+ | "required"
+ | "disabled"
+ | "readOnly"
+ | "onFocus"
+ | "onBlur"
+ >,
+ never
+>;
+
+/**
+ * SUI select / combobox backed by Mantine. Supports optional search and clear.
+ * Use with for labels and error display. Appearance is locked to SUI tokens.
+ *
+ * onChange receives the selected string value (or null when cleared), not a DOM event.
+ */
+export function Select({
+ inputSize = "md",
+ invalid,
+ options,
+ value,
+ onChange,
+ defaultValue,
+ searchable,
+ clearable,
+ placeholder,
+ nothingFoundMessage,
+ maxDropdownHeight,
+ comboboxProps,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-invalid": ariaInvalid,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+}: SelectProps) {
+ const inputRef = useInputAria({ describedBy: ariaDescribedBy });
+ const passthroughProps: PassthroughProps = {
+ value,
+ onChange,
+ defaultValue,
+ searchable,
+ clearable,
+ placeholder,
+ nothingFoundMessage,
+ maxDropdownHeight,
+ comboboxProps,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/ui/Slider.tsx b/frontend/editor/src/proprietary/ui/Slider.tsx
index 3b540def06..ebeb782f9e 100644
--- a/frontend/editor/src/proprietary/ui/Slider.tsx
+++ b/frontend/editor/src/proprietary/ui/Slider.tsx
@@ -1,63 +1,113 @@
-import { forwardRef, type InputHTMLAttributes } from "react";
-import "@app/ui/Slider.css";
+import {
+ Slider as MantineSlider,
+ type SliderProps as MantineSliderProps,
+} from "@mantine/core";
+import { useThumbAria } from "@app/ui/ariaForwarding";
+import "@app/ui/MantineForms.css";
-export interface SliderProps extends Omit<
- InputHTMLAttributes,
- "type" | "value" | "onChange"
-> {
+export interface SliderMark {
value: number;
+ label?: React.ReactNode;
+}
+
+export interface SliderProps {
+ value: number;
+ onChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
- onChange: (value: number) => void;
- /** Optional formatter for the value pill (e.g. "0.85", "30 days"). */
+
+ /** Tick marks along the track. */
+ marks?: SliderMark[];
+
+ /** Format the tooltip shown while dragging. Defaults to the raw number. */
formatValue?: (value: number) => string;
- /** Show the right-aligned value badge. Defaults to true. */
+
+ /**
+ * Show the value tooltip on hover/drag. Defaults to true.
+ * Pass false to hide the label entirely (useful when the value is shown elsewhere).
+ */
showValue?: boolean;
+
+ // Form
+ id?: string;
+ /** Accessible name for the slider thumb — the visible FormField label can't
+ * associate with Mantine's non-input thumb element, so set this too. */
+ "aria-label"?: string;
+ "aria-invalid"?: boolean;
+ "aria-describedby"?: string;
+ disabled?: boolean;
+
+ // SUI
+ inputSize?: "sm" | "md";
}
-export const Slider = forwardRef(function Slider(
- {
+type PassthroughProps = Omit<
+ Pick<
+ MantineSliderProps,
+ | "value"
+ | "onChange"
+ | "min"
+ | "max"
+ | "step"
+ | "marks"
+ | "label"
+ | "thumbLabel"
+ | "id"
+ | "disabled"
+ | "size"
+ >,
+ never
+>;
+
+/**
+ * SUI range slider backed by Mantine. Provides accessible keyboard navigation,
+ * optional tick marks, and a drag tooltip. Use with for labels.
+ * Appearance is locked to SUI tokens.
+ */
+export function Slider({
+ value,
+ onChange,
+ min = 0,
+ max = 1,
+ step = 0.01,
+ marks,
+ formatValue,
+ showValue = true,
+ id,
+ "aria-label": ariaLabel,
+ "aria-invalid": ariaInvalid,
+ "aria-describedby": ariaDescribedBy,
+ disabled,
+ inputSize = "md",
+}: SliderProps) {
+ const label = showValue
+ ? (v: number) => (formatValue ? formatValue(v) : String(v))
+ : null;
+
+ // The role="slider" element is the thumb, not an input, so FormField's
+ // injected aria wiring has to land there for AT to announce it.
+ const rootRef = useThumbAria(ariaDescribedBy, ariaInvalid);
+
+ const passthroughProps: PassthroughProps = {
value,
- min = 0,
- max = 1,
- step = 0.01,
onChange,
- formatValue,
- showValue = true,
- className,
- ...rest
- },
- ref,
-) {
- const pct = ((value - min) / (max - min)) * 100;
+ min,
+ max,
+ step,
+ marks,
+ label,
+ thumbLabel: ariaLabel,
+ id,
+ disabled,
+ size: inputSize,
+ };
+
return (
-
- onChange(Number(e.target.value))}
- className="sui-slider__input"
- {...rest}
- />
- {showValue && (
-
- {formatValue ? formatValue(value) : value.toString()}
-
- )}
-
+
);
-});
+}
diff --git a/frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx b/frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx
new file mode 100644
index 0000000000..608baff21d
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx
@@ -0,0 +1,106 @@
+import { describe, expect, it } from "vitest";
+import { render } from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
+import { Select } from "@app/ui/Select";
+import { MultiSelect } from "@app/ui/MultiSelect";
+import { NumberInput } from "@app/ui/NumberInput";
+import { ColorInput } from "@app/ui/ColorInput";
+import { Slider } from "@app/ui/Slider";
+
+// Guards the FormField contract on the Mantine-backed components: the
+// injected required / aria-describedby / aria-invalid wiring must reach the
+// focusable element. Mantine internals clobber some of these (see
+// ariaForwarding.ts), so this exercises the real DOM output.
+
+function renderInProvider(ui: React.ReactElement) {
+ return render({ui} );
+}
+
+const OPTIONS = [{ value: "a", label: "A" }];
+
+describe("Mantine-backed SUI aria forwarding", () => {
+ it("Select forwards required and aria-describedby to the input", () => {
+ const { container } = renderInProvider(
+ {}}
+ required
+ aria-describedby="help-1"
+ />,
+ );
+ const input = container.querySelector("input");
+ expect(input?.hasAttribute("required")).toBe(true);
+ expect(input?.getAttribute("aria-describedby")).toBe("help-1");
+ });
+
+ it("Select sets aria-invalid from the invalid flag", () => {
+ const { container } = renderInProvider(
+ {}} invalid />,
+ );
+ expect(container.querySelector("input")?.getAttribute("aria-invalid")).toBe(
+ "true",
+ );
+ });
+
+ it("MultiSelect forwards aria-required and aria-describedby to the field", () => {
+ const { container } = renderInProvider(
+ {}}
+ required
+ aria-describedby="help-2"
+ />,
+ );
+ // The focusable pills field; a native `required` would misfire form
+ // validation there, so the requirement is announced via aria-required.
+ const input = container.querySelector("input");
+ expect(input?.getAttribute("aria-required")).toBe("true");
+ expect(input?.getAttribute("aria-describedby")).toBe("help-2");
+ });
+
+ it("NumberInput forwards required and aria-describedby to the input", () => {
+ const { container } = renderInProvider(
+ {}}
+ required
+ aria-describedby="help-3"
+ />,
+ );
+ const input = container.querySelector("input");
+ expect(input?.hasAttribute("required")).toBe(true);
+ expect(input?.getAttribute("aria-describedby")).toBe("help-3");
+ });
+
+ it("ColorInput forwards required and aria-describedby to the input", () => {
+ const { container } = renderInProvider(
+ {}}
+ required
+ aria-describedby="help-4"
+ />,
+ );
+ const input = container.querySelector("input");
+ expect(input?.hasAttribute("required")).toBe(true);
+ expect(input?.getAttribute("aria-describedby")).toBe("help-4");
+ });
+
+ it("Slider forwards aria wiring to the role=slider thumb", () => {
+ const { container } = renderInProvider(
+ {}}
+ aria-label="Confidence"
+ aria-invalid
+ aria-describedby="help-5"
+ />,
+ );
+ const thumb = container.querySelector('[role="slider"]');
+ expect(thumb?.getAttribute("aria-label")).toBe("Confidence");
+ expect(thumb?.getAttribute("aria-invalid")).toBe("true");
+ expect(thumb?.getAttribute("aria-describedby")).toBe("help-5");
+ });
+});
diff --git a/frontend/editor/src/proprietary/ui/ariaForwarding.ts b/frontend/editor/src/proprietary/ui/ariaForwarding.ts
new file mode 100644
index 0000000000..344016a50c
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/ariaForwarding.ts
@@ -0,0 +1,57 @@
+import { useEffect, useRef } from "react";
+
+/**
+ * Mantine drops caller-supplied accessibility wiring in a few places:
+ * Input-based components overwrite `aria-describedby` with their own
+ * Input.Wrapper context (unset here — FormField owns the help text),
+ * MultiSelect consumes `required` for its label asterisk without marking the
+ * focusable field, and Slider's thumb ignores unknown `thumbProps` keys
+ * entirely. These hooks re-apply the attributes to the rendered DOM node
+ * after every render so FormField's injected wiring survives.
+ */
+
+/**
+ * Ref for a Mantine input component; keeps `aria-describedby` applied.
+ * Pass `required` only when Mantine doesn't put the attribute on the field
+ * itself (MultiSelect) — it is announced as `aria-required`, since a native
+ * `required` on a combobox search field would misfire form validation.
+ */
+export function useInputAria(options: {
+ describedBy?: string;
+ required?: boolean;
+}) {
+ const { describedBy, required } = options;
+ const ref = useRef(null);
+ useEffect(() => {
+ applyAria(ref.current, "aria-describedby", describedBy);
+ applyAria(ref.current, "aria-required", required ? "true" : undefined);
+ });
+ return ref;
+}
+
+/** Ref for Mantine Slider's root; keeps aria wiring applied to the thumb. */
+export function useThumbAria(
+ describedBy: string | undefined,
+ invalid: boolean | undefined,
+) {
+ const rootRef = useRef(null);
+ useEffect(() => {
+ const thumb = rootRef.current?.querySelector('[role="slider"]');
+ applyAria(thumb, "aria-describedby", describedBy);
+ applyAria(thumb, "aria-invalid", invalid ? "true" : undefined);
+ });
+ return rootRef;
+}
+
+function applyAria(
+ el: Element | null | undefined,
+ attribute: string,
+ value: string | undefined,
+) {
+ if (!el) return;
+ if (value !== undefined) {
+ el.setAttribute(attribute, value);
+ } else {
+ el.removeAttribute(attribute);
+ }
+}
diff --git a/frontend/editor/src/proprietary/ui/index.ts b/frontend/editor/src/proprietary/ui/index.ts
index 6c2132f894..e6c4864c52 100644
--- a/frontend/editor/src/proprietary/ui/index.ts
+++ b/frontend/editor/src/proprietary/ui/index.ts
@@ -41,3 +41,8 @@ export * from "@app/ui/Select";
export * from "@app/ui/Checkbox";
export * from "@app/ui/Radio";
export * from "@app/ui/Slider";
+
+// Mantine-backed form elements (SUI-styled)
+export * from "@app/ui/MultiSelect";
+export * from "@app/ui/NumberInput";
+export * from "@app/ui/ColorInput";
From 8ba8f69252aed0c89480998af84cf56f3ab03c0c Mon Sep 17 00:00:00 2001
From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Date: Tue, 7 Jul 2026 17:06:56 +0100
Subject: [PATCH 08/43] Consolidate buttons and related components (#6787)
SegmentedControl, Chip, ChipFlow. Bring in the portal dark mode theme
and other small fixes to issues I found during testing
---
frontend/.storybook/declarations.d.ts | 1 +
frontend/.storybook/preview.tsx | 51 +--
frontend/.storybook/tsconfig.json | 24 ++
.../public/locales/en-US/translation.toml | 40 ++-
.../components/onboarding/renderButtons.tsx | 40 +--
.../onboarding/slides/TeamSlide.tsx | 3 +-
.../shared/FreeLimitReachedModal.tsx | 5 +-
.../shared/SpendCapReachedModal.tsx | 5 +-
.../shared/TeamInvitationBanner.tsx | 18 +-
.../shared/config/configSections/Payg.css | 21 +-
.../shared/config/configSections/Payg.tsx | 21 +-
.../shared/config/configSections/PaygFree.css | 19 --
.../shared/config/configSections/PaygFree.tsx | 9 +-
.../config/configSections/SpendCapControl.css | 8 +-
.../configSections/StripeCheckoutPanel.tsx | 10 +-
.../config/configSections/TeamSection.tsx | 31 +-
.../config/configSections/UpgradeModal.css | 85 +----
.../config/configSections/UpgradeModal.tsx | 48 ++-
.../editor/src/core/assets/login/github.svg | 2 +-
.../src/core/components/StorageStatsCard.tsx | 16 +-
.../annotation/shared/ColorControl.tsx | 29 +-
.../annotation/shared/ColorPicker.tsx | 3 +-
.../annotation/shared/DrawingCanvas.tsx | 5 +-
.../annotation/shared/DrawingControls.tsx | 14 +-
.../annotation/shared/OpacityControl.tsx | 28 +-
.../annotation/shared/PropertiesPopover.tsx | 44 +--
.../annotation/shared/TextInputWithFont.tsx | 12 +-
.../annotation/shared/WidthControl.tsx | 28 +-
.../components/fileEditor/AddFileCard.tsx | 5 +-
.../fileEditor/FileEditor.module.css | 6 +-
.../fileEditor/FileEditorThumbnail.tsx | 39 +--
.../fileManager/CompactFileDetails.tsx | 18 +-
.../fileManager/EmptyFilesState.tsx | 4 +-
.../components/fileManager/FileActions.tsx | 44 ++-
.../components/fileManager/FileDetails.tsx | 11 +-
.../components/fileManager/FileInfoCard.tsx | 6 +-
.../components/fileManager/FileListItem.tsx | 7 +-
.../fileManager/FileSourceButtons.tsx | 95 ++----
.../filesPage/DeleteFilesDialog.tsx | 8 +-
.../filesPage/DeleteFolderDialog.tsx | 15 +-
.../components/filesPage/FileDetailsPanel.tsx | 65 ++--
.../core/components/filesPage/FileGrid.tsx | 18 +-
.../components/filesPage/FileManagerView.tsx | 80 +++--
.../filesPage/FolderAppearancePicker.tsx | 11 +-
.../components/filesPage/FolderNameDialog.tsx | 6 +-
.../filesPage/FolderTreeSidebar.tsx | 7 +-
.../filesPage/MoveToFolderDialog.tsx | 44 ++-
.../components/filesPage/VersionTimeline.tsx | 58 ++--
.../InitialOnboardingModal/renderButtons.tsx | 41 +--
.../onboarding/OnboardingModalSlide.tsx | 24 +-
.../components/onboarding/OnboardingTour.tsx | 12 +-
.../onboarding/onboardingFlowConfig.ts | 4 +
.../slides/AnalyticsChoiceSlide.tsx | 4 +-
.../onboarding/slides/DesktopInstallTitle.tsx | 9 +-
.../onboarding/slides/FirstLoginSlide.tsx | 7 +-
.../onboarding/slides/MFASetupSlide.tsx | 6 +-
.../components/pageEditor/FileThumbnail.tsx | 52 ++--
.../pageEditor/PageEditorControls.tsx | 87 ++----
.../pageEditor/PageSelectByNumberButton.tsx | 6 +-
.../bulkSelectionPanel/OperatorsSection.tsx | 13 +-
.../bulkSelectionPanel/PageSelectionInput.tsx | 22 +-
.../bulkSelectionPanel/SelectPages.tsx | 3 +-
.../core/components/shared/AppConfigModal.tsx | 5 +-
.../core/components/shared/BulkShareModal.tsx | 8 +-
.../shared/BulkUploadToServerModal.tsx | 5 +-
.../components/shared/ButtonSelector.test.tsx | 73 +++--
.../core/components/shared/ButtonSelector.tsx | 109 +++----
.../core/components/shared/ButtonToggle.tsx | 86 ++---
.../shared/DismissAllErrorsButton.tsx | 9 +-
.../shared/DropdownListWithFooter.tsx | 6 +-
.../components/shared/EditableSecretField.tsx | 11 +-
.../shared/EncryptedPdfUnlockModal.tsx | 16 +-
.../core/components/shared/ErrorBoundary.tsx | 9 +-
.../src/core/components/shared/FileCard.tsx | 34 +-
.../components/shared/FileDropdownMenu.tsx | 15 +-
.../src/core/components/shared/FileGrid.tsx | 15 +-
.../components/shared/FilePickerModal.tsx | 8 +-
.../components/shared/FileSelectorPicker.tsx | 12 +-
.../core/components/shared/FileSidebar.tsx | 47 +--
.../components/shared/FileSidebarFileItem.css | 2 +-
.../components/shared/FileSidebarFileItem.tsx | 16 +-
.../components/shared/FileUploadButton.tsx | 44 ++-
.../components/shared/FirstLoginModal.tsx | 14 +-
.../src/core/components/shared/Footer.tsx | 10 +-
.../components/shared/HoverActionMenu.tsx | 6 +-
.../src/core/components/shared/InfoBanner.tsx | 59 +++-
.../core/components/shared/LandingActions.tsx | 15 +-
.../core/components/shared/LandingPage.css | 11 +-
.../core/components/shared/LandingPage.tsx | 8 +-
.../components/shared/LanguageSelector.tsx | 88 ++----
.../components/shared/LoginAgreementModal.tsx | 6 +-
.../components/shared/MultiSelectControls.tsx | 16 +-
.../shared/NavigationWarningModal.tsx | 43 +--
.../components/shared/ObscuredOverlay.tsx | 7 +-
.../core/components/shared/ShareFileModal.tsx | 8 +-
.../shared/ShareManagementModal.tsx | 42 ++-
.../src/core/components/shared/TextInput.tsx | 7 +-
.../components/shared/ToolPanelHeader.tsx | 10 +-
.../src/core/components/shared/Tooltip.tsx | 6 +-
.../core/components/shared/TopControls.tsx | 37 +--
.../core/components/shared/UpdateModal.tsx | 66 ++--
.../components/shared/UploadToServerModal.tsx | 5 +-
.../core/components/shared/UserSelector.tsx | 7 +-
.../shared/ViewerInlineControls.tsx | 9 +-
.../core/components/shared/WorkbenchBar.css | 21 +-
.../core/components/shared/WorkbenchBar.tsx | 78 ++---
.../components/shared/ZipWarningModal.tsx | 31 +-
.../config/RestartConfirmationModal.tsx | 6 +-
.../shared/config/SettingsStickyFooter.tsx | 5 +-
.../config/configSections/GeneralSection.tsx | 23 +-
.../config/configSections/HelpSection.tsx | 7 +-
.../config/configSections/HotkeysSection.tsx | 11 +-
.../config/configSections/LegalSection.tsx | 5 +-
.../config/configSections/ProviderCard.tsx | 10 +-
.../shared/filePreview/NavigationArrows.tsx | 13 +-
.../quickAccessBar/QuickAccessButton.tsx | 6 +-
.../shared/signing/SharedSigningLauncher.tsx | 5 +-
.../steps/ConfigureSignatureDefaultsStep.tsx | 5 +-
.../signing/steps/ReviewSessionStep.tsx | 16 +-
.../signing/steps/SelectDocumentStep.tsx | 3 +-
.../signing/steps/SelectParticipantsStep.tsx | 5 +-
.../wetSignature/DrawSignatureCanvas.tsx | 7 +-
.../wetSignature/SignatureTypeSelector.tsx | 8 +-
.../wetSignature/UploadSignatureImage.tsx | 9 +-
.../core/components/toast/ToastRenderer.tsx | 18 +-
.../core/components/tools/RightSidebar.tsx | 35 +--
.../src/core/components/tools/ToolPanel.css | 1 +
.../components/tools/ToolPanelModePrompt.tsx | 13 +-
.../components/tools/ToolPanelViewerBar.tsx | 8 +-
.../src/core/components/tools/ToolPicker.tsx | 7 +-
.../addAttachments/AddAttachmentsSettings.tsx | 39 +--
.../addPageNumbers/PageNumberPreview.tsx | 7 +-
.../StampPositionFormattingSettings.tsx | 38 ++-
.../tools/addStamp/StampPreview.tsx | 7 +-
.../tools/addStamp/StampSetupSettings.tsx | 7 +-
.../AdjustPageScaleSettings.tsx | 14 +-
.../tools/automate/AutomationCreation.tsx | 10 +-
.../tools/automate/AutomationEntry.tsx | 43 ++-
.../tools/automate/AutomationImportModal.tsx | 4 +-
.../tools/automate/AutomationRun.tsx | 13 +-
.../tools/automate/IconSelector.tsx | 20 +-
.../tools/automate/ToolConfigurationModal.tsx | 14 +-
.../components/tools/automate/ToolList.tsx | 27 +-
.../BookletImpositionSettings.tsx | 4 +-
.../certSign/CertificateFormatSettings.tsx | 17 +-
.../tools/certSign/CertificateSelector.tsx | 15 +-
.../certSign/CertificateTypeSettings.tsx | 45 +--
.../certSign/HardwareCertificateSettings.tsx | 34 +-
.../certSign/SignatureAppearanceSettings.tsx | 19 +-
.../tools/certSign/SignatureSettingsInput.tsx | 18 +-
.../tools/certSign/WetSignatureInput.tsx | 11 +-
.../certSign/modals/AddParticipantsFlow.tsx | 7 +-
.../modals/CertificateConfigModal.tsx | 8 +-
.../certSign/modals/SelectSignatureModal.tsx | 29 +-
.../certSign/panels/ParticipantListPanel.tsx | 11 +-
.../certSign/panels/SessionActionsPanel.tsx | 8 +-
.../certSign/panels/SessionDetailPanel.tsx | 27 +-
.../certSign/panels/SignControlsPanel.tsx | 37 +--
.../certSign/panels/SignRequestPanel.tsx | 14 +-
.../certSign/steps/AddSignaturesStep.tsx | 7 +-
.../steps/CertificateSelectionStep.tsx | 5 +-
.../certSign/steps/ReviewSignatureStep.tsx | 11 +-
.../certSign/steps/SignatureCreationStep.tsx | 3 +-
.../certSign/steps/SignaturePlacementStep.tsx | 5 +-
.../steps/CustomMetadataStep.tsx | 13 +-
.../compare/ComparePixelWorkbenchView.tsx | 7 +-
.../components/tools/compare/compareView.css | 73 +----
.../tools/compress/CompressSettings.tsx | 18 +-
.../tools/convert/ConvertSettings.tsx | 20 +-
.../tools/convert/GroupedFormatDropdown.tsx | 34 +-
.../components/tools/crop/CropSettings.tsx | 4 +-
.../editTableOfContents/BookmarkEditor.tsx | 36 ++-
.../EditTableOfContentsSettings.tsx | 66 ++--
.../EditTableOfContentsWorkbenchView.tsx | 6 +-
.../tools/fullscreen/CompactToolItem.tsx | 35 ++-
.../tools/fullscreen/DetailedToolItem.tsx | 7 +-
.../tools/getPdfInfo/GetPdfInfoResults.tsx | 4 +-
.../tools/merge/MergeFileSorter.tsx | 15 +-
.../tools/overlayPdfs/OverlayPdfsSettings.tsx | 19 +-
.../pdfTextEditor/PdfTextEditorSidebar.tsx | 39 ++-
.../tools/pdfTextEditor/PdfTextEditorView.tsx | 23 +-
.../tools/redact/ManualRedactionControls.tsx | 8 +-
.../redact/RedactSingleStepSettings.test.tsx | 20 +-
.../tools/redact/WordsToRedactInput.tsx | 18 +-
.../tools/rotate/RotateSettings.tsx | 11 +-
.../tools/shared/NavigationControls.tsx | 11 +-
.../tools/shared/OperationButton.tsx | 53 +++-
.../tools/shared/ReviewToolStep.tsx | 10 +-
.../tools/shared/ScopedOperationButton.tsx | 14 +-
.../components/tools/showJS/ShowJSView.tsx | 30 +-
.../tools/sign/SavedSignaturesSection.tsx | 35 +--
.../components/tools/sign/SignSettings.tsx | 59 ++--
.../tools/toolPicker/FavoriteStar.tsx | 25 +-
.../tools/toolPicker/ToolButton.tsx | 201 ++++++------
.../tools/toolPicker/ToolPicker.css | 7 +-
.../tools/toolPicker/ToolSearch.tsx | 7 +-
.../ValidateSignatureResults.tsx | 17 +-
.../ValidateSignatureSettings.tsx | 6 +-
.../viewer/AnnotationMenuButtons.tsx | 94 ++----
.../components/viewer/AttachmentSidebar.tsx | 48 ++-
.../components/viewer/BookmarkSidebar.tsx | 127 ++++++--
.../components/viewer/CommentsSidebar.tsx | 136 ++++----
.../core/components/viewer/EmbedPdfViewer.tsx | 10 +-
.../core/components/viewer/LayerSidebar.tsx | 19 +-
.../src/core/components/viewer/LinkLayer.tsx | 16 +-
.../core/components/viewer/NonPdfViewer.tsx | 7 +-
.../components/viewer/PdfViewerToolbar.tsx | 84 ++---
.../viewer/RedactionSelectionMenu.tsx | 31 +-
.../components/viewer/SearchInterface.tsx | 11 +-
.../viewer/SignaturePreviewLayer.tsx | 9 +-
.../components/viewer/TextSelectionMenu.tsx | 9 +-
.../components/viewer/ThumbnailSidebar.tsx | 14 +-
.../viewer/ViewerAnnotationControls.tsx | 19 +-
.../components/viewer/ViewerShareButton.tsx | 17 +-
.../viewer/nonpdf/MarkdownRenderer.tsx | 6 +-
.../components/viewer/nonpdf/NonPdfBanner.tsx | 8 +-
.../viewer/useViewerWorkbenchBarButtons.tsx | 48 ++-
.../src/core/contexts/FileManagerContext.tsx | 13 +-
.../core/contexts/UnsavedChangesContext.tsx | 7 +-
frontend/editor/src/core/pages/HomePage.tsx | 21 +-
.../src/core/pages/MobileScannerPage.tsx | 66 ++--
.../editor/src/core/styles/cookieconsent.css | 39 +--
frontend/editor/src/core/styles/theme.css | 271 ++++++++--------
.../live/viewer-sidebar-add-buttons.spec.ts | 4 +-
.../tests/stubbed/cert-sign-wizard.spec.ts | 17 +-
.../stubbed/page-editor-rotation.spec.ts | 6 +-
.../core/tests/stubbed/watermark-tool.spec.ts | 8 +-
.../editor/src/core/theme/mantineTheme.ts | 18 ++
frontend/editor/src/core/tokens/tokens.css | 13 +
frontend/editor/src/core/tools/AddStamp.tsx | 1 -
frontend/editor/src/core/tools/Compare.tsx | 45 ++-
frontend/editor/src/core/tools/GetPdfInfo.tsx | 28 +-
frontend/editor/src/core/tools/Merge.tsx | 7 +-
frontend/editor/src/core/tools/SharedSign.tsx | 40 ++-
.../core/tools/annotate/AnnotationPanel.tsx | 71 ++---
.../core/tools/formFill/FormFieldSidebar.tsx | 12 +-
.../src/core/tools/formFill/FormFill.tsx | 23 +-
.../src/core/tools/formFill/FormSaveBar.tsx | 31 +-
frontend/editor/src/core/ui/ActionIcon.css | 21 ++
.../editor/src/core/ui/ActionIcon.stories.tsx | 132 ++++++++
frontend/editor/src/core/ui/ActionIcon.tsx | 140 +++++++++
.../src/{proprietary => core}/ui/Avatar.css | 0
.../ui/Avatar.stories.tsx | 0
.../src/{proprietary => core}/ui/Avatar.tsx | 0
.../src/{proprietary => core}/ui/Banner.css | 15 +-
.../ui/Banner.stories.tsx | 2 +-
.../src/{proprietary => core}/ui/Banner.tsx | 22 +-
frontend/editor/src/core/ui/Button.css | 41 +++
.../editor/src/core/ui/Button.stories.tsx | 293 ++++++++++++++++++
frontend/editor/src/core/ui/Button.tsx | 205 ++++++++++++
.../src/{proprietary => core}/ui/Card.css | 20 +-
.../{proprietary => core}/ui/Card.stories.tsx | 42 ++-
.../src/{proprietary => core}/ui/Card.tsx | 10 +-
.../ui/ChatFABButton.css | 0
.../ui/ChatFABButton.stories.tsx | 6 +-
.../ui/ChatFABButton.tsx | 12 +-
.../ui/ChatFABWindow.css | 4 +-
.../ui/ChatFABWindow.stories.tsx | 0
.../ui/ChatFABWindow.tsx | 0
.../src/{proprietary => core}/ui/Checkbox.css | 0
.../src/{proprietary => core}/ui/Checkbox.tsx | 0
frontend/editor/src/core/ui/Chip.css | 49 +++
frontend/editor/src/core/ui/Chip.stories.tsx | 91 ++++++
frontend/editor/src/core/ui/Chip.tsx | 113 +++++++
.../src/{proprietary => core}/ui/ChipFlow.css | 0
.../ui/ChipFlow.stories.tsx | 0
.../src/{proprietary => core}/ui/ChipFlow.tsx | 14 +-
.../{proprietary => core}/ui/CodeBlock.css | 0
.../ui/CodeBlock.stories.tsx | 0
.../{proprietary => core}/ui/CodeBlock.tsx | 26 +-
.../{proprietary => core}/ui/Collapsible.css | 0
.../ui/Collapsible.stories.tsx | 0
.../{proprietary => core}/ui/Collapsible.tsx | 0
.../{proprietary => core}/ui/ColorInput.tsx | 0
.../src/{proprietary => core}/ui/DataRow.css | 0
.../ui/DataRow.stories.tsx | 0
.../src/{proprietary => core}/ui/DataRow.tsx | 0
.../src/{proprietary => core}/ui/Drawer.css | 16 +-
.../ui/Drawer.stories.tsx | 6 +-
.../src/{proprietary => core}/ui/Drawer.tsx | 51 ++-
.../src/{proprietary => core}/ui/Dropdown.css | 0
.../ui/Dropdown.stories.tsx | 8 +-
.../src/{proprietary => core}/ui/Dropdown.tsx | 0
.../{proprietary => core}/ui/EmptyState.css | 0
.../ui/EmptyState.stories.tsx | 4 +-
.../{proprietary => core}/ui/EmptyState.tsx | 0
frontend/editor/src/core/ui/FilePicker.tsx | 55 ++++
.../{proprietary => core}/ui/FormField.css | 0
.../{proprietary => core}/ui/FormField.tsx | 0
.../ui/Forms.stories.tsx | 0
.../{proprietary => core}/ui/IconBadge.css | 0
.../ui/IconBadge.stories.tsx | 0
.../{proprietary => core}/ui/IconBadge.tsx | 0
.../src/{proprietary => core}/ui/Inline.css | 0
.../ui/Inline.stories.tsx | 6 +-
.../src/{proprietary => core}/ui/Inline.tsx | 0
.../src/{proprietary => core}/ui/Input.css | 0
.../src/{proprietary => core}/ui/Input.tsx | 0
.../src/{proprietary => core}/ui/ListRow.css | 0
.../ui/ListRow.stories.tsx | 0
.../src/{proprietary => core}/ui/ListRow.tsx | 0
.../{proprietary => core}/ui/MantineForms.css | 0
.../ui/MantineForms.stories.tsx | 0
.../{proprietary => core}/ui/MethodBadge.css | 0
.../ui/MethodBadge.stories.tsx | 0
.../{proprietary => core}/ui/MethodBadge.tsx | 0
.../{proprietary => core}/ui/MetricCard.css | 0
.../ui/MetricCard.stories.tsx | 0
.../{proprietary => core}/ui/MetricCard.tsx | 0
.../{proprietary => core}/ui/MetricStrip.css | 0
.../ui/MetricStrip.stories.tsx | 0
.../{proprietary => core}/ui/MetricStrip.tsx | 0
.../src/{proprietary => core}/ui/Modal.css | 17 +-
.../src/{proprietary => core}/ui/Modal.tsx | 58 ++--
.../{proprietary => core}/ui/MultiSelect.tsx | 0
.../src/{proprietary => core}/ui/NavItem.css | 0
.../ui/NavItem.stories.tsx | 0
.../src/{proprietary => core}/ui/NavItem.tsx | 0
.../{proprietary => core}/ui/NumberInput.tsx | 0
.../{proprietary => core}/ui/PanelHeader.css | 0
.../ui/PanelHeader.stories.tsx | 27 +-
.../{proprietary => core}/ui/PanelHeader.tsx | 3 -
.../{proprietary => core}/ui/ProgressBar.css | 0
.../ui/ProgressBar.stories.tsx | 0
.../{proprietary => core}/ui/ProgressBar.tsx | 0
.../src/{proprietary => core}/ui/Radio.css | 0
.../src/{proprietary => core}/ui/Radio.tsx | 0
.../ui/SectionDivider.css | 0
.../ui/SectionDivider.stories.tsx | 0
.../ui/SectionDivider.tsx | 0
.../ui/SectionHeader.css | 0
.../ui/SectionHeader.stories.tsx | 0
.../ui/SectionHeader.tsx | 0
.../editor/src/core/ui/SegmentedControl.css | 38 +++
.../src/core/ui/SegmentedControl.stories.tsx | 135 ++++++++
.../editor/src/core/ui/SegmentedControl.tsx | 103 ++++++
.../src/{proprietary => core}/ui/Select.css | 0
.../src/{proprietary => core}/ui/Select.tsx | 0
.../{proprietary => core}/ui/SettingsRow.css | 0
.../ui/SettingsRow.stories.tsx | 0
.../{proprietary => core}/ui/SettingsRow.tsx | 0
.../ui/SettingsShell.css | 20 +-
.../ui/SettingsShell.stories.tsx | 4 +-
.../ui/SettingsShell.tsx | 62 ++--
.../src/{proprietary => core}/ui/Skeleton.css | 0
.../ui/Skeleton.stories.tsx | 0
.../src/{proprietary => core}/ui/Skeleton.tsx | 0
.../src/{proprietary => core}/ui/Slider.css | 0
.../src/{proprietary => core}/ui/Slider.tsx | 0
.../src/{proprietary => core}/ui/Spinner.css | 0
.../ui/Spinner.stories.tsx | 0
.../src/{proprietary => core}/ui/Spinner.tsx | 0
.../src/{proprietary => core}/ui/Stack.css | 0
.../ui/Stack.stories.tsx | 0
.../src/{proprietary => core}/ui/Stack.tsx | 0
.../src/{proprietary => core}/ui/StatTile.css | 0
.../ui/StatTile.stories.tsx | 0
.../src/{proprietary => core}/ui/StatTile.tsx | 0
.../{proprietary => core}/ui/StatusBadge.css | 0
.../ui/StatusBadge.stories.tsx | 0
.../{proprietary => core}/ui/StatusBadge.tsx | 0
.../ui/StepIndicator.css | 0
.../ui/StepIndicator.stories.tsx | 0
.../ui/StepIndicator.tsx | 0
.../src/{proprietary => core}/ui/Table.css | 0
.../ui/Table.stories.tsx | 0
.../src/{proprietary => core}/ui/Table.tsx | 0
.../src/{proprietary => core}/ui/Tabs.css | 0
.../{proprietary => core}/ui/Tabs.stories.tsx | 0
.../src/{proprietary => core}/ui/Tabs.tsx | 0
.../src/{proprietary => core}/ui/Toast.css | 13 +-
.../ui/Toast.stories.tsx | 0
.../src/{proprietary => core}/ui/Toast.tsx | 18 +-
.../{proprietary => core}/ui/ToggleSwitch.css | 0
.../ui/ToggleSwitch.stories.tsx | 0
.../{proprietary => core}/ui/ToggleSwitch.tsx | 0
frontend/editor/src/core/ui/accents.css | 101 ++++++
.../ui/ariaForwarding.test.tsx | 0
.../ui/ariaForwarding.ts | 0
frontend/editor/src/core/ui/controlSizes.ts | 9 +
.../src/{proprietary => core}/ui/index.ts | 3 +
.../desktop/components/ConnectionSettings.tsx | 9 +-
.../components/DesktopOnboardingModal.tsx | 24 +-
.../SetupWizard/DesktopOAuthButtons.tsx | 6 +-
.../SetupWizard/SaaSLoginScreen.tsx | 13 +-
.../components/SetupWizard/SelfHostedLink.tsx | 7 +-
.../SetupWizard/ServerSelection.tsx | 15 +-
.../desktop/components/SetupWizard/index.tsx | 24 +-
.../shared/SelfHostedOfflineBanner.tsx | 22 +-
.../configSections/DefaultAppSettings.tsx | 6 +-
.../toolPicker/ToolPickerFooterExtensions.tsx | 8 +-
.../src/desktop/routes/login/LoginHeader.tsx | 9 +-
.../src/portal/components/AssistantButton.tsx | 7 +-
.../src/portal/components/AssistantPanel.tsx | 20 +-
.../components/ChatFABWidget.stories.tsx | 68 ++--
.../portal/components/ErrorBoundary.test.tsx | 12 +-
.../editor/src/portal/components/Header.tsx | 25 +-
.../src/portal/components/MocksToggle.tsx | 7 +-
.../components/NotificationsDropdown.tsx | 20 +-
.../portal/components/PipelineForkWizard.tsx | 13 +-
.../src/portal/components/PolicySummary.tsx | 4 +-
.../src/portal/components/PopularUseCases.tsx | 32 +-
.../components/ProcessingStatusStrip.tsx | 4 +-
.../src/portal/components/RecentActivity.tsx | 6 +-
.../src/portal/components/SearchModal.tsx | 10 +-
.../src/portal/components/SettingsModal.tsx | 15 +-
.../editor/src/portal/components/Sidebar.tsx | 8 +-
.../src/portal/components/SingleOpRunner.tsx | 22 +-
.../src/portal/components/WelcomeCarousel.tsx | 9 +-
.../account-link/LinkAccountCard.tsx | 4 +-
.../account-link/LinkAccountModal.tsx | 2 +-
.../account-link/LinkedInstancesTable.tsx | 4 +-
.../agent-builder/AgentSelector.tsx | 10 +-
.../agent-builder/BootstrapDialog.tsx | 2 +-
.../components/agent-builder/EvalsPanel.tsx | 2 +-
.../agent-builder/ScenariosPanel.tsx | 4 +-
.../components/agent-builder/ToolsPanel.tsx | 4 +-
.../agent-builder/VersionsPanel.tsx | 4 +-
.../components/billing/EnterpriseUpsell.tsx | 6 +-
.../components/billing/FreePdfEditorsCard.tsx | 4 +-
.../components/billing/FreePlanView.tsx | 3 +-
.../components/billing/InvoicesList.tsx | 2 +-
.../components/billing/LinkAccountPrompt.tsx | 6 +-
.../components/billing/PaymentMethodCard.tsx | 2 +-
.../components/billing/SpendLimitCard.tsx | 11 +-
.../billing/StripeCheckoutModal.tsx | 2 +-
.../components/catalogue/ComponentCard.tsx | 2 +-
.../catalogue/ComponentDetailModal.tsx | 4 +-
.../catalogue/ComponentPropsTable.tsx | 2 +-
.../components/docs/AuthenticationSection.tsx | 4 +-
.../components/docs/ComponentsSection.tsx | 2 +-
.../src/portal/components/docs/DocsNav.tsx | 10 +-
.../components/docs/GettingStartedSection.tsx | 2 +-
.../components/docs/PlaybooksSection.tsx | 4 +-
.../components/docs/WebhooksSection.tsx | 2 +-
.../editor-admin/CredentialRotationCard.tsx | 4 +-
.../editor-admin/DeploymentTargets.tsx | 5 +-
.../editor-admin/InstanceHealthTable.tsx | 13 +-
.../editor-admin/OfflineActivationCard.tsx | 10 +-
.../components/editor-admin/PairingPanel.tsx | 8 +-
.../components/infrastructure/ApiKeyCard.tsx | 12 +-
.../components/infrastructure/ApiKeysTab.tsx | 3 +-
.../infrastructure/CreateKeyModal.tsx | 7 +-
.../infrastructure/DeploymentsTab.tsx | 6 +-
.../components/infrastructure/ModelsTab.tsx | 6 +-
.../components/infrastructure/SecurityTab.tsx | 6 +-
.../components/infrastructure/StorageTab.tsx | 2 +-
.../components/infrastructure/infraFormat.ts | 12 +-
.../components/pipelines/PipelineComposer.tsx | 17 +-
.../pipelines/PipelineDetailCard.tsx | 20 +-
.../components/pipelines/PipelinesTable.tsx | 2 +-
.../policies/PolicyCategoryCard.tsx | 4 +-
.../components/policies/PolicyDetailPanel.tsx | 18 +-
.../components/policies/PolicyFieldRow.tsx | 2 +-
.../components/policies/PolicySetupWizard.tsx | 16 +-
.../components/procurement/ActionModal.tsx | 8 +-
.../components/procurement/DealJourney.tsx | 4 +-
.../components/procurement/DealStatusHero.tsx | 4 +-
.../portal/components/procurement/DocRow.tsx | 14 +-
.../components/procurement/DocumentLedger.tsx | 4 +-
.../components/procurement/LockedState.tsx | 2 +-
.../procurement/ProcurementAgreement.tsx | 4 +-
.../procurement/ProcurementExtras.tsx | 4 +-
.../procurement/ProcurementHome.tsx | 8 +-
.../procurement/ProcurementModal.stories.tsx | 4 +-
.../procurement/ProcurementStages.tsx | 14 +-
.../components/procurement/QuoteBuilder.tsx | 14 +-
.../components/sources/ConnectWizard.tsx | 10 +-
.../components/sources/SourceDetailCard.tsx | 21 +-
.../components/sources/SourceDetailPanel.tsx | 2 +-
.../components/sources/SourcesTable.tsx | 4 +-
.../portal/components/sources/sourceTypes.ts | 10 +-
.../components/users/AccessControls.tsx | 4 +-
.../components/users/InviteMemberModal.tsx | 2 +-
.../portal/components/users/MembersTable.tsx | 23 +-
.../src/portal/components/users/RolesGrid.tsx | 3 +-
.../src/portal/components/users/format.ts | 19 +-
frontend/editor/src/portal/mocks/docs.ts | 11 +-
.../editor/src/portal/views/AgentBuilder.tsx | 2 +-
frontend/editor/src/portal/views/Home.tsx | 43 +--
.../src/portal/views/Infrastructure.tsx | 2 +-
.../src/portal/views/Pipelines.test.tsx | 13 +-
.../editor/src/portal/views/Pipelines.tsx | 6 +-
frontend/editor/src/portal/views/Policies.css | 74 +++++
frontend/editor/src/portal/views/Policies.tsx | 2 +-
frontend/editor/src/portal/views/Sources.css | 10 +-
.../editor/src/portal/views/Sources.test.tsx | 13 +-
frontend/editor/src/portal/views/Sources.tsx | 10 +-
frontend/editor/src/portal/views/Usage.tsx | 2 +-
frontend/editor/src/portal/views/Users.tsx | 2 +-
.../proprietary/auth/ui/EmailPasswordForm.tsx | 16 +-
.../auth/ui/LoginRightCarousel.tsx | 11 +-
.../src/proprietary/auth/ui/OAuthButtons.tsx | 85 +++--
.../proprietary/auth/ui/SpringLoginForm.tsx | 5 +-
.../src/proprietary/auth/ui/auth-theme.css | 4 +-
.../editor/src/proprietary/auth/ui/auth.css | 55 ++++
.../proprietary/billing/SpendCapControl.tsx | 12 +-
.../proprietary/components/chat/ChatFAB.css | 8 +
.../proprietary/components/chat/ChatFAB.tsx | 26 +-
.../proprietary/components/chat/ChatPanel.css | 107 ++++++-
.../proprietary/components/chat/ChatPanel.tsx | 20 +-
.../components/chat/ChatQuickActions.tsx | 23 +-
.../components/policies/Policies.css | 5 +
.../components/policies/PoliciesSidebar.tsx | 37 ++-
.../policies/PolicyDeleteConfirmModal.tsx | 7 +-
.../components/policies/PolicyDetailPanel.tsx | 18 +-
.../components/policies/PolicyFieldRow.tsx | 7 +-
.../components/policies/PolicySetupWizard.tsx | 11 +-
.../components/policies/PolicyToolConfig.tsx | 6 +-
.../shared/ChangeUserPasswordModal.tsx | 22 +-
.../components/shared/InviteMembersModal.tsx | 26 +-
.../components/shared/ManageBillingButton.tsx | 4 +-
.../components/shared/UpdateSeatsButton.tsx | 6 +-
.../components/shared/UpdateSeatsModal.tsx | 4 +-
.../components/shared/UpgradeBanner.tsx | 2 +-
.../shared/config/OverviewHeader.tsx | 5 +-
.../config/configSections/AccountSection.tsx | 22 +-
.../configSections/AdminAdvancedSection.tsx | 6 +-
.../configSections/AdminAuditSection.tsx | 6 +-
.../configSections/AdminDatabaseSection.tsx | 36 ++-
.../configSections/AdminGeneralSection.tsx | 4 +-
.../configSections/AdminUsageSection.tsx | 35 +--
.../configSections/LoginAgreementEditor.tsx | 3 +-
.../config/configSections/PeopleSection.tsx | 38 ++-
.../configSections/TeamDetailsSection.tsx | 50 ++-
.../config/configSections/TeamsSection.tsx | 42 ++-
.../configSections/apiKeys/ApiKeySection.tsx | 27 +-
.../configSections/apiKeys/RefreshModal.tsx | 7 +-
.../audit/AuditChartsSection.tsx | 25 +-
.../audit/AuditClearDataSection.tsx | 12 +-
.../configSections/audit/AuditEventsTable.tsx | 28 +-
.../audit/AuditExportSection.tsx | 21 +-
.../configSections/audit/AuditFiltersForm.tsx | 28 +-
.../plan/AvailablePlansSection.tsx | 5 +-
.../configSections/plan/LicenseKeySection.tsx | 52 ++--
.../config/configSections/plan/PlanCard.tsx | 6 +-
.../plan/StaticCheckoutModal.tsx | 8 +-
.../configSections/plan/StaticPlanSection.tsx | 29 +-
.../shared/stripeCheckout/StripeCheckout.tsx | 5 +-
.../stripeCheckout/stages/EmailStage.tsx | 5 +-
.../stripeCheckout/stages/ErrorStage.tsx | 5 +-
.../stages/PlanSelectionStage.tsx | 15 +-
.../stripeCheckout/stages/SuccessStage.tsx | 19 +-
.../watchedFolders/CardExpansionModal.tsx | 6 +-
.../DeleteFolderConfirmModal.tsx | 12 +-
.../watchedFolders/WatchedFolderCard.tsx | 28 +-
.../watchedFolders/WatchedFolderHomePage.tsx | 34 +-
.../WatchedFolderManagementModal.tsx | 38 +--
.../watchedFolders/WatchedFolderSection.tsx | 10 +-
.../WatchedFolderWorkbenchView.tsx | 99 +++---
.../components/workflow/ParticipantView.tsx | 10 +-
.../src/proprietary/routes/InviteAccept.tsx | 10 +-
.../editor/src/proprietary/routes/Landing.tsx | 5 +-
.../src/proprietary/routes/Login.test.tsx | 16 +-
.../editor/src/proprietary/routes/Login.tsx | 11 +-
.../src/proprietary/routes/ShareLinkPage.tsx | 4 +-
.../editor/src/proprietary/routes/Signup.tsx | 7 +-
.../routes/login/NavigationLink.tsx | 5 +-
.../routes/login/OAuthButtons.stories.tsx | 33 ++
.../proprietary/routes/signup/SignupForm.tsx | 3 +-
frontend/editor/src/proprietary/ui/Button.css | 118 -------
.../src/proprietary/ui/Button.stories.tsx | 57 ----
frontend/editor/src/proprietary/ui/Button.tsx | 68 ----
frontend/editor/src/proprietary/ui/Chip.css | 96 ------
.../src/proprietary/ui/Chip.stories.tsx | 79 -----
frontend/editor/src/proprietary/ui/Chip.tsx | 88 ------
.../components/SignupRequiredBootstrap.tsx | 5 +-
.../saas/components/auth/GuestUserBanner.css | 32 --
.../saas/components/auth/GuestUserBanner.tsx | 17 +-
.../saas/components/shared/AppConfigModal.tsx | 12 +-
.../shared/config/ProfilePictureCropper.tsx | 5 +-
.../shared/config/configSections/ApiKeys.tsx | 3 +-
.../config/configSections/McpSection.tsx | 13 +-
.../shared/config/configSections/Overview.tsx | 64 ++--
.../configSections/PasswordSecurity.tsx | 6 +-
.../components/tools/sign/SignSettings.tsx | 59 ++--
.../editor/src/saas/routes/AuthCallback.tsx | 4 +-
frontend/editor/src/saas/routes/Login.tsx | 37 +--
.../editor/src/saas/routes/OAuthConsent.tsx | 18 +-
.../editor/src/saas/routes/ResetPassword.tsx | 5 +-
frontend/editor/src/saas/routes/Signup.tsx | 21 +-
.../routes/authShared/GuestSignInButton.tsx | 9 +-
.../src/saas/routes/authShared/saas-auth.css | 5 +
.../saas/routes/login/EmailPasswordForm.tsx | 8 +-
.../src/saas/routes/login/MagicLinkForm.tsx | 10 +-
.../src/saas/routes/login/OAuthButtons.tsx | 29 +-
.../editor/src/saas/styles/saas-theme.css | 36 +--
frontend/editor/tsconfig.portal.vite.json | 2 +-
frontend/editor/vitest.config.ts | 2 +-
frontend/eslint.config.mjs | 77 +++++
frontend/scripts/find-unused-css.mjs | 129 ++++++++
frontend/shared/components/index.ts | 45 +++
592 files changed, 6399 insertions(+), 5057 deletions(-)
create mode 100644 frontend/.storybook/declarations.d.ts
create mode 100644 frontend/.storybook/tsconfig.json
create mode 100644 frontend/editor/src/core/ui/ActionIcon.css
create mode 100644 frontend/editor/src/core/ui/ActionIcon.stories.tsx
create mode 100644 frontend/editor/src/core/ui/ActionIcon.tsx
rename frontend/editor/src/{proprietary => core}/ui/Avatar.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Avatar.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Avatar.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Banner.css (85%)
rename frontend/editor/src/{proprietary => core}/ui/Banner.stories.tsx (96%)
rename frontend/editor/src/{proprietary => core}/ui/Banner.tsx (71%)
create mode 100644 frontend/editor/src/core/ui/Button.css
create mode 100644 frontend/editor/src/core/ui/Button.stories.tsx
create mode 100644 frontend/editor/src/core/ui/Button.tsx
rename frontend/editor/src/{proprietary => core}/ui/Card.css (74%)
rename frontend/editor/src/{proprietary => core}/ui/Card.stories.tsx (82%)
rename frontend/editor/src/{proprietary => core}/ui/Card.tsx (86%)
rename frontend/editor/src/{proprietary => core}/ui/ChatFABButton.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/ChatFABButton.stories.tsx (90%)
rename frontend/editor/src/{proprietary => core}/ui/ChatFABButton.tsx (84%)
rename frontend/editor/src/{proprietary => core}/ui/ChatFABWindow.css (93%)
rename frontend/editor/src/{proprietary => core}/ui/ChatFABWindow.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/ChatFABWindow.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Checkbox.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Checkbox.tsx (100%)
create mode 100644 frontend/editor/src/core/ui/Chip.css
create mode 100644 frontend/editor/src/core/ui/Chip.stories.tsx
create mode 100644 frontend/editor/src/core/ui/Chip.tsx
rename frontend/editor/src/{proprietary => core}/ui/ChipFlow.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/ChipFlow.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/ChipFlow.tsx (65%)
rename frontend/editor/src/{proprietary => core}/ui/CodeBlock.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/CodeBlock.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/CodeBlock.tsx (66%)
rename frontend/editor/src/{proprietary => core}/ui/Collapsible.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Collapsible.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Collapsible.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/ColorInput.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/DataRow.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/DataRow.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/DataRow.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Drawer.css (83%)
rename frontend/editor/src/{proprietary => core}/ui/Drawer.stories.tsx (91%)
rename frontend/editor/src/{proprietary => core}/ui/Drawer.tsx (75%)
rename frontend/editor/src/{proprietary => core}/ui/Dropdown.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Dropdown.stories.tsx (90%)
rename frontend/editor/src/{proprietary => core}/ui/Dropdown.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/EmptyState.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/EmptyState.stories.tsx (90%)
rename frontend/editor/src/{proprietary => core}/ui/EmptyState.tsx (100%)
create mode 100644 frontend/editor/src/core/ui/FilePicker.tsx
rename frontend/editor/src/{proprietary => core}/ui/FormField.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/FormField.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Forms.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/IconBadge.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/IconBadge.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/IconBadge.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Inline.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Inline.stories.tsx (88%)
rename frontend/editor/src/{proprietary => core}/ui/Inline.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Input.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Input.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/ListRow.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/ListRow.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/ListRow.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/MantineForms.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/MantineForms.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/MethodBadge.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/MethodBadge.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/MethodBadge.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/MetricCard.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/MetricCard.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/MetricCard.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/MetricStrip.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/MetricStrip.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/MetricStrip.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Modal.css (83%)
rename frontend/editor/src/{proprietary => core}/ui/Modal.tsx (66%)
rename frontend/editor/src/{proprietary => core}/ui/MultiSelect.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/NavItem.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/NavItem.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/NavItem.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/NumberInput.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/PanelHeader.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/PanelHeader.stories.tsx (75%)
rename frontend/editor/src/{proprietary => core}/ui/PanelHeader.tsx (94%)
rename frontend/editor/src/{proprietary => core}/ui/ProgressBar.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/ProgressBar.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/ProgressBar.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Radio.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Radio.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/SectionDivider.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/SectionDivider.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/SectionDivider.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/SectionHeader.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/SectionHeader.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/SectionHeader.tsx (100%)
create mode 100644 frontend/editor/src/core/ui/SegmentedControl.css
create mode 100644 frontend/editor/src/core/ui/SegmentedControl.stories.tsx
create mode 100644 frontend/editor/src/core/ui/SegmentedControl.tsx
rename frontend/editor/src/{proprietary => core}/ui/Select.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Select.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/SettingsRow.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/SettingsRow.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/SettingsRow.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/SettingsShell.css (88%)
rename frontend/editor/src/{proprietary => core}/ui/SettingsShell.stories.tsx (94%)
rename frontend/editor/src/{proprietary => core}/ui/SettingsShell.tsx (65%)
rename frontend/editor/src/{proprietary => core}/ui/Skeleton.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Skeleton.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Skeleton.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Slider.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Slider.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Spinner.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Spinner.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Spinner.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Stack.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Stack.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Stack.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/StatTile.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/StatTile.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/StatTile.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/StatusBadge.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/StatusBadge.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/StatusBadge.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/StepIndicator.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/StepIndicator.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/StepIndicator.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Table.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Table.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Table.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Tabs.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/Tabs.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Tabs.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Toast.css (81%)
rename frontend/editor/src/{proprietary => core}/ui/Toast.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/Toast.tsx (90%)
rename frontend/editor/src/{proprietary => core}/ui/ToggleSwitch.css (100%)
rename frontend/editor/src/{proprietary => core}/ui/ToggleSwitch.stories.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/ToggleSwitch.tsx (100%)
create mode 100644 frontend/editor/src/core/ui/accents.css
rename frontend/editor/src/{proprietary => core}/ui/ariaForwarding.test.tsx (100%)
rename frontend/editor/src/{proprietary => core}/ui/ariaForwarding.ts (100%)
create mode 100644 frontend/editor/src/core/ui/controlSizes.ts
rename frontend/editor/src/{proprietary => core}/ui/index.ts (92%)
create mode 100644 frontend/editor/src/proprietary/routes/login/OAuthButtons.stories.tsx
delete mode 100644 frontend/editor/src/proprietary/ui/Button.css
delete mode 100644 frontend/editor/src/proprietary/ui/Button.stories.tsx
delete mode 100644 frontend/editor/src/proprietary/ui/Button.tsx
delete mode 100644 frontend/editor/src/proprietary/ui/Chip.css
delete mode 100644 frontend/editor/src/proprietary/ui/Chip.stories.tsx
delete mode 100644 frontend/editor/src/proprietary/ui/Chip.tsx
create mode 100644 frontend/scripts/find-unused-css.mjs
create mode 100644 frontend/shared/components/index.ts
diff --git a/frontend/.storybook/declarations.d.ts b/frontend/.storybook/declarations.d.ts
new file mode 100644
index 0000000000..ef6d741f62
--- /dev/null
+++ b/frontend/.storybook/declarations.d.ts
@@ -0,0 +1 @@
+declare module "*.css" {}
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
index cd601cf525..bab413a0e8 100644
--- a/frontend/.storybook/preview.tsx
+++ b/frontend/.storybook/preview.tsx
@@ -14,7 +14,7 @@ void React;
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
-import { ThemeProvider } from "@portal/contexts/ThemeContext";
+import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { SuiProvider } from "@portal/theme/SuiProvider";
import { handlers } from "@portal/mocks/handlers";
@@ -78,13 +78,21 @@ function TierKey({
);
}
-/** Keeps useTheme() and the data-theme attribute in sync. */
-function ThemeWatcher() {
+/**
+ * Makes the Storybook toolbar the SINGLE source of truth for the theme.
+ */
+function ThemeBridge({
+ theme,
+ children,
+}: {
+ theme: "light" | "dark";
+ children: React.ReactNode;
+}) {
+ const { setTheme } = useTheme();
useEffect(() => {
- // The addon-themes decorator already sets data-theme on .
- // We just read it on mount so ThemeProvider picks it up.
- }, []);
- return null;
+ setTheme(theme);
+ }, [theme, setTheme]);
+ return <>{children}>;
}
const withProviders: Decorator = (Story, context) => {
@@ -101,20 +109,21 @@ const withProviders: Decorator = (Story, context) => {
return (
-
- {/* LinkProvider must wrap TierProvider: TierContext derives its tier
- from useLink() (matches App.tsx's nesting). */}
-
-
-
-
-
-
-
-
-
-
-
+
+
+ {/* LinkProvider must wrap TierProvider: TierContext derives its tier
+ from useLink() (matches App.tsx's nesting). */}
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/frontend/.storybook/tsconfig.json b/frontend/.storybook/tsconfig.json
new file mode 100644
index 0000000000..9615d34a22
--- /dev/null
+++ b/frontend/.storybook/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "jsx": "react-jsx",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "paths": {
+ "@app/*": [
+ "../editor/src/desktop/*",
+ "../editor/src/proprietary/*",
+ "../editor/src/core/*"
+ ],
+ "@core/*": ["../editor/src/core/*"],
+ "@proprietary/*": ["../editor/src/proprietary/*"],
+ "@portal/*": ["../editor/src/portal/*"]
+ },
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "skipLibCheck": true
+ },
+ "include": ["./**/*"]
+}
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 241289d1db..6d827eb895 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -7,6 +7,7 @@ black = "Black"
blue = "Blue"
cancel = "Cancel"
chooseFile = "Choose File"
+clear = "Clear"
close = "Close"
comingSoon = "Coming soon"
confirm = "Confirm"
@@ -87,6 +88,7 @@ processingCompleteMultiple = "{{count}} files are ready."
property = "Property"
quickPosition = "Quick Position"
red = "Red"
+remove = "Remove"
reset = "Reset"
review = "Review"
save = "Save"
@@ -180,6 +182,7 @@ addMoreFiles = "Add more files..."
attachments = "Select Attachments"
info = "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel."
placeholder = "Choose files..."
+removeFile = "Remove file"
selectedFiles = "Selected Files"
submit = "Add Attachments"
@@ -1628,6 +1631,9 @@ title = "Do you want to help make Stirling PDF better?"
tags = "annotate,highlight,draw,markup,comment,notes,review,redline,feedback,markup tools,sticky notes,shapes,arrows,text box,freehand"
[annotation]
+alignCenter = "Align center"
+alignLeft = "Align left"
+alignRight = "Align right"
annotationStyle = "Annotation style"
backgroundColor = "Background color"
borderOff = "Border: Off"
@@ -2531,6 +2537,7 @@ title = "Rule of thumb"
[certSign.source]
device = "This device"
+noOtherSources = "No other certificate sources are available."
server = "Server"
stepTitle = "Certificate source"
upload = "Upload"
@@ -3595,7 +3602,9 @@ makeCopy = "Make a copy"
mobileShort = "Mobile"
mobileUpload = "Mobile Upload"
mobileUploadNotAvailable = "Mobile upload not enabled"
+moreOptions = "More options"
myFiles = "My Files"
+nextFile = "Next file"
noFiles = "No files available"
noFilesFound = "No files found matching your search"
noRecentFiles = "No recent files found"
@@ -3605,6 +3614,7 @@ openInFileEditor = "Open in File Editor"
openInPageEditor = "Open in Page Editor"
owner = "Owner"
ownerUnknown = "Unknown"
+previousFile = "Previous file"
recent = "Recent"
removeBoth = "Remove from both"
removeFilePrompt = "This file is saved on this device and on your server. Where would you like to remove it from?"
@@ -4614,10 +4624,10 @@ welcomeTitle = "You've been invited!"
[landing]
addFiles = "Add Files"
heroSubtitle = "Drop in or add an existing PDF to get started."
-heroTitle = "Stirling PDF"
mobileUpload = "Upload from Mobile"
openFromComputer = "Open from computer"
uploadFromComputer = "Upload from computer"
+workbenchEmptyStateHero = "Drop a PDF anywhere"
[language]
direction = "ltr"
@@ -4930,6 +4940,7 @@ title = "Output"
[onboarding]
activeFiles = "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."
allTools = "This is the Tools panel, where you can browse and select from all available PDF tools."
+close = "Close"
cropSettings = "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."
fileCheckbox = "Clicking one of the files selects it for processing. You can select multiple files for batch operations."
fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."
@@ -4955,6 +4966,7 @@ skipTheTour = "Skip the tour"
[onboarding.desktopInstall]
body = "Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer."
+selectOs = "Select operating system"
title = "Download"
titleWithOs = "Download for {{osLabel}}"
@@ -5566,7 +5578,9 @@ viewLabel = "PDF Editor"
[pdfTextEditor.actions]
applyChanges = "Apply Changes"
+clearText = "Clear text"
downloadCopy = "Download Copy"
+moreOptions = "More options"
reset = "Reset Changes"
[pdfTextEditor.badges]
@@ -9670,7 +9684,9 @@ memberRemoved = "Member removed successfully"
namePlaceholder = "Enter team name"
personal = "Personal"
removeError = "Failed to remove member"
+renameCancel = "Cancel rename"
renameError = "Failed to rename team"
+renameSubmit = "Save team name"
renameSuccess = "Team renamed successfully"
[team.invitationBanner]
@@ -9686,6 +9702,7 @@ sendButton = "Send Invitation"
title = "Invite Team Member"
[team.members]
+actions = "Member actions"
emailColumn = "Email"
empty = "No team members yet"
nameColumn = "Name"
@@ -10041,14 +10058,23 @@ zoomOut = "Zoom Out"
[viewer.attachments]
addAttachment = "Add attachment"
close = "Close attachments"
+closeSidebar = "Close attachments sidebar"
+download = "Download attachment"
empty = "No attachments in this document"
loading = "Loading attachments..."
noDocument = "Open a PDF to view its attachments."
noMatch = "No attachments match your search"
noSupport = "Attachment support is unavailable for this viewer."
+retry = "Retry"
searchPlaceholder = "Search attachments"
title = "Attachments"
+[viewer.bookmarks]
+bookmarkTitle = "Bookmark title"
+closeSidebar = "Close bookmarks sidebar"
+collapseAll = "Collapse all bookmarks"
+expandAll = "Expand all bookmarks"
+
[viewer.comments]
addComment = "Add comment"
addCommentPlaceholder = "Add comment..."
@@ -10059,6 +10085,7 @@ clearAll = "Clear all comments"
clearAllDescription = "This removes comments and replies from the sidebar while keeping any attached annotations in the document."
clearAllTitle = "Clear all comments?"
close = "Close comments"
+closeSidebar = "Close comments sidebar"
deleteAnnotationAndComment = "Delete annotation & comment"
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
deleteTitle = "Remove annotation from comments?"
@@ -10090,6 +10117,11 @@ title = "Form Fields"
unsavedBadge = "Unsaved"
unsavedDesc = "You have unsaved changes"
+[viewer.layers]
+closeSidebar = "Close layers sidebar"
+hideAll = "Hide all layers"
+showAll = "Show all layers"
+
[viewer.link]
delete = "Delete link"
@@ -10120,6 +10152,9 @@ resultsOf = "of {{total}}"
[viewer.signature]
delete = "Delete signature"
+[viewer.thumbnails]
+closeSidebar = "Close thumbnails sidebar"
+
[viewPdf]
tags = "view,read,annotate,text,image,highlight,edit"
title = "View/Edit PDF"
@@ -10546,6 +10581,7 @@ loading = "Loading people..."
locked = "locked"
lockedBadge = "Locked"
loginRequired = "Enable login mode first"
+memberActions = "Member actions"
noMembersFound = "No members found"
role = "Role"
searchMembers = "Search members..."
@@ -10555,6 +10591,7 @@ unlockAccount = "Unlock Account"
unlockUserError = "Failed to unlock user account"
unlockUserSuccess = "User account unlocked successfully"
user = "User"
+userInfo = "User info"
[workspace.people.actions]
upgrade = "Upgrade"
@@ -10700,6 +10737,7 @@ removeMemberError = "Failed to remove user from team"
removeMemberSuccess = "User removed from team"
renameTeamLabel = "Rename Team"
system = "System"
+teamActions = "Team actions"
teamName = "Team Name"
teamNotFound = "Team not found"
title = "Teams"
diff --git a/frontend/editor/src/cloud/components/onboarding/renderButtons.tsx b/frontend/editor/src/cloud/components/onboarding/renderButtons.tsx
index f2857af460..7ff108d121 100644
--- a/frontend/editor/src/cloud/components/onboarding/renderButtons.tsx
+++ b/frontend/editor/src/cloud/components/onboarding/renderButtons.tsx
@@ -1,5 +1,7 @@
import React from "react";
-import { Button, Group, ActionIcon } from "@mantine/core";
+import { Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import { TFunction } from "i18next";
import {
@@ -31,22 +33,6 @@ export function renderButtons({
(btn) => btn.group === "right",
);
- const buttonStyles = (variant: ButtonDefinition["variant"]) =>
- variant === "primary"
- ? {
- root: {
- background: "var(--onboarding-primary-button-bg)",
- color: "var(--onboarding-primary-button-text)",
- },
- }
- : {
- root: {
- background: "var(--onboarding-secondary-button-bg)",
- border: "1px solid var(--onboarding-secondary-button-border)",
- color: "var(--onboarding-secondary-button-text)",
- },
- };
-
const resolveButtonLabel = (button: ButtonDefinition) => {
// Translate the label (it's a translation key)
const label = button.label ?? "";
@@ -65,20 +51,15 @@ export function renderButtons({
onAction(button.action)}
- radius="md"
- size={40}
+ size="lg"
+ variant="secondary"
+ accent="neutral"
disabled={disabled}
- styles={{
- root: {
- background: "var(--onboarding-secondary-button-bg)",
- border: "1px solid var(--onboarding-secondary-button-border)",
- color: "var(--onboarding-secondary-button-text)",
- },
- }}
+ aria-label={t("onboarding.buttons.back", "Back")}
>
- {button.icon === "chevron-left" && (
+ {button.icon === "chevron-left" ? (
- )}
+ ) : null}
);
}
@@ -91,7 +72,8 @@ export function renderButtons({
key={button.key}
onClick={() => onAction(button.action)}
disabled={disabled}
- styles={buttonStyles(variant)}
+ variant={variant === "primary" ? "primary" : "secondary"}
+ accent="neutral"
>
{label}
diff --git a/frontend/editor/src/cloud/components/onboarding/slides/TeamSlide.tsx b/frontend/editor/src/cloud/components/onboarding/slides/TeamSlide.tsx
index 03eadf0f4c..a1948f2f06 100644
--- a/frontend/editor/src/cloud/components/onboarding/slides/TeamSlide.tsx
+++ b/frontend/editor/src/cloud/components/onboarding/slides/TeamSlide.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
-import { Badge, Button, TextInput } from "@mantine/core";
+import { Badge, TextInput } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { createLightSlideBackground } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
diff --git a/frontend/editor/src/cloud/components/shared/FreeLimitReachedModal.tsx b/frontend/editor/src/cloud/components/shared/FreeLimitReachedModal.tsx
index 7db30887e7..177a15b7af 100644
--- a/frontend/editor/src/cloud/components/shared/FreeLimitReachedModal.tsx
+++ b/frontend/editor/src/cloud/components/shared/FreeLimitReachedModal.tsx
@@ -1,5 +1,6 @@
import { useMemo } from "react";
-import { Modal, Stack, Button } from "@mantine/core";
+import { Modal, Stack } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import CelebrationIcon from "@mui/icons-material/CelebrationOutlined";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
@@ -169,7 +170,7 @@ export function FreeLimitReachedModal({ onClose }: FreeLimitReachedModalProps) {
>
}
- styles={{
- label: {
- color: "var(--mantine-color-dark-9)",
- },
- }}
>
{t("team.invitationBanner.acceptButton", "Accept")}
- setOpen((o) => !o)}
+ leftSection={ }
>
-
{t("payg.docHelp.toggle", "What counts as a PDF?")}
-
+
{open && (
@@ -446,16 +447,16 @@ function CapReachedHelp() {
const [open, setOpen] = useState(false);
return (
-
setOpen((o) => !o)}
+ leftSection={ }
>
-
{t("payg.gates.title", "What happens when the cap is reached")}
-
+
{open && (
@@ -656,7 +657,7 @@ function StripePortalLink({
onClick={handleClick}
loading={loading}
rightSection={ }
- variant="light"
+ variant="secondary"
>
{t("payg.stripe.open", "Open billing portal")}
diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css
index 17ef96e8b3..6ade6c7b6d 100644
--- a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css
+++ b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css
@@ -217,25 +217,6 @@
border-top: 1px solid var(--payg-divider);
padding-top: 16px;
}
-.paygf-cta__button {
- padding: 13px 22px;
- border: none;
- border-radius: 11px;
- background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%);
- color: white;
- font-weight: 600;
- font-size: 0.95rem;
- font-family: inherit;
- cursor: pointer;
- transition:
- transform 120ms ease,
- box-shadow 120ms ease;
- white-space: nowrap;
-}
-.paygf-cta__button:hover {
- transform: translateY(-1px);
- box-shadow: 0 8px 22px -6px rgba(10, 139, 255, 0.55);
-}
.paygf-cta__reassurance {
margin: 0;
font-size: 0.78rem;
diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.tsx
index 330bffa71e..ae3738bd5f 100644
--- a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.tsx
+++ b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.tsx
@@ -25,6 +25,7 @@
*/
import React, { useState } from "react";
import { Stack } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import BoltIcon from "@mui/icons-material/BoltRounded";
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
import CheckIcon from "@mui/icons-material/CheckRounded";
@@ -171,14 +172,14 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
{isLeader ? (
<>
-
{t("payg.free.cta.button", "Turn on Processor →")}
-
+
{t(
"payg.free.cta.reassurance",
diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css
index 90083fb80e..bc9ce0cef8 100644
--- a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css
+++ b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css
@@ -22,10 +22,10 @@
gap: 14px;
}
[data-mantine-color-scheme="dark"] .scc {
- --scc-accent-text: #66b8ff;
- --scc-accent-soft: rgba(10, 139, 255, 0.16);
- --scc-chip-bg: #272d35;
- --scc-chip-border: #3d444e;
+ --scc-accent-text: #7ab4ff;
+ --scc-accent-soft: rgba(79, 142, 245, 0.16);
+ --scc-chip-bg: #1c2340;
+ --scc-chip-border: #2d3560;
}
/* ── Inline row: presets · custom · no-cap · (save) ──────────────────── */
diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/StripeCheckoutPanel.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/StripeCheckoutPanel.tsx
index 1ffc9f4ee2..03584f11de 100644
--- a/frontend/editor/src/cloud/components/shared/config/configSections/StripeCheckoutPanel.tsx
+++ b/frontend/editor/src/cloud/components/shared/config/configSections/StripeCheckoutPanel.tsx
@@ -64,6 +64,7 @@
*/
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
+import { Button } from "@app/ui/Button";
import { useAuth } from "@app/auth/UseSession";
import {
createCheckoutSession,
@@ -267,17 +268,12 @@ const StripeCheckoutPanel: React.FC = ({
)}
-
+
{t(
"payg.checkout.mock.continue",
"Continue with mock subscription",
)}
-
+
);
diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx
index 6755a4d746..44fc59c738 100644
--- a/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx
+++ b/frontend/editor/src/cloud/components/shared/config/configSections/TeamSection.tsx
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from "react";
import {
- Button,
TextInput,
Group,
Text,
@@ -8,9 +7,10 @@ import {
Alert,
Table,
Badge,
- ActionIcon,
Menu,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import { useSaaSTeam } from "@app/contexts/SaaSTeamContext";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -233,19 +233,18 @@ const TeamSection: React.FC = () => {
}}
/>
@@ -257,7 +256,7 @@ const TeamSection: React.FC = () => {
{isTeamLeader && !isPersonalTeam && (
{
{!isPersonalTeam && !isTeamLeader && !isEditingName && (
@@ -449,7 +448,13 @@ const TeamSection: React.FC = () => {
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
-
+
{
{isTeamLeader && !isPersonalTeam && (
handleCancelInvitation(
invitation.invitationId,
diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.css b/frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.css
index 2e7aafef5c..c6da318f0f 100644
--- a/frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.css
+++ b/frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.css
@@ -17,10 +17,10 @@
--upm-muted: var(--text-muted);
}
[data-mantine-color-scheme="dark"] .upm {
- --upm-card-bg: #313842;
- --upm-border: #3d444e;
- --upm-divider: #3d444e;
- --upm-accent-soft: rgba(10, 139, 255, 0.14);
+ --upm-card-bg: #1c2340;
+ --upm-border: #2d3560;
+ --upm-divider: #2d3560;
+ --upm-accent-soft: rgba(79, 142, 245, 0.14);
}
/* Backdrop locks the page and centres the modal. z-index matches
@@ -101,26 +101,6 @@
color: var(--upm-text);
margin: 0;
}
-.upm-header__close {
- background: transparent;
- border: none;
- color: var(--upm-muted);
- cursor: pointer;
- width: 32px;
- height: 32px;
- border-radius: 8px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- transition:
- background 120ms ease,
- color 120ms ease;
-}
-.upm-header__close:hover {
- background: var(--upm-divider);
- color: var(--upm-text);
-}
-
/* Left cluster: optional back arrow + title. The back arrow only renders on the
checkout step (cap/confirm have no parent step to return to) — it replaces the
old footer "← Back" button. */
@@ -130,28 +110,6 @@
gap: 6px;
min-width: 0;
}
-.upm-header__back {
- background: transparent;
- border: none;
- color: var(--upm-muted);
- cursor: pointer;
- width: 32px;
- height: 32px;
- border-radius: 8px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- margin-left: -6px;
- transition:
- background 120ms ease,
- color 120ms ease;
-}
-.upm-header__back:hover {
- background: var(--upm-divider);
- color: var(--upm-text);
-}
-
.upm-steps {
display: flex;
align-items: center;
@@ -357,41 +315,6 @@
display: flex;
gap: 8px;
}
-.upm-btn {
- padding: 10px 18px;
- border-radius: 10px;
- border: 1.5px solid transparent;
- font-weight: 600;
- font-size: 0.9rem;
- cursor: pointer;
- transition: all 120ms ease;
- font-family: inherit;
- white-space: nowrap;
-}
-.upm-btn[data-variant="ghost"] {
- background: transparent;
- border-color: var(--upm-divider);
- color: var(--upm-text);
-}
-.upm-btn[data-variant="ghost"]:hover {
- border-color: var(--upm-accent);
- color: var(--upm-accent);
-}
-.upm-btn[data-variant="primary"] {
- background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2));
- color: white;
-}
-.upm-btn[data-variant="primary"]:hover {
- transform: translateY(-1px);
- box-shadow: 0 6px 18px -6px rgba(10, 139, 255, 0.5);
-}
-.upm-btn[disabled] {
- opacity: 0.5;
- cursor: not-allowed;
- transform: none !important;
- box-shadow: none !important;
-}
-
/* ── Step 3: confirmation ─────────────────────────────────────────────── */
.upm-confirm {
diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.tsx
index 0ca290611b..bdbd84c6bc 100644
--- a/frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.tsx
+++ b/frontend/editor/src/cloud/components/shared/config/configSections/UpgradeModal.tsx
@@ -18,6 +18,8 @@
*/
import React, { Suspense, useEffect, useState } from "react";
import { createPortal } from "react-dom";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import CloseIcon from "@mui/icons-material/CloseRounded";
import ArrowBackIcon from "@mui/icons-material/ArrowBackRounded";
import ShieldIcon from "@mui/icons-material/ShieldOutlined";
@@ -152,14 +154,14 @@ export default function UpgradeModal({
{/* Step indicator. Hidden on the confirmation panel since the
@@ -198,13 +199,13 @@ export default function UpgradeModal({
"{{symbol}}{{amount}} / month",
{ symbol: sym, amount: effectiveCap },
)}
-
{t("payg.upgrade.checkout.edit", "Edit")}
-
+
) : (
@@ -264,36 +265,23 @@ export default function UpgradeModal({
{step === "cap" && (
<>
-
+
{t("payg.upgrade.button.cancel", "Cancel")}
-
-
+
+
{t("payg.upgrade.button.continue", "Continue →")}
-
+
>
)}
{step === "confirm" && (
- {
setStep("cap");
onComplete({ capUsd: effectiveCap });
}}
>
{t("payg.upgrade.button.finish", "Finish")}
-
+
)}
diff --git a/frontend/editor/src/core/assets/login/github.svg b/frontend/editor/src/core/assets/login/github.svg
index 651eaac2b8..1174b67928 100644
--- a/frontend/editor/src/core/assets/login/github.svg
+++ b/frontend/editor/src/core/assets/login/github.svg
@@ -1,3 +1,3 @@
-
+
diff --git a/frontend/editor/src/core/components/StorageStatsCard.tsx b/frontend/editor/src/core/components/StorageStatsCard.tsx
index 468d24fe37..186b56c4ca 100644
--- a/frontend/editor/src/core/components/StorageStatsCard.tsx
+++ b/frontend/editor/src/core/components/StorageStatsCard.tsx
@@ -1,5 +1,6 @@
import React from "react";
-import { Card, Group, Text, Button, Progress } from "@mantine/core";
+import { Card, Group, Text, Progress } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import StorageIcon from "@mui/icons-material/Storage";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -58,21 +59,16 @@ const StorageStatsCard: React.FC = ({
{filesCount > 0 && (
}
>
{t("fileManager.clearAll", "Clear All")}
)}
-
+
Reload Files
diff --git a/frontend/editor/src/core/components/annotation/shared/ColorControl.tsx b/frontend/editor/src/core/components/annotation/shared/ColorControl.tsx
index ca41f0f6a2..524314ee62 100644
--- a/frontend/editor/src/core/components/annotation/shared/ColorControl.tsx
+++ b/frontend/editor/src/core/components/annotation/shared/ColorControl.tsx
@@ -1,5 +1,4 @@
import {
- ActionIcon,
Tooltip,
Popover,
Stack,
@@ -10,6 +9,7 @@ import {
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import ColorizeIcon from "@mui/icons-material/Colorize";
+import { ActionIcon } from "@app/ui/ActionIcon";
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
// the button is hidden in the UI if the API is not supported.
@@ -66,24 +66,12 @@ export function ColorControl({
setOpened(!opened)}
disabled={disabled}
- styles={{
- root: {
- flexShrink: 0,
- backgroundColor: "var(--bg-raised)",
- border: "1px solid var(--border-default)",
- color: "var(--text-secondary)",
- "&:hover": {
- backgroundColor: "var(--hover-bg)",
- borderColor: "var(--border-strong)",
- color: "var(--text-primary)",
- },
- },
- }}
>
@@ -117,11 +105,14 @@ export function ColorControl({
label={t("color.eyeDropper.tooltip", "Pick colour from screen")}
>
diff --git a/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx
index e12db7f340..ec854426e6 100644
--- a/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx
+++ b/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx
@@ -4,13 +4,12 @@ import {
Stack,
ColorPicker as MantineColorPicker,
Group,
- Button,
ColorSwatch,
Slider,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
-
+import { Button } from "@app/ui/Button";
interface ColorPickerProps {
isOpen: boolean;
onClose: () => void;
diff --git a/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx
index 08808b5b61..c10d9d9023 100644
--- a/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx
+++ b/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
-import { Paper, Button, Modal, Stack, Text, Group } from "@mantine/core";
+import { Paper, Modal, Stack, Text, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
+import { Button } from "@app/ui/Button";
import { ColorSwatchButton } from "@app/components/annotation/shared/ColorPicker";
import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector";
import SignaturePad from "signature_pad";
@@ -331,7 +332,7 @@ export const DrawingCanvas: React.FC = ({
-
+
{t("sign.canvas.clear", "Clear canvas")}
{t("common.done", "Done")}
diff --git a/frontend/editor/src/core/components/annotation/shared/DrawingControls.tsx b/frontend/editor/src/core/components/annotation/shared/DrawingControls.tsx
index 7118a817e8..0844125117 100644
--- a/frontend/editor/src/core/components/annotation/shared/DrawingControls.tsx
+++ b/frontend/editor/src/core/components/annotation/shared/DrawingControls.tsx
@@ -1,7 +1,9 @@
import React from "react";
-import { Group, Button, ActionIcon, Tooltip } from "@mantine/core";
+import { Group, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { LocalIcon } from "@app/components/shared/LocalIcon";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
interface DrawingControlsProps {
onUndo?: () => void;
@@ -37,12 +39,11 @@ export const DrawingControls: React.FC = ({
{onUndo && (
= ({
{onRedo && (
= ({
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
{placeButtonText}
diff --git a/frontend/editor/src/core/components/annotation/shared/OpacityControl.tsx b/frontend/editor/src/core/components/annotation/shared/OpacityControl.tsx
index 496b5c4070..3853fa32c2 100644
--- a/frontend/editor/src/core/components/annotation/shared/OpacityControl.tsx
+++ b/frontend/editor/src/core/components/annotation/shared/OpacityControl.tsx
@@ -1,14 +1,8 @@
-import {
- ActionIcon,
- Tooltip,
- Popover,
- Stack,
- Slider,
- Text,
-} from "@mantine/core";
+import { Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import OpacityIcon from "@mui/icons-material/Opacity";
+import { ActionIcon } from "@app/ui/ActionIcon";
interface OpacityControlProps {
value: number; // 0-100
@@ -29,24 +23,12 @@ export function OpacityControl({
setOpened(!opened)}
disabled={disabled}
- styles={{
- root: {
- flexShrink: 0,
- backgroundColor: "var(--bg-raised)",
- border: "1px solid var(--border-default)",
- color: "var(--text-secondary)",
- "&:hover": {
- backgroundColor: "var(--hover-bg)",
- borderColor: "var(--border-strong)",
- color: "var(--text-primary)",
- },
- },
- }}
>
diff --git a/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.tsx b/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.tsx
index 5f23b81ede..2f2a42b982 100644
--- a/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.tsx
+++ b/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.tsx
@@ -1,14 +1,7 @@
-import {
- ActionIcon,
- Tooltip,
- Popover,
- Stack,
- Slider,
- Text,
- Group,
- Button,
-} from "@mantine/core";
+import { Tooltip, Popover, Stack, Slider, Text, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useState } from "react";
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
import type { PdfAnnotationObject } from "@embedpdf/models";
@@ -106,21 +99,24 @@ export function PropertiesPopover({
onUpdate({ textAlign: 0 })}
size="md"
>
onUpdate({ textAlign: 1 })}
size="md"
>
onUpdate({ textAlign: 2 })}
size="md"
>
@@ -176,8 +172,8 @@ export function PropertiesPopover({
/>
{
const newValue = borderVisible ? 0 : 1;
onUpdate({
@@ -201,24 +197,12 @@ export function PropertiesPopover({
setOpened(!opened)}
disabled={disabled}
- styles={{
- root: {
- flexShrink: 0,
- backgroundColor: "var(--bg-raised)",
- border: "1px solid var(--border-default)",
- color: "var(--text-secondary)",
- "&:hover": {
- backgroundColor: "var(--hover-bg)",
- borderColor: "var(--border-strong)",
- color: "var(--text-primary)",
- },
- },
- }}
>
diff --git a/frontend/editor/src/core/components/annotation/shared/TextInputWithFont.tsx b/frontend/editor/src/core/components/annotation/shared/TextInputWithFont.tsx
index a1a07a95b7..60905d3065 100644
--- a/frontend/editor/src/core/components/annotation/shared/TextInputWithFont.tsx
+++ b/frontend/editor/src/core/components/annotation/shared/TextInputWithFont.tsx
@@ -7,8 +7,8 @@ import {
useCombobox,
Group,
Box,
- SegmentedControl,
} from "@mantine/core";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
@@ -253,14 +253,14 @@ export const TextInputWithFont: React.FC = ({
{/* Text Alignment */}
{onTextAlignChange && (
-
value={textAlign}
- onChange={(value: string) => {
- onTextAlignChange(value as "left" | "center" | "right");
+ onChange={(value) => {
+ onTextAlignChange(value);
onAnyChange?.();
}}
- disabled={disabled}
- data={[
+ loading={disabled}
+ options={[
{ label: t("textAlign.left", "Left"), value: "left" },
{ label: t("textAlign.center", "Center"), value: "center" },
{ label: t("textAlign.right", "Right"), value: "right" },
diff --git a/frontend/editor/src/core/components/annotation/shared/WidthControl.tsx b/frontend/editor/src/core/components/annotation/shared/WidthControl.tsx
index d4f063f604..b7e9d28531 100644
--- a/frontend/editor/src/core/components/annotation/shared/WidthControl.tsx
+++ b/frontend/editor/src/core/components/annotation/shared/WidthControl.tsx
@@ -1,14 +1,8 @@
-import {
- ActionIcon,
- Tooltip,
- Popover,
- Stack,
- Slider,
- Text,
-} from "@mantine/core";
+import { Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import LineWeightIcon from "@mui/icons-material/LineWeight";
+import { ActionIcon } from "@app/ui/ActionIcon";
interface WidthControlProps {
value: number;
@@ -33,24 +27,12 @@ export function WidthControl({
setOpened(!opened)}
disabled={disabled}
- styles={{
- root: {
- flexShrink: 0,
- backgroundColor: "var(--bg-raised)",
- border: "1px solid var(--border-default)",
- color: "var(--text-secondary)",
- "&:hover": {
- backgroundColor: "var(--hover-bg)",
- borderColor: "var(--border-strong)",
- color: "var(--text-primary)",
- },
- },
- }}
>
diff --git a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx
index 00c9e4b396..bf19c16967 100644
--- a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx
+++ b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx
@@ -1,5 +1,6 @@
import React, { useRef, useState } from "react";
-import { Button, Group } from "@mantine/core";
+import { Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -106,6 +107,7 @@ const AddFileCard = ({
>
{!isUploadHover && (
)}
{
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
@@ -606,17 +603,13 @@ const FileEditorThumbnail = ({
{file.name}
-
+
{t("confirmCloseCancel", "Cancel")}
-
+
{t("confirmCloseDiscard", "Discard changes and close")}
-
+
{t("confirmCloseSave", "Save and close")}
@@ -633,14 +626,10 @@ const FileEditorThumbnail = ({
{file.name}
-
+
{t("confirmCloseCancel", "Cancel")}
-
+
{t("confirmCloseConfirm", "Close File")}
diff --git a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx
index 09ca9a10b1..11657cc288 100644
--- a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx
+++ b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx
@@ -1,5 +1,7 @@
import React from "react";
-import { Stack, Box, Text, Button, ActionIcon, Center } from "@mantine/core";
+import { Stack, Box, Text, Center } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
@@ -128,18 +130,20 @@ const CompactFileDetails: React.FC = ({
{hasMultipleFiles && (
@@ -150,16 +154,10 @@ const CompactFileDetails: React.FC = ({
{/* Action Button */}
{canCloseAll
? t("fileManager.closeAllFiles", "Close all files")
diff --git a/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx b/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx
index 422dd8306e..95cbff8b9b 100644
--- a/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx
+++ b/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx
@@ -1,5 +1,6 @@
import React, { useState } from "react";
-import { Button, Group, Text, Stack } from "@mantine/core";
+import { Group, Text, Stack } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import HistoryIcon from "@mui/icons-material/History";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
@@ -77,6 +78,7 @@ const EmptyFilesState: React.FC = () => {
onMouseLeave={() => setIsUploadHover(false)}
>
{
}
>
{showStorageFilter && (
onStorageFilterChange(
value as "all" | "local" | "sharedWithMe" | "sharedByMe",
)
}
- data={storageFilterOptions}
+ options={storageFilterOptions}
/>
)}
@@ -177,12 +176,11 @@ const FileActions: React.FC = () => {
{uploadEnabled && (
setShowBulkUploadModal(true)}
disabled={!canBulkUpload}
- radius="sm"
+ aria-label={t("fileManager.uploadSelected", "Upload Selected")}
>
@@ -191,12 +189,11 @@ const FileActions: React.FC = () => {
{shareLinksEnabled && (
setShowBulkShareModal(true)}
disabled={!canBulkShare}
- radius="sm"
+ aria-label={t("fileManager.shareSelected", "Share Selected")}
>
@@ -204,12 +201,12 @@ const FileActions: React.FC = () => {
)}
@@ -217,12 +214,11 @@ const FileActions: React.FC = () => {
diff --git a/frontend/editor/src/core/components/fileManager/FileDetails.tsx b/frontend/editor/src/core/components/fileManager/FileDetails.tsx
index 4621559ae8..3fd116b75e 100644
--- a/frontend/editor/src/core/components/fileManager/FileDetails.tsx
+++ b/frontend/editor/src/core/components/fileManager/FileDetails.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
-import { Stack, Button, Box } from "@mantine/core";
+import { Stack, Box } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
@@ -108,16 +109,10 @@ const FileDetails: React.FC = ({ compact = false }) => {
{canCloseAll
? t("fileManager.closeAllFiles", "Close all files")
diff --git a/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx b/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx
index 594442aefb..f43df3d86c 100644
--- a/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx
+++ b/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx
@@ -8,8 +8,8 @@ import {
Group,
Divider,
ScrollArea,
- Button,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { detectFileExtension, getFileSize } from "@app/utils/fileUtils";
import { StirlingFileStub } from "@app/types/fileContext";
@@ -208,7 +208,7 @@ const FileInfoCard: React.FC = ({
onMakeCopy(currentFile)}
fullWidth
>
@@ -260,7 +260,7 @@ const FileInfoCard: React.FC = ({
setShowShareManageModal(true)}
fullWidth
>
diff --git a/frontend/editor/src/core/components/fileManager/FileListItem.tsx b/frontend/editor/src/core/components/fileManager/FileListItem.tsx
index ae22ced51f..ba1fe52bc9 100644
--- a/frontend/editor/src/core/components/fileManager/FileListItem.tsx
+++ b/frontend/editor/src/core/components/fileManager/FileListItem.tsx
@@ -3,12 +3,12 @@ import {
Group,
Box,
Text,
- ActionIcon,
Checkbox,
Divider,
Menu,
Badge,
} from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
@@ -323,10 +323,11 @@ const FileListItem: React.FC = ({
>
e.stopPropagation()}
+ aria-label={t("fileManager.moreOptions", "More options")}
style={{
opacity: shouldShowHovered ? 1 : 0,
transform: shouldShowHovered ? "scale(1)" : "scale(0.8)",
diff --git a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx
index 6905f1bc75..6ac3e3c91e 100644
--- a/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx
+++ b/frontend/editor/src/core/components/fileManager/FileSourceButtons.tsx
@@ -1,5 +1,6 @@
import React, { useState } from "react";
-import { Stack, Text, Button, Group } from "@mantine/core";
+import { Stack, Text, Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import HistoryIcon from "@mui/icons-material/History";
import PhonelinkIcon from "@mui/icons-material/Phonelink";
import { useTranslation } from "react-i18next";
@@ -65,86 +66,45 @@ const FileSourceButtons: React.FC = ({
// Determine visibility of Mobile QR Scanner button
const shouldHideMobileQR =
!isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
-
- const buttonProps = {
- variant: (source: string) =>
- activeSource === source ? "filled" : "subtle",
- getColor: (source: string) =>
- activeSource === source ? "var(--mantine-color-gray-2)" : undefined,
- getStyles: (source: string) => ({
- root: {
- backgroundColor: activeSource === source ? undefined : "transparent",
- color:
- activeSource === source
- ? "var(--mantine-color-gray-9)"
- : "var(--mantine-color-gray-6)",
- border: "none",
- "&:hover": {
- backgroundColor:
- activeSource === source ? undefined : "var(--mantine-color-gray-0)",
- },
- },
- }),
- };
-
+ // Shared Button has no `xs`; map the old horizontal `xs` to `sm`.
+ const buttonSize = "sm" as const;
+ const buttonJustify = horizontal ? "center" : "start";
const buttons = (
<>
}
- justify={horizontal ? "center" : "flex-start"}
+ justify={buttonJustify}
onClick={() => onSourceChange("recent")}
fullWidth={!horizontal}
- size={horizontal ? "xs" : "sm"}
- color={buttonProps.getColor("recent")}
- styles={buttonProps.getStyles("recent")}
+ size={buttonSize}
>
- {horizontal
- ? t("fileManager.recent", "Recent")
- : t("fileManager.recent", "Recent")}
+ {t("fileManager.recent", "Recent")}
}
- justify={horizontal ? "center" : "flex-start"}
+ justify={buttonJustify}
onClick={onLocalFileClick}
fullWidth={!horizontal}
- size={horizontal ? "xs" : "sm"}
- styles={{
- root: {
- backgroundColor: "transparent",
- border: "none",
- "&:hover": {
- backgroundColor: "var(--mantine-color-gray-0)",
- },
- },
- }}
+ size={buttonSize}
>
{horizontal ? terminology.upload : terminology.uploadFiles}
{!shouldHideGoogleDrive && (
}
- justify={horizontal ? "center" : "flex-start"}
+ justify={buttonJustify}
onClick={handleGoogleDriveClick}
fullWidth={!horizontal}
- size={horizontal ? "xs" : "sm"}
+ size={buttonSize}
disabled={!isGoogleDriveEnabled}
- styles={{
- root: {
- backgroundColor: "transparent",
- border: "none",
- "&:hover": {
- backgroundColor: isGoogleDriveEnabled
- ? "var(--mantine-color-gray-0)"
- : "transparent",
- },
- },
- }}
title={
!isGoogleDriveEnabled
? t(
@@ -162,25 +122,14 @@ const FileSourceButtons: React.FC = ({
{!shouldHideMobileQR && (
}
- justify={horizontal ? "center" : "flex-start"}
+ justify={buttonJustify}
onClick={handleMobileUploadClick}
fullWidth={!horizontal}
- size={horizontal ? "xs" : "sm"}
+ size={buttonSize}
disabled={!isMobileUploadEnabled}
- styles={{
- root: {
- backgroundColor: "transparent",
- border: "none",
- "&:hover": {
- backgroundColor: isMobileUploadEnabled
- ? "var(--mantine-color-gray-0)"
- : "transparent",
- },
- },
- }}
title={
!isMobileUploadEnabled
? t(
diff --git a/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.tsx b/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.tsx
index 2eb57716f2..0261cf58dd 100644
--- a/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.tsx
+++ b/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.tsx
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
-import { Alert, Button, Group, Modal, Radio, Stack, Text } from "@mantine/core";
+import { Alert, Group, Modal, Radio, Stack, Text } from "@mantine/core";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
+import { Button } from "@app/ui/Button";
+
import type { StirlingFileStub } from "@app/types/fileContext";
import type { DeleteScope } from "@app/services/serverStorageDelete";
@@ -164,11 +166,11 @@ export function DeleteFilesDialog({
)}
-
+
{t("filesPage.cancel", "Cancel")}
runConfirm(showChoice ? scope : fixedScope)}
>
diff --git a/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx
index e2d7edc438..08811f0053 100644
--- a/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx
+++ b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.tsx
@@ -1,16 +1,9 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
-import {
- Alert,
- Button,
- Checkbox,
- Group,
- Modal,
- Stack,
- Text,
-} from "@mantine/core";
+import { Alert, Checkbox, Group, Modal, Stack, Text } from "@mantine/core";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
+import { Button } from "@app/ui/Button";
import { FolderRecord } from "@app/types/folder";
interface DeleteFolderDialogProps {
@@ -95,11 +88,11 @@ export function DeleteFolderDialog({
)}
-
+
{t("filesPage.cancel", "Cancel")}
{
setSubmitting(true);
diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx
index aaab5d3b08..e789167038 100644
--- a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx
+++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.tsx
@@ -1,6 +1,8 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
-import { ActionIcon, Badge, Button, Tooltip } from "@mantine/core";
+import { Badge, Tooltip } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import CloseIcon from "@mui/icons-material/Close";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove";
@@ -143,7 +145,12 @@ export function FileDetailsPanel({
label={t("filesPage.closeDetails", "Close details")}
withinPortal
>
-
+
@@ -181,25 +188,27 @@ export function FileDetailsPanel({
{ext}
)}
{(single.versionNumber ?? 1) > 1 && (
-
+
v{single.versionNumber}
)}
- setFieldsOpen((o) => !o)}
aria-expanded={fieldsOpen}
+ rightSection={
+
+ }
>
{t("filesPage.fileInfo", "File info")}
-
-
+
{fieldsOpen && (
}
- variant="default"
+ variant="secondary"
onClick={onOpenVersionHistory}
>
{t(
@@ -291,7 +300,6 @@ export function FileDetailsPanel({
}
- variant="filled"
onClick={() => onAddToWorkspace(selectedFileIds)}
>
{files.length === 1
@@ -302,7 +310,7 @@ export function FileDetailsPanel({
}
- variant="default"
+ variant="secondary"
onClick={handleDownload}
loading={downloading}
>
@@ -329,14 +337,12 @@ export function FileDetailsPanel({
>
}
- variant="default"
+ variant="secondary"
disabled={!sharingEnabled}
onClick={() => setShareModalOpen(true)}
- styles={{
- root: {
- // Keep tooltip hoverable while button is disabled.
- pointerEvents: sharingEnabled ? undefined : "auto",
- },
+ style={{
+ // Keep tooltip hoverable while button is disabled.
+ pointerEvents: sharingEnabled ? undefined : "auto",
}}
>
{t("filesPage.shareManage", "Manage sharing")}
@@ -345,7 +351,7 @@ export function FileDetailsPanel({
)}
}
- variant="default"
+ variant="secondary"
onClick={() => onMove(selectedFileIds)}
>
{t("filesPage.moveTo", "Move to…")}
@@ -363,16 +369,12 @@ export function FileDetailsPanel({
>
}
- variant="default"
+ variant="secondary"
disabled={Boolean(saveToServerDisabledReason)}
onClick={() => onSaveToServer(localOnlyFiles)}
- styles={{
- root: {
- // Keep tooltip hoverable while button is disabled.
- pointerEvents: saveToServerDisabledReason
- ? "auto"
- : undefined,
- },
+ style={{
+ // Keep tooltip hoverable while button is disabled.
+ pointerEvents: saveToServerDisabledReason ? "auto" : undefined,
}}
>
{t("filesPage.saveToServer", "Save to server")}
@@ -381,8 +383,7 @@ export function FileDetailsPanel({
)}
}
- color="red"
- variant="light"
+ accent="danger"
onClick={() => onRemove(selectedFileIds)}
>
{t("filesPage.remove", "Delete")}
diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx
index 01e9b9355a..4d18fccc80 100644
--- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx
+++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx
@@ -1,6 +1,8 @@
import React, { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
-import { ActionIcon, Button, Checkbox, Menu, Tooltip } from "@mantine/core";
+import { Checkbox, Menu, Tooltip } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import FolderIcon from "@mui/icons-material/Folder";
@@ -297,10 +299,10 @@ function EmptyState({
}
disabled
- styles={{ root: { pointerEvents: "auto" } }}
+ style={{ pointerEvents: "auto" }}
>
{t("filesPage.empty.newFolderCta", "Create folder")}
@@ -309,7 +311,7 @@ function EmptyState({
) : (
}
onClick={onCreateFolder}
>
@@ -528,8 +530,6 @@ function FolderCard({
e.stopPropagation()}
aria-label={t("filesPage.folderMenu", "Folder actions")}
@@ -771,8 +771,6 @@ function FileCard({
e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
@@ -1151,7 +1149,7 @@ function FolderRow({
e.stopPropagation()}
aria-label={t("filesPage.folderMenu", "Folder actions")}
@@ -1370,7 +1368,7 @@ function FileRow({
e.stopPropagation()}
aria-label={t("filesPage.fileMenu", "File actions")}
diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx
index f88e6b7945..002d81ca00 100644
--- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx
+++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx
@@ -7,19 +7,12 @@ import React, {
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
-import {
- ActionIcon,
- Button,
- Drawer,
- Group,
- MultiSelect,
- SegmentedControl,
- Select,
- Tooltip,
-} from "@mantine/core";
+import { Drawer, Group, MultiSelect, Select, Tooltip } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useMediaQuery } from "@mantine/hooks";
import SearchIcon from "@mui/icons-material/Search";
-import CloseIcon from "@mui/icons-material/Close";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import QrCode2Icon from "@mui/icons-material/QrCode2";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
@@ -910,8 +903,8 @@ export default function FileManagerView() {
withinPortal
>
}
disabled
- styles={{ root: { pointerEvents: "auto" } }}
+ style={{ pointerEvents: "auto" }}
>
{t("filesPage.newFolder", "New folder")}
@@ -942,7 +935,7 @@ export default function FileManagerView() {
) : (
}
onClick={() => openNewFolderDialog()}
@@ -966,9 +959,8 @@ export default function FileManagerView() {
withinPortal
>
setMobileUploadModalOpen(true)}
aria-label={t(
"filesPage.uploadFromMobile",
@@ -1012,11 +1004,11 @@ export default function FileManagerView() {
{folders.error}
folders.setError(null)}
>
-
+ ×
)}
@@ -1147,8 +1139,8 @@ export default function FileManagerView() {
w={280}
>
{
if (allSelected) {
setSelectedFileIds(new Set());
@@ -1210,19 +1202,17 @@ export default function FileManagerView() {
>
}
disabled={Boolean(saveToServerDisabledReason)}
onClick={() =>
setSaveToServerTarget(localOnlySelectedStubs)
}
- styles={{
- root: {
- // Keep the tooltip hoverable while disabled.
- pointerEvents: saveToServerDisabledReason
- ? "auto"
- : undefined,
- },
+ style={{
+ // Keep the tooltip hoverable while disabled.
+ pointerEvents: saveToServerDisabledReason
+ ? "auto"
+ : undefined,
}}
aria-label={t(
"filesPage.saveToServer",
@@ -1242,7 +1232,7 @@ export default function FileManagerView() {
>
}
@@ -1259,7 +1249,7 @@ export default function FileManagerView() {
}
onClick={() => promptMoveFiles(selectedFiles)}
aria-label={moveLabel}
@@ -1270,8 +1260,8 @@ export default function FileManagerView() {
}
onClick={() => handleRemoveFiles(selectedFiles)}
aria-label={removeLabel}
@@ -1284,7 +1274,7 @@ export default function FileManagerView() {
withinPortal
>
clearSelection()}
aria-label={t(
@@ -1292,7 +1282,7 @@ export default function FileManagerView() {
"Clear selection",
)}
>
-
+ ×
@@ -1388,7 +1378,7 @@ export default function FileManagerView() {
/>
{
// Mantine only emits values declared in `data[].value`, but
@@ -1401,7 +1391,7 @@ export default function FileManagerView() {
setViewMode(v as (typeof FILES_PAGE_VIEW_MODES)[number]);
}}
aria-label={t("filesPage.viewMode.label", "View mode")}
- data={[
+ options={[
{
value: "grid",
label: (
@@ -1686,12 +1676,12 @@ const SearchField = React.forwardRef<
/>
{value && (
onChange("")}
aria-label={t("filesPage.clearSearch", "Clear search")}
>
-
+ ×
)}
@@ -1712,8 +1702,8 @@ function Breadcrumbs() {
const isLast = idx === trail.length - 1;
return (
- folders.setCurrentFolderId(entry.id)}
onDragOver={(e) => {
@@ -1772,7 +1762,7 @@ function Breadcrumbs() {
}}
>
{entry.name}
-
+
{!isLast && (
{FOLDER_COLOR_PALETTE.map((c) => (
-
- {
@@ -172,7 +173,7 @@ function IconButton({
}}
>
{icon.glyph || "-"}
-
+
);
}
diff --git a/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx b/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx
index 4197d652ed..62858076cd 100644
--- a/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx
+++ b/frontend/editor/src/core/components/filesPage/FolderNameDialog.tsx
@@ -1,8 +1,10 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
-import { Alert, Button, Group, Modal, Stack, TextInput } from "@mantine/core";
+import { Alert, Group, Modal, Stack, TextInput } from "@mantine/core";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
+import { Button } from "@app/ui/Button";
+
interface FolderNameDialogProps {
opened: boolean;
title: string;
@@ -94,7 +96,7 @@ export function FolderNameDialog({
)}
-
+
{t("filesPage.folderName.cancel", "Cancel")}
-
+ ×
) : (
}
onClick={() => {
setCreatingFolder(true);
setNewFolderName("");
}}
- styles={{ root: { alignSelf: "flex-start" } }}
+ style={{ alignSelf: "flex-start" }}
data-testid="move-dialog-create-folder-toggle"
>
{t(
@@ -272,7 +270,7 @@ export function MoveToFolderDialog({
)}
-
+
{t("filesPage.moveDialog.cancel", "Cancel")}
+ ) : isActive ? (
+
+ ) : (
+
+ )
+ }
style={{
- all: "unset",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.45 : 1,
- display: "flex",
- alignItems: "center",
gap: "0.5rem",
padding: `0.4rem 0.75rem 0.4rem ${0.75 + depth * 0.85}rem`,
- width: "100%",
background: isActive ? "var(--hover-bg)" : "transparent",
borderBottom: "1px solid var(--border-subtle)",
boxSizing: "border-box",
fontWeight: isActive ? 600 : 400,
}}
>
- {isRoot ? (
-
- ) : isActive ? (
-
- ) : (
-
- )}
{label}
-
+
);
}
diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx
index 937db8c384..0b6a75878b 100644
--- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx
+++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx
@@ -1,6 +1,8 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
-import { ActionIcon, Badge, Menu } from "@mantine/core";
+import { Badge, Menu } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
@@ -144,8 +146,8 @@ export function VersionTimeline({
)}
- setShowAllCollapsed(true)}
>
@@ -154,7 +156,7 @@ export function VersionTimeline({
"Show {{count}} earlier versions",
{ count: row.hidden },
)}
-
+
);
}
@@ -181,24 +183,31 @@ export function VersionTimeline({
)}
-
toggleExpand(v.id)}
aria-expanded={isExpanded}
+ leftSection={
+
+ v{v.versionNumber ?? 1}
+
+ }
+ rightSection={
+
+ }
>
-
- v{v.versionNumber ?? 1}
-
{delta ? (
-
- +
-
) : (
@@ -206,14 +215,7 @@ export function VersionTimeline({
{t("filesPage.versionOrigin", "Original upload")}
)}
-
-
-
+
{formatFileSize(v.size)}
{v.lastModified ? (
@@ -230,7 +232,7 @@ export function VersionTimeline({
{collapsible && showAllCollapsed && (
- setShowAllCollapsed(false)}
>
{t("filesPage.versionCollapse", "Collapse middle versions")}
-
+
)}
);
diff --git a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx
index fc7914db55..86a087f325 100644
--- a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx
+++ b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx
@@ -1,5 +1,7 @@
import React from "react";
-import { Button, Group, ActionIcon } from "@mantine/core";
+import { Group } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
+import { Button } from "@app/ui/Button";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import { useTranslation } from "react-i18next";
import {
@@ -33,22 +35,6 @@ export function SlideButtons({
(btn) => btn.group === "right",
);
- const buttonStyles = (variant: ButtonDefinition["variant"]) =>
- variant === "primary"
- ? {
- root: {
- background: "var(--onboarding-primary-button-bg)",
- color: "var(--onboarding-primary-button-text)",
- },
- }
- : {
- root: {
- background: "var(--onboarding-secondary-button-bg)",
- border: "1px solid var(--onboarding-secondary-button-border)",
- color: "var(--onboarding-secondary-button-text)",
- },
- };
-
const resolveButtonLabel = (button: ButtonDefinition) => {
// Special case: override "See Plans" with "Upgrade now" when over limit
if (
@@ -77,20 +63,14 @@ export function SlideButtons({
onAction(button.action)}
- radius="md"
- size={40}
+ variant="secondary"
+ accent="neutral"
disabled={disabled}
- styles={{
- root: {
- background: "var(--onboarding-secondary-button-bg)",
- border: "1px solid var(--onboarding-secondary-button-border)",
- color: "var(--onboarding-secondary-button-text)",
- },
- }}
+ aria-label={t("onboarding.buttons.back", "Back")}
>
- {button.icon === "chevron-left" && (
+ {button.icon === "chevron-left" ? (
- )}
+ ) : null}
);
}
@@ -103,7 +83,10 @@ export function SlideButtons({
key={button.key}
onClick={() => onAction(button.action)}
disabled={disabled}
- styles={buttonStyles(variant)}
+ variant={variant === "primary" ? "primary" : "secondary"}
+ accent={
+ button.accent ?? (variant === "primary" ? "default" : "neutral")
+ }
>
{label}
diff --git a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx
index b1bc731d52..b8135739bd 100644
--- a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx
+++ b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx
@@ -6,9 +6,10 @@
*/
import React from "react";
-import { Modal, Stack, ActionIcon } from "@mantine/core";
+import { Modal, Stack } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { ActionIcon } from "@app/ui/ActionIcon";
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
-import CloseIcon from "@mui/icons-material/Close";
import type {
SlideDefinition,
@@ -45,6 +46,7 @@ export default function OnboardingModalSlide({
onAction,
allowDismiss = true,
}: OnboardingModalSlideProps) {
+ const { t } = useTranslation();
const renderHero = () => {
if (slideDefinition.hero.type === "dual-icon") {
return (
@@ -139,8 +141,9 @@ export default function OnboardingModalSlide({
{allowDismiss && (
-
+
)}
diff --git a/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx b/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx
index 1f3ca67692..c38d9f952b 100644
--- a/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx
+++ b/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx
@@ -8,7 +8,7 @@
import React from "react";
import { TourProvider, useTour, type StepType } from "@reactour/tour";
-import { CloseButton, ActionIcon } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import CheckIcon from "@mui/icons-material/Check";
@@ -137,7 +137,7 @@ export default function OnboardingTour({
setIsOpen,
})
}
- variant="subtle"
+ variant="tertiary"
size="lg"
aria-label={
isLast
@@ -151,11 +151,15 @@ export default function OnboardingTour({
}}
components={{
Close: ({ onClick }) => (
-
+ >
+ ×
+
),
Content: ({ content }: { content: string }) => (
boolean;
@@ -238,6 +241,7 @@ export const SLIDE_DEFINITIONS: Record
= {
type: "button",
label: "onboarding.serverLicense.seePlans",
variant: "primary",
+ accent: "premium",
group: "right",
action: "see-plans",
},
diff --git a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx
index 6960b88c27..23566a5527 100644
--- a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx
+++ b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx
@@ -1,6 +1,6 @@
import React from "react";
import { Trans } from "react-i18next";
-import { Button } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import i18n from "@app/i18n";
import { SlideConfig } from "@app/types/types";
@@ -36,7 +36,7 @@ export default function AnalyticsChoiceSlide({
window.open(
diff --git a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx
index 7362774017..97329db5d7 100644
--- a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx
+++ b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
-import { Menu, ActionIcon } from "@mantine/core";
+import { Menu } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
export interface OSOption {
@@ -67,8 +68,12 @@ export const DesktopInstallTitle: React.FC = ({
{t("firstLogin.changePassword", "Change Password")}
diff --git a/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx
index de76eadd6b..c018d69265 100644
--- a/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx
+++ b/frontend/editor/src/core/components/onboarding/slides/MFASetupSlide.tsx
@@ -8,13 +8,13 @@ import {
import {
Alert,
Box,
- Button,
Group,
Loader,
Stack,
Text,
TextInput,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { QRCodeSVG } from "qrcode.react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
@@ -210,8 +210,8 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
@@ -226,7 +226,7 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
>
Enable MFA
-
+
Logout
diff --git a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx
index 93ff3c2ab9..472fc141d7 100644
--- a/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx
+++ b/frontend/editor/src/core/components/pageEditor/FileThumbnail.tsx
@@ -5,7 +5,9 @@ import React, {
useMemo,
useEffect,
} from "react";
-import { ActionIcon, CheckboxIndicator } from "@mantine/core";
+import { CheckboxIndicator } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
@@ -259,7 +261,7 @@ const FileThumbnail = ({
{/* Kebab menu */}
{
e.stopPropagation();
@@ -277,8 +279,18 @@ const FileThumbnail = ({
style={{ width: actionsWidth }}
onClick={(e) => e.stopPropagation()}
>
-
+ ) : (
+
+ )
+ }
onClick={() => {
if (actualFile) {
if (isPinned) {
@@ -292,38 +304,36 @@ const FileThumbnail = ({
setShowActions(false);
}}
>
- {isPinned ? (
-
- ) : (
-
- )}
- {isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}
-
-
-
+ }
onClick={() => {
downloadSelectedFile();
setShowActions(false);
}}
>
-
- {terminology.download}
-
-
+ {terminology.download}
+
-
-
}
onClick={() => {
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
setShowActions(false);
}}
>
-
-
{t("delete", "Delete")}
-
+ {t("delete", "Delete")}
+
)}
diff --git a/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx b/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx
index 2e8653fa89..96762a9203 100644
--- a/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx
+++ b/frontend/editor/src/core/components/pageEditor/PageEditorControls.tsx
@@ -1,4 +1,5 @@
-import { Tooltip, ActionIcon } from "@mantine/core";
+import { Tooltip } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import ContentCutIcon from "@mui/icons-material/ContentCut";
@@ -135,28 +136,22 @@ const PageEditorControls = ({
{/* Undo/Redo */}
@@ -176,17 +171,14 @@ const PageEditorControls = ({
label={t("pageEditor.toolbar.rotateLeft", "Rotate Selected Left")}
>
onRotate("left")}
disabled={selectedPageIds.length === 0}
- variant="subtle"
- style={{
- color:
- selectedPageIds.length > 0
- ? "var(--text-secondary)"
- : "var(--text-muted)",
- }}
- radius="md"
- size="lg"
+ aria-label={t(
+ "pageEditor.toolbar.rotateLeft",
+ "Rotate Selected Left",
+ )}
>
@@ -195,68 +187,47 @@ const PageEditorControls = ({
label={t("pageEditor.toolbar.rotateRight", "Rotate Selected Right")}
>
onRotate("right")}
disabled={selectedPageIds.length === 0}
- variant="subtle"
- style={{
- color:
- selectedPageIds.length > 0
- ? "var(--text-secondary)"
- : "var(--text-muted)",
- }}
- radius="md"
- size="lg"
+ aria-label={t(
+ "pageEditor.toolbar.rotateRight",
+ "Rotate Selected Right",
+ )}
>
0
- ? "var(--text-secondary)"
- : "var(--text-muted)",
- }}
- radius="md"
- size="lg"
+ aria-label={t("pageEditor.toolbar.delete", "Delete Selected")}
>
0
- ? "var(--text-secondary)"
- : "var(--text-muted)",
- }}
- radius="md"
- size="lg"
+ aria-label={getSplitTooltip()}
>
0
- ? "var(--text-secondary)"
- : "var(--text-muted)",
- }}
- radius="md"
- size="lg"
+ aria-label={getPageBreakTooltip()}
>
diff --git a/frontend/editor/src/core/components/pageEditor/PageSelectByNumberButton.tsx b/frontend/editor/src/core/components/pageEditor/PageSelectByNumberButton.tsx
index fff0b8ef8f..dc2032fa4a 100644
--- a/frontend/editor/src/core/components/pageEditor/PageSelectByNumberButton.tsx
+++ b/frontend/editor/src/core/components/pageEditor/PageSelectByNumberButton.tsx
@@ -1,4 +1,5 @@
-import { ActionIcon, Popover } from "@mantine/core";
+import { Popover } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel";
@@ -37,8 +38,7 @@ export default function PageSelectByNumberButton({
diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx
index 71c07d26b3..d2ff79ad97 100644
--- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx
+++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx
@@ -1,4 +1,5 @@
-import { Button, Text, Group, Divider } from "@mantine/core";
+import { Text, Group, Divider } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
import { LogicalOperator } from "@app/utils/bulkselection/selectionBuilders";
@@ -22,7 +23,7 @@ const OperatorsSection = ({
onInsertOperator("and")}
disabled={!csvInput.trim()}
@@ -37,7 +38,7 @@ const OperatorsSection = ({
onInsertOperator("or")}
disabled={!csvInput.trim()}
@@ -52,7 +53,7 @@ const OperatorsSection = ({
onInsertOperator("not")}
disabled={!csvInput.trim()}
@@ -70,7 +71,7 @@ const OperatorsSection = ({
onInsertOperator("even")}
title={t(
@@ -84,7 +85,7 @@ const OperatorsSection = ({
onInsertOperator("odd")}
title={t(
diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx
index 3a386f039d..2e5513a7c8 100644
--- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx
+++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/PageSelectionInput.tsx
@@ -1,4 +1,5 @@
-import { TextInput, Button, Text, Flex, Switch } from "@mantine/core";
+import { TextInput, Text, Flex, Switch } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
@@ -77,20 +78,15 @@ const PageSelectionInput = ({
placeholder="1,3,5-10"
rightSection={
csvInput && (
-
- ×
-
+ ×
+
)
}
onKeyDown={(e) => e.key === "Enter" && onUpdatePagesFromCSV()}
diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx
index 314ca282b6..5fc7341430 100644
--- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx
+++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
-import { Button, Text, NumberInput, Group } from "@mantine/core";
+import { Text, NumberInput, Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
interface SelectPagesProps {
diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx
index 365d42608a..264db178ac 100644
--- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx
+++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx
@@ -5,7 +5,8 @@ import React, {
useCallback,
useRef,
} from "react";
-import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
+import { Badge, Modal, Text, Tooltip, Group } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -365,7 +366,7 @@ const AppConfigModalInner: React.FC
= ({
/>
= ({
label={t("storageShare.linkLabel", "Share link")}
rightSection={
}
@@ -297,7 +297,7 @@ const BulkShareModal: React.FC = ({
-
+
{t("cancel", "Cancel")}
= ({
)}
-
+
{t("cancel", "Cancel")}
(
{children}
);
+// The shared SegmentedControl renders each option as a radio (inside a
+// ) whose `value` attribute matches the option value. Select by value
+// since it is stable regardless of how the label is wrapped (e.g. FitText).
+const getRadioByValue = (container: HTMLElement, value: string) =>
+ container.querySelector(
+ `input[type="radio"][value="${value}"]`,
+ );
+
describe("ButtonSelector", () => {
const mockOnChange = vi.fn();
@@ -15,7 +23,7 @@ describe("ButtonSelector", () => {
vi.clearAllMocks();
});
- test("should render all options as buttons", () => {
+ test("should render all options as segments", () => {
const options = [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
@@ -37,13 +45,13 @@ describe("ButtonSelector", () => {
expect(screen.getByText("Option 2")).toBeInTheDocument();
});
- test("should highlight selected button with filled variant", () => {
+ test("should mark selected option as checked", () => {
const options = [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
];
- render(
+ const { container } = render(
{
,
);
- const selectedButton = screen.getByRole("button", { name: "Option 1" });
- const unselectedButton = screen.getByRole("button", { name: "Option 2" });
+ const selectedRadio = getRadioByValue(container, "option1");
+ const unselectedRadio = getRadioByValue(container, "option2");
- // Check data-variant attribute for filled/outline
- expect(selectedButton).toHaveAttribute("data-variant", "filled");
- expect(unselectedButton).toHaveAttribute("data-variant", "outline");
+ // Selected option is marked via the radio's checked state.
+ expect(selectedRadio).toBeChecked();
+ expect(unselectedRadio).not.toBeChecked();
expect(screen.getByText("Selection Label")).toBeInTheDocument();
});
- test("should call onChange when button is clicked", () => {
+ test("should call onChange when an option is clicked", () => {
const options = [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
];
- render(
+ const { container } = render(
{
,
);
- fireEvent.click(screen.getByRole("button", { name: "Option 2" }));
+ fireEvent.click(getRadioByValue(container, "option2")!);
expect(mockOnChange).toHaveBeenCalledWith("option2");
});
@@ -90,7 +98,7 @@ describe("ButtonSelector", () => {
{ value: "option2", label: "Option 2" },
];
- render(
+ const { container } = render(
{
,
);
- // Both buttons should be outlined when no value is selected
- const button1 = screen.getByRole("button", { name: "Option 1" });
- const button2 = screen.getByRole("button", { name: "Option 2" });
+ // No option should be checked when no value is selected
+ const radio1 = getRadioByValue(container, "option1");
+ const radio2 = getRadioByValue(container, "option2");
- expect(button1).toHaveAttribute("data-variant", "outline");
- expect(button2).toHaveAttribute("data-variant", "outline");
+ expect(radio1).not.toBeChecked();
+ expect(radio2).not.toBeChecked();
});
test.each([
{
- description: "disable buttons when disabled prop is true",
+ description: "disable options when disabled prop is true",
options: [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
@@ -128,7 +136,7 @@ describe("ButtonSelector", () => {
expectedStates: [false, true],
},
])("should $description", ({ options, globalDisabled, expectedStates }) => {
- render(
+ const { container } = render(
{
);
options.forEach((option, index) => {
- const button = screen.getByRole("button", { name: option.label });
- expect(button).toHaveProperty("disabled", expectedStates[index]);
+ const radio = getRadioByValue(container, String(option.value));
+ expect(radio).toHaveProperty("disabled", expectedStates[index]);
});
});
- test("should not call onChange when disabled button is clicked", () => {
+ test("should not allow selecting a disabled option", () => {
const options = [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2", disabled: true },
];
- render(
+ const { container } = render(
{
,
);
- fireEvent.click(screen.getByRole("button", { name: "Option 2" }));
-
+ // The disabled option's radio is disabled, so a real user cannot select it
+ // and onChange will not fire from genuine interaction. (jsdom does not
+ // replicate the browser's disabled-click blocking, so assert the disabled
+ // state — that is what prevents selection for real users.)
+ const disabledRadio = getRadioByValue(container, "option2");
+ expect(disabledRadio).toBeDisabled();
expect(mockOnChange).not.toHaveBeenCalled();
});
- test("should not apply fullWidth styling when fullWidth is false", () => {
+ test("should render options when fullWidth is false", () => {
const options = [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
@@ -184,8 +196,7 @@ describe("ButtonSelector", () => {
,
);
- const button = screen.getByRole("button", { name: "Option 1" });
- expect(button).not.toHaveStyle({ flex: "1" });
+ expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("Layout Label")).toBeInTheDocument();
});
@@ -205,14 +216,14 @@ describe("ButtonSelector", () => {
,
);
- // Should render buttons
+ // Should render the options
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("Option 2")).toBeInTheDocument();
- // Stack should only contain the Group (buttons), no Text element for label
+ // Stack should only contain the SegmentedControl, no label Text element
const stackElement = container.querySelector(
'[class*="mantine-Stack-root"]',
);
- expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text
+ expect(stackElement?.children).toHaveLength(1); // Only the SegmentedControl, no label Text
});
});
diff --git a/frontend/editor/src/core/components/shared/ButtonSelector.tsx b/frontend/editor/src/core/components/shared/ButtonSelector.tsx
index 73e18cac8a..f52256ba0a 100644
--- a/frontend/editor/src/core/components/shared/ButtonSelector.tsx
+++ b/frontend/editor/src/core/components/shared/ButtonSelector.tsx
@@ -1,5 +1,6 @@
-import { Button, Group, Stack, Text, Tooltip } from "@mantine/core";
+import { Stack, Text, Tooltip } from "@mantine/core";
import FitText from "@app/components/shared/FitText";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
export interface ButtonOption {
value: T;
@@ -15,7 +16,6 @@ interface ButtonSelectorProps {
label?: string;
disabled?: boolean;
fullWidth?: boolean;
- buttonClassName?: string;
textClassName?: string;
}
@@ -26,9 +26,43 @@ const ButtonSelector = ({
label = undefined,
disabled = false,
fullWidth = true,
- buttonClassName,
textClassName,
}: ButtonSelectorProps) => {
+ const selectedValue = value === undefined ? "" : String(value);
+
+ const segmentedOptions = options.map((option) => {
+ const isDisabled = disabled || option.disabled;
+ const fitText = (
+
+ );
+
+ return {
+ value: String(option.value),
+ disabled: isDisabled,
+ label:
+ option.tooltip && isDisabled ? (
+
+ {fitText}
+
+ ) : (
+ fitText
+ ),
+ };
+ });
+
+ const handleChange = (next: string) => {
+ const matched = options.find((option) => String(option.value) === next);
+ if (matched) {
+ onChange(matched.value);
+ }
+ };
+
return (
{/* Label (if it exists) */}
@@ -44,69 +78,12 @@ const ButtonSelector = ({
)}
- {/* Buttons */}
-
- {options.map((option) => {
- const isDisabled = disabled || option.disabled;
- const button = (
- onChange(option.value)}
- disabled={isDisabled}
- className={buttonClassName}
- style={{
- flex: fullWidth ? 1 : undefined,
- height: "auto",
- minHeight: "2.5rem",
- fontSize: "var(--mantine-font-size-sm)",
- lineHeight: "1.4",
- paddingTop: "0.5rem",
- paddingBottom: "0.5rem",
- }}
- >
-
-
- );
-
- // Wrap with tooltip if provided (useful for disabled state explanations)
- if (option.tooltip && isDisabled) {
- return (
-
-
- {button}
-
-
- );
- }
-
- return (
-
- {button}
-
- );
- })}
-
+
);
};
diff --git a/frontend/editor/src/core/components/shared/ButtonToggle.tsx b/frontend/editor/src/core/components/shared/ButtonToggle.tsx
index 5e02fb169b..9932c88062 100644
--- a/frontend/editor/src/core/components/shared/ButtonToggle.tsx
+++ b/frontend/editor/src/core/components/shared/ButtonToggle.tsx
@@ -1,5 +1,4 @@
-import { Button, Stack } from "@mantine/core";
-import React from "react";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
export interface ButtonToggleOption {
value: string;
@@ -13,70 +12,49 @@ export interface ButtonToggleProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
- orientation?: "vertical" | "horizontal";
size?: "xs" | "sm" | "md" | "lg";
fullWidth?: boolean;
}
-export const ButtonToggle: React.FC = ({
+export const ButtonToggle = ({
options,
value,
onChange,
disabled = false,
- orientation = "vertical",
size = "md",
fullWidth = true,
-}) => {
- const isVertical = orientation === "vertical";
+}: ButtonToggleProps) => {
+ const segmentedSize = size === "xs" || size === "sm" ? "sm" : "md";
- const buttonStyle: React.CSSProperties = {
- justifyContent: "flex-start",
- height: isVertical ? "auto" : undefined,
- minHeight: isVertical ? "50px" : undefined,
- padding: isVertical ? "12px 16px" : undefined,
- textAlign: "left",
- };
-
- const renderButton = (option: ButtonToggleOption) => {
- const isSelected = value === option.value;
- const isDisabled = disabled || option.disabled;
-
- return (
- !isDisabled && onChange(option.value)}
- disabled={isDisabled}
- size={size}
- fullWidth={fullWidth}
- style={buttonStyle}
- >
-
-
{option.label}
- {option.description && (
-
- {option.description}
-
- )}
-
-
- );
- };
-
- if (isVertical) {
- return {options.map(renderButton)} ;
- }
+ const segmentedOptions = options.map((option) => ({
+ value: option.value,
+ disabled: disabled || option.disabled,
+ label: (
+
+
{option.label}
+ {option.description && (
+
+ {option.description}
+
+ )}
+
+ ),
+ }));
return (
-
- {options.map(renderButton)}
-
+
);
};
diff --git a/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx b/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx
index 3bcc53daf7..5f3f4ada2a 100644
--- a/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx
+++ b/frontend/editor/src/core/components/shared/DismissAllErrorsButton.tsx
@@ -1,9 +1,9 @@
import React from "react";
-import { Button, Group } from "@mantine/core";
+import { Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { useFileState } from "@app/contexts/FileContext";
import { useFileActions } from "@app/contexts/file/fileHooks";
-import CloseIcon from "@mui/icons-material/Close";
import { Z_INDEX_TOAST } from "@app/styles/zIndex";
interface DismissAllErrorsButtonProps {
@@ -32,10 +32,9 @@ const DismissAllErrorsButton: React.FC = ({
return (
}
onClick={handleDismissAllErrors}
style={{
position: "absolute",
diff --git a/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx b/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx
index c2f9ac6db2..8965c18af3 100644
--- a/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx
+++ b/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx
@@ -16,7 +16,7 @@ import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
export interface DropdownItem {
value: string;
name: string;
- leftIcon?: ReactNode;
+ leftSection?: ReactNode;
disabled?: boolean;
}
@@ -232,9 +232,9 @@ const DropdownListWithFooter: React.FC = ({
}}
>
- {item.leftIcon && (
+ {item.leftSection && (
- {item.leftIcon}
+ {item.leftSection}
)}
{item.name}
diff --git a/frontend/editor/src/core/components/shared/EditableSecretField.tsx b/frontend/editor/src/core/components/shared/EditableSecretField.tsx
index 88ee8cad95..24e6d881c3 100644
--- a/frontend/editor/src/core/components/shared/EditableSecretField.tsx
+++ b/frontend/editor/src/core/components/shared/EditableSecretField.tsx
@@ -1,12 +1,7 @@
import { useState, useRef, useEffect } from "react";
-import {
- PasswordInput,
- Group,
- ActionIcon,
- Tooltip,
- TextInput,
-} from "@mantine/core";
+import { PasswordInput, Group, Tooltip, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
+import { ActionIcon } from "@app/ui/ActionIcon";
import LocalIcon from "@app/components/shared/LocalIcon";
interface EditableSecretFieldProps {
@@ -95,7 +90,7 @@ export default function EditableSecretField({
@@ -97,7 +91,7 @@ const EncryptedPdfUnlockModal = ({
{remainingCount > 0 && (
)}
-
+
Try Again
diff --git a/frontend/editor/src/core/components/shared/FileCard.tsx b/frontend/editor/src/core/components/shared/FileCard.tsx
index 5d53418d7f..c731092459 100644
--- a/frontend/editor/src/core/components/shared/FileCard.tsx
+++ b/frontend/editor/src/core/components/shared/FileCard.tsx
@@ -1,15 +1,7 @@
import { useState } from "react";
-import {
- Card,
- Stack,
- Text,
- Group,
- Badge,
- Button,
- Box,
- ActionIcon,
- Tooltip,
-} from "@mantine/core";
+import { Card, Stack, Text, Group, Badge, Box, Tooltip } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
@@ -114,8 +106,8 @@ const FileCard = ({
{
e.stopPropagation();
onView();
@@ -131,8 +123,12 @@ const FileCard = ({
>
{
e.stopPropagation();
onEdit();
@@ -182,14 +178,14 @@ const FileCard = ({
{
e.stopPropagation();
onRemove();
}}
- mt={4}
+ style={{ marginTop: 4 }}
>
{t("delete", "Remove")}
diff --git a/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx b/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx
index 519366b9d6..735d633fee 100644
--- a/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx
+++ b/frontend/editor/src/core/components/shared/FileDropdownMenu.tsx
@@ -1,5 +1,6 @@
import React from "react";
-import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core";
+import { Menu, Loader, Group, Text, Tooltip } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
@@ -102,10 +103,14 @@ export const FileDropdownMenu: React.FC = ({
withArrow
>
{
e.stopPropagation();
onFileRemove(file.fileId as FileId);
diff --git a/frontend/editor/src/core/components/shared/FileGrid.tsx b/frontend/editor/src/core/components/shared/FileGrid.tsx
index 2ade54426f..c1e87ab18a 100644
--- a/frontend/editor/src/core/components/shared/FileGrid.tsx
+++ b/frontend/editor/src/core/components/shared/FileGrid.tsx
@@ -1,13 +1,6 @@
import { useState } from "react";
-import {
- Box,
- Flex,
- Group,
- Text,
- Button,
- TextInput,
- Select,
-} from "@mantine/core";
+import { Box, Flex, Group, Text, TextInput, Select } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort";
@@ -121,7 +114,7 @@ const FileGrid = ({
{onDeleteAll && (
-
+
{t("fileManager.deleteAll", "Delete All")}
)}
@@ -179,7 +172,7 @@ const FileGrid = ({
{/* Show All Button */}
{hasMoreFiles && onShowAll && (
-
+
{t("fileManager.showAll", "Show All")} ({sortedFiles.length} files)
diff --git a/frontend/editor/src/core/components/shared/FilePickerModal.tsx b/frontend/editor/src/core/components/shared/FilePickerModal.tsx
index da1bb91b6c..5e077d3437 100644
--- a/frontend/editor/src/core/components/shared/FilePickerModal.tsx
+++ b/frontend/editor/src/core/components/shared/FilePickerModal.tsx
@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
import {
Modal,
Text,
- Button,
Group,
Stack,
Checkbox,
@@ -11,6 +10,7 @@ import {
Badge,
SimpleGrid,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
import { FileId } from "@app/types/file";
@@ -151,10 +151,10 @@ const FilePickerModal = ({
)}
-
+
{t("pageEdit.selectAll", "Select All")}
-
+
{t("pageEdit.deselectAll", "Select None")}
@@ -249,7 +249,7 @@ const FilePickerModal = ({
{/* Action buttons */}
-
+
{t("close", "Cancel")}
void loadAndSelect(stub)}
disabled={!!loadingId}
@@ -579,6 +581,9 @@ export function FileSelectorPicker({
})
}
onMouseLeave={() => setHoveredStub(null)}
+ rightSection={
+ isItemLoading ? : undefined
+ }
>
@@ -586,8 +591,7 @@ export function FileSelectorPicker({
{meta && {meta} }
- {isItemLoading && }
-
+
);
})
)}
diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx
index 1eb51b759b..db3820350e 100644
--- a/frontend/editor/src/core/components/shared/FileSidebar.tsx
+++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx
@@ -7,6 +7,7 @@ import React, {
forwardRef,
} from "react";
import { Loader, Tooltip } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useFileState, useFileActions } from "@app/contexts/file/fileHooks";
@@ -961,26 +962,31 @@ const FileSidebar = forwardRef(
{t("fileSidebar.files", "Files")}
- navigate("/files")}
title={t(
"fileSidebar.openFileManager",
"Browse all files & folders",
)}
- type="button"
+ aria-label={t(
+ "fileSidebar.openFileManager",
+ "Browse all files & folders",
+ )}
data-testid="open-files-page"
>
-
-
+ nativeFileInputRef.current?.click()}
title={t("fileSidebar.addFiles", "Add files")}
- type="button"
+ aria-label={t("fileSidebar.addFiles", "Add files")}
>
-
+
{!stubsLoaded ? (
@@ -989,35 +995,6 @@ const FileSidebar = forwardRef(
) : filteredFileStubs.length > 0 ? (
-
nativeFileInputRef.current?.click()}
- data-testid="add-files-row"
- style={{
- background: "transparent",
- border: "none",
- cursor: "pointer",
- color: "var(--text-muted)",
- padding: "4px 6px",
- marginBottom: 4,
- display: "flex",
- alignItems: "center",
- gap: 6,
- fontSize: 12,
- width: "100%",
- textAlign: "left",
- borderRadius: 4,
- }}
- onMouseEnter={(e) => {
- e.currentTarget.style.background = "var(--hover-bg)";
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.background = "transparent";
- }}
- >
-
- {t("fileSidebar.addFiles", "Add files")}
-
{filteredFileStubs.map((stub) => {
const workbenchFileId = state.files.ids.find(
(id) => (id as string) === (stub.id as string),
diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css
index cab9f589f2..af6d9e06cc 100644
--- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.css
+++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.css
@@ -91,7 +91,7 @@
width: 20px;
height: 20px;
border-radius: 5px;
- border: 1.5px solid var(--border-hover, #52525b);
+ border: 1.5px solid var(--border-hover, var(--border-strong));
}
.file-sidebar-file-item:hover .file-sidebar-file-checkbox-hover {
diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx
index 58a318e0d8..ff48a9704d 100644
--- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx
+++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { Menu, Tooltip } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
@@ -369,14 +370,15 @@ export function FileItem({
)}
-
{
e.stopPropagation();
onEyeClick(fileId, e);
}}
tabIndex={-1}
- type="button"
aria-label={
isViewedInViewer
? t("fileSidebar.fileItem.closeViewer", "Close viewer")
@@ -391,25 +393,25 @@ export function FileItem({
className="file-sidebar-eye-closed"
sx={{ fontSize: "1.1rem" }}
/>
-
-
+
{(onDelete ||
onSaveToCloud ||
(hasVersionHistory && onVersionHistory)) && (
- e.stopPropagation()}
tabIndex={-1}
- type="button"
aria-label={t(
"fileSidebar.fileItem.moreActions",
"More actions",
)}
>
-
+
e.stopPropagation()}>
{hasVersionHistory && onVersionHistory && (
diff --git a/frontend/editor/src/core/components/shared/FileUploadButton.tsx b/frontend/editor/src/core/components/shared/FileUploadButton.tsx
index 000639f09c..b07fbd0271 100644
--- a/frontend/editor/src/core/components/shared/FileUploadButton.tsx
+++ b/frontend/editor/src/core/components/shared/FileUploadButton.tsx
@@ -1,6 +1,32 @@
import { useRef } from "react";
-import { FileButton, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
+import { FilePicker } from "@app/ui/FilePicker";
+import type { ButtonVariant } from "@app/ui/Button";
+
+// Accept both shared DS variants and the legacy Mantine variant names that
+// existing callers still pass, mapping the latter onto the DS equivalents.
+type LegacyVariant =
+ | "outline"
+ | "filled"
+ | "light"
+ | "default"
+ | "subtle"
+ | "gradient";
+
+const VARIANT_MAP: Record = {
+ filled: "primary",
+ outline: "secondary",
+ default: "secondary",
+ light: "tertiary",
+ subtle: "tertiary",
+ gradient: "primary",
+};
+
+function resolveVariant(variant: ButtonVariant | LegacyVariant): ButtonVariant {
+ return variant in VARIANT_MAP
+ ? VARIANT_MAP[variant as LegacyVariant]
+ : (variant as ButtonVariant);
+}
interface FileUploadButtonProps {
file?: File;
@@ -8,7 +34,7 @@ interface FileUploadButtonProps {
accept?: string;
disabled?: boolean;
placeholder?: string;
- variant?: "outline" | "filled" | "light" | "default" | "subtle" | "gradient";
+ variant?: ButtonVariant | LegacyVariant;
fullWidth?: boolean;
}
@@ -18,7 +44,7 @@ const FileUploadButton = ({
accept,
disabled = false,
placeholder,
- variant = "outline",
+ variant = "secondary",
fullWidth = true,
}: FileUploadButtonProps) => {
const { t } = useTranslation();
@@ -27,18 +53,16 @@ const FileUploadButton = ({
const defaultPlaceholder = t("chooseFile", "Choose File");
return (
-
- {(props) => (
-
- {file ? file.name : placeholder || defaultPlaceholder}
-
- )}
-
+ {file ? file.name : placeholder || defaultPlaceholder}
+
);
};
diff --git a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx
index 4d7764f91d..be91ccc03d 100644
--- a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx
+++ b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx
@@ -1,12 +1,6 @@
import { useState } from "react";
-import {
- Modal,
- Stack,
- Text,
- PasswordInput,
- Button,
- Alert,
-} from "@mantine/core";
+import { Modal, Stack, Text, PasswordInput, Alert } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { accountService } from "@app/services/accountService";
@@ -195,8 +189,8 @@ export default function FirstLoginModal({
/>
{t("firstLogin.changePassword", "Change Password")}
diff --git a/frontend/editor/src/core/components/shared/Footer.tsx b/frontend/editor/src/core/components/shared/Footer.tsx
index e73a48fd2a..2fb7312fba 100644
--- a/frontend/editor/src/core/components/shared/Footer.tsx
+++ b/frontend/editor/src/core/components/shared/Footer.tsx
@@ -2,6 +2,7 @@ import { Flex } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useCookieConsent } from "@app/hooks/useCookieConsent";
import { useFooterInfo } from "@app/hooks/useFooterInfo";
+import { Button } from "@app/ui/Button";
interface FooterProps {
privacyPolicy?: string;
@@ -53,8 +54,8 @@ export default function Footer({
)}
{finalAnalyticsEnabled && (
-
{t("legal.showCookieBanner", "Cookie Preferences")}
-
+
)}
diff --git a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx
index fafebdf8f2..a6b827bb1f 100644
--- a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx
+++ b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx
@@ -1,5 +1,6 @@
import React from "react";
-import { ActionIcon, Tooltip } from "@mantine/core";
+import { Tooltip } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import styles from "@app/components/shared/HoverActionMenu.module.css";
import { Z_INDEX_HOVER_ACTION_MENU } from "@app/styles/zIndex";
@@ -60,10 +61,9 @@ const HoverActionMenu: React.FC = ({
= ({
const iconSize = compact ? "1rem" : "1.2rem";
const textSize = compact ? "xs" : "sm";
- const buttonSize = compact ? "xs" : "xs";
return (
= ({
{buttonText && onButtonClick && (
= ({
height={compact ? "0.75rem" : "0.9rem"}
/>
}
- styles={
- buttonTextColor
- ? { label: { color: buttonTextColor } }
- : undefined
- }
+ style={buttonTextColor ? { color: buttonTextColor } : undefined}
>
{buttonText}
)}
{dismissible && (
{
e.stopPropagation();
onMobileUploadClick();
diff --git a/frontend/editor/src/core/components/shared/LandingPage.css b/frontend/editor/src/core/components/shared/LandingPage.css
index 233b15cfee..a785418877 100644
--- a/frontend/editor/src/core/components/shared/LandingPage.css
+++ b/frontend/editor/src/core/components/shared/LandingPage.css
@@ -5,11 +5,12 @@
/* ── Hero text ───────────────────────────────────────────── */
.landing-title {
- display: block;
- margin: 1.75rem auto 0.5rem;
- height: 3rem;
- width: auto;
- max-width: 100%;
+ margin: 1.75rem 0 0.5rem;
+ text-align: center;
+ font-size: 2rem;
+ font-weight: 700;
+ line-height: 1.25;
+ color: var(--text-primary);
}
.landing-subtitle {
diff --git a/frontend/editor/src/core/components/shared/LandingPage.tsx b/frontend/editor/src/core/components/shared/LandingPage.tsx
index 1f9f117f3e..efb54cd7b6 100644
--- a/frontend/editor/src/core/components/shared/LandingPage.tsx
+++ b/frontend/editor/src/core/components/shared/LandingPage.tsx
@@ -8,7 +8,6 @@ import MobileUploadModal from "@app/components/shared/MobileUploadModal";
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack";
import { LandingActions } from "@app/components/shared/LandingActions";
-import { Wordmark } from "@app/components/shared/Wordmark";
import "@app/components/shared/LandingPage.css";
const LandingPage = () => {
@@ -80,10 +79,9 @@ const LandingPage = () => {
>
-
+
+ {t("landing.workbenchEmptyStateHero", "Drop a PDF anywhere")}
+
{t(
"landing.heroSubtitle",
diff --git a/frontend/editor/src/core/components/shared/LanguageSelector.tsx b/frontend/editor/src/core/components/shared/LanguageSelector.tsx
index d4ff7bbacb..aad6c714d7 100644
--- a/frontend/editor/src/core/components/shared/LanguageSelector.tsx
+++ b/frontend/editor/src/core/components/shared/LanguageSelector.tsx
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from "react";
-import { Menu, Button, ActionIcon } from "@mantine/core";
+import { Menu } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
import { useTranslation } from "react-i18next";
import { supportedLanguages, setUserLanguage } from "@app/i18n";
@@ -73,50 +75,26 @@ const LanguageItem: React.FC = ({
}}
>
{label}
@@ -272,43 +250,23 @@ const LanguageSelector: React.FC = ({
{compact ? (
) : (
}
- styles={{
- root: {
- border: "none",
- color: "var(--text-primary)",
- transition:
- "background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
- "&:hover": {
- backgroundColor:
- "light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))",
- },
- },
- label: { fontSize: "12px", fontWeight: 500 },
- }}
>
{currentLanguage}
diff --git a/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx b/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx
index 8d419e7cc0..9fb7f7c925 100644
--- a/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx
+++ b/frontend/editor/src/core/components/shared/LoginAgreementModal.tsx
@@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import {
Box,
- Button,
Divider,
Group,
Modal,
@@ -10,6 +9,7 @@ import {
Stack,
Text,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import Markdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -188,10 +188,10 @@ export default function LoginAgreementModal() {
)}
-
+
{t("loginAgreementDecline", "Decline")}
-
+
{t("loginAgreementAccept", "Accept")}
diff --git a/frontend/editor/src/core/components/shared/MultiSelectControls.tsx b/frontend/editor/src/core/components/shared/MultiSelectControls.tsx
index 447662e9dd..160346dd1b 100644
--- a/frontend/editor/src/core/components/shared/MultiSelectControls.tsx
+++ b/frontend/editor/src/core/components/shared/MultiSelectControls.tsx
@@ -1,4 +1,5 @@
-import { Box, Group, Text, Button } from "@mantine/core";
+import { Box, Group, Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
interface MultiSelectControlsProps {
@@ -36,20 +37,20 @@ const MultiSelectControls = ({
{selectedCount} {t("fileManager.filesSelected", "files")}
-
+
{t("fileManager.clearSelection", "Clear files")}
{onAddToUpload && (
-
+
{t("fileManager.addToUpload", "Add to Upload")}
)}
{onOpenInFileEditor && (
@@ -59,8 +60,7 @@ const MultiSelectControls = ({
{onOpenInPageEditor && (
@@ -69,7 +69,7 @@ const MultiSelectControls = ({
)}
{onDeleteAll && (
-
+
{t("fileManager.deleteAll", "Delete All")}
)}
diff --git a/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx b/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx
index 9917f37ac5..7e3c675f3b 100644
--- a/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx
+++ b/frontend/editor/src/core/components/shared/NavigationWarningModal.tsx
@@ -1,5 +1,6 @@
import { useRef, useEffect } from "react";
-import { Modal, Text, Button, Group, Stack } from "@mantine/core";
+import { Modal, Text, Group, Stack } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useNavigationGuard } from "@app/contexts/NavigationContext";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
@@ -103,10 +104,12 @@ const NavigationWarningModal = () => {
}
>
{t("keepWorking", "Keep Working")}
@@ -114,19 +117,19 @@ const NavigationWarningModal = () => {
}
>
{t("discardChanges", "Discard Changes")}
{hasApply && (
}
>
{t("applyAndContinue", "Apply & Leave")}
@@ -134,9 +137,8 @@ const NavigationWarningModal = () => {
)}
{hasExport && (
}
>
{t("exportAndContinue", "Export & Leave")}
@@ -148,28 +150,28 @@ const NavigationWarningModal = () => {
{/* Mobile layout: centered stack of 4 buttons */}
}
>
{t("keepWorking", "Keep Working")}
}
>
{t("discardChanges", "Discard Changes")}
{hasApply && (
}
>
{t("applyAndContinue", "Apply & Leave")}
@@ -177,9 +179,8 @@ const NavigationWarningModal = () => {
)}
{hasExport && (
}
>
{t("exportAndContinue", "Export & Leave")}
diff --git a/frontend/editor/src/core/components/shared/ObscuredOverlay.tsx b/frontend/editor/src/core/components/shared/ObscuredOverlay.tsx
index ba8140d303..01f14e9be7 100644
--- a/frontend/editor/src/core/components/shared/ObscuredOverlay.tsx
+++ b/frontend/editor/src/core/components/shared/ObscuredOverlay.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { Button } from "@app/ui/Button";
import styles from "@app/components/shared/ObscuredOverlay/ObscuredOverlay.module.css";
type ObscuredOverlayProps = {
@@ -34,13 +35,13 @@ export default function ObscuredOverlay({
{overlayMessage}
)}
{buttonText && onButtonClick && (
-
{buttonText}
-
+
)}
diff --git a/frontend/editor/src/core/components/shared/ShareFileModal.tsx b/frontend/editor/src/core/components/shared/ShareFileModal.tsx
index 2f205326e3..1430f9df20 100644
--- a/frontend/editor/src/core/components/shared/ShareFileModal.tsx
+++ b/frontend/editor/src/core/components/shared/ShareFileModal.tsx
@@ -3,13 +3,13 @@ import {
Modal,
Stack,
Text,
- Button,
Group,
Alert,
TextInput,
Paper,
Select,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import LinkIcon from "@mui/icons-material/Link";
import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded";
import { useTranslation } from "react-i18next";
@@ -253,8 +253,8 @@ const ShareFileModal: React.FC = ({
label={t("storageShare.linkLabel", "Share link")}
rightSection={
}
@@ -307,7 +307,7 @@ const ShareFileModal: React.FC = ({
-
+
{t("cancel", "Cancel")}
= ({
setShowEmailWarning(false)}
disabled={isLoading}
>
@@ -731,9 +731,8 @@ const ShareManagementModal: React.FC = ({
{confirmRemoveUser === user.username ? (
{
void handleRemoveUser(user.username);
}}
@@ -742,8 +741,8 @@ const ShareManagementModal: React.FC = ({
{t("confirm", "Confirm")}
setConfirmRemoveUser(null)}
>
{t("cancel", "Cancel")}
@@ -751,9 +750,9 @@ const ShareManagementModal: React.FC = ({
) : (
}
@@ -813,8 +812,8 @@ const ShareManagementModal: React.FC = ({
label={t("storageShare.linkLabel", "Share link")}
rightSection={
= ({
}
@@ -898,9 +897,8 @@ const ShareManagementModal: React.FC = ({
{confirmRevokeToken === link.token ? (
{
void handleRevokeLink(link.token);
}}
@@ -909,8 +907,8 @@ const ShareManagementModal: React.FC = ({
{t("confirm", "Confirm")}
setConfirmRevokeToken(null)}
>
{t("cancel", "Cancel")}
@@ -918,9 +916,9 @@ const ShareManagementModal: React.FC = ({
) : (
}
diff --git a/frontend/editor/src/core/components/shared/TextInput.tsx b/frontend/editor/src/core/components/shared/TextInput.tsx
index f6f4eb632d..06147fb99b 100644
--- a/frontend/editor/src/core/components/shared/TextInput.tsx
+++ b/frontend/editor/src/core/components/shared/TextInput.tsx
@@ -1,5 +1,6 @@
import React, { forwardRef } from "react";
import { useTranslation } from "react-i18next";
+import { ActionIcon } from "@app/ui/ActionIcon";
import LocalIcon from "@app/components/shared/LocalIcon";
import styles from "@app/components/shared/textInput/TextInput.module.css";
@@ -110,14 +111,14 @@ export const TextInput = forwardRef(
{...props}
/>
{shouldShowClearButton && (
-
-
+
)}
);
diff --git a/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx b/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx
index e34ca4c6c2..9d5f921bec 100644
--- a/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx
+++ b/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
-import { ActionIcon } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import CloseIcon from "@mui/icons-material/Close";
import "@app/components/shared/ToolPanelHeader.css";
@@ -38,12 +38,12 @@ export function ToolPanelHeader({
{onClose && (
diff --git a/frontend/editor/src/core/components/shared/Tooltip.tsx b/frontend/editor/src/core/components/shared/Tooltip.tsx
index 12232effc1..9c94bc4726 100644
--- a/frontend/editor/src/core/components/shared/Tooltip.tsx
+++ b/frontend/editor/src/core/components/shared/Tooltip.tsx
@@ -7,6 +7,7 @@ import React, {
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
+import { ActionIcon } from "@app/ui/ActionIcon";
import LocalIcon from "@app/components/shared/LocalIcon";
import { addEventListenerWithCleanup } from "@app/utils/genericUtils";
import { useTooltipPosition } from "@app/hooks/useTooltipPosition";
@@ -396,7 +397,8 @@ export const Tooltip: React.FC = ({
}
>
{shouldShowCloseButton && (
- {
e.stopPropagation();
@@ -407,7 +409,7 @@ export const Tooltip: React.FC = ({
aria-label={t("tooltip.close", "Close tooltip")}
>
-
+
)}
{arrow && !sidebarTooltip && (
diff --git a/frontend/editor/src/core/components/shared/UpdateModal.tsx b/frontend/editor/src/core/components/shared/UpdateModal.tsx
index c18549360c..4a5e5b2404 100644
--- a/frontend/editor/src/core/components/shared/UpdateModal.tsx
+++ b/frontend/editor/src/core/components/shared/UpdateModal.tsx
@@ -4,7 +4,6 @@ import {
Stack,
Text,
Badge,
- Button,
Group,
Loader,
Center,
@@ -13,9 +12,11 @@ import {
Progress,
Alert,
Divider,
- CloseButton,
Anchor,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
+import LocalIcon from "@app/components/shared/LocalIcon";
import { useTranslation } from "react-i18next";
import {
updateService,
@@ -271,12 +272,14 @@ const UpdateModal: React.FC = ({
{canClose && (
-
+ >
+
+
)}
@@ -520,12 +523,12 @@ const UpdateModal: React.FC = ({
}
@@ -616,12 +619,11 @@ const UpdateModal: React.FC = ({
= ({
{version.compatibility.migration_guide_url && (
= ({
version.compatibility
.migration_guide_url ?? "",
)}
- variant="light"
- color="orange"
- size="xs"
- mt="xs"
+ variant="secondary"
+ accent="warning"
+ size="sm"
+ style={{
+ marginTop: "var(--mantine-spacing-xs)",
+ }}
rightSection={
}
@@ -724,8 +728,8 @@ const UpdateModal: React.FC = ({
{sortedVersions.length > 10 && (
setShowAllVersions(!showAllVersions)}
>
{showAllVersions
@@ -837,10 +841,9 @@ const UpdateModal: React.FC = ({
>
{t("update.later", "Later")}
@@ -848,8 +851,6 @@ const UpdateModal: React.FC = ({
{desktopInstall ? (
desktopInstall.state === "ready-to-restart" ? (
}
onClick={() => void desktopInstall.actions.restartApp()}
@@ -866,12 +867,11 @@ const UpdateModal: React.FC = ({
{(installBlocked || desktopInstall.state === "error") &&
downloadUrl && (
}
>
@@ -879,16 +879,10 @@ const UpdateModal: React.FC = ({
)}
}
onClick={() => void desktopInstall.actions.startInstall()}
disabled={installBlocked}
- styles={{
- root: { paddingLeft: 16, paddingRight: 20 },
- inner: { gap: 10 },
- }}
>
@@ -907,13 +901,11 @@ const UpdateModal: React.FC = ({
// unreachable, the pubkey is wrong, signatures don't match, etc.
downloadUrl && (
}
>
{t("update.downloadLatest", "Download Latest")}
diff --git a/frontend/editor/src/core/components/shared/UploadToServerModal.tsx b/frontend/editor/src/core/components/shared/UploadToServerModal.tsx
index 10cfc57dd8..55c4b61c4f 100644
--- a/frontend/editor/src/core/components/shared/UploadToServerModal.tsx
+++ b/frontend/editor/src/core/components/shared/UploadToServerModal.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from "react";
-import { Modal, Stack, Text, Button, Group, Alert } from "@mantine/core";
+import { Modal, Stack, Text, Group, Alert } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import { useTranslation } from "react-i18next";
@@ -120,7 +121,7 @@ const UploadToServerModal: React.FC = ({
)}
-
+
{t("cancel", "Cancel")}
navigate("/settings/people")}
>
{t("certSign.collab.userSelector.inviteUsers", "Add Users")}
diff --git a/frontend/editor/src/core/components/shared/ViewerInlineControls.tsx b/frontend/editor/src/core/components/shared/ViewerInlineControls.tsx
index 9535926c02..bdaf4c6ade 100644
--- a/frontend/editor/src/core/components/shared/ViewerInlineControls.tsx
+++ b/frontend/editor/src/core/components/shared/ViewerInlineControls.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react";
-import { ActionIcon, Slider } from "@mantine/core";
+import { Slider } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import { useViewer } from "@app/contexts/ViewerContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
@@ -37,8 +38,7 @@ export function ViewerInlineControls() {
{/* Zoom controls */}
viewer.zoomActions.zoomOut()}
aria-label={t("viewer.zoomOut", "Zoom out")}
@@ -66,8 +66,7 @@ export function ViewerInlineControls() {
viewer.zoomActions.zoomIn()}
aria-label={t("viewer.zoomIn", "Zoom in")}
diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.css b/frontend/editor/src/core/components/shared/WorkbenchBar.css
index 76490fd2b5..2a61e8099c 100644
--- a/frontend/editor/src/core/components/shared/WorkbenchBar.css
+++ b/frontend/editor/src/core/components/shared/WorkbenchBar.css
@@ -77,6 +77,14 @@
line-height: 1;
}
+/* Segmented view switcher: size the icons to the pre-consolidation 16px
+ * (centering + gap come from the shared SegmentedControl innerLabel). */
+.workbench-bar-views .mantine-SegmentedControl-label svg {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+}
+
/* ---- Center: tool buttons ---- */
/* Single-row: center sits between views and globals */
@@ -126,11 +134,17 @@
margin-left: auto;
}
-/* Shared action icon style - applies to both center and global buttons */
+/* Shared action icon style - applies to both center and global buttons.
+ * Tight 24px hit-box and no hover background: these are quiet toolbar icons —
+ * hover feedback comes from the icon colour darkening, not a bg tint. */
.workbench-bar-action-icon {
color: var(--text-secondary) !important;
- width: 28px !important;
- height: 28px !important;
+ /* min-* too: Mantine's ActionIcon sets min-width/min-height from --ai-size
+ * (36px), which floors the width and defeats the 24px clamp. */
+ width: 24px !important;
+ height: 24px !important;
+ min-width: 24px !important;
+ min-height: 24px !important;
background: transparent !important;
}
@@ -142,7 +156,6 @@
.workbench-bar-action-icon:hover {
color: var(--text-primary) !important;
- background-color: var(--hover-bg) !important;
}
.workbench-bar-action-icon[data-variant="filled"] {
diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx
index edfbdeba85..87e80a16c5 100644
--- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx
+++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx
@@ -5,7 +5,9 @@ import React, {
useRef,
useSyncExternalStore,
} from "react";
-import { ActionIcon } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
@@ -40,8 +42,8 @@ import {
WorkbenchBarRenderContext,
WorkbenchBarSection,
} from "@app/types/workbenchBar";
-import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
-import FolderIcon from "@mui/icons-material/Folder";
+import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined";
+import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import CloseIcon from "@mui/icons-material/Close";
import PrintIcon from "@mui/icons-material/Print";
import "@app/components/shared/WorkbenchBar.css";
@@ -316,17 +318,16 @@ export default function WorkbenchBar({
const ariaLabel =
btn.ariaLabel ||
- (typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined);
+ (typeof btn.tooltip === "string" ? (btn.tooltip as string) : btn.id);
const buttonNode = (
{btn.icon}
@@ -341,12 +342,12 @@ export default function WorkbenchBar({
{
value: "viewer",
label: t("workbenchBar.viewer", "Viewer"),
- icon: ,
+ icon: ,
},
{
value: "fileEditor",
label: t("workbenchBar.activeFiles", "Active Files"),
- icon: ,
+ icon: ,
},
...(selectedTool === "multiTool"
? [
@@ -368,7 +369,7 @@ export default function WorkbenchBar({
.map((v) => ({
value: v.workbenchId,
label: v.label,
- icon: v.icon ?? ,
+ icon: v.icon ?? ,
})),
];
@@ -415,8 +416,8 @@ export default function WorkbenchBar({
{returnRoute && hasFiles && (
<>
-
}
>
-
{returnRoute.label
? t("filesPage.backToFolder", "Back to {{folder}}", {
@@ -437,23 +438,28 @@ export default function WorkbenchBar({
})
: t("filesPage.backToMyFiles", "Back to My Files")}
-
+
>
)}
- {(hasFiles || isCustomView) &&
- viewOptions.map((opt) => (
-
setCurrentView(opt.value)}
- aria-pressed={currentView === opt.value}
- type="button"
- >
- {opt.icon}
- {opt.label}
-
- ))}
+ {(hasFiles || isCustomView) && (
+
+ className="workbench-bar-views"
+ size="sm"
+ value={currentView}
+ onChange={setCurrentView}
+ variant="secondary"
+ options={viewOptions.map((opt) => ({
+ value: opt.value,
+ label: (
+ <>
+ {opt.icon}
+ {opt.label}
+ >
+ ),
+ }))}
+ />
+ )}
{/* Tool buttons - second row, only rendered when buttons exist */}
@@ -493,8 +499,8 @@ export default function WorkbenchBar({
{currentView === "viewer" &&
renderWithTooltip(
handleExportAll()}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
}
+ aria-label={downloadTooltip}
>
handleExportAll(true)}
disabled={
disableForFullscreen || totalItems === 0 || allButtonsDisabled
}
+ aria-label={t("workbenchBar.saveAs", "Save As")}
>
}
- w="10rem"
+ style={{
+ width: "10rem",
+ }}
>
{t("zipWarning.cancel", "Cancel")}
}
- w="10rem"
+ style={{
+ width: "10rem",
+ }}
>
{t("zipWarning.confirm", "Extract")}
@@ -77,20 +80,22 @@ const ZipWarningModal = ({
{/* Mobile layout: vertical stack */}
}
- w="10rem"
+ style={{
+ width: "10rem",
+ }}
>
{t("zipWarning.cancel", "Cancel")}
}
- w="10rem"
+ style={{
+ width: "10rem",
+ }}
>
{t("zipWarning.confirm", "Extract")}
diff --git a/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.tsx b/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.tsx
index af96918154..f4e9c47b0a 100644
--- a/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.tsx
+++ b/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.tsx
@@ -1,4 +1,5 @@
-import { Modal, Text, Group, Button, Stack } from "@mantine/core";
+import { Modal, Text, Group, Stack } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import RefreshIcon from "@mui/icons-material/Refresh";
import ScheduleIcon from "@mui/icons-material/Schedule";
@@ -48,14 +49,13 @@ export default function RestartConfirmationModal({
}
onClick={onClose}
>
{t("admin.settings.restart.later", "Restart Later")}
}
onClick={onRestart}
>
diff --git a/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.tsx b/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.tsx
index a640bb1a22..f3a204b011 100644
--- a/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.tsx
+++ b/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.tsx
@@ -1,4 +1,5 @@
-import { Button, Group, Text } from "@mantine/core";
+import { Group, Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
interface SettingsStickyFooterProps {
@@ -29,7 +30,7 @@ export function SettingsStickyFooter({
{t("admin.settings.unsavedChanges.hint", "You have unsaved changes")}
-
+
{t("admin.settings.discard", "Discard")}
diff --git a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx
index 598b705347..2ec530a2d5 100644
--- a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx
+++ b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx
@@ -6,15 +6,15 @@ import {
Text,
Tooltip,
NumberInput,
- SegmentedControl,
Select,
Code,
Group,
Anchor,
- ActionIcon,
- Button,
Badge,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -195,8 +195,7 @@ const GeneralSection: React.FC = ({
}}
>
= ({
= ({
{updateSummary && (
setUpdateModalOpened(true)}
leftSection={
@@ -488,7 +489,7 @@ const GeneralSection: React.FC = ({
setTheme(val as ThemeMode)}
- data={[
+ options={[
{
label: t("settings.general.themeLight", "Light"),
value: "light",
@@ -555,7 +556,7 @@ const GeneralSection: React.FC = ({
onChange={(val: string) =>
updatePreference("defaultToolPanelMode", val as ToolPanelMode)
}
- data={[
+ options={[
{
label: t("settings.general.mode.sidebar", "Sidebar"),
value: "sidebar",
@@ -593,7 +594,7 @@ const GeneralSection: React.FC = ({
onChange={(val: string) =>
updatePreference("defaultStartupView", val as StartupView)
}
- data={[
+ options={[
{
label: t("settings.general.startupView.tools", "Tools"),
value: "tools",
diff --git a/frontend/editor/src/core/components/shared/config/configSections/HelpSection.tsx b/frontend/editor/src/core/components/shared/config/configSections/HelpSection.tsx
index 89299fe3d0..013db92d45 100644
--- a/frontend/editor/src/core/components/shared/config/configSections/HelpSection.tsx
+++ b/frontend/editor/src/core/components/shared/config/configSections/HelpSection.tsx
@@ -1,5 +1,6 @@
import React from "react";
-import { Button, Group, Paper, Stack, Text } from "@mantine/core";
+import { Group, Paper, Stack, Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { requestStartTour } from "@app/constants/events";
@@ -37,7 +38,7 @@ const HelpSection: React.FC = ({
= ({
diff --git a/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.tsx b/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.tsx
index e7e15c9e89..563cd286f1 100644
--- a/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.tsx
+++ b/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.tsx
@@ -3,7 +3,6 @@ import {
Alert,
Badge,
Box,
- Button,
Divider,
Group,
Paper,
@@ -11,6 +10,7 @@ import {
Text,
TextInput,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useHotkeys } from "@app/contexts/HotkeyContext";
@@ -215,9 +215,8 @@ const HotkeysSection: React.FC = () => {
handleStartCapture(toolId)}
>
{isEditing
@@ -228,8 +227,8 @@ const HotkeysSection: React.FC = () => {
: t("settings.hotkeys.change", "Change shortcut")}
{
setExpanded(!expanded)
@@ -280,8 +280,8 @@ export default function ProviderCard({
{onDisconnect && (
= ({
disabled = false,
children,
}) => {
+ const { t } = useTranslation();
const navigationArrowStyle = {
position: "absolute" as const,
top: "50%",
@@ -27,11 +30,11 @@ const NavigationArrows: React.FC = ({
{/* Left Navigation Arrow */}
= ({
{/* Right Navigation Arrow */}
= ({
const actionIconProps =
component === "a" && href
? {
- component: "a" as const,
+ as: "a" as const,
href,
onClick,
"aria-label": ariaLabel,
@@ -63,7 +63,7 @@ const QuickAccessButton: React.FC = ({
}
rightSection={
pendingCount > 0 ? (
diff --git a/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.tsx b/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.tsx
index 901ae80732..d6685f6248 100644
--- a/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.tsx
+++ b/frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.tsx
@@ -1,4 +1,5 @@
-import { Button, Stack, Text, Group, Paper } from "@mantine/core";
+import { Stack, Text, Group, Paper } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SignatureSettingsInput, {
@@ -72,7 +73,7 @@ export const ConfigureSignatureDefaultsStep: React.FC<
}
>
diff --git a/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.tsx b/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.tsx
index 66eeee0689..5e229ab9b3 100644
--- a/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.tsx
+++ b/frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.tsx
@@ -1,4 +1,5 @@
-import { Button, Stack, Text, Group, Divider, TextInput } from "@mantine/core";
+import { Stack, Text, Group, Divider, TextInput } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import PeopleIcon from "@mui/icons-material/People";
@@ -170,7 +171,7 @@ export const ReviewSessionStep: React.FC = ({
}
>
@@ -179,18 +180,9 @@ export const ReviewSessionStep: React.FC = ({
}
- color="green"
- styles={{
- root: { paddingTop: 6, paddingBottom: 6 },
- label: {
- whiteSpace: "normal",
- textAlign: "center",
- lineHeight: 1.2,
- },
- }}
+ accent="success"
>
{t("groupSigning.steps.review.send", "Send Signing Requests")}
diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx
index aaedc076d3..14bd56f75c 100644
--- a/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx
+++ b/frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.tsx
@@ -1,4 +1,5 @@
-import { Button, Stack, Text } from "@mantine/core";
+import { Stack, Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import type { FileState } from "@app/types/file";
diff --git a/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx
index 3baa15b7ba..99c0801519 100644
--- a/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx
+++ b/frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx
@@ -1,4 +1,5 @@
-import { Button, Stack, Text, Group } from "@mantine/core";
+import { Stack, Text, Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import UserSelector from "@app/components/shared/UserSelector";
@@ -53,7 +54,7 @@ export const SelectParticipantsStep: React.FC = ({
}
>
diff --git a/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.tsx b/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.tsx
index d316dd521a..ef1cb54aa4 100644
--- a/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.tsx
+++ b/frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.tsx
@@ -1,5 +1,6 @@
import { useRef, useEffect, useState } from "react";
-import { Stack, Button, Group, ColorPicker, Slider, Text } from "@mantine/core";
+import { Stack, Group, ColorPicker, Slider, Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -153,8 +154,8 @@ export const DrawSignatureCanvas: React.FC = ({
}
onClick={clearCanvas}
disabled={disabled || !signature}
diff --git a/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx
index a0875aa7ae..7b4950f4aa 100644
--- a/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx
+++ b/frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx
@@ -1,4 +1,4 @@
-import { SegmentedControl } from "@mantine/core";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
export type SignatureType = "draw" | "upload" | "type";
@@ -20,11 +20,11 @@ export const SignatureTypeSelector: React.FC = ({
onChange(val as SignatureType)}
- disabled={disabled}
- data={[
+ options={[
{
value: "draw",
label: t("certSign.collab.signRequest.signatureType.draw", "Draw"),
+ disabled,
},
{
value: "upload",
@@ -32,10 +32,12 @@ export const SignatureTypeSelector: React.FC = ({
"certSign.collab.signRequest.signatureType.upload",
"Upload",
),
+ disabled,
},
{
value: "type",
label: t("certSign.collab.signRequest.signatureType.type", "Type"),
+ disabled,
},
]}
fullWidth
diff --git a/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.tsx b/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.tsx
index 49d4c22ca4..2318b14cd1 100644
--- a/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.tsx
+++ b/frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.tsx
@@ -1,5 +1,6 @@
import { useState, useRef } from "react";
-import { Stack, Button, Text, Image } from "@mantine/core";
+import { Stack, Text, Image } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -100,8 +101,8 @@ export const UploadSignatureImage: React.FC = ({
}
onClick={handleClear}
disabled={disabled}
@@ -112,7 +113,7 @@ export const UploadSignatureImage: React.FC = ({
) : (
}
onClick={handleUploadClick}
disabled={disabled}
diff --git a/frontend/editor/src/core/components/toast/ToastRenderer.tsx b/frontend/editor/src/core/components/toast/ToastRenderer.tsx
index c13010e279..df2a871a5a 100644
--- a/frontend/editor/src/core/components/toast/ToastRenderer.tsx
+++ b/frontend/editor/src/core/components/toast/ToastRenderer.tsx
@@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next";
import { useToast } from "@app/components/toast/ToastContext";
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
import { LocalIcon } from "@app/components/shared/LocalIcon";
+import { ActionIcon } from "@app/ui/ActionIcon";
+import { Button } from "@app/ui/Button";
import "@app/components/toast/ToastRenderer.css";
const locationToClass: Record = {
@@ -99,7 +101,8 @@ export default function ToastRenderer() {
{/* Controls */}
{t.expandable && (
-
-
+
)}
- dismiss(t.id)}
className="toast-button"
>
-
-
+ ×
+
{/* Progress bar - always show when present */}
@@ -142,12 +146,12 @@ export default function ToastRenderer() {
{/* Button - always show when present, positioned below body */}
{t.buttonText && t.buttonCallback && (
-
{t.buttonText}
-
+
)}
diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx
index 7e3aa04629..4333e263f1 100644
--- a/frontend/editor/src/core/components/tools/RightSidebar.tsx
+++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx
@@ -1,5 +1,4 @@
import { useMemo, useState } from "react";
-import { ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useSidebarContext } from "@app/contexts/SidebarContext";
@@ -21,6 +20,7 @@ import type { SubcategoryGroup } from "@app/hooks/useToolSections";
import { ToolIcon } from "@app/components/shared/ToolIcon";
import { ToolPanelHeader } from "@app/components/shared/ToolPanelHeader";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { withViewTransition } from "@app/utils/viewTransition";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
@@ -224,13 +224,13 @@ export default function RightSidebar() {
@@ -248,18 +248,18 @@ export default function RightSidebar() {
arrow
delay={300}
>
-
{
handleExpand();
handleToolSelectWithTransition(id);
}}
- aria-label={tool.name}
>
-
+
))}
@@ -322,10 +322,9 @@ export default function RightSidebar() {
) : null}
{showCloseButton ? (
) : (
diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css
index e5949e365a..a8ad41588f 100644
--- a/frontend/editor/src/core/components/tools/ToolPanel.css
+++ b/frontend/editor/src/core/components/tools/ToolPanel.css
@@ -130,6 +130,7 @@
width 0.3s ease,
max-width 0.3s ease;
view-transition-name: tool-rail;
+ user-select: none;
}
.tool-panel__collapsed-strip {
diff --git a/frontend/editor/src/core/components/tools/ToolPanelModePrompt.tsx b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.tsx
index 3e3f072b69..f7f69ad2e7 100644
--- a/frontend/editor/src/core/components/tools/ToolPanelModePrompt.tsx
+++ b/frontend/editor/src/core/components/tools/ToolPanelModePrompt.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
-import { Badge, Button, Card, Group, Modal, Stack, Text } from "@mantine/core";
+import { Badge, Card, Group, Modal, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
+import { Button } from "@app/ui/Button";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { usePreferences } from "@app/contexts/PreferencesContext";
import "@app/components/tools/ToolPanelModePrompt.css";
@@ -122,9 +123,6 @@ const ToolPanelModePrompt = ({
handleSelect("sidebar")}
>
@@ -174,9 +172,6 @@ const ToolPanelModePrompt = ({
handleSelect("fullscreen")}
>
@@ -189,9 +184,7 @@ const ToolPanelModePrompt = ({
diff --git a/frontend/editor/src/core/components/tools/ToolPanelViewerBar.tsx b/frontend/editor/src/core/components/tools/ToolPanelViewerBar.tsx
index 5e01b4f711..f67499aed5 100644
--- a/frontend/editor/src/core/components/tools/ToolPanelViewerBar.tsx
+++ b/frontend/editor/src/core/components/tools/ToolPanelViewerBar.tsx
@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
-import { ActionIcon } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
import { useNavigationState } from "@app/contexts/NavigationContext";
import { Tooltip } from "@app/components/shared/Tooltip";
@@ -49,13 +49,11 @@ export function ToolPanelViewerBar() {
(typeof btn.tooltip === "string" ? btn.tooltip : undefined);
const buttonNode = (
{btn.icon}
diff --git a/frontend/editor/src/core/components/tools/ToolPicker.tsx b/frontend/editor/src/core/components/tools/ToolPicker.tsx
index af50c8ceab..27b0caf7d5 100644
--- a/frontend/editor/src/core/components/tools/ToolPicker.tsx
+++ b/frontend/editor/src/core/components/tools/ToolPicker.tsx
@@ -1,6 +1,7 @@
import React, { useMemo, useRef } from "react";
-import { Box, Button, Stack } from "@mantine/core";
+import { Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
+import { Button } from "@app/ui/Button";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import "@app/components/tools/toolPicker/ToolPicker.css";
import { useToolSections } from "@app/hooks/useToolSections";
@@ -33,7 +34,7 @@ const EMPTY_FILTERED_TOOLS: ToolPickerProps["filteredTools"] = [];
const HEADER_TEXT_STYLE: React.CSSProperties = {
fontSize: "0.68rem",
fontWeight: 600,
- padding: "1rem 0 0.35rem 0.5rem",
+ padding: "0.25rem 0 0.35rem 0.5rem",
textTransform: "uppercase",
letterSpacing: "0.06em",
color: "var(--text-muted)",
@@ -182,7 +183,7 @@ const ToolPicker = ({
)}
{onShowAllTools && (
(
@@ -55,10 +46,9 @@ const AddAttachmentsSettings = ({
style={{ display: "none" }}
id="attachments-input"
/>
- }
@@ -66,7 +56,7 @@ const AddAttachmentsSettings = ({
{parameters.attachments?.length > 0
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
: t("AddAttachmentsRequest.placeholder", "Choose files...")}
-
+
{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 (
-
onParameterChange(
@@ -277,7 +278,7 @@ export default function PageNumberPreview({
}}
>
{idx}
-
+
);
})}
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 (
{
onParameterChange("position", idx);
// Ensure we're using grid positioning, not custom overrides
@@ -63,11 +64,9 @@ const StampPositionFormattingSettings = ({
onParameterChange("overrideY", -1 as any);
}}
disabled={disabled}
- styles={{
- root: {
- height: "50px",
- padding: "0",
- },
+ style={{
+ height: "50px",
+ padding: 0,
}}
>
{idx}
@@ -83,9 +82,10 @@ const StampPositionFormattingSettings = ({
content={t("AddStampRequest.rotation", "Rotation")}
position="top"
>
- onParameterChange("_activePill", "rotation")}
@@ -95,21 +95,22 @@ const StampPositionFormattingSettings = ({
width="1.1rem"
height="1.1rem"
/>
-
+
- onParameterChange("_activePill", "opacity")}
>
-
+
- onParameterChange("_activePill", "fontSize")}
@@ -131,7 +137,7 @@ const StampPositionFormattingSettings = ({
width="1.1rem"
height="1.1rem"
/>
-
+
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 (
- {
// Clear overrides to use grid positioning and set position
@@ -443,7 +444,7 @@ export default function StampPreview({
onMouseLeave={() => setHoverTile(null)}
>
{idx}
-
+
);
})}
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/adjustPageScale/AdjustPageScaleSettings.tsx b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx
index 4012909f2a..aeb874b58e 100644
--- a/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx
+++ b/frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx
@@ -1,4 +1,8 @@
-import { Stack, NumberInput, Select, SegmentedControl } from "@mantine/core";
+import { Stack, NumberInput, Select } from "@mantine/core";
+import {
+ SegmentedControl,
+ type SegmentedOption,
+} from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import {
AdjustPageScaleParameters,
@@ -47,14 +51,17 @@ const AdjustPageScaleSettings = ({
},
];
- const orientationOptions = [
+ const orientationDisabled = disabled || isKeepSelected;
+ const orientationOptions: SegmentedOption[] = [
{
value: "PORTRAIT",
label: t("adjustPageScale.orientation.portrait", "Portrait"),
+ disabled: orientationDisabled,
},
{
value: "LANDSCAPE",
label: t("adjustPageScale.orientation.landscape", "Landscape"),
+ disabled: orientationDisabled,
},
];
@@ -99,8 +106,7 @@ const AdjustPageScaleSettings = ({
onChange={(value) =>
onParameterChange("orientation", value as Orientation)
}
- data={orientationOptions}
- disabled={disabled || isKeepSelected}
+ options={orientationOptions}
fullWidth
/>
diff --git a/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx b/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx
index 69116e3a43..65c50a3481 100644
--- a/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx
+++ b/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
- Button,
Text,
Stack,
Group,
@@ -10,6 +9,7 @@ import {
Divider,
Modal,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { Z_INDEX_AUTOMATE_MODAL } from "@app/styles/zIndex";
import CheckIcon from "@mui/icons-material/Check";
import DownloadIcon from "@mui/icons-material/Download";
@@ -302,16 +302,17 @@ export default function AutomationCreation({
}
onClick={() => {
downloadAutomationConfig(buildExportableAutomation());
}}
disabled={!canSaveAutomation()}
- variant="light"
>
{t("automate.creation.export", "Export")}
}
onClick={() => {
downloadFolderScanningConfig(
@@ -320,7 +321,6 @@ export default function AutomationCreation({
);
}}
disabled={!canSaveAutomation()}
- variant="light"
>
{t(
"automate.creation.exportForFolderScanning",
@@ -360,10 +360,10 @@ export default function AutomationCreation({
)}
-
+
{t("automate.creation.unsavedChanges.cancel", "Cancel")}
-
+
{t("automate.creation.unsavedChanges.confirm", "Go Back")}
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)}
>
-
{buttonContent}
-
+
{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({
)}
-
+
{t("automate.importModal.cancel", "Cancel")}
{hasResults && (
-
+
{t("automate.sequence.finish", "Finish")}
)}
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}
>
- setIsDropdownOpen(!isDropdownOpen)}
style={{
- width: size === "sm" ? "3.75rem" : "4.375rem",
+ width: size === "sm" ? "2.5rem" : "3rem",
position: "relative",
display: "flex",
justifyContent: "flex-start",
@@ -84,7 +78,7 @@ export default function IconSelector({
transform: "translateY(-50%)",
}}
/>
-
+
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({
}
onClick={onCancel}
>
diff --git a/frontend/editor/src/core/components/tools/automate/ToolList.tsx b/frontend/editor/src/core/components/tools/automate/ToolList.tsx
index e57e96daa3..ad67ed4aa9 100644
--- a/frontend/editor/src/core/components/tools/automate/ToolList.tsx
+++ b/frontend/editor/src/core/components/tools/automate/ToolList.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
-import { Text, Stack, Group, ActionIcon } from "@mantine/core";
+import { Text, Stack, Group } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import SettingsIcon from "@mui/icons-material/Settings";
import CloseIcon from "@mui/icons-material/Close";
import AddCircleOutline from "@mui/icons-material/AddCircleOutlined";
@@ -75,17 +76,23 @@ export default function ToolList({
tool.operation && !tool.configured ? "0" : "1px",
}}
>
- {/* Delete X in top right - only show for tools after the first 2 */}
+ {/* Delete X - centered vertically, anchored right */}
{index > 1 && (
onToolRemove(index)}
+ aria-label={t(
+ "automate.creation.tools.remove",
+ "Remove tool",
+ )}
title={t("automate.creation.tools.remove", "Remove tool")}
style={{
position: "absolute",
- top: "4px",
- right: "4px",
+ top: "50%",
+ right: "8px",
+ transform: "translateY(-50%)",
zIndex: 1,
color: "var(--mantine-color-gray-6)",
}}
@@ -94,7 +101,7 @@ export default function ToolList({
)}
-
+
{/* Tool Selection Dropdown with inline settings cog */}
@@ -113,9 +120,13 @@ export default function ToolList({
{/* Settings cog - only show if tool is selected, aligned right */}
{tool.operation && (
onToolConfigure(index)}
+ aria-label={t(
+ "automate.creation.tools.configure",
+ "Configure tool",
+ )}
title={t(
"automate.creation.tools.configure",
"Configure tool",
diff --git a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx
index 5c2c5059d3..fab1f3ff5e 100644
--- a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx
+++ b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx
@@ -5,10 +5,10 @@ import {
Text,
Divider,
Collapse,
- Button,
NumberInput,
Checkbox,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { BookletImpositionParameters } from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector";
@@ -117,7 +117,7 @@ const BookletImpositionSettings = ({
{/* Advanced Options */}
setAdvancedOpen(!advancedOpen)}
disabled={disabled}
>
diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx
index 135d564537..5be808b492 100644
--- a/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.tsx
@@ -1,4 +1,5 @@
-import { Stack, Button } from "@mantine/core";
+import { Stack } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters";
interface CertificateFormatSettingsProps {
@@ -18,10 +19,7 @@ const CertificateFormatSettings = ({
{/* First row - PKCS#12 and PFX */}
onParameterChange("certType", "PKCS12")}
disabled={disabled}
style={{
@@ -42,8 +40,7 @@ const CertificateFormatSettings = ({
onParameterChange("certType", "PFX")}
disabled={disabled}
style={{
@@ -67,8 +64,7 @@ const CertificateFormatSettings = ({
{/* Second row - PEM and JKS */}
onParameterChange("certType", "PEM")}
disabled={disabled}
style={{
@@ -89,8 +85,7 @@ const CertificateFormatSettings = ({
onParameterChange("certType", "JKS")}
disabled={disabled}
style={{
diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateSelector.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateSelector.tsx
index be2f1d6102..0c09b8c5e6 100644
--- a/frontend/editor/src/core/components/tools/certSign/CertificateSelector.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/CertificateSelector.tsx
@@ -1,12 +1,5 @@
-import {
- Stack,
- Radio,
- Divider,
- TextInput,
- Text,
- Group,
- Button,
-} from "@mantine/core";
+import { Stack, Radio, Divider, TextInput, Text, Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -158,8 +151,8 @@ export const CertificateSelector: React.FC = ({
{(["PKCS12", "PFX", "PEM", "JKS"] as UploadFormat[]).map((fmt) => (
handleFormatChange(fmt)}
disabled={disabled}
>
diff --git a/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx
index a64474b7b8..4311c5f6ca 100644
--- a/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.tsx
@@ -1,5 +1,6 @@
+import { Button } from "@app/ui/Button";
import { useEffect } from "react";
-import { Stack, Button } from "@mantine/core";
+import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters";
import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -17,11 +18,6 @@ const sourceButtonStyle = {
fontSize: "11px",
} as const;
-// Let labels wrap instead of clipping ("This device" was truncating to "This devi").
-const sourceButtonStyles = {
- label: { whiteSpace: "normal" as const, lineHeight: 1.15 },
-} as const;
-
const CertificateTypeSettings = ({
parameters,
onParameterChange,
@@ -73,45 +69,52 @@ const CertificateTypeSettings = ({
onParameterChange("alias", undefined);
};
+ const hasAlternativeSources =
+ isServerCertificateEnabled || isHardwareAvailable;
+
+ if (!hasAlternativeSources) {
+ return (
+
+
+ {t(
+ "certSign.source.noOtherSources",
+ "No other certificate sources are available.",
+ )}
+
+
+ );
+ }
+
return (
{t("certSign.source.upload", "Upload")}
{isServerCertificateEnabled && (
{t("certSign.source.server", "Server")}
)}
{isHardwareAvailable && (
{t("certSign.source.device", "This device")}
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 && (
selectKind("WINDOWS_STORE")}
disabled={disabled || loading}
- style={{ flex: 1, fontSize: "11px", minHeight: 40, height: "auto" }}
- styles={{ label: { whiteSpace: "normal", lineHeight: 1.15 } }}
+ style={{
+ flex: 1,
+ fontSize: "11px",
+ minHeight: 40,
+ height: "auto",
+ whiteSpace: "normal",
+ lineHeight: 1.15,
+ }}
>
{t("certSign.format.windowsStore", "Windows certificate store")}
selectKind("PKCS11")}
disabled={disabled || loading}
- style={{ flex: 1, fontSize: "11px", minHeight: 40, height: "auto" }}
- styles={{ label: { whiteSpace: "normal", lineHeight: 1.15 } }}
+ style={{
+ flex: 1,
+ fontSize: "11px",
+ minHeight: 40,
+ height: "auto",
+ whiteSpace: "normal",
+ lineHeight: 1.15,
+ }}
>
{t("certSign.format.pkcs11", "USB Token")}
@@ -332,7 +344,7 @@ const HardwareCertificateSettings = ({
)}
/>
@@ -408,7 +420,7 @@ const HardwareCertificateSettings = ({
/>
onParameterChange("showSignature", false)}
disabled={disabled}
style={{
@@ -43,8 +44,8 @@ const SignatureAppearanceSettings = ({
onParameterChange("showSignature", true)}
disabled={disabled}
style={{
@@ -110,8 +111,8 @@ const SignatureAppearanceSettings = ({
onParameterChange("showLogo", false)}
disabled={disabled}
style={{
@@ -132,8 +133,8 @@ const SignatureAppearanceSettings = ({
onParameterChange("showLogo", true)}
disabled={disabled}
style={{
diff --git a/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.tsx b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.tsx
index 812d34749c..d0e7a69d2f 100644
--- a/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.tsx
@@ -1,11 +1,5 @@
-import {
- Stack,
- Text,
- Button,
- TextInput,
- NumberInput,
- Switch,
-} from "@mantine/core";
+import { Stack, Text, TextInput, NumberInput, Switch } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
export interface SignatureSettings {
@@ -49,8 +43,8 @@ const SignatureSettingsInput = ({
{/* Signature Visibility */}
handleChange("showSignature", false)}
disabled={disabled}
style={{
@@ -67,8 +61,8 @@ const SignatureSettingsInput = ({
handleChange("showSignature", true)}
disabled={disabled}
style={{
diff --git a/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx
index 57c0ec27ff..f87fda19fe 100644
--- a/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/WetSignatureInput.tsx
@@ -2,13 +2,13 @@ import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Stack,
- SegmentedControl,
Text,
Radio,
FileInput,
PasswordInput,
Divider,
} from "@mantine/core";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
@@ -241,12 +241,11 @@ const WetSignatureInput = ({
onChange={(value) =>
handleSignatureTypeChange(value as SignatureType)
}
- data={[
- { label: t("sign.type.canvas", "Draw"), value: "canvas" },
- { label: t("sign.type.image", "Upload"), value: "image" },
- { label: t("sign.type.text", "Type"), value: "text" },
+ options={[
+ { label: t("sign.type.canvas", "Draw"), value: "canvas", disabled },
+ { label: t("sign.type.image", "Upload"), value: "image", disabled },
+ { label: t("sign.type.text", "Type"), value: "text", disabled },
]}
- disabled={disabled}
/>
diff --git a/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.tsx b/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.tsx
index f9be2f9787..b7e514d1aa 100644
--- a/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
-import { Modal, Stack, TextInput, Button, Group } from "@mantine/core";
+import { Modal, Stack, TextInput, Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import UserSelector from "@app/components/shared/UserSelector";
@@ -74,15 +75,15 @@ export const AddParticipantsFlow: React.FC = ({
/>
-
+
{t("common.cancel", "Cancel")}
}
- color="green"
>
{t(
"certSign.collab.addParticipants.add",
diff --git a/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx b/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx
index 0032f186e2..f11e24596c 100644
--- a/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx
@@ -2,12 +2,12 @@ import {
Modal,
Stack,
Group,
- Button,
Text,
Collapse,
TextInput,
Loader,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { useState, useEffect, useRef } from "react";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
@@ -286,8 +286,8 @@ export const CertificateConfigModal: React.FC = ({
{/* Advanced Settings - Optional */}
setShowAdvanced(!showAdvanced)}
disabled={disabled || signing}
style={{ marginBottom: "8px" }}
@@ -331,7 +331,7 @@ export const CertificateConfigModal: React.FC = ({
-
+
{t("cancel", "Cancel")}
= ({
overflow: "hidden",
}}
>
- {
onSignatureSelected(sig);
onClose();
@@ -144,10 +139,10 @@ export const SelectSignatureModal: React.FC = ({
style={{ flex: 1, padding: "12px" }}
>
{renderSignaturePreview(sig)}
-
+
removeSignature(sig.id)}
aria-label={t(
@@ -177,7 +172,7 @@ export const SelectSignatureModal: React.FC = ({
}
onClick={() => {
onCreateNew("canvas");
@@ -187,7 +182,7 @@ export const SelectSignatureModal: React.FC = ({
{t("certSign.collab.signRequest.modeTabs.draw", "Draw")}
}
onClick={() => {
onCreateNew("text");
@@ -197,7 +192,7 @@ export const SelectSignatureModal: React.FC = ({
{t("certSign.collab.signRequest.modeTabs.text", "Type")}
}
onClick={() => {
onCreateNew("image");
diff --git a/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.tsx
index 47c6ac008f..5045a0544e 100644
--- a/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.tsx
@@ -1,4 +1,5 @@
-import { Stack, Text, List, Group, Badge, ActionIcon } from "@mantine/core";
+import { Stack, Text, List, Group, Badge } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import PendingIcon from "@mui/icons-material/Pending";
@@ -84,13 +85,17 @@ export const ParticipantListPanel: React.FC = ({
{!finalized && !isSigned && !isDeclined && (
onRemove(participant.id)}
title={t(
"certSign.collab.sessionDetail.removeParticipant",
"Remove",
)}
+ aria-label={t(
+ "certSign.collab.sessionDetail.removeParticipant",
+ "Remove",
+ )}
>
diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.tsx
index 51601cfaaf..7b98bf868b 100644
--- a/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.tsx
@@ -1,4 +1,5 @@
-import { Stack, Text, Button, Divider, Paper } from "@mantine/core";
+import { Stack, Text, Divider, Paper } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import AddIcon from "@mui/icons-material/Add";
@@ -57,9 +58,9 @@ export const SessionActionsPanel: React.FC = ({
<>
}
onClick={onAddParticipants}
- variant="light"
fullWidth
>
{t(
@@ -72,7 +73,7 @@ export const SessionActionsPanel: React.FC = ({
}
- color={allSigned ? "green.6" : "orange"}
+ accent={allSigned ? "success" : "warning"}
fullWidth
onClick={onFinalize}
loading={finalizing}
@@ -94,7 +95,6 @@ export const SessionActionsPanel: React.FC = ({
<>
}
- color="blue"
fullWidth
onClick={onLoadSignedPdf}
loading={loadingPdf}
diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.tsx
index 04539d39b5..3ec2a9114e 100644
--- a/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.tsx
@@ -1,14 +1,7 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
-import {
- Stack,
- Text,
- Group,
- Badge,
- Button,
- Divider,
- Modal,
-} from "@mantine/core";
+import { Stack, Text, Group, Badge, Divider, Modal } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { alert } from "@app/components/toast";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -159,11 +152,10 @@ export const SessionDetailPanel = ({ data }: SessionDetailPanelProps) => {
}
- variant="subtle"
+ variant="tertiary"
size="sm"
onClick={onBack}
- justify="flex-start"
- px={6}
+ justify="start"
style={{ alignSelf: "flex-start" }}
>
{t("certSign.collab.sessionDetail.backToList", "Back to Sessions")}
@@ -220,8 +212,8 @@ export const SessionDetailPanel = ({ data }: SessionDetailPanelProps) => {
{!session.finalized && (
}
- color="red"
- variant="light"
+ accent="danger"
+ variant="tertiary"
fullWidth
onClick={() => setDeleteModalOpen(true)}
>
@@ -253,10 +245,13 @@ export const SessionDetailPanel = ({ data }: SessionDetailPanelProps) => {
)}
- setDeleteModalOpen(false)}>
+ setDeleteModalOpen(false)}
+ >
{t("cancel", "Cancel")}
-
+
{t("delete", "Delete")}
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({
}
- styles={{ label: { flex: 1, overflow: "hidden" } }}
aria-label={t(
"certSign.collab.signRequest.changeSignature",
"Change signature",
@@ -396,10 +388,10 @@ export default function SignControlsPanel({
{renderSavedSignaturePreview(sig)}
{
e.stopPropagation();
removeSignature(sig.id);
@@ -441,7 +433,7 @@ export default function SignControlsPanel({
fullWidth
value={placementMode ? "place" : "move"}
onChange={(value) => onPlacementModeChange(value === "place")}
- data={[
+ options={[
{
value: "place",
label: (
@@ -466,16 +458,15 @@ export default function SignControlsPanel({
},
]}
size="xs"
- radius="xl"
- aria-label={t(
+ ariaLabel={t(
"certSign.collab.signRequest.mode.title",
"Sign or move mode",
)}
/>
}
onClick={onDeleteSelected}
disabled={!hasSelectedAnnotation}
diff --git a/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx b/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx
index 7a5c021de8..2b128ebd11 100644
--- a/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/panels/SignRequestPanel.tsx
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
-import { Stack, Button, Text, Divider } from "@mantine/core";
+import { Stack, Text, Divider } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import CancelIcon from "@mui/icons-material/Cancel";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
@@ -272,11 +273,10 @@ const SignRequestPanel = ({ data }: SignRequestPanelProps) => {
}
- variant="subtle"
+ variant="tertiary"
size="sm"
onClick={onBack}
- justify="flex-start"
- px={6}
+ justify="start"
style={{ alignSelf: "flex-start" }}
>
{t("certSign.collab.signRequest.backToList", "Back to Sign Requests")}
@@ -312,7 +312,7 @@ const SignRequestPanel = ({ data }: SignRequestPanelProps) => {
)}
}
onClick={handleAddToActiveFiles}
fullWidth
@@ -328,8 +328,8 @@ const SignRequestPanel = ({ data }: SignRequestPanelProps) => {
{signRequest.myStatus !== "SIGNED" &&
signRequest.myStatus !== "DECLINED" && (
}
onClick={handleDecline}
loading={declining}
diff --git a/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.tsx b/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.tsx
index e08ff2361d..bde8102d19 100644
--- a/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.tsx
@@ -1,5 +1,6 @@
import { useState } from "react";
-import { Button, Stack, Text, Paper } from "@mantine/core";
+import { Stack, Text, Paper } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import CancelIcon from "@mui/icons-material/Cancel";
@@ -118,11 +119,11 @@ export const AddSignaturesStep: React.FC = ({
) : (
}
onClick={onCancelPlacement}
disabled={disabled}
- variant="light"
- color="red"
>
{t(
"certSign.collab.signRequest.steps.cancelPlacement",
diff --git a/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.tsx b/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.tsx
index 6df5368339..1566e20334 100644
--- a/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.tsx
@@ -1,4 +1,5 @@
-import { Button, Stack, Group } from "@mantine/core";
+import { Stack, Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import {
@@ -78,7 +79,7 @@ export const CertificateSelectionStep: React.FC<
}
>
diff --git a/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.tsx b/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.tsx
index d46c780309..8d654a8896 100644
--- a/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.tsx
@@ -1,4 +1,5 @@
-import { Button, Stack, Text, Group, Divider, Paper } from "@mantine/core";
+import { Stack, Text, Group, Divider, Paper } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DrawIcon from "@mui/icons-material/Draw";
@@ -167,15 +168,15 @@ export const ReviewSignatureStep: React.FC = ({
{/* Action Buttons */}
}
>
{t("certSign.collab.signRequest.steps.back", "Back")}
}
@@ -183,10 +184,10 @@ export const ReviewSignatureStep: React.FC
= ({
{t("certSign.collab.signRequest.declineButton", "Decline")}
}
>
{t("certSign.collab.signRequest.signButton", "Sign Document")}
diff --git a/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.tsx b/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.tsx
index 7f1ed24125..c24fa1241b 100644
--- a/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.tsx
@@ -1,4 +1,5 @@
-import { Button, Stack } from "@mantine/core";
+import { Stack } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import {
SignatureTypeSelector,
diff --git a/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.tsx b/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.tsx
index c6010f27b3..a9dfb0cf03 100644
--- a/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.tsx
+++ b/frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.tsx
@@ -1,4 +1,5 @@
-import { Button, Stack, Text, Group } from "@mantine/core";
+import { Stack, Text, Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
@@ -41,7 +42,7 @@ export const SignaturePlacementStep: React.FC = ({
}
>
diff --git a/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.tsx b/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.tsx
index ea6e85833a..66a608b4ee 100644
--- a/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.tsx
+++ b/frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.tsx
@@ -1,4 +1,5 @@
-import { Stack, TextInput, Button, Group, Text } from "@mantine/core";
+import { Stack, TextInput, Group, Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { ChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
@@ -30,8 +31,8 @@ const CustomMetadataStep = ({
{t("changeMetadata.customFields.title", "Custom Metadata")}
addCustomMetadata()}
disabled={disabled}
>
@@ -73,9 +74,9 @@ const CustomMetadataStep = ({
disabled={disabled}
/>
removeCustomMetadata(entry.id)}
disabled={disabled}
>
diff --git a/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx
index d0b16336a7..d6e3c55f71 100644
--- a/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx
+++ b/frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
-import { Badge, Group, SegmentedControl, Stack, Text } from "@mantine/core";
+import { Badge, Group, Stack, Text } from "@mantine/core";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -167,10 +168,10 @@ const ComparePixelWorkbenchView = ({
setViewMode(value as PixelViewMode)}
- data={[
+ options={[
{
value: "side-by-side",
label: t("compare.pixel.sideBySide", "Side-by-side"),
diff --git a/frontend/editor/src/core/components/tools/compare/compareView.css b/frontend/editor/src/core/components/tools/compare/compareView.css
index d65c5fa866..e399f98b40 100644
--- a/frontend/editor/src/core/components/tools/compare/compareView.css
+++ b/frontend/editor/src/core/components/tools/compare/compareView.css
@@ -550,7 +550,7 @@
gap: 0.5rem;
}
-/* Full-height light bar to the right of both cards (reference UI) */
+/* Full-height bar to the right of both cards */
.compare-step-selection__swap {
flex-shrink: 0;
width: 36px;
@@ -558,48 +558,15 @@
align-self: stretch;
margin: 0;
padding: 0.5rem 0.2rem;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
+ display: flex !important;
+ flex-direction: column !important;
+ align-items: center !important;
+ justify-content: center !important;
gap: 0.35rem;
- border-radius: var(--radius-md);
- border: 1px solid
- color-mix(in srgb, var(--mantine-color-blue-4) 28%, var(--border-default));
- background: color-mix(
- in srgb,
- var(--mantine-color-blue-0) 92%,
- var(--mantine-color-body)
- );
- color: var(--mantine-color-blue-filled);
- cursor: pointer;
- font: inherit;
- transition:
- background 0.12s ease,
- border-color 0.12s ease;
-}
-
-.compare-step-selection__swap:hover:not(:disabled) {
- background: color-mix(
- in srgb,
- var(--mantine-color-blue-1) 75%,
- var(--mantine-color-body)
- );
- border-color: color-mix(
- in srgb,
- var(--mantine-color-blue-5) 35%,
- var(--border-default)
- );
-}
-
-.compare-step-selection__swap:disabled {
- opacity: 0.45;
- cursor: not-allowed;
}
.compare-step-selection__swap-icon {
font-size: 1.35rem !important;
- color: var(--mantine-color-blue-filled);
}
.compare-step-selection__swap-label {
@@ -607,36 +574,6 @@
font-weight: 600;
line-height: 1.15;
text-align: center;
- color: var(--mantine-color-blue-filled);
- writing-mode: horizontal-tb;
-}
-
-[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap {
- background: color-mix(
- in srgb,
- var(--mantine-color-blue-9) 22%,
- var(--mantine-color-dark-6)
- );
- border-color: color-mix(
- in srgb,
- var(--mantine-color-blue-4) 40%,
- var(--border-default)
- );
- color: var(--mantine-color-blue-3);
-}
-
-[data-mantine-color-scheme="”dark”"]
- .compare-step-selection__swap:hover:not(:disabled) {
- background: color-mix(
- in srgb,
- var(--mantine-color-blue-9) 32%,
- var(--mantine-color-dark-6)
- );
-}
-
-[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap-icon,
-[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap-label {
- color: var(--mantine-color-blue-3);
}
/* Pixel compare mode */
diff --git a/frontend/editor/src/core/components/tools/compress/CompressSettings.tsx b/frontend/editor/src/core/components/tools/compress/CompressSettings.tsx
index 1c5cb8ef4d..f0cd39b2c0 100644
--- a/frontend/editor/src/core/components/tools/compress/CompressSettings.tsx
+++ b/frontend/editor/src/core/components/tools/compress/CompressSettings.tsx
@@ -6,10 +6,10 @@ import {
Divider,
Checkbox,
Slider,
- SegmentedControl,
Tooltip,
Box,
} from "@mantine/core";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import SliderWithInput from "@app/components/shared/sliderWithInput/SliderWithInput";
import { useTranslation } from "react-i18next";
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
@@ -258,14 +258,22 @@ const CompressSettings = ({
diff --git a/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx
index 5c0843660f..cc33ccbbb4 100644
--- a/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx
+++ b/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx
@@ -1,12 +1,6 @@
import { useMemo } from "react";
-import {
- Stack,
- Text,
- Group,
- Divider,
- UnstyledButton,
- useMantineTheme,
-} from "@mantine/core";
+import { Stack, Text, Group, Divider, useMantineTheme } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { useTranslation } from "react-i18next";
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
@@ -336,7 +330,11 @@ const ConvertSettings = ({
{t("convert.convertTo", "Convert to")}:
{!parameters.fromExtension ? (
-
-
+
{t(
"convert.selectSourceFormatFirst",
@@ -360,7 +358,7 @@ const ConvertSettings = ({
}}
/>
-
+
) : (
- 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({
}
onMouseDown={(e) => {
e.preventDefault();
@@ -432,8 +441,7 @@ export default function BookmarkEditor({
)}
}
onMouseDown={(e) => {
e.preventDefault();
diff --git a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.tsx b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.tsx
index 32c5087505..387e1ffe6e 100644
--- a/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.tsx
+++ b/frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.tsx
@@ -1,15 +1,8 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
-import {
- Alert,
- Button,
- Divider,
- FileButton,
- Stack,
- Switch,
- Text,
- Tooltip,
-} from "@mantine/core";
+import { Alert, Divider, Stack, Switch, Text, Tooltip } from "@mantine/core";
+import { Button as DSButton } from "@app/ui/Button";
+import { FilePicker } from "@app/ui/FilePicker";
import LocalIcon from "@app/components/shared/LocalIcon";
import { BookmarkNode } from "@app/utils/editTableOfContents";
@@ -87,8 +80,8 @@ export default function EditTableOfContentsSettings({
- }
onClick={onSelectFiles}
fullWidth
@@ -96,8 +89,7 @@ export default function EditTableOfContentsSettings({
{selectedFileName
? t("editTableOfContents.workbench.changeFile", "Change PDF")
: t("editTableOfContents.workbench.selectFile", "Select PDF")}
-
-
+
- }
onClick={onLoadFromPdf}
loading={isLoading}
@@ -118,27 +110,18 @@ export default function EditTableOfContentsSettings({
fullWidth
>
{t("editTableOfContents.actions.loadFromPdf", "Load from PDF")}
-
+
-
- file && onImportJson(file)}
accept="application/json"
disabled={disabled}
+ variant="secondary"
+ leftSection={ }
+ fullWidth
>
- {(props) => (
- }
- disabled={disabled}
- fullWidth
- >
- {t("editTableOfContents.actions.importJson", "Import JSON")}
-
- )}
-
-
+ {t("editTableOfContents.actions.importJson", "Import JSON")}
+
- }
onClick={onImportClipboard}
disabled={disabled || !canReadClipboard}
@@ -161,7 +144,7 @@ export default function EditTableOfContentsSettings({
"editTableOfContents.actions.importClipboard",
"Paste from clipboard",
)}
-
+
@@ -184,16 +167,15 @@ export default function EditTableOfContentsSettings({
- }
onClick={onExportJson}
disabled={disabled || bookmarks.length === 0}
fullWidth
>
{t("editTableOfContents.actions.exportJson", "Download JSON")}
-
-
+
- }
onClick={onExportClipboard}
disabled={disabled || bookmarks.length === 0 || !canWriteClipboard}
@@ -216,7 +198,7 @@ export default function EditTableOfContentsSettings({
"editTableOfContents.actions.exportClipboard",
"Copy to clipboard",
)}
-
+
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 = ({
}
- color="blue"
onClick={onExecute}
disabled={isExecuteDisabled}
loading={isExecuting}
@@ -213,7 +213,7 @@ const EditTableOfContentsWorkbenchView = ({
)}
}
onClick={onUndo}
disabled={isExecuting}
diff --git a/frontend/editor/src/core/components/tools/fullscreen/CompactToolItem.tsx b/frontend/editor/src/core/components/tools/fullscreen/CompactToolItem.tsx
index a6f4d257b3..37300cfeec 100644
--- a/frontend/editor/src/core/components/tools/fullscreen/CompactToolItem.tsx
+++ b/frontend/editor/src/core/components/tools/fullscreen/CompactToolItem.tsx
@@ -15,6 +15,7 @@ import {
useToolMeta,
getDisabledLabel,
} from "@app/components/tools/fullscreen/shared";
+import { Button } from "@app/ui/Button";
interface CompactToolItemProps {
id: string;
@@ -53,27 +54,31 @@ const CompactToolItem: React.FC = ({
iconNode = tool.icon;
}
+ const iconElement = tool.icon ? (
+
+ {iconNode}
+
+ ) : undefined;
+
const compactButton = (
-
- {tool.icon ? (
-
- {iconNode}
-
- ) : null}
{tool.name}
@@ -93,7 +98,7 @@ const CompactToolItem: React.FC = ({
/>
)}
-
+
);
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")}
selectedFile && handleDownload(selectedFile)}
disabled={!selectedFile}
fullWidth
diff --git a/frontend/editor/src/core/components/tools/merge/MergeFileSorter.tsx b/frontend/editor/src/core/components/tools/merge/MergeFileSorter.tsx
index 5a7e8c994f..61300fbb68 100644
--- a/frontend/editor/src/core/components/tools/merge/MergeFileSorter.tsx
+++ b/frontend/editor/src/core/components/tools/merge/MergeFileSorter.tsx
@@ -1,5 +1,7 @@
import React, { useState } from "react";
-import { Group, Button, Text, ActionIcon, Stack, Select } from "@mantine/core";
+import { Group, Text, Stack, Select } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import SortIcon from "@mui/icons-material/Sort";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
@@ -67,7 +69,7 @@ const MergeFileSorter: React.FC = ({
/>
= ({
? t("merge.sortBy.ascending", "Ascending")
: t("merge.sortBy.descending", "Descending")
}
+ aria-label={
+ ascending
+ ? t("merge.sortBy.ascending", "Ascending")
+ : t("merge.sortBy.descending", "Descending")
+ }
>
{ascending ? : }
}
onClick={handleSort}
disabled={disabled}
diff --git a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx
index ce059c9204..2648990248 100644
--- a/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx
+++ b/frontend/editor/src/core/components/tools/overlayPdfs/OverlayPdfsSettings.tsx
@@ -3,12 +3,12 @@ import {
Text,
Group,
Select,
- SegmentedControl,
NumberInput,
- Button,
- ActionIcon,
Divider,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import {
type OverlayPdfsParameters,
@@ -121,17 +121,18 @@ export default function OverlayPdfsSettings({
onChange={(v) =>
onParameterChange("overlayPosition", (v === "1" ? 1 : 0) as 0 | 1)
}
- data={[
+ options={[
{
label: t("overlay-pdfs.position.foreground", "Foreground"),
value: "0",
+ disabled,
},
{
label: t("overlay-pdfs.position.background", "Background"),
value: "1",
+ disabled,
},
]}
- disabled={disabled}
/>
@@ -183,8 +184,7 @@ export default function OverlayPdfsSettings({
{t("overlay-pdfs.overlayFiles.label", "Overlay Files")}
}
@@ -219,8 +219,8 @@ export default function OverlayPdfsSettings({
{
const next = (parameters.overlayFiles || []).filter(
@@ -229,6 +229,7 @@ export default function OverlayPdfsSettings({
handleOverlayFilesChange(next);
}}
disabled={disabled}
+ aria-label={t("remove", "Remove")}
>
{
header={pdfTextEditorTips.header}
pinOnClick
>
-
+
{
position="top"
>
@@ -275,7 +282,7 @@ const PdfTextEditorSidebar = ({ data }: PdfTextEditorSidebarProps) => {
onChange={(value) =>
handleModeChangeRequest(value as GroupingMode)
}
- data={[
+ options={[
{
label: t("pdfTextEditor.groupingMode.auto", "Auto"),
value: "auto",
@@ -316,9 +323,12 @@ const PdfTextEditorSidebar = ({ data }: PdfTextEditorSidebarProps) => {
position="top"
>
@@ -357,7 +367,6 @@ const PdfTextEditorSidebar = ({ data }: PdfTextEditorSidebarProps) => {
{
@@ -410,10 +423,10 @@ const PdfTextEditorSidebar = ({ data }: PdfTextEditorSidebarProps) => {
)}
-
+
{t("pdfTextEditor.modeChange.cancel", "Cancel")}
-
+
{t("pdfTextEditor.modeChange.confirm", "Reset and Change Mode")}
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 && (
{
{t("pdfTextEditor.welcomeBanner.gotIt", "Got it")}
@@ -2126,8 +2126,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
>
{
>
{
diff --git a/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.test.tsx b/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.test.tsx
index fa320edeff..7f57b8299e 100644
--- a/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.test.tsx
+++ b/frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.test.tsx
@@ -34,10 +34,9 @@ describe("RedactSingleStepSettings", () => {
);
expect(screen.getByText("Mode")).toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: "Automatic" }),
- ).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Manual" })).toBeInTheDocument();
+ // Mode selector renders as a radio-group (shared SegmentedControl)
+ expect(screen.getByText("Automatic")).toBeInTheDocument();
+ expect(screen.getByText("Manual")).toBeInTheDocument();
});
test("should render automatic mode settings when mode is automatic", () => {
@@ -126,7 +125,7 @@ describe("RedactSingleStepSettings", () => {
});
test("should disable all controls when disabled prop is true", () => {
- render(
+ const { container } = render(
{
,
);
- // Mode selector buttons should be disabled
- expect(screen.getByRole("button", { name: "Automatic" })).toBeDisabled();
- expect(screen.getByRole("button", { name: "Manual" })).toBeDisabled();
+ // Mode selector renders as radio inputs (shared SegmentedControl); both
+ // radios should be disabled when the control is disabled.
+ expect(
+ container.querySelector('input[type="radio"][value="automatic"]'),
+ ).toBeDisabled();
+ expect(
+ container.querySelector('input[type="radio"][value="manual"]'),
+ ).toBeDisabled();
// Automatic settings controls should be disabled
expect(screen.getByPlaceholderText("Enter a word")).toBeDisabled();
diff --git a/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.tsx b/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.tsx
index db346bd4ec..018a04a2b7 100644
--- a/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.tsx
+++ b/frontend/editor/src/core/components/tools/redact/WordsToRedactInput.tsx
@@ -1,13 +1,8 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
-import {
- Stack,
- Text,
- TextInput,
- Button,
- Group,
- ActionIcon,
-} from "@mantine/core";
+import { Stack, Text, TextInput, Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
interface WordsToRedactInputProps {
wordsToRedact: string[];
@@ -73,10 +68,11 @@ export default function WordsToRedactInput({
removeWord(index)}
disabled={disabled}
+ aria-label={t("remove", "Remove")}
>
×
@@ -99,7 +95,7 @@ export default function WordsToRedactInput({
/>
diff --git a/frontend/editor/src/core/components/tools/rotate/RotateSettings.tsx b/frontend/editor/src/core/components/tools/rotate/RotateSettings.tsx
index cae25c5a65..ba66790298 100644
--- a/frontend/editor/src/core/components/tools/rotate/RotateSettings.tsx
+++ b/frontend/editor/src/core/components/tools/rotate/RotateSettings.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState, useEffect } from "react";
-import { Stack, Text, Box, ActionIcon, Group, Center } from "@mantine/core";
+import { Stack, Text, Box, Group, Center } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
@@ -94,8 +95,8 @@ const RotateSettings = ({
{/* Rotation Controls */}
{
+ const { t } = useTranslation();
if (totalFiles <= 1) return null;
return (
,
+ ButtonVariant
+> = {
+ filled: "primary",
+ outline: "secondary",
+ subtle: "tertiary",
+};
+
+export const operationButtonAccentMap: Record = {
+ gray: "neutral",
+ grey: "neutral",
+ blue: "default",
+ red: "danger",
+ green: "success",
+ yellow: "warning",
+ violet: "premium",
+ grape: "premium",
+};
+
const OperationButton = ({
onClick,
isLoading = false,
@@ -77,31 +98,29 @@ const OperationButton = ({
? (reasonTooltip[disabledReason] ?? null)
: null;
+ const sharedVariant: ButtonVariant =
+ operationButtonVariantMap[variant] ?? "primary";
+ const sharedAccent: ButtonAccent =
+ operationButtonAccentMap[color] ?? "default";
+
const button = (
{isLoading
diff --git a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx
index 82e936678e..8c89029913 100644
--- a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx
+++ b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useRef } from "react";
-import { Button, Stack } from "@mantine/core";
+import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
+import { Button } from "@app/ui/Button";
import UndoIcon from "@mui/icons-material/Undo";
import ErrorNotification from "@app/components/tools/shared/ErrorNotification";
import ResultsPreview from "@app/components/tools/shared/ResultsPreview";
@@ -128,8 +129,8 @@ function ReviewStepContent({
>
}
- variant="outline"
- color="var(--mantine-color-gray-6)"
+ variant="secondary"
+ accent="neutral"
onClick={handleUndo}
fullWidth
>
@@ -141,9 +142,8 @@ function ReviewStepContent({
}
- color="blue"
fullWidth
- mb="md"
+ style={{ marginBottom: "1rem" }}
onClick={handleDownload}
>
{terminology.download}
diff --git a/frontend/editor/src/core/components/tools/shared/ScopedOperationButton.tsx b/frontend/editor/src/core/components/tools/shared/ScopedOperationButton.tsx
index 1761c91e4b..0dd351e95e 100644
--- a/frontend/editor/src/core/components/tools/shared/ScopedOperationButton.tsx
+++ b/frontend/editor/src/core/components/tools/shared/ScopedOperationButton.tsx
@@ -1,7 +1,10 @@
-import { Text, Box, Button } from "@mantine/core";
+import { Text, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
+import { Button, type ButtonVariant, type ButtonAccent } from "@app/ui/Button";
import OperationButton, {
OperationButtonProps,
+ operationButtonVariantMap,
+ operationButtonAccentMap,
} from "@app/components/tools/shared/OperationButton";
import { StirlingFile } from "@app/types/fileContext";
import { useAllFiles } from "@app/contexts/FileContext";
@@ -73,6 +76,11 @@ export function ScopedOperationButton({
allFiles.length > 0 &&
selectedFiles.length === 0;
+ const loadingVariant: ButtonVariant =
+ operationButtonVariantMap[props.variant ?? "filled"] ?? "primary";
+ const loadingAccent: ButtonAccent =
+ operationButtonAccentMap[props.color ?? "blue"] ?? "default";
+
return (
<>
{isFilesHydrating ? (
@@ -81,8 +89,8 @@ export function ScopedOperationButton({
fullWidth
disabled
loading={!isBulkLoading}
- variant={props.variant ?? "filled"}
- color={props.color ?? "blue"}
+ variant={loadingVariant}
+ accent={loadingAccent}
style={{
position: "relative",
overflow: "hidden",
diff --git a/frontend/editor/src/core/components/tools/showJS/ShowJSView.tsx b/frontend/editor/src/core/components/tools/showJS/ShowJSView.tsx
index 6585b1cddf..685d238e79 100644
--- a/frontend/editor/src/core/components/tools/showJS/ShowJSView.tsx
+++ b/frontend/editor/src/core/components/tools/showJS/ShowJSView.tsx
@@ -5,16 +5,9 @@ import React, {
useRef,
useState,
} from "react";
-import {
- ActionIcon,
- Box,
- Button,
- Group,
- Stack,
- Text,
- ScrollArea,
- TextInput,
-} from "@mantine/core";
+import { Box, Group, Stack, Text, ScrollArea, TextInput } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded";
import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
@@ -168,7 +161,7 @@ const ShowJSView: React.FC = ({ data }) => {
{
if (matches.length)
setActive((p) => (p - 1 + matches.length) % matches.length);
@@ -179,7 +172,7 @@ const ShowJSView: React.FC = ({ data }) => {
{
if (matches.length) setActive((p) => (p + 1) % matches.length);
}}
@@ -190,8 +183,8 @@ const ShowJSView: React.FC = ({ data }) => {
= ({ data }) => {
{terminology.download}
}
@@ -321,7 +314,8 @@ const ShowJSView: React.FC = ({ data }) => {
>
{end != null ? (
-
toggleFold(ln)}
aria-label={
@@ -331,7 +325,7 @@ const ShowJSView: React.FC = ({ data }) => {
}
>
{folded ? "▸" : "▾"}
-
+
) : (
)}
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 = (
onClick(scope)}
disabled={
!isReady || disabled || isSavedSignatureLimitReached || !hasChanges
@@ -1122,49 +1121,33 @@ const SignSettings = ({
onActivateSignaturePlacement || onDeactivateSignature ? (
isPlacementMode ? (
-
+ }
>
-
-
- {translate("mode.pause", "Pause placement")}
-
-
+ {translate("mode.pause", "Pause placement")}
+
) : (
-
+ }
>
-
-
- {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 && (
-
+
{translate("applySignatures", "Apply Signatures")}
)}
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
{buttonContent}
@@ -259,26 +256,23 @@ const ToolButton: React.FC = ({
) : tool.link && !isUnavailable ? (
// For external links, render Button as an anchor with proper href
{buttonContent}
@@ -286,23 +280,20 @@ const ToolButton: React.FC = ({
) : (
// For unavailable tools, use regular button
handleClick(id)}
size="sm"
- radius="md"
fullWidth
- justify="flex-start"
+ justify="start"
className="tool-button"
aria-disabled={isUnavailable}
data-tour={`tool-button-${id}`}
- styles={{
- root: {
- borderRadius: 0,
- color: "var(--tools-text-and-icon-color)",
- cursor: visuallyUnavailable ? "not-allowed" : undefined,
- overflow: "visible",
- },
- label: { overflow: "visible" },
+ leftSection={buttonIcon}
+ style={{
+ borderRadius: 0,
+ cursor: visuallyUnavailable ? "not-allowed" : undefined,
+ overflow: "visible",
}}
>
{buttonContent}
diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css b/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css
index 338afb095a..1a24278b4d 100644
--- a/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css
+++ b/frontend/editor/src/core/components/tools/toolPicker/ToolPicker.css
@@ -72,7 +72,7 @@
padding-left: 0.5rem;
}
-.tool-button .mantine-Button-label {
+.tool-button .sui-btn__label {
font-size: 0.84rem;
font-weight: 500;
line-height: 1.2;
@@ -90,7 +90,8 @@
.tool-button-star {
position: absolute;
- top: 0.35rem;
+ top: 50%;
+ transform: translateY(-50%);
right: 0.35rem;
opacity: 0;
transition: opacity 0.2s ease;
@@ -126,7 +127,7 @@
height: auto;
}
-.tool-picker__compact .tool-button .mantine-Button-label {
+.tool-picker__compact .tool-button .sui-btn__label {
align-items: center;
}
diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx
index ae136c4333..66463b9005 100644
--- a/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx
+++ b/frontend/editor/src/core/components/tools/toolPicker/ToolSearch.tsx
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useMemo } from "react";
-import { Stack, Button, Text } from "@mantine/core";
+import { Stack, Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
@@ -141,7 +142,7 @@ const ToolSearch = ({
{filteredTools.map(({ id, tool }) => (
{
onToolSelect?.(id as ToolId);
setDropdownOpen(false);
@@ -152,7 +153,7 @@ const ToolSearch = ({
}
fullWidth
- justify="flex-start"
+ justify="start"
style={{
borderRadius: "6px",
color: "var(--tools-text-and-icon-color)",
diff --git a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx
index e805a47636..22020ac6dc 100644
--- a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx
+++ b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureResults.tsx
@@ -2,14 +2,14 @@ import { useCallback, useMemo, useState } from "react";
import {
Alert,
Badge,
- Button,
Divider,
Group,
Loader,
Stack,
Text,
- SegmentedControl,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import type { SignatureValidationReportEntry } from "@app/types/validateSignature";
import type { ValidateSignatureOperationHook } from "@app/hooks/tools/validateSignature/useValidateSignatureOperation";
@@ -119,10 +119,16 @@ const ValidateSignatureResults = ({
return t("validateSignature.downloadJson", "Download JSON");
}, [selectedType, t]);
- const downloadTypeOptions = [
+ const downloadTypeOptions: {
+ label: string;
+ value: "pdf" | "csv" | "json";
+ }[] = [
{ label: t("validateSignature.downloadType.pdf", "PDF"), value: "pdf" },
{ label: t("validateSignature.downloadType.csv", "CSV"), value: "csv" },
- { label: t("validateSignature.downloadType.json", "JSON"), value: "json" },
+ {
+ label: t("validateSignature.downloadType.json", "JSON"),
+ value: "json",
+ },
];
const handleDownload = useCallback((file: File) => {
@@ -289,10 +295,9 @@ const ValidateSignatureResults = ({
setSelectedType(v as "pdf" | "csv" | "json")}
- data={downloadTypeOptions}
+ options={downloadTypeOptions}
/>
selectedFile && handleDownload(selectedFile)}
disabled={!selectedFile}
fullWidth
diff --git a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.tsx b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.tsx
index 018afc5fea..5e865551c0 100644
--- a/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.tsx
+++ b/frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.tsx
@@ -1,4 +1,5 @@
-import { Card, Group, Stack, Text, Button } from "@mantine/core";
+import { Card, Group, Stack, Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import FileUploadButton from "@app/components/shared/FileUploadButton";
import { ValidateSignatureParameters } from "@app/hooks/tools/validateSignature/useValidateSignatureParameters";
@@ -52,8 +53,7 @@ const ValidateSignatureSettings = ({
/>
{certFile && (
handleCertFileChange(null)}
disabled={disabled}
>
diff --git a/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.tsx b/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.tsx
index 2b6608d582..1ca0652483 100644
--- a/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.tsx
+++ b/frontend/editor/src/core/components/viewer/AnnotationMenuButtons.tsx
@@ -1,11 +1,4 @@
-import {
- ActionIcon,
- Tooltip,
- Popover,
- TextInput,
- Button,
- Stack,
-} from "@mantine/core";
+import { Tooltip, Popover, TextInput, Stack } from "@mantine/core";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -14,41 +7,20 @@ import CommentIcon from "@mui/icons-material/ChatBubbleOutlineRounded";
import AddCommentIcon from "@mui/icons-material/AddCommentOutlined";
import OpenInNewIcon from "@mui/icons-material/OpenInNewRounded";
import LocalIcon from "@app/components/shared/LocalIcon";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import type { FirstLinkTarget } from "@app/components/viewer/useAnnotationMenuHandlers";
-export const commonButtonStyles = {
- root: {
- flexShrink: 0,
- backgroundColor: "var(--bg-raised)",
- border: "1px solid var(--border-default)",
- color: "var(--text-secondary)",
- "&:hover": {
- backgroundColor: "var(--hover-bg)",
- borderColor: "var(--border-strong)",
- color: "var(--text-primary)",
- },
- },
-};
-
export function DeleteButton({ onDelete }: { onDelete: () => void }) {
const { t } = useTranslation();
return (
@@ -61,11 +33,11 @@ export function EditTextButton({ onEdit }: { onEdit: () => void }) {
return (
@@ -85,20 +57,17 @@ export function AttachCommentButton({
onAdd,
}: AttachCommentButtonProps) {
const { t } = useTranslation();
+ const label = isInSidebar
+ ? t("viewer.comments.viewComment", "View comment")
+ : t("viewer.comments.addComment", "Add comment");
return (
-
+
@@ -113,20 +82,17 @@ interface CommentButtonProps {
export function CommentButton({ hasContent, onClick }: CommentButtonProps) {
const { t } = useTranslation();
+ const label = hasContent
+ ? t("viewer.comments.viewComment", "View comment")
+ : t("viewer.comments.addComment", "Add comment");
return (
-
+
@@ -153,11 +119,11 @@ export function LinkButton({
return (
@@ -170,11 +136,11 @@ export function LinkButton({
setOpen((o) => !o)}
- styles={commonButtonStyles}
>
@@ -190,7 +156,7 @@ export function LinkButton({
style={{ minWidth: 220 }}
/>
{
onAddLink(url);
diff --git a/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx b/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx
index a858498bb6..4dcc61fe2b 100644
--- a/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx
+++ b/frontend/editor/src/core/components/viewer/AttachmentSidebar.tsx
@@ -1,15 +1,8 @@
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
-import {
- Box,
- ScrollArea,
- Text,
- ActionIcon,
- Button,
- Loader,
- Stack,
- TextInput,
-} from "@mantine/core";
+import { Box, ScrollArea, Text, Loader, Stack, TextInput } 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 { PdfAttachmentObject } from "@embedpdf/models";
@@ -322,9 +315,10 @@ export const AttachmentSidebar = ({
)}
handleDownload(attachment, event)}
>
@@ -388,11 +382,14 @@ export const AttachmentSidebar = ({
@@ -451,7 +448,11 @@ export const AttachmentSidebar = ({
{currentError}
-
+
@@ -487,8 +488,8 @@ export const AttachmentSidebar = ({
)}
@@ -502,20 +503,15 @@ export const AttachmentSidebar = ({
{showAttachmentList && (
<>
}
- mb="xs"
- styles={{
- root: {
- justifyContent: "flex-start",
- paddingInline: 6,
- },
- }}
+ style={{ marginBottom: "var(--space-xs)" }}
>
{t("viewer.attachments.addAttachment", "Add attachment")}
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}
-
+
Retry
@@ -726,7 +798,6 @@ export const BookmarkSidebar = ({
)}
-
{showEmptyState && !isAddingBookmark && (
@@ -769,7 +840,10 @@ export const BookmarkSidebar = ({
setNewBookmarkTitle(e.currentTarget.value)}
autoFocus
@@ -793,16 +867,16 @@ export const BookmarkSidebar = ({
)}
Cancel
)}
-
{showBookmarkList && (
<>
{!isAddingBookmark && (
}
- mb="xs"
- styles={{
- root: {
- justifyContent: "flex-start",
- paddingInline: 6,
- },
- }}
+ style={{ marginBottom: "var(--space-xs)" }}
>
Add bookmark
@@ -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 ? (
) : (
@@ -809,10 +816,11 @@ export function CommentsSidebar({
<>
{isPlacingComment ? (
}
- styles={{
- root: {
- justifyContent: "flex-start",
- paddingInline: 6,
- },
- }}
+ style={{ paddingInline: 6 }}
>
{t(
"viewer.comments.placingHint",
@@ -835,19 +838,15 @@ export function CommentsSidebar({
) : (
}
- styles={{
- root: {
- justifyContent: "flex-start",
- paddingInline: 6,
- },
- }}
+ style={{ paddingInline: 6 }}
>
{t("viewer.comments.addComment", "Add comment")}
@@ -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({
)}
-
+
{t("viewer.comments.removeCommentOnly", "Remove comment only")}
-
+
{t(
"viewer.comments.deleteAnnotationAndComment",
"Delete annotation & comment",
@@ -1331,10 +1340,13 @@ export function CommentsSidebar({
)}
- setClearAllModalOpen(false)}>
+ setClearAllModalOpen(false)}
+ >
{t("viewer.comments.cancelClearAll", "Cancel")}
-
+
{t("viewer.comments.clearAll", "Clear all comments")}
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 = ({
)}
{
if (currentFile && isStirlingFile(currentFile)) {
actions.openEncryptedUnlockPrompt(currentFile.fileId);
diff --git a/frontend/editor/src/core/components/viewer/LayerSidebar.tsx b/frontend/editor/src/core/components/viewer/LayerSidebar.tsx
index 91eaac01bd..126da6f016 100644
--- a/frontend/editor/src/core/components/viewer/LayerSidebar.tsx
+++ b/frontend/editor/src/core/components/viewer/LayerSidebar.tsx
@@ -6,13 +6,14 @@ import {
Checkbox,
Stack,
Loader,
- ActionIcon,
Tooltip,
} from "@mantine/core";
import LayersIcon from "@mui/icons-material/Layers";
+import { ActionIcon } from "@app/ui/ActionIcon";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import LocalIcon from "@app/components/shared/LocalIcon";
+import { useTranslation } from "react-i18next";
import { useViewer } from "@app/contexts/ViewerContext";
import "@app/components/viewer/SidebarBase.css";
import "@app/components/viewer/LayerSidebar.css";
@@ -51,6 +52,7 @@ export function LayerSidebar({
onApplyLayers,
onLayersDetected,
}: LayerSidebarProps) {
+ const { t } = useTranslation();
const { toggleLayerSidebar } = useViewer();
const [layers, setLayers] = useState([]);
const [visibility, setVisibility] = useState>({});
@@ -339,26 +341,25 @@ export function LayerSidebar({
{isApplying && }
-
{status === "ready" && leafIds.length > 0 && (
<>
@@ -366,11 +367,11 @@ export function LayerSidebar({
>
)}
diff --git a/frontend/editor/src/core/components/viewer/LinkLayer.tsx b/frontend/editor/src/core/components/viewer/LinkLayer.tsx
index 227045ebb9..e1a2aeb8f0 100644
--- a/frontend/editor/src/core/components/viewer/LinkLayer.tsx
+++ b/frontend/editor/src/core/components/viewer/LinkLayer.tsx
@@ -17,7 +17,8 @@ import {
type PdfLinkAnnoObject,
} from "@embedpdf/models";
import { Z_INDEX_VIEWER_FLOATING_MENU } from "@app/styles/zIndex";
-
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
// ---------------------------------------------------------------------------
// Inline SVG icons (thin-stroke, modern)
// ---------------------------------------------------------------------------
@@ -206,8 +207,8 @@ const LinkToolbar: React.FC = React.memo(
onMouseLeave={onMouseLeave}
>
{/* Delete */}
- {
e.stopPropagation();
@@ -217,13 +218,12 @@ const LinkToolbar: React.FC = React.memo(
title={t("viewer.link.delete", "Delete link")}
>
-
-
+
{/* Navigate / Open */}
- {
e.stopPropagation();
@@ -234,7 +234,7 @@ const LinkToolbar: React.FC = React.memo(
>
{internal ? : }
{label}
-
+
);
},
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 && (
}
onClick={handleConvertToPdf}
>
diff --git a/frontend/editor/src/core/components/viewer/PdfViewerToolbar.tsx b/frontend/editor/src/core/components/viewer/PdfViewerToolbar.tsx
index b3bab51953..893cd35d44 100644
--- a/frontend/editor/src/core/components/viewer/PdfViewerToolbar.tsx
+++ b/frontend/editor/src/core/components/viewer/PdfViewerToolbar.tsx
@@ -1,17 +1,10 @@
import { useState, useEffect } from "react";
-import {
- ActionIcon,
- Button,
- Paper,
- Group,
- Menu,
- NumberInput,
- Slider,
-} from "@mantine/core";
+import { Paper, Group, Menu, NumberInput, Slider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useViewer } from "@app/contexts/ViewerContext";
import { useIsPhone } from "@app/hooks/useIsMobile";
import { Tooltip } from "@app/components/shared/Tooltip";
+import { ActionIcon } from "@app/ui/ActionIcon";
import FirstPageIcon from "@mui/icons-material/FirstPage";
import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
@@ -161,35 +154,31 @@ export function PdfViewerToolbar({
>
{/* First Page Button */}
{!isPhone && (
-
-
+
)}
{/* 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
>
-
{isDualPageActive ? (
) : (
)}
-
+
)}
@@ -292,11 +280,9 @@ export function PdfViewerToolbar({
position="top"
arrow
>
- }
{pdfRenderMode === "dark" && }
{pdfRenderMode === "sepia" && }
-
+
)}
@@ -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"
>
}
- styles={{
- root: { flexShrink: 0, whiteSpace: "nowrap" },
- }}
+ style={{ flexShrink: 0, whiteSpace: "nowrap" }}
>
Apply (permanent)
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}
>
setConfirmOpen(false)}
disabled={saving}
>
{t("cancel", "Cancel")}
}
onClick={handleSaveAndShare}
loading={saving}
diff --git a/frontend/editor/src/core/components/viewer/nonpdf/MarkdownRenderer.tsx b/frontend/editor/src/core/components/viewer/nonpdf/MarkdownRenderer.tsx
index 5856326096..7cd69a2330 100644
--- a/frontend/editor/src/core/components/viewer/nonpdf/MarkdownRenderer.tsx
+++ b/frontend/editor/src/core/components/viewer/nonpdf/MarkdownRenderer.tsx
@@ -2,11 +2,13 @@ import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
+import { Button } from "@app/ui/Button";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
-
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
@@ -31,7 +33,7 @@ function CopyButton({ text }: { text: string }) {
}}
>
{copied ? "✓ Copied" : "Copy"}
-
+
);
}
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 (
}
onClick={onConvertToPdf}
style={{
diff --git a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx
index 4960672dd9..14a308e0f3 100644
--- a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx
+++ b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState, useEffect, useCallback } from "react";
-import { ActionIcon, Slider, Popover, Select } from "@mantine/core";
+import { Slider, Popover, Select } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import { supportedLanguages } from "@app/i18n";
import { useViewer } from "@app/contexts/ViewerContext";
@@ -169,14 +170,13 @@ export function useViewerWorkbenchBarButtons(
@@ -194,9 +194,7 @@ export function useViewerWorkbenchBarButtons(
},
{
id: "viewer-pan-mode",
- icon: (
-
- ),
+ icon: ,
tooltip:
!isPanning && pendingCount > 0 && redactionActiveType !== null
? applyRedactionsLabel
@@ -221,7 +219,7 @@ export function useViewerWorkbenchBarButtons(
},
{
id: "viewer-ruler",
- icon: ,
+ icon: ,
tooltip: rulerLabel,
ariaLabel: rulerLabel,
section: "top" as const,
@@ -238,7 +236,7 @@ export function useViewerWorkbenchBarButtons(
},
{
id: "viewer-rotate-left",
- icon: ,
+ icon: ,
tooltip: rotateLeftLabel,
ariaLabel: rotateLeftLabel,
section: "top" as const,
@@ -249,9 +247,7 @@ export function useViewerWorkbenchBarButtons(
},
{
id: "viewer-rotate-right",
- icon: (
-
- ),
+ icon: ,
tooltip: rotateRightLabel,
ariaLabel: rotateRightLabel,
section: "top" as const,
@@ -262,7 +258,7 @@ export function useViewerWorkbenchBarButtons(
},
{
id: "viewer-toggle-sidebar",
- icon: ,
+ icon: ,
tooltip: sidebarLabel,
ariaLabel: sidebarLabel,
section: "top" as const,
@@ -312,7 +308,7 @@ export function useViewerWorkbenchBarButtons(
? [
{
id: "viewer-toggle-layers",
- icon: ,
+ icon: ,
tooltip: layersLabel,
ariaLabel: layersLabel,
section: "top" as const,
@@ -326,7 +322,7 @@ export function useViewerWorkbenchBarButtons(
: []),
{
id: "viewer-toggle-comments",
- icon: ,
+ icon: ,
tooltip: commentsLabel,
ariaLabel: commentsLabel,
section: "top" as const,
@@ -363,8 +359,7 @@ export function useViewerWorkbenchBarButtons(
portalTarget={document.body}
>
{isReadingAloud ? (
-
+
) : (
-
+
)}
@@ -451,8 +445,7 @@ export function useViewerWorkbenchBarButtons(
portalTarget={document.body}
>
{
if (disabled || isAnnotationsActive) return;
@@ -479,9 +472,9 @@ export function useViewerWorkbenchBarButtons(
}}
disabled={disabled}
aria-pressed={isAnnotationsActive}
- color={isAnnotationsActive ? "blue" : undefined}
+ aria-label={annotationsLabel}
>
-
+
),
@@ -509,8 +502,7 @@ export function useViewerWorkbenchBarButtons(
portalTarget={document.body}
>
{
if (disabled) return;
@@ -522,9 +514,9 @@ export function useViewerWorkbenchBarButtons(
}}
disabled={disabled}
aria-pressed={isFormFillActive}
- color={isFormFillActive ? "blue" : undefined}
+ aria-label={formFillLabel}
>
-
+
),
diff --git a/frontend/editor/src/core/contexts/FileManagerContext.tsx b/frontend/editor/src/core/contexts/FileManagerContext.tsx
index 5e8bcf93ca..a557a9d31b 100644
--- a/frontend/editor/src/core/contexts/FileManagerContext.tsx
+++ b/frontend/editor/src/core/contexts/FileManagerContext.tsx
@@ -7,7 +7,8 @@ import React, {
useEffect,
useMemo,
} from "react";
-import { Button, Group, Modal, Stack, Text } from "@mantine/core";
+import { Group, Modal, Stack, Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { fileStorage } from "@app/services/fileStorage";
import { useFileActions, useFileManagement } from "@app/contexts/FileContext";
import { zipFileService } from "@app/services/zipFileService";
@@ -1210,14 +1211,14 @@ export const FileManagerProvider: React.FC = ({
resolveDeleteChoice("cancel")}
>
{t("cancel", "Cancel")}
{!deletePromptIsServerOnly && (
resolveDeleteChoice("local")}
>
{t("fileManager.removeLocalOnly", "This device only")}
@@ -1225,7 +1226,7 @@ export const FileManagerProvider: React.FC = ({
)}
{deletePromptCanLeaveShare && (
resolveDeleteChoice("leave")}
>
{t("fileManager.leaveShare", "Remove from my list")}
@@ -1234,14 +1235,14 @@ export const FileManagerProvider: React.FC = ({
{deletePromptFile?.remoteOwnedByCurrentUser !== false && (
<>
resolveDeleteChoice("server")}
>
{t("fileManager.removeServerOnly", "Server only")}
{!deletePromptIsServerOnly && (
resolveDeleteChoice("both")}
>
{t("fileManager.removeBoth", "Remove from both")}
diff --git a/frontend/editor/src/core/contexts/UnsavedChangesContext.tsx b/frontend/editor/src/core/contexts/UnsavedChangesContext.tsx
index 51ca85371d..8497731d6d 100644
--- a/frontend/editor/src/core/contexts/UnsavedChangesContext.tsx
+++ b/frontend/editor/src/core/contexts/UnsavedChangesContext.tsx
@@ -5,7 +5,8 @@ import React, {
useCallback,
ReactNode,
} from "react";
-import { Modal, Text, Button, Group, Stack } from "@mantine/core";
+import { Modal, Text, Group, Stack } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
interface UnsavedChangesContextType {
@@ -89,10 +90,10 @@ export function UnsavedChangesProvider({
)}
-
+
{t("admin.settings.unsavedChanges.cancel", "Keep Editing")}
-
+
{t("admin.settings.unsavedChanges.discard", "Discard Changes")}
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() {
)}
- {
@@ -418,9 +420,10 @@ export default function HomePage() {
{t("quickAccess.allTools", "Tools")}
-
+
{toolAvailability["automate"]?.available !== false && (
- {
@@ -438,9 +441,10 @@ export default function HomePage() {
{t("quickAccess.automate", "Automate")}
-
+
)}
- navigate("/files")}
@@ -453,8 +457,9 @@ export default function HomePage() {
{t("quickAccess.files", "Files")}
-
-
+ setConfigModalOpen(true)}
@@ -467,7 +472,7 @@ export default function HomePage() {
{t("quickAccess.config", "Config")}
-
+
{/* Back button - floating top left */}
- setMode("choice")}
- variant="filled"
+ variant="primary"
size="sm"
style={{
position: "absolute",
@@ -1187,8 +1187,7 @@ export default function MobileScannerPage() {
}}
>
← {t("mobileScanner.back", "Back")}
-
-
+
{/* Video feed - fills available space */}
{/* Capture button */}
-
{isProcessing
? t("mobileScanner.processing", "Processing...")
: t("mobileScanner.capture", "Capture")}
-
+
@@ -1291,15 +1289,14 @@ export default function MobileScannerPage() {
align="center"
style={{ maxWidth: "500px", margin: "0 auto" }}
>
- setMode("choice")}
- variant="subtle"
+ variant="tertiary"
size="sm"
style={{ alignSelf: "flex-start" }}
>
← {t("mobileScanner.back", "Back")}
-
-
+
- fileInputRef.current?.click()}
leftSection={ }
>
{t("mobileScanner.selectImage", "Select Image")}
-
+
@@ -1383,23 +1379,22 @@ export default function MobileScannerPage() {
>
-
+
{t("mobileScanner.retake", "Retake")}
-
-
+
+
{t("mobileScanner.addToBatch", "Add to Batch")}
-
+
-
{t("mobileScanner.upload", "Upload")}
-
+
@@ -1413,17 +1408,22 @@ export default function MobileScannerPage() {
)
-
{t("mobileScanner.clearBatch", "Clear")}
-
-
+
+
{t("mobileScanner.uploadAll", "Upload All")}
-
+
{
});
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%" }}
>
- {
setClearConfirmOpen(true)}
disabled={!hasAnySelected}
- styles={{ root: { textDecoration: "underline" } }}
style={{
+ textDecoration: "underline",
background: !hasAnySelected ? "transparent" : undefined,
color: !hasAnySelected
? "var(--spdf-clear-disabled-text)"
@@ -509,8 +508,7 @@ const Compare = (props: BaseToolProps) => {
{hasBothSelected && (
- {
{t("compare.swap.label", "Swap")}
-
+
)}
@@ -543,13 +541,12 @@ const Compare = (props: BaseToolProps) => {
setSwapConfirmOpen(false)}
>
{t("cancel", "Cancel")}
{
setSwapConfirmOpen(false);
performSwap();
@@ -577,13 +574,13 @@ const Compare = (props: BaseToolProps) => {
setClearConfirmOpen(false)}
>
{t("cancel", "Cancel")}
{
setClearConfirmOpen(false);
performClearSelected();
diff --git a/frontend/editor/src/core/tools/GetPdfInfo.tsx b/frontend/editor/src/core/tools/GetPdfInfo.tsx
index 9a9d4b4b63..7407c064ec 100644
--- a/frontend/editor/src/core/tools/GetPdfInfo.tsx
+++ b/frontend/editor/src/core/tools/GetPdfInfo.tsx
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import LinkIcon from "@mui/icons-material/Link";
-import { Stack, Group, Divider, Text, UnstyledButton } from "@mantine/core";
+import { Stack, Divider, Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "@app/types/tool";
@@ -177,7 +178,10 @@ const GetPdfInfo = (props: BaseToolProps) => {
{CHAPTERS.map((c, idx) => (
- {
if (!reportData) return;
setCustomWorkbenchViewData(REPORT_VIEW_ID, {
@@ -188,19 +192,15 @@ const GetPdfInfo = (props: BaseToolProps) => {
navigationActions.setWorkbench(REPORT_WORKBENCH_ID);
}
}}
- style={{
- width: "100%",
- textAlign: "left",
- padding: "8px 4px",
- }}
- >
-
+ style={{ padding: "8px 4px" }}
+ leftSection={
-
- {t(c.labelKey, c.fallback)}
-
-
-
+ }
+ >
+
+ {t(c.labelKey, c.fallback)}
+
+
{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) => {
)}
navActions.setWorkbench("fileEditor")}
>
{t("merge.goToFileEditor", "Go to file editor")}
diff --git a/frontend/editor/src/core/tools/SharedSign.tsx b/frontend/editor/src/core/tools/SharedSign.tsx
index 82b5787c41..5131db40c3 100644
--- a/frontend/editor/src/core/tools/SharedSign.tsx
+++ b/frontend/editor/src/core/tools/SharedSign.tsx
@@ -2,16 +2,16 @@ import { useMemo, useState } from "react";
import {
Alert,
Badge,
- Button,
Center,
- Chip,
Group,
Loader,
Paper,
- SegmentedControl,
Stack,
Text,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { Chip } from "@app/ui/Chip";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
@@ -164,8 +164,8 @@ const SharedSign = (_props: BaseToolProps) => {
}
onClick={() => setShowCreate(false)}
>
@@ -248,7 +248,7 @@ const SharedSign = (_props: BaseToolProps) => {
fullWidth
value={tab}
onChange={(value) => changeTab(value as Tab)}
- data={[
+ options={[
{ label: t("sharedSign.tab.active", "Active"), value: "active" },
{
label: t("sharedSign.tab.completed", "Completed"),
@@ -258,22 +258,34 @@ const SharedSign = (_props: BaseToolProps) => {
/>
}
onClick={() => setShowCreate(true)}
>
{t("sharedSign.newRequest", "Request signatures")}
-
-
- {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")}
>
{
setTextBackgroundColor("");
annotationApiRef?.current?.setAnnotationStyle?.(
@@ -739,8 +744,8 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
}}
/>
{
setNoteBackgroundColor("");
annotationApiRef?.current?.setAnnotationStyle?.(
@@ -814,8 +819,8 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
/>
setShapeThickness(shapeThickness === 0 ? 1 : 0)
}
@@ -1179,30 +1184,22 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
- {
activateAnnotationTool("select");
}}
- style={{
- width: "auto",
- paddingInline: "0.75rem",
- display: "inline-flex",
- alignItems: "center",
- gap: "0.4rem",
- }}
+ leftSection={
+
+ }
>
-
-
- {t("annotation.selectAndMove", "Select and Edit")}
-
-
+ {t("annotation.selectAndMove", "Select and Edit")}
+
void handleApplyChangesClick()}
+ style={{ marginTop: "0.75rem" }}
>
{t("annotation.saveChanges", "Save Changes")}
@@ -1324,14 +1318,13 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
setIsClearDocumentModalOpen(false)}
>
{t("common.cancel", "Cancel")}
{
}
- size="xs"
+ size="sm"
onClick={handleSave}
loading={saving}
disabled={!formState.isDirty && !flattenChanged}
@@ -524,7 +524,7 @@ const FormFill = (_props: BaseToolProps) => {
position="bottom"
>
{
}
loading={extracting}
onClick={handleExtractJson}
- size="xs"
+ size="sm"
>
JSON
}
loading={extracting}
onClick={handleExtractCsv}
- size="xs"
+ size="sm"
>
CSV
}
loading={extracting}
onClick={handleExtractXlsx}
- size="xs"
+ size="sm"
>
XLSX
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 && (
}
loading={applying}
disabled={saving}
onClick={handleApply}
- flex={1}
+ style={{ flex: 1 }}
>
{t("viewer.formBar.apply", "Apply Changes")}
}
loading={saving}
disabled={applying}
onClick={handleDownload}
- flex={1}
+ style={{ flex: 1 }}
>
{t("viewer.formBar.download", "Download PDF")}
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: (
-
+
Upgrade
),
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) => (
+
+
+
+
+
+ ),
+};
+
+/** Every accent × the three variants. Unset = `default` (blue). */
+export const Accents: Story = {
+ render: () => (
+
+ {(["primary", "secondary", "tertiary"] as const).flatMap((variant) =>
+ (
+ [
+ "default",
+ "neutral",
+ "brand",
+ "ai",
+ "premium",
+ "danger",
+ "success",
+ "warning",
+ ] as const
+ ).map((accent) => (
+
+ )),
+ )}
+
+ ),
+};
+
+/** Real size differences. */
+export const Sizes: Story = {
+ render: (args) => (
+
+ {(["sm", "md", "lg", "xl"] as const).map((size) => (
+
+ ))}
+
+ ),
+};
+
+/** Icons are optional and positional: `leftSection`, `rightSection`, or both. */
+export const WithIcons: Story = {
+ render: (args) => (
+
+ } text="Left icon" />
+ } text="Right icon" />
+ }
+ rightSection={ }
+ text="Both"
+ />
+
+ ),
+};
+
+/** Pass an icon and NO text — that's all an "icon-only" button is. No separate component. */
+export const IconOnly: Story = {
+ render: () => (
+
+ {(["sm", "md", "lg", "xl"] as const).map((size) => (
+ }
+ aria-label="Add"
+ />
+ ))}
+ } aria-label="Add" />
+ }
+ aria-label="Delete"
+ />
+
+ ),
+};
+
+/** How content sits across the width (only visible when wider than the content,
+ * e.g. `fullWidth`). `between` pins icons to the edges and keeps the label dead-
+ * centre — the toolbar/nav row pattern. */
+export const Justify: Story = {
+ render: () => (
+
+ {(["center", "start", "end", "between"] as const).map((justify) => (
+
}
+ rightSection={
}
+ text={justify}
+ />
+ ))}
+
+ ),
+};
+
+/** `circle` makes a round control (pair it with an icon-only button); `pill` fully rounds a text button. */
+export const Shape: Story = {
+ render: () => (
+
+ }
+ aria-label="Add"
+ />
+ }
+ aria-label="Next"
+ />
+ }
+ aria-label="Delete"
+ />
+
+
+
+ ),
+};
+
+/** `accent="premium"` — a gradient CTA for upgrade moments. The gradient lives
+ * on the `filled` variant (subtle brighten on hover, nothing flashy);
+ * outlined/ghost fall back to a calm violet. */
+export const Premium: Story = {
+ render: () => (
+
+ }
+ text="Upgrade to Processor Plan"
+ />
+
+
+ } text="Go Pro" />
+ }
+ text="Get Pro"
+ />
+ }
+ aria-label="Upgrade"
+ />
+
+
+
+
+
+
+
+ {(["sm", "md", "lg", "xl"] as const).map((size) => (
+ }
+ text={size}
+ />
+ ))}
+
+
+ ),
+};
diff --git a/frontend/editor/src/core/ui/Button.tsx b/frontend/editor/src/core/ui/Button.tsx
new file mode 100644
index 0000000000..08dbeeddd8
--- /dev/null
+++ b/frontend/editor/src/core/ui/Button.tsx
@@ -0,0 +1,205 @@
+import { Button as MantineButton } 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/Button.css";
+
+/** primary=solid, secondary=outlined, tertiary=ghost (tinted hover), quiet=plain (no bg, hovers to text colour). */
+export type ButtonVariant = "primary" | "secondary" | "tertiary" | "quiet";
+/** default(blue) | neutral | brand | ai | premium | danger | success | warning. */
+export type ButtonAccent =
+ | "default"
+ | "neutral"
+ | "brand"
+ | "ai"
+ | "premium"
+ | "danger"
+ | "success"
+ | "warning";
+export type ButtonSize = "sm" | "md" | "lg" | "xl";
+/** `between` pins leftSection/label/rightSection to left/center/right (toolbar rows). */
+export type ButtonJustify = "center" | "start" | "end" | "between";
+export type ButtonShape = "default" | "circle" | "pill";
+
+type ButtonOwnProps = {
+ variant?: ButtonVariant;
+ accent?: ButtonAccent;
+ size?: ButtonSize;
+ justify?: ButtonJustify;
+ shape?: ButtonShape;
+ /** Alternative to children; use one or the other. */
+ text?: ReactNode;
+ leftSection?: ReactNode;
+ rightSection?: ReactNode;
+ loading?: boolean;
+ fullWidth?: boolean;
+ /** false = no hover background change. */
+ hover?: boolean;
+ overflow?: "wrap" | "hidden";
+ /** Polymorphic root element (e.g. `"a"` or a router Link). */
+ as?: ElementType;
+ style?: CSSProperties;
+ children?: ReactNode;
+};
+
+export type ButtonProps = ButtonOwnProps &
+ Omit, keyof ButtonOwnProps | "color"> & {
+ href?: string;
+ target?: string;
+ rel?: string;
+ htmlFor?: string;
+ };
+
+export interface ButtonGroupProps {
+ children?: ReactNode;
+ className?: string;
+ style?: CSSProperties;
+ orientation?: "horizontal" | "vertical";
+ /** Border width between attached buttons (Mantine `--button-border-width`). */
+ borderWidth?: number | string;
+}
+
+function ButtonGroup({
+ children,
+ className,
+ style,
+ orientation,
+ borderWidth,
+}: ButtonGroupProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+const MANTINE_VARIANT: Record = {
+ primary: "filled",
+ secondary: "outline",
+ tertiary: "subtle",
+ quiet: "subtle",
+};
+
+const MANTINE_JUSTIFY: Record = {
+ center: "center",
+ start: "flex-start",
+ end: "flex-end",
+ between: "space-between",
+};
+
+const ButtonRoot = forwardRef(
+ function ButtonRoot(
+ {
+ variant = "primary",
+ accent = "default",
+ size = "md",
+ justify = "center",
+ shape = "default",
+ text,
+ leftSection,
+ rightSection,
+ loading = false,
+ fullWidth = false,
+ hover = true,
+ overflow = "wrap",
+ as,
+ disabled,
+ className,
+ style,
+ children,
+ ...rest
+ },
+ ref,
+ ) {
+ const label = text ?? children;
+ const hasLabel = label != null && label !== false && label !== "";
+ const iconOnly = !hasLabel && (!!leftSection || !!rightSection || loading);
+
+ // Sections flank a label → spread them without requiring justify="between".
+ const effectiveJustify =
+ justify === "center" && hasLabel && !!leftSection && !!rightSection
+ ? "between"
+ : justify;
+
+ const classes = [
+ "sui-btn",
+ `sui-acc-${accent}`,
+ `sui-btn--${variant}`,
+ iconOnly ? "sui-btn--icon" : "",
+ shape !== "default" ? `sui-btn--${shape}` : "",
+ overflow === "wrap" ? "sui-btn--wrap" : "",
+ !hover ? "sui-btn--no-hover" : "",
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ // Accent palette → Mantine button vars (inline to win). --button-bd is a full `border` shorthand.
+ const accentVars =
+ variant === "primary"
+ ? {
+ "--button-bg": "var(--_solid)",
+ "--button-hover": "var(--_solid-hover)",
+ "--button-color": "var(--_on)",
+ "--button-bd": "1px solid transparent",
+ }
+ : variant === "quiet"
+ ? {
+ "--button-bg": "transparent",
+ "--button-hover": "transparent",
+ "--button-color": "var(--_text)",
+ "--button-hover-color": "var(--color-text-1)",
+ "--button-bd": "1px solid transparent",
+ }
+ : {
+ "--button-bg": "transparent",
+ "--button-hover": "var(--_tint)",
+ "--button-color": "var(--_text)",
+ "--button-bd":
+ variant === "secondary"
+ ? "1px solid var(--_bd)"
+ : "1px solid transparent",
+ };
+
+ // Loosely-typed alias so the polymorphic `component={as}` doesn't fight Mantine's typing.
+ const Comp = MantineButton as ElementType;
+
+ return (
+
+ {label}
+
+ );
+ },
+);
+
+export const Button = Object.assign(ButtonRoot, { Group: ButtonGroup });
diff --git a/frontend/editor/src/proprietary/ui/Card.css b/frontend/editor/src/core/ui/Card.css
similarity index 74%
rename from frontend/editor/src/proprietary/ui/Card.css
rename to frontend/editor/src/core/ui/Card.css
index f38e2adac4..bfc49be935 100644
--- a/frontend/editor/src/proprietary/ui/Card.css
+++ b/frontend/editor/src/core/ui/Card.css
@@ -32,11 +32,11 @@
transform: translateY(-0.0625rem);
}
-.sui-card--accent-blue::before,
-.sui-card--accent-purple::before,
-.sui-card--accent-green::before,
-.sui-card--accent-amber::before,
-.sui-card--accent-red::before {
+.sui-card--accent-default::before,
+.sui-card--accent-premium::before,
+.sui-card--accent-success::before,
+.sui-card--accent-warning::before,
+.sui-card--accent-danger::before {
content: "";
position: absolute;
top: 0;
@@ -45,18 +45,18 @@
width: 0.25rem;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
}
-.sui-card--accent-blue::before {
+.sui-card--accent-default::before {
background: var(--color-blue);
}
-.sui-card--accent-purple::before {
+.sui-card--accent-premium::before {
background: var(--color-purple);
}
-.sui-card--accent-green::before {
+.sui-card--accent-success::before {
background: var(--color-green);
}
-.sui-card--accent-amber::before {
+.sui-card--accent-warning::before {
background: var(--color-amber);
}
-.sui-card--accent-red::before {
+.sui-card--accent-danger::before {
background: var(--color-red);
}
diff --git a/frontend/editor/src/proprietary/ui/Card.stories.tsx b/frontend/editor/src/core/ui/Card.stories.tsx
similarity index 82%
rename from frontend/editor/src/proprietary/ui/Card.stories.tsx
rename to frontend/editor/src/core/ui/Card.stories.tsx
index df445fd89a..8f480e2bf3 100644
--- a/frontend/editor/src/proprietary/ui/Card.stories.tsx
+++ b/frontend/editor/src/core/ui/Card.stories.tsx
@@ -36,7 +36,14 @@ const meta: Meta = {
argTypes: {
accent: {
control: "inline-radio",
- options: [undefined, "blue", "purple", "green", "amber", "red"],
+ options: [
+ undefined,
+ "default",
+ "premium",
+ "success",
+ "warning",
+ "danger",
+ ],
},
padding: {
control: "inline-radio",
@@ -79,13 +86,20 @@ export const AccentMatrix: Story = {
gap: 12,
}}
>
- {(["blue", "purple", "green", "amber", "red", undefined] as const).map(
- (accent) => (
-
-
-
- ),
- )}
+ {(
+ [
+ "default",
+ "premium",
+ "success",
+ "warning",
+ "danger",
+ undefined,
+ ] as const
+ ).map((accent) => (
+
+
+
+ ))}
),
};
@@ -106,7 +120,7 @@ export const InContext_ProductGrid: Story = {
gap: 14,
}}
>
-
+
Sources
Attach pipelines where PDFs already live.
-
+
Connect a source
-
+
Compose document workflows from typed operations.
-
+
Build a pipeline
-
+
Agents
Wire your agent via MCP, REST, or tool definitions.
-
+
Connect an agent
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 && (
)}
{showTick && (
diff --git a/frontend/editor/src/proprietary/ui/ChatFABWindow.css b/frontend/editor/src/core/ui/ChatFABWindow.css
similarity index 93%
rename from frontend/editor/src/proprietary/ui/ChatFABWindow.css
rename to frontend/editor/src/core/ui/ChatFABWindow.css
index 1f59d2444f..b5dbb4f451 100644
--- a/frontend/editor/src/proprietary/ui/ChatFABWindow.css
+++ b/frontend/editor/src/core/ui/ChatFABWindow.css
@@ -15,7 +15,7 @@
/* Closed state: collapsed toward bottom-right origin */
opacity: 0;
- transform: scale(0.9) translateY(14px);
+ transform: scale(0.9);
pointer-events: none;
transform-origin: bottom right;
transition:
@@ -25,7 +25,7 @@
.chat-fab-window--open {
opacity: 1;
- transform: scale(1) translateY(0);
+ transform: scale(1);
pointer-events: auto;
}
diff --git a/frontend/editor/src/proprietary/ui/ChatFABWindow.stories.tsx b/frontend/editor/src/core/ui/ChatFABWindow.stories.tsx
similarity index 100%
rename from frontend/editor/src/proprietary/ui/ChatFABWindow.stories.tsx
rename to frontend/editor/src/core/ui/ChatFABWindow.stories.tsx
diff --git a/frontend/editor/src/proprietary/ui/ChatFABWindow.tsx b/frontend/editor/src/core/ui/ChatFABWindow.tsx
similarity index 100%
rename from frontend/editor/src/proprietary/ui/ChatFABWindow.tsx
rename to frontend/editor/src/core/ui/ChatFABWindow.tsx
diff --git a/frontend/editor/src/proprietary/ui/Checkbox.css b/frontend/editor/src/core/ui/Checkbox.css
similarity index 100%
rename from frontend/editor/src/proprietary/ui/Checkbox.css
rename to frontend/editor/src/core/ui/Checkbox.css
diff --git a/frontend/editor/src/proprietary/ui/Checkbox.tsx b/frontend/editor/src/core/ui/Checkbox.tsx
similarity index 100%
rename from frontend/editor/src/proprietary/ui/Checkbox.tsx
rename to frontend/editor/src/core/ui/Checkbox.tsx
diff --git a/frontend/editor/src/core/ui/Chip.css b/frontend/editor/src/core/ui/Chip.css
new file mode 100644
index 0000000000..c341bb8e5c
--- /dev/null
+++ b/frontend/editor/src/core/ui/Chip.css
@@ -0,0 +1,49 @@
+/* Mantine Pill-backed chip/tag — colour from the shared accent palette (accents.css). */
+@import "./accents.css";
+
+.sui-chip.mantine-Pill-root {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35em;
+ border: 1px solid transparent;
+ font-weight: 500;
+ width: auto;
+}
+.sui-chip--primary.mantine-Pill-root {
+ background: var(--_solid);
+ color: var(--_on);
+}
+.sui-chip--secondary.mantine-Pill-root {
+ background: var(--_tint);
+ color: var(--_text);
+ border-color: var(--_bd);
+}
+.sui-chip--interactive {
+ cursor: pointer;
+}
+.sui-chip--loading {
+ opacity: 0.6;
+ pointer-events: none;
+}
+
+.sui-chip__dot {
+ width: 0.5em;
+ height: 0.5em;
+ border-radius: 50%;
+ background: currentColor;
+}
+.sui-chip__icon {
+ display: inline-flex;
+ align-items: center;
+}
+.sui-chip__label {
+ display: inline-block;
+}
+.sui-chip__spinner {
+ width: 0.75em;
+ height: 0.75em;
+ border-radius: 50%;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ animation: spin 0.7s linear infinite;
+}
diff --git a/frontend/editor/src/core/ui/Chip.stories.tsx b/frontend/editor/src/core/ui/Chip.stories.tsx
new file mode 100644
index 0000000000..0d54da3891
--- /dev/null
+++ b/frontend/editor/src/core/ui/Chip.stories.tsx
@@ -0,0 +1,91 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Chip } from "@app/ui/Chip";
+
+const ACCENTS = [
+ "default",
+ "neutral",
+ "brand",
+ "ai",
+ "premium",
+ "danger",
+ "success",
+ "warning",
+] as const;
+
+const meta: Meta = {
+ title: "Primitives/Chip",
+ component: Chip,
+ tags: ["autodocs"],
+ parameters: { layout: "centered" },
+ args: {
+ children: "us-east-1",
+ accent: "default",
+ variant: "secondary",
+ size: "md",
+ showDot: false,
+ },
+ argTypes: {
+ accent: { control: "inline-radio", options: ACCENTS },
+ variant: { control: "inline-radio", options: ["primary", "secondary"] },
+ size: { control: "inline-radio", options: ["xs", "sm", "md", "lg"] },
+ showDot: { control: "boolean" },
+ onClick: { action: "clicked" },
+ onRemove: { action: "removed" },
+ },
+};
+export default meta;
+type Story = StoryObj;
+
+/** Flip accent / variant / size / dot / interactive / removable in controls. */
+export const Playground: Story = {};
+
+export const Accents: Story = {
+ render: () => (
+
+ {(["secondary", "primary"] as const).map((variant) => (
+
+ {ACCENTS.map((a) => (
+
+ {a}
+
+ ))}
+
+ ))}
+
+ ),
+};
+
+export const InContext_OpChain: Story = {
+ render: () => (
+
+
+ ocr
+
+
+ classify
+
+
+ extract
+
+
+ validate
+
+
+ redact
+
+
+ encrypt-rest
+
+
+ store-primary
+
+
+ ),
+};
diff --git a/frontend/editor/src/core/ui/Chip.tsx b/frontend/editor/src/core/ui/Chip.tsx
new file mode 100644
index 0000000000..49478d84c6
--- /dev/null
+++ b/frontend/editor/src/core/ui/Chip.tsx
@@ -0,0 +1,113 @@
+import { Pill as MantinePill } from "@mantine/core";
+import type {
+ CSSProperties,
+ HTMLAttributes,
+ KeyboardEvent,
+ ReactNode,
+} from "react";
+import "@app/ui/Chip.css";
+
+/** Same accent dial as Button. */
+export type ChipAccent =
+ | "default"
+ | "neutral"
+ | "brand"
+ | "ai"
+ | "premium"
+ | "danger"
+ | "success"
+ | "warning";
+export type ChipSize = "xs" | "sm" | "md" | "lg";
+/** primary = solid fill; secondary = soft tinted tag (the default tag look). */
+export type ChipVariant = "primary" | "secondary";
+
+export interface ChipProps extends Omit<
+ HTMLAttributes,
+ "onClick"
+> {
+ accent?: ChipAccent;
+ variant?: ChipVariant;
+ size?: ChipSize;
+ leadingIcon?: ReactNode;
+ trailingIcon?: ReactNode;
+ /** Shows a spinner and dims the chip. */
+ loading?: boolean;
+ onRemove?: () => void;
+ onClick?: () => void;
+ /** Leading status dot. Use for status-style chips. */
+ showDot?: boolean;
+ style?: CSSProperties;
+ children?: ReactNode;
+ className?: string;
+}
+
+/** Open-ended chip/tag (Mantine Pill-backed). For semantic status use StatusBadge. */
+export function Chip({
+ accent = "default",
+ variant = "secondary",
+ size = "md",
+ leadingIcon,
+ trailingIcon,
+ loading = false,
+ onRemove,
+ onClick,
+ showDot,
+ style,
+ children,
+ className,
+ ...rest
+}: ChipProps) {
+ const classes = [
+ "sui-chip",
+ `sui-acc-${accent}`,
+ `sui-chip--${variant}`,
+ onClick ? "sui-chip--interactive" : "",
+ loading ? "sui-chip--loading" : "",
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ // Interactive chips are a , so add button semantics + Enter/Space activation.
+ const interactive = !!onClick && !loading;
+ const interactiveProps = interactive
+ ? {
+ role: "button",
+ tabIndex: 0,
+ onClick,
+ onKeyDown: (e: KeyboardEvent) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onClick();
+ }
+ },
+ }
+ : {};
+
+ return (
+
+ {showDot && }
+ {loading ? (
+
+ ) : leadingIcon ? (
+
+ {leadingIcon}
+
+ ) : null}
+ {children}
+ {trailingIcon && !onRemove && (
+
+ {trailingIcon}
+
+ )}
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/ui/ChipFlow.css b/frontend/editor/src/core/ui/ChipFlow.css
similarity index 100%
rename from frontend/editor/src/proprietary/ui/ChipFlow.css
rename to frontend/editor/src/core/ui/ChipFlow.css
diff --git a/frontend/editor/src/proprietary/ui/ChipFlow.stories.tsx b/frontend/editor/src/core/ui/ChipFlow.stories.tsx
similarity index 100%
rename from frontend/editor/src/proprietary/ui/ChipFlow.stories.tsx
rename to frontend/editor/src/core/ui/ChipFlow.stories.tsx
diff --git a/frontend/editor/src/proprietary/ui/ChipFlow.tsx b/frontend/editor/src/core/ui/ChipFlow.tsx
similarity index 65%
rename from frontend/editor/src/proprietary/ui/ChipFlow.tsx
rename to frontend/editor/src/core/ui/ChipFlow.tsx
index 4af337e308..528796ece8 100644
--- a/frontend/editor/src/proprietary/ui/ChipFlow.tsx
+++ b/frontend/editor/src/core/ui/ChipFlow.tsx
@@ -1,24 +1,18 @@
import { Fragment } from "react";
import type { ReactNode } from "react";
import { Chip } from "@app/ui/Chip";
-import type { ChipTone, ChipSize } from "@app/ui/Chip";
+import type { ChipAccent, ChipSize } from "@app/ui/Chip";
import "@app/ui/ChipFlow.css";
export interface ChipFlowProps {
- /** Items rendered as chips, in order. */
items: ReactNode[];
- /** `arrow` joins chips with a → connector (pipeline look); `none` just wraps. */
+ /** `arrow` joins chips with a → connector. */
separator?: "arrow" | "none";
- tone?: ChipTone;
+ tone?: ChipAccent;
size?: ChipSize;
className?: string;
}
-/**
- * A sequence of {@link Chip}s, optionally joined by arrows to read as a
- * pipeline (A → B → C). Use `separator="arrow"` for flows, `none` for a plain
- * wrapped chip list.
- */
export function ChipFlow({
items,
separator = "none",
@@ -37,7 +31,7 @@ export function ChipFlow({
→
)}
-
+
{item}
diff --git a/frontend/editor/src/proprietary/ui/CodeBlock.css b/frontend/editor/src/core/ui/CodeBlock.css
similarity index 100%
rename from frontend/editor/src/proprietary/ui/CodeBlock.css
rename to frontend/editor/src/core/ui/CodeBlock.css
diff --git a/frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx b/frontend/editor/src/core/ui/CodeBlock.stories.tsx
similarity index 100%
rename from frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx
rename to frontend/editor/src/core/ui/CodeBlock.stories.tsx
diff --git a/frontend/editor/src/proprietary/ui/CodeBlock.tsx b/frontend/editor/src/core/ui/CodeBlock.tsx
similarity index 66%
rename from frontend/editor/src/proprietary/ui/CodeBlock.tsx
rename to frontend/editor/src/core/ui/CodeBlock.tsx
index 4e0dc645e1..1a05b23e4f 100644
--- a/frontend/editor/src/proprietary/ui/CodeBlock.tsx
+++ b/frontend/editor/src/core/ui/CodeBlock.tsx
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from "react";
+import { Button } from "@app/ui/Button";
import "@app/ui/CodeBlock.css";
export type CodeLang =
@@ -12,27 +13,18 @@ export type CodeLang =
| "plain";
export interface CodeBlockProps {
- /** The code content. */
code: string;
- /** Language label shown in the chrome bar. Highlight wiring is out of scope here — bring Shiki/Prism. */
+ /** Shown in the chrome bar. Syntax highlighting is not included — wire Shiki/Prism at the call site. */
lang?: CodeLang;
- /** Optional caption text shown in the chrome bar (e.g. a file path). */
+ /** Caption in the chrome bar (e.g. a file path). */
caption?: ReactNode;
- /** Show a copy-to-clipboard button. Defaults to true. */
+ /** Defaults to true. */
copyable?: boolean;
- /** Max height in pixels; longer content scrolls. */
+ /** Longer content scrolls vertically. */
maxHeight?: number;
className?: string;
}
-/**
- * Always-dark code block, matched to the prototype's CODE palette.
- *
- * Highlighting is intentionally not wired here — drop in Shiki at the call
- * site and feed pre-highlighted HTML through `dangerouslySetInnerHTML` on a
- * fork of this component if you need it. For most surfaces the raw
- * monospaced text plus copy button is sufficient (and ~80% lighter).
- */
export function CodeBlock({
code,
lang = "plain",
@@ -64,14 +56,16 @@ export function CodeBlock({
{caption && {caption} }
{lang}
{copyable && (
-
{copied ? "Copied" : "Copy"}
-
+
)}
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={
<>
- setOpen(false)}>
+ setOpen(false)}>
Close
- Edit composition
- View runs
+ Edit composition
+ View runs
>
}
>
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: () => (
- Open menu
+ Open menu
console.log("a")}>Item A
@@ -37,7 +37,7 @@ export const WithDivider: Story = {
render: () => (
- Account
+ Account
Profile
@@ -53,7 +53,7 @@ export const WithTrailingHints: Story = {
render: () => (
- Commands
+ Commands
Search
@@ -71,7 +71,7 @@ export const AlignStart: Story = {
render: () => (
- Aligned to start
+ Aligned to start
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: (
<>
- →}>
+ →}>
Browse templates
- Build from scratch
+ Build from scratch
>
),
},
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) => (
+
+ {children}
+
+ )}
+
+ );
+}
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: () => (
- Primary
- Secondary
- Cancel
+ Primary
+ Secondary
+ Cancel
),
};
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
+
+
+ Edit composition
+
+
+ View runs
+
+ >
),
},
};
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={
<>
- Cancel
- Save changes
+ Cancel
+ Save changes
>
}
>
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}
)}
- onDismiss(entry.id)}
aria-label="Dismiss"
- >
- ×
-
+ 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" ? (
-
+
{t("settings.connection.signIn", "Sign In")}
) : (
{t("settings.connection.logout", "Log Out")}
diff --git a/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx b/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx
index 0a07c8127c..96bddff2af 100644
--- a/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx
+++ b/frontend/editor/src/desktop/components/DesktopOnboardingModal.tsx
@@ -1,5 +1,7 @@
import { useState, useMemo } from "react";
-import { Modal, Stack, Group, Button, ActionIcon } from "@mantine/core";
+import { Modal, Stack, Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import CloseIcon from "@mui/icons-material/Close";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -105,8 +107,9 @@ export function DesktopOnboardingModal() {
/>
@@ -165,15 +163,7 @@ export function DesktopOnboardingModal() {
- setStep(1)}
- styles={{
- root: {
- background: "var(--onboarding-primary-button-bg)",
- color: "var(--onboarding-primary-button-text)",
- },
- }}
- >
+ setStep(1)} accent="neutral">
{t("onboarding.buttons.next", "Next →")}
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 (
- handleOAuthLogin(providerEntry)}
disabled={isDisabled || oauthLoading}
@@ -168,7 +170,7 @@ export const DesktopOAuthButtons: React.FC = ({
{label}
-
+
);
})}
{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" }}
>
- {
setValidationError(null);
onSwitchToSignup();
@@ -117,7 +118,7 @@ export const SaaSLoginScreen: React.FC = ({
disabled={loading}
>
{t("signup.signUp", "Sign Up")}
-
+
@@ -127,14 +128,14 @@ export const SaaSLoginScreen: React.FC = ({
className="navigation-link-container"
style={{ marginTop: "0.5rem", textAlign: "center" }}
>
-
{t("setup.login.skipSignIn", "Continue without signing in")}
-
+
)}
>
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 (
-
{t("setup.selfhosted.link", "or connect to a self hosted account")}
-
+
);
};
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 && (
- {
@@ -331,7 +332,7 @@ export const ServerSelection: React.FC = ({
{t("setup.server.useLast", "Last used server: {{serverUrl}}", {
serverUrl: 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 = ({
void loadLockedConfig()}
@@ -498,8 +497,7 @@ export const SetupWizard: React.FC = ({
) : (
{
@@ -514,8 +512,8 @@ export const SetupWizard: React.FC = ({
)}
@@ -547,14 +545,14 @@ export const SetupWizard: React.FC = ({
className="navigation-link-container"
style={{ marginTop: "1.5rem" }}
>
-
{t("setup.selfhosted.switchToLocal", "Use local tools instead")}
-
+
>
)}
@@ -565,13 +563,13 @@ export const SetupWizard: React.FC = ({
className="navigation-link-container"
style={{ marginTop: "1.5rem" }}
>
-
{t("common.back", "Back")}
-
+
)}
>
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 = () => {
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT))
diff --git a/frontend/editor/src/desktop/routes/login/LoginHeader.tsx b/frontend/editor/src/desktop/routes/login/LoginHeader.tsx
index d842ff6e86..fc715e6696 100644
--- a/frontend/editor/src/desktop/routes/login/LoginHeader.tsx
+++ b/frontend/editor/src/desktop/routes/login/LoginHeader.tsx
@@ -1,5 +1,6 @@
-import { ActionIcon } from "@mantine/core";
import CloseIcon from "@mui/icons-material/Close";
+import { useTranslation } from "react-i18next";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
interface LoginHeaderProps {
@@ -19,6 +20,7 @@ export default function LoginHeader({
centerOnly = false,
onClose,
}: LoginHeaderProps) {
+ const { t } = useTranslation();
const { tooltipLogo } = useLogoAssets();
return (
@@ -57,9 +59,8 @@ export default function LoginHeader({
{onClose && (
)}
-
+
);
}
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) => (
- send(s)}
disabled={typing}
>
{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 */}
- {
setOpen(true);
setHasUnviewedResult(false);
@@ -171,22 +168,13 @@ function ChatFABWidgetDemo({
right: 16,
bottom: 16,
padding: 0,
- border: "none",
- background: "none",
- cursor: "pointer",
opacity: open ? 0 : 1,
transform: open ? "scale(0.78)" : "scale(1)",
transition:
"opacity 160ms ease, transform 180ms cubic-bezier(0.32, 0.72, 0, 1)",
pointerEvents: open ? "none" : "auto",
}}
- >
-
-
+ />
{/* Chat panel */}
))}
-
Simulate agent run
-
+
{/* 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 (
-
{theme === "light" ? : }
-
+
);
}
@@ -49,7 +50,11 @@ function TierSwitcher() {
return (
-
+
{info.label}
-
+
{(Object.keys(TIER_INFO) as Tier[]).map((id) => (
@@ -89,14 +94,15 @@ function UserMenu() {
return (
-
-
+
{name}
@@ -120,8 +126,9 @@ export function Header() {
-
⌘K
-
+
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 (
-
{enabled ? t("portal.mocks.label.on") : t("portal.mocks.label.off")}
-
+
);
}
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() {
)}
-
{t("portal.notifications.markAllRead")}
-
-
+
+
{t("portal.notifications.viewAll")}
-
+
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) => (
- fork(t)}
@@ -96,12 +96,12 @@ export function PipelineForkWizard() {
{t.blurb}
{t.docTypes.map((d) => (
-
+
{d}
))}
-
+
))}
)}
@@ -139,17 +139,16 @@ export function PipelineForkWizard() {
-
+
{phase === "ready"
? t("portal.forkWizard.action.pickAnother")
: t("portal.forkWizard.action.cancel")}
→}
+ rightSection={→ }
>
{phase === "ready"
? t("portal.forkWizard.action.deploy")
diff --git a/frontend/editor/src/portal/components/PolicySummary.tsx b/frontend/editor/src/portal/components/PolicySummary.tsx
index 084e7e5b69..29d93cda3c 100644
--- a/frontend/editor/src/portal/components/PolicySummary.tsx
+++ b/frontend/editor/src/portal/components/PolicySummary.tsx
@@ -105,7 +105,7 @@ export function PolicySummary() {
render: ({ state }) => {
if (state === "locked") {
return (
-
+
{t("portal.policySummary.action.comingSoon")}
);
@@ -113,7 +113,7 @@ export function PolicySummary() {
return (
{state === "active"
diff --git a/frontend/editor/src/portal/components/PopularUseCases.tsx b/frontend/editor/src/portal/components/PopularUseCases.tsx
index b85912761f..85ccc90c72 100644
--- a/frontend/editor/src/portal/components/PopularUseCases.tsx
+++ b/frontend/editor/src/portal/components/PopularUseCases.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
-import { Card, type CardProps } from "@app/ui";
+import { Button, Card, type CardProps } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
import "@portal/components/PopularUseCases.css";
@@ -11,12 +11,12 @@ interface UseCase {
accent: Accent;
}
-const ACCENT_COLOR: Record = {
- blue: "var(--color-blue)",
- purple: "var(--color-purple)",
- green: "var(--color-green)",
- amber: "var(--color-amber)",
- red: "var(--color-red)",
+const ACCENT_COLOR: Partial> = {
+ default: "var(--color-blue)",
+ premium: "var(--color-purple)",
+ success: "var(--color-green)",
+ warning: "var(--color-amber)",
+ danger: "var(--color-red)",
};
/**
@@ -26,10 +26,10 @@ const ACCENT_COLOR: Record = {
* copy (eyebrow, title, blurb, cta) is keyed into useCases.items..
*/
const USE_CASES: UseCase[] = [
- { key: "autoRouting", accent: "blue" },
- { key: "piiRedaction", accent: "red" },
- { key: "trainingData", accent: "purple" },
- { key: "authenticity", accent: "green" },
+ { key: "autoRouting", accent: "default" },
+ { key: "piiRedaction", accent: "danger" },
+ { key: "trainingData", accent: "premium" },
+ { key: "authenticity", accent: "success" },
];
export function PopularUseCases() {
@@ -42,13 +42,14 @@ export function PopularUseCases() {
>
{t("portal.useCases.title")}
- setActiveView("pipelines")}
>
{t("portal.useCases.viewAll")} →
-
+
{USE_CASES.map((uc) => (
@@ -70,7 +71,8 @@ export function PopularUseCases() {
{t(`portal.useCases.items.${uc.key}.blurb`)}
-
{t(`portal.useCases.items.${uc.key}.cta`)}{" "}
→
-
+
))}
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 ? (
setActiveView("usage")}
>
{t("portal.processingStatus.upgrade")}
@@ -104,7 +104,7 @@ export function ProcessingStatusStrip() {
setActiveView("usage")}
>
diff --git a/frontend/editor/src/portal/components/RecentActivity.tsx b/frontend/editor/src/portal/components/RecentActivity.tsx
index 8934a5f0bd..11d27a9770 100644
--- a/frontend/editor/src/portal/components/RecentActivity.tsx
+++ b/frontend/editor/src/portal/components/RecentActivity.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
-import { Card, EmptyState, Skeleton, StatusBadge } from "@app/ui";
+import { Button, Card, EmptyState, Skeleton, StatusBadge } from "@app/ui";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchRecentActivity,
@@ -33,9 +33,9 @@ export function RecentActivity() {
{t("portal.recentActivity.title")}
-
+
{t("portal.recentActivity.viewAll")} →
-
+
{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) => (
-
@@ -112,7 +114,7 @@ export function SearchModal() {
{item.hint}
-
+
))}
))}
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")}
-
+
{t("portal.settings.cancel")}
-
+
{t("portal.settings.saveChanges")}
>
@@ -401,7 +401,7 @@ function ProfilePanel({
{email}
-
+
{t("portal.settings.profile.changePhoto")}
@@ -458,9 +458,10 @@ function AppearancePanel({
aria-label={t("portal.settings.appearance.themeTitle")}
>
{THEME_OPTIONS.map((opt) => (
-
{t(`portal.settings.appearance.${opt.value}.hint`)}
-
+
))}
@@ -639,7 +640,7 @@ function WorkspacePanel({
)}
-
+
{t("portal.settings.workspace.manageBilling")}
@@ -811,7 +812,7 @@ function SessionsPanel({
) : (
// TODO(backend): DELETE /v1/settings/sessions/{id}
-
+
{t("portal.settings.sessions.revoke")}
)}
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() {
-
-
+
)}
-
+
{t("portal.opRunner.action.close")}
{phase === "done" ? (
<>
-
+
{t("portal.opRunner.action.runAgain")}
→}
+ rightSection={→ }
>
{t("portal.opRunner.action.openBuilder")}
>
) : (
→}
+ rightSection={→ }
>
{phase === "running"
? t("portal.opRunner.action.running")
@@ -233,15 +233,16 @@ export function SingleOpRunner({
>
)}
-
{sample
? t("portal.opRunner.drop.pickAnother")
: t("portal.opRunner.drop.useSample")}
-
+
@@ -269,9 +270,10 @@ export function SingleOpRunner({
/>
)}
{ops?.map((op) => (
-
{op.blurb}
-
+
))}
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) {
runAction(slide.primary)}
- trailingIcon={→ }
+ rightSection={→ }
>
{t(slide.primary.labelKey)}
runAction(slide.secondary)}
>
{t(slide.secondary.labelKey)}
@@ -213,9 +212,9 @@ export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
aria-label={t("portal.welcome.pagination")}
>
{SLIDES.map((s, i) => (
-
diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx
index 2b07e12b3a..3f8da872c8 100644
--- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx
+++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx
@@ -106,7 +106,7 @@ export function LinkAccountModal({
{import.meta.env.DEV && (
{
await onLinked({ access_token: "dev-stub-jwt" });
onClose();
diff --git a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx
index f6839596d3..63defa92ac 100644
--- a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx
+++ b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx
@@ -98,8 +98,8 @@ export function LinkedInstancesTable({
render: (i) =>
i.revoked ? null : (
onRevoke(i)}
diff --git a/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx b/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx
index f6734721b8..9891c10d03 100644
--- a/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx
+++ b/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents";
-import { StatusBadge } from "@app/ui";
+import { Button, StatusBadge } from "@app/ui";
import "@portal/views/AgentBuilder.css";
interface AgentSelectorProps {
@@ -22,9 +22,11 @@ export function AgentSelector({
aria-label={t("portal.agentBuilder.selectorAriaLabel")}
>
{agents.map((a) => (
-
{a.version}
-
+
))}
);
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={
-
+
{t("portal.agentBuilder.bootstrap.cancel")}
diff --git a/frontend/editor/src/portal/components/agent-builder/EvalsPanel.tsx b/frontend/editor/src/portal/components/agent-builder/EvalsPanel.tsx
index 3f1580652f..4654f10635 100644
--- a/frontend/editor/src/portal/components/agent-builder/EvalsPanel.tsx
+++ b/frontend/editor/src/portal/components/agent-builder/EvalsPanel.tsx
@@ -86,7 +86,7 @@ export function EvalsPanel({ agent }: EvalsPanelProps) {
value={`${agent.evalsPassing} / ${agent.evalsTotal}`}
/>
-
+
{t("portal.agentBuilder.evals.runEvals")}
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) {
toggleEnabled(s.id)}
>
{s.enabled
@@ -80,7 +80,7 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) {
-
+
{t("portal.agentBuilder.scenarios.addScenario")}
diff --git a/frontend/editor/src/portal/components/agent-builder/ToolsPanel.tsx b/frontend/editor/src/portal/components/agent-builder/ToolsPanel.tsx
index 4dde8ff54d..6c0d3030ac 100644
--- a/frontend/editor/src/portal/components/agent-builder/ToolsPanel.tsx
+++ b/frontend/editor/src/portal/components/agent-builder/ToolsPanel.tsx
@@ -47,7 +47,7 @@ export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) {
: t("portal.agentBuilder.tools.governanceGate")
}
/>
-
+
{restricted
? t("portal.agentBuilder.tools.restricted")
: t("portal.agentBuilder.tools.broadAccess")}
@@ -68,7 +68,7 @@ export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) {
return (
toggleDenied(tool) : undefined
diff --git a/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx b/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx
index 88edc7993c..20d71dbfbc 100644
--- a/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx
+++ b/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx
@@ -69,7 +69,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
{v.status === "draft" && (
publish(v.version)}
>
{t("portal.agentBuilder.versions.publish")}
@@ -78,7 +78,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
{v.status === "published" && !isCurrent && (
rollback(v.version)}
>
{t("portal.agentBuilder.versions.rollBack")}
diff --git a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx
index 6525e66970..f27f62097e 100644
--- a/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx
+++ b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx
@@ -35,11 +35,7 @@ export function EnterpriseUpsell({ bare = false }: Props) {
)}
- setActiveView("procurement")}
- >
+ setActiveView("procurement")}>
{t(
"portal.billing.enterpriseUpsell.cta",
"Build your Enterprise quote",
diff --git a/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx
index 77cbdbad5b..d06dfd23a6 100644
--- a/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx
+++ b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx
@@ -72,9 +72,9 @@ export function FreePdfEditorsCard() {
{/* Opens the Users tab with its invite-member modal (via the ?invite param). */}
}
+ leftSection={ }
onClick={() => navigate("/users?invite=1")}
>
{t("portal.billing.freeEditors.inviteTeammates", "Invite teammates")}
diff --git a/frontend/editor/src/portal/components/billing/FreePlanView.tsx b/frontend/editor/src/portal/components/billing/FreePlanView.tsx
index 7132e6dfa1..0192d5761e 100644
--- a/frontend/editor/src/portal/components/billing/FreePlanView.tsx
+++ b/frontend/editor/src/portal/components/billing/FreePlanView.tsx
@@ -56,7 +56,8 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) {
const switchOnAction = isLeader ? (
diff --git a/frontend/editor/src/portal/components/billing/InvoicesList.tsx b/frontend/editor/src/portal/components/billing/InvoicesList.tsx
index 1a9f015f5e..8e4c8a8540 100644
--- a/frontend/editor/src/portal/components/billing/InvoicesList.tsx
+++ b/frontend/editor/src/portal/components/billing/InvoicesList.tsx
@@ -218,7 +218,7 @@ export function InvoicesList() {
{hasMore && (
setShowAll((v) => !v)}
>
diff --git a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx
index 522fcc17c3..e21fec3591 100644
--- a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx
+++ b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx
@@ -23,7 +23,11 @@ export function LinkAccountPrompt() {
"Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use.",
)}
actions={
- openLinkModal()}>
+ openLinkModal()}
+ >
{t("portal.billing.linkPrompt.cta", "Link Stirling account")}
}
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) {
)}
{proj && draftCap !== proj.suggestedMajor && (
- setDraftCap(proj.suggestedMajor)}
@@ -157,7 +158,7 @@ export function SpendLimitCard({
amount: formatMoneyMajor(proj.suggestedMajor, wallet.currency),
},
)}
-
+
)}
@@ -184,13 +185,13 @@ export function SpendLimitCard({
onAdjustingChange(false)}
>
{t("portal.billing.spendLimit.cancel", "Cancel")}
-
+
{t("portal.billing.spendLimit.save", "Save limit")}
@@ -223,7 +224,7 @@ export function SpendLimitCard({
{isLeader && (
onAdjustingChange(true)}
>
diff --git a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx
index 2b8048ebcc..7e8e55e70a 100644
--- a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx
+++ b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx
@@ -91,7 +91,7 @@ function CheckoutActivationSlow({ onClose }: { onClose: () => void }) {
"Your payment succeeded, but activation is taking a little longer than usual. It'll switch on automatically - close this and it'll appear here shortly.",
)}
-
+
{t("portal.billing.checkout.activationSlow.close", "Close")}
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({
) : (
onClose()}
>
@@ -147,7 +147,7 @@ export function ComponentDetailModal({
{component.frameworks.map((fw) => (
-
+
{fw}
))}
diff --git a/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.tsx b/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.tsx
index 8ff5155b63..9a87fc3c0d 100644
--- a/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.tsx
+++ b/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.tsx
@@ -32,7 +32,7 @@ export function ComponentPropsTable({ props: rows }: ComponentPropsTableProps) {
header: t("portal.catalogue.props.columns.required"),
render: (p) =>
p.required ? (
-
+
{t("portal.catalogue.props.required")}
) : (
diff --git a/frontend/editor/src/portal/components/docs/AuthenticationSection.tsx b/frontend/editor/src/portal/components/docs/AuthenticationSection.tsx
index e15e04592c..25481f8cf7 100644
--- a/frontend/editor/src/portal/components/docs/AuthenticationSection.tsx
+++ b/frontend/editor/src/portal/components/docs/AuthenticationSection.tsx
@@ -18,13 +18,13 @@ export function AuthenticationSection() {
/>
-
+
sk_live_
{t("portal.docs.authentication.liveKey")}
-
+
sk_test_
{t("portal.docs.authentication.testKey")}
diff --git a/frontend/editor/src/portal/components/docs/ComponentsSection.tsx b/frontend/editor/src/portal/components/docs/ComponentsSection.tsx
index 5ad8c4b502..6bb9326ab8 100644
--- a/frontend/editor/src/portal/components/docs/ComponentsSection.tsx
+++ b/frontend/editor/src/portal/components/docs/ComponentsSection.tsx
@@ -21,7 +21,7 @@ export function ComponentsSection({
{c.name}
-
+
{c.tag}
diff --git a/frontend/editor/src/portal/components/docs/DocsNav.tsx b/frontend/editor/src/portal/components/docs/DocsNav.tsx
index cbd45e7d6c..48d325e9ae 100644
--- a/frontend/editor/src/portal/components/docs/DocsNav.tsx
+++ b/frontend/editor/src/portal/components/docs/DocsNav.tsx
@@ -1,5 +1,5 @@
+import { Button, Skeleton, StatusBadge } from "@app/ui";
import { useTranslation } from "react-i18next";
-import { Skeleton, StatusBadge } from "@app/ui";
import type { DocsNavSection } from "@portal/api/docs";
/** Left-hand documentation nav tree; each leaf selects an in-page section. */
@@ -31,8 +31,10 @@ export function DocsNav({
const isActive = item.id === active;
return (
-
)}
-
+
);
})}
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. */}
-
+
{t("portal.docs.recipes.cloneButton")}
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) {
diff --git a/frontend/editor/src/portal/components/editor-admin/DeploymentTargets.tsx b/frontend/editor/src/portal/components/editor-admin/DeploymentTargets.tsx
index 9920b12d03..7add4fd8b5 100644
--- a/frontend/editor/src/portal/components/editor-admin/DeploymentTargets.tsx
+++ b/frontend/editor/src/portal/components/editor-admin/DeploymentTargets.tsx
@@ -79,10 +79,7 @@ export function DeploymentTargets({ targets, onUpgrade }: Props) {
{lockCopy(target)}
diff --git a/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx b/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx
index 4cf664288d..f7923b1272 100644
--- a/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx
+++ b/frontend/editor/src/portal/components/editor-admin/InstanceHealthTable.tsx
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import {
Card,
Chip,
+ type ChipAccent,
EmptyState,
StatusBadge,
Table,
@@ -20,6 +21,13 @@ const TARGET_LABEL: Record = {
kubernetes: "K8s",
};
+/** Map the target palette tone onto the shared Chip accent set. */
+const TARGET_CHIP_ACCENT: Record<"neutral" | "blue" | "purple", ChipAccent> = {
+ neutral: "neutral",
+ blue: "default",
+ purple: "premium",
+};
+
interface Props {
instances: EditorInstance[];
}
@@ -36,7 +44,10 @@ export function InstanceHealthTable({ instances }: Props) {
{i.host}
-
+
{TARGET_LABEL[i.target]}
diff --git a/frontend/editor/src/portal/components/editor-admin/OfflineActivationCard.tsx b/frontend/editor/src/portal/components/editor-admin/OfflineActivationCard.tsx
index 302fe74812..ff780ea516 100644
--- a/frontend/editor/src/portal/components/editor-admin/OfflineActivationCard.tsx
+++ b/frontend/editor/src/portal/components/editor-admin/OfflineActivationCard.tsx
@@ -30,7 +30,7 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) {
}
return (
-
+
@@ -51,8 +51,8 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) {
{t("portal.editorAdmin.offlineActivation.lockCopy")}
@@ -75,8 +75,8 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) {
)}
diff --git a/frontend/editor/src/portal/components/editor-admin/PairingPanel.tsx b/frontend/editor/src/portal/components/editor-admin/PairingPanel.tsx
index bde1f09aa8..6544ab1967 100644
--- a/frontend/editor/src/portal/components/editor-admin/PairingPanel.tsx
+++ b/frontend/editor/src/portal/components/editor-admin/PairingPanel.tsx
@@ -59,8 +59,8 @@ export function PairingPanel({ pairings, onUpgrade }: Props) {
{t("portal.editorAdmin.pairing.talkToSales")}
@@ -77,13 +77,13 @@ export function PairingPanel({ pairings, onUpgrade }: Props) {
)}
{p.expires && (
-
+
{p.expires}
)}
rotate(p.method)}
>
{rotated === p.method
diff --git a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx
index c1a1c8f247..58008b0837 100644
--- a/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/ApiKeyCard.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
+import { Button, Card, Chip, StatusBadge } from "@app/ui";
import { useTranslation } from "react-i18next";
-import { Card, Chip, StatusBadge } from "@app/ui";
import type { ApiKey } from "@portal/api/infrastructure";
import {
KEY_LABEL,
@@ -13,8 +13,8 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
const [open, setOpen] = useState(false);
return (
- setOpen((v) => !v)}
aria-expanded={open}
@@ -34,7 +34,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
›
-
+
{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")}
/>
setModalOpen(true)}
- leadingIcon={+ }
+ leftSection={+ }
>
{t("portal.infrastructure.apiKeys.createKey")}
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 ? (
-
+
{t("portal.infrastructure.createKey.done")}
) : (
-
+
{t("portal.infrastructure.createKey.cancel")}
diff --git a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx
index 9c2037b13f..18967c2aaa 100644
--- a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx
@@ -141,11 +141,11 @@ export function DeploymentsTab() {
header: t("portal.infrastructure.deployments.deployColumns.environment"),
render: (d) => (
(
{m.name}
-
+
{MODEL_PROVIDER_LABEL[m.provider]}
@@ -56,7 +56,7 @@ export function ModelsTab() {
key: "type",
header: t("portal.infrastructure.models.columns.type"),
render: (m) => (
-
+
{MODEL_TYPE_LABEL[m.type]}
),
@@ -134,7 +134,7 @@ export function ModelsTab() {
{r.operation}
{r.isDefault && (
-
+
{t("portal.infrastructure.models.routingColumns.default")}
)}
diff --git a/frontend/editor/src/portal/components/infrastructure/SecurityTab.tsx b/frontend/editor/src/portal/components/infrastructure/SecurityTab.tsx
index 457313eb29..0d299d7032 100644
--- a/frontend/editor/src/portal/components/infrastructure/SecurityTab.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/SecurityTab.tsx
@@ -191,7 +191,7 @@ export function SecurityTab() {
{/* Rotation is a privileged backend action; disabled where Stirling
holds the keys (managed tiers can't rotate customer keys). */}
{
@@ -286,9 +286,7 @@ export function SecurityTab() {
{ATTESTATION_LABEL[a.status]}
-
- {a.framework}
-
+ {a.framework}
{a.detail}
{a.reportUrl ? (
+
{t("portal.infrastructure.storage.providers.connect")}
)}
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={
-
moveStep(i, -1)}
>
↑
-
-
+ moveStep(i, 1)}
>
↓
-
-
+ removeStep(i)}
>
×
-
+
))}
@@ -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")}
onEdit(pipeline)}
>
{t("portal.pipelines.detail.edit")}
onTogglePause(pipeline)}
>
@@ -202,8 +202,8 @@ export function PipelineDetailCard({
: t("portal.pipelines.detail.pause")}
onDelete(pipeline)}
>
diff --git a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx
index a39f1c5e95..d2fda82fdc 100644
--- a/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx
+++ b/frontend/editor/src/portal/components/pipelines/PipelinesTable.tsx
@@ -39,7 +39,7 @@ export function PipelinesTable({
{p.name}
-
+
{t(`portal.pipelines.trigger.${p.trigger}`, {
defaultValue: p.trigger,
})}
diff --git a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx
index 21aef179a1..3ec4a72f78 100644
--- a/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx
+++ b/frontend/editor/src/portal/components/policies/PolicyCategoryCard.tsx
@@ -50,7 +50,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
{comingSoon ? (
-
+
{t("portal.policies.card.comingSoon")}
) : policy ? (
@@ -82,7 +82,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
) : (
-
+
{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}
- setExpanded((v) => !v)}
>
{expanded
? t("portal.policies.detail.showLess")
: t("portal.policies.detail.showMore")}
-
+
);
}
@@ -139,8 +140,8 @@ export function PolicyDetailPanel({
{canDelete && (
)}
{item.status === "flagged" && onRetry && (
- onRetry(item)}
>
{t("portal.policies.detail.retry")}
-
+
)}
))}
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={
-
+
{t("portal.policies.wizard.actions.cancel")}
{step === "workflow" ? (
@@ -265,7 +265,7 @@ function PolicySetupWizardBody({
) : (
<>
setStep("workflow")}
@@ -367,9 +367,10 @@ function PolicySetupWizardBody({
/>
) : (
availableSources.map((src) => (
-
-
+
))
)}
@@ -413,22 +414,23 @@ function PolicySetupWizardBody({
count: scopeTypes.length,
})}
- setScopeNarrow((v) => !v)}
>
{scopeNarrow
? t("portal.policies.wizard.docTypes.clear")
: t("portal.policies.wizard.docTypes.narrow")}
-
+
{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={
-
+
{t("portal.procurement.modal.cancel")}
setFile(e.target.files?.[0] ?? null)}
/>
fileRef.current?.click()}
>
diff --git a/frontend/editor/src/portal/components/procurement/DealJourney.tsx b/frontend/editor/src/portal/components/procurement/DealJourney.tsx
index 96588f3c21..6e01d5c0f7 100644
--- a/frontend/editor/src/portal/components/procurement/DealJourney.tsx
+++ b/frontend/editor/src/portal/components/procurement/DealJourney.tsx
@@ -85,8 +85,8 @@ export function DealJourney({
{!isTerminal && currentStep && (
onAdvance(currentStage)}
>
diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx
index 296ccc1a52..730fb768ec 100644
--- a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx
+++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx
@@ -143,8 +143,8 @@ export function DealStatusHero({
diff --git a/frontend/editor/src/portal/components/procurement/DocRow.tsx b/frontend/editor/src/portal/components/procurement/DocRow.tsx
index ff6d27716e..b4329fbfa4 100644
--- a/frontend/editor/src/portal/components/procurement/DocRow.tsx
+++ b/frontend/editor/src/portal/components/procurement/DocRow.tsx
@@ -10,15 +10,15 @@ import {
/** Maps a document's action to the button accent + variant. */
function buttonStyle(doc: LedgerDoc): {
- variant: "gradient" | "outline";
- accent: "purple" | "blue";
+ variant: "primary" | "secondary";
+ accent: "premium" | "default";
} {
// The agreement signature and online payment are the deal-advancing actions;
- // give them the filled accent CTA. Everything else is a quieter outline.
+ // give them the filled premium CTA. Everything else is a quieter outline.
if (doc.action === "sign" || doc.action === "pay") {
- return { variant: "gradient", accent: "purple" };
+ return { variant: "primary", accent: "premium" };
}
- return { variant: "outline", accent: "blue" };
+ return { variant: "secondary", accent: "default" };
}
/**
@@ -52,12 +52,12 @@ export function DocRow({
{doc.name}
{doc.optional && (
-
+
{t("portal.procurement.docs.optional")}
)}
{doc.fee !== undefined && (
-
+
{t("portal.procurement.docs.paidAddon")}
)}
diff --git a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx b/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx
index 69333325e8..8189d006f8 100644
--- a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx
+++ b/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx
@@ -81,12 +81,12 @@ export function DocumentLedger({
· {blurb}
)}
{cur && (
-
+
{t("portal.procurement.docs.here")}
)}
{done && (
-
+
{t("portal.procurement.docs.done")}
)}
diff --git a/frontend/editor/src/portal/components/procurement/LockedState.tsx b/frontend/editor/src/portal/components/procurement/LockedState.tsx
index 90a61c987a..8e70ba18c4 100644
--- a/frontend/editor/src/portal/components/procurement/LockedState.tsx
+++ b/frontend/editor/src/portal/components/procurement/LockedState.tsx
@@ -23,7 +23,7 @@ export function LockedState({
title={t("portal.procurement.locked.title")}
description={t("portal.procurement.locked.description")}
actions={
-
+
{t("portal.procurement.locked.talkToSales")}
}
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({
openLinkModal()}
>
{t("portal.procurement.link.cta")}
diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx
index 7074e390e7..6c57c501c7 100644
--- a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx
+++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx
@@ -31,10 +31,10 @@ export const Open: Story = {
contract and go live.
-
+
Continue to checkout
- Edit quote
+ Edit quote
),
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({
{t("portal.procurement.milestone.accept")}
-
+
{t("portal.procurement.milestone.download")}
-
+
{t("portal.procurement.milestone.edit")}
@@ -115,8 +115,8 @@ export function PaymentStageCard({
{invoiceUrl && (
window.open(invoiceUrl, "_blank", "noopener")}
>
{t("portal.procurement.payment.viewInvoice")}
@@ -124,7 +124,7 @@ export function PaymentStageCard({
)}
{invoicePdf && (
window.open(invoicePdf, "_blank", "noopener")}
>
{t("portal.procurement.payment.downloadInvoice")}
diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx
index b180c986bf..d13aaaff06 100644
--- a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx
+++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx
@@ -283,14 +283,14 @@ export function QuoteBuilder({
{step > 0 && (
-
setStep(step - 1)}>
+ setStep(step - 1)}>
{t("portal.procurement.builder.back")}
)}
{step === 0 && (
setStep(1)}
>
@@ -299,8 +299,8 @@ export function QuoteBuilder({
)}
{step === 1 && (
setStep(2)}
>
{t("portal.procurement.builder.continue")}
@@ -308,8 +308,8 @@ export function QuoteBuilder({
)}
{step === 2 && (
@@ -169,7 +169,7 @@ export function ConnectWizard({
onClick={advance}
loading={submitting}
disabled={!canContinue}
- trailingIcon={!isLast ? → : undefined}
+ rightSection={!isLast ? → : undefined}
>
{!isLast
? t("portal.sources.wizard.continue")
@@ -200,9 +200,9 @@ export function ConnectWizard({
{stepId === "type" && (
{CREATABLE_SOURCE_TYPES.map((ct) => (
-
{t(ct.labelKey)}
-
+
))}
)}
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({
})}
-
- ×
-
+
+
onEdit(source)}
>
{t("portal.sources.detail.edit")}
onTogglePause(source)}
>
@@ -77,8 +78,8 @@ export function SourceDetailCard({
: t("portal.sources.detail.pause")}
onDelete(source)}
>
diff --git a/frontend/editor/src/portal/components/sources/SourceDetailPanel.tsx b/frontend/editor/src/portal/components/sources/SourceDetailPanel.tsx
index 836d72ba81..f4ee827575 100644
--- a/frontend/editor/src/portal/components/sources/SourceDetailPanel.tsx
+++ b/frontend/editor/src/portal/components/sources/SourceDetailPanel.tsx
@@ -41,7 +41,7 @@ export function SourceDetailPanel({
) : (
{source.referencingPolicies.map((policy) => (
-
+
{policy.name}
))}
diff --git a/frontend/editor/src/portal/components/sources/SourcesTable.tsx b/frontend/editor/src/portal/components/sources/SourcesTable.tsx
index 45ca391575..be66fa350e 100644
--- a/frontend/editor/src/portal/components/sources/SourcesTable.tsx
+++ b/frontend/editor/src/portal/components/sources/SourcesTable.tsx
@@ -40,14 +40,14 @@ export function SourcesTable({
return (
{meta.icon}
{s.name}
-
+
{t(meta.labelKey)}
diff --git a/frontend/editor/src/portal/components/sources/sourceTypes.ts b/frontend/editor/src/portal/components/sources/sourceTypes.ts
index 59fb91fcd2..f3e128ef55 100644
--- a/frontend/editor/src/portal/components/sources/sourceTypes.ts
+++ b/frontend/editor/src/portal/components/sources/sourceTypes.ts
@@ -1,4 +1,4 @@
-import type { ChipTone } from "@app/ui";
+import type { ChipAccent } from "@app/ui";
/**
* Per-type presentation + create-form metadata. User-facing copy is stored as
@@ -12,26 +12,26 @@ import type { ChipTone } from "@app/ui";
export interface SourceTypeMeta {
labelKey: string;
icon: string;
- tone: ChipTone;
+ accent: ChipAccent;
}
const SOURCE_TYPE_META: Record
= {
folder: {
labelKey: "portal.sources.types.folder.label",
icon: "⛁",
- tone: "blue",
+ accent: "default",
},
editor: {
labelKey: "portal.sources.types.editor.label",
icon: "✏",
- tone: "green",
+ accent: "success",
},
};
const UNKNOWN_TYPE_META: SourceTypeMeta = {
labelKey: "portal.sources.types.unknown.label",
icon: "◇",
- tone: "neutral",
+ accent: "neutral",
};
export function sourceTypeMeta(type: string): SourceTypeMeta {
diff --git a/frontend/editor/src/portal/components/users/AccessControls.tsx b/frontend/editor/src/portal/components/users/AccessControls.tsx
index d45d43bbaf..9744e13141 100644
--- a/frontend/editor/src/portal/components/users/AccessControls.tsx
+++ b/frontend/editor/src/portal/components/users/AccessControls.tsx
@@ -142,7 +142,7 @@ export function AccessControls({ access }: AccessControlsProps) {
value={access.sso.domains.join(", ") || "—"}
/>
-
+
{t("portal.users.access.sso.manage")}
@@ -188,7 +188,7 @@ export function AccessControls({ access }: AccessControlsProps) {
title={t("portal.users.access.upgrade.title")}
description={access.upgradeHint}
action={
-
+
{t("portal.users.access.upgrade.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.invite.cancel")}
diff --git a/frontend/editor/src/portal/components/users/MembersTable.tsx b/frontend/editor/src/portal/components/users/MembersTable.tsx
index deda005d1f..6a918813b5 100644
--- a/frontend/editor/src/portal/components/users/MembersTable.tsx
+++ b/frontend/editor/src/portal/components/users/MembersTable.tsx
@@ -1,15 +1,24 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Menu } from "@mantine/core";
-import { Avatar, Chip, StatusBadge, Table, type TableColumn } from "@app/ui";
+import {
+ ActionIcon,
+ Avatar,
+ Chip,
+ StatusBadge,
+ Table,
+ type TableColumn,
+} from "@app/ui";
import {
type Member,
type RoleId,
MEMBER_STATUS_TONE,
ROLE_LABEL,
- ROLE_TONE,
} from "@portal/api/users";
-import { avatarToneForRole } from "@portal/components/users/format";
+import {
+ avatarToneForRole,
+ chipAccentForRole,
+} from "@portal/components/users/format";
import "@portal/views/Users.css";
interface MembersTableProps {
@@ -57,7 +66,7 @@ export function MembersTable({
key: "role",
header: t("portal.users.table.role"),
render: (m) => (
-
+
{ROLE_LABEL[m.role]}
),
@@ -92,16 +101,16 @@ export function MembersTable({
// a row-action menu needs; SUI has no equivalent.
- e.stopPropagation()}
+ variant="tertiary"
>
⋯
-
+
{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() {
setBootstrapOpen(true)}
- leadingIcon={⇪ }
+ leftSection={⇪ }
>
{t("portal.agentBuilder.bootstrapFromDocument")}
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}
setActiveView(target)}
- trailingIcon={→ }
+ rightSection={→ }
>
{cta}
@@ -87,14 +90,14 @@ function ProductGrid() {
aria-label={t("portal.home.productGrid.ariaLabel")}
>
void }) {
- void }) {
→
-
-
+ setActiveView("pipelines")}
@@ -175,8 +180,9 @@ function QuickActions({ onTryOp }: { onTryOp: () => void }) {
→
-
-
+ setActiveView("sources")}
@@ -198,8 +204,9 @@ function QuickActions({ onTryOp }: { onTryOp: () => void }) {
→
-
-
+ setActiveView("infrastructure")}
@@ -221,7 +228,7 @@ function QuickActions({ onTryOp }: { onTryOp: () => void }) {
→
-
+
);
@@ -243,7 +250,7 @@ function FreeOnboarding({ onTryOp }: { onTryOp: () => void }) {
function renderCta(step: OnboardingStep) {
if (step.done) {
return (
-
+
{t("portal.home.onboarding.runAgain")}
);
@@ -251,7 +258,7 @@ function FreeOnboarding({ onTryOp }: { onTryOp: () => void }) {
if (!step.cta) return null;
if (step.cta.kind === "try-op") {
return (
-
+
{t("portal.home.onboarding.start")}
);
@@ -260,7 +267,7 @@ function FreeOnboarding({ onTryOp }: { onTryOp: () => void }) {
return (
setActiveView(target as ViewId)}
>
{t("portal.home.onboarding.start")}
diff --git a/frontend/editor/src/portal/views/Infrastructure.tsx b/frontend/editor/src/portal/views/Infrastructure.tsx
index eebea50483..bc0920382c 100644
--- a/frontend/editor/src/portal/views/Infrastructure.tsx
+++ b/frontend/editor/src/portal/views/Infrastructure.tsx
@@ -44,7 +44,7 @@ export function Infrastructure() {
setActiveView("editor")}
>
diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx
index 6b69d8ca33..ece7a2d87e 100644
--- a/frontend/editor/src/portal/views/Pipelines.test.tsx
+++ b/frontend/editor/src/portal/views/Pipelines.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 { PipelinesOverviewResponse, Policy } from "@portal/api/pipelines";
import type { SourcesResponse } from "@portal/api/sources";
import { Pipelines } from "@portal/views/Pipelines";
diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx
index d87e697446..8781778ced 100644
--- a/frontend/editor/src/portal/views/Pipelines.tsx
+++ b/frontend/editor/src/portal/views/Pipelines.tsx
@@ -114,7 +114,7 @@ export function Pipelines() {
{t("portal.pipelines.subtitle")}
- +}>
+ +}>
{t("portal.pipelines.actions.newPipeline")}
@@ -179,7 +179,7 @@ export function Pipelines() {
footer={
setPendingDelete(null)}
@@ -188,7 +188,7 @@ export function Pipelines() {
diff --git a/frontend/editor/src/portal/views/Policies.css b/frontend/editor/src/portal/views/Policies.css
index 5744b2ff42..6bad9365c5 100644
--- a/frontend/editor/src/portal/views/Policies.css
+++ b/frontend/editor/src/portal/views/Policies.css
@@ -36,6 +36,31 @@
gap: 0.375rem;
}
+/* Category row — locked (coming soon) */
+.portal-policies__row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.25rem 0.75rem;
+ min-height: 2.25rem;
+}
+
+.portal-policies__row-name {
+ flex: 1;
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--color-text-3);
+}
+
+/* "Set up" underline link inside the active-row button's rightSection */
+.portal-policies__setup-link {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--color-blue);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
/* Category card — horizontal table row */
.portal-policies__card {
display: flex;
@@ -109,6 +134,55 @@
color: var(--color-text-1);
}
+.portal-policies__card-blurb {
+ font-size: 0.75rem;
+ color: var(--color-text-4);
+ line-height: 1.4;
+}
+
+.portal-policies__card-summary {
+ margin: 0;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ color: var(--color-text-2);
+}
+
+.portal-policies__card-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ margin-top: auto;
+ flex-wrap: wrap;
+}
+
+.portal-policies__card-rules {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+
+.portal-policies__card-cta {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--color-blue);
+}
+
+.portal-policies__chevron-right {
+ transform: rotate(-90deg);
+ display: block;
+}
+
+/* Configured-card stat footer */
+.portal-policies__card-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.5rem;
+ margin-top: auto;
+ padding-top: 0.75rem;
+ border-top: 1px solid var(--color-border-light);
+}
+
/* Wizard + detail shared chrome */
.portal-policies__wizard-title {
display: inline-flex;
diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx
index d0c4e05d49..609f7b2bfc 100644
--- a/frontend/editor/src/portal/views/Policies.tsx
+++ b/frontend/editor/src/portal/views/Policies.tsx
@@ -133,7 +133,7 @@ export function Policies() {
title={t("portal.policies.offline.title")}
description={t("portal.policies.offline.description")}
action={
-
+
{t("portal.policies.offline.retry")}
}
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() {
setActiveView("agent-builder")}
- leadingIcon={ }
+ leftSection={ }
>
{t("portal.sources.actions.agentBuilder")}
-
+}>
+ +}>
{t("portal.sources.actions.connectSource")}
@@ -200,7 +200,7 @@ export function Sources() {
footer={
setPendingDelete(null)}
@@ -209,7 +209,7 @@ export function Sources() {
diff --git a/frontend/editor/src/portal/views/Usage.tsx b/frontend/editor/src/portal/views/Usage.tsx
index d1f6ede78a..4a4493aa2c 100644
--- a/frontend/editor/src/portal/views/Usage.tsx
+++ b/frontend/editor/src/portal/views/Usage.tsx
@@ -181,7 +181,7 @@ export function Usage() {
{wallet?.status === "subscribed" && (
setInviteOpen(true)}
- leadingIcon={+ }
+ leftSection={+ }
>
{t("portal.common.inviteMember")}
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) => (
- setIndex(i)}
style={{
- width: "10px",
- height: "12px",
+ width: "5px",
+ height: "5px",
borderRadius: "50%",
border: "none",
cursor: "pointer",
@@ -223,6 +225,9 @@ function LoginRightCarousel({
boxShadow: "0 2px 6px rgba(0,0,0,0.25)",
display: "block",
flexShrink: 0,
+ padding: 0,
+ minWidth: 0,
+ minHeight: 0,
}}
/>
))}
diff --git a/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx b/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx
index ad1a3b82f7..3644ed91bc 100644
--- a/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx
+++ b/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { type OAuthProvider } from "@app/auth/spring/oauthTypes";
-import { Button } from "@mantine/core";
+import { Button as DSButton } from "@app/ui/Button";
import { oauthIconUrl, GENERIC_PROVIDER_ICON } from "@app/auth/ui/oauthIcons";
// Debug flag to show all providers for UI testing
@@ -107,58 +107,24 @@ export default function OAuthButtons({
key={p.id}
title={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
- onProviderClick(p.id)}
disabled={isSubmitting}
className="oauth-button-icon"
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
- variant="default"
+ variant="tertiary"
>
-
+
))}
);
}
-
- 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 ?? ""}${p.label}`}
- >
-
-
-
- {ctaPrefix ?? ""}
- {p.label}
-
-
-
- ))}
-
- );
- }
-
if (layout === "grid") {
return (
@@ -167,24 +133,55 @@ export default function OAuthButtons({
key={p.id}
title={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
-
onProviderClick(p.id)}
disabled={isSubmitting}
className="oauth-button-grid"
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
- variant="default"
+ variant="tertiary"
>
-
+
))}
);
}
+ 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}
+ >
+
+
+
+ {ctaPrefix ? `${ctaPrefix} ${p.label}` : p.label}
+
+
+
+ ))}
+
+ );
+ }
return (
- onProviderClick(p.id)}
disabled={!demoMode && isSubmitting}
className={`oauth-button-vertical${useNewStyle && isSingleProvider ? " oauth-button-vertical-single" : ""}${!useNewStyle ? " oauth-button-vertical-legacy" : ""}${isTinted ? " oauth-button-vertical-tinted" : ""}${isOutline ? " oauth-button-vertical-outline" : ""}${isLight ? " oauth-button-vertical-light" : ""}`}
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
- variant="default"
+ variant="tertiary"
style={
isTinted
? ({
@@ -238,7 +235,7 @@ export default function OAuthButtons({
)}
-
+
))}
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) => (
-
{sym}
{preset.toLocaleString()}
-
+
))}
-
{L.noCap}
-
+
{onSave && (
(null);
+ // Separate bounds element inset by FAB_GAP_PX — react-rnd enforces this
+ // during both drag and resize, keeping the panel off the overlay edges.
+ const [boundsEl, setBoundsEl] = useState(null);
+ const boundsRef = useCallback(
+ (el: HTMLDivElement | null) => setBoundsEl(el),
+ [],
+ );
const [rndPos, setRndPos] = useState<{ x: number; y: number } | null>(null);
const [rndSize, setRndSize] = useState({
width: PANEL_WIDTH_PX,
@@ -102,8 +115,7 @@ export function ChatFAB() {
const pos = getDefaultPos();
if (pos) setRndPos(pos);
}, [enabled]);
-
- // bounds="parent" only clamps during active drag/resize; ResizeObserver keeps position valid when the overlay changes size.
+ // ResizeObserver keeps position valid when the overlay changes size (e.g. window resize, sidebar toggle).
useEffect(() => {
if (!enabled) return;
const el = overlayRef.current;
@@ -170,6 +182,8 @@ export function ChatFAB() {
className="chat-fab-overlay"
style={{ zIndex: Z_INDEX_CHAT_FAB_OVERLAY }}
>
+ {/* Inset boundary element — react-rnd uses this to constrain drag+resize */}
+
{/* Trigger button — fades out while panel is open */}
{/* Draggable / resizable panel */}
- {rndPos !== null && (
+ {rndPos !== null && boundsEl !== null && (
- setExpanded((v) => !v)}
aria-expanded={expanded}
@@ -281,7 +284,7 @@ function CompletedProgressLogDropdown({
{label}
-
+
{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}
- onRemove(stub.id)}
aria-label={removeLabel(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 (
-
guestBlocked ? promptGuestSignup() : selectPolicy(cat.id)
@@ -248,7 +259,7 @@ export function PoliciesSection({
sx={{ fontSize: "1rem" }}
/>
-
+
);
})}
@@ -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({
)}
-
+
{t("cancel", "Cancel")}
-
+
{t("delete", "Delete")}
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}
-
setExpanded((v) => !v)}
>
{expanded
? t("policies.detail.showLess", "Show less")
: t("policies.detail.showMore", "Show more")}
-
+
);
}
@@ -221,7 +221,7 @@ export function PolicyDetailPanel({
trailing={
item.status === "flagged" && onRetry ? (
onRetry(item)}
>
@@ -290,22 +290,22 @@ export function PolicyDetailPanel({
{canDelete && (
}
+ leftSection={ }
onClick={onDelete}
style={{ marginRight: "auto" }}
>
{t("delete", "Delete")}
)}
-
+
{isPaused
? t("policies.detail.resume", "Resume")
: t("policies.detail.pause", "Pause")}
-
+
{t("policies.detail.editSettings", "Edit Settings")}
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={
@@ -656,14 +656,15 @@ export function PolicySetupWizard({
{ count: scopeTypes.length },
)}
- setScopeNarrow((v) => !v)}
>
{scopeNarrow
? t("policies.wizard.clear", "Clear")
: t("policies.wizard.edit", "Edit")}
-
+
{scopeNarrow && (
@@ -690,12 +691,11 @@ export function PolicySetupWizard({
-
+
{step > 1 ? t("policies.wizard.back", "Back") : t("cancel", "Cancel")}
{step < TOTAL_STEPS ? (
setStep((s) => Math.min(TOTAL_STEPS, s + 1))}
@@ -704,7 +704,6 @@ export function PolicySetupWizard({
) : (
-
-
+
)}
-
+ >
+
+
@@ -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 && (
-
+
{t("workspace.people.actions.upgrade", "Upgrade")}
)}
@@ -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 }}
/>
{
try {
await navigator.clipboard.writeText(
@@ -850,7 +858,7 @@ export default function InviteMembersModal({
loading={!hasNoSlots && processing}
fullWidth
size="md"
- mt="md"
+ style={{ marginTop: "var(--mantine-spacing-md)" }}
>
{inviteMode === "email"
? t("workspace.people.emailInvite.submit", "Send Invites")
diff --git a/frontend/editor/src/proprietary/components/shared/ManageBillingButton.tsx b/frontend/editor/src/proprietary/components/shared/ManageBillingButton.tsx
index 179479bcd3..22450f441e 100644
--- a/frontend/editor/src/proprietary/components/shared/ManageBillingButton.tsx
+++ b/frontend/editor/src/proprietary/components/shared/ManageBillingButton.tsx
@@ -1,5 +1,5 @@
import React, { useState } from "react";
-import { Button } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import licenseService from "@app/services/licenseService";
import { alert } from "@app/components/toast";
@@ -50,7 +50,7 @@ export const ManageBillingButton: React.FC = ({
};
return (
-
+
{t("billing.manageBilling", "Manage Billing")}
);
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 (
= ({
-
+
{t("common.cancel", "Cancel")}
{
textColor="#fff"
iconColor="#fff"
closeIconColor="#fff"
- buttonVariant="white"
+ buttonVariant="filled"
buttonColor="blue"
minHeight={48}
compact
diff --git a/frontend/editor/src/proprietary/components/shared/config/OverviewHeader.tsx b/frontend/editor/src/proprietary/components/shared/config/OverviewHeader.tsx
index 2307ed78bd..0b96811e18 100644
--- a/frontend/editor/src/proprietary/components/shared/config/OverviewHeader.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/OverviewHeader.tsx
@@ -1,4 +1,5 @@
-import { Text, Button } from "@mantine/core";
+import { Text } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
@@ -45,7 +46,7 @@ export function OverviewHeader() {
)}
{user && (
-
+
{t("account.overview.logOut", "Log out")}
)}
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 && (
}
onClick={() => setUsernameModalOpen(true)}
>
@@ -400,8 +400,8 @@ const AccountSection: React.FC = () => {
)}
}
onClick={handleLogout}
>
@@ -452,8 +452,8 @@ const AccountSection: React.FC = () => {
) : (
}
onClick={() => {
setMfaError("");
@@ -546,7 +546,7 @@ const AccountSection: React.FC = () => {
setPasswordModalOpen(false)}
>
{t("common.cancel", "Cancel")}
@@ -641,7 +641,7 @@ const AccountSection: React.FC = () => {
required
/>
-
+
{t("common.cancel", "Cancel")}
@@ -699,10 +699,10 @@ const AccountSection: React.FC = () => {
required
/>
-
+
{t("common.cancel", "Cancel")}
-
+
{t("account.mfa.confirmDisable", "Disable")}
@@ -760,7 +760,7 @@ const AccountSection: React.FC = () => {
setUsernameModalOpen(false)}
>
{t("common.cancel", "Cancel")}
diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx
index e0b6a75e7c..9c81199ed5 100644
--- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminAdvancedSection.tsx
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import {
NumberInput,
Switch,
- Button,
Stack,
Paper,
Text,
@@ -14,6 +13,7 @@ import {
TextInput,
MultiSelect,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
@@ -752,8 +752,8 @@ export default function AdminAdvancedSection() {
)}
{
)}
navigate("/settings/adminSecurity#auditLogging")}
rightSection={
}
@@ -849,7 +849,7 @@ export default function AdminDatabaseSection() {
styles={{ input: { minWidth: 280 } }}
/>
handleDownload(backup.fileName)
}
disabled={!loginEnabled || !isEmbeddedH2}
+ aria-label={t(
+ "admin.settings.database.download",
+ "Download",
+ )}
>
{downloadingFile === backup.fileName ? (
@@ -952,11 +956,15 @@ export default function AdminDatabaseSection() {
withArrow
>
handleImportExisting(backup.fileName)
}
disabled={!loginEnabled || !isEmbeddedH2}
+ aria-label={t(
+ "admin.settings.database.import",
+ "Import",
+ )}
>
{importingBackupFile === backup.fileName ? (
@@ -977,12 +985,16 @@ export default function AdminDatabaseSection() {
withArrow
>
handleDeleteClick(backup.fileName)
}
disabled={!loginEnabled || !isEmbeddedH2}
+ aria-label={t(
+ "admin.settings.database.delete",
+ "Delete",
+ )}
>
{deletingFile === backup.fileName ? (
@@ -1068,14 +1080,14 @@ export default function AdminDatabaseSection() {
{t("cancel", "Cancel")}
setDeleteConfirmFile(null)}
disabled={deletingFile !== null}
>
{t("cancel", "Cancel")}
deleteConfirmFile && handleDelete(deleteConfirmFile)
}
diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx
index aaf73ec281..5e78d21a22 100644
--- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminGeneralSection.tsx
@@ -12,9 +12,9 @@ import {
Group,
MultiSelect,
Badge,
- SegmentedControl,
Select,
} from "@mantine/core";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
@@ -499,7 +499,7 @@ export default function AdminGeneralSection() {
{
navigate("/settings/adminSecurity")}
rightSection={
{
{t("usage.configureSettings", "Configure Analytics Settings")}
navigate("/settings/adminSecurity#auditLogging")}
rightSection={
{
onChange={(value) =>
setDisplayMode(value as "top10" | "top20" | "all")
}
- disabled={showDemoData}
- data={[
+ options={[
{
value: "top10",
label: t("usage.controls.top10", "Top 10"),
+ disabled: showDemoData,
},
{
value: "top20",
label: t("usage.controls.top20", "Top 20"),
+ disabled: showDemoData,
},
{
value: "all",
label: t("usage.controls.all", "All"),
+ disabled: showDemoData,
},
]}
/>
}
@@ -379,19 +374,21 @@ const AdminUsageSection: React.FC = () => {
setDataType(value as "all" | "api" | "ui")}
- disabled={showDemoData}
- data={[
+ options={[
{
value: "all",
label: t("usage.controls.dataType.all", "All"),
+ disabled: showDemoData,
},
{
value: "api",
label: t("usage.controls.dataType.api", "API"),
+ disabled: showDemoData,
},
{
value: "ui",
label: t("usage.controls.dataType.ui", "UI"),
+ disabled: showDemoData,
},
]}
/>
diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx
index a6c3af0bd2..9af89dedad 100644
--- a/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx
@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Anchor,
- Button,
Group,
Loader,
Paper,
@@ -14,6 +13,7 @@ import {
Textarea,
Tooltip,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -201,6 +201,7 @@ export default function LoginAgreementEditor({
style={{ flex: 1, maxWidth: 340 }}
/>
navigate("/settings/adminPlan")}
>
{t("workspace.people.actions.upgrade", "Upgrade")}
@@ -550,7 +549,7 @@ export default function PeopleSection() {
•
-
+
>
)}
@@ -583,7 +582,7 @@ export default function PeopleSection() {
(licenseInfo ? licenseInfo.availableSlots === 0 : false)
}
>
- {t("workspace.people.addMembers")}
+ {t("workspace.people.addMembers", "Add Members")}
@@ -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")}
-
+
{t("workspace.teams.backToTeams", "Back to Teams")}
@@ -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({
onCopy(publicKey, "public")}
leftSection={
}
- styles={{
- root: {
- background: "var(--api-keys-button-bg)",
- color: "var(--api-keys-button-color)",
- border: "none",
- marginLeft: 12,
- },
+ style={{
+ marginLeft: 12,
}}
aria-label={t("config.apiKeys.copyKeyAriaLabel", "Copy API key")}
>
@@ -77,18 +74,14 @@ export default function ApiKeySection({
}
- styles={{
- root: {
- background: "var(--api-keys-button-bg)",
- color: "var(--api-keys-button-color)",
- border: "none",
- marginLeft: 8,
- },
+ style={{
+ marginLeft: 8,
}}
disabled={disabled}
aria-label={t("config.apiKeys.refreshAriaLabel", "Refresh API key")}
diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx
index ea3539d95a..11f90f4d76 100644
--- a/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.tsx
@@ -1,5 +1,6 @@
import React from "react";
-import { Modal, Stack, Text, Group, Button } from "@mantine/core";
+import { Modal, Stack, Text, Group } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
@@ -44,10 +45,10 @@ export default function RefreshModal({
)}
-
+
{t("common.cancel", "Cancel")}
-
+
{t("config.apiKeys.refreshModal.confirmCta", "Refresh Keys")}
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 = ({
)}
-
+
{t("audit.clearData.cancel", "Cancel")}
= ({
= ({
}}
fz="sm"
>
- toggleSort("timestamp")}
style={{
display: "flex",
@@ -292,7 +294,7 @@ const AuditEventsTable: React.FC = ({
width="0.9rem"
height="0.9rem"
/>
-
+
= ({
}}
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"
/>
-
+
= ({
)}
setSelectedEvent(event)}
disabled={!loginEnabled}
>
diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx
index a0ad482779..4e0f808649 100644
--- a/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.tsx
@@ -1,13 +1,7 @@
import React, { useState } from "react";
-import {
- Card,
- Text,
- Group,
- Stack,
- Button,
- SegmentedControl,
- Checkbox,
-} from "@mantine/core";
+import { Card, Text, Group, Stack, Checkbox } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import auditService from "@app/services/auditService";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -103,12 +97,11 @@ const AuditExportSection: React.FC = ({
value={exportFormat}
onChange={(value) => {
if (!loginEnabled) return;
- setExportFormat(value as "csv" | "json");
+ setExportFormat(value);
}}
- disabled={!loginEnabled}
- data={[
- { label: "CSV", value: "csv" },
- { label: "JSON", value: "json" },
+ options={[
+ { label: "CSV", value: "csv", disabled: !loginEnabled },
+ { label: "JSON", value: "json", disabled: !loginEnabled },
]}
/>
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 = ({
handleQuickPreset("today")}
disabled={disabled}
>
{t("audit.filters.today", "Today")}
handleQuickPreset("last7")}
disabled={disabled}
>
{t("audit.filters.last7Days", "Last 7 days")}
handleQuickPreset("last30")}
disabled={disabled}
>
{t("audit.filters.last30Days", "Last 30 days")}
handleQuickPreset("thisMonth")}
disabled={disabled}
>
@@ -216,7 +210,7 @@ const AuditFiltersForm: React.FC = ({
{/* Clear Button */}
= ({
setShowComparison(!showComparison)}
>
{showComparison
diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx
index ffd89ec7b0..4b890e683a 100644
--- a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx
@@ -1,6 +1,5 @@
import React, { useState } from "react";
import {
- Button,
Collapse,
Alert,
TextInput,
@@ -8,9 +7,10 @@ import {
Stack,
Group,
Text,
- SegmentedControl,
- FileButton,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { FilePicker } from "@app/ui/FilePicker";
+import { SegmentedControl } from "@app/ui/SegmentedControl";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { alert } from "@app/components/toast";
@@ -113,7 +113,7 @@ const LicenseKeySection: React.FC = ({
return (
= ({
if (value === "text") setLicenseFile(null);
if (value === "file") setLicenseKeyInput("");
}}
- data={[
+ options={[
{
label: t(
"admin.settings.premium.inputMethod.text",
"License Key",
),
value: "text",
+ disabled: !loginEnabled || savingLicense,
},
{
label: t(
@@ -252,9 +253,9 @@ const LicenseKeySection: React.FC = ({
"Certificate File",
),
value: "file",
+ disabled: !loginEnabled || savingLicense,
},
]}
- disabled={!loginEnabled || savingLicense}
/>
{/* Input area */}
@@ -292,33 +293,26 @@ const LicenseKeySection: React.FC = ({
"Upload your .lic or .cert license file",
)}
-
+ }
>
- {(props) => (
-
- }
- disabled={!loginEnabled || savingLicense}
- >
- {licenseFile
- ? licenseFile.name
- : t(
- "admin.settings.premium.file.choose",
- "Choose License File",
- )}
-
- )}
-
+ {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 = ({
-
+
{isCurrentTier
? t("plan.current", "Current Plan")
: t("plan.free.included", "Included")}
@@ -190,7 +191,6 @@ const PlanCard: React.FC = ({
withArrow
>
isCurrentTier && onManageClick
diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/StaticCheckoutModal.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/StaticCheckoutModal.tsx
index dbac0833ce..05c0e0ec7f 100644
--- a/frontend/editor/src/proprietary/components/shared/config/configSections/plan/StaticCheckoutModal.tsx
+++ b/frontend/editor/src/proprietary/components/shared/config/configSections/plan/StaticCheckoutModal.tsx
@@ -3,14 +3,14 @@ import {
Modal,
Text,
Group,
- ActionIcon,
Stack,
Paper,
Grid,
TextInput,
- Button,
Alert,
} 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 { EmailStage } from "@app/components/shared/stripeCheckout/stages/EmailStage";
@@ -314,7 +314,7 @@ const StaticCheckoutModal: React.FC = ({
@@ -362,7 +362,7 @@ const StaticCheckoutModal: React.FC = ({
{canGoBack && (
= ({
if (plan.id === "free") {
return (
= ({
if (currentTier === "free") {
return (
handleOpenCheckout("server", false)}
className="plan-button"
@@ -251,7 +244,7 @@ const StaticPlanSection: React.FC = ({
if (isCurrent) {
return (
= ({
if (isDowngradePlan) {
return (
= ({
withArrow
>
= ({
// TODO: Re-enable checkout flow when account syncing is ready
// return (
// handleOpenCheckout('enterprise', true)}
// className="plan-button"
@@ -314,7 +307,7 @@ const StaticPlanSection: React.FC = ({
// );
return (
= ({
if (isCurrent) {
return (
= ({
{/* Feature Comparison Toggle */}
setShowComparison(!showComparison)}
>
{showComparison
diff --git a/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx b/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx
index 80772c8c97..e0b2c1a361 100644
--- a/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx
+++ b/frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx
@@ -1,5 +1,6 @@
import React, { useEffect } from "react";
-import { Modal, Text, Group, ActionIcon } from "@mantine/core";
+import { Modal, Text, Group } from "@mantine/core";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import licenseService from "@app/services/licenseService";
@@ -275,7 +276,7 @@ const StripeCheckout: React.FC = ({
{canGoBack && (
= ({
}}
/>
-
+
{t("payment.emailStage.continue", "Continue")}
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}
-
+
{t("common.close", "Close")}
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 = ({
)}
-
+
{t("payment.planStage.selectMonthly", "Select Monthly")}
@@ -200,7 +193,7 @@ export const PlanSelectionStage: React.FC = ({
)}
-
+
{t("payment.planStage.selectYearly", "Select Yearly")}
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}
navigator.clipboard.writeText(licenseKey)}
>
@@ -113,7 +105,10 @@ export const SuccessStage: React.FC = ({
)}
-
+
{t("common.close", "Close")}
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({
-
+
{t("cancel", "Cancel")}
-
+
{t("delete", "Delete")}
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({
}
>
e.stopPropagation()}>
@@ -119,9 +128,10 @@ export function WatchedFolderCard({
{!folder.isDefault && (
diff --git a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderHomePage.tsx b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderHomePage.tsx
index 798d340567..f1110c9a2a 100644
--- a/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderHomePage.tsx
+++ b/frontend/editor/src/proprietary/components/watchedFolders/WatchedFolderHomePage.tsx
@@ -1,14 +1,7 @@
import { useState, useCallback, useEffect } from "react";
-import {
- Box,
- Text,
- Stack,
- Group,
- ActionIcon,
- Button,
- Loader,
- ScrollArea,
-} from "@mantine/core";
+import { Box, Text, Stack, Group, Loader, ScrollArea } from "@mantine/core";
+import { Button } from "@app/ui/Button";
+import { ActionIcon } from "@app/ui/ActionIcon";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
@@ -243,7 +236,7 @@ function FolderCard({
e.stopPropagation()}>
onTogglePause(folder)}
aria-label={
isPaused
@@ -264,7 +257,7 @@ function FolderCard({
onEdit(folder)}
aria-label={t("watchedFolders.home.editFolder", "Edit folder")}
>
@@ -272,8 +265,8 @@ function FolderCard({
onDelete(folder)}
aria-label={t("watchedFolders.home.deleteFolder", "Delete folder")}
>
@@ -343,9 +336,8 @@ function HowItWorks() {
{
sessionStorage.setItem("wf_howItWorks_dismissed", "1");
setDismissed(true);
@@ -353,7 +345,10 @@ function HowItWorks() {
aria-label={t("watchedFolders.actions.dismiss", "Dismiss")}
>
@@ -418,9 +413,10 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {
}
onClick={onCreate}
- mt="sm"
+ style={{ marginTop: "var(--mantine-spacing-sm)" }}
>
{t("watchedFolders.home.create", "Create your first Watched Folder")}
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({
{
try {
const handle = await (
@@ -505,9 +505,9 @@ export function WatchedFolderManagementModal({
{inputDirName && (
{
pendingInputDirHandle.current = null;
setInputDirName(null);
@@ -575,8 +575,8 @@ export function WatchedFolderManagementModal({
zIndex={Z_INDEX_AUTOMATE_DROPDOWN}
>
{
try {
@@ -601,9 +601,9 @@ export function WatchedFolderManagementModal({
{outputDirName && (
{
pendingDirHandle.current = null;
setOutputDirName(null);
@@ -619,15 +619,13 @@ export function WatchedFolderManagementModal({
{/* ── Advanced (collapsible) ── */}
-
setShowAdvanced((v) => !v)}
style={{
display: "flex",
alignItems: "center",
gap: "0.35rem",
- background: "none",
- border: "none",
- cursor: "pointer",
padding: "0.25rem 0",
width: "100%",
color: "var(--tool-subcategory-text-color)",
@@ -645,7 +643,7 @@ export function WatchedFolderManagementModal({
}}
/>
{t("watchedFolders.modal.advanced", "Advanced")}
-
+
@@ -828,15 +826,11 @@ export function WatchedFolderManagementModal({
)}
-
+
{t("cancel", "Cancel")}
@@ -977,9 +976,9 @@ export function WatchedFolderWorkbenchView({
{folder.isPaused ? (
}
>
@@ -987,9 +986,8 @@ export function WatchedFolderWorkbenchView({
) : (
}
>
@@ -997,8 +995,8 @@ export function WatchedFolderWorkbenchView({
)}
document
.getElementById(`folder-file-input-${folderId}`)
@@ -1277,8 +1275,8 @@ export function WatchedFolderWorkbenchView({
{activityStatusFilter === "error" &&
filteredActivityIds.length > 0 && (
}
>
@@ -1289,8 +1287,8 @@ export function WatchedFolderWorkbenchView({
filteredActivityIds.length > 0 && (
<>
void handleBatchDownload(filteredActivityIds)
}
@@ -1301,8 +1299,8 @@ export function WatchedFolderWorkbenchView({
{t("watchedFolders.workbench.exportZip", "Export zip")}
void handleBatchDownloadSeparate(filteredActivityIds)
}
@@ -1384,8 +1382,8 @@ export function WatchedFolderWorkbenchView({
{selectedActivityIds.size < filteredActivityIds.length && (
setSelectedActivityIds(new Set(filteredActivityIds))
}
@@ -1399,8 +1397,8 @@ export function WatchedFolderWorkbenchView({
{showRetry && (
@@ -1412,8 +1410,8 @@ export function WatchedFolderWorkbenchView({
{showExport && (
<>
void handleBatchDownload()}
leftSection={
@@ -1422,8 +1420,8 @@ export function WatchedFolderWorkbenchView({
{t("watchedFolders.workbench.exportZip", "Export zip")}
void handleBatchDownloadSeparate()}
leftSection={
@@ -1437,9 +1435,9 @@ export function WatchedFolderWorkbenchView({
>
)}
void handleBatchDelete()}
leftSection={
@@ -1448,9 +1446,8 @@ export function WatchedFolderWorkbenchView({
{t("watchedFolders.workbench.delete", "Delete")}
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 && (
(
setStatsPeriod(p)}
>
{p === "all"
@@ -2412,11 +2400,11 @@ export function WatchedFolderWorkbenchView({
)}
- setDeleteConfirm(null)}>
+ setDeleteConfirm(null)}>
{t("watchedFolders.workbench.cancel", "Cancel")}
deleteConfirm && void execDelete(deleteConfirm.ids, false)
}
@@ -2427,7 +2415,8 @@ export function WatchedFolderWorkbenchView({
)}
deleteConfirm && void execDelete(deleteConfirm.ids, true)
}
diff --git a/frontend/editor/src/proprietary/components/workflow/ParticipantView.tsx b/frontend/editor/src/proprietary/components/workflow/ParticipantView.tsx
index 20191a8b57..c8d000a5dc 100644
--- a/frontend/editor/src/proprietary/components/workflow/ParticipantView.tsx
+++ b/frontend/editor/src/proprietary/components/workflow/ParticipantView.tsx
@@ -6,13 +6,13 @@ import {
Text,
Badge,
Group,
- Button,
Loader,
Alert,
TextInput,
FileInput,
Select,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useParticipantSession } from "@app/hooks/workflow/useParticipantSession";
import InfoIcon from "@mui/icons-material/Info";
import DownloadIcon from "@mui/icons-material/Download";
@@ -314,7 +314,7 @@ const ParticipantView: React.FC = ({ token }) => {
size="sm"
leftSection={ }
onClick={() => downloadDocument(token)}
- variant="light"
+ variant="secondary"
>
{t("workflow.participant.downloadDocument", "Download Document")}
@@ -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 }) => {
}
onClick={handleDecline}
- color="red"
- variant="light"
+ variant="secondary"
+ accent="danger"
data-testid="decline-button"
>
{t("workflow.participant.decline", "Decline")}
diff --git a/frontend/editor/src/proprietary/routes/InviteAccept.tsx b/frontend/editor/src/proprietary/routes/InviteAccept.tsx
index 76ed0df90b..080cf4275a 100644
--- a/frontend/editor/src/proprietary/routes/InviteAccept.tsx
+++ b/frontend/editor/src/proprietary/routes/InviteAccept.tsx
@@ -18,7 +18,7 @@ import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/auth/ui/ErrorMessage";
import { BASE_PATH } from "@app/constants/app";
import apiClient from "@app/services/apiClient";
-
+import { Button } from "@app/ui/Button";
interface InviteData {
email: string | null;
role: string;
@@ -163,13 +163,13 @@ export default function InviteAccept() {
/>
- navigate("/login")}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 auth-cta-button"
>
{t("invite.goToLogin", "Go to Login")}
-
+
);
@@ -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.",
)}
-
{t("backendStartup.retry", "Retry")}
-
+
);
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.",
)}
-
{t("backendStartup.retry", "Retry")}
-
+
);
@@ -486,14 +487,16 @@ export default function Login() {
beforeEmailForm={
hasSSOProviders && !showEmailForm && isUserPassAllowed ? (
- setShowEmailForm(true)}
disabled={login.isSubmitting}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t("login.useEmailInstead", "Login with email")}
-
+
) : 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")}
}
onClick={handleDownload}
loading={isWorking}
diff --git a/frontend/editor/src/proprietary/routes/Signup.tsx b/frontend/editor/src/proprietary/routes/Signup.tsx
index dab0ac2624..68be029969 100644
--- a/frontend/editor/src/proprietary/routes/Signup.tsx
+++ b/frontend/editor/src/proprietary/routes/Signup.tsx
@@ -16,6 +16,7 @@ import {
SignupFieldErrors,
} from "@app/routes/signup/SignupFormValidation";
import { useAuthService } from "@app/routes/signup/AuthService";
+import { Button } from "@app/ui/Button";
import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg";
export default function Signup() {
@@ -130,13 +131,13 @@ export default function Signup() {
{/* Bottom row - centered */}
- navigate("/login")}
className="auth-link-black"
>
{t("login.logIn", "Log In")}
-
+
);
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) => (
-
- {variant} · {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 (
-
- {loading ? (
-
- ) : (
- leadingIcon
- )}
- {children && {children} }
- {!loading && trailingIcon}
-
- );
-}
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 && (
- {
- e.stopPropagation();
- onRemove();
- }}
- aria-label="Remove"
- >
- ×
-
- )}
-
- );
-}
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",
}}
>
- setOpened(false)}>
+ setOpened(false)}>
{t("payg.signupRequired.cancel", "Not now")}
-
-
-
-
+
+ }
+ >
{t("guestBanner.signUp", "Sign Up Free")}
-
+
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?
-
setConfirmOpen(false)}>
+ setConfirmOpen(false)}>
Cancel
{
try {
await signOut();
diff --git a/frontend/editor/src/saas/components/shared/config/ProfilePictureCropper.tsx b/frontend/editor/src/saas/components/shared/config/ProfilePictureCropper.tsx
index 0148da621b..8230184a2c 100644
--- a/frontend/editor/src/saas/components/shared/config/ProfilePictureCropper.tsx
+++ b/frontend/editor/src/saas/components/shared/config/ProfilePictureCropper.tsx
@@ -1,5 +1,6 @@
import React, { useState, useCallback, useEffect } from "react";
-import { Modal, Button, Stack, Slider, Alert, Text, Box } from "@mantine/core";
+import { Modal, Stack, Slider, Alert, Text, Box } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import { useTranslation } from "react-i18next";
import Cropper from "react-easy-crop";
import { getCroppedImage, type Area } from "@app/utils/cropImage";
@@ -182,7 +183,7 @@ export const ProfilePictureCropper: React.FC = ({
-
+
{t("common.cancel", "Cancel")}
diff --git a/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx b/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx
index bee502e70d..4d65073f71 100644
--- a/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx
+++ b/frontend/editor/src/saas/components/shared/config/configSections/ApiKeys.tsx
@@ -1,5 +1,6 @@
import React, { useState } from "react";
-import { Anchor, Group, Stack, Text, Button, Paper } from "@mantine/core";
+import { Anchor, Group, Stack, Text, Paper } from "@mantine/core";
+import { Button } from "@app/ui/Button";
import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection";
import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal";
import useApiKey from "@app/components/shared/config/configSections/apiKeys/hooks/useApiKey";
diff --git a/frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx
index 6a6ea06b6e..705032da58 100644
--- a/frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx
+++ b/frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx
@@ -7,12 +7,12 @@ import {
Group,
Alert,
Code,
- Button,
CopyButton,
Tabs,
Tooltip,
ThemeIcon,
} from "@mantine/core";
+import { Button } from "@app/ui/Button";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { openAppSettings } from "@app/utils/appSettings";
@@ -41,9 +41,9 @@ function CopyInline({ value, label }: { value: string; label: string }) {
withArrow
>
@@ -277,8 +278,8 @@ export default function McpSection() {
)}
diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx
index 6033b5cd90..fac87148fe 100644
--- a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx
+++ b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx
@@ -2,9 +2,7 @@ import React, { useState } from "react";
import {
Alert,
Avatar,
- Button,
Divider,
- FileButton,
Group,
Image,
LoadingOverlay,
@@ -13,6 +11,8 @@ import {
TextInput,
Modal,
} from "@mantine/core";
+import { Button as DSButton } from "@app/ui/Button";
+import { FilePicker } from "@app/ui/FilePicker";
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import {
@@ -363,9 +363,9 @@ const Overview: React.FC = ({ onLogoutClick }) => {
)}
-
+
{t("logOut", "Log out")}
-
+
@@ -434,8 +434,8 @@ const Overview: React.FC = ({ onLogoutClick }) => {
},
)}
-
@@ -443,7 +443,7 @@ const Overview: React.FC = ({ onLogoutClick }) => {
"config.account.profilePicture.useCustom",
"Use custom picture",
)}
-
+
) : (
@@ -464,24 +464,21 @@ const Overview: React.FC = ({ onLogoutClick }) => {
}}
>
-
- {(props) => (
-
- {t("config.account.profilePicture.upload", "Upload")}
-
- )}
-
-
+
{t("config.account.profilePicture.remove", "Remove")}
-
+
{t(
@@ -559,8 +556,8 @@ const Overview: React.FC = ({ onLogoutClick }) => {
key={provider.id}
content={`${t("config.account.upgrade.linkWith", "Link with")} ${provider.label}`}
>
- = ({ onLogoutClick }) => {
disabled={isLoading}
>
{provider.label}
-
+
))}
@@ -624,9 +621,9 @@ const Overview: React.FC = ({ onLogoutClick }) => {
)}
style={{ flex: 1 }}
/>
-
+
{t("config.account.upgrade.upgradeButton", "Upgrade Account")}
-
+
@@ -644,13 +641,13 @@ const Overview: React.FC = ({ onLogoutClick }) => {
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
- setDeleteModalOpen(true)}
>
{t("config.account.overview.deleteAccount", "Delete Account")}
-
+
)}
@@ -693,11 +690,16 @@ const Overview: React.FC = ({ onLogoutClick }) => {
mb="md"
/>
-
+
{t("cancel", "Cancel")}
-
-
+ = ({ onLogoutClick }) => {
loading={isDeletingAccount}
>
{t("config.account.overview.confirmDelete", "Delete My Account")}
-
+
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 = () => {
)}
- setOpened(true)} variant="filled">
+ setOpened(true)}>
{t("config.account.security.changePassword", "Change password")}
@@ -166,7 +166,7 @@ const PasswordSecurity: React.FC = () => {
setOpened(false)}
>
{t("common.cancel", "Cancel")}
diff --git a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx
index a6e406a410..b8691df30d 100644
--- a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx
+++ b/frontend/editor/src/saas/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";
@@ -440,9 +439,9 @@ const SignSettings = ({
const button = (
onClick(scope)}
disabled={
!isReady || disabled || isSavedSignatureLimitReached || !hasChanges
@@ -1154,49 +1153,33 @@ const SignSettings = ({
onActivateSignaturePlacement || onDeactivateSignature ? (
isPlacementMode ? (
-
+ }
>
-
-
- {translate("mode.pause", "Pause placement")}
-
-
+ {translate("mode.pause", "Pause placement")}
+
) : (
-
+ }
>
-
-
- {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 && (
-
+
{translate("applySignatures", "Apply Signatures")}
)}
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 (
navigate("/login", { replace: true })}
- className="inline-flex items-center rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white shadow hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-500"
>
Back to login
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 */}
- @
{`${t("login.signInWith", "Sign in with")} email`}
-
+
{/* Email form — animated expand */}
- navigate("/auth/reset")}
className="auth-link-black"
style={{ fontSize: "0.8125rem", marginTop: "0.25rem" }}
>
{t("login.forgotPassword", "Forgot your password?")}
-
+
{/* Skip */}
-
{isSigningIn
? t("login.signingIn", "Signing in...")
: `${t("signup.skip", "Skip")} →`}
-
+
{/* Bottom */}
@@ -441,8 +442,8 @@ export default function Login() {
paddingTop: "1rem",
}}
>
- navigate("/signup")}
style={{
background: "none",
@@ -453,7 +454,7 @@ export default function Login() {
}}
>
{t("login.createAccount", "Create an account")}
-
+
);
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.",
)}
- navigate(`/login?next=${encodeURIComponent(next)}`)}
>
{t("oauthConsent.signInButton", "Sign in to continue")}
-
+
);
}
@@ -358,8 +359,7 @@ export default function OAuthConsent() {
- decide("approve")}
style={{
@@ -378,9 +378,9 @@ export default function OAuthConsent() {
{deciding === "approve"
? t("oauthConsent.approving", "Allowing...")
: t("oauthConsent.approve", "Allow access")}
-
-
+ decide("deny")}
className="oauth-button-fullwidth"
@@ -388,7 +388,7 @@ export default function OAuthConsent() {
{deciding === "deny"
? t("oauthConsent.denying", "Denying...")
: t("oauthConsent.deny", "Deny")}
-
+
{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 */}
- setShowEmailForm((v) => !v)}
className={`oauth-button-fullwidth auth-expandable-trigger ${showEmailForm ? "auth-expandable-trigger--active" : ""}`}
@@ -220,7 +221,7 @@ export default function Signup() {
@
{`${t("signup.signUpWith", "Sign up with")} email`}
-
+
{/* Email form — animated expand */}
-
{isSigningUp
? t("login.signingIn", "Signing in...")
: `${t("signup.skip", "Skip")} →`}
-
+
{/* Bottom */}
@@ -276,8 +277,8 @@ export default function Signup() {
paddingTop: "1rem",
}}
>
- navigate("/login")}
style={{
background: "none",
@@ -288,7 +289,7 @@ export default function Signup() {
}}
>
{t("signup.alreadyHaveAccount", "I already have an account")}
-
+
);
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 (
-
{label}
-
+
);
}
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({
)}
-
{submitButtonText}
-
+
);
}
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 (
- {
setShowMagicLink(true);
}}
@@ -32,7 +34,7 @@ export default function MagicLinkForm({
className="auth-toggle-link"
>
{t("login.useMagicLink")}
-
+
);
}
@@ -47,13 +49,13 @@ export default function MagicLinkForm({
onKeyPress={(e) => e.key === "Enter" && !isSubmitting && onSubmit()}
className="auth-input"
/>
-
{isSubmitting ? t("login.sending") : t("login.sendMagicLink")}
-
+
);
}
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"
>
- onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-icon"
@@ -47,9 +49,9 @@ export default function OAuthButtons({
-
+
))}
@@ -65,7 +67,8 @@ export default function OAuthButtons({
content={`${t("login.signInWith", "Sign in with")} ${p.label}`}
position="top"
>
- onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-grid"
@@ -74,9 +77,9 @@ export default function OAuthButtons({
-
+
))}
@@ -87,8 +90,9 @@ export default function OAuthButtons({
return (
{oauthProviders.map((p) => (
-
onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-fullwidth"
@@ -98,7 +102,7 @@ export default function OAuthButtons({
@@ -106,7 +110,7 @@ export default function OAuthButtons({
{p.label}
-
+
))}
);
@@ -115,8 +119,9 @@ export default function OAuthButtons({
return (
{oauthProviders.map((p) => (
-
onProviderClick(p.id as "github" | "google")}
disabled={isSubmitting || p.isDisabled}
className="oauth-button-vertical"
@@ -125,10 +130,10 @@ export default function OAuthButtons({
{p.label}
-
+
))}
);
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 ` `. If no variant fits, extend the shared Button rather than importing Mantine.',
+ },
+ {
+ selector:
+ "ImportDeclaration[source.value='@mantine/core'] > ImportSpecifier[imported.name='SegmentedControl']",
+ message:
+ "Use the shared SegmentedControl (@app/ui/SegmentedControl) instead of Mantine's.",
+ },
+ {
+ selector:
+ "ImportDeclaration[source.value='@mantine/core'] > ImportSpecifier[imported.name=/^(Chip|Pill)$/]",
+ message:
+ "Use the shared Chip (@app/ui/Chip) instead of Mantine's Chip/Pill.",
+ },
+];
+
+// Raw should be a shared Button too — but bespoke CSS-styled controls
+// (tabs, nav rows, preset chips) can be exempted from this selector alone.
+const rawButtonSyntaxRestriction = {
+ selector: "JSXOpeningElement[name.name='button']",
+ message:
+ "Use the shared Button (@app/ui/Button) instead of a raw element. If no variant fits, extend the shared Button.",
+};
+
+const sharedComponentSyntaxRestrictions = [
+ ...mantineComponentImportRestrictions,
+ rawButtonSyntaxRestriction,
+];
+
export default defineConfig(
{
// Everything that contains 3rd party code that we don't want to lint
@@ -146,6 +182,7 @@ export default defineConfig(
],
"no-restricted-syntax": [
"error",
+ ...sharedComponentSyntaxRestrictions,
{
selector:
"MemberExpression[object.name='window'][property.name='location']",
@@ -161,6 +198,46 @@ export default defineConfig(
],
},
},
+ // app code must use shared DS Button/SegmentedControl/Chip; cloud/ covered above.
+ {
+ files: ["editor/src/**/*.{js,mjs,jsx,ts,tsx}"],
+ ignores: [
+ "editor/src/cloud/**/*.{js,mjs,jsx,ts,tsx}", // covered by cloud/ block above
+ "editor/src/core/ui/**/*.{js,mjs,jsx,ts,tsx}", // the shared DS itself — wraps Mantine/raw elements
+ "**/*.stories.{js,mjs,jsx,ts,tsx}", // stories may demo Mantine directly
+ "**/*.test.{js,mjs,jsx,ts,tsx}", // tests may use raw elements as fixtures
+ "editor/src/prototypes/**/*.{js,mjs,jsx,ts,tsx}", // not shipped
+ ],
+ rules: {
+ "no-restricted-syntax": ["error", ...sharedComponentSyntaxRestrictions],
+ },
+ },
+ // Intentional exceptions: ARIA tablist tabs and sub-26px segmented header —
+ // semantically not buttons; shared Button sizing can't represent them.
+ // Do NOT add ordinary buttons here.
+ {
+ files: [
+ "editor/src/core/components/shared/FileSelectorPicker.tsx",
+ "editor/src/core/components/filesPage/FileManagerView.tsx",
+ "editor/src/core/pages/HomePage.tsx",
+ ],
+ rules: {
+ "no-restricted-syntax": "off",
+ },
+ },
+ // TEMPORARY: the procurement feature was merged in from main and still uses
+ // bespoke CSS-styled raw s. Exempt ONLY the raw- rule here —
+ // the Mantine import bans stay in force so this feature can't regress to
+ // Mantine's Button/Chip/SegmentedControl — and migrate these to the shared
+ // Button in a follow-up PR. Do NOT add other folders to this block.
+ {
+ files: [
+ "editor/src/portal/components/procurement/**/*.{js,mjs,jsx,ts,tsx}",
+ ],
+ rules: {
+ "no-restricted-syntax": ["error", ...mantineComponentImportRestrictions],
+ },
+ },
// Stricter rules that not all sub-folders are conformant to yet.
{
files: srcGlobs,
diff --git a/frontend/scripts/find-unused-css.mjs b/frontend/scripts/find-unused-css.mjs
new file mode 100644
index 0000000000..358965a729
--- /dev/null
+++ b/frontend/scripts/find-unused-css.mjs
@@ -0,0 +1,129 @@
+// Conservative unused-CSS detector (no deps).
+// Reports CSS class selectors + custom properties that have ZERO references
+// anywhere in the frontend source (className strings, `styles.x` module access,
+// template-literal tokens, `var(--x)`, inline `"--x":` custom props, `composes:`).
+// Conservative by design: if a token appears ANYWHERE outside its own selector
+// definition, it's treated as USED (so dynamically-built classes are kept).
+//
+// Usage: cd frontend && node scripts/find-unused-css.mjs [filterSubstring]
+
+import { readFileSync, readdirSync } from "node:fs";
+import { join, extname, isAbsolute } from "node:path";
+
+const ROOTS = ["editor/src", "shared", "portal/src"];
+const SRC_EXT = new Set([
+ ".ts",
+ ".tsx",
+ ".js",
+ ".jsx",
+ ".mjs",
+ ".cjs",
+ ".html",
+ ".mdx",
+]);
+const filter = process.argv[2] ?? "";
+
+function walk(dir, acc = []) {
+ let entries;
+ try {
+ entries = readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return acc;
+ }
+ for (const e of entries) {
+ if (
+ e.name === "node_modules" ||
+ e.name === "dist" ||
+ e.name.startsWith(".")
+ )
+ continue;
+ // Dirents from readdirSync are always single path components, but guard
+ // anyway so a name can never escape `dir` (satisfies Aikido's path-traversal
+ // check on the readFile below).
+ if (e.name.includes("..") || isAbsolute(e.name)) continue;
+ const p = join(dir, e.name);
+ if (e.isDirectory()) walk(p, acc);
+ // Exclude the temporary button-catalog gallery so its reproduced classes
+ // don't mask genuinely-dead CSS.
+ else if (!p.includes("_buttonGallery")) acc.push(p);
+ }
+ return acc;
+}
+
+const allFiles = ROOTS.flatMap((r) => walk(r));
+const cssFiles = allFiles.filter((f) => extname(f) === ".css");
+const srcFiles = allFiles.filter((f) => SRC_EXT.has(extname(f)));
+
+const srcCorpus = srcFiles.map((f) => readFileSync(f, "utf8")).join("\n");
+const cssCorpus = cssFiles.map((f) => readFileSync(f, "utf8")).join("\n");
+
+const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+// --- collect definitions ---------------------------------------------------
+const classDef = new Map(); // class -> Set(file)
+const varDef = new Map(); // --var -> Set(file)
+
+for (const f of cssFiles) {
+ const clean = readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
+ // class names only from SELECTOR preludes (text before each `{`), so we don't
+ // pick up `.png`/`.svg` etc. inside property values.
+ for (const rule of clean.matchAll(/([^{}]*)\{/g)) {
+ for (const m of rule[1].matchAll(/\.(-?[a-zA-Z_][\w-]*)/g)) {
+ if (!classDef.has(m[1])) classDef.set(m[1], new Set());
+ classDef.get(m[1]).add(f);
+ }
+ }
+ for (const m of clean.matchAll(/(--[a-zA-Z][\w-]*)\s*:/g)) {
+ if (!varDef.has(m[1])) varDef.set(m[1], new Set());
+ varDef.get(m[1]).add(f);
+ }
+}
+
+// --- usage checks ----------------------------------------------------------
+function classUsed(c) {
+ // token in any source file (className="..", clsx, `tpl-${}`, styles.camel, etc.)
+ if (new RegExp(`(^|[^\\w-])${esc(c)}([^\\w-]|$)`).test(srcCorpus))
+ return true;
+ // composed by another stylesheet
+ if (new RegExp(`composes:[^;]*\\b${esc(c)}\\b`).test(cssCorpus)) return true;
+ // dynamically constructed: a `-`/`_`-bounded prefix of `c` is immediately
+ // followed by a template `${...}` in source, e.g. `sui-btn--${variant}`.
+ for (let i = c.length - 1; i >= 3; i--) {
+ if (c[i] === "-" || c[i] === "_") {
+ if (new RegExp(`${esc(c.slice(0, i + 1))}\\$\\{`).test(srcCorpus))
+ return true;
+ }
+ }
+ return false;
+}
+function varUsed(v) {
+ if (new RegExp(`var\\(\\s*${esc(v)}\\b`).test(cssCorpus + "\n" + srcCorpus))
+ return true;
+ if (new RegExp(`["']${esc(v)}["']\\s*:`).test(srcCorpus)) return true; // inline custom prop
+ return false;
+}
+
+const rel = (f) =>
+ f.replace("editor/src/", "").replace("portal/src/", "portal/");
+// `mantine-*` are the framework's own runtime classes (applied by Mantine, not
+// referenced in our source) — exclude; they can't be validated by source refs.
+const unusedClasses = [...classDef.keys()]
+ .filter((c) => !c.startsWith("mantine-") && !classUsed(c))
+ .sort();
+const unusedVars = [...varDef.keys()].filter((v) => !varUsed(v)).sort();
+
+const show = (name) =>
+ !filter || name.toLowerCase().includes(filter.toLowerCase());
+
+console.log(
+ `\n=== UNUSED CLASSES (${unusedClasses.filter(show).length}${filter ? ` matching "${filter}"` : ""} of ${unusedClasses.length} total) ===`,
+);
+for (const c of unusedClasses.filter(show)) {
+ console.log(`.${c}\t${[...classDef.get(c)].map(rel).join(", ")}`);
+}
+console.log(
+ `\n=== UNUSED VARS (${unusedVars.filter(show).length}${filter ? ` matching "${filter}"` : ""} of ${unusedVars.length} total) ===`,
+);
+for (const v of unusedVars.filter(show)) {
+ console.log(`${v}\t${[...varDef.get(v)].map(rel).join(", ")}`);
+}
diff --git a/frontend/shared/components/index.ts b/frontend/shared/components/index.ts
new file mode 100644
index 0000000000..86117f0eaa
--- /dev/null
+++ b/frontend/shared/components/index.ts
@@ -0,0 +1,45 @@
+export * from "@shared/components/Button";
+export * from "@shared/components/ActionIcon";
+export * from "@shared/components/FilePicker";
+export * from "@shared/components/StatusBadge";
+export * from "@shared/components/MethodBadge";
+export * from "@shared/components/ToggleSwitch";
+export * from "@shared/components/ProgressBar";
+export * from "@shared/components/MetricCard";
+export * from "@shared/components/NavItem";
+export * from "@shared/components/PanelHeader";
+export * from "@shared/components/CodeBlock";
+export * from "@shared/components/SectionDivider";
+export * from "@shared/components/Card";
+export * from "@shared/components/Modal";
+export * from "@shared/components/SettingsShell";
+
+// Layout
+export * from "@shared/components/Stack";
+export * from "@shared/components/Inline";
+export * from "@shared/components/MetricStrip";
+export * from "@shared/components/StatTile";
+
+// Feedback
+export * from "@shared/components/Spinner";
+export * from "@shared/components/Skeleton";
+export * from "@shared/components/Avatar";
+export * from "@shared/components/Chip";
+export * from "@shared/components/EmptyState";
+export * from "@shared/components/Banner";
+export * from "@shared/components/Toast";
+
+// Compound
+export * from "@shared/components/Collapsible";
+export * from "@shared/components/Tabs";
+export * from "@shared/components/Dropdown";
+export * from "@shared/components/Drawer";
+export * from "@shared/components/Table";
+
+// Forms
+export * from "@shared/components/FormField";
+export * from "@shared/components/Input";
+export * from "@shared/components/Select";
+export * from "@shared/components/Checkbox";
+export * from "@shared/components/Radio";
+export * from "@shared/components/Slider";
From be57f1174729091eaa942ee1707b4b9c5baec896 Mon Sep 17 00:00:00 2001
From: James Brunton
Date: Tue, 7 Jul 2026 17:43:06 +0100
Subject: [PATCH 09/43] Improve type safety of tool definitions (#6895)
# Description of Changes
Followup work requested in review of #6867. Currently, there is nothing
enforcing that the endpoint chosen in the tool config is the correct
mapping for `toApiParams`, so theoretically it's possible for a tool to
be set up to call an endpoint with the wrong API params for it. There's
also nothing currently enforcing that `toApiParams` and `fromApiParams`
are compatible with each other (using the same types). This PR changes
it so that instead of creating the config object directly, tools create
it via a generic function, which enforces that all of the relevant
mappings are using compatible types.
---
.../useAddPageNumbersOperation.ts | 7 +-
.../tools/addStamp/useAddStampOperation.ts | 7 +-
.../useAddAttachmentsOperation.ts | 21 +++--
.../addPassword/useAddPasswordOperation.ts | 7 +-
.../addWatermark/useAddWatermarkOperation.ts | 7 +-
.../useAdjustContrastOperation.ts | 9 +--
.../useAdjustPageScaleOperation.ts | 7 +-
.../autoRename/useAutoRenameOperation.ts | 7 +-
.../tools/automate/useAutomateOperation.ts | 15 ++--
.../useBookletImpositionOperation.ts | 7 +-
.../tools/certSign/useCertSignOperation.ts | 8 +-
.../useChangeMetadataOperation.ts | 7 +-
.../useChangePermissionsOperation.ts | 7 +-
.../tools/compress/useCompressOperation.ts | 7 +-
.../tools/convert/useConvertOperation.ts | 7 +-
.../core/hooks/tools/crop/useCropOperation.ts | 7 +-
.../useEditTableOfContentsOperation.ts | 19 ++---
.../useExtractImagesOperation.ts | 7 +-
.../extractPages/useExtractPagesOperation.ts | 7 +-
.../tools/flatten/useFlattenOperation.ts | 8 +-
.../hooks/tools/merge/useMergeOperation.ts | 8 +-
.../core/hooks/tools/ocr/useOCROperation.ts | 7 +-
.../overlayPdfs/useOverlayPdfsOperation.ts | 7 +-
.../pageLayout/usePageLayoutOperation.ts | 7 +-
.../hooks/tools/redact/useRedactOperation.ts | 7 +-
.../useRemoveAnnotationsOperation.ts | 7 +-
.../removeBlanks/useRemoveBlanksOperation.ts | 8 +-
.../useRemoveCertificateSignOperation.ts | 7 +-
.../removeImage/useRemoveImageOperation.ts | 19 ++---
.../removePages/useRemovePagesOperation.ts | 8 +-
.../useRemovePasswordOperation.ts | 7 +-
.../useReorganizePagesOperation.ts | 19 ++---
.../hooks/tools/repair/useRepairOperation.ts | 7 +-
.../replaceColor/useReplaceColorOperation.ts | 8 +-
.../hooks/tools/rotate/useRotateOperation.ts | 7 +-
.../tools/sanitize/useSanitizeOperation.ts | 8 +-
.../useScannerImageSplitOperation.ts | 7 +-
.../tools/shared/migratedToolMappers.test.ts | 8 +-
.../hooks/tools/shared/toolOperationTypes.ts | 78 ++++++++++++++-----
.../hooks/tools/shared/useToolOperation.ts | 18 +++--
.../core/hooks/tools/sign/useSignOperation.ts | 7 +-
.../useSingleLargePageOperation.ts | 7 +-
.../hooks/tools/split/useSplitOperation.ts | 12 +--
.../timestampPdf/useTimestampPdfOperation.ts | 8 +-
.../useUnlockPdfFormsOperation.ts | 7 +-
.../pdfCommentAgentOperationConfig.ts | 17 ++--
46 files changed, 239 insertions(+), 249 deletions(-)
diff --git a/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts b/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts
index cb5624bce2..ba8ca13b32 100644
--- a/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts
+++ b/frontend/editor/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -73,15 +73,14 @@ export const buildAddPageNumbersFormData = (
): FormData =>
objectToFormData(addPageNumbersToApiParams(parameters), { fileInput: file });
-export const addPageNumbersOperationConfig = {
- toolType: ToolType.singleFile,
+export const addPageNumbersOperationConfig = defineSingleFileTool({
buildFormData: buildAddPageNumbersFormData,
toApiParams: addPageNumbersToApiParams,
fromApiParams: addPageNumbersFromApiParams,
operationType: "addPageNumbers",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useAddPageNumbersOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts b/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts
index bdceee49f1..71510694b0 100644
--- a/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts
+++ b/frontend/editor/src/core/components/tools/addStamp/useAddStampOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -87,15 +87,14 @@ export const buildAddStampFormData = (
: { fileInput: file },
);
-export const addStampOperationConfig = {
- toolType: ToolType.singleFile,
+export const addStampOperationConfig = defineSingleFileTool({
buildFormData: buildAddStampFormData,
toApiParams: addStampToApiParams,
fromApiParams: addStampFromApiParams,
operationType: "addStamp",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useAddStampOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts b/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts
index bc9fb738bc..ddbf20f247 100644
--- a/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/addAttachments/useAddAttachmentsOperation.ts
@@ -1,8 +1,7 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolOperationConfig,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -48,16 +47,14 @@ const buildFormData = (
});
// Operation configuration for automation
-export const addAttachmentsOperationConfig: ToolOperationConfig =
- {
- toolType: ToolType.singleFile,
- buildFormData,
- toApiParams: addAttachmentsToApiParams,
- fromApiParams: addAttachmentsFromApiParams,
- operationType: "addAttachments",
- endpoint: ENDPOINT,
- defaultParameters: DEFAULT_ADD_ATTACHMENTS_PARAMETERS,
- };
+export const addAttachmentsOperationConfig = defineSingleFileTool({
+ buildFormData,
+ toApiParams: addAttachmentsToApiParams,
+ fromApiParams: addAttachmentsFromApiParams,
+ operationType: "addAttachments",
+ endpoint: ENDPOINT,
+ defaultParameters: DEFAULT_ADD_ATTACHMENTS_PARAMETERS,
+});
export const useAddAttachmentsOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts
index 0bc3c1b396..f6eca6ffbd 100644
--- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -85,15 +85,14 @@ const fullDefaultParameters: AddPasswordFullParameters = {
};
// Static configuration object
-export const addPasswordOperationConfig = {
- toolType: ToolType.singleFile,
+export const addPasswordOperationConfig = defineSingleFileTool({
buildFormData: buildAddPasswordFormData,
toApiParams: addPasswordToApiParams,
fromApiParams: addPasswordFromApiParams,
operationType: "addPassword",
endpoint: ENDPOINT,
defaultParameters: fullDefaultParameters,
-} as const;
+});
export const useAddPasswordOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts
index 75651f7bcd..423110a2d2 100644
--- a/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/addWatermark/useAddWatermarkOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -87,15 +87,14 @@ export const buildAddWatermarkFormData = (
);
// Static configuration object
-export const addWatermarkOperationConfig = {
- toolType: ToolType.singleFile,
+export const addWatermarkOperationConfig = defineSingleFileTool({
buildFormData: buildAddWatermarkFormData,
toApiParams: addWatermarkToApiParams,
fromApiParams: addWatermarkFromApiParams,
operationType: "watermark",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useAddWatermarkOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts b/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts
index 4a26de244d..18981b58d0 100644
--- a/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
+ defineCustomTool,
useToolOperation,
CustomProcessorResult,
} from "@app/hooks/tools/shared/useToolOperation";
@@ -195,14 +195,11 @@ async function processPdfClientSide(
};
}
-export const adjustContrastOperationConfig = {
- toolType: ToolType.custom,
+export const adjustContrastOperationConfig = defineCustomTool({
customProcessor: processPdfClientSide,
operationType: "adjustContrast",
defaultParameters,
- settingsComponentPath:
- "components/tools/adjustContrast/AdjustContrastSingleStepSettings",
-} as const;
+});
export const useAdjustContrastOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts
index c25e12e3a9..db0ce22da2 100644
--- a/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/adjustPageScale/useAdjustPageScaleOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
@@ -21,15 +21,14 @@ export {
adjustPageScaleFromApiParams,
};
-export const adjustPageScaleOperationConfig = {
- toolType: ToolType.singleFile,
+export const adjustPageScaleOperationConfig = defineSingleFileTool({
buildFormData: buildAdjustPageScaleFormData,
toApiParams: adjustPageScaleToApiParams,
fromApiParams: adjustPageScaleFromApiParams,
operationType: "scalePages",
endpoint: ADJUST_PAGE_SCALE_ENDPOINT,
defaultParameters,
-} as const;
+});
export const useAdjustPageScaleOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts b/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts
index faca5b0ae7..15466998e5 100644
--- a/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/autoRename/useAutoRenameOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -44,8 +44,7 @@ export const buildAutoRenameFormData = (
objectToFormData(autoRenameToApiParams(parameters), { fileInput: file });
// Static configuration object
-export const autoRenameOperationConfig = {
- toolType: ToolType.singleFile,
+export const autoRenameOperationConfig = defineSingleFileTool({
buildFormData: buildAutoRenameFormData,
toApiParams: autoRenameToApiParams,
fromApiParams: autoRenameFromApiParams,
@@ -53,7 +52,7 @@ export const autoRenameOperationConfig = {
endpoint: ENDPOINT,
preserveBackendFilename: true, // Use filename from backend response headers
defaultParameters,
-} as const;
+});
export const useAutoRenameOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts b/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts
index 2ef3d33d96..d1d6436fa9 100644
--- a/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/automate/useAutomateOperation.ts
@@ -1,5 +1,5 @@
import {
- ToolType,
+ defineCustomTool,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import { useCallback } from "react";
@@ -55,10 +55,11 @@ export function useAutomateOperation() {
[toolRegistry],
);
- return useToolOperation({
- toolType: ToolType.custom,
- operationType: "automate",
- customProcessor,
- consumesAllInputs: true,
- });
+ return useToolOperation(
+ defineCustomTool({
+ operationType: "automate",
+ customProcessor,
+ consumesAllInputs: true,
+ }),
+ );
}
diff --git a/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts b/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts
index f5a213d48c..7add5d126c 100644
--- a/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/bookletImposition/useBookletImpositionOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -59,15 +59,14 @@ export const buildBookletImpositionFormData = (
});
// Static configuration object
-export const bookletImpositionOperationConfig = {
- toolType: ToolType.singleFile,
+export const bookletImpositionOperationConfig = defineSingleFileTool({
buildFormData: buildBookletImpositionFormData,
toApiParams: bookletImpositionToApiParams,
fromApiParams: bookletImpositionFromApiParams,
operationType: "bookletImposition",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useBookletImpositionOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts b/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts
index 9f4767044e..01cfa32b1a 100644
--- a/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/certSign/useCertSignOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -136,16 +136,14 @@ export const buildCertSignFormData = (
});
// Static configuration object
-export const certSignOperationConfig = {
- toolType: ToolType.singleFile,
+export const certSignOperationConfig = defineSingleFileTool({
buildFormData: buildCertSignFormData,
toApiParams: certSignToApiParams,
fromApiParams: certSignFromApiParams,
operationType: "certSign",
endpoint: ENDPOINT,
- multiFileEndpoint: false,
defaultParameters,
-} as const;
+});
export const useCertSignOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts b/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts
index 045b029522..6b0cd8b5d5 100644
--- a/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/changeMetadata/useChangeMetadataOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
@@ -75,13 +75,12 @@ export const buildChangeMetadataFormData = (
};
// Static configuration object
-export const changeMetadataOperationConfig = {
- toolType: ToolType.singleFile,
+export const changeMetadataOperationConfig = defineSingleFileTool({
buildFormData: buildChangeMetadataFormData,
operationType: "changeMetadata",
endpoint: "/api/v1/misc/update-metadata",
defaultParameters,
-} as const;
+});
export const useChangeMetadataOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts
index dd0c532706..a2e3eb3d1d 100644
--- a/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/changePermissions/useChangePermissionsOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -71,15 +71,14 @@ export const buildChangePermissionsFormData = (
});
// Static configuration object
-export const changePermissionsOperationConfig = {
- toolType: ToolType.singleFile,
+export const changePermissionsOperationConfig = defineSingleFileTool({
buildFormData: buildChangePermissionsFormData,
toApiParams: changePermissionsToApiParams,
fromApiParams: changePermissionsFromApiParams,
operationType: "changePermissions",
endpoint: ENDPOINT, // Change Permissions is a fake endpoint for the Add Password tool
defaultParameters,
-} as const;
+});
export const useChangePermissionsOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts
index f62c981879..b24bb8c6ee 100644
--- a/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/compress/useCompressOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -90,15 +90,14 @@ export const buildCompressFormData = (
objectToFormData(compressToApiParams(parameters), { fileInput: file });
// Static configuration object
-export const compressOperationConfig = {
- toolType: ToolType.singleFile,
+export const compressOperationConfig = defineSingleFileTool({
buildFormData: buildCompressFormData,
toApiParams: compressToApiParams,
fromApiParams: compressFromApiParams,
operationType: "compress",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useCompressOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts
index 1da1b96b31..a9488e373d 100644
--- a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts
@@ -8,7 +8,7 @@ import {
import { createFileFromApiResponse } from "@app/utils/fileResponseUtils";
import {
useToolOperation,
- ToolType,
+ defineCustomTool,
CustomProcessorResult,
} from "@app/hooks/tools/shared/useToolOperation";
import {
@@ -300,8 +300,7 @@ export const convertProcessor = async (
};
// Static configuration object
-export const convertOperationConfig = {
- toolType: ToolType.custom,
+export const convertOperationConfig = defineCustomTool({
customProcessor: convertProcessor, // Can't use callback version here
operationType: "convert",
defaultParameters,
@@ -311,7 +310,7 @@ export const convertOperationConfig = {
params.toExtension === "pdfx" ? "pdfa" : params.toExtension;
return getEndpointUrl(params.fromExtension, actualToExtension) ?? undefined;
},
-} as const;
+});
export const useConvertOperation = (parameters?: ConvertParameters) => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts
index 24dc35daa3..b3a69f9dbe 100644
--- a/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/crop/useCropOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -59,15 +59,14 @@ export const buildCropFormData = (
objectToFormData(cropToApiParams(parameters), { fileInput: file });
// Static configuration object
-export const cropOperationConfig = {
- toolType: ToolType.singleFile,
+export const cropOperationConfig = defineSingleFileTool({
buildFormData: buildCropFormData,
toApiParams: cropToApiParams,
fromApiParams: cropFromApiParams,
operationType: "crop",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useCropOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts
index c395ed0521..8473ea972c 100644
--- a/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/editTableOfContents/useEditTableOfContentsOperation.ts
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
- type ToolOperationConfig,
+ defineSingleFileTool,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
@@ -64,15 +63,13 @@ const buildFormData = (
fileInput: file,
});
-export const editTableOfContentsOperationConfig: ToolOperationConfig =
- {
- toolType: ToolType.singleFile,
- operationType: "editTableOfContents",
- endpoint: ENDPOINT,
- buildFormData,
- toApiParams: editTableOfContentsToApiParams,
- fromApiParams: editTableOfContentsFromApiParams,
- };
+export const editTableOfContentsOperationConfig = defineSingleFileTool({
+ operationType: "editTableOfContents",
+ endpoint: ENDPOINT,
+ buildFormData,
+ toApiParams: editTableOfContentsToApiParams,
+ fromApiParams: editTableOfContentsFromApiParams,
+});
export const useEditTableOfContentsOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts b/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts
index 4f43ddf3ec..0a70f43424 100644
--- a/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/extractImages/useExtractImagesOperation.ts
@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -41,15 +41,14 @@ export const buildExtractImagesFormData = (
objectToFormData(extractImagesToApiParams(parameters), { fileInput: file });
// Static configuration object (without response handler - will be added in hook)
-export const extractImagesOperationConfig = {
- toolType: ToolType.singleFile,
+export const extractImagesOperationConfig = defineSingleFileTool({
buildFormData: buildExtractImagesFormData,
toApiParams: extractImagesToApiParams,
fromApiParams: extractImagesFromApiParams,
operationType: "extractImages",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useExtractImagesOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts b/frontend/editor/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts
index 4119ed191b..0cd5273f0b 100644
--- a/frontend/editor/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/extractPages/useExtractPagesOperation.ts
@@ -1,7 +1,7 @@
import apiClient from "@app/services/apiClient";
import { useTranslation } from "react-i18next";
import {
- ToolType,
+ defineCustomTool,
useToolOperation,
CustomProcessorResult,
} from "@app/hooks/tools/shared/useToolOperation";
@@ -33,8 +33,7 @@ async function resolveSelectionToCsv(
}
}
-export const extractPagesOperationConfig = {
- toolType: ToolType.custom,
+export const extractPagesOperationConfig = defineCustomTool({
operationType: "extractPages",
customProcessor: async (
parameters: ExtractPagesParameters,
@@ -71,7 +70,7 @@ export const extractPagesOperationConfig = {
};
},
defaultParameters,
-} as const;
+});
export const useExtractPagesOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts b/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts
index 7e01678e73..4027b7048e 100644
--- a/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/flatten/useFlattenOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -59,16 +59,14 @@ export const buildFlattenFormData = (
objectToFormData(flattenToApiParams(parameters), { fileInput: file });
// Static configuration object
-export const flattenOperationConfig = {
- toolType: ToolType.singleFile,
+export const flattenOperationConfig = defineSingleFileTool({
buildFormData: buildFlattenFormData,
toApiParams: flattenToApiParams,
fromApiParams: flattenFromApiParams,
operationType: "flatten",
endpoint: ENDPOINT,
- multiFileEndpoint: false,
defaultParameters,
-} as const;
+});
export const useFlattenOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts
index 9b43f35fcd..fc742ff410 100644
--- a/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/merge/useMergeOperation.ts
@@ -1,8 +1,7 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolOperationConfig,
- ToolType,
+ defineMultiFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -56,8 +55,7 @@ const buildFormData = (
};
// Operation configuration for automation
-export const mergeOperationConfig: ToolOperationConfig = {
- toolType: ToolType.multiFile,
+export const mergeOperationConfig = defineMultiFileTool({
buildFormData,
toApiParams: mergeToApiParams,
fromApiParams: mergeFromApiParams,
@@ -65,7 +63,7 @@ export const mergeOperationConfig: ToolOperationConfig = {
endpoint: ENDPOINT,
filePrefix: "merged_",
defaultParameters,
-};
+});
export const useMergeOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts b/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts
index e9b1e92b2d..f995ef06fc 100644
--- a/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts
+++ b/frontend/editor/src/core/hooks/tools/ocr/useOCROperation.ts
@@ -7,7 +7,7 @@ import {
import {
useToolOperation,
ToolOperationConfig,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -151,15 +151,14 @@ export const ocrResponseHandler = async (
};
// Static configuration object (without t function dependencies)
-export const ocrOperationConfig = {
- toolType: ToolType.singleFile,
+export const ocrOperationConfig = defineSingleFileTool({
buildFormData: buildOCRFormData,
toApiParams: ocrToApiParams,
fromApiParams: ocrFromApiParams,
operationType: "ocr",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useOCROperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts b/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts
index 448d70346d..f71c237e9d 100644
--- a/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/overlayPdfs/useOverlayPdfsOperation.ts
@@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolType,
type ToolOperationConfig,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -60,14 +60,13 @@ const buildFormData = (
});
export const overlayPdfsOperationConfig: ToolOperationConfig =
- {
- toolType: ToolType.singleFile,
+ defineSingleFileTool({
buildFormData,
toApiParams: overlayPdfsToApiParams,
fromApiParams: overlayPdfsFromApiParams,
operationType: "overlayPdfs",
endpoint: ENDPOINT,
- };
+ });
export const useOverlayPdfsOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts
index dce2101cd4..5de635f965 100644
--- a/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/pageLayout/usePageLayoutOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -67,15 +67,14 @@ export const buildPageLayoutFormData = (
): FormData =>
objectToFormData(pageLayoutToApiParams(parameters), { fileInput: file });
-export const pageLayoutOperationConfig = {
- toolType: ToolType.singleFile,
+export const pageLayoutOperationConfig = defineSingleFileTool({
buildFormData: buildPageLayoutFormData,
toApiParams: pageLayoutToApiParams,
fromApiParams: pageLayoutFromApiParams,
operationType: "pageLayout",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const usePageLayoutOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts b/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts
index c7cefe1f43..d93e3953c9 100644
--- a/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/redact/useRedactOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -64,8 +64,7 @@ export const buildRedactFormData = (
};
// Static configuration object
-export const redactOperationConfig = {
- toolType: ToolType.singleFile,
+export const redactOperationConfig = defineSingleFileTool({
buildFormData: buildRedactFormData,
toApiParams: redactToApiParams,
fromApiParams: redactFromApiParams,
@@ -73,7 +72,7 @@ export const redactOperationConfig = {
endpoint: (parameters: RedactParameters) =>
parameters.mode === "automatic" ? AUTO_ENDPOINT : null,
defaultParameters,
-} as const;
+});
export const useRedactOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts b/frontend/editor/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts
index 4f9d3fbc7d..de4f384b9e 100644
--- a/frontend/editor/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolType,
+ defineCustomTool,
CustomProcessorResult,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
@@ -78,12 +78,11 @@ const removeAnnotationsProcessor = async (
};
// Static configuration object
-export const removeAnnotationsOperationConfig = {
- toolType: ToolType.custom,
+export const removeAnnotationsOperationConfig = defineCustomTool({
operationType: "removeAnnotations",
customProcessor: removeAnnotationsProcessor,
defaultParameters,
-} as const;
+});
export const useRemoveAnnotationsOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts b/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts
index 2cde7832e4..54e45d43fe 100644
--- a/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/removeBlanks/useRemoveBlanksOperation.ts
@@ -1,9 +1,8 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
- ToolOperationConfig,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -41,15 +40,14 @@ export const buildRemoveBlanksFormData = (
): FormData =>
objectToFormData(removeBlanksToApiParams(parameters), { fileInput: file });
-export const removeBlanksOperationConfig = {
- toolType: ToolType.singleFile,
+export const removeBlanksOperationConfig = defineSingleFileTool({
buildFormData: buildRemoveBlanksFormData,
toApiParams: removeBlanksToApiParams,
fromApiParams: removeBlanksFromApiParams,
operationType: "removeBlanks",
endpoint: ENDPOINT,
defaultParameters,
-} as const satisfies ToolOperationConfig;
+});
export const useRemoveBlanksOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts b/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts
index 55d053fe09..fd2b5200c3 100644
--- a/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
fileOnlyMapping,
@@ -25,15 +25,14 @@ export const buildRemoveCertificateSignFormData = (
): FormData => objectToFormData(toApiParams(), { fileInput: file });
// Static configuration object
-export const removeCertificateSignOperationConfig = {
- toolType: ToolType.singleFile,
+export const removeCertificateSignOperationConfig = defineSingleFileTool({
buildFormData: buildRemoveCertificateSignFormData,
toApiParams,
fromApiParams,
operationType: "removeCertSign",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useRemoveCertificateSignOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts b/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts
index 2ba770c822..96de39321c 100644
--- a/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/removeImage/useRemoveImageOperation.ts
@@ -1,8 +1,7 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolOperationConfig,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
fileOnlyMapping,
@@ -22,15 +21,13 @@ export const buildRemoveImageFormData = (
file: File,
): FormData => objectToFormData(toApiParams(), { fileInput: file });
-export const removeImageOperationConfig: ToolOperationConfig =
- {
- toolType: ToolType.singleFile,
- buildFormData: buildRemoveImageFormData,
- toApiParams,
- fromApiParams,
- operationType: "removeImage",
- endpoint: ENDPOINT,
- };
+export const removeImageOperationConfig = defineSingleFileTool({
+ buildFormData: buildRemoveImageFormData,
+ toApiParams,
+ fromApiParams,
+ operationType: "removeImage",
+ endpoint: ENDPOINT,
+});
export const useRemoveImageOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts b/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts
index 8d8f14f3b4..c8a8aca2d0 100644
--- a/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/removePages/useRemovePagesOperation.ts
@@ -1,8 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
- ToolOperationConfig,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -42,15 +41,14 @@ export const buildRemovePagesFormData = (
): FormData =>
objectToFormData(removePagesToApiParams(parameters), { fileInput: file });
-export const removePagesOperationConfig = {
- toolType: ToolType.singleFile,
+export const removePagesOperationConfig = defineSingleFileTool({
buildFormData: buildRemovePagesFormData,
toApiParams: removePagesToApiParams,
fromApiParams: removePagesFromApiParams,
operationType: "removePages",
endpoint: ENDPOINT,
defaultParameters,
-} as const satisfies ToolOperationConfig;
+});
export const useRemovePagesOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts
index 2d516b861b..644bbe3b1a 100644
--- a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
@@ -19,15 +19,14 @@ import {
export { buildRemovePasswordFormData };
// Static configuration object
-export const removePasswordOperationConfig = {
- toolType: ToolType.singleFile,
+export const removePasswordOperationConfig = defineSingleFileTool({
buildFormData: buildRemovePasswordFormData,
toApiParams: removePasswordToApiParams,
fromApiParams: removePasswordFromApiParams,
operationType: "removePassword",
endpoint: REMOVE_PASSWORD_ENDPOINT,
defaultParameters,
-} as const;
+});
export const useRemovePasswordOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts b/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts
index 2bdb75f31c..457132887b 100644
--- a/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/reorganizePages/useReorganizePagesOperation.ts
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import {
- ToolOperationConfig,
- ToolType,
+ defineSingleFileTool,
useToolOperation,
} from "@app/hooks/tools/shared/useToolOperation";
import {
@@ -54,15 +53,13 @@ const buildFormData = (
fileInput: file,
});
-export const reorganizePagesOperationConfig: ToolOperationConfig =
- {
- toolType: ToolType.singleFile,
- buildFormData,
- toApiParams: reorganizePagesToApiParams,
- fromApiParams: reorganizePagesFromApiParams,
- operationType: "reorganizePages",
- endpoint: ENDPOINT,
- };
+export const reorganizePagesOperationConfig = defineSingleFileTool({
+ buildFormData,
+ toApiParams: reorganizePagesToApiParams,
+ fromApiParams: reorganizePagesFromApiParams,
+ operationType: "reorganizePages",
+ endpoint: ENDPOINT,
+});
export const useReorganizePagesOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts b/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts
index ef60151d07..af05cf59b9 100644
--- a/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/repair/useRepairOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
fileOnlyMapping,
@@ -25,15 +25,14 @@ export const buildRepairFormData = (
): FormData => objectToFormData(toApiParams(), { fileInput: file });
// Static configuration object
-export const repairOperationConfig = {
- toolType: ToolType.singleFile,
+export const repairOperationConfig = defineSingleFileTool({
buildFormData: buildRepairFormData,
toApiParams,
fromApiParams,
operationType: "repair",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useRepairOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts b/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts
index 94bbc32d9e..33223afa43 100644
--- a/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/replaceColor/useReplaceColorOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -62,16 +62,14 @@ export const buildReplaceColorFormData = (
): FormData =>
objectToFormData(replaceColorToApiParams(parameters), { fileInput: file });
-export const replaceColorOperationConfig = {
- toolType: ToolType.singleFile,
+export const replaceColorOperationConfig = defineSingleFileTool({
buildFormData: buildReplaceColorFormData,
toApiParams: replaceColorToApiParams,
fromApiParams: replaceColorFromApiParams,
operationType: "replaceColor",
endpoint: ENDPOINT,
- multiFileEndpoint: false,
defaultParameters,
-} as const;
+});
export const useReplaceColorOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts
index 7e73aab99f..f8c6d32216 100644
--- a/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/rotate/useRotateOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
useToolOperation,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -43,15 +43,14 @@ export const buildRotateFormData = (
objectToFormData(rotateToApiParams(parameters), { fileInput: file });
// Static configuration object
-export const rotateOperationConfig = {
- toolType: ToolType.singleFile,
+export const rotateOperationConfig = defineSingleFileTool({
buildFormData: buildRotateFormData,
toApiParams: rotateToApiParams,
fromApiParams: rotateFromApiParams,
operationType: "rotate",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useRotateOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts b/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts
index 93fd64240d..26a5ca7250 100644
--- a/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/sanitize/useSanitizeOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -55,16 +55,14 @@ export const buildSanitizeFormData = (
objectToFormData(sanitizeToApiParams(parameters), { fileInput: file });
// Static configuration object
-export const sanitizeOperationConfig = {
- toolType: ToolType.singleFile,
+export const sanitizeOperationConfig = defineSingleFileTool({
buildFormData: buildSanitizeFormData,
toApiParams: sanitizeToApiParams,
fromApiParams: sanitizeFromApiParams,
operationType: "sanitize",
endpoint: ENDPOINT,
- multiFileEndpoint: false,
defaultParameters,
-} as const;
+});
export const useSanitizeOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts b/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts
index 27aadeb98b..26db1414d0 100644
--- a/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts
@@ -1,9 +1,9 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
ToolOperationConfig,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -54,15 +54,14 @@ export const buildScannerImageSplitFormData = (
});
// Static configuration object
-export const scannerImageSplitOperationConfig = {
- toolType: ToolType.singleFile,
+export const scannerImageSplitOperationConfig = defineSingleFileTool({
buildFormData: buildScannerImageSplitFormData,
toApiParams: scannerImageSplitToApiParams,
fromApiParams: scannerImageSplitFromApiParams,
operationType: "scannerImageSplit",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useScannerImageSplitOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts b/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts
index a809ae9604..be81fd1884 100644
--- a/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/migratedToolMappers.test.ts
@@ -117,7 +117,7 @@ describe("migrated tool mappers (sweep)", () => {
describe("redact mappers", () => {
test("toApiParams builds the auto-redact body from UI parameters", () => {
- const api = redactOperationConfig.toApiParams({
+ const api = redactOperationConfig.toApiParams!({
mode: "automatic",
wordsToRedact: ["foo", "bar"],
useRegex: true,
@@ -138,7 +138,7 @@ describe("redact mappers", () => {
});
test("round-trips through fromApiParams", () => {
- const api = redactOperationConfig.toApiParams({
+ const api = redactOperationConfig.toApiParams!({
mode: "automatic",
wordsToRedact: ["secret"],
useRegex: false,
@@ -147,7 +147,7 @@ describe("redact mappers", () => {
customPadding: 0.1,
convertPDFToImage: true,
});
- const roundTripped = redactOperationConfig.toApiParams({
+ const roundTripped = redactOperationConfig.toApiParams!({
mode: "automatic",
wordsToRedact: [],
useRegex: false,
@@ -155,7 +155,7 @@ describe("redact mappers", () => {
redactColor: "#000000",
customPadding: 0,
convertPDFToImage: false,
- ...redactOperationConfig.fromApiParams(api),
+ ...redactOperationConfig.fromApiParams!(api),
});
expect(roundTripped).toEqual(api);
diff --git a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts
index 93ade85368..8a895a78da 100644
--- a/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/toolOperationTypes.ts
@@ -3,7 +3,7 @@ import { StirlingFile } from "@app/types/fileContext";
import type { ResponseHandler } from "@app/utils/toolResponseProcessor";
import { ToolId } from "@app/types/toolId";
import type { ProcessingProgress } from "@app/hooks/tools/shared/useToolState";
-import type { ToolApiRequest, ToolEndpoint } from "@app/types/toolApiTypes";
+import type { ToolApiParams, ToolEndpoint } from "@app/types/toolApiTypes";
export type { ProcessingProgress, ResponseHandler };
@@ -53,7 +53,7 @@ export interface CustomProcessorResult {
* 2. Multi-file tools: toolType: multiFile, single API call with all files
* 3. Complex tools: toolType: custom, customProcessor handles all processing logic
*/
-interface BaseToolOperationConfig {
+interface BaseToolOperationConfig {
/** Operation identifier for tracking and logging */
operationType: ToolId;
@@ -82,15 +82,16 @@ interface BaseToolOperationConfig {
/**
* Typed frontend params -> backend request model. When a tool provides this,
* it is the spec-checked source of truth for the request body and its
- * buildFormData is derived from it via objectToFormData.
+ * buildFormData is derived from it via objectToFormData. Bound to the tool's
+ * endpoint, so a spec rename of that endpoint's model breaks the build here.
*/
- toApiParams?(params: TParams): ToolApiRequest;
+ toApiParams?(params: TParams): ToolApiParams[TEndpoint];
/**
* Backend request model -> partial frontend params, so a stored API call
* can be re-hydrated into this tool's settings UI.
*/
- fromApiParams?(apiParams: ToolApiRequest): Partial;
+ fromApiParams?(apiParams: ToolApiParams[TEndpoint]): Partial;
/**
* For custom tools: if true, success implies all input files were successfully processed.
@@ -102,7 +103,8 @@ interface BaseToolOperationConfig {
export interface SingleFileToolOperationConfig<
TParams,
-> extends BaseToolOperationConfig {
+ TEndpoint extends ToolEndpoint = ToolEndpoint,
+> extends BaseToolOperationConfig {
/** This tool processes one file at a time. */
toolType: ToolType.singleFile;
@@ -110,18 +112,18 @@ export interface SingleFileToolOperationConfig<
buildFormData: (params: TParams, file: File) => FormData;
/**
- * API endpoint for the operation. Can be static or a function for dynamic routing.
+ * API endpoint for the operation, or a function for dynamic routing. `null`
+ * when the operation has no backend endpoint (see {@link ToolOperationEndpoint}).
*/
- endpoint:
- | ToolOperationEndpoint
- | ((params: TParams) => ToolOperationEndpoint);
+ endpoint: TEndpoint | null | ((params: TParams) => TEndpoint | null);
customProcessor?: undefined;
}
export interface MultiFileToolOperationConfig<
TParams,
-> extends BaseToolOperationConfig {
+ TEndpoint extends ToolEndpoint = ToolEndpoint,
+> extends BaseToolOperationConfig {
/** This tool processes multiple files at once. */
toolType: ToolType.multiFile;
@@ -132,18 +134,17 @@ export interface MultiFileToolOperationConfig<
buildFormData: (params: TParams, files: File[]) => FormData;
/**
- * API endpoint for the operation. Can be static or a function for dynamic routing.
+ * API endpoint for the operation, or a function for dynamic routing. `null`
+ * when the operation has no backend endpoint (see {@link ToolOperationEndpoint}).
*/
- endpoint:
- | ToolOperationEndpoint
- | ((params: TParams) => ToolOperationEndpoint);
+ endpoint: TEndpoint | null | ((params: TParams) => TEndpoint | null);
customProcessor?: undefined;
}
export interface CustomToolOperationConfig<
TParams,
-> extends BaseToolOperationConfig {
+> extends BaseToolOperationConfig {
/** This tool has custom behaviour. */
toolType: ToolType.custom;
@@ -171,11 +172,50 @@ export interface CustomToolOperationConfig<
) => Promise;
}
-export type ToolOperationConfig =
- | SingleFileToolOperationConfig
- | MultiFileToolOperationConfig
+export type ToolOperationConfig<
+ TParams = void,
+ TEndpoint extends ToolEndpoint = ToolEndpoint,
+> =
+ | SingleFileToolOperationConfig
+ | MultiFileToolOperationConfig
| CustomToolOperationConfig;
+/**
+ * Define a single-file tool's operation config. Infers the endpoint literal from
+ * `endpoint` and binds toApiParams/fromApiParams to that endpoint's request
+ * model, so a mapper cannot silently drift from the generated spec.
+ */
+export function defineSingleFileTool<
+ TParams,
+ const TEndpoint extends ToolEndpoint,
+>(
+ config: Omit, "toolType">,
+): SingleFileToolOperationConfig {
+ return { ...config, toolType: ToolType.singleFile };
+}
+
+/** Multi-file counterpart of {@link defineSingleFileTool}. */
+export function defineMultiFileTool<
+ TParams,
+ const TEndpoint extends ToolEndpoint,
+>(
+ config: Omit, "toolType">,
+): MultiFileToolOperationConfig {
+ return { ...config, toolType: ToolType.multiFile };
+}
+
+/**
+ * Custom-processor counterpart of {@link defineSingleFileTool}, for tools whose
+ * customProcessor owns the API calls and file handling. Rejects fields that
+ * belong to the file-based patterns (e.g. buildFormData) and any property not on
+ * the config, so a stray or stale field is a build error rather than dead weight.
+ */
+export function defineCustomTool(
+ config: Omit, "toolType">,
+): CustomToolOperationConfig {
+ return { ...config, toolType: ToolType.custom };
+}
+
/**
* One generic source-of-truth for the props every automation settings component
* accepts: the tool's parameters plus a typed change handler.
diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts
index 17e4c47442..7ee69da142 100644
--- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts
@@ -40,6 +40,9 @@ import {
} from "@app/hooks/tools/shared/toolOperationHelpers";
import {
ToolType,
+ defineSingleFileTool,
+ defineMultiFileTool,
+ defineCustomTool,
ToolOperationConfig,
ToolOperationHook,
CustomProcessorResult,
@@ -50,7 +53,12 @@ import {
ResponseHandler,
} from "@app/hooks/tools/shared/toolOperationTypes";
-export { ToolType };
+export {
+ ToolType,
+ defineSingleFileTool,
+ defineMultiFileTool,
+ defineCustomTool,
+};
export type {
ToolOperationConfig,
ToolOperationHook,
@@ -69,10 +77,10 @@ export { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
* Shared hook for tool operations providing consistent error handling, progress tracking,
* and FileContext integration. Eliminates boilerplate while maintaining flexibility.
*
- * Supports three tool patterns:
- * 1. Single-file tools: Set multiFileEndpoint: false, processes files individually
- * 2. Multi-file tools: Set multiFileEndpoint: true, single API call with all files
- * 3. Complex tools: Provide customProcessor for full control over processing logic
+ * Supports three tool patterns, selected by the config's toolType:
+ * 1. Single-file tools (ToolType.singleFile): processes files individually
+ * 2. Multi-file tools (ToolType.multiFile): single API call with all files
+ * 3. Complex tools (ToolType.custom): customProcessor takes full control
*
* @param config - Tool operation configuration
* @returns Hook interface with state and execution methods
diff --git a/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts b/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts
index 175fa744ab..d54be4ecbf 100644
--- a/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/sign/useSignOperation.ts
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import {
useToolOperation,
ToolOperationHook,
- ToolType,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
SignParameters,
@@ -50,8 +50,7 @@ export const buildSignFormData = (
};
// Static configuration object
-export const signOperationConfig = {
- toolType: ToolType.singleFile,
+export const signOperationConfig = defineSingleFileTool({
buildFormData: buildSignFormData,
operationType: "sign",
// Signing is applied client-side in the viewer (see createStampTool ->
@@ -60,7 +59,7 @@ export const signOperationConfig = {
endpoint: null,
filePrefix: "signed_",
defaultParameters: DEFAULT_PARAMETERS,
-} as const;
+});
export const useSignOperation = (): ToolOperationHook => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts b/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts
index 835959c484..432aae59ec 100644
--- a/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/singleLargePage/useSingleLargePageOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
fileOnlyMapping,
@@ -26,15 +26,14 @@ export const buildSingleLargePageFormData = (
): FormData => objectToFormData(toApiParams(), { fileInput: file });
// Static configuration object
-export const singleLargePageOperationConfig = {
- toolType: ToolType.singleFile,
+export const singleLargePageOperationConfig = defineSingleFileTool({
buildFormData: buildSingleLargePageFormData,
toApiParams,
fromApiParams,
operationType: "pdfToSinglePage",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useSingleLargePageOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts
index adeb65546a..fad5b1f04e 100644
--- a/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/split/useSplitOperation.ts
@@ -1,9 +1,9 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
ToolOperationConfig,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -33,7 +33,8 @@ const SPLIT_ENDPOINTS = {
[SPLIT_METHODS.BY_POSTER]: "/api/v1/general/split-for-poster-print",
} as const satisfies Record;
-type SplitApiParams = ToolApiParams[(typeof SPLIT_ENDPOINTS)[SplitMethod]];
+type SplitEndpoint = (typeof SPLIT_ENDPOINTS)[SplitMethod];
+type SplitApiParams = ToolApiParams[SplitEndpoint];
type SectionsApiParams =
ToolApiParams[(typeof SPLIT_ENDPOINTS)[typeof SPLIT_METHODS.BY_SECTIONS]];
type PosterApiParams =
@@ -165,20 +166,19 @@ export const buildSplitFormData = (
): FormData =>
objectToFormData(splitToApiParams(parameters), { fileInput: file });
-export const getSplitEndpoint = (parameters: SplitParameters): ToolEndpoint =>
+export const getSplitEndpoint = (parameters: SplitParameters): SplitEndpoint =>
// Default to BY_PAGES when no method is selected yet.
SPLIT_ENDPOINTS[parameters.method ?? SPLIT_METHODS.BY_PAGES];
// Static configuration object
-export const splitOperationConfig = {
- toolType: ToolType.singleFile,
+export const splitOperationConfig = defineSingleFileTool({
buildFormData: buildSplitFormData,
toApiParams: splitToApiParams,
fromApiParams: splitFromApiParams,
operationType: "split",
endpoint: getSplitEndpoint,
defaultParameters,
-} as const;
+});
export const useSplitOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts b/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts
index f607a727f0..0769f37952 100644
--- a/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
objectToFormData,
@@ -35,16 +35,14 @@ export const buildTimestampPdfFormData = (
): FormData =>
objectToFormData(timestampPdfToApiParams(parameters), { fileInput: file });
-export const timestampPdfOperationConfig = {
- toolType: ToolType.singleFile,
+export const timestampPdfOperationConfig = defineSingleFileTool({
buildFormData: buildTimestampPdfFormData,
toApiParams: timestampPdfToApiParams,
fromApiParams: timestampPdfFromApiParams,
operationType: "timestampPdf",
endpoint: ENDPOINT,
- multiFileEndpoint: false,
defaultParameters,
-} as const;
+});
export const useTimestampPdfOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts b/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts
index 2d3e22fb06..2212d284bd 100644
--- a/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts
+++ b/frontend/editor/src/core/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation.ts
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import {
- ToolType,
useToolOperation,
+ defineSingleFileTool,
} from "@app/hooks/tools/shared/useToolOperation";
import {
fileOnlyMapping,
@@ -26,15 +26,14 @@ export const buildUnlockPdfFormsFormData = (
): FormData => objectToFormData(toApiParams(), { fileInput: file });
// Static configuration object
-export const unlockPdfFormsOperationConfig = {
- toolType: ToolType.singleFile,
+export const unlockPdfFormsOperationConfig = defineSingleFileTool({
buildFormData: buildUnlockPdfFormsFormData,
toApiParams,
fromApiParams,
operationType: "unlockPDFForms",
endpoint: ENDPOINT,
defaultParameters,
-} as const;
+});
export const useUnlockPdfFormsOperation = () => {
const { t } = useTranslation();
diff --git a/frontend/editor/src/prototypes/hooks/tools/pdfCommentAgent/pdfCommentAgentOperationConfig.ts b/frontend/editor/src/prototypes/hooks/tools/pdfCommentAgent/pdfCommentAgentOperationConfig.ts
index f9aec1718a..8994ee9df1 100644
--- a/frontend/editor/src/prototypes/hooks/tools/pdfCommentAgent/pdfCommentAgentOperationConfig.ts
+++ b/frontend/editor/src/prototypes/hooks/tools/pdfCommentAgent/pdfCommentAgentOperationConfig.ts
@@ -1,7 +1,6 @@
import apiClient from "@app/services/apiClient";
import {
- ToolType,
- CustomToolOperationConfig,
+ defineCustomTool,
CustomProcessorResult,
} from "@app/hooks/tools/shared/toolOperationTypes";
import {
@@ -104,10 +103,10 @@ const processPdfCommentAgent = async (
return { files: [resultFile] };
};
-export const pdfCommentAgentOperationConfig = {
- toolType: ToolType.custom,
- operationType: "pdfCommentAgent",
- endpoint: PDF_COMMENT_AGENT_ENDPOINT,
- customProcessor: processPdfCommentAgent,
- defaultParameters,
-} as const satisfies CustomToolOperationConfig;
+export const pdfCommentAgentOperationConfig =
+ defineCustomTool({
+ operationType: "pdfCommentAgent",
+ endpoint: PDF_COMMENT_AGENT_ENDPOINT,
+ customProcessor: processPdfCommentAgent,
+ defaultParameters,
+ });
From 43162c40adfc072c253c29b155935600d35369df Mon Sep 17 00:00:00 2001
From: Ludy
Date: Tue, 7 Jul 2026 22:56:16 +0200
Subject: [PATCH 10/43] chore(frontend): remove unused OG images (#6826)
---
.../og_images/auto-split-by-size-count.png | Bin 64795 -> 0 bytes
.../public/og_images/auto-split-pages.png | Bin 59845 -> 0 bytes
.../public/og_images/manage-certificates.png | Bin 61723 -> 0 bytes
.../public/og_images/split-by-chapters.png | Bin 58098 -> 0 bytes
.../public/og_images/split-by-sections.png | Bin 61572 -> 0 bytes
frontend/editor/public/og_images/splitPdf.png | Bin 46238 -> 0 bytes
frontend/editor/public/og_images/view-pdf.png | Bin 52283 -> 0 bytes
7 files changed, 0 insertions(+), 0 deletions(-)
delete mode 100644 frontend/editor/public/og_images/auto-split-by-size-count.png
delete mode 100644 frontend/editor/public/og_images/auto-split-pages.png
delete mode 100644 frontend/editor/public/og_images/manage-certificates.png
delete mode 100644 frontend/editor/public/og_images/split-by-chapters.png
delete mode 100644 frontend/editor/public/og_images/split-by-sections.png
delete mode 100644 frontend/editor/public/og_images/splitPdf.png
delete mode 100644 frontend/editor/public/og_images/view-pdf.png
diff --git a/frontend/editor/public/og_images/auto-split-by-size-count.png b/frontend/editor/public/og_images/auto-split-by-size-count.png
deleted file mode 100644
index 59c7ed77cadbbe4fc6b4f91e42f4b5a6bf9a409c..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 64795
zcmYJabwE`A(>A>HBDH`Zl1sOA=dM9XDuQ%(2}n0fOP7Fji-2^4(w)*R-QD#Z{QmCu
zEE)1~^)3oFl`=X!fD>-^pDIy5V50|VBfmeg-_pkEO5w=s?l8uPs;
z<+JjVl8@I5b&A9+d3$v@kdTl9m11(PSB)B&6#BUT_wj6N%SjpAO8@Pn>=>`4B-aHi
z-0M435GpzFd0(r!v7zjfms0}B$LA%JjEdKp#T+{-2>5k$zOL?B@xHMh(DB(U+?rw$f*=lHdYO6&iI$qCRFKct$4C7G0`5{%Q9^KNoa1U+heGPS+Axw5vFeFvBaSoc4C1{QT!e#%q1QVaOotOuCMK;_C9<)sv0?VOD;0^daGMWCQ_753mO5=4dn*
z!0_Nw@PDeRWR3qn3}s`Go&FEQ{~+GY*Vy}*t$gl=3E~8T;WN(2IQ#mOM#BF-7AwkE
zg&F8!A^n2L#u%KR^!J~bI1C>!0`Yj1W$NVQY`Ai}%l1EAx4lG~;C~+0=x(g*RBBX1
z9!+L8b1v0(Z>kd_$0hvF$_D3^!2iGwowYqVai5+2PvO`3md}>nri}l|tH8j*YB|_v
z00s8|>Q3!G9Sy0K5`YlKfTPuiW3x4wAY^VJ9>oz~L^U+78*38(*#*EmGSb!8*LASS
z_~;c~zmR5EAI97pD9n&`NbD{%lDV{|99Q1)}<`oxM^ht_0f?W
zpJG)O#htU#RE7UtpI~69K5pD~BK((Dk9bxE{QLFqhtlyhKGLz~+Jl
zgTgCfWGFi2pKrbCyb&>P>DIk(2K1Ad|S0cxocTkrCpdEm&CYUMCC=EFhnE(PRPUb&k2uQm@GG
zCxx2$Vr0_K&vE57_R9~$iv&9U{LwLLHQ-HtB8L}Hx^RB-o&D-{Ji5Ox(mx0bgV{V>
zZ}=V1L7=M7b}pIAo%<*?z^TV!9vn52iSTGWSP)m&+m%XA+Q}%bbuF
zy(CV;VA{ytHD43?1C$LbRo;IK<0fCeD}s|b);*)YzN>rbsKRV03WsdwbfuZW)Wj+C
zEZI`PX11hIEHb|e9}zi^j){n%0x7yx3`~pHAhSmBMeKn2>o>GS?s!#lM^N(0w>(rdQ$60
zLo3KnDeEy+EON`aX|ndV-l2m?p3FeKpN;DU%ARMe^U}ItWIDy!YF|^z{>DSscd~<$
z@iW90=G>Zf_Z!E(j3Ac?gC8yUclA@pMNg#j>G#4h!ra}@9G;c_)X{Z16r@GPgu^M)
z4Vs`d!aaYJP$=;qQ)}5NiA}k1YOi>1XCpR4Nm>jmvS6HIU;rER3vZ^~;Wf@a)y3fM@Voc@!zc}83z`;bVrEGpYU7%sLlPYOluk0
zfnpJ-5n_5;RBU)fhsx*oHC}SJGu^eJZH%IS-#q`9Y9woGM6`*FtVHJG3mV?(jiANR
z44>W%$w4u37UwDU_k~PYB3tNKAaPW?*Epk|oK4onk9M3g3&@2sA-kee_XJAWhbDOA
z*lPxKDWvI>E
z?PoY#&61T60)im;G-(Ik(9&L_Q4b^xTWfW_&ftVyu1<$7VOCt_LpRzB!)6wpraXNh
zuwdvTZc=Zn-V&F--&J8Y-{F{Dtbq=few)1}3%x!#bHjm5n~5$b+d+-3SJ!4Ul$3ax40Nr
ztGnb~uCWT-dlufCyd=!!`&U4>-G=FvXNlX{+Y;wJ)^|9IF(XJeS9u-is7l23HZgo4
zaBI{3_xwaJQ`7@!|8t&e`wt8O>4z|q=1v8^=#;FO{RM9$PLcw<>fI@GXZgz+riq~6
zW{vI#BRyj+#%5S~{dXhhNiW?5hiCRe3g-lV^6;hH2%64&m+-LKANQfN2|Dc4TqtU4
zEXIAHyfN11)ehw`pZPog{N@saS`6F#W*fz(s<5z0P!Gvd;z#5pyY=Y-d@@U;rF~UM
zVxQwjMvcwSBCV}^%6;v>Z#YJs-S0lovb&tFChgUnrd!rP9{>=g87bcCp9!D9l>CxJlv<++Ss6_KhXXZ!To_YjK{R#-S?{|EWCb02YzY7
zR#lvL6#MLa`)n@R`2c%y;7NrxF}H*H1M>XVVqj&}Tg_LiZ`rDs&TaCS
zJXCR3=8le{{p`nMRsV95}%34zK34P%bQ~fcPxC
zGFf5P+zqmVC9X8b%U=t~g)v_NRu2$|&0~PG|2jEXk|PK`OQx+TqoG=tD>EhgX;fa}
ztKu|9keGpi()^(N-Amnf4K0qpW~FHlD7<@X57x(qaq*r!@&~6Z!;Zwcj6k!|)!oIE
zDcadra1PBGLc*_|%a92xyc^Cb3PEfb)My5Bmn{u0e+QaH-6~l$H0SETse(@1-AA*H
zVy3b|rF1e<8sc@d*SIP+U7gOP4w+fpteA%7>Qyh8r3zYerC(scHtzwAHXc2PxrXW0
z0?omFjyA^#j9Q1eS~)bdPUi!lIki$JZ=YdWUzAZFckQ9GG)3dUXV%M8s2nXm{pvmb
zZh--Fxfz_#nb(Lh!JH|{l-k{-`Y5*{8bpYg+YH0UhVMr)dOqj|AaJXf?%NpWCJT#N
zY@BgA*v06jBgIDrzC6OdIpKf*#$k^UXDdcU@#ftupO=THYu&Vw0SF4dD#Ur>^7)RG
zOxTuEjQj|i)5pHIE~OIc^s9HJz5>@T>ZS9#-yUgK&6i_5IL4?pcM<=7#*ffog{+%;b)bdDD)V6C7EQh)2I77o#8Lvn7d%q}~hRlv?HMe?Uu;~kMzVpy^
zYNh*cY;A$w;!IEFS+p>39f_scdu}7j$BNHvN56|#7O%@Th9RZhVx$VY9#_#yMFoIh
zf?vDT0?FLnpHojm>2`IfWObSr^qO{mw=wd59{%to6F{$iA^=^&t`C9T`cs$Y68lhk
zY+b~w7)O)vANSMqZBFzzHEW8q_iVA6T4P5bG8y~ydP5JW+Wg0)-$3PR$Z_bT6Dg3d=z!a8WupjywK
z2mOnujsLw~==kewB{I^P_nLj8BtDN+R$sSF%Tk%Ywo#%HAL>`e&>PSn8a!N+b`(z=l&BQCCi7Wflg96n(7zD
zB;xXIh^dj;!MmSBd}?}}JqG%AJ|zhH3P7Iv_AsNRTz~Ey^8yW9*cJKXWkGYAE8vR@
zi+ya5o}L2yVO@S~q>Tm>PQx$cuSj?`I6LU!vQ32st^}
zb~}nSu|dvwaP4;VbW2il|vlZvFk5e&tz13XyXTKLlo3%=0R
ze{Jk3fT{JJasilg6K^#ByRzuLV^jbK8QF8tJ{h9rV0z#is+ga2c-NvOnk(<#@X+ehEYwwz^JBP0I2uIt3(kKz&RwfP(3>$w4ynAJG^B(36bfPuJ
zPMA06$K2Jce9Y|CN&Fn
z22aJbZ2Y7?v-@o0zKB%ZQn#G*?a7%Il4)_VN!Gc*FJFc?WzHq=t1hUaH`L3S6=Kh91I
zVxK&KV3B1jyz@{fr8j~k#8It1M_|a}&S=X}NkNXW;h`4du*;4bFg%C$(Y1{395MY{
zAHhBn?}vA!ICu5^AiFWBRg?3<`R~w79qQQQt;MYXRNPj7DMu%sgWHZiln<1`PQClR
zqrHY;cpB_$y}*zY?ERdL$!k;7XP!#3FcS9IJ2egt%WP;pK77RXu7h<|6ypw7aP(n0
zzq76r9Tke@tBFoMu02rAe*5n5jWTTd+w(arOmf62YR3F+MhV%^@}Gb1mq!66e$-G~
znlb0}J2{xdrnPz(C$RdG5rN9dNO<9
zWLbA-jZwpJA(@1Mn)!1p7{J{BzW38EA?_@>x9+W7-5}5{2RGJ9Pum+`L+;bErgskHcceJMTRsMdmmjOI@Smsh>aF#L&sryjr}E}ErMe`>lZrZp{1@bb^+Ym3hs13XB1
zI9{@7Q8~-w_?y9+z|93haMJc+41f$2MZq|Ed}HOkd__vdpuYti$Qji0*ikV
z6I0|F(0yL{9qR8*W7|G2UM552c651tY`%$Y8Ik2erQVaBW{)jA@nR_!bX%W$y>}lI
z^6uHR_B;3c(O+1fXJ+QK>bPV$PVm~~lKB0sQIilm
zHGLs^dDB;KR?*RFrQYnegCTLT6QwkS4WD7qF4Ga4nl0D2^y@Lof;!GEfZ@v>{tDl>7(>`0st|%no4IvL
z?Uk(`0WFo4)m-j#_47vV%}-(7`kbU&>t7E{9~939zEUY%)T-c&A~(R)z1Q-j{gv2b
zaX3k7DCW5+sL=ske|flL8TDpXdvJtP1NdpQutDzPH;hBjT??BRNn8QqgA!m4I!1d&
zxl~rqS*s}}TSUKBZw30~tIp98rLHly=C8bonvu{-x!Dw_dg;I%f!LeU+TVVM9YrQR
zsMOxs)CxszZ-osWyTF>92T%^Y6-KDu8G6^1<|Tu0P`SU<5oW8}WQm18@}~c-|KVvu
zuvof7)s~S%Cpwqu@btL+7=xEim2rQ&(0iy82Ho(}v+JV9>hC(u779Ic61F|PNDTqN
zl=VWVSzLco2X$F*Sd(`tZ1}!-+A6{8U-VQIq=I4IkbOLos~$on5nG*i*6a>AAi=5A
z6~CWbmm|PB;uOCbrmo>}xxsmzv6B--ko0bAYb$rlZ-YZe-0ApjM;X4mAZ?tWrFQmU&-UDH{t`n148eu)e%9}jkWfL2qm0LN
z7=pmv7Wgy*NeH8bu!u$LJ(7g*K>KM0lx+20VUa@Rw^G;Mml*omCT>o48AX;+jrk%y
zId~a3Q*B*}BNd6&Jk;j1z`4&c&Py1+g?H@_>15_~g*&bX1caKdtBBYjnQ)7Y|ZpfqztDZx69}chx2qVpxS$N
zwG?kvK=pM&4=#~FXj@|7j0I
z0s%wMoE`W`E+tB=m>s*;9P;AYe12?$<2iLabhVVU{s&ofM3h%ML0srl+ch1D11;=p
z|KY;Njtbv>)3au5Iaw|0)(*NYo)dLEakvMMgUT0JDXwKw;{p5>REh2$EeY7f)4Dgwl>kXg2%p}
zDjKdGr^>!wEE{Tud1rUbdO=!W<-$XiQO8+`jP*BmCasu2hqw0HTR+VI7XBWc9@G6}
zc*`IOln(uDxLuG|TF(33qocv&lLbZpdp~5BLo$9ujLHi8au1JbTLqJmRNx=!lT+5)Akj;b||>
z95!Kop-j>1
zQ99>zF{9$I)Rj(c7ADSS_bxzkttR9zOe*rN)HV9w8ygRqi?hL_iypjF*0u}@@G|Gy
zGvzwZr@ysVx+ec?iXf0A{4sX~I@NKmvx{5)j@0fE!Z(eoyu>L9UMul@%F1R#eT3ug
zc;H=5_E>jdNB-p^u9TZ8!P8J}7US=c
zH4_JSWkaL*ZmW23u=#KS#vA;%>B3d8{0eop{@h1AAi!|)WpDOc?o8Pn2n%tD`3`XF
z7SydWUm?xs`B?rtV(xt4Mljrwb~IWivHG5u#W=4NZ~jt6(*OQd%NYhW;^;|ankZ6L
zW~O{qm6i1Wq>9*Ysuf5?db(}MI6WG?ufmM~`hTY+Y!I*!qhuOA^6xyOxOsc!Fib^p
z({7y(f!A=gO_B+r*0FiK*Bt^R!N6kt6kEDHu);r$Eqh}k0>3UK7bg~fXGq?E0M4#C
z{hhC~54{I~-So)tFW|l0m|xV3hszAE;igfp65DR~WB`IiQ|}C2nZ>9JfA~jCY6MLb
zrAx%|F)1_)tU}yuWn}~c&L@>bTTC4`tAUNC*P~^ME%%1$81x482uS_g`uor)Q7vh)
z5y0Yu6&ugi#=-!ATA94K1oPU&q-Exgo)UtfkpW5uKH|L_TYX=C=ZSagx)S#OSaIp`
z+~KC4?2Y86A>7JT*J!mjlh75J6pWObs%<)`-QWM>%VJWY%n&D5^QbxXJ_Oa|ULj+XmeE&7O}_*Qd;c&m~K}6Y~bB
zF6KSZr`vf=ZM({S0QO9xJCRf(u7N_RFhP^Q-$KTSFD&o&KaGBx`|Zc+
za?5u8qZyp$uI2-;BWBQMDUvG}@e9reyskl_@di~oMimlre{&8ev`tzQN
zVUF;0J;~$tRR9bu2TeVPZyrvs`hX+R;~IUCQ`mW15PY!U{YueyCjOpj8meT9tNu3V
zeO>9*-YI>xZukwE0Tn7W4wbVWo1=nmV
zGex7m!yju99)%SsDP7_$s+$@6!84VaZz4U{KRufFP8gsSr9cgau=^-2UR-IZV4E*T
z7ZSN6n<665A&`2dxEs|5jSKJH)BtC)$%&4ZmVA?j{{H^Cz&@M?>^$Qq(`Z36oIE@N
zto=PPZCWPl_q#nc4(TR8-ahTgZr>=m)0q{yh{#E5PfH&Zx?z*~i_NN5y7NKHtwWxX
zo9@7)fKV@KDmcde)%M@C%|EGMb85oq#_?D3Zr2Z|5_LUKW<>bXGCn-W$g=D-W-ld(
z;7yp*5-0b2oL?o{s-5POL_Vuuua;`LU-w=hQ~3UE`C5c>gQ@U{x
zHdihRsqEt-qn<)aW^1c%ij3#9h;*8@wBQ!3w^aC$z{JdKMUSlQUA9qi2{YN7eky(Q
zbcUBwv0JbfWQpug0k90)hY%~svB%k!yNYpt=4@x|YXwBLeO{J_H+4&3Ag8kWo(b|A<8xkdB
zhmuZ+^Phm1$%|bxyJqvz5K@tzP9Z0_zO&d!aP^`R{jUotEt}C(wo9EH&A0V!Cmrmr
zMYv>QG6GC=5Pt0ExhW+1QnM}OmUAg1k|a7C3<4&`swSrH5gh(cPJ&j>%_BG%R^G&J
zS$&XKaFSIT9v&GkyUhwqz@xmyILhM1Bf}ytb)?F`Y*2<1m%rIj)p?Mq
z?Tloidu!pVTQk3ycBl=YzukW&nRi3LaVpwl2;$_i@ukYQ_k?ZLB@#gAh-QoU)GraJ
za2)aGgaO?~nwLIN8AS6=a
z!HA4j`}5e#)#z5v5P=D7k{ou5bPk527#}h=q_To^>S_OdVs6ZNX@$fR+bEkSo$XEs
zMZ2@VDsDOlWy!rbAX3hmtuzpZ_&3YBK(rHES7t@rItPjCr9=`$3Uys$R*}
zWnEI*>&K!QFJL##@#;B@HVX|N;99kQOh!p^dQ{tVgNNVW9-7b`Cj9Eydam=CdvkX7
zh3HJc0WCP)AZYBMDHD|};4e@oc$kQsk^q&oVT2$d=&P
zt6)AgRlH@0&CD*uG1Jr6>BS#>4A%B`ks2P=eZIc_47zAwQjUbu*#7jwdm?n@2@1jOsa!cmt+^UiVPPq3o(o>OwG19vY2i|s(1SEf)
zLdRmz;i)*C6QrLD@bC~BKN8|6n->Wn%^zoK$`$=}eGDvo4>2D;g;YAZ~A@}>#b|22B
zP4$u8OO^D*J_n!4)h6q}`?rrJ6|#tI-pcb*>Sb55WywbYuiHg$B+5c6{>=cN4molN
zi(9o_&-dE1mw}OZbmY_ZOf%UTIP?KF7G^)wO-X&Cq@%qL%Hu67^^Q+n@6mW?AzlcK
z)}S=6=%Jkuf>+4?aievx5z10kGeZ|Bs#(>K%f0Qfy_eF1Mi#zNhpEI$IGehMzn7FX
zh9CSRQ7Ybc$tQ1d$U2Hx?@o=Apo4AeH<)voc0>Zqx*n7jsR~9N;A_*ywy%m!nW_dS)Q_Z9p
z`K@1)R;puBQp$Gy4n|A(G{ihd)R^}tCQP-(SL0
zrEh|MFsTq^VghFZxltd*aPS44<-!01efRF7w0JSG&zQbX)Q|TymNaR+t6^{O5XYvL2>I
zdJ@Afr6(N(Nf(f@UwWtQcAe{l{3p;=(+v5yH;dI*8|YJiw6xo`g@f-}?1y)(LFaWg
zwrN5Td#*mqg$GzkNmw4M&NT*~geMA5f{Shas;
zj!x-aqRv5M=X>_4kD)2>Y}KJ&sEz%s@(-zpj{+LvJJD_ZBw4)1lG7#hHswl^1WAC~
zpS?EmeEQF(qItS08<5nGsnDv7jj-f3vyVf{t+mmJ@y_c}EWofCUy-M&!;*~C6}$
zA_f3^thKS5tGePyzrcvV2pl|d63z1VOLhvBZlNv4I=v);tEE^DCgammySj$ex7(tZ
zGJ&Y@tJFMVFf~T8kmIWQ*9$5lNQp+pZ|l9_Ez3WW1;M#&u6_rpe!euCqf&ea!VMN4
zy!L*?r}Wc;ypZ}4__nsOL#XWc?O7%WE^{b!Z|q;}gp<3)LY|ypQxp$>8m5ih)vV}*
z>^pW{%g^58wDU1#jaBfie_JpzS8Zb0T6(swx~Y`-D|u8xy26oc;wm#B=pF#aOk9m!8Ovu`F#`1-Mg_-$up=FnNYB{^iXOj*!r
zF_i|RSjB}t?x4jMRCDTYu9MXqL!{n$Px>x?(|Ou*cP=s=$Zhas8rwTeU^|Obgi1|toXySD+F}N;1^z4%&9;G;GBFDB+4teDR=Xj4w)*)2JtY+51J~cCv{JQUdk7USWw{XV=k;3=s_h(%Pg?qw(OL38
z1m|D=%yN%of!V)Wu@GUkkgywIWRy>l3UfG6`tgawHIvP5l9h{S8LL;$#}D+?4NixF
zxQ8-wRPt1JiS>H_#c2C%#s{UViV5?vn@i$0ye~Ry#~vkh;~zC`
zO?lYfvDJO?VkA>a#BnX5?#HH6CD4e{zo{`R(#Pe$d@1#IxTKZ?3XFb#%e|S6QaO$J
zZ8c-Hp1cO9?~n4hL9t8A|Agq^ztIhhU;r9F7eRcgGv(W_lD4_Jo#BBIt*wS4P9&FC
zLCGChV8SrdeNPPB2H^B2+?%s=7I}u1+GYNcnlP?5;(YPpkhUlNQ$9VIVHZ_)(t3(?
zDqaDXUgK)%9mh9Jh#!s4g=>g2GHSDq3RY(~O%;(3=iA9AKV+*|HK2b)oZEbY7w{r0
z-FpJmH6nTUr>Sc2=hjwgIfurZ<-9@3V!Jq|DPwS8%8fZmyLlT^6Ll3Rf6#C(X|To{zDEsN&N2wr*f+5
zMhVv!F~+i5tVToV9drTuUi$blS4qTU`rqdAS-1&v-FzYIL!%)cczT!r5
zB*L%#&!#UYfvT~oBEQNbv%U?CP%e1E!6hA;KKq(gaZcpbl>Vzmgn41+ms3fjXmsFa_zI=cAkjd!0o
zN+a}W#kqJk*z?>VW6|$ymirqF+@G;)cO>vx3LL2~{_XX?rzYn${mnh77@{pKE?5--
zECrTm{hxby)H5I!E&Vm?c&gnOvWOh<5qkQ&hoX%p^Xnf^h)=qF4v+~P35jl$-*!_e
zBvXCAR5E<{-PWg8yvAheKl*SV+0<}oO((J>xZu6PjeYJ|_%wc&iBXU^4yI(&uHV;F
zQW0mA1TRmWi=Q<#F)`6HNsS3;6n6OH+T*s~RMsNl`8jK)VLml-*Y%)HLX*eej^?1x
zA0kZPNcUZ>_LJhK<#yO)y0Rn`K`1%H~cfRws$29%LW0@vB`SIt8XM0j#T#X?W3n
z7+=_F`dmU-L~(bKgT(Mh1eA@WGw6)fI^}@H4(dp^DUC{@wpy>55W>1CEvxQwyafAA
zV)z*&)8h`aa{6=JChnuotSoa!ZO0MQoj=@wQg_b>iMzXeTq@!(TzMcw&v2QMrfh#8
z8KwZiNR;*Gs!EUQA=NAf$M*2yG68T2M?CQU6VBN=bqq60j-lV{%+l{K_(k%l`xR{(
zA(j$LF(5S4P?uyvUC{kFN80fVm*iJu3RdN>;$<1MFe#cD{MxdeQLq5Ki;ks4{nN?e
z;cnqOKd#RA{&rW68p8wEDuP7-KbX_OHe(Yz?EHadfLM5Axl#{BAMNg!DGZ)fJj1O`j|
z$;KdNCrJ@NUb!p6uLTq#kDgYJlo>vGA+#Axc9y?#ZR<@&%-yMivV6RyUdcxPfA@?#
ztl~c+LA+IaB_-judf=Td;jP2j`4t(`t2&4g6_`Prt0
zIFLOwe2(OmZ|}nM5fP{q?p8O+5fO&Ajnt&(0B}fST9pR%I6^*A!5OrLnJehC*96@7
zK!P_#oRkg@#$w=tJJnBol$s61NSA;J0Pu8&Yl`L9}
zp@U;H3}1Huw!f;4b_&ud)o)Dvf}Kh0Xk{XNj`15OceJ4^601`^5N7sKCAY8R=$oJa
zn-D;;{HnjYe>qGFZ~;qS-EeugC)ST^;wSWrTx>+c%ScmsKyRGaIMbK#MD<#HVSXkG
zj1NC*ja^8HxzWKpFT1SMl7a98f{551T&Mga0B*qjq2;@%{HtHG0nKNzi!@9r$
z1(}F|03(UvwZBqc+?HS)Ec?|fmQCqd&zd35=7NRX$0*pCS$h^}h%Im;%{*52
z0~zrO2++m)?Ua{2Oll5HK|gc3XWItv1TBxwBx1R2(*TZb2@s(jZ~rs8SKzpfO8oqy
zW$5vq%(o552gyW2NhKwQk8-kqN^spQzX3p!nw?3jKiA-r0Eo}$P;O?sAqE;JlNRxK
z7X~}4^2|RdYpQGhsYM>Bj&52lw*j~HiqOXV^g9USb0rqiPD
z@m*XtCc;BA{#?s-t?1&Bif8;dJ%tt4{7hwOZs7Z4H
zIFlDu|Jrem@LCs{QRTSf`&PjR^^jKkxj%4NanEJVS}lvSG=Xww(W^|+2Ow662$`RU
zd--VuU$}Ci?-vKJ$$4e_HgwO#ZrJrWzI9_5VqL|^uRgmk`0_U@xWFvAZ2Bo0MJ6qW
zFzK5sftKlq-3lO|Dxu_OqbSq{-|l~T#a`)gKk203V;pf2XOdcW%%emN2@bT96GHy<
zsI4ha=p;yj-0RBtH;E-2!r*j*dhULZ-IAeIp?j;m;Ig1=VJK(Iot|VHOMq|fMBy6v
z5Ak<;%h8iMgkQ?){q0hXJ-=58f969pNZyQxwjRUn&*d@{D>$E=K2$~po249BtO)@4
zx{j0$RX;3@d^!ymctrq|zif_A1kqLsqZxyje(5pwgTc*~`tGRg(@l>?7W|QgVOEki
zOHPk#FQXb#KUr^kS7c2?OT(GfiLd#^V{_
z$i{O6hycE!bzLc%ZWQ`sybcA_v1F|54_h7|cnr25+^7U2sl9S<)tzk
zQ8Kd$1PB&Cu%rL33TWMG3+g!TB7s)!b-deX+iRYDCGhgqa#zVE*3-s=d?qwq?1T(j
ze4ux*d|m(hJP;1FSVRO$-JJZ(mlcc(!8rhve|ZP*!k;wXw50UA*mp|r&+n6X3#6QM
zR(J_58vM%c(ZmqGs3DG@hkux(r+`X&vRS)OO}f?5((8WP=QqqrZ9|u(YiA>4TWp>qx1@5g%(K&CpOr-?3SWQzkOsIJj;U6Lv?
zG&uOA^D8-c?VV(oy-74D#agejAQD=leP{m{{)a2ThwwFj678ff&mJ)&*Qm(beS8ZvrbpIjv
zWM*dK{8Hi`3F~X>AEi=0n+|_FHS33Hl!h4
zHG598IfZTQXcYx?aKnm`PsbY)-ZwprXg~$T^4CS!;Ca8pCtcOdel=XiOnRoZiAEtr
zn=h1deKm9;aaXAaMA5b>`x%(kjI)?Q&@v8adp4#ZAR^Zp+2wC5j%ODsUG{|lmG3oQ
z=PQOX*ekI&nfM^#JHDS$9}JQ~rAV0zN9(I!kMtHj-Y)Vh>qJ?0n@zBiHox{zd4K;m
z62ts&(XtUvKdXJ?n<-9wn;dS8@J@Unsb6~HiHbW}Q*
z={eZokc{kY^tUGttOU!Q>)9(0(+yY$4AoVUE8fb6u|^7E4Gomez)udc^(T?s%!DWV
z`*ANnITu~LFL<{9j&jO!f;WyjsYk)y+ud{VTT~u(>AbsyC0Uu>o}$Efd;&572}oak
ztM=DgJKt%v?uW%RvPCz~Sw^Z+!~25eysyLgvT5XRz`hY7)q=zsWs+S}c#iFFx?0F_
z4fH)fJ{R;wf&>=NCNjk>T~|-=Pa9Q^&<@7-RmC
zI?rU^`JL6+EHp^$SJy1=Z7ywSihC$#aLJbDO96(RXhpQF{6#kT<-(;Pg(_@Nwar`O
zZfRiPU~T=y4G9JMv?tGyvS=vIPm^O?)o7sQ6Mhj)cPQWD#kZEOm3{5d`{|Jx*~l6^
zN+xoNz{Tge7~wPs$u4@EXD1vvOK(txJ9#NgT|`saeiWf_avD$}$~UDc4f_IwFuQsR
zo*ab?Zm&x1MG8L@utcM`@(6vrV3y8aS!nw`qcJI6^p&hU@Pzo1ZCpz9Vx3ew&j%i9&tL`Pn8V(hk
z5mU^Im-n(M3!Xc^AQ9nqY~Z`9iIHi8#{rktYYXrY2r`Ak`pYu8BzRUg-XI0soJ%JX$3AhyK)EVrrU*3n=(hBZZC^m|{*&AHZW
z^WJEERTnY)rsyw+e(B$@d%|A3cc`U{NIfLBWAt#Mbv&iUFUu}I)AH5bmUo4&}LE~RFpJT-1G
zK0J7kpOBQ7a`REkr*j`93?A-FiB^3*5Q2JyYn+)7Azo@wg+3`NvesFWdco_@JLPJ?
zI63mWh^qrB&AC_h;_e*l!A4M?30%K?rbf!jy4k>mcBL%3d@!x9tjue!RZtO4+4C5G
zrTV_#zp!YA@oipZ7;b04r0g9;W@dlmVf%1is_dS7xI(oCp_M;gjy>2wCixv59i1+G
zL07L+t5C6q);5Yai4@bLuHt4dEyf`$v9Gr#U}b8YOBXq12N$~impgR+5NZT5%OIo)
zL)b3@?D&>U^s9RJ12ZlP3KTm}Yn**2Lpf|6{px7*wyrVC$7FWOx1N>U#o*@-u9%mG
zTdkn8-Gc&Q^0ipQqL}mz*_m#6-zl`%lkcT}tt;FMoM}v%$eiF$29rV+7$g(BR#(PX
z4@QEZ$0`zl?>w@;@Wtokqc1Ja<5IueTRKb9;2LE;9ZC*+cH4##%lY$jg(G*4qoT;zqt2Ey*zT+#i`>8TheygR2T?DlQ2vLb!krG4u
z!`R*?ZAq?xEs1jOdgR6+sbE;oGogqKi`UhCS&dbTc?S
zB7^ox$(N1v7Pn1EoF9-BvfBfMd5TpdpZ9(+an*FrqD?lPHlAD8PX#p!L^6TxY~Te?uIhoT|8r*JU46Azdj&k>aZCKY!!V_6eH
z6{N}uY6Y!H3!Cwd{c#6-VlH5@Vs#`dF0$LhHL7*D*gM;M$y>Vc%F>Nk1Q*A
zJrsXR|6X!ZG@lp&nnDt30UDMTN>MPOt-QA0FKIVSYJ}0$>@x2&NY=F6N!(hQ9e~
z{vZV{vo(n6F-Om+$OtGJxelN%_x7v=prEZP7@Gu#`*jMOX)o5jbeE$ROP~~17qp4{
z$hk7zy%P4!qJGC2mysps+9i-@Ay66~}ysJ79`QjF;I!MVDlv
z_C8${g$0#@A>1VvgXt%DROE<4d-PL!r0v0Y>W(Xd#!R0mKIu2P**1=*J;A>@Mi-m!
z*PbCH*x7ZzCqjU_SE*q|z4rx*1c<&yTkElX0Xi)zM3{X3X%aN9D$%OWRY1||;g*mc
z?Al{Ca>)f$m#RKw)EMtp8QGkEtB|c8`i{tJmC&*sTmP3F*qksMlrw&%n)C#2M++BM
zP13Ij`D$VRNX?H?H+2(DxxX&*RruNU3rvAPk5TMeOEYVBg{v0u4T;U4X%P#XcV04Q
z8@#bu(JURygUyF<&^wI6AHGYCn(Ww6Jbh{9*;r=Ds2%^?=TA@yqf-9}ex}y=z*Am=
zO_ITXk$-#YR>R1^KRF?ugw6?q&&1?gCit$7ehQf-Y7$WVnfl>Wp`gX}WzFEps)Ss{
z&Bgi!$wNU~Pl#~Nh+vFx*dpXqVD7TB$bkNPgx_CmvgGmAjx2o?eSTb0Gz$C}?W|9z
zevg#WKnCl2rMhRN@9B9c{tE;SXBY}s_B=uSBpfK-+Q<7L>}hev6MH4rsV{?39~_^B
z4bNimRMtT*4Ve(Yn^64C4V0Ya^wxl^fu-RE4?rEK#jD;G22!M9T?nhj^!7fDWk
z9llsb*>->{wUx|%##7o-OdZG_hT0OOd6b)i@qv(wb_k7`V12{2vdexC#HNmiHsw7ezq>($z0sPLDPU5zD1?wm
zFbdE_YZm*j`0$`C%fKJQQCeOfai)ioz~mxsmAnq(;RkJ}M^d+Z3f76?t+_#a9eI?^
zLLwf#n*T9Z)kCN8^Q0Q~JF-E;Kh;@tv1BREn?ff8_C!(A)NYS|YY@+&nPM^|+ne4n
zv`x6?p3*S64FA9e0_7Ak7RT7&-`{1gc+6nIA4=1=h_-n}bz;~Sf;BOf9DpMqiM}NQ
zM>~Fe_;43hz9R05E-U9HgKW6kQIW2l`5?k?!)5e$D<5gj{pkMElIh3QM>i#X_E^f9
zUH*)%wHyru5M7CRnSK603<6-N=<01ODuZw7i{0OmNk}v>djiBF32;_Az389@EjL!F
zS4l*(Irfrk^%eTzp?~~*&O}|1uJQ%)(pSCC=kpNz#Qd2V=L^Z#i|TkTyRtiN9ZEg5
zF4p*8v2=uBr9PPXxJ-SSGZN(A3I`BMq#TIu`Fk+Vzg9$G%|XEn*1a)#f`O+|b`zGZ
z%`fV=2a3@;sWjhk^!4P%o@Jp{rUWOEZ)APGSzgt8pOf@PVhnku-Kw6EGq{SPfOe&q
z{=@~7TkSr{!%|v1_qJDHT-CayY=cz#H0q(huY6<3T!}%L=bL?&6cA8W_atqCu$fXI
zjZi>b6rxvs!-*!eNFgT?>71>%kxFPNwJNZ**C+YVf{9;|Xv?%*Bht(51ZGeRf_|6f
zjI1L}^ROsu54vvdzcyQNLnDYfVGkZGfuF|IHUvC_?#8DrHfmy)ebTXL;4HcXNZBvhKIU2lER}
zy^%L@2(?Fu#Zx)DheLV!s8VOl%SI%QO1Yj}^F6nt&yUQ?gSDXmuG%;Ddy|LMtW3cl
z`Sr6cjdK`#q;-92`9HLll0?UUm>NEj{fjU^Y6OFVTFgXAn;e
zU=aOrJ_%e1gtAnb+Y{4Ic*d)}bTI@}G
zy-ywC(&f13{SjW$@t)YEXBUwpAyZG=az$o>+&QTIYPWi~!5Z}Q;OKYpHyfox`0TON
z8`sIn2`5oKU-48}h`}hd6ObM4=yPn>Xf7cDmvfBxu7#E%V+Fo}F(ueMXA4DLvH!WZ
zjan-(c#EgyzjQu@EkHchC?K%xv((h=a2(4mxno?^pOMI{iozl>431WIqVNCJXk-bC
zw!I5@KGU6xUwDRDWUNT(z+2>DkC%=EaHsprIR{EfEDl&O?lPcO
z@mzeOfpG$Rg6-5;1zP~={pDByPx12aFXffudklZ%t
z$X*%p!Hvm~SkV2Tks++9{D}AB^ya!u03xj1@pq^=wcSA-(aQ
zcUjI|;ifo+A{1IS1PzK#uXcKpfm;^{EeMmd?KcmOSVU}Clr#~&8)3vAq(;LaI`L{|4MlQCN&z(}
z){!1+ReQVYQ*|&x+Qs_owN=}6xOLX=Aw_hT=2GUVGO>mUb446wu_SYvK^1na!f*?eDdQOIxxV_>SXgU_ykt|d`mnCsmyp_L>qp?u0$gTlEboyFl
zq}&=Q1$F?=zR!3gNS;iR`tNtc%S}x$XUe1qq)1p@8PY?k!ME4riSZNoS!yzr`aD(j
z9Y)pa!s=q~;?`frILw33J{AP44_WrVVO;?(qmoj|0f;UFLJe#+&@4AFid;s4%WVd&
zNN-`mXS*pCEsUS(H%WU?R7Lw6H=@REstky}2f+#`T5V#6t)EN=L+IU@M`iV%JhxLb
z{>p>ZlY~%kf?u|h1RdZ;-94C+&j!Ztk+qyuUX?y0oHxI5UQUr%g
z4GqP7Nvzwv_GjKGSXAP?@SrEFrNBm$7B!2T5y
zz&%JQL6KHtZ!fXNQp`zs*~4Gd&cQJiv`0;jT52Y^z-|GIP%0sZRB
zWR1G4^<;(B!>rTJ^6^G{*l7@H3aP=p9x-w1pW1B8U-V(w*M3!Ut_&b=Rv~)|-c19m
z;=7oHC@HAwA;yX285sZZv95jUbuCL$zoC2p{Z;s}o4=ov)~C(Djx0_>c~+W>jPO`l
zg-E^UGo`EBL%DVNJbFkx9SAhLZQARcmq)cRr^*GF+gg`6gnx)m8gk=(m%N2`KCSZ|
zDgF2FSl^lxy?1#z65mlPV7xYCuWBt4+yhe@1|d93R-5muzbX6qUVam*2%)z;
zKUwRNHrMLj@}iM-?seH3m4cRRq9zpi<_VGr`D0|r2@HyS5}Sb|6YzwQX3gNE{p45O
zTz)YBtx9}LqV$)7I7=RVb9`RIy$2pjL@b3XFm&Y*v8NW|;+9wus{Sffyjc&0#&1UW
z{iQZW$>&xPDTW@l0Iay?J00kVM6!d0QSPy4XzNf4S`Q@|+Kg~_wb+V${<*j@U}35r
zhVx4;D<@DL-*7mik^Jpj0(lTMYhUdiwOc87hQhs7V;q4yDh{v{z)yF2RF7s_Y3n{p
zK5r-sevP05eIt*El^w|Z&UDK~goh#?7weNMwpJ|WX)^1IFr1zr;!S|l7S`ldZyZh8
z!?UgDye^Z-$i~Zt!K5`>>`_|A3p4}XTw-Rml)RT9viVD8%kZt#bxMMSaRzsYa4AKg
zia=`MQT7(9b$&~flcvXUSLe1kLkL$Iy30=5?GE?%8|K4iHJK7F>MX^Y)Gt!oPfqU*
zo7g3Mwr>AW(yf-0(|h7=OD88}vL-Rt5(+#8B>pXhAavw0}!|g!PRxb3eBa@#atJig^oARKs$6S(?~r
zx8!~ZYwc<&H^0z}p7?HU
zdoDYlw~7Z*QDYee?Bxuu1gC^;x)<%Gu)Ku-V8>y*V>N_x2)M%>(!T4nH_
z&KFTrl86qkp^Cykw4}|xWPp1a9%V%6%sTaP6AD=RUJMp|-?D5)5R>Dk`W&-qZ)mqa
znvV9M{JA&ZW|Dh1hSmKWJR-lv*!_jQN=%Ou#nyzid>@HXOk5OnRl$rl=qnwNJMHoj
zGRQ`Y#GL*~f=L8B4-G#bq?~15OOPGRgzWh1?VW%pq@KA3c>X)%xRlR30cL=~ys-M~
zgFKCEK~{_*e(8xf9PLX{Ybm@CDuj8+n%BKe7wQR_7NeyThUwtNl4D{3P11(}+3i~OA4HCa}QObxbq2>V_K`Q5ShE&@bDMc?8WttU_wH%Pe^&&f{~}+OzPC>cjl&eR8%nVr+5pi
zJBb_U+lh>fOBE&WC4;5>F1?zGb)N7fi?iJS^X+^{n(#=vI%tM9G`TgTZAuGZ*jyG
zO8w8ur_txOb3r}?=}5Y>OH7TK&JA>l&Nh3u4`#kfULTz1v%tCVx$EMsfnKvUHtm)D
zM@s%~UG;>zGT$S>XX!oRzuUNuBd_=drANLHKkM(=
zNB$;PqF(#K{OXm5NNc!@D(5rlUp94h|9qOOHJSdhVr};YzXT~o4cURR_%*dG!^AM&
ziSZBdY3tSNVLv)x@%Lm*nBj#q#Fo@`VG0PiONS*ap8egC5zD=Hf4I+Z8oVs?zg8j8
z-5utCyQOMKNjferWi|X8@(MKH_?=ny^l*ZiT3kbE!o=40s!?f#O0}Xliilq)0iCJO
z_LY<_UaeZ@@rr&Z)>xW`%V}P*Dd@A&Oay_sbxF-fqnVuv^8}!U+~0$X9k61_WBkW{
z+rCdMq9`625q?;qsKF2IW*)CxY)1dz4ryRqAjEw)Ui@Y|%^QDOcFrahjS
z&AFf6&J06lr*YMmRhWA@z<87;_1)bHIu$M0jp&v3@Y1b&X7(3~42IFA-|Ml$cg3!qNr8G@G-ih
z=H?~GW3xLvVPC!1WbF?qIjCUo>m_WlC)}^cQje3u?Vy{}EqLgK-_4p%ReWKW(VY!iB~=vRJ>-
ztXv$GWP^uCS%e_H*N+Q1?MjIskhm9i*zhLU8nLXk7=LHMLnM=&^xIY^%8vE>FVwTC
z)s>3M4l+tFn<1Tnoq#WCYx(!r*Z
zDxW0J!UDzY#nA@K$6QwNow(KV(1Ej%p>+iuoKxrkb@|atQB6hhX=J76brJ>^R=`l;*89|zsL9Oo+&6#8MO_q8F5$sHfzs*i|@1NPY1OMFwGQDAT7Cv7gCEcN*2(@=SZK~*-
z0wz9GH`xX9kX*Qb|IwTq+8A|AdIU%8|ADK))CQM}*DM`0TG()R|3^XeUp1V6e1UgD
zSR@m@HD9Hg{;XpE-Dv@3h^@_fiCxxToiWz_3_FlWmny#Q@|RDEg=_zMHz6R!qCm=R
zw^Cpy2VekkweUPEW-emBJMO06
z-0O((*xbZlZ|Agxdw<+6xAtV{2spkHq)rpT)FDn5zk=Lv+YuiwoI+Q`_Zte?8A+!K
zBkg{i-j7~8ZQnaglQ&u@{oOLtha6?X?R=BR62LAOaaoYSIYLvHg3hc1)|hzcAmF9l
zNHXmm3;WVzcZ!;f)HCYuFWvDQ+y#ICcZbV08q2-bni&a&yvh(iS2wxQ{zx|4a;eX0
zuy{f$99{!c_gwTiZeS)M^XL8YK
zClRUnh4y>Wc!~QH40l+L++TNOZE1o(R10B{21fpA2}0G}X$zU~-H9ORnpV;mbc13#
z2Y$`t;&^`Z%+iT>+St6t2rvB
z=s5i?7dyv$A3WDS1w0M?m&K_
zO6KO8J&3Xb)RV6DY|4Jol9a&mow=n$D?DwpWJ@wPU%8#$B-e&c`?!*eoA}k2tK+c8
zJc2~wrT}Y$gU-u#pKDn;ES2k{CV2ohjORL*N(xwsz!f5r_g
zCNb!!FNWT>Fi&}fvDxS3cJYwKL;=n%LuLsf%SrXF|7m-KB;3!u_%P
z#faYp@ed=<8N;@mQw*lO*&?oryWWD=4-`@r7@P_2V%D;uD&Vxe;DGb`vPKs!vo8~$
zq%r=|T2I*Uqw(;?$bbKWEm)5Ta=yAtI
z^z(0QhW$tB6Tw=wkpJ;UXeb@%OXuzOOgRT!F1BrJr)3~d&$5)~x#!jMrB~@sBQt5)
z&J|BIIq7=^M&+~#ExpnJ4b2w3##LI~FK#tjo$M{zYZ8$qJqTZM&*M-I*yXg?&eio#+?!xA9whuAmyxQ4X|`ph(>Rgl6YD5f%PkoUvU;l`Ado
zR;qqLFBG0j!eu&IDDk}7t;G4q#LS?h79wW8#-ET<+XC-pkh8E?AI>>v_-C2&_2t%)
z{q>KZIoMp6Fv}`zOYL_br5E9WJG^=H;?6JhY&?oeWR1@vAye!P&4u>iH3E)&C_)P{
zN)%ns40Rk*&*H6DgeKD(>on>ib)uk0Y-#T3T|?o(_1`&mVyap
zG$*OzTjpczZhK5s;A6r?Fr=6Tx*PguFu^H*%TmA;<3LR=ggx@BS$eSkrhbkop(N+x
z(F!hnqzYrdr}j~cz~K+u1FVZiHT(ZV{9Ym;`H_qd{&yGXuwea%hdROg4l`-7zwG$=
zffIh9nhWG*&5}Tzj{F^+N7GJel-c0QzF$y(jmOd4I7tL9HnRg8k92+z|g%{?FBKoRXhz4qtx^
z;W6(Hnau>j_B2bf;jPV}j>X8wjX#fU%1_g(7FG-O6T?d2(Z1xvGW3FXI
zXU5i*EXbG(radP{-8W=H_I1u-1GbksV!^#`s1VJbnSOCc@2`0@_ajK6DwsZhi5_o)
zbQHQ&e^nFO5v6=Gwot)-;*w%=vD_}-wLlPqPB%kXY&_Qa2`;n_z&vD#>8U>?YcR8a
z4RI5LoIe@T_Z-zIkL^D7*v_xR++H2U3U3PlY-Ct`Q
zTfa*D%X)=|vE`4gkL%HhFKOt6)w6SWnKw}-7-1xXa`*d~lw}ulzabTk-uSn--0q#i
z-vw@cHbceGb48&Ak!$q))H5%wL)9q3k4X>=eM`dzzQcJHWwlPqgxc@M&c`RK^+ov5
z$Zj;%foLDIN$rzHv3QTwh4+Dkf_#1>W$7WRMHThn}Rp1^rN5FXH
zAgo`-4RWfh4qodxD7ZLe*QvY%JdQfO9Q3n7Ttn+=I|dW+w&fA8jNiZ0wbZo{CS%t@
z85V``a4Ghpb1~VO2S+#2kD%5CB|yQd&qb~2%p>J$Z6rfr^lmzH_$_|%&y}n{5%&li
zGntatn}4+9)94zklgcC*6;AfKGKjxqfBE=Z(u$vRsR7aJLNTb*?SQ4!H<)b(&Mr+p
zU)Y!er6{!^HzYc`yTs8*{C>j&Kqs_cke2V6TTi!==Q=MYU0!sGm$=C)P|xfo8>{Ly
zRX^Ri!R21(ZD0X1MPCaxid=-6Or}N8S@Z?`)QY2l)czdvW4lKUZ2ILxZfrr7ga?+7
zuTG6256xTGf|6pZqhU3opNh&5qmYRA%A791D|HfN4SFm*@@Kzs{
z3_pUrmqY|NY)-uZ@fZ2l%v9zBY0w0D{pdJ$=lZ?(T=8U0zp?4(U#_9uEvpmiO+pw~
zJSa)(TOIQnz1Z9-Is~MU^5-J!PwYjVk~;83ajf&5Q3t`{u^AJ5Q=Wh~ZFL6f0P0L$q6jd;l_rj!l(bEkFN{psAqoNY>D%`oLlww=cjR75=f
z&k6L%5Z6DP#N%}XR9NmI$Bn#TRU&Da2O%jxU5n(`r+~Y72>Km7hE|mOq4512XP$s?L4PfhXY%5K4a
z`Y!$ZU8J_LftC+z(e7P+SVe_}W0F5D5rsm$=5aoM)A%{RqU!}?Zq3Qy(x1SjDW#5I
zx+b%twUfpHn4D?D*`Y8pv(moU@T`keQk?GFPLGf=gHV!cB7R&fMiiWvIfb&l+P)MR+HU8Ce@k2?!
zw9JnLrSa}wMjz};igJyJ>|Ln{wV)pJ>kZTTWL|nI8RM=y>^=RU{Z9W|r14*vm{a_L
zl8+zA8HBEQa_Ne;Pv7=j<=DGdHM}QRH3N!(ih!74qRJ0JZd~hK8%YBe4ZAW$5tI#l
z718Yj>fp=2>cP4$pK@%GRb961beS`($DU3yZ_uMax^ly?pYCA&xhi#yvVHz2OLH%E
z%(P*Gn{5+3yhK=M614$oyNx>p)qB0RyXJO
z9SPn{`lS0^frMpG6J_D=EHMXKT?#C&Sb7SgQHM?uO}X~xU=2`2fb$z${I8`FuwRlq
z%E~I*E^fNvVG=Lda?}$L5H*8ZmBZ5WsYhlU&`3Vx@eaPVgBry10FdKGxx7QPNuRx3
z8ZCE}j=A7Fe5UXp3JOuQ%G?;a(7pGk_~>Jx>ZG9isD;1zJ)Md@R?}?QYZNaN6@V^#
zOUVwnb$5SYf4@{xw!bC?eT0TvnZ!NL9v!`rwnI+q^hvfsL);0vSlAcRq$vI!QT!E#
zY2#Pym5Q3ivu+*@MB^>d^{10egGnmP={
z9Vy=)kB=;1taR<`O_lnT+1LV6d$r&+FD?hR52nA@3xTT
z<8Z9~Ps~Lwx9Gkj)k@wYh*z#3(j==9psxMSQ*yV$5m?0?Y-qk{X~~lk$S72{l5OgT
zT(2Uc?vKNolFV9aysnUNTJr70BHr^K91Sp4noWOv>;hvk>c;<*XRiL*YXgF1yEeA03e0XO6t}(3OY;F-LpRW)
z@Tph&3mpiJTLstb3911=Z4wii8{r8oSnMw52SjT&R-~&G1;WGm?IkNMlJRgvTNNjA
zI$vx##S8aHo$f=Vn=r>VK&)^Qi55*mC~KAHTgfmJKatmqMB*FS5P9%LYp^G>%Scwdwb>;+(JzbeFzlveFUIJk9hWS_Ifov%{6TpRB>lzr{!@R%a=>2S1k{hpQ%AKD5h
z_b=r_p;)7f&EofqjBgy2UwgJoza9)@LdsVsVZdC#f#BY5Lw^v+*uqFEqq1#I5#_LM
z4CI`BF=_co9S{f+o{vc@%5Bzo`1HA=SPJnS$vCvEOA661u{!8=2&1<;E*r~{Y~NP*
zvQ}zTDJG?1vCv+G#8xSsB>5#l)10q@Hy!R0ONL3tYM>Q@oavO9ps9&5fmZx7_M|;4
zXMa(UH%*2ry2odkX
zpq>=6e|_T{!f*$U*oc=W5%&y~Q6n?f&{FW0Wp$=^-jRKUx#o{jB4byIuK_k;tA%IrHOMAZ%BU!g5F`
zLJx~6|K5HLICc=npKm+lt<>vm5;6?*FD}Y3VO{1%;
zNxo<(+GpSa!b5+igfr9!d?5@JgqrX0C93SGhG{-B*}6nl3g&FCdczm}EfXjRz&3j1JH1EbW6<9u<(q7?oA%YvEnbX_nD7o{pb)1!U;*BJDi}E
zVTz+bqb4)J8nJ$ykUjAE@D8rU(l7(20N^Xw;1_fNaA}~c`*XBWP(I#3=&9kQsig;a
z62(q~d2&4z6uP(xo{eJ1VNVTeXoC*WFP#}Ljwx4n2;&TZmnw9gpJjKZn{(ado6K^V
zFIgSDAr)n_ovj;5ox+NyC|ynaUi$Gi=#vu}n)z7SCy~yIY0n}OY`Y&=9zFB~y)k+G
z$U+*TB^X;n5horsD?@*dJLRT3VmpN_uaudld&5}7jEo*FPqPeZ-pt3qSwVb#)+8nA
z4X8dQeG>7o&d=t55q1jyZvyBM4G240NxL1`V52Xwt?=TQV7dy48#xn6lmlWMLcDPP
zTU`fkn0H;JU;BdmUJ-koCT~fxdV+IGD7&G^@))}fK7*c*^;1!u9P<}*KP3J4C(!vU
z+fZATT)2*2f24mf(F$YmHQ)&hD~|XQ9K7ISi7x0M_VkB20{&h^Yyu;x$xeVw!1bc{
zno3ZpOlhCN+a;ap)%4R0mq8MwV?oZHH|oqM>+9K7;p$y
z#cMw;ooR+F^O-CWKa=T`%8sMkACmk;M7m+6RRc%xneNc9`U*b}WPe00iQSk|Wr_b5
z!&FIMXLKwPx!5KMOI9VlXG59tw!X15%bE~j=t{q!?w$R?@#?9s$Lp>hfShZ%uy=zF
zT|4>idE@wrYPQfeZ%)Ck`R^B7p$mk?ffJQ40dW3r6o!!rC^7=t54r8#Vfa$%JuYqTZ1Iagcj0c!;yi}S0q
z(hWat_Kqz*i`MRO8L5YxGwZ7bXlAF|W?fbPkZZd*E0x4Uy1+dqaEWL#bxYejYNEoo
z%lfiM=Xt-`<7G8EgPnE1CYo@`kN?D8gzgb~9S&DWhuO!rhY%$ytZD_f(JEPbzt?+L-3gu^bW7o^nD-tx0EYF8
zu)r6pkR5+bz%j4XP*K_M7Orz~fW@p#!I0fX;NaJ=wB_Vha1XXA0^3Fnd6J>W0X(9*
z=GAxFul-@<_=w{z=R4&ndM?`+-4OZ-S#OJ>xlnyUQi$%sWu=%Zsragkm2tBczUTv$ninytF3q{;pDFzM4u$~PhR
z`qu?2WXRD&w{HER*~vZaQ)?>Ql^+<{oZ}W3&Ytxgj9%U6U-uK1{fuj7jx~cyT?A95
zo(tNOrPg!_5qT|K1m~JTjt4B{APpFsJVZC5_w3nS~3sh0`B;g;)qvA}bQ7Ii+YPt=t}gcKlRNd$oPi${ef-+?w^>N?5g
zni0qU!EE}M=^q@rLx%Xi7md~#=Zofg5;0-tsMpk5J@M{Z^B}B+`gD9JVWNsyYOA>^
z<@6UpqruU(>7TFaPCO!BP?}4Q(oo0UX|?+0c9*J#hCNR~kQgF!ojg#b%C2AZFXraICb~?)&p=aM{#S7XywQJj!MIf4N2?>S3>KUwI9m>yexP
zHtU42pN&_Ro%K`tqt-6AOGL+*KgFhKF-?56x`igSo$bXm&Hipup*OjKREh>iUB#v>+Ci3J)SpZ;1;XL^fC9l<>KNGm^F}!6r`P`b
zs0axd{p2sy>v@9##Mkfr)|*WETP7kPEG+FNzYCcujakY{v(y?<`eh!t5vVFq)*B@c
z-`F4cA6{J3qXBDY{~tI*Ae!CW@sY&wMhC5jb_^ojXIp3;_(Xb1^`Q*DyI2HjkVN{a
z6fDNXEDM#dS$UhAt!zBc{RZ}k-CveD2UHHWSUC(37c$7rej?bM*L3Zw!eYT_R-dnY
z^oI!_Z0h{PjP-r-W$KLNpCJ*qZ}TB-Vfg0~GvolRvzMetaT81FHYvav6WTj$mCK9J
z$~*v6-6K@jP6&&fYk>mR3w4R3@Qr!i{JxM!cZ@9UAf+(Qtfo$|^xlLcqD0Kqg02)R
zU`u@qzjKN`TY0~wgZr%`J(;Wq0k1fZVKyg1W%$Qe1YvurLvC2quaAq}AzH!x#W@NH
zNQKel6++V)AZu~h2KJPaeXZ&z_7Am%`LHv$g5clzccoIQtCAo|4O_QQ{0khCn={Xr
z{N4xo*N|82f>s{ybvJ0zsRCH4gSzEvp5=%jP7XXlUth31kQc3QWe4j0{fDB61QL?)
zP_pT#r`CNCMYIeW!}FAX6X9P;;eHt!)>xDM2J%WZWD_7TGFjJLmvgrcxpzk4qf{=e>2atRAtqLjq1t>sFE;(>sm
zczbVq%a8M=6$8L(!*aBSQV)qT^O;B4(=p*O{c^>!rm~oVv^=h_L?tysL)f?R$QX5O%qjtd&%%Nu6hbRC)rSbU9i*mt_$*ar-e|U~*V2u-_Dc|mm*v4g#SSSm
z8!fQFNoQwHQ7q0I4KM-HxN@$*J6nJQHD94iLV&>0rl~U*BL!WCHnDfD)*dLh7ECa+
zJ`K}WQl282ky|{Q$iHimBtqhIEYiYt8TMha+~N-B4lwgEZNNOtq~s%eJQbxEuR`41
z=DBTW_#D^#VXX{j0zEThJQ}^QP|KnqU(d>~g6#D{-Gq_t&c`5Qmi1#bznYxICn&3p
zP_+Ex!{c(U2z
zObSv56+Y(OyVsi@_RGHZaA2F?eIh9S>U@z`p-*r4R4JFgVF_hFKIu>Yk&VasA?d=*
zp$jg;D#j49=ER00gIw(n51=P&M4RV15qyq-l}16F8NjAq_Z1G#yTwnzESBf4ywhz_
zAs{3os2&<02XOKcVe>zp1W;F>%VDGTJAap2%bQQ2nD}8B76D$phpU?R|i;a%*%pf>LjDpJ5
zP+o_&C>AqV5oMKC2K3zIXhqT(hI9alg8U*852&;Q
z-(RJ!tGnQ*spAGWcm73Cw3q$0JQRA)_1J;u7>v=?!
zB}tbq?DB9k)|#Nbl1MC6XUgXnkKuoUQcET0<9+r*)9Gv5{U{W0d^b16Z(PdnNY@Gt
zu*KNBdnp6>Dv5ENhp~jL$l5>oHa!_VnU792IaoVht|=4v3jNoZ-8v%$-M4%@bA%?6
zj`Zamn!h?C2hN2m!|WnNoWeriNPglBpE~Hx5=jZ8D>}(~Eq*wrV$GAkWNa|qOx?j3
zL`oY3vt0LPqZ-zMx;OHs4*}CG7x%h(XSJk_)+jVoLR$_(Fr!&d-$fqub-DBt@U@M=5u
z*}|k#5yH9n)jA$e85da*%gi7>KBEAY0hqne10S)iyq_y+rX8%0E8061ucP<)%GJ`v
z)__S_VvS}SMJz9yZ+4z{=@m=9#y`hwM63~O-bU7|k7)S#hCqi`7$9cw3T9hs`H-57
zLL(b2?RT_wR)F9tx9zZ`j@QyECQ@>BkoqOqSJ&07YdjY_1_Rso1m^W2*3{Z)B&n}(
zcQIvX`+m(UGwk7SJw8bN@)+`ISwHL@jSg4I`J)<5Iuv#sNI%91a+`L%=D)@xDbC`I
z=KOqh?!v@N3xQ10fdEstbN;>*^bksdFRiFhJ_qkH1NdC-tdD3T`+F6S!MB>?g(fYz5J=YWvqb
z?`kux(20&0WDW%9#bkxDT!}&!(*Xl|8Vbuxa;qu?S(~h?QtM`s99PTyv|1#p(Zo&D
zO(fm*4pEthr%tPlwZO!T2q=VDjSU-5*ADd}Q{O}VJl6_TDHcuhA(V@KHI7rT!Aw{O
zK4wprj%adkk@vnM>I=dm;_}SbkoJF=(r~Ebbpdcu0b=QZZGhrmq5zzI*fIg??mF-_{ye`w=48N!kNxM=(oo%-fWOXq4leF
z#kr~3h*mSlw&+~@9;c3wIsm4i^dSFmOa%D-Fm8@#)O1mXn+{$2jZ>mZ`3KCNKFvy$
z5)$ENw)XP6+k+*l(Yd?~(-J
zZ}?Vhna|Pnj{CRX+ze1>=c1Q^gptmEtzcmIh@{qB
znV_SPBXWL5-MLly)LUkc@hKEnIEI2ujH0zQhV#)jiLKw@$JFqgXDU9MmAQWy!|6cD>w!zG!gyKC%Z-WrWBAj3n*hJY`qNy
zxOX|J1J(0I{>S6a^!ch}{UdxtDLsyb$R-}rCnB8Ko`0RsX>
zr1JlQ2}CrgI(@>4I%q;5^ZB$j0Hbg?LvZE^b8n_ts$A%F{f2FS2Mc&e-!vbk_KX
znzsDvus4cfD||?Kox#Z3Q3v@x*L^QeV+YD1w)rO7ng9nK3AH)MEAxY5eFdxynaWmA
zSZd0ri@1@@7*&nQzvzr?z-5htoxH(+8sxvgq8Pp-05)+7BH|FTgy6}dMj#5eYdVaT
zbJn*Dx0W+H*;?Kf-&hxrei`5h-zrt5gYN7VLDR+w;EpD
z9dh=rZ)6qVOc8*E;CmC0pE)Cz8QfTxnDIEGybX6OXuI}rb|sp0g17#YIcvJzK^diQ783i&3Noyh2Q{+lKU~<+;GMf
z=+0UW7mu<)xB|Z#`5WNmlA|oFn(6&zwoy#QMHT}$)25UTKuP3(cv1s??I;kY%>7dK
zkP)Ya<7*-eAx~V&CmAbI3pjJKzC+NCrtkJNwfq@@N^@mM{ukQ~B9if((aV!XK2vcU
zxV6J5VytMAvuFsk$7?&2?l0^YP)jF4QHc~H1Q>7M?(*DR0A
zCFKbZ7Uc>a$65DJSy$cHJ4J5eyWGTE
z#k~wTeQ#lNRc?7fcNjR4_ENej6!#|fK%uUblcQmRMR78BU=fpRfwp|$E6OxHbGr&V
z-enb#U3v4@spac*k&=a^m?j;-avn{u#b@C<&58v`GqDli9@VIVPwQXr@dJ
zgVFGXi%d1f0mug%udriTXQ#G4STW+IUP2&YvbZSXJu#K}A6=`}Qk#@hi!FynwEfmS
znaBEWGImwqw9Tpc;+X)8$K^q&xuk@Zm7^}^lvL=p+D*L)54Xn*byH;Un}QS5kQ^q4
zh4lIf83$EZ7Cp81p?#`t13*i9dv(xbK;34i?uAc0sakN7?&bR20RVnttGy=3q!qt}
zYMVpfD)DCnZVJsyPt((*%$u(&WCU?->Hsv+QVxfaUZexW&7l|y*9#{N1Zi&RfnpQt
zC@;^+C+RrGclTNPcQUlND(OPl<}_o#gxPcLj{{Mdqf>w2U{{Y2^yObkhJ|c3Hp-lh`OZP;D8-5dMI>>J7
z!!sDxTfraxBh7FW{(SarzJ}*G%55q_mCtqgt`#TrV$=o+_R>)&bd@orRL=Z?tLUqen$92IPY~+L7I7U@%Qo!
zLu|DoK17l(dAtbKL4*`{_FU`D61!m9dvMprAGrT@xXz28DvD@%_*
zJA9H>Lsr4YBL_};755*NQ@{e%51>r7IKhQH{$lSD)
zdmzoVnG|Mc`j@AlUG%pvgw!1fwCjUo$om8fXmS);{vT6c8CKWQG>ZmzcXxLQ?(Xgy
z+#$HTdywE8Cs;y)1PSi$1PJc#&RyiZ=iKi<4|~=~PgQmG^pM@{p;IGHsLO}*jIJV#
z!;K3|WG2nY1-dVGtbtw!vCz49d+^*&oYjg%%yDR42j4h9lHj}N65bW4rqDwiroOwO
z@?F3I;8j;hE9QT-YDN5DVeJVC@WMv}-}a*T8XlGn?Bc7)Sa8%o7U1k+34`nHJP~|R
z8OfFTf!bC;s+{SUeVbS)F=-G3o#*ZaDKo{9t4%+xu6g$pbv3s9
zT1`CMQm}wMT`bCoB?@*dB4oChY+@q+c)Ko-VQq_G5IRBJ9RaiGyN(%sDNf;Q%%iG9-Fv7n|A5b+31dE6SnLIFm
z?~bv6+jvq`_oR*UFS^r`^$1doCcPiOF_bDn1Vw|=^yB14CiU6UGH*n<86aORGcwjxZVckk;;7FqG1JC7ikR!Hph-1#eJI`yQ;Ns2ShF|n84
zuOCNe_(o4rCvZ94jHJ}^yTeKXwNQrOAHfp=-Ri&M0tnMR1jsN`(coXg?=Kj6x|Ahn
zrW5?{@&d4#vte;D2~aCf&SnaZ>&9+~X3FmG;-1*sI|Y
zYS&0hisSyTOn%abEXFPB-jMaELBD*tW@+JyenlOa_1R%)78f=SGNp^e0CxFgq5qQ$
zrvwJU-xpR+UU&;%np-JSwtlcvh!~teXD(f_B?A7T<(me=59SZ9CbE0Phk1ZVq0-J#
zh>ViG2U}=RS`~GS1Y?%C%918vk6Of@Fp`(f?jxa1!>@*E`6ngg!1fqHwVnO1==Ar{
z!G-0COH+z^b087pa>IrTF{Br!L?}dk*>}o$UDtPKTIJ8VR#Ck>Y+H@EOhC#O!*zyHIYnTLhyIJOPKc~ZwP+l+U^L0EiA~VWIVRQ%I
z#jN=Tl_=4r%}+^zhBbD}Fk^h@iztG#Tb`o4;4qnnH#XP!A>#zfn`Rh*s9QeV@ml$^
zLmc4y!P6*X32xO5sEPYGi~06TDqcw$+ORN?u$KGdJ8Ec{%^BZH+Z+Wimp$broM=AD
z2Ho1)21RvQH#lEP(Gc_DB8597dr&&t`#X%FX5`U|+2H)8F<|_guHJ|pl
zS=XJ>>M($^>U6OrL{UyaJZdOK_3B{6A70t1mFtLotq-<*zjOIyWUAV?`)nU=)YerW
z?4b!v%}s@{n|qr<>>wtN=H@Q&^vI|10U9J6YF?xCZma)(V>&Z+0cl(5dWGTHY)5yF
z{csNV^$}03oA$Gd>0)B0^2cW;6|~OaRjHuA0PpSMr{sST#vE<1|5Z2++6-`l7Mt0q
zk(7#x68;|K22^AiBqp?8{f5^OB_!n2=&1DS=f0#$$FpHYX&}xMvN4#Q>_)*S_~mPx
zm`%wT8>>jfaS_<;qV~{Jzj{-TO7xu;o>}Ein(%3R^wqA7VRy}HVDyVZj@#jYX(T~X
zOSJU_&8KkeX#zT!X_|P??G_DpnWPLCKG9x`=1w1ao>d)h8
zr%Ovi7@Mh)h|U+4#o+!Yf7+jWbZCTMB{&Kn-Wi_{FZbqWkYSKJ9apL&?35_un)j;u
zU4KXhUJZx~6tS$Wxa(-|+?Iu7Hmav|zZFg-F$c;K_C(G#0GIpi;X&Rg@%DBW
z_-EJy7z9@dRqmrJ$%+M3k}rSl
zIsr3yVsqIVgNUMyE9PwPsF8SP9--#_`M&hHKiNS5V1!h5gGB&1JI>R0;ttEogM0>z
zAk5}^dC3w*!ry+!^e;QBBO?7|^vfw4R