From d7c130fca9ad04f912caa4c9b552229cac0b6b50 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:03:12 +0100 Subject: [PATCH 01/99] Serve SPA shell for deep frontend routes (#7145) # Description of Changes stops the /new urls crashing page on f5 --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../web/ReactRoutingController.java | 89 +++++++++++++++++++ .../web/ReactRoutingControllerTest.java | 89 +++++++++++++++++++ frontend/editor/scripts/lint/theme-lint.mjs | 6 +- 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 1e05ec17b2..7689d109fe 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -5,17 +5,26 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; +import java.util.Set; import java.util.regex.Pattern; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.RouterFunctions; +import org.springframework.web.servlet.function.ServerResponse; +import org.springframework.web.servlet.function.support.RouterFunctionMapping; import org.springframework.web.util.HtmlUtils; import org.springframework.web.util.JavaScriptUtils; @@ -32,6 +41,33 @@ public class ReactRoutingController { private static final Pattern BASE_HREF_PATTERN = Pattern.compile(""); + // First path segments owned by the backend or static assets, never SPA routes. + // Mirrors the exclusion regexes on forwardRootPaths/forwardNestedPaths below. + private static final Set NON_SPA_FIRST_SEGMENTS = + Set.of( + "api", + "static", + "pipeline", + "pdfjs", + "pdfjs-legacy", + "pdfium", + "vendor", + "fonts", + "images", + "css", + "js", + "assets", + "locales", + "modern-logo", + "classic-logo", + "Login", + "og_images", + "samples"); + + // After the annotated controllers (order 0), before the resource chain + // (LOWEST_PRECEDENCE - 1). + private static final int SPA_FALLBACK_ORDER = Ordered.LOWEST_PRECEDENCE - 2; + @Value("${server.servlet.context-path:/}") private String contextPath; @@ -256,6 +292,59 @@ public class ReactRoutingController { return serveIndexHtml(request); } + // The regex mappings above only cover 1- and 2-segment paths (Spring path variables cannot + // span '/'), so deep SPA links like /processor/pipelines/new 404d on direct navigation. + // + // Registered as its own mapping rather than exposed as a bare RouterFunction @Bean: + // Spring's own RouterFunctionMapping is ordered -1, ahead of the annotated controllers at + // order 0, so a plain bean would shadow every dot-free backend route the denylist below + // does not name (/v1/api-docs, /error, /actuator, ...). LOWEST_PRECEDENCE - 2 puts it after + // the controllers and before the resource chain (LOWEST_PRECEDENCE - 1), which is the only + // position where a catch-all fallback is safe. + @Bean + public RouterFunctionMapping spaDeepLinkFallbackMapping() { + RouterFunction fallback = + RouterFunctions.route( + request -> { + HttpServletRequest servletRequest = request.servletRequest(); + return "GET".equals(servletRequest.getMethod()) + && isSpaFallbackRoute( + stripContextPath( + servletRequest.getContextPath(), + servletRequest.getRequestURI())); + }, + request -> + ServerResponse.ok() + .cacheControl(CacheControl.noCache().mustRevalidate()) + .contentType(MediaType.TEXT_HTML) + .body(serveIndexHtml(request.servletRequest()).getBody())); + RouterFunctionMapping mapping = new RouterFunctionMapping(fallback); + mapping.setOrder(SPA_FALLBACK_ORDER); + mapping.setMessageConverters( + List.of(new StringHttpMessageConverter(StandardCharsets.UTF_8))); + return mapping; + } + + // Dot-free paths only, so requests for real files still fall through to the resource + // handlers. This is a denylist, so it is only safe because the mapping above runs after + // the annotated controllers - see spaDeepLinkFallbackMapping. + static boolean isSpaFallbackRoute(String path) { + if (path == null || path.isEmpty() || "/".equals(path) || path.indexOf('.') >= 0) { + return false; + } + String[] segments = (path.startsWith("/") ? path.substring(1) : path).split("/"); + return segments.length > 0 + && !segments[0].isEmpty() + && !NON_SPA_FIRST_SEGMENTS.contains(segments[0]); + } + + private static String stripContextPath(String contextPath, String uri) { + if (contextPath != null && !contextPath.isBlank() && uri.startsWith(contextPath)) { + return uri.substring(contextPath.length()); + } + return uri; + } + private String buildFallbackHtml() { String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/"; diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java index 2df3fb5587..41909e3e0a 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java @@ -4,12 +4,24 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.Ordered; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.servlet.function.EntityResponse; +import org.springframework.web.servlet.function.HandlerFunction; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.ServerRequest; +import org.springframework.web.servlet.function.ServerResponse; +import org.springframework.web.servlet.function.support.RouterFunctionMapping; +import org.springframework.web.util.ServletRequestPathUtils; import jakarta.servlet.http.HttpServletRequest; @@ -175,6 +187,83 @@ class ReactRoutingControllerTest { assertNotNull(response.getBody()); } + // --- deep-link SPA fallback (router function) --- + + @Test + void isSpaFallbackRoute_acceptsDeepSpaPaths() { + assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new")); + assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/123/runs/456")); + assertTrue(ReactRoutingController.isSpaFallbackRoute("/workflow/sign/some-token")); + assertTrue(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/new/")); + // "pipelines" must not be swallowed by the "pipeline" exclusion + assertTrue(ReactRoutingController.isSpaFallbackRoute("/pipelines")); + } + + @Test + void isSpaFallbackRoute_rejectsBackendStaticAndFilePaths() { + assertFalse(ReactRoutingController.isSpaFallbackRoute("/api/v1/some/endpoint")); + assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline")); + assertFalse(ReactRoutingController.isSpaFallbackRoute("/pipeline/anything")); + assertFalse(ReactRoutingController.isSpaFallbackRoute("/assets/deep/path")); + assertFalse(ReactRoutingController.isSpaFallbackRoute("/processor/pipelines/file.js")); + assertFalse(ReactRoutingController.isSpaFallbackRoute("/branding/sub/logo.png")); + assertFalse(ReactRoutingController.isSpaFallbackRoute("/")); + assertFalse(ReactRoutingController.isSpaFallbackRoute("")); + assertFalse(ReactRoutingController.isSpaFallbackRoute(null)); + } + + @Test + void spaDeepLinkFallback_servesIndexForDeepRoute() throws Exception { + controller.init(); + RouterFunction router = routerOf(controller.spaDeepLinkFallbackMapping()); + + ServerRequest deepRequest = serverRequest("GET", "/processor/pipelines/new"); + Optional> handler = router.route(deepRequest); + assertTrue(handler.isPresent()); + + ServerResponse response = handler.get().handle(deepRequest); + assertEquals(HttpStatus.OK, response.statusCode()); + assertInstanceOf(EntityResponse.class, response); + Object body = ((EntityResponse) response).entity(); + assertTrue(body.toString().contains("Stirling PDF")); + } + + @Test + void spaDeepLinkFallback_ignoresApiFilesAndNonGet() { + controller.init(); + RouterFunction router = routerOf(controller.spaDeepLinkFallbackMapping()); + + assertTrue(router.route(serverRequest("GET", "/api/v1/policies/run")).isEmpty()); + assertTrue(router.route(serverRequest("GET", "/branding/sub/logo.png")).isEmpty()); + assertTrue(router.route(serverRequest("POST", "/processor/pipelines/new")).isEmpty()); + } + + @Test + void spaDeepLinkFallback_runsAfterControllersAndBeforeResources() { + controller.init(); + int order = controller.spaDeepLinkFallbackMapping().getOrder(); + + // A catch-all denylist is only safe below every annotated controller; Spring's own + // RouterFunctionMapping sits at -1, which would shadow /v1/api-docs, /error and friends. + assertTrue(order > 0, "SPA fallback must run after annotated controllers"); + assertTrue( + order < Ordered.LOWEST_PRECEDENCE - 1, + "SPA fallback must run before the static-resource chain"); + } + + private static RouterFunction routerOf(RouterFunctionMapping mapping) { + @SuppressWarnings("unchecked") + RouterFunction router = + (RouterFunction) mapping.getRouterFunction(); + return router; + } + + private static ServerRequest serverRequest(String method, String uri) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest(method, uri); + ServletRequestPathUtils.parseAndCache(servletRequest); + return ServerRequest.create(servletRequest, List.of(new StringHttpMessageConverter())); + } + // --- context path handling --- @Test diff --git a/frontend/editor/scripts/lint/theme-lint.mjs b/frontend/editor/scripts/lint/theme-lint.mjs index 0ae53a3ce9..42f6153119 100644 --- a/frontend/editor/scripts/lint/theme-lint.mjs +++ b/frontend/editor/scripts/lint/theme-lint.mjs @@ -314,6 +314,10 @@ function check() { const violations = []; const primitiveValues = new Map(); const lineOf = (text, index) => text.slice(0, index).split("\n").length; + // path.relative emits backslashes on Windows; normalize so the PRIMITIVES + // comparison below matches and printed paths stay POSIX-style. + const posixRel = (name) => + relative(process.cwd(), join(THEME, name)).replaceAll("\\", "/"); // Fail if a theme .css exists that isn't registered above (readdir is only // compared here — never used to build a path passed to readFileSync). @@ -321,7 +325,7 @@ function check() { for (const name of readdirSync(THEME)) { if (name.endsWith(".css") && !known.has(name)) { violations.push({ - file: relative(process.cwd(), join(THEME, name)), + file: posixRel(name), line: 1, msg: `unregistered theme CSS — add "${name}" to THEME_FILES in theme-lint.mjs`, }); From 62e5e28039d9e3cad1e101daf175c0774dd8aa33 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:10:28 +0100 Subject: [PATCH 02/99] Avoid renderer OOM when adding large PDFs to the workbench (#7175) # Description of Changes - "Out of Memory" tab crash on merge with big PDFs (Discord report) is a WebView2 renderer OOM, not system RAM; adding files did 2 full parses + a parallel full read per file - IndexedDB now stores files as Blobs (by reference, no JS-side copy); old ArrayBuffer records stay readable - Both thumbnail variants now come from one PDFium parse instead of two - PDFs over 100MB never get a full client-side parse: 2MB linearized-prefix attempt, placeholder icon otherwise - Follow-ups if placeholders aren't enough: backend-rendered thumbnails, PDFium ranged reads in a worker --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- .../fileEditor/FileEditorThumbnail.tsx | 4 + .../src/core/components/shared/FileCard.tsx | 10 +- .../src/core/contexts/file/fileActions.ts | 19 +- .../editor/src/core/services/fileAnalyzer.ts | 81 ++++++-- .../editor/src/core/services/fileStorage.ts | 9 +- .../src/core/utils/thumbnailUtils.test.ts | 53 ++++++ .../editor/src/core/utils/thumbnailUtils.ts | 180 +++++++++++++++++- 7 files changed, 324 insertions(+), 32 deletions(-) create mode 100644 frontend/editor/src/core/utils/thumbnailUtils.test.ts diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx index e183e45d6e..ccf07e9dea 100644 --- a/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/editor/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -45,6 +45,7 @@ import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryMod import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useFileThumbnail } from "@app/hooks/useFileThumbnail"; import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail"; +import { LARGE_PDF_PARSE_LIMIT } from "@app/utils/thumbnailUtils"; import { truncateCenter } from "@app/utils/textUtils"; import { FileEditorStatusDot } from "@app/components/fileEditor/FileEditorStatusDot"; @@ -553,6 +554,9 @@ const FileEditorThumbnail = ({ isLoading={ !isEncrypted && !displayThumbnail && + // No thumbnail is ever produced at/above the parse limit, so + // without this the spinner has no terminal state. + file.size < LARGE_PDF_PARSE_LIMIT && (isThumbGenerating || file.type?.startsWith("application/pdf") || file.type?.startsWith("image/")) diff --git a/frontend/editor/src/core/components/shared/FileCard.tsx b/frontend/editor/src/core/components/shared/FileCard.tsx index e43dbeeb1c..495a76a53b 100644 --- a/frontend/editor/src/core/components/shared/FileCard.tsx +++ b/frontend/editor/src/core/components/shared/FileCard.tsx @@ -11,6 +11,7 @@ import { StirlingFileStub } from "@app/types/fileContext"; import { getFileSize, getFileDate } from "@app/utils/fileUtils"; import { useFileThumbnail } from "@app/hooks/useFileThumbnail"; import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail"; +import { LARGE_PDF_PARSE_LIMIT } from "@app/utils/thumbnailUtils"; interface FileCardProps { file: File; @@ -44,7 +45,14 @@ const FileCard = ({ const [isHovered, setIsHovered] = useState(false); const isPdf = file.type === "application/pdf"; - const isHydrating = isPdf && !isEncrypted && !thumb && !isGenerating; + // Files at/above the parse limit never get a thumbnail, so without the size + // check their spinner has no terminal state and runs forever. + const isHydrating = + isPdf && + file.size < LARGE_PDF_PARSE_LIMIT && + !isEncrypted && + !thumb && + !isGenerating; return ( { + const tailStart = Math.max(0, file.size - ENCRYPT_PROBE_BYTES); + if (bufferHasEncryptMarker(await file.slice(tailStart).arrayBuffer())) { + return true; + } + if (tailStart === 0) return false; + return bufferHasEncryptMarker( + await file.slice(0, ENCRYPT_PROBE_BYTES).arrayBuffer(), + ); +} + export class FileAnalyzer { private static readonly SIZE_THRESHOLDS = { SMALL: 10 * 1024 * 1024, // 10MB @@ -80,32 +96,57 @@ export class FileAnalyzer { /** * Cheap encryption-only probe for the upload-time detection path. * - * Looks for a /Encrypt entry in the last 8KB of the file (where the PDF - * trailer lives). If absent, the file is definitely not encrypted and we - * can skip a full pdf.js parse. If present, falls back to pdf.js so we can - * distinguish user-password (blocks open) from owner-password-only (opens - * fine) — only the former should prompt. + * Looks for a /Encrypt entry in a bounded window at either end of the file + * (where PDF trailers live). If absent, the file is definitely not encrypted + * and we can skip a full pdf.js parse. If present, falls back to pdf.js so we + * can distinguish user-password (blocks open) from owner-password-only (opens + * fine); only the former should prompt. + * + * Runs inside the addFiles mutex, so it must always settle: an unbounded + * parse here stalls every later upload as well as this one. */ static async isPDFUserPasswordProtected(file: File): Promise { - const arrayBuffer = await file.arrayBuffer(); - if (!hasEncryptMarker(arrayBuffer)) return false; + if (!(await hasEncryptMarker(file))) return false; - let pdf: PDFDocumentProxy | undefined; - try { - pdf = await pdfWorkerManager.createDocument(arrayBuffer, { - stopAtErrors: false, - verbosity: 0, + // Too big to hand pdf.js: that full-buffer parse is the renderer OOM the + // large-file path exists to avoid, so trust the marker instead of it. + if (file.size >= LARGE_PDF_PARSE_LIMIT) return true; + + const arrayBuffer = await file.arrayBuffer(); + let timedOut = false; + let timer: ReturnType | undefined; + const opening = pdfWorkerManager + .createDocument(arrayBuffer, { stopAtErrors: false, verbosity: 0 }) + .then((pdf) => { + // Arrived after the timeout won the race - nothing else frees it. + if (timedOut) pdfWorkerManager.destroyDocument(pdf); + return pdf; }); + + try { + const pdf = await Promise.race([ + opening, + new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true; + reject(new Error(PROBE_TIMEOUT)); + }, PROBE_TIMEOUT_MS); + }), + ]); + pdfWorkerManager.destroyDocument(pdf); // pdf.js opened it — owner-password-only case, no prompt needed. return false; } catch (error) { const errorMessage = error instanceof Error ? error.message.toLowerCase() : ""; + // Unconfirmed either way; the /Encrypt marker is the better guess, and a + // spurious prompt beats a card that never stops spinning. + if (errorMessage === PROBE_TIMEOUT) return true; return ( errorMessage.includes("password") || errorMessage.includes("encrypted") ); } finally { - if (pdf) pdfWorkerManager.destroyDocument(pdf); + clearTimeout(timer); } } diff --git a/frontend/editor/src/core/services/fileStorage.ts b/frontend/editor/src/core/services/fileStorage.ts index 4cdecca9f1..4f82af3a8f 100644 --- a/frontend/editor/src/core/services/fileStorage.ts +++ b/frontend/editor/src/core/services/fileStorage.ts @@ -23,7 +23,9 @@ import { const THUMBNAIL_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days export interface StoredStirlingFileRecord extends BaseFileMetadata { - data: ArrayBuffer; + // Blob since the large-file OOM fix (stored by reference, no JS-side copy); + // ArrayBuffer records predate it and are still readable. + data: ArrayBuffer | Blob; fileId: FileId; // Matches runtime StirlingFile.fileId exactly quickKey: string; // Matches runtime StirlingFile.quickKey exactly thumbnail?: string; @@ -118,7 +120,6 @@ class FileStorageService { stub: StirlingFileStub, ): Promise { const db = await this.getDatabase(); - const arrayBuffer = await stirlingFile.arrayBuffer(); const record: StoredStirlingFileRecord = { id: stirlingFile.fileId, @@ -129,7 +130,9 @@ class FileStorageService { size: stirlingFile.size, lastModified: stirlingFile.lastModified, createdAt: stub.createdAt, - data: arrayBuffer, + // Store the File (a Blob) itself: IndexedDB persists it by reference and + // streams to disk, so multi-GB files never materialize in JS memory. + data: stirlingFile, thumbnail: stub.thumbnailUrl, thumbnailStoredAt: stub.thumbnailUrl ? Date.now() : undefined, isLeaf: stub.isLeaf ?? true, diff --git a/frontend/editor/src/core/utils/thumbnailUtils.test.ts b/frontend/editor/src/core/utils/thumbnailUtils.test.ts new file mode 100644 index 0000000000..c47bd05bdf --- /dev/null +++ b/frontend/editor/src/core/utils/thumbnailUtils.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { containsEncryptMarker } from "@app/utils/thumbnailUtils"; + +/** + * The only signal that a PDF too large to fully parse is password-protected. + * Tested over raw bytes because jsdom's Blob does not return its own contents + * from arrayBuffer(), so the slicing wrapper cannot be exercised here. + */ + +/** PDF-shaped bytes with `trailer` written at the very end. */ +function bytesEndingWith(trailer: string, size: number): Uint8Array { + const bytes = new Uint8Array(size).fill(0x20); // padding + const tail = new TextEncoder().encode(trailer); + bytes.set(tail, size - tail.length); + return bytes; +} + +describe("containsEncryptMarker — trailer probe for large PDFs", () => { + it("detects /Encrypt in a trailer dictionary", () => { + const bytes = bytesEndingWith( + "trailer\n<< /Size 9 /Root 1 0 R /Encrypt 8 0 R >>\nstartxref\n1234\n%%EOF\n", + 4096, + ); + expect(containsEncryptMarker(bytes)).toBe(true); + }); + + it("leaves an unencrypted document alone", () => { + const bytes = bytesEndingWith( + "trailer\n<< /Size 9 /Root 1 0 R >>\nstartxref\n1234\n%%EOF\n", + 4096, + ); + expect(containsEncryptMarker(bytes)).toBe(false); + }); + + it("does not match longer keys that merely start the same way", () => { + const bytes = bytesEndingWith("<< /EncryptionAware true >>\n%%EOF\n", 512); + expect(containsEncryptMarker(bytes)).toBe(false); + }); + + it("survives binary bytes around the marker", () => { + // Under UTF-8 these become replacement characters and consume the marker. + const prefix = new Uint8Array([0xff, 0xfe, 0x80, 0x00, 0x9d]); + const marker = new TextEncoder().encode(" /Encrypt 8 0 R >>"); + const bytes = new Uint8Array(prefix.length + marker.length); + bytes.set(prefix); + bytes.set(marker, prefix.length); + expect(containsEncryptMarker(bytes)).toBe(true); + }); + + it("reports nothing for an empty read", () => { + expect(containsEncryptMarker(new Uint8Array(0))).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/utils/thumbnailUtils.ts b/frontend/editor/src/core/utils/thumbnailUtils.ts index 838992e38b..b356b26db9 100644 --- a/frontend/editor/src/core/utils/thumbnailUtils.ts +++ b/frontend/editor/src/core/utils/thumbnailUtils.ts @@ -31,6 +31,37 @@ export function calculateScaleFromFileSize(fileSize: number): number { /** PDFium error code 4 = password required (encrypted PDF). */ const PDFIUM_ERR_PASSWORD = 4; +/** PDFs at or above this size never get a full-buffer client-side parse + * (renderer OOM) - only the linearized-prefix attempt below. */ +export const LARGE_PDF_PARSE_LIMIT = 100 * 1024 * 1024; + +/** Linearized PDFs keep page 1 + hint tables in the first bytes, so a small + * prefix is often enough to render a thumbnail without reading the file. */ +const LINEARIZED_PREFIX_BYTES = 2 * 1024 * 1024; + +/** Window at each end of the file searched for an /Encrypt entry. */ +const ENCRYPT_PROBE_BYTES = 64 * 1024; + +/** Decoded latin1 because the input is binary - UTF-8 replacement characters + * can swallow the marker. \b excludes longer keys like /Encryption. */ +export function containsEncryptMarker(bytes: Uint8Array): boolean { + return /\/Encrypt\b/.test(new TextDecoder("latin1").decode(bytes)); +} + +/** /Encrypt is referenced from a trailer, never from the page data the prefix + * parse sees. Linearized PDFs (most large ones) keep their first-page trailer at + * the head and the main one at the tail, so both windows have to be probed. + * Heuristic: a false positive offers unlock on a file that did not need it, a + * false negative leaves it unopenable. */ +async function looksEncryptedFromTrailer(file: File): Promise { + const tailStart = Math.max(0, file.size - ENCRYPT_PROBE_BYTES); + const tail = await file.slice(tailStart).arrayBuffer(); + if (containsEncryptMarker(new Uint8Array(tail))) return true; + if (tailStart === 0) return false; + const head = await file.slice(0, ENCRYPT_PROBE_BYTES).arrayBuffer(); + return containsEncryptMarker(new Uint8Array(head)); +} + interface PdfiumRenderResult { thumbnail: string; pageCount: number; @@ -112,6 +143,72 @@ async function renderPdfThumbnailPdfium( } } +/** + * Render both thumbnail variants (upright + rotation-baked) from a single + * document open - halves the parse and memory cost of the add-files path. + */ +async function renderPdfThumbnailPairPdfium( + data: ArrayBuffer, + scale: number, + collectAllPagesMetadata: boolean, +): Promise<{ unrotated: PdfiumRenderResult; rotated: PdfiumRenderResult }> { + const m = await getPdfiumModule(); + let docPtr: number; + try { + docPtr = await openRawDocumentSafe(data); + } catch (error) { + if ( + error instanceof Error && + new RegExp(`error ${PDFIUM_ERR_PASSWORD}`).test(error.message) + ) { + const encrypted: PdfiumRenderResult = { + thumbnail: "", + pageCount: 1, + pageRotations: [], + pageDimensions: [], + isEncrypted: true, + }; + return { unrotated: encrypted, rotated: { ...encrypted } }; + } + throw error; + } + + try { + const pageCount = m.FPDF_GetPageCount(docPtr); + const unrotatedThumb = await renderPdfiumPageDataUrl(docPtr, 0, scale, { + applyRotation: false, + }); + const rotatedThumb = await renderPdfiumPageDataUrl(docPtr, 0, scale, { + applyRotation: true, + }); + if (!unrotatedThumb || !rotatedThumb) { + throw new Error("PDFium: failed to render page 0"); + } + + const firstMeta = await readPdfiumPageMetadata(docPtr, 0); + const pageRotations: number[] = [firstMeta?.rotation ?? 0]; + const pageDimensions: Array<{ width: number; height: number }> = [ + { width: firstMeta?.width ?? 0, height: firstMeta?.height ?? 0 }, + ]; + if (collectAllPagesMetadata) { + for (let i = 1; i < pageCount; i++) { + const meta = await readPdfiumPageMetadata(docPtr, i); + if (!meta) continue; + pageRotations[i] = meta.rotation; + pageDimensions[i] = { width: meta.width, height: meta.height }; + } + } + + const base = { pageCount, pageRotations, pageDimensions }; + return { + unrotated: { thumbnail: unrotatedThumb, ...base }, + rotated: { thumbnail: rotatedThumb, ...base }, + }; + } finally { + await closeRawDocument(docPtr); + } +} + async function generatePDFThumbnail( arrayBuffer: ArrayBuffer, scale: number, @@ -133,7 +230,7 @@ async function generatePDFThumbnail( */ export async function generateThumbnailForFile(file: File): Promise { // Very large PDFs skip thumbnail generation — SVG icon shown in UI instead - if (file.size >= 100 * 1024 * 1024) { + if (file.size >= LARGE_PDF_PARSE_LIMIT) { return ""; } @@ -152,8 +249,7 @@ export async function generateThumbnailForFile(file: File): Promise { const scale = calculateScaleFromFileSize(file.size); // Only read first 2MB for thumbnail generation to save memory - const chunkSize = 2 * 1024 * 1024; // 2MB - const chunk = file.slice(0, Math.min(chunkSize, file.size)); + const chunk = file.slice(0, Math.min(LINEARIZED_PREFIX_BYTES, file.size)); const arrayBuffer = await chunk.arrayBuffer(); try { @@ -193,6 +289,36 @@ export async function generateThumbnailWithMetadata( const scale = calculateScaleFromFileSize(file.size); + // Never full-parse huge PDFs client-side - the renderer process OOMs long + // before system RAM runs out. The prefix succeeds for linearized PDFs. + if (file.size >= LARGE_PDF_PARSE_LIMIT) { + // Probe inside the try: an unreadable file must still resolve, or the + // caller leaves the card with no metadata and a spinner that never stops. + try { + if (await looksEncryptedFromTrailer(file)) { + return { thumbnail: "", pageCount: 1, isEncrypted: true }; + } + const chunk = await file.slice(0, LINEARIZED_PREFIX_BYTES).arrayBuffer(); + const result = await renderPdfThumbnailPdfium( + chunk, + scale, + applyRotation, + false, + ); + if (result.isEncrypted) { + return { thumbnail: "", pageCount: 1, isEncrypted: true }; + } + return { + thumbnail: result.thumbnail, + pageCount: result.pageCount, + pageRotations: result.pageRotations, + pageDimensions: result.pageDimensions, + }; + } catch { + return { thumbnail: "", pageCount: 0 }; + } + } + try { const arrayBuffer = await file.arrayBuffer(); // Always read per-page rotation: PageEditor renders thumbnails upright and @@ -222,3 +348,51 @@ export async function generateThumbnailWithMetadata( return { thumbnail: "", pageCount: 1 }; } } + +/** + * Both thumbnail variants + page metadata from ONE full parse instead of two. + * Large PDFs only get the linearized-prefix attempt; if that fails, both + * variants are empty placeholders and page metadata is omitted. + */ +export async function generateThumbnailPairWithMetadata(file: File): Promise<{ + unrotated: ThumbnailWithMetadata; + rotated: ThumbnailWithMetadata; +}> { + const scale = calculateScaleFromFileSize(file.size); + const isLarge = file.size >= LARGE_PDF_PARSE_LIMIT; + try { + // Probe inside the try: an unreadable file must still resolve, or the + // caller leaves the card with no metadata and a spinner that never stops. + if (isLarge && (await looksEncryptedFromTrailer(file))) { + const encrypted: ThumbnailWithMetadata = { + thumbnail: "", + pageCount: 1, + isEncrypted: true, + }; + return { unrotated: encrypted, rotated: { ...encrypted } }; + } + const buffer = isLarge + ? await file.slice(0, LINEARIZED_PREFIX_BYTES).arrayBuffer() + : await file.arrayBuffer(); + const pair = await renderPdfThumbnailPairPdfium(buffer, scale, !isLarge); + + const toPublic = (r: PdfiumRenderResult): ThumbnailWithMetadata => + r.isEncrypted + ? { thumbnail: "", pageCount: 1, isEncrypted: true } + : { + thumbnail: r.thumbnail, + pageCount: r.pageCount, + pageRotations: r.pageRotations, + pageDimensions: r.pageDimensions, + }; + return { + unrotated: toPublic(pair.unrotated), + rotated: toPublic(pair.rotated), + }; + } catch { + return { + unrotated: { thumbnail: "", pageCount: 0 }, + rotated: { thumbnail: "", pageCount: 0 }, + }; + } +} From 8f5344ec7da36ff153432652ddc6c9c1c4c7d4c2 Mon Sep 17 00:00:00 2001 From: Ludy Date: Wed, 5 Aug 2026 16:25:44 +0200 Subject: [PATCH 03/99] build(tool-models): separate API model generation from Swagger setup (#7300) # Description of Changes - Added internal `_generate` tasks for frontend and engine tool-model generation. - Kept the public `tool-models` tasks responsible for dependency installation and Swagger generation. - Separated environment preparation from the actual model generation commands. - This allows higher-level tasks to prepare the OpenAPI specification once and invoke both generators without repeating the backend setup. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .taskfiles/backend.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index fbabd783a3..63773f61fc 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -146,6 +146,7 @@ tasks: swagger: desc: "Generate OpenAPI docs" + run: once cmds: - cmd: cmd /c ".\gradlew.bat :stirling-pdf:copySwaggerDoc" platforms: [windows] From d0d197f09f84a5609aa8191dc08187f4c873b12d Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:23:50 +0100 Subject: [PATCH 04/99] Procurement: draft Enterprise Agreement + signature, legal pages & consent, quote/agreement split (#7021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the enterprise procurement and legal work into one PR off `main`. Supersedes #7020 (closed; every commit from it is contained here). Sits on top of PAYG prepaid bundles (#7032) and the `--color-*` → `--c-*` portal token rename. ## Why Enterprise procurement was a mock. The stage screens read from a fake state machine, the "agreement" was prose hardcoded in a component, and nothing a buyer did was recorded anywhere. To actually sell to an enterprise we need three things it didn't have: a real document they can read and sign, a record that proves they signed that exact version, and a licence that flips when they pay. ## What **The agreement is a real versioned document** - Registry at `resources/legal/manifest.json` + `legal///*.md`. Publishing a new version is a markdown file and a manifest bump, no code change. `@`-prefixed parts are generated sections. - `AgreementAssembler` builds MSA (Part A) + generated Order Form (Part B) + DPA (Part C) as one document. Only the Order Form varies per deal. - `AgreementPdfRenderer` goes through our own pipeline (commonmark → `FileToPdf`/WeasyPrint), so we dogfood it. - Immutable signature record pinning document id and version, a SHA-256 of the exact rendered markdown, the variable snapshot, typed signatory details, timestamp and IP. **Legal document pages and consent logging** - `GET /api/v1/legal/{docId}` serves any registry document; a viewer modal renders it with a draft badge. The SLA exhibit is viewable for the first time. - `legal_consent` + `POST /api/v1/legal/consent`. EULA clickwrap is recorded once: at trial start, or at the quote step only if there was no trial. **Quote and Agreement are separate steps** The quote step is a plain itemised review (figures, renewal, PO) with download and "Accept quote". Accepting advances to the agreement and does not charge Stripe. Signing the agreement is still the commitment point. **One quote number** We no longer mint our own reference. The Stripe quote number is the identifier everywhere, so the UI and the memo can't disagree. `quote_number` is nullable until Stripe assigns it at finalisation (`20260808000000`). **Payment takes the deal live** `invoice.paid` on the stripe-webhook moves the deal to live and the UI reflects it. Nothing watched for payment before, so a paid customer sat in "payment" forever. Needs `invoice.paid` enabled on the webhook endpoint in the Stripe dashboard. **Security** Any signup could self-issue a $0 enterprise licence, from three things compounding: leader-on-signup, no entitlement gate, and no ACV floor. So: `startTrial` now has a stage guard (it was replacing committed licences), the offline `.lic` is gated on entitlement, the ACV floor is enforced before the quote persists, and the air-gap check reads the quote's deployment rather than the deal's. Invitee emails are redacted in logs. Dev and Storybook were hitting real Stripe; both now route through `resolveDemoResponse`. **Removed the dead procurement island** The original stage-by-stage page survived the rebuild with no route and no consumer, so it was invisible to review but still cost a reader's time. 16 unreferenced files, 182 lines of superseded API, 53 orphaned en-US keys, and `Procurement.css` from 1665 to 968 lines. Nothing deleted had a live consumer. ## Screenshots Home, deal underway (hero card footer): Quote builder, step 1: Agreement, ready to sign: Payment and live: ## How to test **Storybook** covers every state without a backend: ```bash cd frontend && npm run storybook ``` Then `Portal/Procurement/*`: | Story | What to look at | | --- | --- | | `DealStatusHero` — Trial / Quote / Agreement / Payment / Live | One hero per stage: progress band, one-line status, stage CTA | | `QuoteBuilder` — Default | 4 steps. Users + volume drive the price; Governance and PDF size are multipliers; step 4 is the itemised review | | `ProcurementAgreement` — Default / Signing | Header actions, always-visible scrollbar on the paper, one-line signature row | | `ProcurementStages` — Payment / Live / License | "View & pay invoice" opens Stripe directly; licence key and `.lic` download | | `Views/Home` — Subscribed In Procurement | The hero in real page context | Note: `ProcurementAgreement` renders "Could not load the agreement" in Storybook because it fetches the document from the backend. The chrome is accurate, the paper body needs the app. **Full flow** needs SaaS running and a linked team: 1. Home → **Explore enterprise** → trial setup (deployment + seats). EULA is recorded here. 2. **Build your quote** → 4 steps → Generate. Buyer details are required first. 3. Review the itemised quote → **Accept quote**. Confirm Stripe was *not* charged. 4. Agreement → tick, fill signatory, **Sign agreement**. Check `procurement_signature` for the version and content hash. 5. **View & pay invoice** → pay in Stripe test mode → deal should move to live on the `invoice.paid` webhook. Worth reviewing specifically: the licence cannot be issued without entitlement (step 3 before payment), and `startTrial` on an already-committed deal is rejected rather than overwriting. ## Verification - `:saas compileJava` + `spotlessJavaCheck` - `task frontend:check:all` green end to end: 9 typecheck variants, eslint at zero warnings, `theme-lint`, `lint:css`, prettier, build, **1656 tests across 188 files** - 7 deno tests on the `invoice.paid` handler, covering all four shapes Stripe uses for the subscription reference ## Open, not addressed here - **The commercial model contradicts itself in three places.** The Order Form says annual-in-advance, the MSA §2.3/§3.2 implies otherwise, the quote engine computes `tcv = annualNet × termYears` flat, and Stripe only invoices one year. Needs a decision before this is customer-facing. - The 25 MB data-processing increments vs the ×1.4/×2.4 size multiplier, deferred pending Matt. - All legal text is **draft**. It renders with a draft badge and is not presented as executed; counsel's read is still a publish gate. - `{{subprocessor_url}}` / `{{eula_url}}` awaiting marketing's final links. - `frontend-a11y` is red on pre-existing portal contrast debt, deferred by decision. ## Schema notes Two migrations land on the SaaS side (`v3`), both applied by that repo's PR CI: - `20260808000000` drops the NOT NULL on `procurement_quote.quote_number`, which is required rather than cosmetic — the number now comes from Stripe at finalisation, so a draft holds NULL, and `ddl-auto` cannot drop an existing NOT NULL itself. - `20260809000000` adds `procurement_deal.last_paid_invoice_id`, nullable. Nothing here needs a migration in this repo: Flyway is not on the classpath, so the Java side only ever adds via `ddl-auto`, and Postgres migrations run ahead of the app deploy. --- app/saas/build.gradle | 5 + .../software/saas/config/SaasJpaConfig.java | 6 +- .../software/saas/legal/LegalConsent.java | 62 + .../saas/legal/LegalConsentRepository.java | 5 + .../saas/legal/LegalConsentService.java | 47 + .../software/saas/legal/LegalController.java | 133 ++ .../saas/legal/LegalDocumentMeta.java | 27 + .../saas/legal/LegalDocumentRegistry.java | 151 ++ .../saas/payg/bundle/PrepaidBundle.java | 15 +- .../api/ProcurementController.java | 247 ++- .../ProcurementConfigurationProperties.java | 13 + .../procurement/legal/AgreementAssembler.java | 278 ++++ .../legal/AgreementPdfRenderer.java | 71 + .../procurement/legal/AgreementSigning.java | 12 + .../procurement/legal/AssembledAgreement.java | 16 + .../model/ProcurementAgreementSignature.java | 86 + .../procurement/model/ProcurementDeal.java | 33 + .../procurement/model/ProcurementQuote.java | 6 +- .../pricing/ProcurementPricingService.java | 49 +- ...ocurementAgreementSignatureRepository.java | 29 + .../service/ProcurementService.java | 390 ++++- .../legal/enterprise-agreement/0.9.1/dpa.md | 53 + .../legal/enterprise-agreement/0.9.1/msa.md | 119 ++ .../main/resources/legal/eula/1.0.0/eula.md | 75 + .../src/main/resources/legal/manifest.json | 38 + .../src/main/resources/legal/sla/1.0.0/sla.md | 37 + .../subprocessors/1.0.0/subprocessors.md | 19 + .../ProcurementTrialRestartPolicyTest.java | 44 + frontend/.storybook/a11y-baseline.json | 22 +- .../public/locales/en-US/translation.toml | 241 ++- frontend/editor/src/portal/ViewRouter.tsx | 2 - .../editor/src/portal/api/externalUrl.test.ts | 51 + frontend/editor/src/portal/api/externalUrl.ts | 25 + frontend/editor/src/portal/api/procurement.ts | 369 ++--- .../portal/components/EditorStatusCard.css | 99 +- .../components/EditorStatusCard.stories.tsx | 30 +- .../portal/components/EditorStatusCard.tsx | 179 +-- .../portal/components/HomeHero.stories.tsx | 21 +- .../editor/src/portal/components/HomeHero.tsx | 72 +- .../src/portal/components/SetupChecklist.css | 122 -- .../components/SetupChecklist.stories.tsx | 66 - .../src/portal/components/SetupChecklist.tsx | 150 -- .../src/portal/components/WelcomeBanner.css | 103 -- .../components/WelcomeBanner.stories.tsx | 41 - .../src/portal/components/WelcomeBanner.tsx | 97 -- .../billing/BundleCheckoutModal.tsx | 4 +- .../components/billing/EnterpriseUpsell.tsx | 13 +- .../components/billing/FreePlanView.tsx | 1 - .../components/billing/LinkAccountPrompt.tsx | 6 +- .../components/billing/PrepayModalHeader.tsx | 100 +- .../components/billing/SpendLimitCard.tsx | 2 +- .../billing/StripeCheckoutModal.tsx | 1 - .../src/portal/components/billing/billing.css | 53 - .../editor/src/portal/components/icons.tsx | 29 + .../procurement/ActionModal.stories.tsx | 51 - .../components/procurement/ActionModal.tsx | 166 -- .../procurement/DealJourney.stories.tsx | 30 - .../components/procurement/DealJourney.tsx | 99 -- .../procurement/DealStatusHero.stories.tsx | 6 +- .../components/procurement/DealStatusHero.tsx | 311 ++-- .../components/procurement/DocRow.stories.tsx | 62 - .../portal/components/procurement/DocRow.tsx | 89 -- .../procurement/DocumentLedger.stories.tsx | 30 - .../components/procurement/DocumentLedger.tsx | 160 -- .../procurement/LockedState.stories.tsx | 18 - .../components/procurement/LockedState.tsx | 36 - .../ProcurementAgreement.stories.tsx | 14 +- .../procurement/ProcurementAgreement.tsx | 369 +++-- .../procurement/ProcurementBanner.stories.tsx | 79 - .../procurement/ProcurementBanner.tsx | 65 +- .../procurement/ProcurementExtras.stories.tsx | 7 +- .../procurement/ProcurementExtras.tsx | 542 +++++-- .../procurement/ProcurementFlow.stories.tsx | 10 +- .../procurement/ProcurementFlow.tsx | 110 +- .../procurement/ProcurementHome.stories.tsx | 18 - .../procurement/ProcurementHome.tsx | 22 - .../procurement/ProcurementModal.stories.tsx | 4 +- .../procurement/ProcurementModal.tsx | 115 +- .../procurement/ProcurementStages.tsx | 102 +- .../components/procurement/QuoteBuilder.tsx | 377 ++++- .../procurement/StageStepper.stories.tsx | 20 - .../components/procurement/StageStepper.tsx | 52 - .../portal/components/procurement/format.ts | 32 +- .../procurement/useProcurement.test.tsx | 137 ++ .../components/procurement/useProcurement.ts | 165 +- .../portal/components/shared/FlowModal.css | 52 + .../components/shared/FlowModal.test.tsx | 94 ++ .../portal/components/shared/FlowModal.tsx | 52 + .../components/shared/StepModalHeader.css | 92 ++ .../components/shared/StepModalHeader.tsx | 127 ++ .../src/portal/contexts/UIContext.test.tsx | 47 + .../editor/src/portal/contexts/UIContext.tsx | 16 + .../src/portal/contexts/ViewContext.tsx | 2 - .../src/portal/hooks/useOnboardingProgress.ts | 63 - .../editor/src/portal/mocks/handlers/index.ts | 3 - .../src/portal/mocks/handlers/procurement.ts | 92 -- .../portal/mocks/handlers/procurementSaas.ts | 122 +- .../editor/src/portal/mocks/procurement.ts | 307 ---- .../portal/mocks/procurementMachine.test.ts | 86 - .../src/portal/mocks/procurementMachine.ts | 114 -- frontend/editor/src/portal/queries/keys.ts | 3 + frontend/editor/src/portal/queries/users.ts | 5 +- .../editor/src/portal/views/Home.stories.tsx | 1 + frontend/editor/src/portal/views/Home.tsx | 2 +- .../editor/src/portal/views/Procurement.css | 1383 ++++++----------- .../editor/src/portal/views/Procurement.tsx | 15 - frontend/eslint.config.mjs | 7 +- 107 files changed, 5347 insertions(+), 4601 deletions(-) create mode 100644 app/saas/src/main/java/stirling/software/saas/legal/LegalConsent.java create mode 100644 app/saas/src/main/java/stirling/software/saas/legal/LegalConsentRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/legal/LegalConsentService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/legal/LegalController.java create mode 100644 app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentMeta.java create mode 100644 app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementPdfRenderer.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementSigning.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/legal/AssembledAgreement.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementAgreementSignature.java create mode 100644 app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementAgreementSignatureRepository.java create mode 100644 app/saas/src/main/resources/legal/enterprise-agreement/0.9.1/dpa.md create mode 100644 app/saas/src/main/resources/legal/enterprise-agreement/0.9.1/msa.md create mode 100644 app/saas/src/main/resources/legal/eula/1.0.0/eula.md create mode 100644 app/saas/src/main/resources/legal/manifest.json create mode 100644 app/saas/src/main/resources/legal/sla/1.0.0/sla.md create mode 100644 app/saas/src/main/resources/legal/subprocessors/1.0.0/subprocessors.md create mode 100644 app/saas/src/test/java/stirling/software/saas/procurement/service/ProcurementTrialRestartPolicyTest.java create mode 100644 frontend/editor/src/portal/api/externalUrl.test.ts create mode 100644 frontend/editor/src/portal/api/externalUrl.ts delete mode 100644 frontend/editor/src/portal/components/SetupChecklist.css delete mode 100644 frontend/editor/src/portal/components/SetupChecklist.stories.tsx delete mode 100644 frontend/editor/src/portal/components/SetupChecklist.tsx delete mode 100644 frontend/editor/src/portal/components/WelcomeBanner.css delete mode 100644 frontend/editor/src/portal/components/WelcomeBanner.stories.tsx delete mode 100644 frontend/editor/src/portal/components/WelcomeBanner.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/ActionModal.stories.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/ActionModal.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/DealJourney.stories.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/DealJourney.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/DocRow.stories.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/DocRow.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/DocumentLedger.stories.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/DocumentLedger.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/LockedState.stories.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/LockedState.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/ProcurementHome.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/StageStepper.stories.tsx delete mode 100644 frontend/editor/src/portal/components/procurement/StageStepper.tsx create mode 100644 frontend/editor/src/portal/components/procurement/useProcurement.test.tsx create mode 100644 frontend/editor/src/portal/components/shared/FlowModal.css create mode 100644 frontend/editor/src/portal/components/shared/FlowModal.test.tsx create mode 100644 frontend/editor/src/portal/components/shared/FlowModal.tsx create mode 100644 frontend/editor/src/portal/components/shared/StepModalHeader.css create mode 100644 frontend/editor/src/portal/components/shared/StepModalHeader.tsx create mode 100644 frontend/editor/src/portal/contexts/UIContext.test.tsx delete mode 100644 frontend/editor/src/portal/hooks/useOnboardingProgress.ts delete mode 100644 frontend/editor/src/portal/mocks/handlers/procurement.ts delete mode 100644 frontend/editor/src/portal/mocks/procurement.ts delete mode 100644 frontend/editor/src/portal/mocks/procurementMachine.test.ts delete mode 100644 frontend/editor/src/portal/mocks/procurementMachine.ts delete mode 100644 frontend/editor/src/portal/views/Procurement.tsx diff --git a/app/saas/build.gradle b/app/saas/build.gradle index 495f583a74..a954e6d05a 100644 --- a/app/saas/build.gradle +++ b/app/saas/build.gradle @@ -6,6 +6,11 @@ dependencies { implementation project(':common') implementation project(':proprietary') + // Markdown -> HTML for rendering versioned legal documents (agreement) to PDF via the + // shared FileToPdf/WeasyPrint path in :common. Same library the core Markdown-to-PDF tool uses. + implementation "org.commonmark:commonmark:$commonmarkVersion" + implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion" + api 'org.springframework.boot:spring-boot-starter-security' api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' 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 cd991cc44e..612c6e3171 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 @@ -20,7 +20,8 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; "stirling.software.saas.ai.repository", "stirling.software.saas.payg.repository", "stirling.software.saas.payg.bundle", - "stirling.software.saas.procurement.repository" + "stirling.software.saas.procurement.repository", + "stirling.software.saas.legal" }) @EntityScan({ "stirling.software.saas.accountlink", @@ -28,6 +29,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; "stirling.software.saas.billing.model", "stirling.software.saas.ai.model", "stirling.software.saas.payg", - "stirling.software.saas.procurement.model" + "stirling.software.saas.procurement.model", + "stirling.software.saas.legal" }) public class SaasJpaConfig {} diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalConsent.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsent.java new file mode 100644 index 0000000000..5698133fe8 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsent.java @@ -0,0 +1,62 @@ +package stirling.software.saas.legal; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +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 lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * An append-only record that a user accepted a versioned legal document at a particular moment in + * the product. Distinct from a signed agreement (which is a negotiated, signature-bearing artifact, + * see {@code ProcurementAgreementSignature}); this captures the lighter clickwrap consents — the + * EULA accepted at trial start and at quote generation — with the exact document version, so what + * was agreed is auditable even after the document versions up. + */ +@Entity +@Table(name = "legal_consent") +@NoArgsConstructor +@Getter +@Setter +public class LegalConsent implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "consent_id") + private Long consentId; + + @Column(name = "team_id") + private Long teamId; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "document_id", nullable = false, length = 64) + private String documentId; + + @Column(name = "document_version", nullable = false, length = 32) + private String documentVersion; + + // Where in the product the consent was given: "trial", "quote", etc. + @Column(name = "context", nullable = false, length = 32) + private String context; + + @Column(name = "signer_ip", length = 64) + private String signerIp; + + @CreationTimestamp + @Column(name = "consented_at", nullable = false, updatable = false) + private LocalDateTime consentedAt; +} diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentRepository.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentRepository.java new file mode 100644 index 0000000000..9d79640cd0 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentRepository.java @@ -0,0 +1,5 @@ +package stirling.software.saas.legal; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LegalConsentRepository extends JpaRepository {} diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentService.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentService.java new file mode 100644 index 0000000000..c6377f35b7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalConsentService.java @@ -0,0 +1,47 @@ +package stirling.software.saas.legal; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** Records clickwrap consents to versioned legal documents (see {@link LegalConsent}). */ +@Slf4j +@Service +@Profile("saas") +@RequiredArgsConstructor +public class LegalConsentService { + + private final LegalDocumentRegistry registry; + private final LegalConsentRepository consents; + + /** + * Record that the given user accepted the current version of {@code documentId} in {@code + * context} (e.g. "trial", "quote"). No-op for an unknown document. Best-effort: callers treat a + * failure as non-fatal so it never blocks the flow the consent accompanies. + */ + @Transactional + public void record(Long teamId, Long userId, String documentId, String context, String ip) { + LegalDocumentMeta meta = registry.meta(documentId).orElse(null); + if (meta == null) { + log.warn("[legal] consent for unknown document '{}' ignored", documentId); + return; + } + LegalConsent consent = new LegalConsent(); + consent.setTeamId(teamId); + consent.setUserId(userId); + consent.setDocumentId(meta.id()); + consent.setDocumentVersion(meta.version()); + consent.setContext(context); + consent.setSignerIp(ip); + consents.save(consent); + log.info( + "[legal] consent recorded team={} doc={} v{} context={}", + teamId, + meta.id(), + meta.version(), + context); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalController.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalController.java new file mode 100644 index 0000000000..308b3e99bd --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalController.java @@ -0,0 +1,133 @@ +package stirling.software.saas.legal; + +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +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.PathVariable; +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 io.swagger.v3.oas.annotations.Hidden; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; +import stirling.software.saas.util.AuthenticationUtils; + +/** + * Serves the versioned legal documents (EULA, SLA exhibit, subprocessors) for in-product viewing, + * and records the lighter clickwrap consents. The enterprise agreement itself is served + signed + * through the procurement controller, since it needs a quote to fill its Order Form. + */ +@Slf4j +@Hidden +@RestController +@RequestMapping("/api/v1/legal") +@Profile("saas") +@RequiredArgsConstructor +public class LegalController { + + private final LegalDocumentRegistry registry; + private final LegalConsentService consents; + private final TeamMembershipRepository memberRepo; + private final UserRepository userRepository; + + /** A legal document rendered for viewing: registry metadata + the static markdown body. */ + public record LegalDocumentResponse( + String docId, + String version, + String versionLabel, + String displayName, + String effectiveDate, + String status, + String markdown) {} + + public record ConsentRequest(String documentId, String context) {} + + /** Fetch a legal document's current version as markdown. 404 for an unknown document. */ + @GetMapping("/{docId}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity document(@PathVariable String docId) { + return registry.meta(docId) + .>map( + meta -> + ResponseEntity.ok( + new LegalDocumentResponse( + meta.id(), + meta.version(), + meta.versionLabel(), + meta.displayName(), + meta.effectiveDate(), + meta.status(), + registry.staticMarkdown(docId)))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + /** + * Record a clickwrap consent (e.g. the EULA accepted at trial start or quote generation). + * Best-effort — a teamless caller still returns 200 so the accompanying flow is never blocked. + */ + @PostMapping("/consent") + @PreAuthorize("isAuthenticated()") + public ResponseEntity consent( + @RequestBody ConsentRequest request, Authentication auth, HttpServletRequest http) { + if (request == null || request.documentId() == null || request.context() == null) { + return ResponseEntity.badRequest().build(); + } + Optional membership = primaryMembership(auth); + Long teamId = membership.map(m -> m.getTeam().getId()).orElse(null); + Long userId = membership.map(m -> m.getUser().getId()).orElse(null); + // Best-effort for real: consent is audit metadata, not an authorisation gate, so a failed + // write must not fail the trial start or quote generation this call accompanies. Previously + // that only held because the caller happened to swallow the 500. + try { + consents.record( + teamId, userId, request.documentId(), request.context(), clientIp(http)); + } catch (RuntimeException e) { + log.warn( + "[legal] consent not recorded doc={} context={}: {}", + request.documentId(), + request.context(), + e.getMessage()); + } + return ResponseEntity.ok().build(); + } + + private Optional primaryMembership(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return Optional.empty(); + } + return memberRepo.findPrimaryMembership(user.getId()).stream().findFirst(); + } + + /** + * Best guess at the caller's address, for the audit record. + * + *

Informational only, and must stay that way: the first {@code X-Forwarded-For} hop is set + * by the client, so a stored address is trivially spoofable and is not evidence of where a + * consent or signature came from. Treat it as a hint when reconstructing events, never as + * proof. + */ + private static String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentMeta.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentMeta.java new file mode 100644 index 0000000000..9f35e42053 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentMeta.java @@ -0,0 +1,27 @@ +package stirling.software.saas.legal; + +import java.util.List; + +/** + * One legal document's registry entry, as declared in {@code legal/manifest.json}. Immutable + * snapshot loaded at startup by {@link LegalDocumentRegistry}. + * + *

{@code parts} lists the pieces, in render order, that make up the document. A plain entry + * (e.g. {@code "msa.md"}) is a static markdown file under {@code legal///}; an entry + * prefixed with {@code "@"} (e.g. {@code "@order-form"}) is a dynamic section that a document + * assembler generates at render time. + */ +public record LegalDocumentMeta( + String id, + String label, + String displayName, + String version, + String effectiveDate, + String status, + List parts) { + + /** Fully-qualified version label shown to users and stored on signatures, e.g. "SEA v0.9.1". */ + public String versionLabel() { + return label + " v" + version; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java new file mode 100644 index 0000000000..ffbe030863 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java @@ -0,0 +1,151 @@ +package stirling.software.saas.legal; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +/** + * Loads the versioned legal-document registry from {@code legal/manifest.json} on startup and + * serves document metadata + rendered markdown from the classpath. + * + *

Publishing a new version of any document is a content-only change: drop the markdown under + * {@code legal///} and bump that document's {@code version} in the manifest — no + * code change. Signatures pin the exact {@code {id, version, contentHash}} they were signed against + * (see the procurement agreement flow), so historical documents stay reproducible. + * + *

Token slots of the form {{name}} in the markdown are filled at render time. This + * registry fills the document-level common tokens ({@code version}, {@code version_date}, {@code + * subprocessor_url}, {@code eula_url}); callers that need per-quote tokens (the enterprise + * agreement's Order Form) fill the rest. + */ +@Slf4j +@Service +public class LegalDocumentRegistry { + + private static final String MANIFEST = "legal/manifest.json"; + private static final Pattern TOKEN = Pattern.compile("\\{\\{\\s*([a-zA-Z0-9_]+)\\s*}}"); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final Map documents = new LinkedHashMap<>(); + private String subprocessorUrl = ""; + private String eulaUrl = ""; + + @PostConstruct + void load() throws IOException { + JsonNode root; + try (InputStream in = new ClassPathResource(MANIFEST).getInputStream()) { + root = objectMapper.readTree(in); + } + subprocessorUrl = root.path("subprocessorUrl").asText(""); + eulaUrl = root.path("eulaUrl").asText(""); + JsonNode docs = root.path("documents"); + docs.fieldNames() + .forEachRemaining( + id -> { + JsonNode d = docs.get(id); + List parts = + objectMapper.convertValue( + d.path("parts"), + objectMapper + .getTypeFactory() + .constructCollectionType( + List.class, String.class)); + documents.put( + id, + new LegalDocumentMeta( + id, + d.path("label").asText(id), + d.path("displayName").asText(id), + d.path("version").asText("0"), + d.path("effectiveDate").asText(""), + d.path("status").asText("draft"), + parts == null ? List.of() : parts)); + }); + log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST); + } + + public Optional meta(String docId) { + return Optional.ofNullable(documents.get(docId)); + } + + public String subprocessorUrl() { + return subprocessorUrl; + } + + public String eulaUrl() { + return eulaUrl; + } + + /** Document-level tokens available to every document (before any per-quote tokens). */ + public Map commonTokens(LegalDocumentMeta meta) { + Map t = new LinkedHashMap<>(); + t.put("version", meta.version()); + t.put("version_date", meta.effectiveDate()); + t.put("subprocessor_url", subprocessorUrl); + t.put("eula_url", eulaUrl); + return t; + } + + /** Read one static markdown part of a document from the classpath. */ + public String readPart(LegalDocumentMeta meta, String partFile) { + String path = "legal/" + meta.id() + "/" + meta.version() + "/" + partFile; + try (InputStream in = new ClassPathResource(path).getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("Missing legal document part: " + path, e); + } + } + + /** + * The concatenated static parts of a document (dynamic {@code @}-parts skipped), with only the + * common tokens filled. Use for fully-static documents (EULA, SLA, subprocessors). + */ + public String staticMarkdown(String docId) { + LegalDocumentMeta meta = + meta(docId) + .orElseThrow( + () -> new IllegalArgumentException("Unknown document: " + docId)); + Map tokens = commonTokens(meta); + StringBuilder sb = new StringBuilder(); + for (String part : meta.parts()) { + if (part.startsWith("@")) continue; // dynamic section — not part of the static body + if (sb.length() > 0) sb.append("\n\n"); + sb.append(fill(readPart(meta, part), tokens)); + } + return sb.toString(); + } + + /** Replace {@code {{token}}} slots; unknown tokens are left intact so gaps are visible. */ + public static String fill(String markdown, Map tokens) { + Matcher m = TOKEN.matcher(markdown); + StringBuilder out = new StringBuilder(); + while (m.find()) { + String key = m.group(1); + String value = tokens.get(key); + m.appendReplacement( + out, + value == null + ? Matcher.quoteReplacement(m.group(0)) + : Matcher.quoteReplacement(value)); + } + m.appendTail(out); + return out.toString(); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundle.java b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundle.java index e2dff403a7..f864bf4d98 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundle.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/bundle/PrepaidBundle.java @@ -34,17 +34,22 @@ import lombok.Setter; @Entity @Table( name = "payg_prepaid_bundle", - // Declared here for ddl-auto (fresh schemas) and to document intent. The authoritative creator - // in production is the Supabase CLI migration 20260720000000_payg_prepaid_bundle, which builds + // Declared here for ddl-auto (fresh schemas) and to document intent. The authoritative + // creator + // in production is the Supabase CLI migration 20260720000000_payg_prepaid_bundle, which + // builds // the partial forms (WHERE units_remaining > 0 / WHERE stripe_ref IS NOT NULL). Flyway was // retired for SaaS (#7100), so there is no migration twin — names match the CLI migration. indexes = { - // Hot-path FIFO draw lookup — findDrawableForUpdate runs a locked read on every billable - // charge past the free grant; without it that degrades to a locked scan as the table grows. + // Hot-path FIFO draw lookup — findDrawableForUpdate runs a locked read on every + // billable + // charge past the free grant; without it that degrades to a locked scan as the table + // grows. @Index( name = "idx_payg_prepaid_bundle_team_expiry", columnList = "team_id, expires_at"), - // One pool per Stripe payment — the idempotency guard so a redelivered invoice.paid can't + // One pool per Stripe payment — the idempotency guard so a redelivered invoice.paid + // can't // credit the same purchase twice. @Index( name = "uq_payg_prepaid_bundle_stripe_ref", 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 index d67a0fddbf..1c495b3afd 100644 --- 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 @@ -22,6 +22,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; +import jakarta.servlet.http.HttpServletRequest; + import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.enumeration.TeamRole; @@ -30,6 +32,8 @@ import stirling.software.proprietary.security.database.repository.UserRepository import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.procurement.config.ProcurementConfigurationProperties; +import stirling.software.saas.procurement.legal.AgreementSigning; +import stirling.software.saas.procurement.model.ProcurementAgreementSignature; import stirling.software.saas.procurement.model.ProcurementDeal; import stirling.software.saas.procurement.model.ProcurementQuote; import stirling.software.saas.procurement.model.QuoteDetails; @@ -178,7 +182,17 @@ public class ProcurementController { String taxId) {} /** Trial setup captured before the trial starts: deployment target + seat count. */ - public record StartTrialRequest(String deployment, int users) {} + /** + * Setup step 2 collects the buying entity; all of it is optional so an older client still + * starts. + */ + public record StartTrialRequest( + String deployment, + int users, + String businessName, + String contactName, + String contactEmail, + String inviteEmails) {} public record SnapshotResponse( Long dealId, @@ -190,8 +204,33 @@ public class ProcurementController { int trialExtensionsUsed, boolean licensed, String licenseKey, + // Version label of the signed agreement PDF available for download, else null. + String agreementSignedVersion, + // Buying entity captured at trial setup; null on deals started before that step. + String businessName, + String contactName, + String contactEmail, QuoteResponse latestQuote) {} + /** The filled agreement for review: registry metadata + the rendered markdown body. */ + public record AgreementDocumentResponse( + String docId, + String version, + String versionLabel, + String displayName, + String effectiveDate, + String status, + String markdown) {} + + /** Buyer-supplied signing inputs from the agreement stage. */ + public record SignAgreementRequest( + String customerLegalName, + String signatoryName, + String signatoryTitle, + boolean authorityConfirmed) {} + + public record SignAgreementResponse(Long signatureId, String versionLabel, boolean pdfStored) {} + // ---- endpoints ---------------------------------------------------------- /** @@ -213,7 +252,8 @@ public class ProcurementController { } private static final SnapshotResponse EMPTY_SNAPSHOT = - new SnapshotResponse(null, null, null, 0, null, null, 0, false, null, null); + new SnapshotResponse( + null, null, null, 0, null, null, 0, false, null, null, null, null, null, null); /** * Download the offline / air-gapped licence file (.lic) for the team — available for an @@ -239,6 +279,15 @@ public class ProcurementController { .orElseGet(() -> ResponseEntity.notFound().build()); } + /** Mark the account as looking at enterprise. Idempotent; never disturbs an existing deal. */ + @PostMapping("/interest") + @PreAuthorize("isAuthenticated()") + public ResponseEntity recordInterest(Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok(toSnapshot(procurement.recordInterest(teamId), true)); + } + @PostMapping("/trial/start") @PreAuthorize("isAuthenticated()") public ResponseEntity startTrial( @@ -248,8 +297,30 @@ public class ProcurementController { // Body is optional so an older client (no setup step) still starts a cloud trial. String deployment = request != null ? request.deployment() : null; int seats = request != null ? request.users() : 0; - return ResponseEntity.ok( - toSnapshot(procurement.startTrial(teamId, deployment, seats), true)); + ProcurementDeal deal; + try { + deal = + procurement.startTrial( + teamId, + deployment, + seats, + request != null ? request.businessName() : null, + request != null ? request.contactName() : null, + request != null ? request.contactEmail() : null, + request != null ? request.inviteEmails() : null); + } catch (IllegalStateException e) { + // Past the trial the deal holds a committed licence; restarting would replace it. + log.warn("[procurement] trial start rejected team={}: {}", teamId, e.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + // After the trial exists, so a rejected invite can never stop it starting. + if (request != null) { + procurement.sendTrialInvites( + teamId, + primaryMembership(auth).map(TeamMembership::getUser).orElse(null), + request.inviteEmails()); + } + return ResponseEntity.ok(toSnapshot(deal, true)); } @PostMapping("/trial/extend") @@ -270,8 +341,17 @@ public class ProcurementController { @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.toDetails()))); + try { + return ResponseEntity.ok( + toQuote( + procurement.buildQuote( + teamId, request.toConfig(), request.toDetails()))); + } catch (IllegalStateException e) { + // Below the minimum deal size, or the deal is already live. A client error, not a + // fault. + log.warn("[procurement] quote rejected team={}: {}", teamId, e.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } } // Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a @@ -293,6 +373,111 @@ public class ProcurementController { } } + /** + * The filled Stirling Enterprise Agreement (MSA + Order Form + DPA) for the team's current + * quote, as markdown, for the buyer to review before signing. 404 when there's no quote yet. + */ + @GetMapping("/agreement/document") + @PreAuthorize("isAuthenticated()") + public ResponseEntity agreementDocument(Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return procurement + .agreementDocument(teamId) + .>map( + a -> + ResponseEntity.ok( + new AgreementDocumentResponse( + a.docId(), + a.version(), + a.versionLabel(), + a.displayName(), + a.effectiveDate(), + a.status(), + a.markdown()))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + /** + * Record a signed agreement: capture the typed legal name / signatory / title / authority, pin + * the exact document version + content hash + variable snapshot, and store the rendered PDF + * (best-effort). The caller then proceeds to accept the quote as before. + */ + @PostMapping("/agreement/sign") + @PreAuthorize("isAuthenticated()") + public ResponseEntity signAgreement( + @RequestBody SignAgreementRequest request, + Authentication auth, + HttpServletRequest http) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + if (request == null + || request.signatoryName() == null + || request.signatoryName().isBlank() + || !request.authorityConfirmed()) { + return ResponseEntity.badRequest().build(); + } + try { + ProcurementAgreementSignature sig = + procurement.signAgreement( + teamId, + new AgreementSigning( + request.customerLegalName(), + request.signatoryName(), + request.signatoryTitle(), + request.authorityConfirmed()), + clientIp(http)); + return ResponseEntity.ok( + new SignAgreementResponse( + sig.getSignatureId(), sig.getDocumentLabel(), sig.getPdf() != null)); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + /** Download the stored signed-agreement PDF for the team. 404 if none was rendered/stored. */ + @GetMapping("/agreement/signature/pdf") + @PreAuthorize("isAuthenticated()") + public ResponseEntity signaturePdf(Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return procurement + .signedAgreementPdf(teamId) + .>map( + pdf -> + ResponseEntity.ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment;" + + " filename=\"stirling-enterprise-agreement.pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(pdf)) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + /** + * Download the current (unsigned) agreement as a PDF — the document shown at the sign step. 404 + * when there's no quote yet or the render runtime is unavailable. + */ + @GetMapping("/agreement/document/pdf") + @PreAuthorize("isAuthenticated()") + public ResponseEntity agreementDocumentPdf(Authentication auth) { + Long teamId = requireLeader(auth); + if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return procurement + .agreementDocumentPdf(teamId) + .>map( + pdf -> + ResponseEntity.ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment;" + + " filename=\"stirling-enterprise-agreement.pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(pdf)) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + /** * Provision on accept: upgrade the team's licence to the committed annual term, valid * immediately. Called server-side by the accept edge function (ROLE_ADMIN via X-API-Key) once @@ -311,9 +496,38 @@ public class ProcurementController { } } + /** + * Go live once payment settles: advance the deal to active and re-affirm the annual licence. + * Called server-side by the {@code invoice.paid} webhook (ROLE_ADMIN via X-API-Key), alongside + * {@code /provision}, which runs earlier at accept and deliberately leaves the stage alone. + * + *

Answers 200 when the team has no deal at all, rather than erroring: a committed + * subscription can be closed directly in Stripe by sales with no portal deal behind it, and a + * non-2xx would have Stripe retry a webhook that can never succeed. + * + *

{@code invoiceId} is what makes this idempotent without swallowing renewals: the same + * invoice twice is a redelivery, a different one is next year's payment and has to re-issue the + * licence. Optional so an older caller still works, at the cost of that distinction. + */ + @PostMapping("/activate") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity activate( + @RequestParam("teamId") long teamId, + @RequestParam(value = "invoiceId", required = false) String invoiceId) { + try { + procurement.markLive(teamId, invoiceId); + } catch (IllegalStateException e) { + log.info( + "[procurement] activate skipped, no deal for team={}: {}", + teamId, + e.getMessage()); + } + return ResponseEntity.ok().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. + * annual licence, advance to active). Production go-live runs through {@code /activate}. */ @PostMapping("/go-live") @PreAuthorize("isAuthenticated()") @@ -360,6 +574,21 @@ public class ProcurementController { .orElse(null); } + /** + * Best-effort client IP for the signature record: first X-Forwarded-For hop, else the peer. + * + *

Informational only. That header is client-set, so {@code signer_ip} is spoofable and is + * not evidence of where a signature came from — the document hash and version are what make the + * record trustworthy. Treat the address as a hint, never as proof. + */ + private static String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } + /** * Build the snapshot for a deal. {@code includeLicenseKey} is true only for the team leader; a * member sees {@code licensed} but not the key itself (see {@link #snapshot}). Mutation @@ -381,6 +610,10 @@ public class ProcurementController { deal.getTrialExtensionsUsed(), deal.getLicenseRef() != null, includeLicenseKey ? deal.getLicenseRef() : null, + procurement.signedAgreementLabel(deal.getDealId()).orElse(null), + deal.getBusinessName(), + deal.getContactName(), + deal.getContactEmail(), latest); } 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 index 98ec0cd573..ad745cf5bc 100644 --- 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 @@ -30,4 +30,17 @@ public class ProcurementConfigurationProperties { * /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid. */ private boolean demoControlsEnabled = false; + + /** + * Smallest annual fee, in minor units, that may be quoted. The pricing curve has no natural + * floor — a small enough committed volume rounds the meter to zero — and every registered user + * is the leader of their own team, so without this any signup could price a $0 enterprise + * quote, accept it, and be provisioned a committed licence. 12_000_00 is USD 12,000/yr, the + * self-hosted deploy fee, chosen so the floor cannot sit below a line item the quote itself can + * contain. + * + *

This is a commercial number, not a technical one: set it to whatever the smallest + * enterprise deal you will actually sign is. Zero disables the check. + */ + private long minAnnualNetMinor = 12_000_00L; } diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java new file mode 100644 index 0000000000..c641f2e596 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java @@ -0,0 +1,278 @@ +package stirling.software.saas.procurement.legal; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.saas.legal.LegalDocumentMeta; +import stirling.software.saas.legal.LegalDocumentRegistry; +import stirling.software.saas.procurement.model.ProcurementQuote; +import stirling.software.saas.procurement.pricing.ProcurementPricingService; +import stirling.software.saas.procurement.pricing.QuoteConfig; +import stirling.software.saas.procurement.pricing.QuoteLineItem; + +/** + * Builds the full Stirling Enterprise Agreement for a specific quote: the static MSA (Part A) and + * DPA (Part C) from the {@link LegalDocumentRegistry}, with the dynamic Order Form (Part B) + * generated from the quote and slotted where the manifest's {@code @order-form} part sits. + * + *

Only the Order Form varies per deal; the MSA and DPA bodies are rendered verbatim with token + * substitution. The set of values used is returned as {@code variablesJson} so a signature can pin + * exactly what was rendered. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AgreementAssembler { + + public static final String DOC_ID = "enterprise-agreement"; + + private static final DateTimeFormatter DATE = + DateTimeFormatter.ofPattern("MMMM d, yyyy", Locale.US); + private static final String BLANK = "\\_\\_\\_\\_\\_\\_\\_\\_\\_\\_"; + + private final LegalDocumentRegistry registry; + private final ProcurementPricingService pricing; + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Render the agreement for a quote. {@code signing} is null for a preview (before signing) — + * the effective date and signature block then read as blanks / "On signature". + */ + public AssembledAgreement assemble(ProcurementQuote quote, AgreementSigning signing) { + LegalDocumentMeta meta = + registry.meta(DOC_ID) + .orElseThrow( + () -> + new IllegalStateException( + "Enterprise agreement not registered")); + + Map tokens = tokens(quote, signing, meta); + + StringBuilder md = new StringBuilder(); + for (String part : meta.parts()) { + if (md.length() > 0) md.append("\n\n"); + if ("@order-form".equals(part)) { + md.append(LegalDocumentRegistry.fill(orderForm(quote, tokens), tokens)); + } else { + md.append(LegalDocumentRegistry.fill(registry.readPart(meta, part), tokens)); + } + } + + String variablesJson; + try { + variablesJson = objectMapper.writeValueAsString(tokens); + } catch (Exception e) { + variablesJson = "{}"; + } + + return new AssembledAgreement( + meta.id(), + meta.version(), + meta.versionLabel(), + meta.displayName(), + meta.effectiveDate(), + meta.status(), + md.toString(), + variablesJson); + } + + private Map tokens( + ProcurementQuote quote, AgreementSigning signing, LegalDocumentMeta meta) { + QuoteConfig cfg = toConfig(quote); + boolean signed = signing != null; + + String legalName = + signed && notBlank(signing.customerLegalName()) + ? signing.customerLegalName().trim() + : (notBlank(quote.getBusinessName()) + ? quote.getBusinessName().trim() + : "Customer"); + + Map t = new LinkedHashMap<>(registry.commonTokens(meta)); + t.put("effective_date", signed ? LocalDate.now().format(DATE) : "On signature"); + t.put("customer_legal_name", cell(legalName)); + t.put("quote_ref", nz(quote.getQuoteNumber())); + t.put("deployment", ProcurementPricingService.deploymentName(quote.getDeployment())); + t.put("committed_pdfs_yr", String.format(Locale.US, "%,d", Math.max(0, quote.getVolume()))); + t.put("posture", ProcurementPricingService.postureName(quote.getIntensity())); + t.put("processes_per_pdf", String.valueOf(Math.max(1, quote.getIntensity()))); + t.put("rate_per_pdf", String.format(Locale.US, "$%.4f", pricing.effectiveRatePerPdf(cfg))); + t.put("term_years", String.valueOf(quote.getTermYears())); + t.put("term_discount_pct", pricing.termDiscountPct(quote.getTermYears()) + "%"); + t.put("sla_tier", slaTier(quote.getServiceLevel())); + t.put("annual_fee_y1", money(quote.getAnnualNetMinor())); + t.put("contract_total", money(quote.getTcvMinor())); + t.put("elected_or_not", quote.isIndemnification() ? "Elected" : "Not elected"); + t.put("po_number", notBlank(quote.getPoNumber()) ? cell(quote.getPoNumber()) : "—"); + t.put( + "customer_signatory", + signed && notBlank(signing.signatoryName()) + ? cell(signing.signatoryName()) + : BLANK); + t.put( + "customer_signatory_title", + signed && notBlank(signing.signatoryTitle()) + ? cell(signing.signatoryTitle()) + : BLANK); + return t; + } + + /** + * Make a buyer-supplied value safe to slot into a markdown table cell. + * + *

An unescaped {@code |} or newline splits the cell and breaks the Order Form's table. That + * matters beyond appearance: this markdown is what gets hashed into the signature record, so a + * value that restructures the table means the SHA-256 we keep as proof covers a document + * reading differently from the one the signatory saw. + */ + private static String cell(String raw) { + return raw.trim().replace("|", "\\|").replaceAll("\\s*\\R+\\s*", " "); + } + + /** Part B — the Order Form. Generated from the quote; the only per-deal section. */ + private String orderForm(ProcurementQuote quote, Map t) { + String date = t.get("effective_date"); + String signatory = t.get("customer_signatory"); + String signatoryTitle = t.get("customer_signatory_title"); + + StringBuilder sb = new StringBuilder(); + sb.append("## Part B — Order Form · {{quote_ref}}\n\n"); + sb.append("| Term | Value |\n| --- | --- |\n"); + row(sb, "Customer", "{{customer_legal_name}}"); + row(sb, "Subscription", "Enterprise · {{deployment}}"); + row(sb, "Purchase order", "{{po_number}}"); + row(sb, "Committed Volume", "{{committed_pdfs_yr}} PDFs / year at the {{posture}} posture"); + row(sb, "Committed rate", "{{rate_per_pdf}} per PDF"); + row(sb, "Service level", "{{sla_tier}} (per SLA Exhibit)"); + row( + sb, + "Term", + "{{term_years}} year(s) · term discount {{term_discount_pct}} on committed processing"); + row(sb, "Itemized services", itemizedServices(quote)); + row(sb, "Annual Fee (year 1)", "{{annual_fee_y1}}"); + row(sb, "Total (paid in advance)", "{{contract_total}}"); + row(sb, "Escalator", "+3% at each anniversary during the Term"); + row( + sb, + "Payment", + "Full {{term_years}}-year term invoiced in advance on acceptance · net 30 · ACH," + + " wire, or check"); + row(sb, "Overage", "Committed rate, billed quarterly in arrears"); + row( + sb, + "Data schedule", + "First 25 MB per file included; each additional 25 MB or part thereof (decimal MB," + + " rounded up per file, measured once at ingestion) draws down 1 PDF Process." + + " Frozen for the Term (MSA §3.5)."); + row( + sb, + "Drawdown schedule", + "{{posture}}: {{processes_per_pdf}} PDF Processes per PDF (MSA §3.3, frozen for the Term)"); + row( + sb, + "Enhanced IP Protection", + "{{elected_or_not}} — extends §7.3 to patent claims at the §8.2 super-cap"); + row(sb, "Standard terms", "SSO, SCIM, RBAC, and audit logs included."); + + sb.append( + "\n**Itemized services menu (include as elected):** Self-hosted deployment $12,000/yr" + + " · Air-gapped deployment $36,000/yr · Dedicated SE/CSM $30,000/yr · Enhanced IP" + + " Protection (patent coverage, Section 7.3) 5% of committed processing fees ·" + + " Onboarding & training $7,500 one-time · Quarterly business reviews $8,000/yr." + + " Baseline IP indemnification (copyright, trademark, trade secret) is included at" + + " no charge.\n\n"); + sb.append( + "**Signatures.** By signing, each signatory represents they have authority to bind" + + " their Party. Signatures delivered electronically or in counterparts are" + + " effective as originals.\n\n"); + sb.append("| Provider | Customer |\n| --- | --- |\n"); + sb.append("| Stirling PDF, Inc. | {{customer_legal_name}} |\n"); + sb.append("| Name: Matt Joseph | Name: ").append(signatory).append(" |\n"); + sb.append("| Title: CEO | Title: ").append(signatoryTitle).append(" |\n"); + sb.append("| Date: ").append(date).append(" | Date: ").append(date).append(" |\n"); + return sb.toString(); + } + + /** + * The elected add-on lines, taken from the quote's stored breakdown (excludes the base meter). + */ + private String itemizedServices(ProcurementQuote quote) { + List lines = parseLineItems(quote.getLineItemsJson()); + List elected = new ArrayList<>(); + for (QuoteLineItem li : lines) { + if (li.key().equals("usage") + || li.key().equals("seats") + || li.key().equals("multi-year")) { + continue; + } + String suffix = li.kind() == QuoteLineItem.Kind.ONE_TIME ? " (one-time)" : "/yr"; + elected.add(li.label() + " " + money(li.amountMinor()) + suffix); + } + return elected.isEmpty() ? "None elected" : String.join(" · ", elected); + } + + private List parseLineItems(String json) { + if (json == null || json.isBlank()) return List.of(); + try { + return objectMapper.readValue( + json, + objectMapper + .getTypeFactory() + .constructCollectionType(List.class, QuoteLineItem.class)); + } catch (Exception e) { + log.warn("[legal] could not parse quote line items for the order form", e); + return List.of(); + } + } + + private static QuoteConfig toConfig(ProcurementQuote q) { + int users = q.getSeats() == null ? 0 : q.getSeats(); + return new QuoteConfig( + q.getVolume(), + users, + q.getIntensity(), + q.getSizeMult(), + q.getDeployment(), + q.getTermYears(), + q.getServiceLevel(), + q.isIndemnification(), + q.isTraining(), + q.isQbr(), + q.getCurrency()); + } + + private static void row(StringBuilder sb, String term, String value) { + sb.append("| ").append(term).append(" | ").append(value).append(" |\n"); + } + + private static String slaTier(String serviceLevel) { + if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated"; + if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority"; + return "Standard"; + } + + /** Minor units (cents) → whole-dollar display; the quote figures are whole dollars. */ + private static String money(long minor) { + return String.format(Locale.US, "$%,d", minor / 100L); + } + + private static boolean notBlank(String s) { + return s != null && !s.isBlank(); + } + + private static String nz(String s) { + return s == null ? "" : s; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementPdfRenderer.java b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementPdfRenderer.java new file mode 100644 index 0000000000..8204df6514 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementPdfRenderer.java @@ -0,0 +1,71 @@ +package stirling.software.saas.procurement.legal; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.commonmark.Extension; +import org.commonmark.ext.gfm.tables.TablesExtension; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.CustomHtmlSanitizer; +import stirling.software.common.util.FileToPdf; +import stirling.software.common.util.TempFileManager; + +/** + * Renders an assembled agreement's markdown to a PDF, dogfooding Stirling's own conversion path: + * commonmark (markdown → HTML) then {@link FileToPdf#convertHtmlToPdf} (HTML → PDF via WeasyPrint), + * the same pipeline as the product's Markdown-to-PDF tool. + * + *

The signed PDF is a stored artifact, but it must never block signing: {@link #tryRender} + * returns {@code null} if the conversion runtime (WeasyPrint) is unavailable, so the signature is + * still recorded and the buyer keeps the on-the-fly download. + */ +@Service +@RequiredArgsConstructor +public class AgreementPdfRenderer { + + private final RuntimePathConfig runtimePathConfig; + private final TempFileManager tempFileManager; + private final CustomHtmlSanitizer customHtmlSanitizer; + private final CustomPDFDocumentFactory pdfDocumentFactory; + + private static final List EXTENSIONS = List.of(TablesExtension.create()); + + /** Render to PDF, or return null if the conversion runtime isn't available. */ + public byte[] tryRender(String markdown) { + try { + return render(markdown); + } catch (Exception e) { + org.slf4j.LoggerFactory.getLogger(AgreementPdfRenderer.class) + .warn( + "[legal] agreement PDF render unavailable; recording signature without a" + + " stored PDF: {}", + e.getMessage()); + return null; + } + } + + private byte[] render(String markdown) throws Exception { + Parser parser = Parser.builder().extensions(EXTENSIONS).build(); + Node document = parser.parse(markdown); + HtmlRenderer renderer = HtmlRenderer.builder().extensions(EXTENSIONS).build(); + String html = renderer.render(document); + + byte[] pdfBytes = + FileToPdf.convertHtmlToPdf( + runtimePathConfig.getWeasyPrintPath(), + null, + html.getBytes(StandardCharsets.UTF_8), + "agreement.html", + tempFileManager, + customHtmlSanitizer); + return pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementSigning.java b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementSigning.java new file mode 100644 index 0000000000..600ecda415 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementSigning.java @@ -0,0 +1,12 @@ +package stirling.software.saas.procurement.legal; + +/** + * The buyer-supplied inputs captured at the moment of signing the enterprise agreement: the legal + * entity name, the signatory's typed name and title, and their representation of authority to bind. + * Null when the agreement is rendered for preview (before signing). + */ +public record AgreementSigning( + String customerLegalName, + String signatoryName, + String signatoryTitle, + boolean authorityConfirmed) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AssembledAgreement.java b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AssembledAgreement.java new file mode 100644 index 0000000000..3e88cf555a --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AssembledAgreement.java @@ -0,0 +1,16 @@ +package stirling.software.saas.procurement.legal; + +/** + * A rendered enterprise agreement: the full markdown the buyer sees (MSA + Order Form + DPA, tokens + * filled), plus the registry metadata that pins it. {@code variablesJson} is the exact set of + * Order-Form values as rendered, stored alongside a signature so the document is reproducible. + */ +public record AssembledAgreement( + String docId, + String version, + String versionLabel, + String displayName, + String effectiveDate, + String status, + String markdown, + String variablesJson) {} diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementAgreementSignature.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementAgreementSignature.java new file mode 100644 index 0000000000..425b100ba9 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementAgreementSignature.java @@ -0,0 +1,86 @@ +package stirling.software.saas.procurement.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +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 lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * An immutable record of a signed enterprise agreement. Each signature pins the exact legal + * document it was signed against — {@code documentId} + {@code documentVersion} + a SHA-256 {@code + * contentHash} of the rendered markdown — plus the Order-Form variable snapshot and the typed + * signatory details, so the agreement stays reproducible even after the templates version up. The + * rendered PDF is stored when the conversion runtime is available. + */ +@Entity +@Table(name = "procurement_agreement_signature") +@NoArgsConstructor +@Getter +@Setter +public class ProcurementAgreementSignature implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "signature_id") + private Long signatureId; + + @Column(name = "deal_id", nullable = false) + private Long dealId; + + @Column(name = "quote_id", nullable = false) + private Long quoteId; + + // Which legal document, and which version of it, was signed. + @Column(name = "document_id", nullable = false, length = 64) + private String documentId; + + @Column(name = "document_version", nullable = false, length = 32) + private String documentVersion; + + @Column(name = "document_label", length = 64) + private String documentLabel; + + // SHA-256 (hex) of the exact rendered agreement markdown the buyer accepted. + @Column(name = "content_hash", nullable = false, length = 64) + private String contentHash; + + // The Order-Form variable values as rendered, so the document can be reproduced. + @Column(name = "variables_json", columnDefinition = "text") + private String variablesJson; + + @Column(name = "customer_legal_name", length = 255) + private String customerLegalName; + + @Column(name = "signatory_name", nullable = false, length = 255) + private String signatoryName; + + @Column(name = "signatory_title", length = 255) + private String signatoryTitle; + + @Column(name = "authority_confirmed", nullable = false) + private boolean authorityConfirmed; + + @Column(name = "signer_ip", length = 64) + private String signerIp; + + // The rendered PDF artifact; null when the conversion runtime was unavailable at signing. + @Column(name = "pdf") + private byte[] pdf; + + @CreationTimestamp + @Column(name = "signed_at", nullable = false, updatable = false) + private LocalDateTime signedAt; +} 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 index 3a5d3db902..f0db2fb6db 100644 --- 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 @@ -34,6 +34,13 @@ public class ProcurementDeal implements Serializable { private static final long serialVersionUID = 1L; + /** + * Interest, before any commitment: the account asked about enterprise but has not started a + * trial. Kept as a real stage so intent survives a refresh, so the enterprise surface is only + * shown to accounts that asked for it, and so drop-off at the cheapest step is measurable. + */ + public static final String STAGE_EXPLORING = "exploring"; + public static final String STAGE_TRIAL = "trial"; public static final String STAGE_QUOTE = "quote"; public static final String STAGE_AGREEMENT = "security"; @@ -69,12 +76,38 @@ public class ProcurementDeal implements Serializable { @Column(name = "trial_extensions_used", nullable = false) private int trialExtensionsUsed; + // Captured at trial setup, so the buying entity is known before any quote exists — the quote's + // own copies seed from these and may then diverge (a deal can change hands mid-cycle). + // Nullable: trials started before this step, and older clients, supply none. + @Column(name = "business_name", length = 255) + private String businessName; + + @Column(name = "contact_name", length = 255) + private String contactName; + + @Column(name = "contact_email", length = 320) + private String contactEmail; + + // Addresses the buyer named at setup. Kept as the record of what was asked for; the invitations + // themselves go out through the team-invite path when the trial starts. + @Column(name = "invite_emails", length = 2000) + private String inviteEmails; + @Column(name = "license_ref", length = 128) private String licenseRef; @Column(name = "subscription_id", length = 255) private String subscriptionId; + /** + * The last Stripe invoice whose payment was applied to this deal. Distinguishes a redelivered + * {@code invoice.paid} for a payment already handled from a genuine renewal, which has to + * re-issue: the committed licence expires term years from issue, so a renewal that doesn't + * re-issue leaves the licence lapsing after the customer has paid. + */ + @Column(name = "last_paid_invoice_id", length = 255) + private String lastPaidInvoiceId; + @Column(name = "accepted_quote_id") private Long acceptedQuoteId; 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 index aa94128172..19e9e01004 100644 --- 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 @@ -47,7 +47,11 @@ public class ProcurementQuote implements Serializable { @Column(name = "deal_id", nullable = false) private Long dealId; - @Column(name = "quote_number", nullable = false, length = 64) + /** + * Stripe's quote number, the deal's one buyer-facing reference. Null until the quote is issued: + * Stripe assigns it at finalisation, and the issue edge function writes it back then. + */ + @Column(name = "quote_number", length = 64) private String quoteNumber; @Column(name = "status", nullable = false, length = 24) 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 index 08ade5a157..5d1234e9fc 100644 --- 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 @@ -60,12 +60,7 @@ public class ProcurementPricingService { rates.discountPerDoubling() * (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2)) : 0.0; - double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc)); - // File-size multiplier (D93): larger, image-heavy PDFs cost more OCR/compute/storage. Folds - // into the per-run rate after the floor, so it flows through the meter, TCV and renewal. - // QuoteConfig has already snapped it to a known tier, so a tampered request can't sneak a - // cheaper factor in. - rate *= cfg.sizeMult(); + double rate = perRunRate(cfg, rates); double termDisc = rates.termDiscount(cfg.termYears()); // The meter is a whole-dollar figure (the quote reads in dollars), then minor units. @@ -160,6 +155,48 @@ public class ProcurementPricingService { return new QuoteBreakdown(lines, annualNet, tcv, renewalAnnual, cfg.currency()); } + /** + * The per-run rate after the committed-volume curve, the half-cent floor, and the file-size + * multiplier — the same value {@link #price} meters against. Extracted so read-only callers + * (the Order Form) can quote it without re-deriving the curve. + */ + private static double perRunRate(QuoteConfig cfg, PricingRates rates) { + long runVol = Math.max(0, cfg.volume()) * (long) Math.max(1, cfg.intensity()); + double volDisc = + runVol > RUN_CURVE_KNEE + ? Math.min( + 0.5, + rates.discountPerDoubling() + * (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2)) + : 0.0; + return Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc)) + * cfg.sizeMult(); + } + + /** + * The effective per-PDF rate at the chosen posture, in dollars (4-decimal quote figure). This + * is what the Order Form and quote copy speak in — never the per-run rate. Read-only; does not + * affect billing. + */ + public double effectiveRatePerPdf(QuoteConfig cfg) { + return perRunRate(cfg, PricingRates.defaults()) * Math.max(1, cfg.intensity()); + } + + /** The multi-year term discount as a whole-percent figure for the Order Form (0.05 → 5). */ + public int termDiscountPct(int termYears) { + return (int) Math.round(PricingRates.defaults().termDiscount(termYears) * 100.0); + } + + /** Buyer-facing posture name (Essentials / Governed / Regulated) for the given intensity. */ + public static String postureName(int intensity) { + return postureLabel(intensity); + } + + /** Buyer-facing deployment name (Stirling Cloud / Self-hosted / Air-gapped). */ + public static String deploymentName(String deployment) { + return deploymentLabel(deployment); + } + /** The default CPI escalator (fraction) applied to the annual fee on each post-term renewal. */ public double cpiEscalator() { return PricingRates.defaults().cpiEscalator(); diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementAgreementSignatureRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementAgreementSignatureRepository.java new file mode 100644 index 0000000000..419189617e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementAgreementSignatureRepository.java @@ -0,0 +1,29 @@ +package stirling.software.saas.procurement.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import stirling.software.saas.procurement.model.ProcurementAgreementSignature; + +public interface ProcurementAgreementSignatureRepository + extends JpaRepository { + + Optional findFirstByDealIdOrderBySignedAtDesc(Long dealId); + + Optional findFirstByQuoteIdOrderBySignedAtDesc(Long quoteId); + + /** + * Version labels of a deal's signatures, newest first. Projects just the label column so the + * frequently-polled snapshot never loads the PDF bytes. A signature means the agreement is + * signed; the PDF is resolved (stored or re-rendered) at download time. + */ + @Query( + "SELECT s.documentLabel FROM ProcurementAgreementSignature s" + + " WHERE s.dealId = :dealId" + + " ORDER BY s.signedAt DESC") + List findSignedLabels(@Param("dealId") 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 index cae1cf36fb..a739e538b2 100644 --- 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 @@ -5,7 +5,6 @@ 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; @@ -20,16 +19,24 @@ import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.procurement.config.ProcurementConfigurationProperties; +import stirling.software.saas.procurement.legal.AgreementAssembler; +import stirling.software.saas.procurement.legal.AgreementPdfRenderer; +import stirling.software.saas.procurement.legal.AgreementSigning; +import stirling.software.saas.procurement.legal.AssembledAgreement; import stirling.software.saas.procurement.license.EnterpriseLicenseService; import stirling.software.saas.procurement.license.LicenseEntitlements; +import stirling.software.saas.procurement.model.ProcurementAgreementSignature; import stirling.software.saas.procurement.model.ProcurementDeal; import stirling.software.saas.procurement.model.ProcurementQuote; import stirling.software.saas.procurement.model.QuoteDetails; 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.ProcurementAgreementSignatureRepository; import stirling.software.saas.procurement.repository.ProcurementDealRepository; import stirling.software.saas.procurement.repository.ProcurementQuoteRepository; +import stirling.software.saas.service.SaasTeamService; +import stirling.software.saas.util.LogRedactionUtils; /** * Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a @@ -52,6 +59,12 @@ public class ProcurementService { private final EnterpriseLicenseService licenses; private final ProcurementConfigurationProperties config; private final TeamMembershipRepository memberRepo; + private final AgreementAssembler agreementAssembler; + private final AgreementPdfRenderer agreementPdfRenderer; + private final ProcurementAgreementSignatureRepository signatureRepo; + // Trial-setup invitations run through the team-invite path, with its seat, role and + // rate-limit rules rather than a second implementation here. + private final SaasTeamService teams; public ProcurementService( ProcurementDealRepository dealRepo, @@ -59,13 +72,21 @@ public class ProcurementService { ProcurementPricingService pricing, EnterpriseLicenseService licenses, ProcurementConfigurationProperties config, - TeamMembershipRepository memberRepo) { + TeamMembershipRepository memberRepo, + AgreementAssembler agreementAssembler, + AgreementPdfRenderer agreementPdfRenderer, + ProcurementAgreementSignatureRepository signatureRepo, + SaasTeamService teams) { this.dealRepo = dealRepo; this.quoteRepo = quoteRepo; this.pricing = pricing; this.licenses = licenses; this.config = config; this.memberRepo = memberRepo; + this.agreementAssembler = agreementAssembler; + this.agreementPdfRenderer = agreementPdfRenderer; + this.signatureRepo = signatureRepo; + this.teams = teams; } /** @@ -89,11 +110,45 @@ public class ProcurementService { return dealRepo.findByTeamId(teamId); } + /** + * Starting a trial is only legitimate before one exists, while the buyer is still exploring, or + * to restart within the trial itself. A null stage is a deal that has just been constructed. + * + *

Package-private so the policy can be tested without the service's ten dependencies. Adding + * a later stage here would let a leader replace a paying customer's committed licence. + */ + static boolean canStartTrial(String stage) { + return stage == null + || ProcurementDeal.STAGE_EXPLORING.equals(stage) + || ProcurementDeal.STAGE_TRIAL.equals(stage); + } + @Transactional(readOnly = true) public List quotesForDeal(Long dealId) { return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId); } + /** + * Record that the account is looking at enterprise. Creates the deal at {@code exploring} when + * there is none; an existing deal is returned untouched, so this can never walk a live deal + * backwards or restart a trial. + */ + @Transactional + public ProcurementDeal recordInterest(Long teamId) { + return dealRepo.findByTeamId(teamId) + .orElseGet( + () -> { + ProcurementDeal deal = new ProcurementDeal(teamId); + deal.setStage(ProcurementDeal.STAGE_EXPLORING); + ProcurementDeal saved = dealRepo.save(deal); + log.info( + "[procurement] interest recorded team={} deal={}", + teamId, + saved.getDealId()); + return saved; + }); + } + /** * 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 @@ -101,10 +156,40 @@ public class ProcurementService { * ({@code cloud}/{@code selfhost}/{@code airgap}) and seat count are captured here so the quote * builder opens seeded to their environment; both are still editable when the quote is built. */ - @Transactional public ProcurementDeal startTrial(Long teamId, String deployment, int seats) { + return startTrial(teamId, deployment, seats, null, null, null, null); + } + + /** + * Start (or restart) the trial, capturing the buying entity if the setup step collected it. + * Blank details are ignored rather than written, so a re-run without them keeps what is there. + * + *

Only from before the trial or during it. Past that, {@code licenseRef} points at the + * committed annual licence, and this method would replace it with a fresh 14-day trial key + * while Stripe kept billing — the same hazard {@link #extendTrial} guards against, one step + * worse because it re-issues rather than re-dates. It would also rewind the stage and reset the + * extension counter. + * + *

The transaction is declared here rather than on the 3-arg overload: that one only + * delegates, and Spring's proxy cannot intercept a self-invocation, so an annotation there does + * nothing for either path. This is the method the controller calls, and it reaches out to + * Keygen between the read and the write. + */ + @Transactional + public ProcurementDeal startTrial( + Long teamId, + String deployment, + int seats, + String businessName, + String contactName, + String contactEmail, + String inviteEmails) { ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId)); + if (!canStartTrial(deal.getStage())) { + throw new IllegalStateException( + "Trial cannot be started from stage " + deal.getStage()); + } LocalDateTime now = LocalDateTime.now(); LocalDateTime ends = now.plusDays(config.getTrialDurationDays()); deal.setStage(ProcurementDeal.STAGE_TRIAL); @@ -113,6 +198,10 @@ public class ProcurementService { deal.setTrialStartedAt(now); deal.setTrialEndsAt(ends); deal.setTrialExtensionsUsed(0); + if (isNotBlank(businessName)) deal.setBusinessName(businessName.trim()); + if (isNotBlank(contactName)) deal.setContactName(contactName.trim()); + if (isNotBlank(contactEmail)) deal.setContactEmail(contactEmail.trim()); + if (isNotBlank(inviteEmails)) deal.setInviteEmails(inviteEmails.trim()); deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends)); deal = dealRepo.save(deal); log.info( @@ -128,6 +217,45 @@ public class ProcurementService { /** * Constrain a caller-supplied deployment to the known set; anything else falls back to cloud. */ + /** + * Send the invitations named at trial setup. Best-effort per address: a rejection (already a + * member, an invitee with their own paid plan, the hourly rate limit) must not fail the trial, + * so each is logged and skipped rather than propagated. + * + *

Note the first accepted invitation converts a personal team into a shared one with + * unlimited seats — that is {@code inviteUserToTeam}'s own rule, and naming teammates here is + * the buyer asking for exactly that. + */ + public void sendTrialInvites( + Long teamId, stirling.software.proprietary.security.model.User inviter, String emails) { + if (inviter == null || !isNotBlank(emails)) return; + for (String raw : emails.split("[,;\s]+")) { + String email = raw.trim(); + if (email.isEmpty()) continue; + try { + teams.inviteUserToTeam(teamId, email, inviter); + // Redacted: an invitee list is third-party PII, and these logs are the one place it + // would otherwise be written in full. LogRedactionUtils is what the rest of the + // SaaS + // module uses for the same reason. + log.info( + "[procurement] trial invite sent team={} to={}", + teamId, + LogRedactionUtils.redactEmail(email)); + } catch (Exception e) { + log.warn( + "[procurement] trial invite skipped team={} to={}: {}", + teamId, + LogRedactionUtils.redactEmail(email), + e.getMessage()); + } + } + } + + private static boolean isNotBlank(String value) { + return value != null && !value.isBlank(); + } + private static String normalizeDeployment(String deployment) { if (deployment == null) return "cloud"; String d = deployment.trim().toLowerCase(Locale.ROOT); @@ -172,16 +300,32 @@ public class ProcurementService { } // (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. + QuoteBreakdown breakdown = pricing.price(cfg); + // Enforced before anything is persisted, and server-side rather than in the builder: the + // pricing curve has no natural floor (a small enough committed volume rounds the meter to + // zero) and every registered user leads their own team, so without this any signup could + // price a $0 enterprise quote, accept it, and be provisioned a committed licence. + long floor = config.getMinAnnualNetMinor(); + if (floor > 0 && breakdown.annualNetMinor() < floor) { + throw new IllegalStateException( + "Quoted annual fee " + + breakdown.annualNetMinor() + + " is below the minimum enterprise deal size " + + floor); + } + 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. + // No quote number here: the deal's one reference is Stripe's, and Stripe does not assign it + // until the quote is finalised. The edge fn creates the Stripe Quote, flips this to SENT, + // and + // writes the number back. Nothing displays a reference in between — the builder only shows + // one + // for an issued quote, and the agreement is not assembled until after issue. quote.setStatus(ProcurementQuote.STATUS_DRAFT); quote.setCurrency(cfg.currency()); quote.setVolume(cfg.volume()); @@ -210,10 +354,11 @@ public class ProcurementService { quote.setLineItemsJson(writeLineItems(breakdown)); quote.setValidUntil(LocalDate.now().plusDays(30)); quote = quoteRepo.save(quote); + // Logged by id, not reference: a draft has no reference until Stripe issues it. log.info( "[procurement] quote built team={} quote={} annualNet={} tcv={}", teamId, - quote.getQuoteNumber(), + quote.getQuoteId(), quote.getAnnualNetMinor(), quote.getTcvMinor()); return quote; @@ -241,6 +386,164 @@ public class ProcurementService { return deal; } + /** The quote a team is currently transacting on: its accepted quote, else the most recent. */ + @Transactional(readOnly = true) + public Optional currentQuote(Long teamId) { + return dealRepo.findByTeamId(teamId) + .flatMap( + deal -> { + if (deal.getAcceptedQuoteId() != null) { + Optional accepted = + quoteRepo.findById(deal.getAcceptedQuoteId()); + if (accepted.isPresent()) return accepted; + } + return quoteRepo + .findByDealIdOrderByCreatedAtDesc(deal.getDealId()) + .stream() + .findFirst(); + }); + } + + /** + * The filled enterprise agreement for a team's current quote, rendered for review (unsigned). + */ + @Transactional(readOnly = true) + public Optional agreementDocument(Long teamId) { + return currentQuote(teamId).map(q -> agreementAssembler.assemble(q, null)); + } + + /** + * The current (unsigned) agreement rendered to PDF, for download at the sign step. Empty when + * there's no quote yet or the render runtime is unavailable. The signed PDF (with the signature + * block filled) is a separate artifact recorded at signing (see {@link #latestSignature}). + */ + @Transactional(readOnly = true) + public Optional agreementDocumentPdf(Long teamId) { + return currentQuote(teamId) + .map(q -> agreementAssembler.assemble(q, null)) + .map(a -> agreementPdfRenderer.tryRender(a.markdown())); + } + + /** + * Record a signed enterprise agreement: assemble the final document, hash it, render + store + * the PDF (best-effort), and persist an immutable signature pinned to the exact document + * version. Does not itself accept the quote into a subscription — the caller proceeds to accept + * as before. + * + *

Deliberately not {@code @Transactional}: rendering the PDF shells out to WeasyPrint, and + * holding the deal's row lock across an external process buys nothing here. The only write is a + * single insert, which {@code save} makes atomic on its own. + */ + public ProcurementAgreementSignature signAgreement( + Long teamId, AgreementSigning signing, String signerIp) { + ProcurementDeal deal = + dealRepo.findByTeamId(teamId) + .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId)); + ProcurementQuote quote = + currentQuote(teamId) + .orElseThrow( + () -> + new IllegalStateException( + "No quote to sign for team " + teamId)); + + // Signing is only meaningful against an issued quote at the agreement stage. Without this a + // direct API call could record a signature over a draft (whose quote_ref is still empty), + // or + // re-sign a deal that has already moved on. + if (!ProcurementDeal.STAGE_AGREEMENT.equals(deal.getStage())) { + throw new IllegalStateException( + "Deal is not at the agreement stage for team " + teamId); + } + if (!ProcurementQuote.STATUS_SENT.equals(quote.getStatus())) { + throw new IllegalStateException("Quote is not issued for team " + teamId); + } + + AssembledAgreement assembled = agreementAssembler.assemble(quote, signing); + + ProcurementAgreementSignature sig = new ProcurementAgreementSignature(); + sig.setDealId(deal.getDealId()); + sig.setQuoteId(quote.getQuoteId()); + sig.setDocumentId(assembled.docId()); + sig.setDocumentVersion(assembled.version()); + sig.setDocumentLabel(assembled.versionLabel()); + sig.setContentHash(sha256(assembled.markdown())); + sig.setVariablesJson(assembled.variablesJson()); + sig.setCustomerLegalName(signing.customerLegalName()); + sig.setSignatoryName(signing.signatoryName()); + sig.setSignatoryTitle(signing.signatoryTitle()); + sig.setAuthorityConfirmed(signing.authorityConfirmed()); + sig.setSignerIp(signerIp); + sig.setPdf(agreementPdfRenderer.tryRender(assembled.markdown())); + sig = signatureRepo.save(sig); + log.info( + "[procurement] agreement signed team={} quote={} doc={} pdf={}", + teamId, + quote.getQuoteId(), + assembled.versionLabel(), + sig.getPdf() != null); + return sig; + } + + /** The latest recorded signature for a team's deal, if any (for the signed-PDF download). */ + @Transactional(readOnly = true) + public Optional latestSignature(Long teamId) { + return dealRepo.findByTeamId(teamId) + .flatMap( + deal -> + signatureRepo.findFirstByDealIdOrderBySignedAtDesc( + deal.getDealId())); + } + + /** + * The version label of the deal's latest signed agreement, if any. Used to surface the + * "download signed agreement" action once a signature exists; the snapshot polls this, so it + * deliberately avoids loading the PDF bytes. + * + *

Deliberately not conditional on a stored PDF: download re-renders from the pinned document + * version on demand, so the action works whether or not the render succeeded at signing time. + */ + @Transactional(readOnly = true) + public Optional signedAgreementLabel(Long dealId) { + return signatureRepo.findSignedLabels(dealId).stream().findFirst(); + } + + /** + * The signed agreement as a PDF for download: the artifact stored at signing, or — if the + * render runtime was unavailable then — re-rendered now from the signature's details. Empty + * when the team has no signature or the render runtime is still unavailable. + */ + @Transactional(readOnly = true) + public Optional signedAgreementPdf(Long teamId) { + return latestSignature(teamId) + .flatMap( + sig -> { + if (sig.getPdf() != null) return Optional.of(sig.getPdf()); + return quoteRepo + .findById(sig.getQuoteId()) + .map( + q -> + agreementAssembler.assemble( + q, + new AgreementSigning( + sig.getCustomerLegalName(), + sig.getSignatoryName(), + sig.getSignatoryTitle(), + sig.isAuthorityConfirmed()))) + .map(a -> agreementPdfRenderer.tryRender(a.markdown())); + }); + } + + private static String sha256(String s) { + try { + byte[] digest = + java.security.MessageDigest.getInstance("SHA-256") + .digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(digest); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + /** * Provision on accept: upgrade the team's licence to the committed annual term (valid * immediately), so the buyer can get going the moment they accept — before the invoice is paid. @@ -260,22 +563,58 @@ public class ProcurementService { } /** - * Mark the deal fully live (advance to the active stage) once payment settles. In production - * this is the {@code invoice.paid} webhook; here it's the demo/manual stand-in. Re-affirms the + * Mark the deal fully live (advance to the active stage) once payment settles — driven by the + * {@code invoice.paid} webhook, and by the demo control when those are enabled. Re-affirms the * annual licence in case provisioning didn't run at accept. + * + *

Idempotent per invoice rather than per stage, which matters because {@code invoice.paid} + * carries two different meanings. Stripe redelivers events, so the same invoice + * arriving twice must do nothing. But the renewal payment a year later is also an {@code + * invoice.paid}, and the committed licence expires term years from issue — so a + * different invoice has to re-issue, moving the expiry out, or the customer's licence + * lapses after they have paid. Keying on the stage alone couldn't tell those apart and treated + * every renewal as a duplicate. + * + * @param paidInvoiceId the Stripe invoice that was paid, or null when the caller has no invoice + * to identify the payment by (the demo control). Null keeps the old conservative behaviour: + * a live deal short-circuits, since there is nothing to tell a renewal from a repeat. */ @Transactional - public ProcurementDeal markLive(Long teamId) { + public ProcurementDeal markLive(Long teamId, String paidInvoiceId) { ProcurementDeal deal = dealRepo.findByTeamId(teamId) .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId)); + if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage()) + && (paidInvoiceId == null || paidInvoiceId.equals(deal.getLastPaidInvoiceId()))) { + log.debug( + "[procurement] invoice.paid already applied team={} deal={} invoice={}", + teamId, + deal.getDealId(), + paidInvoiceId); + return deal; + } + boolean renewal = ProcurementDeal.STAGE_LIVE.equals(deal.getStage()); deal.setLicenseRef(issueOrUpgradeAnnual(deal)); + if (paidInvoiceId != null) deal.setLastPaidInvoiceId(paidInvoiceId); deal.setStage(ProcurementDeal.STAGE_LIVE); deal = dealRepo.save(deal); - log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId()); + log.info( + "[procurement] deal live team={} deal={} renewal={} invoice={}", + teamId, + deal.getDealId(), + renewal, + paidInvoiceId); return deal; } + /** + * Go live with no invoice reference — the demo control. See {@link #markLive(Long, String)}. + */ + @Transactional + public ProcurementDeal markLive(Long teamId) { + return markLive(teamId, null); + } + /** * Issue or upgrade the committed annual licence from the deal's accepted (else latest) quote, * stamping the full entitlement snapshot onto it and upgrading the trial licence in place when @@ -327,15 +666,34 @@ public class ProcurementService { * before paying — that's bounded: the trial licence carries {@code expiry = trialEndsAt}, so * the file the verifier accepts self-expires at trial end. The buyer must re-download after * provisioning to get the committed-term file (the portal warns about this). + * + *

Once a quote exists, the quote's deployment decides — not the deal's. They are two + * different values: the deal's is chosen free at trial setup, the quote's is the one carrying + * the air-gap deploy fee. Reading the deal's here meant selecting air-gapped in the trial and + * then buying a cloud quote still yielded the offline file, and after provisioning it was + * checked out against the committed annual licence — so the self-expiry above no longer bounded + * it. */ @Transactional(readOnly = true) public Optional offlineLicenseFile(Long teamId) { ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null); if (deal == null || deal.getLicenseRef() == null) return Optional.empty(); - if (!"airgap".equalsIgnoreCase(deal.getDeployment())) return Optional.empty(); + if (!"airgap".equalsIgnoreCase(entitledDeployment(deal))) return Optional.empty(); return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef())); } + /** + * The deployment the team is actually entitled to: the priced quote's once one exists, + * otherwise the trial's self-selected target. Paid entitlements must follow what was quoted. + */ + private String entitledDeployment(ProcurementDeal deal) { + ProcurementQuote quote = currentQuote(deal.getTeamId()).orElse(null); + if (quote != null && quote.getDeployment() != null && !quote.getDeployment().isBlank()) { + return quote.getDeployment(); + } + return deal.getDeployment(); + } + /** * Reset a team's procurement: delete the deal (quotes + activity cascade). For * re-demos/testing. @@ -346,12 +704,6 @@ public class ProcurementService { 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()); diff --git a/app/saas/src/main/resources/legal/enterprise-agreement/0.9.1/dpa.md b/app/saas/src/main/resources/legal/enterprise-agreement/0.9.1/dpa.md new file mode 100644 index 0000000000..12cceaef65 --- /dev/null +++ b/app/saas/src/main/resources/legal/enterprise-agreement/0.9.1/dpa.md @@ -0,0 +1,53 @@ +## Part C — Data Processing Addendum + +This DPA forms part of the Agreement and applies where Provider processes Personal Data on Customer's behalf. + +### C1. Roles; scope; instructions + +Customer is the controller (or a processor on behalf of its own controllers); Provider is a processor (or subprocessor, as applicable). Provider processes Personal Data only on Customer's documented instructions — including processing initiated by Customer's users, policies, pipelines, and API calls — unless required by law (in which case Provider informs Customer unless legally prohibited). **Provider will inform Customer without undue delay if, in Provider's opinion, an instruction infringes the GDPR, UK GDPR, or other applicable data-protection law.** Customer is responsible for the lawfulness of the Personal Data it submits and the instructions it gives; Customer's rights under this DPA include instruction, audit (C9), objection to subprocessors (C5), assistance (C6), and return or deletion of data (C10). + +### C2. Details of processing + +**Subject matter/nature:** PDF processing and governance (classification, redaction, routing, retention, conversion, signing, extraction, AI-assisted analysis). **Duration:** the Term plus the deletion period. **Categories of data:** any Personal Data contained in Customer files and metadata (names, contact details, identifiers, financial or health data if present in Customer files), account data of Customer users. **Data subjects:** Customer's employees, users, customers, and other persons appearing in Customer files. **Sensitive data:** may be present in Customer files at Customer's discretion; Customer is responsible for the lawful basis. + +### C3. Confidentiality; personnel + +Provider ensures persons authorized to process Personal Data are bound by confidentiality and receive security training. Zero-standing-access applies: content access is just-in-time, logged, and audited (MSA Section 4.2). + +### C4. Security measures (Annex II summary) + +Encryption in transit (TLS 1.2+) and at rest (AES-256); zero-standing-access with audited JIT elevation; role-based access control; SSO/SCIM; tenant isolation; vulnerability management and penetration testing; audit logging of processing events (including file name, hash, size, and operations); backup and recovery. For Self-hosted and Air-gapped deployments, Customer operates the runtime environment and is responsible for infrastructure-level controls; Provider's measures apply to license/metering services and support access. + +### C5. Subprocessors + +Customer generally authorizes the subprocessors listed at {{subprocessor_url}}: cloud infrastructure (Amazon Web Services), payment processing (Stripe, as independent controller for payment data), email delivery (Google), account infrastructure (Supabase), product telemetry (PostHog, EU-hosted; pseudonymous usage events, never file content), and AI model providers: **Anthropic** (Claude models — receives prompts and the document text or excerpts needed for the requested AI feature) and **Voyage AI** (embedding models — receives extracted text excerpts solely to generate embeddings where Customer enables Ingestion/RAG features). AI features are optional and may be disabled; where used, AI providers receive only the content needed for the requested feature. **Whole Customer files are never transmitted to any AI provider.** Neither AI provider trains on Customer data (verified against the signed provider agreements, Jul 10, 2026). Provider gives thirty (30) days' notice of new subprocessors; Customer may object on reasonable data-protection grounds, and if unresolved, may terminate the affected Services with a pro-rata refund. **Provider imposes data-protection obligations on each subprocessor by written contract that are at least as protective as this DPA, and remains fully responsible to Customer for each subprocessor's performance.** + +### C6. Data subject requests; assistance + +Taking into account the nature of the processing, Provider provides reasonable assistance (including through the Processor's search, redaction, and audit tools) for Customer's obligations under GDPR Articles 32–36: security of processing, breach notification to authorities and data subjects, data protection impact assessments, and prior consultations with supervisory authorities, as well as responses to data subject requests. Provider forwards requests received directly to Customer and does not respond except as legally required. **Provider makes available to Customer all information necessary to demonstrate compliance with this DPA and allows for and contributes to audits, including inspections, per Section C9.** + +### C7. Breach notification + +Per MSA Section 5.3: without undue delay after becoming aware of a Personal Data Breach, and in any event within forty-eight (48) hours of awareness, with information provided in phases as available — including the nature of the breach, categories and approximate volumes affected, likely consequences, and measures taken or proposed. + +### C8. International transfers + +Where Personal Data subject to GDPR/UK GDPR is transferred to countries without adequacy, the Parties incorporate the EU Standard Contractual Clauses (Commission Decision 2021/914): **Module 2** (controller-to-processor) where Customer is a controller, and **Module 3** (processor-to-processor) where Customer acts as a processor, with the following selections — Clause 7 (docking): included; Clause 9(a): Option 2 (general written authorization, 30 days' notice per C5); Clause 11(a) optional language: not used; Clause 17: the law of Ireland; Clause 18: the courts of Ireland; competent supervisory authority: the Irish Data Protection Commission (per Annex I.C). Annex I (parties, description of transfer: as per Section C2), Annex II (technical and organizational measures: as per Section C4), and Annex III (subprocessors: as per Section C5 and {{subprocessor_url}}) are completed by reference to this DPA. For UK transfers, the UK International Data Transfer Addendum applies with its Tables completed by reference to the foregoing. Provider is not certified under the EU-U.S. Data Privacy Framework; the SCCs are the transfer mechanism. + +**Note:** Provider does not currently offer contractual EU data residency for Stirling Cloud; residency is achieved via Self-hosted or Air-gapped deployment. + +### C9. Audits + +Provider's security reports and documentation (Section 5.1) are the ordinary means of demonstrating compliance. Customer may additionally audit — by itself or a mandated auditor — once per year on thirty (30) days' notice, and at any time where: (a) a security incident affecting Customer Personal Data has occurred; (b) provided documentation reveals a material deficiency; (c) a competent supervisory authority requires it; or (d) Customer reasonably suspects material noncompliance with this DPA. Audits are conducted during business hours, under confidentiality, at Customer's cost, with reasonable notice, without unreasonable interference with Provider's operations, and without access to other customers' data. + +### C10. Return & deletion + +On termination, at Customer's choice, Provider returns Customer file content and Personal Data (export of Customer files and the governed-record metadata) and/or deletes them — from live systems within thirty (30) days and from backups within ninety (90) days — except as retention is required by law, and certifies deletion on request. Where Customer uses HYOK, key destruction by Customer renders content cryptographically inaccessible immediately. + +### C11. CCPA/CPRA + +Provider is a "service provider" under the CCPA/CPRA. Provider: (a) processes Personal Information only for the business purposes specified in this Agreement — providing, securing, metering, and supporting the Services described in Section C2; (b) shall not sell or share Personal Information; (c) shall not retain, use, or disclose it for any purpose other than those business purposes, or outside the direct business relationship between the Parties; (d) shall not combine it with Personal Information received from other sources, except as permitted by CCPA regulations for the business purposes; (e) provides the same level of privacy protection required of businesses by the CCPA; (f) will notify Customer if it determines it can no longer meet its CCPA obligations; (g) grants Customer the right, upon reasonable notice, to take reasonable and appropriate steps to ensure Provider's use of Personal Information is consistent with Customer's obligations, and to stop and remediate any unauthorized use; and (h) flows these requirements down to its subprocessors per Section C5. Provider certifies that it understands these restrictions and will comply with them. + +### C12. Liability + +Liability under this DPA is subject to the MSA's limitations (Section 8). diff --git a/app/saas/src/main/resources/legal/enterprise-agreement/0.9.1/msa.md b/app/saas/src/main/resources/legal/enterprise-agreement/0.9.1/msa.md new file mode 100644 index 0000000000..cd3f6742eb --- /dev/null +++ b/app/saas/src/main/resources/legal/enterprise-agreement/0.9.1/msa.md @@ -0,0 +1,119 @@ +# Stirling Enterprise Agreement + +One signature executes all three parts: the Master Services Agreement (Part A), the Order Form (Part B), and the Data Processing Addendum (Part C). The Stirling EULA & Commercial Terms is incorporated by reference. + +## Part A — Master Services Agreement + +This Master Services Agreement (the "Agreement") is entered into as of {{effective_date}} (the "Effective Date") by and between **Stirling PDF, Inc.**, a Delaware corporation with offices at 548 Market Street PMB 887643, San Francisco, CA 94104 ("Provider"), and **{{customer_legal_name}}** ("Customer"). Each a "Party," together the "Parties." + +### 1. Services & License + +1.1 **The Services.** Provider will provide the Stirling PDF Processor (the "Processor") — the hosted or customer-deployed platform for distributing PDF editors and governing PDF processing, including policies, pipelines, the Stirling Agent, API access, and the administrative console — and the Stirling PDF Editor (the "Editor"), as described in the Order Form. + +1.2 **License grants.** Provider grants Customer, for the Term: (a) a non-exclusive, non-transferable right to access and use the Processor for Customer's internal business operations, up to the Committed Volume; and (b) a non-exclusive right to deploy and distribute the Editor to Customer's authorized users without limit on user count. Open-source components of the Editor remain governed by their own licenses, which control for those components. + +1.3 **Deployment.** The Services are delivered via the deployment stated in the Order Form (Stirling Cloud, Self-hosted, or Air-gapped). Self-hosted deployments validate their license and report metering data online; Air-gapped deployments verify a signed activation bundle offline and reconcile usage periodically as described in the Documentation. Customer shall not disable, circumvent, or falsify license validation or usage metering. **The metered rate does not vary by deployment**; deployment-specific services are priced as line items in the Order Form. + +1.4 **Restrictions.** Customer shall not: resell or provide the Services to third parties as a service bureau; reverse engineer non-open-source components; use the Services to violate law; or exceed the scope of the Order Form other than through Overage (Section 3.4). + +### 2. Term & Renewal + +2.1 **Initial Term.** {{term_years}} year(s) from the Effective Date. + +2.2 **Renewal.** The Agreement auto-renews for successive periods equal to the Initial Term unless either Party gives sixty (60) days' written notice of non-renewal before the end of the then-current term. The Annual Fee for each renewal year equals the immediately preceding year's Annual Fee increased by three percent (3%) — the same formula as Section 2.3. Itemized services escalate at the same rate unless restated in a superseding Order Form. + +2.3 **In-term escalator.** The Annual Fee (including itemized services) increases by a fixed three percent (3%) at each anniversary of the Effective Date during the Term. + +### 3. Fees & Payment + +3.1 **Annual Fee.** Customer shall pay the Annual Fee stated in the Order Form, calculated as the Committed Volume ({{committed_pdfs_yr}} PDFs per year) at {{rate_per_pdf}} per PDF at the {{posture}} governance posture, plus the itemized services in the Order Form, less the term discount stated there. + +3.2 **Invoicing.** Fees are invoiced annually in advance, due net thirty (30) days. Late amounts accrue interest at 1.5% per month or the maximum permitted by law, whichever is less. Fees are exclusive of taxes; Customer is responsible for all taxes other than Provider's income taxes. + +3.3 **Committed Volume; measurement.** The Committed Volume is denominated in PDFs processed per year at the stated posture, and converts to a drawdown allowance in PDF Processes at the fixed conversion schedule below, which is frozen for the Term: + +| Posture | PDF Processes per PDF | +| --- | --- | +| Essentials | 2 | +| Governed | 4 | +| Regulated | 7 | + +A **"PDF Process"** is one policy execution, one pipeline run, or one Stirling Agent returned artifact, applied to one file, plus Data Processing increments under Section 3.5. For clarity: a pipeline run counts as one PDF Process regardless of the number of operations in its chain; a Stirling Agent artifact counts as one regardless of the number of messages that produced it; failed processes (those that do not complete) are not counted; reprocessing the same file and duplicate submissions are counted; counts are whole numbers (no rounding). The Processor's audit log records each PDF Process and is the system of record, subject to Section 3.7. Provider will make a per-file usage statement (file identifier, size, processes, drawdown) available for audit. + +**Worked example.** At the Governed posture, a commitment of 90,000,000 PDFs/year provides a drawdown allowance of 360,000,000 PDF Processes. A 60 MB file that runs the four Governed policies draws down 4 PDF Processes plus 2 Data Processing increments (Section 3.5) = 6 PDF Processes. The allowance is a purchased quantity, not a feature limit: Customer may run any number of policies or pipelines; actual consumption simply draws the allowance down faster, and consumption beyond it bills as Overage (Section 3.4). + +3.4 **Overage.** Consumption beyond the Committed Volume in a contract year is billed quarterly in arrears at the committed rate stated in the Order Form. Overage does not increase subsequent years' Committed Volume. + +3.5 **Data Processing.** Each file includes its first twenty-five (25) megabytes (decimal, 1 MB = 1,000,000 bytes) at no additional drawdown. Each additional twenty-five (25) megabytes or part thereof (rounded up per file) draws down one (1) additional PDF Process. File size is measured once per file at ingestion, on the file as submitted. This schedule is stated here in full, is frozen for the Term, and is not subject to alteration through the Documentation. + +3.6 **No refunds.** Except as expressly stated (Sections 7.1, 7.3, 10.3, and DPA Section C5), fees are non-refundable and Committed Volume does not roll over between contract years. + +3.7 **Billing disputes.** Customer may dispute any invoice or metering record in good faith within sixty (60) days of the invoice date. Provider will investigate promptly, provide the relevant audit-log extracts and usage statements, and correct confirmed errors by credit or refund. The audit log is presumptively accurate but not conclusive; Customer may rebut it with reasonable evidence. Undisputed amounts remain payable when due. + +### 4. Data Protection + +4.1 The Data Processing Addendum at Part C (the "DPA") is incorporated into this Agreement and governs Provider's processing of Customer Personal Data, in compliance with the GDPR, UK GDPR, and CCPA/CPRA to the extent applicable. + +4.2 **Zero-standing-access.** Customer file content is encrypted in transit and at rest. Provider personnel have no standing access to Customer file content; access is granted just-in-time under audited elevation, solely as necessary to provide the Services or as instructed by Customer. Document metadata is maintained to operate the governed record. Where the Order Form includes BYOK or HYOK key management, the key terms in the Documentation apply. + +### 5. Security & Availability + +5.1 **Security program.** Provider maintains a written information security program including access controls, encryption (TLS 1.2+ in transit, AES-256 at rest), audit logging, vulnerability management, and personnel security. Provider will provide its available security documentation (including its security program overview and penetration-test attestation) upon request under confidentiality. + +5.2 **Availability.** For Stirling Cloud deployments, Provider targets 99.9% monthly uptime, excluding scheduled maintenance announced at least 48 hours in advance. The uptime figure is a target, not a credited commitment, and no service credits apply. Support response commitments for the {{sla_tier}} tier are set out in the SLA Exhibit referenced by the Order Form. + +5.3 **Breach notice.** Provider will notify Customer without undue delay after becoming aware of a Personal Data Breach affecting Customer Personal Data, and in any event within forty-eight (48) hours of awareness. Provider may provide information in phases as it becomes available and will supplement its notice as investigation proceeds. + +5.4 **Updates.** Provider will make security patches and product upgrades available to Customer at no additional charge for supported versions. + +### 6. Confidentiality + +6.1 Each Party shall protect the other's Confidential Information with at least the care it uses for its own similar information and no less than reasonable care, use it solely to perform under this Agreement, and disclose it only to personnel and advisors with a need to know who are bound by confidentiality obligations at least as protective. Confidential Information excludes information that is public without breach, independently developed, or rightfully received from a third party. + +6.2 Compelled disclosure is permitted with prompt notice (where lawful) and reasonable cooperation to seek protective treatment. + +6.3 Obligations survive three (3) years after termination; trade secrets survive as long as they remain trade secrets. + +### 7. Warranties & Indemnification + +7.1 **Performance warranty.** Provider warrants the Services will perform materially in accordance with the Documentation. Customer's exclusive remedy for breach is re-performance or, if Provider cannot re-perform within thirty (30) days, termination of the affected Services and a pro-rata refund of prepaid, unused fees for those Services. + +7.2 **Mutual warranties.** Each Party warrants it has the authority to enter this Agreement and will comply with applicable law in its performance. + +7.3 **IP indemnification.** Provider shall defend Customer against third-party claims that the Services, as provided and used per this Agreement, infringe a copyright or trademark or misappropriate a trade secret, and shall indemnify Customer for resulting damages finally awarded or agreed in settlement. Where **Enhanced IP Protection** is elected on the Order Form, this obligation extends to patent claims and carries the enhanced cap stated in Section 8.2. Exclusions: combinations with non-Provider materials, Customer content, modifications not made by Provider, and use after notice to stop. Provider may procure rights, modify, or replace the Services; if none is practicable, Provider may terminate the affected Services and refund prepaid, unused fees. This section states Customer's exclusive remedy for IP claims. + +7.4 **Customer indemnification.** Customer shall defend and indemnify Provider against third-party claims arising from Customer content, Customer's breach of Section 1.4, or Customer's violation of law. + +7.5 **Disclaimer.** EXCEPT AS EXPRESSLY STATED, THE SERVICES ARE PROVIDED WITHOUT OTHER WARRANTIES, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. AI-ASSISTED OUTPUTS (INCLUDING CLASSIFICATION, EXTRACTION, AND AGENT ARTIFACTS) ARE PROBABILISTIC; CUSTOMER IS RESPONSIBLE FOR HUMAN REVIEW WHERE OUTPUTS HAVE LEGAL OR REGULATORY EFFECT. + +### 8. Limitation of Liability + +8.1 NEITHER PARTY IS LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR LOST PROFITS OR REVENUE. + +8.2 **General cap.** EACH PARTY'S AGGREGATE LIABILITY IS CAPPED AT THE FEES PAID OR PAYABLE UNDER THIS AGREEMENT IN THE TWELVE (12) MONTHS PRECEDING THE FIRST EVENT GIVING RISE TO LIABILITY. **Super-cap:** for breaches of Section 6 (Confidentiality), breaches of the DPA or Section 4–5 security obligations, and IP indemnification under Section 7.3, the cap is TWO TIMES (2x) such fees. **Uncapped:** fraud, willful misconduct, Customer's payment obligations, and Customer's indemnification under Section 7.4 for claims arising from Customer's willful violation of law. + +8.3 All claims arising from the same event or series of connected events count as a single claim for the purposes of the caps in Section 8.2. + +### 9. General + +9.1 **Entire agreement; precedence.** This Agreement (Parts A–C, the Order Form, the SLA Exhibit, and the Standard Contractual Clauses where applicable) is the entire agreement and supersedes prior proposals and quotes, including {{quote_ref}}. Only the following provisions of the Stirling EULA & Commercial Terms are incorporated: Section 3 (Definitions), Section 8 (AI features), Section 10 (Self-hosted and desktop software), and Section 11 (Fair use). The website Terms of Service do not apply to this Agreement; without limitation, their arbitration and class-waiver provisions, online auto-renewal rules, unilateral-amendment provision, self-serve pricing, and clickwrap acceptance mechanism are expressly excluded. Precedence: Order Form → Standard Contractual Clauses (for international transfers) → DPA → MSA → SLA Exhibit → incorporated EULA sections → Documentation. + +9.2 **Governing law; venue.** Delaware law, excluding conflicts rules. Exclusive jurisdiction and venue in the state or federal courts located in San Francisco County, California, and the Parties consent to personal jurisdiction there. + +9.3 **Assignment.** Neither Party may assign without the other's consent, except to a successor in a merger, acquisition, or sale of substantially all assets, with notice. + +9.4 **Notices.** Written notices to the addresses on the Order Form; email permitted with confirmation of receipt. + +9.5 **Force majeure; independent contractors; waiver; severability.** Standard terms apply: neither Party is liable for delay caused by events beyond reasonable control; the Parties are independent contractors; failure to enforce is not waiver; unenforceable provisions are severed with the remainder in effect. + +9.6 **Publicity.** Neither Party may use the other's name or marks publicly without prior written consent, except Provider may identify Customer as a customer with Customer's prior approval of the specific use. + +9.7 **Suspension.** Provider may suspend the Services for material breach that threatens the security or integrity of the Services, with notice and opportunity to cure where practicable. Undisputed unpaid fees more than thirty (30) days late are grounds for suspension after ten (10) days' notice. + +### 10. Termination + +10.1 Either Party may terminate for material breach uncured thirty (30) days after written notice, or immediately upon the other's insolvency. + +10.2 On termination: Customer's access ends (self-hosted licenses expire per the license mechanism); each Party returns or destroys the other's Confidential Information; the DPA's deletion terms govern Customer Personal Data; Sections 3 (accrued fees), 6, 7, 8, 9, and 10 survive. + +10.3 If Customer terminates for Provider's uncured material breach, Provider refunds prepaid fees for the unused remainder of the then-current contract year. diff --git a/app/saas/src/main/resources/legal/eula/1.0.0/eula.md b/app/saas/src/main/resources/legal/eula/1.0.0/eula.md new file mode 100644 index 0000000000..f6dd32d5e1 --- /dev/null +++ b/app/saas/src/main/resources/legal/eula/1.0.0/eula.md @@ -0,0 +1,75 @@ +# Stirling EULA & Commercial Terms + +This document fills the EULA slot the website Terms of Service §5 already contemplates ("If a separate end-user license (EULA) accompanies software, that license governs to the extent of any conflict"). It carries the commercial terms of the actual product: the PDF Process meter, spend limits, prepaid capacity, the free allotment, trials, self-hosted licensing, and AI features. For customers under a signed Stirling Enterprise Agreement, that agreement controls. + +**Effective:** {{version_date}} · **Version:** {{version}} + +## 1. Agreement; precedence + +These EULA & Commercial Terms ("EULA") supplement the Stirling Terms of Service (stirling.com/legal/terms-of-service). If they conflict, this EULA controls for the software and the commercial terms below. Open-source components are governed by their own licenses, which control for those components. By clicking accept, creating a workspace, or using the software, you agree on behalf of yourself and, if applicable, the organization you represent ("you"). + +## 2. The products + +**The Stirling PDF Editor** is free: manual editing, the tool catalog, and team administration (including SSO) carry no subscription fee or per-seat charge, whether used in the browser, as a desktop application, or self-hosted. Usage limits on automated processing, fair-use rules (Section 11), support levels, and the feature set may change over time, and third-party costs (such as your own hosting) are yours. The Editor's open-source components remain available under their own licenses independently of this EULA. **The Stirling PDF Processor** is the paid platform that processes PDFs automatically — policies, pipelines, the Stirling Agent, and API processing — billed on the meter below. + +## 3. Definitions + +**"PDF Process"** — one policy execution, one pipeline run (regardless of the number of operations in its chain), or one Stirling Agent returned artifact (a processed file or summary, regardless of the number of messages that produced it), applied to one file. **"Data Processing"** — the data-volume component of the meter: files carry their first 25 MB included per file; volume past that is billed per Section 4. **"file"** — a document processed by the Processor. Chatting with the Stirling Agent is free; only returned artifacts meter. + +## 4. Metered billing (pay as you go) + +4.1 **Rates.** 1¢ per PDF Process, plus Data Processing at 1¢ per 25 MB increment past the first 25 MB of each file. Megabytes are decimal (1 MB = 1,000,000 bytes); size is measured once per file as submitted; increments round up ("part thereof" counts — a 26 MB file incurs one Data Processing increment, a 60 MB file incurs two). Rates may change on thirty (30) days' notice; changes apply prospectively. **Example:** two policies on a 3 MB contract = 2¢; two policies on a 60 MB scan set = 2¢ + 2¢ data = 4¢. + +4.2 **Free allotment.** New workspaces receive a one-time allotment of 500 PDF Processes. A file processed by two processes consumes two of the 500. When the allotment is exhausted, processing pauses until the Processor is switched on. + +4.3 **Invoices.** Usage is invoiced monthly on the 1st for the prior cycle, charged to your payment method on file (card or ACH debit). You authorize these charges. + +4.4 **Usage records.** The Processor's audit log is the system of record, subject to Section 4.5. Your Usage & Billing page shows consumption, and a per-file usage statement (name, size, processes, charge) is available for download. + +4.5 **Billing disputes.** You may dispute a charge or metering record in good faith within sixty (60) days of the invoice or charge date. We will investigate, provide the relevant usage-statement detail, and correct confirmed errors by credit or refund. The audit log is presumptively accurate but not conclusive; reasonable contrary evidence will be considered. Undisputed amounts remain payable. + +## 5. Spend limits + +5.1 You may set a monthly spend limit. By default, processing pauses when usage reaches the limit; queued documents resume when you raise the limit or the cycle resets. Nothing already processed is lost. + +5.2 If you enable **keep-processing** ("Keep processing if you hit your limit"), usage past the limit continues to accrue and be billed per Section 4; the limit then functions as a notification threshold. You can change the limit or the toggle at any time in Usage & Billing. + +## 6. Cancellation; downgrade + +You may revert to the free Editor plan at any time from Usage & Billing. Accrued usage remains payable. Your policies, configuration, and history are retained per the Terms of Service data-retention practices. + +## 7. Prepaid capacity (self-serve annual) + +7.1 **Offer.** You may prepay twelve (12) months of processing capacity for the price of ten (10) (the "12-for-10 rate"), sized at purchase. Payment by card, or by bank transfer against a generated invoice (net 30); prepaid capacity activates when payment clears. + +7.2 **No renewal of prepaid capacity; automatic transition to pay-as-you-go.** Prepaid capacity does not renew for another prepaid term. At purchase, you affirmatively consent to the following transition, which is disclosed before you pay: when the term ends, metered billing (Section 4) applies automatically at then-current rates so processing does not pause. We remind you thirty (30) days before term end; the reminder states the metered rates that will apply and how to cancel or revert to the free Editor plan (one click in Usage & Billing). + +7.3 **Consumption; overage; expiry.** Capacity draws down in PDF Processes. If you exhaust capacity mid-term, you may top up at the same 12-for-10 rate, or metered billing applies at list rates (with a card on file) or processing pauses (without one). Unused capacity expires at term end and is not refunded and does not roll over. + +7.4 **Cap.** Self-serve prepaid capacity is limited to 1,000,000 PDF Processes per year; larger commitments are available under a Stirling Enterprise Agreement. + +## 8. AI features + +AI features — classification, extraction, redaction-assist, and the Stirling Agent — are optional. They run only when you invoke a feature that uses them, and an administrator can disable them for the workspace; the rest of the Processor works without them. When used, they call machine-learning models from the providers listed at {{subprocessor_url}}. Currently: **Anthropic** (Claude models), which receives prompts and the document text or excerpts needed to perform the requested task; and **Voyage AI** (embedding models), which receives extracted text excerpts solely to generate embeddings when you enable Ingestion/RAG features. Only the document text or excerpts needed for the requested feature are sent — not your whole files — and only when that feature runs. AI charges are included in the price of whatever runs — there is no separate AI surcharge. Your content is not used to train models, by us or by these providers (verified against our signed provider agreements). AI outputs are probabilistic; review outputs before relying on them where accuracy has legal effect. + +## 9. Evaluations and trials + +Enterprise trials run fourteen (14) days, require no payment method, and are provided for evaluation only, AS IS, without service level commitments. Either party may end an evaluation at any time; on expiry your workspace continues on the free Editor plan. + +## 10. Self-hosted and desktop software + +10.1 **License.** We grant you a non-exclusive, non-transferable license to install and run the Editor and, with an active plan, the self-hosted Processor, for your internal business use. Open-source components remain under their own licenses. + +10.2 **License validation and metering.** Self-hosted Processor deployments validate their license online and transmit usage metering data (process counts, file sizes, and file hashes for billing integrity, and diagnostic data — never file content or file names) to Stirling. File names used for unique PDF identification remain on your server and are not transmitted. Air-gapped deployments verify a signed activation bundle offline and reconcile usage periodically. You will not disable, circumvent, or falsify validation or metering. **The meter is the same regardless of where the software runs.** + +10.3 **Updates.** Security patches and upgrades are made available for supported versions; some updates may install automatically per Terms of Service §5. + +10.4 **Authorized users and administration.** "Authorized Users" are your employees, and the employees of your affiliates and contractors working on your behalf, whom you provision through your workspace. You are responsible for your users' credentials, your administrators' actions, and your users' compliance with this EULA. One workspace serves one legal entity and its affiliates; serving unrelated third parties requires a separate agreement. You may not redistribute the Processor or offer it as a hosted service to others. On termination or downgrade, self-hosted Processor licenses expire per the license mechanism; installed Editor copies remain usable under the free plan. We may verify license compliance through the validation mechanism in Section 10.2. + +## 11. Fair use + +Free-tier and flat-price features are subject to fair use: we may throttle or decline usage patterns that abuse free processing (for example, automation disguised as manual editing) after notice where practicable. + +## 12. Changes to this EULA + +We may update this EULA. Material changes take effect thirty (30) days after notice. Changes that materially increase your price or reduce your rights take effect at your next billing cycle or prepaid term start, or upon your affirmative acceptance — whichever comes first — except changes strictly necessary for legal compliance or security, which may take effect sooner with notice. Continued use after the effective date is acceptance. Version history is available at {{eula_url}}. diff --git a/app/saas/src/main/resources/legal/manifest.json b/app/saas/src/main/resources/legal/manifest.json new file mode 100644 index 0000000000..c34559275d --- /dev/null +++ b/app/saas/src/main/resources/legal/manifest.json @@ -0,0 +1,38 @@ +{ + "subprocessorUrl": "https://www.stirlingpdf.com/legal/subprocessors", + "eulaUrl": "https://www.stirlingpdf.com/legal/eula", + "documents": { + "enterprise-agreement": { + "label": "SEA", + "displayName": "Stirling Enterprise Agreement", + "version": "0.9.1", + "effectiveDate": "2026-07-10", + "status": "draft", + "parts": ["msa.md", "@order-form", "dpa.md"] + }, + "eula": { + "label": "EULA", + "displayName": "Stirling EULA & Commercial Terms", + "version": "1.0.0", + "effectiveDate": "2026-07-10", + "status": "draft", + "parts": ["eula.md"] + }, + "sla": { + "label": "SLA", + "displayName": "Stirling SLA Exhibit", + "version": "1.0.0", + "effectiveDate": "2026-07-10", + "status": "draft", + "parts": ["sla.md"] + }, + "subprocessors": { + "label": "SUBP", + "displayName": "Stirling Subprocessors", + "version": "1.0.0", + "effectiveDate": "2026-07-10", + "status": "draft", + "parts": ["subprocessors.md"] + } + } +} diff --git a/app/saas/src/main/resources/legal/sla/1.0.0/sla.md b/app/saas/src/main/resources/legal/sla/1.0.0/sla.md new file mode 100644 index 0000000000..42271171d5 --- /dev/null +++ b/app/saas/src/main/resources/legal/sla/1.0.0/sla.md @@ -0,0 +1,37 @@ +# SLA Exhibit — Stirling Enterprise Agreement + +Referenced by the Order Form's service-level row and MSA §5.2. One document, three tiers; the Order Form's tier selection determines the applicable column. Uptime is a target, not a credited commitment: no service credits apply at any tier. Tiers differentiate support response, channels, and people. + +## 1. Availability + +For Stirling Cloud deployments, Provider targets **99.9% monthly uptime**, measured at the API and console endpoints, excluding scheduled maintenance announced at least 48 hours in advance and events beyond Provider's reasonable control. Current and historical status is published at the system status page. No service credits apply; persistent material failure to meet the target is addressed through MSA §7.1 (performance warranty and remedies) and §10 (termination for material breach). + +Self-hosted and Air-gapped deployments: availability of the runtime is Customer's responsibility; this Section applies to Provider's license, metering, and update services. + +## 2. Support tiers + +| | **Standard** | **Priority** | **Dedicated** | +| --- | --- | --- | --- | +| Included with | Every Enterprise Agreement | Every Enterprise Agreement | The Dedicated SE/CSM line item ($30,000/yr) | +| Hours | Business hours (Mon–Fri, 9:00–18:00 US Eastern, excl. US holidays) | Business hours + extended (7:00–21:00 US Eastern) | 24×7 for Severity 1 | +| Channels | Email, in-product | Email, in-product, private Slack/Teams channel | All Priority channels + named Solutions Engineer and CSM | +| Severity 1 first response (production down / processing halted org-wide) | 8 business hours | 4 hours | 1 hour, 24×7 | +| Severity 2 (major feature degraded, no workaround) | Next business day | 8 business hours | 4 hours | +| Severity 3 (minor defect, workaround exists) | 3 business days | 2 business days | Next business day | +| Severity 4 (question, cosmetic) | 5 business days | 3 business days | 2 business days | +| Escalation path | Support queue | Support lead | Named SE → CSM → Provider executive | +| Business reviews | — | — | Quarterly, where the QBR line item is elected | + +First response = a qualified human engaging with the issue, not an acknowledgment autoresponder. Resolution times are not committed; Provider works Severity 1 issues continuously within the tier's hours until resolved or downgraded. + +## 3. Severity is set by Customer, subject to reasonable reclassification + +Customer designates severity at filing; Provider may reclassify with explanation. Severity 1 requires production impact in a live (non-evaluation) environment. + +## 4. Maintenance and updates + +Scheduled maintenance is announced at least 48 hours ahead and targeted at low-usage windows. Security patches for supported versions ship to all tiers at no charge (MSA §5.4). Trials and evaluations are provided AS IS and are outside this Exhibit (EULA §9). + +## 5. Exclusions + +This Exhibit does not apply to: issues caused by Customer's environment, modifications, or third-party systems; usage exceeding the fair-use provisions; Preview/Beta features; or Force Majeure events (MSA §9.5). diff --git a/app/saas/src/main/resources/legal/subprocessors/1.0.0/subprocessors.md b/app/saas/src/main/resources/legal/subprocessors/1.0.0/subprocessors.md new file mode 100644 index 0000000000..670ae53150 --- /dev/null +++ b/app/saas/src/main/resources/legal/subprocessors/1.0.0/subprocessors.md @@ -0,0 +1,19 @@ +# Stirling PDF — Subprocessors + +Referenced by DPA §C5 and Annex III, and by EULA §8. Changes to this list carry 30 days' notice per DPA §C5. Last updated: {{version_date}}. + +Stirling PDF, Inc. uses the following subprocessors to provide the Services. Customer files are processed within Stirling's own infrastructure. AI features are optional and can be disabled; where they are used, AI providers receive only the document text or excerpts needed for the requested feature, never whole customer files. + +| Subprocessor | Purpose | Data processed | Location | +| --- | --- | --- | --- | +| **Amazon Web Services (AWS)** | Cloud infrastructure and storage for Stirling Cloud | Customer files (encrypted at rest), account and usage data | United States (EU region availability per deployment — see DPA §C8 note) | +| **Stripe** | Payment processing | Billing contact and transaction data. Payment card details go directly to Stripe, which acts as an independent controller for them | United States | +| **Supabase** | Account and workspace data infrastructure | Account, workspace, and configuration data | United States | +| **Google** | Transactional and operational email delivery | Names, email addresses, message content of service emails | United States | +| **Anthropic** | AI models (Claude) powering the Stirling Agent and AI-assisted features | Prompts and the document text or excerpts needed for the requested feature — never whole files; only where AI features are used | United States | +| **Voyage AI** | Embedding models for Ingestion/RAG features | Extracted text excerpts, only where the customer enables Ingestion/RAG, solely to generate embeddings — never customer files | United States | +| **PostHog** | Product telemetry and usage analytics | Pseudonymous usage events and diagnostic data — never file content | European Union (EU-hosted) | + +Neither AI provider uses customer data for model training (contractually confirmed). + +Self-hosted and air-gapped deployments: customer files remain in the customer's environment; Stirling receives license-validation and metering data only (process counts, file sizes, file hashes — never file names or content). diff --git a/app/saas/src/test/java/stirling/software/saas/procurement/service/ProcurementTrialRestartPolicyTest.java b/app/saas/src/test/java/stirling/software/saas/procurement/service/ProcurementTrialRestartPolicyTest.java new file mode 100644 index 0000000000..86f361ff87 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/procurement/service/ProcurementTrialRestartPolicyTest.java @@ -0,0 +1,44 @@ +package stirling.software.saas.procurement.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.saas.procurement.model.ProcurementDeal; + +/** + * Which stages may (re)start a trial is a security policy, not a convenience. + * + *

From the agreement stage onward a deal's {@code licenseRef} points at the committed annual + * licence. Starting a trial replaces it with a fresh 14-day key, rewinds the stage and resets the + * extension counter — so an unguarded restart would downgrade a paying customer's entitlement while + * Stripe kept billing them. This pins the allowed set so widening it has to be deliberate. + */ +class ProcurementTrialRestartPolicyTest { + + @Test + @DisplayName("allowed before a deal exists, while exploring, and within the trial") + void allowsOnlyPreCommitmentStages() { + assertThat(ProcurementService.canStartTrial(null)).isTrue(); + assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_EXPLORING)).isTrue(); + assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_TRIAL)).isTrue(); + } + + @Test + @DisplayName("refused once the deal is quoting or beyond") + void refusesCommittedStages() { + assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_QUOTE)).isFalse(); + assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_AGREEMENT)).isFalse(); + assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_PAYMENT)).isFalse(); + assertThat(ProcurementService.canStartTrial(ProcurementDeal.STAGE_LIVE)).isFalse(); + } + + @Test + @DisplayName("an unrecognised stage is refused, not waved through") + void refusesUnknownStages() { + // A stage added later, or a hand-edited row, must fail closed. + assertThat(ProcurementService.canStartTrial("renewal")).isFalse(); + assertThat(ProcurementService.canStartTrial("")).isFalse(); + } +} diff --git a/frontend/.storybook/a11y-baseline.json b/frontend/.storybook/a11y-baseline.json index 10fc530c77..a31d84613b 100644 --- a/frontend/.storybook/a11y-baseline.json +++ b/frontend/.storybook/a11y-baseline.json @@ -1707,6 +1707,9 @@ "editor/src/portal/components/billing/InvoicesList.stories.tsx :: Default": [ "color-contrast" ], + "editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx :: Default": [ + "color-contrast" + ], "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Healthy": [ "aria-progressbar-name", "color-contrast" @@ -2107,22 +2110,15 @@ "color-contrast" ], "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License": [ - "aria-dialog-name" + "color-contrast" ], "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License Trial": [ - "aria-dialog-name" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Schedule Call": [ - "aria-dialog-name" + "color-contrast" ], "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage": [ - "aria-dialog-name" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage Maxed": [ - "aria-dialog-name" + "color-contrast" ], "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Setup": [ - "aria-dialog-name", "color-contrast" ], "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Default": [ @@ -2135,6 +2131,9 @@ "editor/src/portal/components/procurement/ProcurementHome.stories.tsx :: Default": [ "color-contrast" ], + "editor/src/portal/components/procurement/ProcurementModal.stories.tsx :: Open": [ + "color-contrast" + ], "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License": [ "color-contrast" ], @@ -2144,6 +2143,9 @@ "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Online Only": [ "color-contrast" ], + "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment": [ + "color-contrast" + ], "editor/src/portal/components/procurement/QuoteBuilder.stories.tsx :: Default": [ "color-contrast" ], diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index bf00cfd08d..33cf0ee8e1 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7331,13 +7331,16 @@ tagline = "Native app for Microsoft Windows" title = "Windows" [portal.home.editor] -activeUsers = "{{n}} active" -install = "Install the editor" -invite = "Invite teammates" -name = "Stirling PDF Editor" +activeOfDeployed = "{{active}} of {{total}} active this month" +name = "PDF Editor" open = "Open in browser" updated = "updated {{time}}" +[portal.home.editor.deploy] +finish = "Finish deployment" +options = "View deployment options" +start = "Deploy the Editor" + [portal.home.editor.target] cloud = "Managed Cloud" docker = "Self-hosted · Docker" @@ -7348,25 +7351,6 @@ afternoon = "Good afternoon" evening = "Good evening" morning = "Good morning" -[portal.home.onboarding.enterprise] -body = "Org-wide SSO + SCIM + RBAC, committed volume pricing, and air-gapped deployment." -cta = "Start Trial" -ctaQuote = "Get Quote" -lead = "For 250+ employees." -tag = "Enterprise" - -[portal.home.onboarding.steps.editor] -blurb = "Install the desktop app or self-host" -title = "Download the editor" - -[portal.home.onboarding.steps.invite] -blurb = "Bring your team into the secure workspace" -title = "Invite teammates" - -[portal.home.onboarding.steps.policies] -blurb = "{{active}} active · {{recommended}} recommended" -title = "Confirm your policies" - [portal.infrastructure] manageEditorDeployment = "Manage Editor deployment" sectionsAriaLabel = "Infrastructure sections" @@ -7767,6 +7751,12 @@ pipelines = "Pipelines" policies = "Policies" sources = "Sources" +[portal.legal] +draft = "{{label}} · draft" +loadError = "Could not load this document. Please try again." +loading = "Loading…" +title = "Legal document" + [portal.nav] agent-builder = "Agent Builder" components = "Components" @@ -8430,19 +8420,27 @@ email = "Email intake" subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place." title = "Procurement" -[portal.procurement.action] -download = "Download" -pay = "Pay now" -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" +agreeCta = "Sign agreement" +confidential = "Confidential" +confirm = "I have read and agree to this Agreement, and I represent that I am authorized to sign it on behalf of the organization named above." +docName = "Stirling Enterprise Agreement" +docSub = "MSA, Order Form, EULA and DPA, combined into one signature." +download = "Download" +downloadDraftError = "Could not generate the agreement PDF - the document renderer is unavailable. Please try again shortly or contact support." +downloadError = "Could not download the signed agreement. Please try again." +legalName = "Legal entity name" +legalNamePlaceholder = "The legal entity that will sign" +loadError = "Could not load the agreement. Please try again." +loading = "Loading the agreement..." +ref = "Ref {{ref}} · {{version}}" +requestChanges = "Request changes" +scrollHint = "Scroll to review" +signatory = "Signatory name" +signatoryPlaceholder = "Full name" +signatoryTitle = "Signatory title" +signatoryTitlePlaceholder = "e.g. General Counsel" +signError = "Could not record your signature. Please try again." [portal.procurement.builder] addons = "Add-ons" @@ -8455,16 +8453,19 @@ businessName = "Business name" businessNamePlaceholder = "Your company" city = "City" cityPlaceholder = "San Francisco" +completeRequired = "Please complete the required fields (marked *) with a valid email before generating the quote." contactEmail = "Contact email" contactEmailPlaceholder = "jane@acme.com" contactName = "Contact name" contactNamePlaceholder = "Jane Doe" continue = "Continue" +done = "Done" 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" +indemnification = "Enhanced IP Protection" +indemnificationSub = "Extends our IP defense to patent claims. Baseline copyright, trademark and trade-secret indemnification is included free." +paperEyebrow = "Enterprise quote" +paperFor = "Prepared for" pdfSize = "PDF size" poNumber = "PO number" poNumberPlaceholder = "Optional" @@ -8512,6 +8513,7 @@ training = "Onboarding & training" trainingSub = "Live sessions to get your team running" users = "Total users" usersPlaceholder = "e.g. 250" +viewEula = "Read it" 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." @@ -8520,54 +8522,55 @@ 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" -done = "Done" -here = "You're here" -hide = "Hide" -optional = "Optional" -paidAddon = "Paid add-on" -show = "Show" -subtitle = "Everything you need at each step of the journey, surfaced as the deal moves through it." -supportingSubtitle = "SOC 2, security reviews, tax forms and more, ready when your security or procurement team asks. Some carry a one-time fee." -supportingTitle = "Supporting your evaluation" +[portal.procurement.documents] +agreement = "Enterprise Agreement" +agreementSub = "MSA, Order Form and DPA in one signature." +download = "Download" +eula = "EULA & Commercial Terms" +eulaSub = "The software and self-serve commercial terms." +invoice = "Invoice" +invoiceSub = "Your first subscription invoice." +laterInvoice = "Available after you accept" +laterQuote = "Available once your quote is issued" +quote = "Quote" +quoteSub = "Your itemised enterprise quote." +sla = "SLA Exhibit" +slaSub = "Support tiers and response targets." +subprocessors = "Subprocessors" +subprocessorsSub = "Third parties that process data on your behalf." +subtitle = "Your deal paperwork, available any time." title = "Documents" -upcoming = "Upcoming" +view = "View" [portal.procurement.error] title = "Something went wrong" [portal.procurement.hero] -company = "Your enterprise deal" -ctaLive = "You're live" -ctaPayment = "Add payment" +barAria = "Stage {{current}} of {{total}}" +ctaAgreement = "Review & sign agreement" +ctaExploring = "Set up your trial" ctaQuote = "Review your quote" +ctaReviewQuote = "Review quote" ctaTrial = "Build your quote" -eyebrow = "Enterprise procurement" +documents = "Documents" +eyebrow = "Enterprise" +eyebrowCompany = "{{company}} Enterprise" inviteTeammates = "Invite teammates" licenseKey = "Licence key" -nextStep = "Next step: {{action}}" -notStarted = "Not started" +liveSub = "Your organization is provisioned and your licence is active." +liveTitle = "You are live on Stirling Enterprise" +next = "Next: {{stage}}" 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" +sentenceAgreement = "review and sign the agreement" +sentenceLive = "provisioning and rollout" +sentencePayment = "purchase order and payment" +sentenceQuote = "review and accept your quote" +sentenceTrial = "set up your trial workspace" [portal.procurement.journey] daysLeft_one = "{{count}} day left" daysLeft_other = "{{count}} days left" -engineerLabel = "Your solutions engineer" -eyebrow = "Your rollout" -live = "You're live on Stirling Enterprise" -nextStep = "Next step: {{action}}" -subtitle = "Your solutions engineer is on every step. One next action at a time; the full checklist is below." -title = "From trial to live, one guided path" -trialTitle = "Enterprise trial" [portal.procurement.journeySteps.agreement] blurb = "One signature covers MSA, order form, EULA and DPA." @@ -8616,45 +8619,26 @@ description = "Your subscription is active and your licence is issued. Your team 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] -download = "Download PDF" downloadError = "Could not download the quote PDF just yet — please try again in a moment." -edit = "Edit quote" - -[portal.procurement.modal] -cancel = "Cancel" -chooseFile = "Choose file" -close = "Close" -downloadBody = "Your download will begin shortly." -downloadCta = "Download" -downloadTitle = "Download" -noFile = "No file selected" -payBody = "Pay your committed contract by card or bank transfer through Stripe. Your workspace provisions as soon as payment clears." -payCta = "Continue to Stripe" -payTitle = "Confirm payment" -requestBodyFree = "We generate this on demand. Confirm and your solutions engineer will send it across shortly." -requestBodyPaid = "This is a paid add-on. Confirm and your solutions engineer will scope it and send the paperwork." -requestCta = "Request" -requestTitle = "Request this document" -signBody = "Opens the Stirling Enterprise Agreement for e-signature: one signature covers the MSA, order form, EULA and DPA. We countersign automatically and you advance to payment." -signCta = "Open for signature" -signTitle = "Review and sign your agreement" -uploadBody = "Send us your PO and we invoice against it on your terms. Drag in the PDF or pick a file below." -uploadCta = "Upload purchase order" -uploadTitle = "Upload your purchase order" [portal.procurement.payment] description = "Your quote is accepted and your licence is already active — your team can start right away. Pay the first invoice when you're ready; you can pay or download it here, no email needed." +downloadAgreement = "Download signed agreement" downloadInvoice = "Download invoice" +eyebrow = "Payment" title = "Subscription created" viewInvoice = "View & pay invoice" +[portal.procurement.review] +acceptCta = "Accept quote" +annual = "Annual fee (year 1)" +downloadCta = "Download quote" +poNumber = "Purchase order: {{po}}" +renewal = "Renews at {{amount}}/year after the term (+{{pct}}% CPI)" +tcv = "{{years}}-year term, paid in full up front: {{tcv}}" +validUntil = "Valid until {{date}}" + [portal.procurement.schedule] fallback = "Couldn't load the scheduler." fallbackLink = "Open scheduling in a new tab" @@ -8664,25 +8648,33 @@ title = "Schedule a call" [portal.procurement.setup] airgap = "Air-gapped" -airgapSub = "Fully offline, isolated network. Includes a downloadable licence file." -cloud = "Cloud" -cloudSub = "Fully managed by Stirling. Nothing for you to run." -deployment = "Where will you run Stirling?" -seats = "Team size" -seatsHint = "Roughly how many people will use it. You can refine this when you build your quote." -seatsPlaceholder = "e.g. 250" +airgapSub = "Sealed, no outbound" +back = "Back" +businessName = "Business name" +businessNamePlaceholder = "Acme Corp" +cloud = "Stirling Cloud" +cloudSub = "Managed, fastest start" +continue = "Continue" +deployment = "Where should it deploy?" +eula = "I agree to the Stirling EULA & Commercial Terms." +fullName = "Full name" +fullNamePlaceholder = "Your name" +invites = "Invite teammates · optional" +invitesPlaceholder = "sam@acme.com, lee@acme.com" +scheduleCall = "Schedule a call" +seats = "People testing" +seatsPlaceholder = "e.g. 15" selfhost = "Self-hosted" -selfhostSub = "Run it in your own cloud or data centre." +selfhostSub = "Docker or Kubernetes" start = "Start trial" -subtitle = "Tell us how you plan to run Stirling so we can tailor your trial and quote. No card required." +stepOf = "Step {{n}} of {{total}}" +subtitle = "Free for 14 days, no card required." +subtitleDetails = "A few details for your quote and agreement." +talkFirst = "Prefer to talk first?" title = "Set up your trial" - -[portal.procurement.status] -action = "Action needed" -available = "Available" -complete = "Complete" -pending = "Pending" -request = "On request" +viewEula = "Read it" +workEmail = "Work email" +workEmailPlaceholder = "you@company.com" [portal.procurement.trial] body = "Extending adds 7 days and notifies your solutions engineer." @@ -8693,12 +8685,6 @@ 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.search] ariaLabel = "Search" placeholder = "Search Stirling — endpoints, pipelines, docs…" @@ -8923,6 +8909,9 @@ name = "Name" namePlaceholder = "e.g. Claims intake" type = "Type" +[portal.stepModal] +close = "Close" + [portal.usage] managePayment = "Manage Payment" subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console." @@ -8985,14 +8974,6 @@ summary = "Owns a team — manages its members' resources and shared configs." limited = "{{used}} / {{limit}}" unlimited = "{{used}} · Unlimited" -[portal.welcome] -ariaLabel = "Welcome to Stirling PDF" -install = "Install the editor" -invite = "Invite teammates" -openInBrowser = "Open in browser" -productName = "PDF Editor" -stats = "30M downloads · 60+ PDF operations · Free forever" - [printFile] title = "Print File" diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 76adc1ad1d..8e77c20830 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -11,7 +11,6 @@ import { Policies } from "@portal/views/Policies"; import { EditorAdmin } from "@portal/views/EditorAdmin"; import { Infrastructure } from "@portal/views/Infrastructure"; import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; -import { Procurement } from "@portal/views/Procurement"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; // Lazy so the generated docs manifest (bundled JSON) lands in its own chunk. @@ -62,7 +61,6 @@ export function ViewRouter() { element={} /> } /> - } /> null); +} + +afterEach(() => vi.restoreAllMocks()); + +describe("openApiUrl", () => { + it("opens an https URL in a new tab with noopener", () => { + const open = spyOpen(); + openApiUrl("https://invoice.stripe.com/i/abc123"); + expect(open).toHaveBeenCalledWith( + "https://invoice.stripe.com/i/abc123", + "_blank", + "noopener,noreferrer", + ); + }); + + it("opens a relative URL by resolving it against the origin", () => { + const open = spyOpen(); + openApiUrl("/invoices/abc.pdf"); + expect(open).toHaveBeenCalledWith( + `${window.location.origin}/invoices/abc.pdf`, + "_blank", + "noopener,noreferrer", + ); + }); + + // The reason this module exists: a navigation sink must not evaluate script. + it.each([ + "javascript:alert(document.domain)", + "JavaScript:alert(1)", + "data:text/html,", + "vbscript:msgbox(1)", + "file:///etc/passwd", + ])("refuses %s", (hostile) => { + const open = spyOpen(); + openApiUrl(hostile); + expect(open).not.toHaveBeenCalled(); + }); + + it("does nothing for an empty or missing URL", () => { + const open = spyOpen(); + openApiUrl(null); + openApiUrl(undefined); + openApiUrl(""); + expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/api/externalUrl.ts b/frontend/editor/src/portal/api/externalUrl.ts new file mode 100644 index 0000000000..10919e1544 --- /dev/null +++ b/frontend/editor/src/portal/api/externalUrl.ts @@ -0,0 +1,25 @@ +/** + * Open a URL that arrived from the API — a Stripe hosted-invoice page or invoice + * PDF — in a new tab, after checking its scheme. + * + * These values are relayed from Stripe through our own edge functions, so they + * are not attacker-controlled today. But a `javascript:` or `data:` URL reaching + * a navigation sink is an XSS primitive, and nothing between Stripe and this call + * promises the field can only ever hold a web address. Anything that is not plain + * http(s) is dropped instead of opened. + */ +export function openApiUrl(url: string | null | undefined): void { + if (!url) return; + let parsed: URL; + try { + parsed = new URL(url, window.location.origin); + } catch { + console.warn("[portal] refusing to open an unparseable URL"); + return; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + console.warn(`[portal] refusing to open a ${parsed.protocol} URL`); + return; + } + window.open(parsed.href, "_blank", "noopener,noreferrer"); +} diff --git a/frontend/editor/src/portal/api/procurement.ts b/frontend/editor/src/portal/api/procurement.ts index 4d8a28e057..b49de2f6b6 100644 --- a/frontend/editor/src/portal/api/procurement.ts +++ b/frontend/editor/src/portal/api/procurement.ts @@ -1,12 +1,12 @@ import { apiClient } from "@portal/api/http"; +import { resolveDemoResponse } from "@portal/api/demoData"; +import { saasApiBase } from "@portal/api/saasApiBase"; import { getSupabaseClient } from "@app/auth/supabase/supabaseClient"; -import type { Tier } from "@portal/contexts/TierContext"; /* - * Procurement models the enterprise commercial journey, trial → quote → - * agreement → payment → implementation, plus the paperwork ledger that rides - * alongside it. The journey is enterprise-only; free/pro tiers receive a - * minimal locked payload the view renders as an upgrade prompt. + * Procurement models the enterprise commercial journey: trial → quote → agreement → payment → + * implementation. It is surfaced by the deal-status hero on Home and the takeover flow beside it; + * there is no separate procurement route. */ /* ──────────────────────────────────────────────────────────────────────── */ @@ -19,11 +19,8 @@ import type { Tier } from "@portal/contexts/TierContext"; * Payment read more plainly than the internal `security` / `procurement`). */ export type DealStage = - | "trial" - | "quote" - | "security" - | "procurement" - | "active"; + /** Asked about enterprise, nothing committed yet. Precedes the journey rather than joining it. */ + "exploring" | "trial" | "quote" | "security" | "procurement" | "active"; export interface JourneyStep { stage: DealStage; @@ -38,9 +35,14 @@ export interface JourneyStep { gatingAction: string; } -/** Ordered journey definition, the stepper renders this verbatim. */ -/** `label`/`blurb`/`gatingAction` values are i18n keys — render with t(). */ -export const JOURNEY: JourneyStep[] = [ +/** + * The commercial flow's stages, rendered verbatim as the hero's progress band. Quote and Agreement + * are distinct: the buyer accepts the quote first (no Stripe), then signs the agreement, which is + * what accepts into a committed subscription. + * + *

`label`/`blurb`/`gatingAction` are i18n keys — render with t(). + */ +export const FLOW_JOURNEY: JourneyStep[] = [ { stage: "trial", label: "portal.procurement.journeySteps.trial.label", @@ -73,204 +75,9 @@ export const JOURNEY: JourneyStep[] = [ }, ]; -/** - * The commercial flow's stepper stages. The real backend collapses quote + agreement into one - * accept step (accepting the issued quote is accepting the agreement), so the flow shows one fewer - * step than the mock ledger's {@link JOURNEY} — the separate "Agreement" step is dropped. Reuses - * JOURNEY's i18n keys. - */ -export const FLOW_JOURNEY: JourneyStep[] = JOURNEY.filter( - (s) => s.stage !== "security", -); - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Deal header */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface SolutionsEngineer { - name: string; - title: string; - email: string; -} - -export interface TrialInfo { - /** License key seeded for the evaluation. */ - key: string; - /** ISO date the trial began. */ - startedOn: string; - /** ISO date the trial expires. */ - endsOn: string; - /** Whole days remaining (derived in the fixture for a stable demo number). */ - daysLeft: number; - extensionsUsed: number; - maxExtensions: number; -} - -export interface QuoteInfo { - number: string; - /** Annual contract value, in USD. */ - amount: number; - /** Contract term, e.g. "12 months". */ - term: string; - /** ISO date the quote expires. */ - validUntil: string; -} - -export interface Deal { - company: string; - currentStage: DealStage; - engineer: SolutionsEngineer; - trial: TrialInfo; - quote: QuoteInfo; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Document ledger + supporting pool */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** - * Lifecycle of a single document. - * available: ready to grab now (download/sign/pay/upload as the action says) - * action: waiting on the buyer to act (the gating paperwork of a stage) - * pending: issued, awaiting the other side / a system step - * request: not generated yet; the buyer asks for it (some carry a fee) - * complete: done, kept for the record - */ -export type DocStatus = - | "available" - | "action" - | "pending" - | "request" - | "complete"; - -/** What pressing the document's button does. */ -export type DocAction = "download" | "sign" | "pay" | "upload" | "request"; - -export interface LedgerDoc { - id: string; - name: string; - /** Sub-line describing what the document is / what it covers. */ - sub: string; - status: DocStatus; - action: DocAction; - /** Buyer-skippable paperwork (e.g. paid onboarding). */ - optional?: boolean; - /** One-off fee in USD when the document/service is a paid add-on. */ - fee?: number; -} - -/** Document ledger grouped by the journey stage the paperwork belongs to. */ -export interface LedgerGroup { - stage: DealStage; - /** Buyer-facing stage name (matches JourneyStep.label). */ - label: string; - docs: LedgerDoc[]; -} - -/** Categories the stage-agnostic supporting pool is grouped under. */ -export type SupportingCategory = - | "security" - | "legal" - | "corporate" - | "procurement"; - -export interface SupportingGroup { - category: SupportingCategory; - label: string; - docs: LedgerDoc[]; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Full procurement payload */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface ProcurementResponse { - tier: Tier; - /** True only for enterprise, gates the whole journey + ledger. */ - unlocked: boolean; - /** Present only when unlocked. */ - deal: Deal | null; - journey: JourneyStep[]; - ledger: LedgerGroup[]; - supporting: SupportingGroup[]; -} - -/** GET /v1/procurement?tier=…, the deal, journey, ledger and supporting pool. */ -export async function fetchProcurement( - tier: Tier, -): Promise { - return apiClient.local.json( - `/v1/procurement?tier=${encodeURIComponent(tier)}`, - ); -} - -/* - * Commercial actions. Each mutates the deal server-side and returns the updated - * ProcurementResponse, the new canonical state, which the view applies so the - * journey progresses. The MSW layer answers these today; a real backend honours - * the same contracts unchanged. - */ - -/** Advance the deal to the next stage (the journey's primary CTA). */ -export async function advanceStage( - fromStage: DealStage, -): Promise { - return apiClient.local.json("/v1/procurement/advance", { - method: "POST", - body: { fromStage }, - }); -} - -/** Sign the Stirling Enterprise Agreement (MSA + order form + EULA + DPA). */ -export async function signAgreement( - docId: string, -): Promise { - // A real backend opens an e-signature envelope and completes on callback; - // here it completes immediately and advances the deal. - return apiClient.local.json("/v1/procurement/sign", { - method: "POST", - body: { docId }, - }); -} - -/** Pay the contract online (card / bank transfer via Stripe). */ -export async function payOnline(): Promise { - return apiClient.local.json("/v1/procurement/pay", { - method: "POST", - }); -} - -/** Upload a purchase order to invoice against (an alternate payment path). */ -export async function uploadPurchaseOrder( - file: File, -): Promise { - // A real backend takes the PO as multipart; the mock only needs the name. - return apiClient.local.json( - "/v1/procurement/purchase-order", - { - method: "POST", - body: { fileName: file.name }, - }, - ); -} - -/** Request a document that is generated on demand (some carry a one-off fee). */ -export async function requestDocument( - docId: string, - action: DocAction, -): Promise { - return apiClient.local.json( - `/v1/procurement/documents/${encodeURIComponent(docId)}/request`, - { 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. +// Enterprise procurement — the real SaaS backend (/api/v1/procurement): trial, +// server-priced quote, agreement, accept -> Stripe. Gated on a linked account. // ============================================================================ export type QuoteLineItemKind = @@ -288,7 +95,11 @@ export interface QuoteLineItem { export interface QuoteResult { quoteId: number; - quoteNumber: string; + /** + * Stripe's quote number — the deal's one reference, shown on the quote, the agreement and the + * invoice. Null while the quote is a local draft: Stripe assigns it only at finalisation. + */ + quoteNumber: string | null; /** draft (priced, editable) | sent (issued Stripe quote — PDF + shareable) | accepted | expired. */ status: string; currency: string; @@ -332,6 +143,12 @@ export interface ProcurementSnapshot { licensed: boolean; /** The team's Keygen licence key (present once licensed); shown in the portal to copy/install. */ licenseKey: string | null; + /** Version label of the signed agreement PDF available to download (e.g. "SEA v0.9.1"), else null. */ + agreementSignedVersion: string | null; + /** Buying entity captured at trial setup; null on deals started before that step existed. */ + businessName: string | null; + contactName: string | null; + contactEmail: string | null; latestQuote: QuoteResult | null; } @@ -385,13 +202,37 @@ export function fetchLicenseFile(): Promise { * Start the trial with the buyer's chosen deployment target and seat count (captured in the setup * step). These seed the quote builder; both remain editable when the quote is built. */ +/** Details collected by trial setup's second step; every field optional server-side. */ +export interface TrialSetupDetails { + businessName?: string; + contactName?: string; + contactEmail?: string; + /** + * Raw comma/space/semicolon separated addresses. Recorded on the deal AND sent through the + * team-invite path once the trial starts; a rejected address is skipped, never fatal. + */ + inviteEmails?: string; +} + export function startTrial( deployment: string, seats: number, + details?: TrialSetupDetails, ): Promise { return apiClient.saas.json( "/api/v1/procurement/trial/start", - { method: "POST", body: { deployment, users: seats } }, + { method: "POST", body: { deployment, users: seats, ...details } }, + ); +} + +/** + * Record that this account is looking at enterprise. Idempotent, and never disturbs an existing + * deal — so it is safe to call on every entry into the flow. + */ +export function recordInterest(): Promise { + return apiClient.saas.json( + "/api/v1/procurement/interest", + { method: "POST" }, ); } @@ -418,12 +259,114 @@ export function buildQuote(cfg: QuoteConfigInput): Promise { }); } +/** The filled enterprise agreement (MSA + Order Form + DPA) for the current quote, as markdown. */ +export interface AgreementDocument { + docId: string; + version: string; + /** e.g. "SEA v0.9.1" — the exact document version a signature will be pinned to. */ + versionLabel: string; + displayName: string; + effectiveDate: string; + /** "draft" until counsel clears it — the UI badges drafts. */ + status: string; + markdown: string; +} + +/** Buyer-supplied signing inputs captured on the agreement stage. */ +export interface SignAgreementInput { + customerLegalName: string; + signatoryName: string; + signatoryTitle: string; + authorityConfirmed: boolean; +} + +export interface SignAgreementResult { + signatureId: number; + versionLabel: string; + /** Whether the signed PDF was rendered + stored (false when the render runtime was unavailable). */ + pdfStored: boolean; +} + +/** Fetch the filled agreement to review before signing. */ +export function fetchAgreementDocument(): Promise { + return apiClient.saas.json( + "/api/v1/procurement/agreement/document", + ); +} + +/** Record the signed agreement (pins version + hash + variable snapshot + signatory + PDF). */ +export function recordAgreementSignature( + input: SignAgreementInput, +): Promise { + return apiClient.saas.json( + "/api/v1/procurement/agreement/sign", + { method: "POST", body: input }, + ); +} + +/** Fetch a static legal document (eula, sla, subprocessors) by id for in-product viewing. */ +export function fetchLegalDocument(docId: string): Promise { + return apiClient.saas.json(`/api/v1/legal/${docId}`); +} + +/** Download the stored, signed enterprise-agreement PDF for the team (post-signing). */ +export function fetchSignedAgreementPdf(): Promise { + return apiClient.saas.blob("/api/v1/procurement/agreement/signature/pdf"); +} + +/** Download the current (unsigned) enterprise-agreement PDF — the document shown at the sign step. */ +export function fetchAgreementPdf(): Promise { + return apiClient.saas.blob("/api/v1/procurement/agreement/document/pdf"); +} + +/** + * Record a clickwrap consent to a legal document (e.g. the EULA at trial start / quote generation). + * Best-effort — never block the flow it accompanies on a consent-logging failure. + */ +export function recordLegalConsent( + documentId: string, + context: string, +): Promise { + return apiClient.saas + .json("/api/v1/legal/consent", { + method: "POST", + body: { documentId, context }, + }) + .catch(() => undefined); +} + // ---- 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). +/** + * Fixture response for an edge function while demo data is on. + * + * These calls go out through the Supabase client rather than {@code apiClient}, which is where demo + * data is normally intercepted — so until this existed they were never mocked. The portal has no + * service worker: the MSW handlers are matched by URL inside {@link resolveDemoResponse}, so the URL + * has to be reconstructed here to match what mocks/handlers/procurementSaas.ts registers. + * + * This mattered rather more than a missing fixture: issue and accept create a real Stripe Quote and a + * real committed subscription. With a live session — the normal state when developing against the + * shared project — pressing "Generate quote" in dev billed nothing but left real objects behind. + */ +async function demoEdgeResponse( + fn: string, + quoteId: number, +): Promise { + const base = saasApiBase(); + if (!base) return undefined; + return resolveDemoResponse(new URL(`${base}/functions/v1/${fn}`), { + method: "POST", + body: { quote_id: quoteId }, + }); +} + async function invokeEdge(fn: string, quoteId: number): Promise { + const demo = await demoEdgeResponse(fn, quoteId); + if (demo) return (await demo.json()) as T; const supabase = getSupabaseClient(); if (!supabase) throw new Error("No SaaS session"); const { data, error } = await supabase.functions.invoke(fn, { @@ -446,6 +389,8 @@ export function acceptQuote(quoteId: number): Promise { /** Fetch the Stripe-generated quote PDF as a blob (for download / share). */ export async function fetchQuotePdf(quoteId: number): Promise { + const demo = await demoEdgeResponse("get-procurement-quote-pdf", quoteId); + if (demo) return await demo.blob(); const supabase = getSupabaseClient(); if (!supabase) throw new Error("No SaaS session"); const { data, error } = await supabase.functions.invoke( diff --git a/frontend/editor/src/portal/components/EditorStatusCard.css b/frontend/editor/src/portal/components/EditorStatusCard.css index e71a50bac3..692d08adc3 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.css +++ b/frontend/editor/src/portal/components/EditorStatusCard.css @@ -13,20 +13,18 @@ .portal-editor-hero__row { display: flex; align-items: center; - gap: 1.25rem; - padding: 1rem 1.25rem; - background: color-mix(in srgb, var(--c-primary) 22%, var(--c-hero-dark)); + gap: 0.75rem; + padding: 0.625rem 0.875rem; + background: var(--c-surface); } .portal-editor-hero__logo { - width: 3.5rem; - height: 3.5rem; - border-radius: 0.875rem; + width: 2rem; + height: 2rem; + border-radius: 0.5rem; overflow: hidden; flex-shrink: 0; - box-shadow: - 0 2px 8px rgba(0, 0, 0, 0.1), - 0 0 0 1px rgba(0, 0, 0, 0.06); + box-shadow: var(--shadow-sm); } .portal-editor-hero__mark { width: 100%; @@ -39,7 +37,7 @@ min-width: 0; display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.25rem; } .portal-editor-hero__title-row { @@ -50,31 +48,36 @@ } .portal-editor-hero__name { - font-size: 1.125rem; + font-size: 0.9375rem; font-weight: 700; - color: #fff; + color: var(--c-text); margin-right: 0.125rem; } -.portal-editor-hero__chip { - display: inline-flex; - align-items: center; - gap: 0.3125rem; - padding: 0.25rem 0.6875rem; - border-radius: var(--radius-pill); - border: 1px solid rgba(255, 255, 255, 0.16); - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.85); - font-size: 0.71875rem; - font-weight: 600; - cursor: pointer; - transition: background var(--motion-fast); +/* The deployment host, sat inline with the name as the rail's subject. */ +.portal-editor-hero__host { + font-family: var(--font-mono); + font-size: 0.8125rem; + font-weight: 500; + color: var(--c-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.portal-editor-hero__chip:hover { - background: rgba(255, 255, 255, 0.16); + +/* Quiet separator between the host and the adoption count. */ +.portal-editor-hero__dot { + width: 0.3125rem; + height: 0.3125rem; + border-radius: 999px; + background: var(--c-border); + flex-shrink: 0; } -.portal-editor-hero__chip svg { - color: rgba(255, 255, 255, 0.6); + +.portal-editor-hero__actives { + font-size: 0.75rem; + color: var(--c-text-muted); + white-space: nowrap; } .portal-editor-hero__meta { @@ -83,15 +86,10 @@ gap: 0.5rem; flex-wrap: wrap; font-size: 0.75rem; - color: rgba(255, 255, 255, 0.5); -} -.portal-editor-hero__host { - color: rgba(255, 255, 255, 0.92); - font-family: var(--font-mono); - font-weight: 500; + color: var(--c-text-subtle); } .portal-editor-hero__meta-sep { - color: rgba(255, 255, 255, 0.3); + color: var(--c-border); } .portal-editor-hero__action { @@ -101,35 +99,6 @@ gap: 0.5rem; } -/* Icon-only actions (e.g. install) on the dark header. */ -.portal-editor-hero__icon-btn { - display: grid; - place-items: center; - width: 2.25rem; - height: 2.25rem; - flex-shrink: 0; - border-radius: var(--radius-md); - border: 1px solid rgba(255, 255, 255, 0.16); - background: rgba(255, 255, 255, 0.08); - color: #fff; - cursor: pointer; - transition: background var(--motion-fast); -} -.portal-editor-hero__icon-btn:hover { - background: rgba(255, 255, 255, 0.16); -} - -/* White CTA on the dark header, matching the marketing card. */ -.portal-editor-hero__action .portal-editor-hero__cta.sui-btn { - background: #ffffff; - border-color: #ffffff; - color: var(--c-hero-dark-cta-text); -} -.portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover { - background: rgba(255, 255, 255, 0.88); - border-color: rgba(255, 255, 255, 0.88); -} - @media (max-width: 48rem) { .portal-editor-hero__row { flex-wrap: wrap; diff --git a/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx b/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx index 9e73983026..144ded6dc4 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.stories.tsx @@ -1,19 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { http, HttpResponse } from "msw"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; -import { SetupChecklist } from "@portal/components/SetupChecklist"; -import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; - -const progress: OnboardingProgress = { - loading: false, - deployed: true, - editorDone: true, - policiesDone: true, - inviteDone: false, - policiesActive: 3, - policiesRecommended: 4, - allComplete: false, -}; const meta: Meta = { title: "Portal/Home/EditorStatusCard", @@ -31,25 +18,14 @@ const meta: Meta = { export default meta; type Story = StoryObj; -/** The deployed-Editor status card on its own. */ +/** The deployment rail, reporting a live deployment. */ export const Default: Story = {}; -/** As it renders on the subscribed home: the setup checklist attached as the footer. */ -export const WithSetupChecklist: Story = { - args: { - footer: , - }, -}; - /** - * Backend without the editor-deployment endpoint (404): the status row is - * skipped and the card falls back to just the footer (setup checklist). It - * lights up automatically once /v1/editor/deployment is served. + * Backend without the editor-deployment endpoint (404): the rail keeps its identity and the neutral + * deploy ask, and states no host or adoption figure, since neither can be read. */ export const DeploymentUnavailable: Story = { - args: { - footer: , - }, parameters: { msw: { handlers: [ diff --git a/frontend/editor/src/portal/components/EditorStatusCard.tsx b/frontend/editor/src/portal/components/EditorStatusCard.tsx index 9726cc1009..b506a0a366 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.tsx +++ b/frontend/editor/src/portal/components/EditorStatusCard.tsx @@ -3,15 +3,12 @@ import { Fragment, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Skeleton } from "@app/ui"; import { useTier } from "@portal/contexts/TierContext"; -import { useView } from "@portal/contexts/ViewContext"; -import { useEditorDeployment } from "@portal/queries/infrastructure"; -import { type EditorInstance } from "@portal/api/editorDeploy"; +import { EDITOR_URL } from "@portal/auth/editorUrl"; import { - DownloadIcon, - ExternalLinkIcon, - UsersIcon, - UserPlusIcon, -} from "@portal/components/icons"; + useEditorDeployment, + useFleetStats, +} from "@portal/queries/infrastructure"; +import { type EditorInstance } from "@portal/api/editorDeploy"; import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; import "@portal/components/EditorStatusCard.css"; @@ -48,41 +45,64 @@ function primaryInstance(instances: EditorInstance[]): EditorInstance | null { ); } +/** + * The rail's primary action matures with adoption rather than deployment alone (marketing note + * D243): the loud ask only stands while the org is still one person on an undeployed editor. Once + * a deployment is in flight, finishing it takes over; once teammates are in, the ask goes quiet. + */ +type DeployAsk = "finish" | "start" | "options"; + +function deployAsk( + instances: EditorInstance[], + activeUsers: number, +): DeployAsk { + const deployed = instances.some((i) => i.status === "healthy"); + if (!deployed && instances.some((i) => i.status === "pairing")) + return "finish"; + if (!deployed && activeUsers <= 1) return "start"; + return "options"; +} + interface EditorStatusCardProps { - /** - * Rendered as an attached footer strip inside the card (e.g. the "Finish - * setting up" checklist), matching the free-tier hero's footer seam. - */ + /** Attached footer strip inside the card — the deal-status hero while a deal is underway. */ footer?: ReactNode; - /** - * Hide the active-users / invite chips. Used on enterprise, where the - * attached procurement deal hero already owns the invite action. - */ - hideChips?: boolean; } /** - * Subscribed/enterprise home hero: a status card for the org's deployed PDF - * Editor. Reads the same `/v1/editor/deployment` data as the Editor admin view - * (host, version, live users, deployment shape) and headlines the busiest - * instance, with a single "Open in browser" action to the workspace URL. + * The Home hero: the deployment rail for the org's PDF Editor. Host and build meta come from the + * same `/v1/editor/deployment` data as the Editor admin view, headlining the busiest instance; the + * adoption count is the figure Usage & Billing reports. The rail always renders — it carries the + * deploy ask itself — and each figure stands down independently when its source can't supply it. */ -export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) { +export function EditorStatusCard({ footer }: EditorStatusCardProps) { const { t } = useTranslation(); const { tier } = useTier(); - const { setActiveView } = useView(); const [installOpen, setInstallOpen] = useState(false); const { data, loading } = useEditorDeployment(tier); + // The adoption figure is the same one Usage & Billing reports, read through the shared cache so + // the two agree and only one request is made. Either field is null when the backend can't compute + // it (e.g. EE auditing off), in which case the rail states no figure rather than a misleading 0. + const { data: fleet } = useFleetStats(); + const adoption = + fleet?.activeThisMonth != null && fleet.editorsDeployed != null + ? { active: fleet.activeThisMonth, total: fleet.editorsDeployed } + : null; const view = useMemo(() => { if (!data) return null; - const primary = primaryInstance(data.instances); - if (!primary) return null; const activeUsers = data.instances.reduce((s, i) => s + i.activeUsers, 0); + const ask = deployAsk(data.instances, activeUsers); + const primary = primaryInstance(data.instances); + // Nothing deployed yet: the rail still shows, carrying the deploy ask. Only the host and + // build meta are instance-derived, so they simply stand down. + if (!primary) { + return { host: null, activeUsers, ask, meta: [] as string[] }; + } const targetLabel = t(`portal.home.editor.target.${primary.target}`); return { host: primary.host, activeUsers, + ask, workspaceUrl: data.summary.workspaceUrl, meta: [ // Skip the deployment label when it just repeats the host (e.g. the @@ -95,21 +115,8 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) { }; }, [data, t]); - const ready = !loading && !!view; - // The editor-deployment endpoint isn't implemented on every backend yet. - // When it's unavailable (finished loading with no data — e.g. a 404), skip - // the status row entirely and fall back to just the footer (the setup - // checklist, which reads supported endpoints). It lights up automatically - // once the backend serves /v1/editor/deployment. - const unavailable = !loading && !view; - - if (unavailable) { - return footer ? ( -

- {footer} -
- ) : null; - } + // Only the deploy ask can go loud; unknown deployment state keeps it quiet. + const loudAsk = !!view && view.ask !== "options"; return (
- {!ready || !view ? ( + {/* Each figure stands down on its own: the host and build meta need the deployment + endpoint, the adoption count needs fleet stats. Neither absence blanks the rail, and + nothing is asserted that its own source could not supply. */} + {loading ? ( <> @@ -133,65 +143,60 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) { {t("portal.home.editor.name")} - {!hideChips && ( - + {view?.host && ( + {view.host} + )} + {adoption && ( + <> + + + {t("portal.home.editor.activeOfDeployed", adoption)} + + )}
-
- {view.host} - {view.meta.map((item, i) => ( - - · - {item} - - ))} -
+ {view && view.meta.length > 0 && ( +
+ {view.meta.map((item, i) => ( + + {i > 0 && ( + · + )} + {item} + + ))} +
+ )} )} + {/* Open in browser is a left-seated secondary in every state, and carries no arrow + (marketing note D244); the deploy ask beside it is the only button that can go loud. + Reaching the editor never depends on deployment data — it falls back to the configured + editor URL — so this stays live even when the deployment endpoint is unavailable. */}
- {!hideChips && ( - - )} - +
diff --git a/frontend/editor/src/portal/components/HomeHero.stories.tsx b/frontend/editor/src/portal/components/HomeHero.stories.tsx index dd1ea07c04..8784bde97a 100644 --- a/frontend/editor/src/portal/components/HomeHero.stories.tsx +++ b/frontend/editor/src/portal/components/HomeHero.stories.tsx @@ -16,17 +16,10 @@ const meta = { export default meta; type Story = StoryObj; -/** Pay-as-you-go tier: welcome header + setup checklist until onboarding completes. */ -export const Default: Story = { - args: { tier: "pro" }, -}; - -/** Free tier renders the same welcome-header composition as pro. */ -export const FreeTier: Story = { - args: { tier: "free" }, -}; - -/** Enterprise tier hides the status chips — the procurement deal hero owns the invite step. */ -export const EnterpriseTier: Story = { - args: { tier: "enterprise" }, -}; +/** + * The hero is the Editor deployment rail on every tier and in both editions — it reports its own + * deployment state and deploy ask, so there is nothing tier-specific left to compose. A live + * procurement deal attaches the deal-status hero as the rail's footer; that comes from + * useProcurement, so it follows the mocked backend rather than a story arg. + */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx index 03bcc17ed0..e81d42dfef 100644 --- a/frontend/editor/src/portal/components/HomeHero.tsx +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -1,60 +1,50 @@ -import type { Tier } from "@portal/contexts/TierContext"; +import { useEffect } from "react"; +import { Skeleton } from "@app/ui"; import { useUI } from "@portal/contexts/UIContext"; -import { WelcomeBanner } from "@portal/components/WelcomeBanner"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; -import { SetupChecklist } from "@portal/components/SetupChecklist"; -import { useOnboardingProgress } from "@portal/hooks/useOnboardingProgress"; import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner"; import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; import { useProcurement } from "@portal/components/procurement/useProcurement"; /** - * The Home hero, composed with a procurement-aware, progress-aware footer: - * - * - no live deployment → welcome header (+ setup steps until complete) - * - deployment live → deployed-Editor status header (+ steps until complete) - * - onboarding complete → header only; the setup steps collapse away - * - enterprise → status header with chips hidden (the deal hero owns invite) - * - * The footer is the deal-status hero while a procurement deal is underway - * (procurement is a bolt-on to any tier); otherwise the setup checklist, until - * every step is done — then it collapses to just the header, matching the - * deployed-status card. The procurement takeover modals render alongside. + * The Home hero: always the Editor deployment rail, carrying the deal-status hero as its footer + * while a procurement deal is underway (procurement is a bolt-on to any tier). The rail states its + * own deployment status and deploy ask, so there is nothing for a tier to choose between. The + * procurement takeover modals render alongside. */ -export function HomeHero({ tier }: { tier: Tier }) { - const { openLinkModal } = useUI(); +export function HomeHero() { const procurement = useProcurement(); - const progress = useOnboardingProgress(); + const { trialSetupRequested, clearTrialSetupRequest } = useUI(); const dealActive = procurement.isLinked && procurement.started && !!procurement.data; - // Start the enterprise flow right here on Home: open the trial-setup modal when the account is - // linked, otherwise prompt to link first — no navigating off to the procurement view. - const onStartEnterprise = () => { - if (procurement.isLinked) procurement.onStartTrial(); - else openLinkModal(); - }; - - // Steps collapse once onboarding is complete; a live deal always keeps its - // hero. Otherwise the setup checklist carries the (progress-aware) steps. - const footer = dealActive ? ( - - ) : progress.allComplete ? undefined : ( - - ); - - // The live-status header (EditorStatusCard) needs a real deployment to show; - // without one it renders nothing, so route to it only when actually deployed. - // Everything else — including a step completed via the local download flag — - // keeps the always-present welcome header, so the card never vanishes. - const showStatus = progress.deployed; + // Someone said yes to enterprise elsewhere (the billing upsell, a sales link). Open trial setup + // once the snapshot has landed, so a buyer who already has a deal is not asked to start another. + useEffect(() => { + if (!trialSetupRequested || procurement.loading) return; + clearTrialSetupRequest(); + if (!procurement.started) procurement.onExploreEnterprise(); + }, [trialSetupRequested, procurement, clearTrialSetupRequest]); return ( <> - {showStatus ? ( - + {procurement.loading ? ( + // Hold the rail's shape rather than committing to a footer: branching before the snapshot + // lands paints the no-deal rail first, flashing on every refresh of an active deal. +
+
+ + +
+
) : ( - + + ) : undefined + } + /> )} diff --git a/frontend/editor/src/portal/components/SetupChecklist.css b/frontend/editor/src/portal/components/SetupChecklist.css deleted file mode 100644 index 0fcb71e678..0000000000 --- a/frontend/editor/src/portal/components/SetupChecklist.css +++ /dev/null @@ -1,122 +0,0 @@ -/* ──────────────────────────────────────────────────────────────────────── */ -/* Getting-started steps (home-hero body) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -.portal-setup { - display: flex; - flex-direction: column; -} - -.portal-setup__list { - list-style: none; - margin: 0; - padding: 0; -} - -.portal-setup__row { - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - gap: 0.875rem; - width: 100%; - padding: 0.6875rem 1.25rem; - border: none; - border-top: 1px solid var(--c-border-subtle); - background: transparent; - text-align: left; - cursor: pointer; - transition: background var(--motion-fast); -} -.portal-setup__item:first-child .portal-setup__row { - border-top: none; -} -.portal-setup__row:hover { - background: var(--c-hover); -} - -/* Numbered step marker */ -.portal-setup__num { - display: grid; - place-items: center; - width: 1.5rem; - height: 1.5rem; - flex-shrink: 0; - border-radius: 50%; - border: 1px solid var(--c-border); - font-size: 0.75rem; - font-weight: 600; - color: var(--c-text-subtle); -} -/* Completed step: filled green check. */ -.portal-setup__num.is-done { - border-color: var(--color-green); - background: var(--color-green); - color: #fff; -} -.portal-setup__row.is-done .portal-setup__text strong { - color: var(--c-text-muted); -} - -.portal-setup__text { - display: flex; - flex-direction: column; - min-width: 0; -} -.portal-setup__text strong { - font-size: 0.875rem; - font-weight: 600; - color: var(--c-text); -} -.portal-setup__text span { - font-size: 0.75rem; - line-height: 1.4; - color: var(--c-text-subtle); -} - -/* ── Enterprise upsell rung ── */ -.portal-setup__enterprise { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - padding: 0.75rem 1.25rem; - border-top: 1px solid var(--c-border-subtle); - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--c-primary) 5%, transparent) 0%, - transparent 55% - ); -} - -.portal-setup__enterprise-copy { - display: flex; - align-items: center; - gap: 0.75rem; - min-width: 0; - flex: 1; -} - -.portal-setup__enterprise-tag { - flex-shrink: 0; - padding: 0.1875rem 0.5625rem; - border-radius: var(--radius-md); - font-size: 0.59375rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--c-primary-hover); - background: var(--c-primary-tint); -} - -.portal-setup__enterprise-text { - margin: 0; - font-size: 0.8125rem; - line-height: 1.45; - color: var(--c-text-subtle); - min-width: 0; -} -.portal-setup__enterprise-text strong { - color: var(--c-text); - font-weight: 700; -} diff --git a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx b/frontend/editor/src/portal/components/SetupChecklist.stories.tsx deleted file mode 100644 index 432be155d7..0000000000 --- a/frontend/editor/src/portal/components/SetupChecklist.stories.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SetupChecklist } from "@portal/components/SetupChecklist"; -import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; - -const base: OnboardingProgress = { - loading: false, - deployed: false, - editorDone: false, - policiesDone: false, - inviteDone: false, - policiesActive: 0, - policiesRecommended: 6, - allComplete: false, -}; - -const meta: Meta = { - title: "Portal/Home/SetupChecklist", - component: SetupChecklist, - parameters: { layout: "padded" }, - args: { progress: base }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -/** A fresh workspace — no step complete yet. */ -export const NotStarted: Story = {}; - -/** Policies confirmed; editor + invite still open. */ -export const InProgress: Story = { - args: { - progress: { - ...base, - policiesDone: true, - policiesActive: 2, - policiesRecommended: 5, - }, - }, -}; - -/** Editor deployed + policies on; only the invite step remains. */ -export const AlmostDone: Story = { - args: { - progress: { - ...base, - editorDone: true, - policiesDone: true, - policiesActive: 4, - policiesRecommended: 3, - }, - }, -}; diff --git a/frontend/editor/src/portal/components/SetupChecklist.tsx b/frontend/editor/src/portal/components/SetupChecklist.tsx deleted file mode 100644 index d854beb771..0000000000 --- a/frontend/editor/src/portal/components/SetupChecklist.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; -import { useTier } from "@portal/contexts/TierContext"; -import { useView } from "@portal/contexts/ViewContext"; -import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; -import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; -import CheckRounded from "@mui/icons-material/CheckRounded"; -import "@portal/components/SetupChecklist.css"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Enterprise upsell rung */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** - * Enterprise on-ramp rung. The CTA differs by tier: free orgs start a guided - * trial, subscribed (paying) orgs jump straight to a quote — both open the - * procurement flow. When {@code onStart} is given the CTA opens the flow's setup - * modal over Home; otherwise it falls back to navigating to the procurement view. - */ -function EnterpriseRung({ - paying, - onStart, -}: { - paying: boolean; - onStart?: () => void; -}) { - const { t } = useTranslation(); - const { setActiveView } = useView(); - return ( -
-
- - {t("portal.home.onboarding.enterprise.tag")} - -

- {t("portal.home.onboarding.enterprise.lead")}{" "} - {t("portal.home.onboarding.enterprise.body")} -

-
- -
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Getting-started steps */ -/* ──────────────────────────────────────────────────────────────────────── */ - -interface Step { - id: string; - title: string; - blurb: string; - done: boolean; - onClick: () => void; -} - -/** - * Numbered getting-started steps, rendered as the body of the home hero. Each - * row opens its in-app surface; a completed step (from {@link OnboardingProgress}) - * swaps its number for a check. When every step is done the parent collapses the - * hero to the deployed-status header and stops rendering this list entirely. - */ -export function SetupChecklist({ - progress, - onStartEnterprise, -}: { - progress: OnboardingProgress; - /** Start the enterprise flow in place (opens the setup modal over Home). Falls back to - * navigating to the procurement view when omitted (e.g. in isolated stories). */ - onStartEnterprise?: () => void; -}) { - const { t } = useTranslation(); - const { tier } = useTier(); - const { setActiveView } = useView(); - const [downloadOpen, setDownloadOpen] = useState(false); - - const steps: Step[] = [ - { - id: "editor", - title: t("portal.home.onboarding.steps.editor.title"), - blurb: t("portal.home.onboarding.steps.editor.blurb"), - done: progress.editorDone, - // Downloads are per-OS, so open the install picker rather than route away. - onClick: () => setDownloadOpen(true), - }, - { - id: "policies", - title: t("portal.home.onboarding.steps.policies.title"), - blurb: t("portal.home.onboarding.steps.policies.blurb", { - active: progress.policiesActive, - recommended: progress.policiesRecommended, - }), - done: progress.policiesDone, - onClick: () => setActiveView("policies"), - }, - { - id: "invite", - title: t("portal.home.onboarding.steps.invite.title"), - blurb: t("portal.home.onboarding.steps.invite.blurb"), - done: progress.inviteDone, - onClick: () => setActiveView("users"), - }, - ]; - - return ( -
-
    - {steps.map((s, i) => ( -
  1. - -
  2. - ))} -
- - - - setDownloadOpen(false)} - /> -
- ); -} diff --git a/frontend/editor/src/portal/components/WelcomeBanner.css b/frontend/editor/src/portal/components/WelcomeBanner.css deleted file mode 100644 index 157ee24ac0..0000000000 --- a/frontend/editor/src/portal/components/WelcomeBanner.css +++ /dev/null @@ -1,103 +0,0 @@ -/* ──────────────────────────────────────────────────────────────────────── */ -/* Free-tier welcome hero — compact product header + steps */ -/* ──────────────────────────────────────────────────────────────────────── */ - -.portal-welcome { - border-radius: var(--radius-xl); - border: 1px solid var(--c-border-subtle); - overflow: hidden; - isolation: isolate; - background: var(--c-surface); -} - -/* ── Dark product header strip ── */ -.portal-welcome__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - padding: 0.875rem 1.25rem; - background: color-mix(in srgb, var(--c-primary) 22%, var(--c-hero-dark)); -} - -.portal-welcome__brand { - display: flex; - align-items: center; - gap: 0.875rem; - min-width: 0; -} - -.portal-welcome__mark { - display: grid; - place-items: center; - flex-shrink: 0; -} -.portal-welcome__mark img { - display: block; - height: 1.75rem; - width: auto; -} - -.portal-welcome__brand-text { - display: flex; - align-items: baseline; - gap: 0.625rem; - min-width: 0; - flex-wrap: wrap; -} - -.portal-welcome__product { - font-size: 1.125rem; - font-weight: 700; - letter-spacing: -0.01em; - color: #fff; -} - -.portal-welcome__stats { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.8125rem; - color: rgba(255, 255, 255, 0.55); -} - -/* Header action group: icon buttons + the CTA. */ -.portal-welcome__actions { - display: flex; - align-items: center; - gap: 0.5rem; -} -.portal-welcome__icon-btn { - display: grid; - place-items: center; - width: 2.25rem; - height: 2.25rem; - flex-shrink: 0; - border-radius: var(--radius-md); - border: 1px solid rgba(255, 255, 255, 0.16); - background: rgba(255, 255, 255, 0.08); - color: #fff; - cursor: pointer; - transition: background var(--motion-fast); -} -.portal-welcome__icon-btn:hover { - background: rgba(255, 255, 255, 0.16); -} - -/* White CTA on the dark header, matching the marketing card. */ -.portal-welcome__header .portal-welcome__cta.sui-btn { - background: #ffffff; - border-color: #ffffff; - color: var(--c-hero-dark-cta-text); -} -.portal-welcome__header .portal-welcome__cta.sui-btn:hover { - background: rgba(255, 255, 255, 0.88); - border-color: rgba(255, 255, 255, 0.88); -} - -/* ── Steps + enterprise (setup checklist) sit directly under the header ── */ -.portal-welcome__footer { - background: var(--c-surface); -} diff --git a/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx b/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx deleted file mode 100644 index 8dba4caf36..0000000000 --- a/frontend/editor/src/portal/components/WelcomeBanner.stories.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { WelcomeBanner } from "@portal/components/WelcomeBanner"; -import { SetupChecklist } from "@portal/components/SetupChecklist"; -import type { OnboardingProgress } from "@portal/hooks/useOnboardingProgress"; - -const progress: OnboardingProgress = { - loading: false, - deployed: false, - editorDone: false, - policiesDone: true, - inviteDone: false, - policiesActive: 2, - policiesRecommended: 5, - allComplete: false, -}; - -const meta: Meta = { - title: "Portal/Home/WelcomeBanner", - component: WelcomeBanner, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -/** The hero on its own, no attached footer. */ -export const Default: Story = {}; - -/** The hero as it renders on the free-tier home: the "Finish setting up" - * checklist attached as the footer strip. */ -export const WithSetupChecklist: Story = { - args: { - footer: , - }, -}; diff --git a/frontend/editor/src/portal/components/WelcomeBanner.tsx b/frontend/editor/src/portal/components/WelcomeBanner.tsx deleted file mode 100644 index c1d6857bdb..0000000000 --- a/frontend/editor/src/portal/components/WelcomeBanner.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { ReactNode } from "react"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; -import { useView } from "@portal/contexts/ViewContext"; -import { EDITOR_URL } from "@portal/auth/editorUrl"; -import { - DownloadIcon, - ExternalLinkIcon, - UserPlusIcon, -} from "@portal/components/icons"; -import { DownloadEditorModal } from "@portal/components/DownloadEditorModal"; -import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; -import "@portal/components/WelcomeBanner.css"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Free-tier welcome hero */ -/* */ -/* A compact product header — brand mark, "PDF Editor" + social-proof */ -/* stats, and a single "Open in browser" CTA — over the getting-started */ -/* steps (passed in as {@code footer}). Deliberately lean: the onboarding */ -/* steps, not marketing copy, are the point of the card. */ -/* ──────────────────────────────────────────────────────────────────────── */ - -interface WelcomeBannerProps { - /** - * The getting-started steps + enterprise rung, rendered inside the card - * below the header. Kept as a slot so the hero stays a presentational shell. - */ - footer?: ReactNode; -} - -export function WelcomeBanner({ footer }: WelcomeBannerProps) { - const { t } = useTranslation(); - const { setActiveView } = useView(); - const [installOpen, setInstallOpen] = useState(false); - - return ( -
-
-
- - - -
- - {t("portal.welcome.productName")} - - - {t("portal.welcome.stats")} - -
-
-
- - - -
-
- - {footer &&
{footer}
} - - setInstallOpen(false)} - /> -
- ); -} diff --git a/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx index b5d0d58a3b..7d322a7be6 100644 --- a/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/BundleCheckoutModal.tsx @@ -665,7 +665,6 @@ export function BundleCheckoutModal({ {t("portal.billing.prepaid.buy.cancel", "Cancel")} - @@ -701,7 +700,6 @@ export function BundleCheckoutModal({ {t("portal.billing.prepaid.buy.back", "Back")} diff --git a/frontend/editor/src/portal/components/billing/FreePlanView.tsx b/frontend/editor/src/portal/components/billing/FreePlanView.tsx index d3a1baf036..e2bbb24a34 100644 --- a/frontend/editor/src/portal/components/billing/FreePlanView.tsx +++ b/frontend/editor/src/portal/components/billing/FreePlanView.tsx @@ -85,7 +85,6 @@ export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { const switchOnAction = isLeader ? ( } diff --git a/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx b/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx index c099836ee4..f07083f215 100644 --- a/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx +++ b/frontend/editor/src/portal/components/billing/PrepayModalHeader.tsx @@ -1,15 +1,14 @@ import { useTranslation } from "react-i18next"; -import { Button } from "@app/ui"; -// The trademarked Stirling wordmark — the font is baked into the SVG (no brand webfont is loaded), so -// we render the same asset the portal nav uses rather than styled text. Theme-switched in CSS. -import wordmarkLight from "@app/assets/brand/modern-logo/StirlingProcessorLogoBlackText.svg"; -import wordmarkDark from "@app/assets/brand/modern-logo/StirlingProcessorLogoWhiteText.svg"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; /** - * Shared header for the prepay-flow modals — the prepaid wizard (activation → calculator → pay, of 3) - * and the metered checkout (spend limit → payment, of 2): Stirling brand + "Step N of M" badge + close, - * an M-segment progress bar, and the step title. Pass {@code step=undefined} to hide the badge + - * progress (e.g. a terminal confirmation). + * The prepay flows' stepped header — the prepaid wizard (activation → calculator → pay, of 3) and + * the metered checkout (spend limit → payment, of 2). + * + * Chrome and copy only: the layout is the shared {@link StepModalHeader}, so this flow reads the + * same as every other stepped modal. Keeps the billing root class, which the framed-checkout rule + * targets to own the header's padding. Pass {@code step=undefined} to hide the badge + progress + * (e.g. a terminal confirmation). */ export function PrepayModalHeader({ step, @@ -24,68 +23,27 @@ export function PrepayModalHeader({ onClose: () => void; }) { const { t } = useTranslation(); - const showSteps = step != null; - const filled = step ?? 0; return ( -
-
-
- Stirling - -
-
- {showSteps && ( - - {t( - "portal.billing.prepaid.buy.step", - "Step {{current}} of {{total}}", - { current: step, total }, - )} - - )} -
-
- {showSteps && ( -
- = 1 ? "is-filled" : ""} /> - = 2 ? "is-filled" : ""} /> - {total >= 3 && = 3 ? "is-filled" : ""} />} -
- )} -
{title}
-
+ ); } diff --git a/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx b/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx index 9ee2fa1f5e..5c7b510618 100644 --- a/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx +++ b/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx @@ -191,7 +191,7 @@ export function SpendLimitCard({ > {t("portal.billing.spendLimit.cancel", "Cancel")} - diff --git a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx index 4787c40b37..ee76a780c2 100644 --- a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx +++ b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx @@ -451,7 +451,6 @@ export function StripeCheckoutModal({ {t("portal.billing.checkout.cap.back", "Back")} - - - } - > -

{copy.body}

- {needsFile && ( -
- setFile(e.target.files?.[0] ?? null)} - /> - - - {file ? file.name : t("portal.procurement.modal.noFile")} - -
- )} - - ); -} diff --git a/frontend/editor/src/portal/components/procurement/DealJourney.stories.tsx b/frontend/editor/src/portal/components/procurement/DealJourney.stories.tsx deleted file mode 100644 index a59386dc3a..0000000000 --- a/frontend/editor/src/portal/components/procurement/DealJourney.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { DealJourney } from "@portal/components/procurement/DealJourney"; -import { buildProcurement } from "@portal/mocks/procurement"; -import type { Deal } from "@portal/api/procurement"; -import "@portal/views/Procurement.css"; - -const data = buildProcurement("enterprise"); -const deal = data.deal as Deal; - -const meta: Meta = { - title: "Portal/Procurement/DealJourney", - component: DealJourney, - parameters: { layout: "padded" }, - args: { deal, journey: data.journey, onAdvance: () => {} }, -}; -export default meta; -type Story = StoryObj; - -// Mid-journey at the Agreement stage, the seeded deal state. -export const Default: Story = {}; - -// Evaluating: the trial strip shows runway + key; next step builds the quote. -export const AtTrial: Story = { - args: { deal: { ...deal, currentStage: "trial" } }, -}; - -// Terminal stage, provisioning, no further CTA. -export const Live: Story = { - args: { deal: { ...deal, currentStage: "active" } }, -}; diff --git a/frontend/editor/src/portal/components/procurement/DealJourney.tsx b/frontend/editor/src/portal/components/procurement/DealJourney.tsx deleted file mode 100644 index 3e8c86e58d..0000000000 --- a/frontend/editor/src/portal/components/procurement/DealJourney.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card } from "@app/ui"; -import type { Deal, DealStage, JourneyStep } from "@portal/api/procurement"; -import { StageStepper } from "@portal/components/procurement/StageStepper"; - -/** - * The deal's commercial journey in one card: who's guiding it (the solutions - * engineer), where it sits (the stage stepper), trial runway while evaluating, - * and the single next action that advances the deal. Mirrors "one next action - * at a time"; the full per-stage checklist lives in the Documents card. - */ -export function DealJourney({ - deal, - journey, - onAdvance, - advancing = false, -}: { - deal: Deal; - journey: JourneyStep[]; - onAdvance: (stage: DealStage) => void; - advancing?: boolean; -}) { - const { t } = useTranslation(); - const { engineer, trial, currentStage } = deal; - const currentStep = journey.find((s) => s.stage === currentStage); - const isTerminal = - journey.length > 0 && journey[journey.length - 1].stage === currentStage; - - return ( - -
-
- - {t("portal.procurement.journey.eyebrow")} - -

- {t("portal.procurement.journey.title")} -

-

- {t("portal.procurement.journey.subtitle")} -

-
-
- - {t("portal.procurement.journey.engineerLabel")} - - {engineer.name} - - {engineer.email} - -
-
- -
- -
- - {currentStage === "trial" && ( -
- - {t("portal.procurement.journey.trialTitle")} - - - {t("portal.procurement.journey.daysLeft", { - count: trial.daysLeft, - })} - - {trial.key} -
- )} - -
-
- - - {isTerminal - ? t("portal.procurement.journey.live") - : t("portal.procurement.journey.nextStep", { - action: currentStep ? t(currentStep.gatingAction) : "", - })} - -
- {!isTerminal && currentStep && ( - - )} -
-
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx index 9421de28bb..77c008ce41 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.stories.tsx @@ -12,6 +12,10 @@ const base: ProcurementSnapshot = { trialExtensionsUsed: 0, licensed: false, licenseKey: null, + businessName: null, + contactName: null, + contactEmail: null, + agreementSignedVersion: null, latestQuote: null, }; @@ -23,11 +27,11 @@ const meta: Meta = { args: { canSchedule: true, onExpand: () => {}, + onAcceptQuote: () => {}, onLicense: () => {}, onInvite: () => {}, onSchedule: () => {}, onManageTrial: () => {}, - onNavigate: () => {}, }, }; export default meta; diff --git a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx index 092e259bb3..ef2172bb27 100644 --- a/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx +++ b/frontend/editor/src/portal/components/procurement/DealStatusHero.tsx @@ -1,31 +1,62 @@ -import { useEffect } from "react"; +import { useEffect, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; -import type { ViewId } from "@portal/contexts/ViewContext"; import { FLOW_JOURNEY, + type DealStage, type ProcurementSnapshot, } from "@portal/api/procurement"; -import { StageStepper } from "@portal/components/procurement/StageStepper"; +import { + CalendarIcon, + CheckIcon, + DocumentsIcon, + KeyIcon, + UserPlusIcon, +} from "@portal/components/icons"; import { warmCalendly } from "@portal/components/procurement/CalendlyInline"; +import { openApiUrl } from "@portal/api/externalUrl"; import "@portal/views/Procurement.css"; +/** What each stage asks of the buyer, read out in the stage sentence. */ +const STAGE_SENTENCE: Record = { + // Exploring sits on the Trial rung: same sentence, since the ask is what differs. + exploring: "portal.procurement.hero.sentenceTrial", + trial: "portal.procurement.hero.sentenceTrial", + quote: "portal.procurement.hero.sentenceQuote", + security: "portal.procurement.hero.sentenceAgreement", + procurement: "portal.procurement.hero.sentencePayment", + active: "portal.procurement.hero.sentenceLive", +}; + +/** The primary action for each stage; expanding the flow runs it. */ +const STAGE_CTA: Record = { + exploring: "portal.procurement.hero.ctaExploring", + trial: "portal.procurement.hero.ctaTrial", + quote: "portal.procurement.hero.ctaQuote", + security: "portal.procurement.hero.ctaAgreement", + // Same label whether it links straight to Stripe or, lacking an invoice URL, opens the stage dialog + // where the invoice actions live — the buyer is being sent to the invoice either way. + procurement: "portal.procurement.payment.viewInvoice", + active: "portal.procurement.hero.open", +}; + /** - * 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, licence key, 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. + * The enterprise deal-status hero on Home (procurement lives here, not as a nav tab) — this card IS + * the procurement surface. It carries the journey as a segmented progress band plus a stage + * sentence, and one primary action with quiet icon buttons beside it; the flow itself opens in the + * takeover modal. Rollout setup lives in the non-procurement setup checklist, not here. */ export function DealStatusHero({ snapshot, busy = false, canSchedule, onExpand, + onAcceptQuote, onLicense, onInvite, onSchedule, onManageTrial, - onNavigate, + onDocuments, }: { snapshot: ProcurementSnapshot; busy?: boolean; @@ -33,11 +64,17 @@ export function DealStatusHero({ * "Schedule a call" action only appears when the org has linked its account. */ canSchedule: boolean; onExpand: () => void; + /** + * Accept the issued quote, which advances the deal to the agreement. Offered here rather than + * inside the quote review so the buyer can circulate the quote and come back to decide. + */ + onAcceptQuote: () => void; onLicense: () => void; onInvite: () => void; onSchedule: () => void; onManageTrial: () => void; - onNavigate: (view: ViewId) => void; + /** Open the Documents reference (agreement, quote, invoice, EULA, SLA, subprocessors). */ + onDocuments: () => void; }) { const { t } = useTranslation(); @@ -49,129 +86,181 @@ export function DealStatusHero({ 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 === "procurement" - ? t("portal.procurement.hero.ctaPayment") - : t("portal.procurement.hero.ctaLive"); + const isLive = stage === "active"; + // A live quote is sitting with the buyer. A draft (or an expired/cancelled one) is not something to + // accept — that stage still means "finish building it". + const quoteAwaitingDecision = + stage === "quote" && + (snapshot.latestQuote?.status === "sent" || + snapshot.latestQuote?.status === "open"); + // Paying happens on Stripe, so the card links straight there rather than opening a dialog whose only + // real action was the same link. Without an invoice URL there is nothing to link to, so the stage + // falls back to its dialog, where the signed agreement is still reachable. + const invoiceUrl = + stage === "procurement" ? snapshot.latestQuote?.invoiceUrl : null; + // Known from trial setup onward. The quote's own copy wins when present, since the buyer may have + // corrected it there; before either exists the eyebrow stands alone rather than inventing a name. + const company = + snapshot.latestQuote?.config.businessName?.trim() || + snapshot.businessName?.trim(); - 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", - }, - ]; + // Exploring is presented as the Trial rung — same position, sentence and next step — because the + // buyer has entered the journey; only the ask differs, since no trial has actually started. + const journeyStage = stage === "exploring" ? "trial" : stage; + const currentIdx = Math.max( + 0, + FLOW_JOURNEY.findIndex((s) => s.stage === journeyStage), + ); + const nextStage = FLOW_JOURNEY[currentIdx + 1]; return (
-
+
- {t("portal.procurement.hero.eyebrow")} + {company + ? t("portal.procurement.hero.eyebrowCompany", { company }) + : t("portal.procurement.hero.eyebrow")} - - {t("portal.procurement.hero.company")} - -
-
+ +
+ {FLOW_JOURNEY.map((s, i) => ( + + ))} +
+ +

+ {t(FLOW_JOURNEY[currentIdx].label)} + {` · ${t(STAGE_SENTENCE[stage])} `} + {nextStage && ( + + {t("portal.procurement.hero.next", { + stage: t(nextStage.label), + })} + + )} +

+ {inTrial && snapshot.trialEndsAt && ( - - )} - {snapshot.licenseKey && ( - - )} - {stage !== "active" && ( - - )} - {canSchedule && ( - +
+ +
)}
-
- -
- - {inTrial && ( -
    - {setupSteps.map((s) => ( -
  • - -
  • - ))} -
+ {isLive && ( +
+ + + + + + {t("portal.procurement.hero.liveTitle")} + + + {t("portal.procurement.hero.liveSub")} + + +
)} -
- - - {t("portal.procurement.hero.nextStep", { action: cta })} - -
- + + + ) : invoiceUrl ? ( + + ) : ( + + )} +
+ {snapshot.licenseKey && ( + + + + )} + + + + {!isLive && ( + + + + )} + {canSchedule && ( + + + + )}
); } +/** A quiet icon-only secondary action; its label carries in the tooltip and to screen readers. */ +function IconAction({ + label, + onClick, + children, +}: { + label: string; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + 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/DocRow.stories.tsx b/frontend/editor/src/portal/components/procurement/DocRow.stories.tsx deleted file mode 100644 index 695b8c6941..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocRow.stories.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { DocRow } from "@portal/components/procurement/DocRow"; -import type { LedgerDoc } from "@portal/api/procurement"; -import "@portal/views/Procurement.css"; - -const meta: Meta = { - title: "Portal/Procurement/DocRow", - component: DocRow, - parameters: { layout: "padded" }, - args: { onAction: () => {} }, -}; -export default meta; -type Story = StoryObj; - -const sign: LedgerDoc = { - id: "d1", - name: "Stirling Enterprise Agreement", - sub: "One signature: MSA + order form + EULA + DPA.", - status: "action", - action: "sign", -}; - -const download: LedgerDoc = { - id: "d2", - name: "SOC 2 Type II report", - sub: "Independent audit of our security controls.", - status: "available", - action: "download", -}; - -const paidAddon: LedgerDoc = { - id: "d3", - name: "Onboarding & training", - sub: "Guided rollout and live training for your team.", - status: "request", - action: "request", - optional: true, - fee: 7_500, -}; - -const done: LedgerDoc = { - id: "d4", - name: "Formal quote", - sub: "Committed-volume pricing, term and line items.", - status: "complete", - action: "download", -}; - -// Deal-advancing action, filled purple CTA. -export const SignAction: Story = { args: { doc: sign } }; - -// Quiet outline action for a ready download. -export const Download: Story = { args: { doc: download } }; - -// Optional paid add-on, chips flag it and the fee folds into the CTA. -export const PaidAddon: Story = { args: { doc: paidAddon } }; - -// Completed paperwork keeps a record but offers no further action. -export const Complete: Story = { args: { doc: done } }; - -// A row in a future, not-yet-reached stage, dimmed, marked "Upcoming", inert. -export const Locked: Story = { args: { doc: sign, locked: true } }; diff --git a/frontend/editor/src/portal/components/procurement/DocRow.tsx b/frontend/editor/src/portal/components/procurement/DocRow.tsx deleted file mode 100644 index b4329fbfa4..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocRow.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Chip, StatusBadge } from "@app/ui"; -import type { LedgerDoc } from "@portal/api/procurement"; -import { - ACTION_LABEL_KEY, - STATUS_LABEL_KEY, - STATUS_TONE, - USD, -} from "@portal/components/procurement/format"; - -/** Maps a document's action to the button accent + variant. */ -function buttonStyle(doc: LedgerDoc): { - variant: "primary" | "secondary"; - accent: "premium" | "default"; -} { - // The agreement signature and online payment are the deal-advancing actions; - // give them the filled premium CTA. Everything else is a quieter outline. - if (doc.action === "sign" || doc.action === "pay") { - return { variant: "primary", accent: "premium" }; - } - return { variant: "secondary", accent: "default" }; -} - -/** - * A single document in the ledger or supporting pool: name + sub-line on the - * left, status badge and action button on the right. Optional/fee-bearing docs - * carry a chip so the buyer sees a paid add-on before clicking. `locked` is for - * rows in a future, not-yet-reached stage: dimmed, marked "Upcoming", inert. - */ -export function DocRow({ - doc, - onAction, - locked = false, -}: { - doc: LedgerDoc; - onAction: (doc: LedgerDoc) => void; - locked?: boolean; -}) { - const { t } = useTranslation(); - const { variant, accent } = buttonStyle(doc); - // Locked (future-stage), in-progress (pending) and completed paperwork all - // offer no action; only "available", "action" and "request" docs do. - const actionable = - !locked && doc.status !== "complete" && doc.status !== "pending"; - const label = t(ACTION_LABEL_KEY[doc.action]); - const actionLabel = - doc.fee !== undefined ? `${label} · ${USD.format(doc.fee)}` : label; - - return ( -
-
-
- {doc.name} - {doc.optional && ( - - {t("portal.procurement.docs.optional")} - - )} - {doc.fee !== undefined && ( - - {t("portal.procurement.docs.paidAddon")} - - )} -
-

{doc.sub}

-
-
- - {locked - ? t("portal.procurement.docs.upcoming") - : t(STATUS_LABEL_KEY[doc.status])} - - {actionable && ( - - )} -
-
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/DocumentLedger.stories.tsx b/frontend/editor/src/portal/components/procurement/DocumentLedger.stories.tsx deleted file mode 100644 index 5de6692999..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocumentLedger.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { DocumentLedger } from "@portal/components/procurement/DocumentLedger"; -import { buildProcurement } from "@portal/mocks/procurement"; -import "@portal/views/Procurement.css"; - -const data = buildProcurement("enterprise"); - -const meta: Meta = { - title: "Portal/Procurement/DocumentLedger", - component: DocumentLedger, - parameters: { layout: "padded" }, - args: { - groups: data.ledger, - supporting: data.supporting, - journey: data.journey, - currentStage: data.deal?.currentStage ?? "trial", - onAction: () => {}, - }, -}; -export default meta; -type Story = StoryObj; - -// Mid-journey: the Agreement stage is open, earlier stages read as done, later -// stages are locked previews, and the supporting pool sits collapsed below. -export const Default: Story = {}; - -// Day one: only the Trial stage has been reached; everything ahead is locked. -export const AtTrial: Story = { - args: { currentStage: "trial" }, -}; diff --git a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx b/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx deleted file mode 100644 index aa9e6ae8ce..0000000000 --- a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Card, Chip, Collapsible } from "@app/ui"; -import type { - DealStage, - JourneyStep, - LedgerDoc, - LedgerGroup, - SupportingGroup, -} from "@portal/api/procurement"; -import { DocRow } from "@portal/components/procurement/DocRow"; - -/** - * The "Documents" card: every artifact the deal needs, as a stage accordion - * that mirrors the journey. Only the current stage is open by default; earlier - * stages read as done, later stages are locked previews. A collapsed-by-default - * "Supporting your evaluation" pool holds the stage-agnostic paperwork. - */ -export function DocumentLedger({ - groups, - supporting, - journey, - currentStage, - onAction, -}: { - groups: LedgerGroup[]; - supporting: SupportingGroup[]; - journey: JourneyStep[]; - currentStage: DealStage; - onAction: (doc: LedgerDoc) => void; -}) { - const { t } = useTranslation(); - const order = journey.map((s) => s.stage); - const curIdx = order.indexOf(currentStage); - // Follow the deal: the stage you're in opens first; any other stage can be - // peeked. null collapses them all. Advancing moves the open section along. - const [openStage, setOpenStage] = useState(currentStage); - const [supportingOpen, setSupportingOpen] = useState(false); - useEffect(() => setOpenStage(currentStage), [currentStage]); - - return ( - -
-

- {t("portal.procurement.docs.title")} -

-

- {t("portal.procurement.docs.subtitle")} -

-
- -
- {groups.map((group) => { - const idx = order.indexOf(group.stage); - const done = idx < curIdx; - const cur = group.stage === currentStage; - const locked = idx > curIdx; - const blurb = journey.find((s) => s.stage === group.stage)?.blurb; - const open = openStage === group.stage; - const count = group.docs.length; - - return ( - setOpenStage(open ? null : group.stage)} - header={ - <> - - - {t(group.label)} - - {blurb && ( - - · {t(blurb)} - - )} - {cur && ( - - {t("portal.procurement.docs.here")} - - )} - {done && ( - - {t("portal.procurement.docs.done")} - - )} - - } - aside={ - - {t("portal.procurement.docs.count", { count })} - - } - > -
- {group.docs.map((doc) => ( - - ))} -
-
- ); - })} - - {supporting.length > 0 && ( - setSupportingOpen((o) => !o)} - header={ - - - {t("portal.procurement.docs.supportingTitle")} - - - {t("portal.procurement.docs.supportingSubtitle")} - - - } - aside={ - - {supportingOpen - ? t("portal.procurement.docs.hide") - : t("portal.procurement.docs.show")} - - } - > -
- {supporting.map((group) => ( -
-
{group.label}
-
- {group.docs.map((doc) => ( - - ))} -
-
- ))} -
-
- )} -
-
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx b/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx deleted file mode 100644 index eae9db4d99..0000000000 --- a/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { LockedState } from "@portal/components/procurement/LockedState"; -import { JOURNEY } from "@portal/api/procurement"; -import "@portal/views/Procurement.css"; - -const meta: Meta = { - title: "Portal/Procurement/LockedState", - component: LockedState, - parameters: { layout: "padded" }, - args: { onTalkToSales: () => {} }, -}; -export default meta; -type Story = StoryObj; - -// Shown to free/pro buyers, the journey preview behind the upgrade prompt. -export const Default: Story = { - args: { journey: JOURNEY }, -}; diff --git a/frontend/editor/src/portal/components/procurement/LockedState.tsx b/frontend/editor/src/portal/components/procurement/LockedState.tsx deleted file mode 100644 index 8e70ba18c4..0000000000 --- a/frontend/editor/src/portal/components/procurement/LockedState.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card, EmptyState } from "@app/ui"; -import type { JourneyStep } from "@portal/api/procurement"; -import { StageStepper } from "@portal/components/procurement/StageStepper"; - -/** - * Enterprise-only gate for free/pro buyers. Shows the journey as a greyed - * preview behind an upgrade prompt so the buyer understands what the - * commercial track looks like before they talk to sales. - */ -export function LockedState({ - journey, - onTalkToSales, -}: { - journey: JourneyStep[]; - onTalkToSales: () => void; -}) { - const { t } = useTranslation(); - return ( -
- - {t("portal.procurement.locked.talkToSales")} - - } - /> - - - -
- ); -} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx index a6ca8dfcf5..fd47d867c6 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx @@ -69,10 +69,9 @@ const meta: Meta = { args: { quote, busy: false, - downloading: false, onAgree: () => {}, - onDownload: () => {}, - onEdit: () => {}, + onRequestChanges: () => {}, + onClose: () => {}, }, }; export default meta; @@ -81,12 +80,7 @@ type Story = StoryObj; export const Default: Story = {}; -// Agreeing: the primary CTA shows its loading state while the accept call is in flight. -export const Agreeing: Story = { +// Signing: the primary CTA shows its loading state while the accept call is in flight. +export const Signing: Story = { args: { busy: true }, }; - -// Downloading: the secondary action shows its loading state instead. -export const Downloading: Story = { - args: { downloading: true }, -}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx index 336b6af5cb..318f23aca5 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx @@ -1,152 +1,267 @@ -import { useState } from "react"; +import { useRef, 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 Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Button } from "@app/ui"; +import { + fetchAgreementDocument, + fetchAgreementPdf, + recordAgreementSignature, + type QuoteResult, +} from "@portal/api/procurement"; +import { DownloadIcon } from "@portal/components/icons"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; +import { useAsync } from "@portal/hooks/useAsync"; 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. + * The agreement (security) step: the buyer reviews the full Stirling Enterprise Agreement — Master + * Services Agreement + Order Form (from the quote) + Data Processing Addendum, one signature — then + * signs it. The document body is served by the backend from the versioned legal registry (static + * legal copy, English only); this component renders it, gates signing behind a scroll-through, and + * captures the typed legal name, signatory, title, and authority. On sign it records the signature + * (pinned to the exact document version + a hash) and then accepts the quote into a subscription. + * + * Presented as the document itself rather than a card about it: this step names the agreement in the + * dialog's own header and carries its download there, so the terms are read on paper-like stock + * instead of in app chrome. That is why it draws its own header — see ProcurementFlow. */ export function ProcurementAgreement({ quote, busy, - downloading, onAgree, - onDownload, - onEdit, + onRequestChanges, + onClose, }: { quote: QuoteResult; busy: boolean; - downloading: boolean; - /** Accept the quote straight into a committed subscription (this is also the agreement). */ + /** Accept the quote straight into a committed subscription (runs after the signature is saved). */ onAgree: () => void; - onDownload: () => void; - onEdit: () => void; + /** Hand the buyer to their SE to negotiate terms: closes this and opens scheduling. */ + onRequestChanges: () => void; + /** This step draws the dialog's header, so it carries the close too. */ + onClose?: () => void; }) { const { t } = useTranslation(); - const [checked, setChecked] = useState(false); - const annual = money(quote.annualNetMinor, quote.currency); - const tcv = money(quote.tcvMinor, quote.currency); - const renewal = money(quote.renewalAnnualNetMinor, quote.currency); - const years = quote.config.termYears; + const { data: doc, loading } = useAsync(fetchAgreementDocument, []); + + const [legalName, setLegalName] = useState(quote.config.businessName ?? ""); + const [signatory, setSignatory] = useState(quote.config.contactName ?? ""); + const [title, setTitle] = useState(""); + const [confirmed, setConfirmed] = useState(false); + const [scrolledToEnd, setScrolledToEnd] = useState(false); + const [signing, setSigning] = useState(false); + const [downloadingMsa, setDownloadingMsa] = useState(false); + const [error, setError] = useState(false); + const [downloadError, setDownloadError] = useState(false); + const docRef = useRef(null); + + const downloadMsa = async () => { + setDownloadingMsa(true); + setDownloadError(false); + try { + const blob = await fetchAgreementPdf(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "stirling-enterprise-agreement.pdf"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } catch { + // Surface the failure — the PDF is rendered server-side, so a failure here means the + // render service is unavailable rather than something the buyer can retry around. + setDownloadError(true); + } finally { + setDownloadingMsa(false); + } + }; + + const onScroll = () => { + const el = docRef.current; + if (!el) return; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 24) { + setScrolledToEnd(true); + } + }; + + const ready = + scrolledToEnd && + confirmed && + legalName.trim().length > 0 && + signatory.trim().length > 0; + + const sign = async () => { + setError(false); + setSigning(true); + try { + await recordAgreementSignature({ + customerLegalName: legalName.trim(), + signatoryName: signatory.trim(), + signatoryTitle: title.trim(), + authorityConfirmed: confirmed, + }); + onAgree(); // proceed into the committed subscription + } catch { + setError(true); + } finally { + // In `finally`, not only on failure: accepting can fail after the signature is recorded, and + // the controller deliberately keeps this dialog open on failure so the error is readable. With + // the flag left set, the button span the rest of the session and there was no way to retry. + setSigning(false); + } + }; return ( - - - {t("portal.procurement.agreement.eyebrow")} - -

- {t("portal.procurement.agreement.title")} -

-

- {t("portal.procurement.agreement.intro")} -

+
+ + {/* On the document, like the quote's: it downloads what is on screen. */} + + {/* Redlines are a conversation, not a form: this hands the buyer to their SE rather than + pretending the terms can be amended in the app. */} + +
+ } + /> -
-

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. -

+ {/* The tray scrolls, not the paper. The paper is its natural height inside it, so mid-document it + runs flush to the footer with no grey beneath, and the tray's bottom padding only comes into + view once the buyer reaches the end — the page ending is what shows they got there. */} +
+
+ {loading &&

{t("portal.procurement.agreement.loading")}

} + {!loading && !doc && ( +

{t("portal.procurement.agreement.loadError")}

+ )} + {doc && ( + <> + {/* Letterhead: the reference ties the terms to the quote they price, and the version + label pins what was signed. The document's own heading follows, so this adds a + masthead rather than repeating the title. */} +
+ + {t("portal.procurement.agreement.confidential")} + + + {t("portal.procurement.agreement.ref", { + ref: quote.quoteNumber, + version: doc.versionLabel, + })} + +
+
+ {doc.markdown} +
+ + )} +
+
-

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: + {error && ( +

+ {t("portal.procurement.agreement.signError")}

-
    - {quote.lineItems.map((li) => ( -
  • - {li.label} - - {li.kind === "INCLUDED" - ? t("portal.procurement.builder.included") - : money(li.amountMinor, quote.currency)} + )} + {downloadError && ( +

    + {t("portal.procurement.agreement.downloadDraftError")} +

    + )} + + {/* The signature block: who is bound, who signs, and the act of signing, on one line — the + shape of a paper signature block rather than a form above a button. The consent sits + directly under the fields it qualifies, with no rule between them: it is part of signing, + not a separate section, and boxing it cost the document a quarter of its height. */} +
    +
    +
    +
  • - ))} -
+ setLegalName(e.target.value)} + /> + + + +
+ +
-

3. Term, renewal and annual fee adjustment

-

- This Agreement runs for the committed {years}-year term set out in the - Order Form. It then renews automatically for successive one-year terms - unless either party gives written notice of non-renewal at least 30 - days before the end of the then-current term. On each renewal the - annual fee increases by {quote.cpiRatePct}%, a fixed CPI adjustment. - Based on this quote, the first renewal year would be approximately{" "} - {renewal} per year; the committed term above is - billed at the rate in the Order Form and is not affected. -

- -

4. 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. -

- -

5. 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. -

- -

6. 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. -

+ {/* Consent under the fields it qualifies; the gate's state under the button it gates, so the + reason signing is unavailable sits beside the unavailable thing. */} +
+ + {doc && !scrolledToEnd && ( + + {t("portal.procurement.agreement.scrollHint")} + + )} +
- - - -
- - - -
- + ); } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx deleted file mode 100644 index 28e9d2f99a..0000000000 --- a/frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner"; -import type { ProcurementController } from "@portal/components/procurement/useProcurement"; -import type { ProcurementSnapshot } from "@portal/api/procurement"; - -const snapshot: ProcurementSnapshot = { - dealId: 1, - stage: "trial", - deployment: "cloud", - seats: 250, - trialStartedAt: "2026-06-25T00:00:00Z", - trialEndsAt: "2026-07-09T00:00:00Z", - trialExtensionsUsed: 0, - licensed: false, - licenseKey: null, - latestQuote: null, -}; - -function makeController( - overrides: Partial = {}, -): ProcurementController { - return { - isLinked: true, - loading: false, - data: null, - started: false, - stage: undefined, - latest: null, - isIssued: false, - isDraft: true, - busy: false, - downloading: false, - downloadingLicense: false, - error: null, - setError: () => {}, - open: false, - setOpen: () => {}, - editing: false, - setEditing: () => {}, - extra: null, - setExtra: () => {}, - invoicePdf: null, - onStartTrial: () => {}, - onConfirmSetup: () => {}, - onExtendTrial: () => {}, - onReset: () => {}, - onGenerate: () => {}, - onAgree: () => {}, - onDownloadPdf: async () => {}, - onDownloadOfflineLicense: async () => {}, - ...overrides, - }; -} - -/** Deal-status hero once a deal is underway, otherwise the enterprise on-ramp. */ -const meta: Meta = { - title: "Portal/Procurement/ProcurementBanner", - component: ProcurementBanner, - parameters: { layout: "padded" }, -}; -export default meta; - -type Story = StoryObj; - -/** No deal yet: the enterprise on-ramp upsell. */ -export const Upsell: Story = { - args: { controller: makeController() }, -}; - -/** A deal is underway: the wired deal-status hero. */ -export const DealUnderway: Story = { - args: { - controller: makeController({ - started: true, - data: snapshot, - stage: snapshot.stage, - }), - }, -}; diff --git a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx index d47ffa0b48..626cc8e9fb 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementBanner.tsx @@ -1,13 +1,10 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card } from "@app/ui"; import { useView } from "@portal/contexts/ViewContext"; import { DealStatusHero } from "@portal/components/procurement/DealStatusHero"; import type { ProcurementController } from "@portal/components/procurement/useProcurement"; /** - * The deal-status hero, wired to a shared ProcurementController. Rendered both - * standalone (the /procurement route) and as the Home hero card's footer once a - * deal is underway. Assumes an active deal (controller.data present). + * The deal-status hero, wired to a shared ProcurementController. Rendered as the Home hero card's + * footer once a deal is underway; assumes an active deal (controller.data present). */ export function ControlledDealStatusHero({ controller, @@ -21,60 +18,18 @@ export function ControlledDealStatusHero({ snapshot={controller.data} busy={controller.busy} canSchedule={controller.isLinked} - onExpand={() => controller.setOpen(true)} + onExpand={() => + // Exploring has no journey to expand into yet — its ask is to set the trial up. + controller.stage === "exploring" + ? controller.onStartTrial() + : controller.setOpen(true) + } + onAcceptQuote={() => void controller.onAcceptQuote()} onLicense={() => controller.setExtra("license")} onInvite={() => setActiveView("users")} onSchedule={() => controller.setExtra("schedule")} onManageTrial={() => controller.setExtra("trial")} - onNavigate={setActiveView} + onDocuments={() => controller.setExtra("documents")} /> ); } - -/** - * Enterprise on-ramp shown when no deal exists yet. Only used on the dedicated - * /procurement route — on Home the setup checklist's Enterprise rung owns the - * on-ramp, so this doesn't render there. - */ -export function ProcurementUpsell({ - controller, -}: { - controller: ProcurementController; -}) { - const { t } = useTranslation(); - return ( - -
- - {t("portal.procurement.upsell.homeBadge")} - -

- {t("portal.procurement.upsell.homeHeadline")} - {t("portal.procurement.upsell.homeBody")} -

-
- -
- ); -} - -/** Deal-status hero when a deal is underway, otherwise the enterprise on-ramp. */ -export function ProcurementBanner({ - controller, -}: { - controller: ProcurementController; -}) { - return controller.isLinked && controller.started && controller.data ? ( - - ) : ( - - ); -} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx index 06361da844..7a6c6ffb18 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx @@ -31,6 +31,10 @@ const SNAPSHOT: ProcurementSnapshot = { trialExtensionsUsed: 0, licensed: false, licenseKey: null, + agreementSignedVersion: null, + businessName: null, + contactName: null, + contactEmail: null, latestQuote: null, }; @@ -72,13 +76,14 @@ export const ScheduleCall: Story = { ), }; -// Deployment + seat count captured before the trial starts. +// Two steps before the trial starts: how they'll run it, then who is buying. export const TrialSetup: Story = { render: () => ( {}} busy={false} + onScheduleCall={() => {}} onConfirm={() => {}} /> ), diff --git a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx index 44bbea1264..10ea20ed0c 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementExtras.tsx @@ -1,11 +1,20 @@ import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { Button } from "@app/ui"; -import type { ProcurementSnapshot } from "@portal/api/procurement"; +import { + fetchLegalDocument, + recordLegalConsent, + type ProcurementSnapshot, + type TrialSetupDetails, +} from "@portal/api/procurement"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; import { CalendlyInline } from "@portal/components/procurement/CalendlyInline"; import { LicensePanel } from "@portal/components/procurement/ProcurementStages"; -import { useFocusTrap } from "@portal/components/procurement/ProcurementModal"; +import { FlowModal } from "@portal/components/shared/FlowModal"; +import { useAsync } from "@portal/hooks/useAsync"; +import { openApiUrl } from "@portal/api/externalUrl"; import "@portal/views/Procurement.css"; /** @@ -14,6 +23,8 @@ import "@portal/views/Procurement.css"; * scheduler. The shells and wiring are real so the hero behaves like the marketing prototype. */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + function SideModal({ open, onClose, @@ -21,56 +32,243 @@ function SideModal({ subtitle, children, footer, + headerAside, wide = false, }: { open: boolean; onClose: () => void; title: string; subtitle?: string; + /** Sits on the title row, before the close button (e.g. a "Step 1 of 2" badge). */ + headerAside?: React.ReactNode; children: React.ReactNode; footer?: React.ReactNode; wide?: boolean; }) { - 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}

+ return ( + +
+

{title}

+ {headerAside} +
{subtitle &&

{subtitle}

} + + } + > + {children} +
+ ); +} + +/** + * Reader for a versioned legal document (EULA, SLA exhibit, subprocessors), fetched from the + * backend registry and rendered as markdown. Open when {@code docId} is set. Drafts are badged. + */ +export function LegalDocumentModal({ + docId, + onClose, +}: { + docId: string | null; + onClose: () => void; +}) { + const { t } = useTranslation(); + const { data, loading } = useAsync( + () => (docId ? fetchLegalDocument(docId) : Promise.resolve(null)), + [docId], + ); + return ( + + {loading && ( +

{t("portal.legal.loading")}

+ )} + {!loading && !data && ( +

{t("portal.legal.loadError")}

+ )} + {data && ( +
+ {data.markdown}
-
{children}
- {footer &&
{footer}
} + )} +
+ ); +} + +// ── Documents ──────────────────────────────────────────────────────────────── +/** + * The deal's paperwork in one place, reachable throughout the journey (not tied to the current + * stage): the enterprise agreement, the quote, the invoice, and the reference documents (EULA, SLA + * exhibit, subprocessors). Each row downloads or views the real artifact when it's available, and + * reads as "available later" until then. The per-stage download buttons remain the primary path; + * this is the secondary, always-on reference. + */ +export function DocumentsModal({ + open, + onClose, + agreementVersion, + downloadingAgreement, + onDownloadAgreement, + onViewAgreement, + quoteAvailable, + downloadingQuote, + onDownloadQuote, + invoiceUrl, + invoicePdf, +}: { + open: boolean; + onClose: () => void; + agreementVersion?: string | null; + downloadingAgreement?: boolean; + onDownloadAgreement: () => void; + /** Jump to the agreement/sign stage in the flow (used before it's signed). */ + onViewAgreement: () => void; + quoteAvailable: boolean; + downloadingQuote?: boolean; + onDownloadQuote: () => void; + invoiceUrl?: string | null; + invoicePdf?: string | null; +}) { + const { t } = useTranslation(); + const [legalDoc, setLegalDoc] = useState(null); + const invoice = invoiceUrl || invoicePdf || null; + + return ( + <> + +
    + + + openApiUrl(invoice), + } + : { + unavailable: t("portal.procurement.documents.laterInvoice"), + } + } + /> + setLegalDoc("eula"), + }} + /> + setLegalDoc("sla"), + }} + /> + setLegalDoc("subprocessors"), + }} + /> +
+
+ setLegalDoc(null)} /> + + ); +} + +/** One row in the Documents list: name + sub on the left, an action button or a muted note. */ +function DocItem({ + name, + sub, + action, +}: { + name: string; + sub: string; + action: + | { label: string; onClick: () => void; loading?: boolean } + | { unavailable: string }; +}) { + return ( +
  • +
    + {name} + {sub}
    -
  • , - document.body, + {"unavailable" in action ? ( + {action.unavailable} + ) : ( + + )} + ); } @@ -154,82 +352,233 @@ export function TrialSetupModal({ open, onClose, busy, + email, + onScheduleCall, onConfirm, }: { open: boolean; onClose: () => void; busy: boolean; - onConfirm: (deployment: string, seats: number) => void; + /** Linked-account email, prefilled as the work email on the details step. */ + email?: string; + /** Open the scheduler — the step-1 escape hatch for buyers who want to talk first. */ + onScheduleCall: () => void; + onConfirm: ( + deployment: string, + seats: number, + details: TrialSetupDetails, + ) => void; }) { const { t } = useTranslation(); + const [step, setStep] = useState(0); const [deployment, setDeployment] = useState("cloud"); const [seats, setSeats] = useState(""); + const [contactName, setContactName] = useState(""); + const [businessName, setBusinessName] = useState(""); + const [contactEmail, setContactEmail] = useState(""); + const [inviteEmails, setInviteEmails] = useState(""); + const [eula, setEula] = useState(false); + const [legalDoc, setLegalDoc] = useState(null); // Reset to defaults each time the dialog opens, so a cancelled setup doesn't linger. useEffect(() => { if (open) { + setStep(0); setDeployment("cloud"); setSeats(""); + setContactName(""); + setBusinessName(""); + setContactEmail(email ?? ""); + setInviteEmails(""); + setEula(false); } - }, [open]); + }, [open, email]); + + // The buying entity is what the quote and agreement are drawn against, so it is required here + // rather than deferred to the quote; invites are genuinely optional. + const detailsValid = + contactName.trim().length > 0 && + businessName.trim().length > 0 && + EMAIL_RE.test(contactEmail.trim()); + + const confirm = () => { + void recordLegalConsent("eula", "trial"); // clickwrap consent, best-effort + onConfirm(deployment, Math.max(0, Number(seats) || 0), { + businessName: businessName.trim(), + contactName: contactName.trim(), + contactEmail: contactEmail.trim(), + inviteEmails: inviteEmails.trim(), + }); + }; return ( - onConfirm(deployment, Math.max(0, Number(seats) || 0))} - > - {t("portal.procurement.setup.start")} - - } - > - + + + ) : ( + <> + + + + ) + } + > + - -

    - {t("portal.procurement.setup.seatsHint")} -

    -
    + {step === 0 && ( + <> + + + + )} + + {step === 1 && ( + <> +
    + + +
    + + + + + + )} + + setLegalDoc(null)} /> + ); } @@ -280,7 +629,6 @@ export function TrialManageModal({ } @@ -103,46 +113,63 @@ export function ProcurementFlow({ {isLinked && started && ( <> -
    - -
    - - {(editing || - (isDraft && (stage === "trial" || stage === "quote"))) && ( + {builderShowing && ( setOpen(false)} onGenerate={onGenerate} + // Null while re-editing: the buyer asked for the form, not the paper they just left. + issued={!editing && isIssued ? latest : null} + downloading={downloading} + onDownload={onDownloadPdf} /> )} - {/* Quote + agreement are one step: review the itemised quote and the agreement, then - accept straight into a committed subscription. Once accepted you can't go back. - ("security" is the retired agreement stage — still handled so an older deal that - stopped there isn't left blank.) */} - {!editing && - isIssued && - (stage === "quote" || stage === "security") && - latest && ( - setEditing(true)} - /> - )} + {/* Agreement step: review and sign the enterprise agreement. Signing accepts the quote + into a committed subscription (Stripe). */} + {agreementShowing && latest && ( + { + setOpen(false); + setExtra("schedule"); + }} + onClose={() => setOpen(false)} + /> + )} {!editing && stage === "procurement" && latest && ( )} - {!editing && stage === "active" && } + {!editing && stage === "active" && ( + + )} )} @@ -151,6 +178,8 @@ export function ProcurementFlow({ open={extra === "setup"} onClose={() => setExtra(null)} busy={busy} + email={scheduleEmail ?? undefined} + onScheduleCall={() => setExtra("schedule")} onConfirm={onConfirmSetup} /> {data?.licenseKey && ( @@ -185,6 +214,23 @@ export function ProcurementFlow({ }} /> )} + setExtra(null)} + agreementVersion={data?.agreementSignedVersion} + downloadingAgreement={downloadingAgreement} + onDownloadAgreement={onDownloadSignedAgreement} + onViewAgreement={() => { + setExtra(null); + setEditing(false); + setOpen(true); + }} + quoteAvailable={!!latest?.stripeQuoteId} + downloadingQuote={downloading} + onDownloadQuote={onDownloadPdf} + invoiceUrl={latest?.invoiceUrl} + invoicePdf={latest?.invoicePdf ?? invoicePdf} + /> ); } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx deleted file mode 100644 index ae424922ad..0000000000 --- a/frontend/editor/src/portal/components/procurement/ProcurementHome.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 1877e0bc3c..0000000000 --- a/frontend/editor/src/portal/components/procurement/ProcurementHome.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ProcurementBanner } from "@portal/components/procurement/ProcurementBanner"; -import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; -import { useProcurement } from "@portal/components/procurement/useProcurement"; -import "@portal/views/Procurement.css"; - -/** - * The standalone procurement experience: a deal-status hero (or enterprise - * on-ramp when no deal exists) above the full-screen takeover flow that holds - * the journey — build + issue a quote, review + agree to the enterprise - * agreement, then accept into a committed subscription. Rendered at - * /procurement (autoOpen). On Home the deal-status hero instead attaches to the - * tier hero card's footer (see HomeHero) so this component isn't used there. - */ -export function ProcurementHome({ autoOpen = false }: { autoOpen?: boolean }) { - const controller = useProcurement(autoOpen); - return ( - <> - - - - ); -} diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx index 7fb8b865f2..14934905d6 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.stories.tsx @@ -31,9 +31,7 @@ export const Open: Story = { contract and go live.

    - +
    diff --git a/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx b/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx index 852c4e68c4..881bb778ce 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementModal.tsx @@ -1,104 +1,49 @@ -import { useEffect, useRef } from "react"; -import { createPortal } from "react-dom"; -import { useTranslation } from "react-i18next"; +import type { ReactNode } from "react"; +import { FlowModal } from "@portal/components/shared/FlowModal"; 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. + * The procurement takeover: the shared {@link FlowModal} at takeover width. Chrome and copy only — + * the shell (portal, focus trap, Escape, close, header/body bands) is shared, so this dialog cannot + * drift from the trial and licence dialogs the way two hand-rolled shells did. */ export function ProcurementModal({ open, onClose, title, subtitle, + headerless = false, children, }: { open: boolean; onClose: () => void; + /** Dialog label. Omit `subtitle` (and pass `headerless`) when the step renders its own heading. */ title: string; subtitle?: string; - children: React.ReactNode; + /** + * Skip the title block, which takes the shell's close with it: the step inside supplies the + * heading, step badge and its own close (see StepModalHeader), so the shell would otherwise stack + * a second header and leave a stray close above it. Escape and the backdrop still dismiss. + */ + headerless?: boolean; + children: 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()} + return ( + +

    {title}

    + {subtitle &&

    {subtitle}

    } + + ) + } > -
    - -
    -

    {title}

    - {subtitle &&

    {subtitle}

    } -
    -
    {children}
    -
    -
    , - document.body, + {children} + ); } diff --git a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx index 35460a7ba5..fcf926f481 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementStages.tsx @@ -1,61 +1,92 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Card } from "@app/ui"; +import { Button } from "@app/ui"; +import { openApiUrl } from "@portal/api/externalUrl"; 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 stage-specific views shown inside the procurement takeover modal once the agreement is signed: + * the subscription-created payment step and the live confirmation. Each is a pure presentational view + * driven by props; the controller owns the state and the actions. + * + * Neither is wrapped in a Card: the dialog is already the surface, and a card inside it drew a second + * border around content that filled it. Both wear the same eyebrow/title/description stack and put + * their actions in the flow's footer bar, so the last two steps of the journey read like the ones + * before them rather than like panels that wandered in. */ -/** The subscription-created step: pay or download the first invoice. */ +/** The subscription-created step: pay or download the first invoice, and the signed agreement. */ export function PaymentStageCard({ invoiceUrl, invoicePdf, + signedAgreementVersion, + downloadingAgreement, + onDownloadSignedAgreement, }: { invoiceUrl?: string | null; invoicePdf?: string | null; + /** Version label of the signed agreement PDF, if one is available to download. */ + signedAgreementVersion?: string | null; + downloadingAgreement?: boolean; + onDownloadSignedAgreement?: () => void; }) { const { t } = useTranslation(); return ( - +
    + + {t("portal.procurement.payment.eyebrow")} +

    {t("portal.procurement.payment.title")}

    {t("portal.procurement.payment.description")}

    - {(invoiceUrl || invoicePdf) && ( -
    - {invoiceUrl && ( - - )} - {invoicePdf && ( - - )} + {(invoiceUrl || invoicePdf || signedAgreementVersion) && ( +
    +
    + {signedAgreementVersion && onDownloadSignedAgreement && ( + + )} + {invoicePdf && ( + + )} + {invoiceUrl && ( + + )} +
    )} - +
    ); } /** The live confirmation once the deal is active. */ -export function LiveStageCard() { +export function LiveStageCard({ + signedAgreementVersion, + downloadingAgreement, + onDownloadSignedAgreement, +}: { + signedAgreementVersion?: string | null; + downloadingAgreement?: boolean; + onDownloadSignedAgreement?: () => void; +} = {}) { const { t } = useTranslation(); return ( - +
    {t("portal.procurement.live.eyebrow")} @@ -65,7 +96,20 @@ export function LiveStageCard() {

    {t("portal.procurement.live.description")}

    - + {signedAgreementVersion && onDownloadSignedAgreement && ( +
    +
    + +
    +
    + )} +
    ); } diff --git a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx index b392247563..98fcbd4f04 100644 --- a/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx +++ b/frontend/editor/src/portal/components/procurement/QuoteBuilder.tsx @@ -3,18 +3,24 @@ import { useTranslation } from "react-i18next"; import { Button } from "@app/ui"; import { DocumentsIcon, + DownloadIcon, PoliciesIcon, UsersIcon, } from "@portal/components/icons"; import { money } from "@portal/components/procurement/format"; import { buildQuote, + recordLegalConsent, type QuoteConfigInput, type QuoteResult, } from "@portal/api/procurement"; +import { LegalDocumentModal } from "@portal/components/procurement/ProcurementExtras"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; import "@portal/views/Procurement.css"; -const STEPS = ["volume", "plan", "details"] as const; +const STEPS = ["volume", "plan", "details", "review"] as const; +const DETAILS_STEP = 2; +const REVIEW_STEP = 3; const TERM_DISCOUNT = [0, 0.03, 0.05, 0.06, 0.07]; // 1..5 years — meter-only discount (D71) // Governance posture: the intensity (runs per PDF) fed to the committed-volume curve. const POSTURES = [ @@ -30,22 +36,57 @@ const SIZE_TIERS = [ ] as const; /** - * 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. + * The enterprise quote builder — volume → commitment & service → details → review. A client-side + * preview drives the live footer total; the backend is authoritative. Generating builds and issues in + * one go, and the issued quote comes back as the fourth step: the buyer reads the real itemised paper + * and can download it, but does not accept here. Accepting is a decision taken from the deal card, + * deliberately, so circulating the quote internally is not a dead end in a modal. */ export function QuoteBuilder({ deployment, seats = 0, + email, + onClose, + dealDetails, initial, + eulaAlreadyAgreed = false, onGenerate, + issued, + downloading = false, + onDownload, }: { deployment: string; /** Seat count from the trial setup; seeds the users field + volume estimate on a fresh quote. */ seats?: number; + /** Linked-account email; prefills the contact email on a fresh quote's details step. */ + email?: string | null; + /** Dismiss the dialog. The builder draws its own header, so it carries the close too. */ + onClose?: () => void; + /** + * The buying entity captured at trial setup. Seeds a fresh quote's details step so it confirms + * what is already known rather than asking twice; the buyer can still correct it here, since a + * deal can change hands between trial and quote. + */ + dealDetails?: { + businessName?: string | null; + contactName?: string | null; + contactEmail?: string | null; + }; /** Seed the builder from an existing quote's config (re-editing a quote). */ initial?: QuoteConfigInput; + /** + * The issued quote, which is what the review step shows. Its arrival is also what opens that step: + * the parent issues the quote and it lands by snapshot refresh, so there is no synchronous result + * to advance on. Null while re-editing, so editing reopens the form rather than the paper. + */ + issued?: QuoteResult | null; + downloading?: boolean; + onDownload?: () => void; + /** + * The buyer already accepted the EULA (e.g. at trial start). When true, the EULA clickwrap is + * hidden here and no consent is recorded at quote time — it's only collected once. + */ + eulaAlreadyAgreed?: boolean; /** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */ onGenerate: (quote: QuoteResult) => void; }) { @@ -65,9 +106,9 @@ export function QuoteBuilder({ indemnification: false, training: false, qbr: false, - businessName: "", - contactName: "", - contactEmail: "", + businessName: dealDetails?.businessName ?? "", + contactName: dealDetails?.contactName ?? "", + contactEmail: dealDetails?.contactEmail ?? email ?? "", addressLine1: "", addressLine2: "", city: "", @@ -79,29 +120,81 @@ export function QuoteBuilder({ ); // 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); + // Never pre-ticked, even when re-editing a quote: a consent the buyer did not tick in this session + // is not a consent, and recordLegalConsent would have logged one as though they had. + const [eula, setEula] = useState(false); + const [legalDoc, setLegalDoc] = useState(null); const [busy, setBusy] = useState(false); + // Only surface field errors once the buyer tries to generate — no red fields on first sight. + const [showErrors, setShowErrors] = 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 + // Required buyer details before a quote can be generated (Order Form / invoice need these). + const emailOk = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( + (cfg.contactEmail ?? "").trim(), + ); + const valid = { + businessName: cfg.businessName.trim().length > 0, + contactName: (cfg.contactName ?? "").trim().length > 0, + contactEmail: emailOk, + addressLine1: (cfg.addressLine1 ?? "").trim().length > 0, + city: (cfg.city ?? "").trim().length > 0, + region: (cfg.region ?? "").trim().length > 0, + postalCode: (cfg.postalCode ?? "").trim().length > 0, + }; + const detailsValid = Object.values(valid).every(Boolean); + const eulaOk = eulaAlreadyAgreed || eula; + const canGenerate = detailsValid && eulaOk; + + // Re-editing an existing quote: everything is seeded, so jump to the details step 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); + if (initial) setStep(DETAILS_STEP); }, []); + // Issuing lands by snapshot refresh rather than as a return value, so the arrival of the issued + // quote is what opens the review step. Keyed on the quote's id, not the object: React Query hands + // back a fresh object on every refetch, which would yank a buyer who had walked Back to the form. + useEffect(() => { + if (issued) setStep(REVIEW_STEP); + }, [issued?.quoteId]); + const preview = previewAnnualMinor(cfg); const tcvPreview = preview * cfg.termYears + (cfg.training ? 750_000 : 0); + // On the review step the footer quotes the issued figures rather than the client-side preview, so + // the running total never disagrees with the paper directly above it. + const onPaper = issued != null && step === REVIEW_STEP; + const running = onPaper + ? { + annual: money(issued.annualNetMinor, issued.currency), + years: issued.config.termYears, + tcv: money(issued.tcvMinor, issued.currency), + } + : { + annual: money(preview), + years: cfg.termYears, + tcv: money(tcvPreview), + }; + // 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() { + if (!canGenerate) { + setShowErrors(true); + return; + } setBusy(true); try { - onGenerate(await buildQuote(cfg)); + const quote = await buildQuote(cfg); + // Record the EULA clickwrap only when it's collected here — i.e. the buyer didn't already + // accept it at trial start. Best-effort. + if (!eulaAlreadyAgreed) void recordLegalConsent("eula", "quote"); + onGenerate(quote); } finally { setBusy(false); } @@ -109,22 +202,16 @@ export function QuoteBuilder({ return (
    -
    -

    - {t("portal.procurement.builder.title")} -

    - - {t("portal.procurement.builder.stepOf", { - n: step + 1, - total: STEPS.length, - })} - -
    -
    - {STEPS.map((s, i) => ( - - ))} -
    +
    {step === 0 && ( @@ -171,15 +258,6 @@ export function QuoteBuilder({ ? t("portal.procurement.builder.volManual") : t("portal.procurement.builder.volNoUsers")}

    - - )} - - {step === 1 && ( - } - title={t("portal.procurement.builder.s2Title")} - sub={t("portal.procurement.builder.s2Sub")} - >
    {POSTURES.map((p) => ( @@ -209,7 +287,15 @@ export function QuoteBuilder({ ))}
    +
    + )} + {step === 1 && ( + } + title={t("portal.procurement.builder.s2Title")} + sub={t("portal.procurement.builder.s2Sub")} + >
    {[1, 2, 3, 4, 5].map((y) => ( @@ -287,7 +373,11 @@ export function QuoteBuilder({ sub={t("portal.procurement.builder.s3Sub")} >
    - + set("businessName", e.target.value)} /> - +
    - + set("contactEmail", e.target.value)} /> - +
    - + set("city", e.target.value)} /> - + set("region", e.target.value)} /> - +
    - + {!eulaAlreadyAgreed && ( + + )} + {showErrors && !canGenerate && ( +

    + {t("portal.procurement.builder.completeRequired")} +

    + )} )} + + {/* No step heading here, unlike the form steps: the quote is the content, and a heading over + it only repeats what the paper already says. The real issued figures, not the footer's + client-side preview — this is the document the buyer circulates, so it has to match the + PDF and the Stripe quote exactly. */} + {step === REVIEW_STEP && issued && ( +
    +
    +
    +
    +
    Stirling PDF
    +
    + {t("portal.procurement.builder.paperEyebrow")} +
    +
    +
    +
    + {issued.quoteNumber} +
    + {issued.validUntil && ( +
    + {t("portal.procurement.review.validUntil", { + date: new Date(issued.validUntil).toLocaleDateString(), + })} +
    + )} + {/* On the document rather than in the footer: it downloads this paper, so it + belongs to it, and the footer stays the flow's own Back/Done. */} + +
    +
    + + {issued.config.businessName?.trim() && ( +
    +
    + {t("portal.procurement.builder.paperFor")} +
    +
    + {issued.config.businessName} +
    +
    + )} + +
      + {issued.lineItems.map((li) => ( +
    • + {li.label} + {money(li.amountMinor, issued.currency)} +
    • + ))} +
    + +
    +
    +
    + {t("portal.procurement.review.annual")} +
    +
    + {t("portal.procurement.review.tcv", { + years: issued.config.termYears, + tcv: money(issued.tcvMinor, issued.currency), + })} +
    +
    + {t("portal.procurement.review.renewal", { + amount: money( + issued.renewalAnnualNetMinor, + issued.currency, + ), + pct: issued.cpiRatePct, + })} +
    + {issued.config.poNumber?.trim() && ( +
    + {t("portal.procurement.review.poNumber", { + po: issued.config.poNumber.trim(), + })} +
    + )} +
    +
    + {money(issued.annualNetMinor, issued.currency)} +
    +
    +
    +
    + )}
    - {t("portal.procurement.builder.running", { - annual: money(preview), - years: cfg.termYears, - tcv: money(tcvPreview), - })} + {t("portal.procurement.builder.running", running)}
    {step > 0 && ( @@ -408,7 +630,6 @@ export function QuoteBuilder({ {step === 0 && ( )} {step === 1 && ( - )} - {step === 2 && ( - )} + {/* No Accept here: the review step ends on the deal card, where accepting is one of two + deliberate choices rather than the only way out of a modal. Download lives on the + document itself. */} + {step === REVIEW_STEP && ( + + )}
    + setLegalDoc(null)} />
    ); } @@ -470,14 +690,25 @@ function Step({ function Field({ label, + required, + invalid, children, }: { label: string; + required?: boolean; + invalid?: boolean; children: React.ReactNode; }) { return ( -
    ); diff --git a/frontend/editor/src/portal/views/Procurement.css b/frontend/editor/src/portal/views/Procurement.css index e26fd74a31..b721768952 100644 --- a/frontend/editor/src/portal/views/Procurement.css +++ b/frontend/editor/src/portal/views/Procurement.css @@ -1,34 +1,10 @@ -.portal-proc { - display: flex; - flex-direction: column; - gap: 1.25rem; - padding: 1.5rem; - max-width: 84rem; - margin: 0 auto; -} - -/* Page header */ -.portal-proc__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; -} - -.portal-proc__title { - margin: 0; - font-size: 1.375rem; - font-weight: 600; - color: var(--c-text); -} - .portal-proc__subtitle { margin: 0.25rem 0 0; font-size: 0.8125rem; color: var(--c-text-subtle); } -/* Eyebrow label shared by the journey header + SE block */ +/* Eyebrow label above a stage panel's heading */ .portal-proc__eyebrow { display: block; font-size: 0.6875rem; @@ -38,613 +14,33 @@ color: var(--c-text-subtle); margin-bottom: 0.25rem; } - -/* ── Journey card ─────────────────────────────────────────────────────── */ -/* Stacked, border-divided sections: header / stepper / trial / next step. */ -.portal-proc__journey-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 1rem; - padding: 1.25rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); - flex-wrap: wrap; -} - -.portal-proc__journey-title { - margin: 0; - font-size: 1.0625rem; - font-weight: 700; - color: var(--c-text); -} - -.portal-proc__journey-sub { - margin: 0.25rem 0 0; - max-width: 36rem; - font-size: 0.8125rem; - line-height: 1.5; - color: var(--c-text-subtle); -} - -.portal-proc__se { - display: flex; - flex-direction: column; - text-align: right; - flex-shrink: 0; -} - -.portal-proc__se .portal-proc__eyebrow { - margin-bottom: 0.25rem; -} - -.portal-proc__se-name { - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__se-email { - font-size: 0.75rem; - color: var(--c-primary); - text-decoration: none; -} - -.portal-proc__se-email:hover { - text-decoration: underline; -} - -/* Stepper band */ -.portal-proc__journey-stepper { - padding: 1.125rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-proc__steps { - display: flex; - align-items: flex-start; -} - -.portal-proc__steps--locked { - opacity: 0.55; - filter: grayscale(0.4); - pointer-events: none; -} - -.portal-proc__step { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.4rem; - min-width: 4rem; -} - -.portal-proc__step-dot { - width: 0.875rem; - height: 0.875rem; - border-radius: 50%; - background: var(--c-border); -} - -.portal-proc__step--complete .portal-proc__step-dot { - background: var(--color-green); -} - -.portal-proc__step--current .portal-proc__step-dot { - background: var(--color-purple); - box-shadow: 0 0 0 4px var(--color-purple-light); -} - -.portal-proc__step-label { - font-size: 0.6875rem; - font-weight: 500; - text-align: center; - white-space: nowrap; - color: var(--c-text-subtle); -} - -.portal-proc__step--complete .portal-proc__step-label { - color: var(--c-text-subtle); -} - -.portal-proc__step--current .portal-proc__step-label { - font-weight: 700; - color: var(--c-text); -} - -/* Connector aligns with the 0.875rem dots: (14px − 2px) / 2 = 6px down */ -.portal-proc__step-line { - flex: 1; - height: 2px; - margin: 0.375rem 0.375rem 0; - background: var(--c-border-subtle); -} - -.portal-proc__step-line[data-filled="true"] { - background: var(--color-green); -} - -/* Trial status strip (shown while evaluating) */ -.portal-proc__trial { - display: flex; - align-items: center; - gap: 0.625rem; - flex-wrap: wrap; - padding: 0.75rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); - background: var(--color-bg-code); -} - -.portal-proc__trial-title { - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__trial-dim { - font-size: 0.78125rem; - color: var(--c-text-subtle); -} - -.portal-proc__trial-key { - font-family: - ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; - font-size: 0.6875rem; - color: var(--c-text-subtle); - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: 0.375rem; - padding: 0.1875rem 0.5rem; -} - -/* Next-step row: one primary action at a time */ -.portal-proc__next { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - flex-wrap: wrap; - padding: 1rem 1.5rem; -} - -.portal-proc__next-label { - display: flex; - align-items: center; - gap: 0.625rem; - font-size: 0.84375rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__next-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 50%; - background: var(--color-amber); - flex-shrink: 0; -} - -.portal-proc__next-dot[data-live="true"] { - background: var(--color-green); -} - -/* ── Documents card ───────────────────────────────────────────────────── */ -.portal-proc__docs-head { - padding: 1.125rem 1.5rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-proc__docs-title { - margin: 0; - font-size: 0.9375rem; - font-weight: 700; - color: var(--c-text); -} - -.portal-proc__docs-sub { - margin: 0.25rem 0 0; - font-size: 0.78125rem; - color: var(--c-text-subtle); -} - -.portal-proc__docs-body { - padding: 0.375rem 1.5rem 1.125rem; -} - -/* Accordion spacing — the disclosure chrome itself lives in shared Collapsible - (.sui-collapsible); here we only space the stacked sections. */ -.portal-proc__docs-body .sui-collapsible { - margin-top: 0.875rem; -} - -/* Stage header bits */ -.portal-proc__stage-dot { - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; - flex-shrink: 0; -} - -.portal-proc__stage-dot[data-state="done"] { - background: var(--color-green); -} - -.portal-proc__stage-dot[data-state="current"] { - background: var(--color-purple); -} - -.portal-proc__stage-dot[data-state="upcoming"] { - background: var(--c-border); -} - -.portal-proc__stage-label { - font-size: 0.75rem; - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; - color: var(--c-text-subtle); -} - -.portal-proc__stage-label[data-current] { - color: var(--c-text); -} - -.portal-proc__stage-hint { - font-size: 0.71875rem; - color: var(--c-text-subtle); -} - -.portal-proc__stage-count { - font-size: 0.6875rem; - color: var(--c-text-subtle); -} - -/* Document lists inside the accordion */ -.portal-proc__doc-list { - border-top: 1px solid var(--c-border-subtle); -} - -.portal-proc__doc-list--boxed { - border: 1px solid var(--c-border-subtle); - border-radius: 0.5rem; - overflow: hidden; -} - -/* Supporting section — extra separation from the stage accordion above it. - Scoped to match the general .sui-collapsible spacing rule's specificity. */ -.portal-proc__docs-body .portal-proc__supporting-acc { - margin-top: 1.5rem; -} - -.portal-proc__supporting-head { - display: flex; - flex-direction: column; - gap: 0.125rem; - min-width: 0; -} - -.portal-proc__supporting-sub { - font-size: 0.71875rem; - font-weight: 400; - line-height: 1.45; - color: var(--c-text-subtle); -} - -.portal-proc__acc-toggle-label { - font-size: 0.75rem; - font-weight: 600; - color: var(--c-primary); -} - -.portal-proc__supporting-groups { - display: flex; - flex-direction: column; - gap: 1rem; - border-top: 1px solid var(--c-border-subtle); - padding: 0.875rem; -} - -.portal-proc__group-label { - font-size: 0.6875rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--c-text-subtle); - margin-bottom: 0.5rem; -} - -/* ── Document rows (ledger + supporting) ──────────────────────────────── */ -.portal-proc__doc { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.75rem 0.875rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-proc__doc:last-child { - border-bottom: none; -} - -.portal-proc__doc[data-locked] { - opacity: 0.6; -} - -.portal-proc__doc-text { - min-width: 0; -} - -.portal-proc__doc-name-row { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.portal-proc__doc-name { - font-size: 0.84375rem; - font-weight: 600; - color: var(--c-text); -} - -.portal-proc__doc-sub { - margin: 0.0625rem 0 0; - font-size: 0.71875rem; - line-height: 1.4; - color: var(--c-text-subtle); -} - -.portal-proc__doc-actions { - display: flex; - align-items: center; - gap: 0.75rem; - flex-shrink: 0; -} - -/* ── Locked state ─────────────────────────────────────────────────────── */ -.portal-proc__locked { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -/* ── Action modal ─────────────────────────────────────────────────────── */ -.portal-proc__modal-body { - margin: 0 0 1rem; - font-size: 0.875rem; - line-height: 1.55; - color: var(--c-text-muted); -} - -.portal-proc__modal-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.625rem; -} - -.portal-proc__upload { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.portal-proc__upload-input { - display: none; -} - -.portal-proc__upload-name { - font-size: 0.75rem; - color: var(--c-text-subtle); -} - -@media (max-width: 48rem) { - .portal-proc__journey-head { - flex-direction: column; - } - - .portal-proc__se { - text-align: left; - } - - .portal-proc__steps { - 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(--c-primary); - background: var(--c-primary-subtle); - 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(--c-text-subtle); - max-width: 44rem; -} -.portal-proc__upsell-copy strong { - color: var(--c-text); -} - -/* ── 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(--c-text); } -.portal-proc__builder-step { - font-size: 0.75rem; - color: var(--c-text-subtle); -} -.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(--c-text-subtle); -} -.portal-proc__field input, -.portal-proc__field select { - padding: 0.45rem 0.6rem; - border: 1px solid var(--c-border); - border-radius: 0.5rem; - font-size: 0.875rem; - background: var(--c-input-bg); - color: var(--c-text); -} -.portal-proc__builder-addons { - display: flex; - flex-direction: column; - gap: 0.4rem; - font-size: 0.8125rem; - color: var(--c-text-muted); -} -.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(--c-border); - padding-bottom: 0.5rem; -} -.portal-proc__quote-number { - font-weight: 650; - color: var(--c-text); -} -.portal-proc__quote-valid { - font-size: 0.75rem; - color: var(--c-text-subtle); -} -.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(--c-text-muted); - border-bottom: 1px solid var(--c-border-subtle); -} -.portal-proc__quote-lines li[data-kind="DISCOUNT"] { - color: var(--c-success); -} -.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(--c-text); -} -.portal-proc__quote-tcv { - font-size: 0.75rem; - color: var(--c-text-subtle); -} /* ══ Quote builder — copied from the marketing prototype (tight density) ═════ */ .portal-qb { - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: 14px; - box-shadow: inset 0 0 0 1px var(--c-border-subtle); - overflow: hidden; -} -.portal-qb__head { display: flex; - align-items: center; - justify-content: space-between; - padding: 18px 24px 14px; - border-bottom: 1px solid var(--c-border-subtle); - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--c-primary) 5.5%, transparent) 0%, - transparent 100% - ); -} -.portal-qb__title { - margin: 0; - font-size: 16px; - font-weight: 700; - color: var(--c-text); -} -.portal-qb__stepchip { - font-size: 11px; - font-weight: 700; - color: var(--c-text-subtle); - background: var(--c-surface-sunken); - 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: var(--c-surface-sunken); - transition: background 0.3s; -} -.portal-qb__progress span[data-on] { - background: var(--c-primary); + flex-direction: column; } +/* No padding or scroll of its own: FlowModal's panel supplies both, and nesting a second scroll + container inside a scrolling panel gave the builder two scrollbars. */ +/* This gap is the builder's only vertical rhythm: the blocks inside carry no bottom margins of their + own, so spacing cannot double up the way a margin plus a gap did. The top margin is the breathing + room under the stepped header, which deliberately has no bottom margin so its host sets this. */ .portal-qb__body { - padding: 20px 24px; - max-height: 56vh; - overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-top: 1.05rem; } .portal-qb__intro { display: flex; align-items: center; gap: 12px; - margin-bottom: 18px; } .portal-qb__intro-icon { width: 38px; @@ -667,9 +63,10 @@ color: var(--c-text-subtle); margin-top: 1px; } +/* No bottom margin: inside .portal-qb__body the gap spaces these, and inside .portal-qb__row a + margin only reserved dead space under the inputs. */ .portal-qb__field { display: block; - margin-bottom: 18px; } .portal-qb__field-label { display: block; @@ -691,6 +88,18 @@ background: var(--c-input-bg); color: var(--c-text); } +.portal-qb__req { + color: var(--c-danger); +} +.portal-qb__field[data-invalid] input, +.portal-qb__field[data-invalid] select { + border-color: var(--c-danger); +} +.portal-qb__error { + margin: 10px 0 0; + font-size: 12px; + color: var(--c-danger); +} .portal-qb__row { display: flex; gap: 14px; @@ -701,7 +110,7 @@ min-width: 190px; } .portal-qb__hint { - margin: 7px 0 0; + margin: 0; font-size: 11.5px; color: var(--c-text-subtle); line-height: 1.4; @@ -736,11 +145,22 @@ gap: 10px; flex-wrap: wrap; } +/* Short-label options that should sit across one row rather than wrapping 2 + 1. Tighter padding + and label size so each caption fits on a single line at three-across. Deliberately no `nowrap`: + a longer translation should wrap rather than clip or push the card out of the row. */ +.portal-qb__opts--across .portal-qb__opt { + min-width: 0; + padding: 11px 12px; +} +.portal-qb__opts--across .portal-qb__opt-sub { + font-size: 11px; + line-height: 1.35; +} .portal-qb__opt { text-align: left; flex: 1; min-width: 150px; - padding: 12px 14px; + padding: 9px 11px; border-radius: 9px; border: 1px solid var(--c-border); background: var(--c-surface); @@ -834,7 +254,11 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 14px 24px; + /* Bleeds to the panel's edges by reading the shell's own inset, so changing FlowModal's padding + can no longer leave this footer stopping short of them. */ + margin: 0.85rem calc(-1 * var(--flowmodal-inset)) + calc(-1 * var(--flowmodal-body-end)); + padding: 0.8rem var(--flowmodal-inset) 0.9rem; border-top: 1px solid var(--c-border-subtle); flex-wrap: wrap; } @@ -846,13 +270,13 @@ display: flex; gap: 10px; } -/* Step 4 — the itemised quote paper */ +/* Step 4 — the itemised quote paper, on a sunken tray that runs to the panel's edges. No scroll of + its own: the dialog body already scrolls, and nesting a second scroller gave the builder two + scrollbars. The bleed reads the shell's inset rather than hard-coding a copy of it. */ .portal-qb__papertray { background: var(--c-surface-sunken); - padding: 18px; - max-height: 56vh; - overflow-y: auto; - margin: -20px -24px; + padding: 14px var(--flowmodal-inset); + margin: 0 calc(-1 * var(--flowmodal-inset)); } .portal-qb__paper { background: var(--c-surface); @@ -880,6 +304,11 @@ .portal-qb__paper-meta { text-align: right; } +/* Reads as a link on the document rather than a button in a toolbar: right-aligned under the quote's + own metadata, with the row's padding trimmed so it sits tight to the date above it. */ +.portal-qb__paper-download { + margin: 0.2rem -0.5rem -0.25rem 0; +} .portal-qb__quote-number { font-size: 12.5px; font-weight: 700; @@ -955,27 +384,24 @@ 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(--c-border); border-radius: 12px; padding: 1.1rem 1.25rem; - background: - radial-gradient( - 120% 140% at 100% 0%, - color-mix(in srgb, var(--c-hue-violet) 8%, transparent), - transparent 55% - ), - var(--c-surface); + background: var(--c-surface); display: flex; flex-direction: column; gap: 1rem; } + +/* Attached under the editor rail the two read as one card, so the hero drops its standalone frame + and lets the footer's top border be the only seam. It keeps the frame on the procurement view, + where it stands alone. */ +.portal-editor-hero__footer .portal-hero { + border: none; + border-radius: 0; +} .portal-hero__top { display: flex; align-items: flex-start; @@ -983,25 +409,56 @@ gap: 1rem; flex-wrap: wrap; } +.portal-hero__ident { + flex: 1; + min-width: 0; +} .portal-hero__eyebrow { display: block; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--c-primary); + color: var(--c-text-subtle); + margin-bottom: 0.55rem; } -.portal-hero__company { - display: block; - font-size: 1.0625rem; - font-weight: 650; +/* The journey band: one segment per stage, filled through the current one. A progress indicator, + so it lives inside the block whose progress it reports and deliberately does not pulse. */ +.portal-hero__bar { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 0.625rem; +} +.portal-hero__bar span { + flex: 1; + height: 6px; + border-radius: 999px; + background: var(--c-hover); + transition: background 0.3s ease; +} +.portal-hero__bar span[data-on] { + background: var(--c-primary); +} +/* The hero's one-line status: bold stage · what it asks, then Next: … */ +.portal-hero__sentence { + margin: 0; + font-size: 0.84375rem; + line-height: 1.5; + color: var(--c-text-muted); +} +.portal-hero__sentence strong { + font-weight: 700; color: var(--c-text); - margin-top: 0.2rem; +} +.portal-hero__sentence-next { + color: var(--c-text-subtle); } .portal-hero__chips { display: flex; gap: 0.4rem; flex-wrap: wrap; + margin-top: 0.5rem; } .portal-hero__chip { font-size: 0.6875rem; @@ -1011,88 +468,74 @@ border-radius: 999px; padding: 0.2rem 0.6rem; } -.portal-hero__stepper { - overflow-x: auto; -} -.portal-hero__next { +/* One action row: the stage's primary CTA leads, quiet icon actions sit beside it. No dividers. */ +.portal-hero__cta { display: flex; align-items: center; - justify-content: space-between; gap: 0.75rem; flex-wrap: wrap; - padding-top: 0.85rem; - border-top: 1px solid var(--c-border-subtle); } -.portal-hero__next-label { +.portal-hero__icons { + display: flex; + align-items: center; + gap: 0.5rem; +} +.portal-hero__iconbtn { + width: 34px; + height: 34px; display: inline-flex; align-items: center; - gap: 0.45rem; - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text-muted); -} -.portal-hero__next-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 999px; - background: var(--c-primary); - box-shadow: 0 0 0 3px var(--c-primary-subtle); -} - -/* ── 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: var(--c-overlay); - 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(--c-surface); + border-radius: 9px; border: 1px solid var(--c-border); - border-radius: 14px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); - padding: 1.5rem 1.5rem 1.75rem; -} -.portal-procmodal__close { - position: absolute; - top: 0.85rem; - right: 0.85rem; - border: none; - background: var(--c-border-subtle); - color: var(--c-text-subtle); - width: 1.9rem; - height: 1.9rem; - border-radius: 8px; - font-size: 0.85rem; + background: var(--c-surface); + color: var(--c-text-muted); cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; } -.portal-procmodal__close:hover { - background: var(--c-border); +.portal-hero__iconbtn:hover { + background: var(--c-hover); + border-color: var(--c-border-strong); color: var(--c-text); } -.portal-procmodal__header { - margin-bottom: 1.25rem; - padding-right: 2.5rem; +/* Terminal state: the deal is done, so the row reports rather than asks. */ +.portal-hero__live { + display: flex; + align-items: center; + gap: 0.75rem; } +.portal-hero__live-tile { + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: 11px; + display: flex; + align-items: center; + justify-content: center; + /* Holds a CheckIcon, which takes its stroke from `color` and its size from its own prop. */ + color: var(--c-success); + background: var(--c-success-subtle); +} +.portal-hero__live-text { + display: flex; + flex-direction: column; + min-width: 0; +} +.portal-hero__live-title { + font-size: 0.90625rem; + font-weight: 700; + color: var(--c-text); +} +.portal-hero__live-sub { + font-size: 0.78125rem; + color: var(--c-text-subtle); + margin-top: 1px; +} + +/* ── Procurement takeover: heading type only, the shell is the shared Modal ── */ .portal-procmodal__title { margin: 0; font-size: 1.35rem; @@ -1104,15 +547,6 @@ font-size: 0.875rem; color: var(--c-text-subtle); } -.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; @@ -1124,7 +558,7 @@ padding: 1rem; border: 1px solid var(--c-border); border-radius: 0.6rem; - background: var(--color-surface-2, rgba(0, 0, 0, 0.02)); + background: var(--c-surface-sunken); } .portal-proc__license-label { display: block; @@ -1139,7 +573,7 @@ margin-top: 0.4rem; padding: 0.55rem 0.7rem; border-radius: 0.4rem; - background: var(--color-surface-3, rgba(0, 0, 0, 0.05)); + background: var(--c-surface-raised); font-family: var(--font-mono, monospace); font-size: 0.85rem; word-break: break-all; @@ -1150,45 +584,9 @@ font-size: 0.75rem; color: var(--c-text-subtle, var(--c-text-muted)); } -.portal-proc__milestone-for { - margin: 0.15rem 0 0; - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text-muted); -} -.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(--c-text); -} -.portal-proc__milestone-annual small { - font-size: 0.8125rem; - font-weight: 500; - color: var(--c-text-subtle); -} -.portal-proc__milestone-tcv { - font-size: 0.8125rem; - color: var(--c-text-subtle); -} /* 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). */ +/* Hero quick-action chips (the trial countdown pill under the stage sentence). */ .portal-hero__chip--action { border: 1px solid var(--c-border); cursor: pointer; @@ -1203,101 +601,8 @@ 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(--c-border-subtle); -} -.portal-hero__checklist li { - border-bottom: 1px solid var(--c-border-subtle); -} -.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(--c-hover, var(--c-border-subtle)); -} -.portal-hero__check-dot { - width: 0.5rem; - height: 0.5rem; - border-radius: 999px; - background: var(--c-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(--c-text); -} -.portal-hero__check-sub { - display: block; - font-size: 0.75rem; - color: var(--c-text-subtle); - margin-top: 0.05rem; -} -.portal-hero__check-pill { - font-size: 0.6875rem; - font-weight: 600; - color: var(--c-text-subtle); - background: var(--c-border-subtle); - 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: var(--c-overlay); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - animation: portal-procmodal-fade 0.15s ease-out; - /* Portaled to , outside .portal-scope, so set the portal UI font explicitly. */ - font-family: var(--font-sans); -} -.portal-sidemodal__panel { - position: relative; - width: 100%; - max-width: 30rem; - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: 14px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28); - padding: 1.35rem 1.4rem 1.4rem; - max-height: 86vh; - overflow-y: auto; -} -/* Wide enough for Calendly's two-pane layout (its single-column layout below ~680px inner width is - tall and scrolls); paired with the embed's taller fixed height so the time view needs no scroll. */ -.portal-sidemodal__panel--wide { - max-width: 52rem; -} -.portal-sidemodal__header { - margin-bottom: 1rem; - padding-right: 2rem; -} +/* Chrome and width come from the shared Modal via FlowModal; only type and content live here. */ .portal-sidemodal__title { margin: 0; font-size: 1.05rem; @@ -1316,15 +621,6 @@ color: var(--c-text-subtle); 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(--c-border-subtle); -} .portal-sidemodal__ghost { border: none; background: none; @@ -1336,63 +632,6 @@ color: var(--c-text-muted); } -/* 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(--c-text-subtle); - 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(--c-border-subtle); -} -.portal-docs__row-text { - flex: 1; - min-width: 0; -} -.portal-docs__row-name { - display: block; - font-size: 0.8125rem; - font-weight: 600; - color: var(--c-text); -} -.portal-docs__row-sub { - display: block; - font-size: 0.72rem; - color: var(--c-text-subtle); - 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(--c-text-subtle); - background: var(--c-border-subtle); -} -.portal-docs__row-action[data-status="action"] { - color: var(--c-primary); - background: var(--c-primary-subtle); -} -.portal-docs__row-action[data-status="request"] { - color: var(--c-text-subtle); -} - /* Calendly scheduler embed (Schedule a call). The widget is always light (Calendly renders inputs on white), so give it a white surface — it reads as a clean card even inside the dark-mode modal. */ .portal-calendly { @@ -1418,17 +657,173 @@ } /* ── Agreement (security) step ────────────────────────────────────────────── */ -.portal-agreement__doc { - margin: 1rem 0; - max-height: 22rem; - overflow-y: auto; - padding: 1rem 1.1rem; - border: 1px solid var(--c-border); - border-radius: 10px; - background: var(--color-bg-subtle, var(--c-bg)); - font-size: 0.8125rem; - line-height: 1.55; +/* The terminal steps (payment, live): a stacked eyebrow/title/description with the flow's own footer + bar beneath. No card of their own — the dialog is already the surface. */ +.portal-procstage { + display: flex; + flex-direction: column; + gap: 0.4rem; +} +/* Nothing to report on the left of these, so the actions take the whole bar. */ +.portal-procstage__foot { + justify-content: flex-end; +} + +/* The two document actions read as a pair, close together and set apart from the close beside them. */ +.portal-agreement__actions { + display: flex; + align-items: center; + gap: 0.1rem; +} + +/* The signature block: the three fields that name the bound party and its signatory, on the same line + as the act of signing, with the consent directly beneath them and no rule between. Fields shrink + below the row's usual floor so all three plus the button hold one line at the takeover's width; + they wrap rather than clip if a translation runs long. */ +/* No rule above it: the tray's grey ends where the signature block begins, which is boundary enough, + and a border there cut the document off from the page it sits on. */ +.portal-agreement__signbar { + flex-direction: column; + align-items: stretch; + gap: 0.6rem; + border-top: none; +} +/* Bottom-aligned: a field is a label above an input, so aligning to the block's centre or its top + leaves the button off the input row. Sharing the input's bottom edge puts its centre on theirs, both + being 37px. The gate note is deliberately NOT in this row — hanging it off the button would make the + column taller than the fields and drag the button back up off the row. */ +.portal-agreement__signrow { + display: flex; + align-items: flex-end; + gap: 0.9rem; +} +/* Consent on the left, the gate's state on the right so it lands under the button it explains. */ +.portal-agreement__signfoot { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} +.portal-agreement__gate { + flex: 0 0 auto; + font-size: 0.6875rem; color: var(--c-text-subtle); + white-space: nowrap; +} +.portal-agreement__signrow .portal-agreement__signfields { + flex: 1; + gap: 0.6rem; + min-width: 0; +} +.portal-agreement__signrow .portal-qb__field { + min-width: 8.5rem; +} +/* Unboxed: a line of small print under the fields, not a panel competing with the document. */ +.portal-agreement__accept { + display: flex; + align-items: flex-start; + gap: 0.5rem; + font-size: 0.75rem; + line-height: 1.45; + color: var(--c-text-subtle); + cursor: pointer; +} +.portal-agreement__accept input { + margin: 0.1rem 0 0; + flex: 0 0 auto; +} + +/* The agreement is presented as paper, not as app copy in a box: a sunken tray running to the panel's + edges, with the terms on white stock in a serif face. Signing is the most consequential thing a + buyer does here, so the document should read like the document it is. */ +/* The document takes every pixel the dialog can spare: it is what the buyer is here to read, and a + fixed height left it a small white box floating in a tall panel. The chain has to carry the fill — + each link needs min-height:0 or a flex child refuses to shrink below its content. */ +.portal-agreement { + display: flex; + flex-direction: column; + gap: 0.75rem; + flex: 1 1 auto; + min-height: 0; +} +/* The tray is the scroll container, so the paper inside it behaves like a page being scrolled past a + window: flush to the footer while there is more to read, and revealing the tray's bottom padding + only at the end. Scrolling the paper instead left a permanent grey band under it, which read as a + box with contents rather than a document. */ +/* `scroll`, not `auto`, and an explicitly styled bar: signing is gated on reaching the end of the + document, so the buyer has to be able to see there is more of it and how far through they are. + Overlay scrollbars fade out when idle, which hid both. Styling the bar also opts Chromium out of + overlay behaviour, so it stays put. Matches the treatment on the files page. */ +.portal-agreement__tray { + background: var(--c-surface-sunken); + padding: 14px var(--flowmodal-inset); + margin: 0 calc(-1 * var(--flowmodal-inset)); + flex: 1 1 auto; + min-height: 0; + overflow-y: scroll; + scrollbar-width: thin; + scrollbar-color: var(--c-text-subtle) var(--c-border-subtle); +} +.portal-agreement__tray::-webkit-scrollbar { + width: 0.625rem; +} +.portal-agreement__tray::-webkit-scrollbar-track { + background: var(--c-border-subtle); + border-radius: 999px; +} +.portal-agreement__tray::-webkit-scrollbar-thumb { + background: var(--c-text-subtle); + border-radius: 999px; + border: 2px solid transparent; + background-clip: content-box; +} +.portal-agreement__tray::-webkit-scrollbar-thumb:hover { + background: var(--c-text-muted); + background-clip: content-box; +} +.portal-agreement__doc { + /* A floor so a short document still reads as a page; no ceiling, so a long one runs on and the tray + does the scrolling. */ + min-height: 16rem; + padding: 1.35rem 1.6rem; + border: 1px solid var(--c-border-subtle); + border-radius: 12px; + background: var(--c-surface); + box-shadow: var(--shadow-sm); + /* No serif token exists in the theme — the serif is specific to rendering legal terms as paper. */ + font-family: Georgia, "Times New Roman", "Liberation Serif", serif; + font-size: 0.84rem; + line-height: 1.62; + color: var(--c-text-muted); +} +/* Masthead above the document's own heading: confidentiality on the left, the quote reference and + signed version on the right, over the rule that opens the terms. */ +.portal-agreement__letterhead { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + padding-bottom: 0.6rem; + margin-bottom: 1rem; + border-bottom: 2px solid var(--c-text); + font-family: var(--font-sans); + font-size: 0.6875rem; + letter-spacing: 0.04em; + color: var(--c-text-subtle); +} +.portal-agreement__confidential { + font-weight: 700; + text-transform: uppercase; +} +/* The document's own title, centred like an executed agreement's. */ +.portal-agreement__md > h1:first-child, +.portal-agreement__md > h2:first-child { + text-align: center; + font-size: 1.05rem; + letter-spacing: 0.02em; + text-transform: uppercase; + margin-bottom: 1rem; } .portal-agreement__doc h4 { margin: 1rem 0 0.35rem; @@ -1445,29 +840,129 @@ .portal-agreement__doc strong { color: var(--c-text); } -.portal-agreement__accept { - margin-top: 0.25rem; +.portal-agreement__md h1 { + font-size: 0.95rem; + font-weight: 700; + color: var(--c-text); + margin: 1.1rem 0 0.5rem; } -.portal-agreement__lines { - margin: 0.4rem 0 0.6rem; +.portal-agreement__md h2 { + font-size: 0.875rem; + font-weight: 650; + color: var(--c-text); + margin: 1rem 0 0.4rem; } -.portal-proc__reset { - display: flex; - justify-content: center; - padding-top: 0.5rem; +.portal-agreement__md h3 { + font-size: 0.8125rem; + font-weight: 650; + color: var(--c-text); + margin: 0.9rem 0 0.3rem; } -.portal-proc__reset button { +.portal-agreement__md h1:first-child, +.portal-agreement__md h2:first-child { + margin-top: 0; +} +.portal-agreement__md p { + margin: 0 0 0.55rem; +} +.portal-agreement__md strong { + color: var(--c-text); +} +.portal-agreement__md ul { + margin: 0 0 0.6rem; + padding-left: 1.1rem; +} +.portal-agreement__md li { + margin-bottom: 0.2rem; +} +.portal-agreement__md table { + border-collapse: collapse; + width: 100%; + margin: 0.4rem 0 0.8rem; + font-size: 0.78rem; +} +.portal-agreement__md th, +.portal-agreement__md td { + border: 1px solid var(--c-border); + padding: 0.35rem 0.5rem; + text-align: left; + vertical-align: top; +} +.portal-agreement__md th { + background: var(--c-bg); + font-weight: 650; + color: var(--c-text); +} +.portal-agreement__signfields { + margin-top: 0.75rem; +} +.portal-proc__error { + color: var(--c-danger); + font-size: 0.8125rem; + margin: 0.5rem 0 0; +} +.portal-legal__link { border: none; background: none; + padding: 0; + font: inherit; + color: var(--c-text); + text-decoration: underline; + cursor: pointer; +} +.portal-legal__link:hover { + color: var(--c-text); +} +.portal-docmodal { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} +.portal-docmodal__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 0; + border-bottom: 1px solid var(--c-border); +} +.portal-docmodal__row:last-child { + border-bottom: none; +} +.portal-docmodal__text { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} +.portal-docmodal__name { + font-weight: 650; + font-size: 0.875rem; + color: var(--c-text); +} +.portal-docmodal__sub { + font-size: 0.75rem; + color: var(--c-text-muted); +} +.portal-docmodal__later { + flex-shrink: 0; font-size: 0.75rem; color: var(--c-text-subtle); - cursor: pointer; - text-decoration: underline; + white-space: nowrap; } -.portal-proc__reset button:hover:not(:disabled) { + +/* Title row: the step badge rides beside the heading, not on a line of its own. */ +.portal-sidemodal__title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +/* Quiet hint sat opposite the primary action in a dialog footer. */ +.portal-sidemodal__foot-hint { + font-size: 0.75rem; color: var(--c-text-subtle); } -.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 deleted file mode 100644 index 1f6e2329a9..0000000000 --- a/frontend/editor/src/portal/views/Procurement.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { ProcurementHome } from "@portal/components/procurement/ProcurementHome"; -import "@portal/views/Procurement.css"; - -/** - * /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() { - return ( -
    - -
    - ); -} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index d2853d8f1e..256bdf640a 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -258,12 +258,7 @@ export default defineConfig( // can't represent. Exempt ONLY the raw- ); diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 7d09261226..742334015c 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -81,9 +81,10 @@ const EXPANDED_WIDTH = "16.25rem"; // ~260px const WATCHED_FOLDER_VIEW_ID = "watchedFolder"; const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder"; -// Stable empty props for rows without folders, so the memoized FileItem -// isn't re-rendered by a fresh `?? []` identity on every list render. +// Stable empty props for rows without folders/policies, so the memoized +// FileItem isn't re-rendered by a fresh `?? []` identity on every list render. const NO_FOLDERS: never[] = []; +const NO_POLICIES: never[] = []; /** Only surface the "Adding files…" progress row for drops big enough that the * pre-dispatch scan is user-visible; small adds finish before it would paint. */ @@ -790,7 +791,7 @@ const FileSidebar = forwardRef( onDragStart={handleWatchedFolderDragStart} folders={memberFolders} onFolderClick={openWatchedFolder} - policies={policyFileBadges.get(stub.id as string) ?? []} + policies={policyFileBadges.get(stub.id as string) ?? NO_POLICIES} onDelete={isWatchedFoldersActive ? undefined : handleSidebarDelete} onSaveToCloud={isWatchedFoldersActive ? undefined : handleSaveToCloud} canSaveToCloud={storageEnabled && fileOrigin !== "shared-with-me"} diff --git a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx index a289a245f1..c1a93594c5 100644 --- a/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebarFileItem.tsx @@ -167,7 +167,9 @@ export interface FileItemProps { const MAX_VISIBLE_FOLDER_TAGS = 2; -export function FileItem({ +// Memoized: sidebar rows bail out unless THEIR props change, so one file's +// update (e.g. a new version landing) re-renders one row, not the whole list. +export const FileItem = React.memo(function FileItem({ fileId, name, size, @@ -509,4 +511,4 @@ export function FileItem({ )} ); -} +}); diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.css b/frontend/editor/src/core/components/shared/PolicyBadges.css index 3a919e24c9..8526de44c6 100644 --- a/frontend/editor/src/core/components/shared/PolicyBadges.css +++ b/frontend/editor/src/core/components/shared/PolicyBadges.css @@ -41,22 +41,3 @@ transform: rotate(360deg); } } - -.policy-badge--recent { - animation: policy-badge-pulse 4.5s ease-in-out forwards; -} -@keyframes policy-badge-pulse { - 0%, - 24%, - 48% { - box-shadow: 0 0 0 0 transparent; - } - 12%, - 36% { - box-shadow: 0 0 5px 2px currentColor; - } - 60%, - 100% { - box-shadow: 0 0 0 0 transparent; - } -} diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx index 81969bf615..c3646f774a 100644 --- a/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx +++ b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx @@ -2,10 +2,14 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { PolicyBadges } from "@app/components/shared/PolicyBadges"; import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; +// Real catalog category ids, so each badge renders its own shared glyph +// (policyCategoryIcon) rather than the unknown-category fallback. Accents mirror +// policyAccentVar's mapping — that lives in the proprietary layer, which a core +// story can't import. const mockPolicies: FileItemPolicyRef[] = [ - { id: "policy-1", name: "Redact PII", accentColor: "#e03131", recent: true }, - { id: "policy-2", name: "Sanitize", accentColor: "#2f9e44", recent: false }, - { id: "policy-3", name: "Watermark", accentColor: "#4263eb", recent: false }, + { id: "security", name: "Redact PII", accentColor: "var(--color-purple)" }, + { id: "compliance", name: "Sanitize", accentColor: "var(--color-green)" }, + { id: "ingestion", name: "Watermark", accentColor: "var(--color-blue)" }, ]; const meta = { @@ -22,15 +26,25 @@ export const Default: Story = { }, }; +/** A blocking policy mid-run: spinner, and the file's exit points are gated. */ export const Enforcing: Story = { + args: { + policies: [ + { ...mockPolicies[0], enforcing: true }, + ...mockPolicies.slice(1), + ], + }, +}; + +/** A non-blocking run (classification tagging): same spinner, nothing gated. */ +export const Background: Story = { args: { policies: [ { - id: "policy-1", - name: "Redact PII", - accentColor: "#e03131", - recent: false, - enforcing: true, + id: "classification", + name: "Classification", + accentColor: "var(--color-orange)", + background: true, }, ...mockPolicies.slice(1), ], diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.tsx b/frontend/editor/src/core/components/shared/PolicyBadges.tsx index f8661b9176..1fa57ca70a 100644 --- a/frontend/editor/src/core/components/shared/PolicyBadges.tsx +++ b/frontend/editor/src/core/components/shared/PolicyBadges.tsx @@ -1,6 +1,6 @@ import { Tooltip } from "@mantine/core"; -import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import AutorenewIcon from "@mui/icons-material/Autorenew"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; import { useTranslation } from "react-i18next"; import "@app/components/shared/PolicyBadges.css"; @@ -10,20 +10,20 @@ export interface FileItemPolicyRef { name: string; /** CSS colour for the badge (matches the policy's accent). */ accentColor: string; - /** True only just after the policy was applied — drives the one-off glow, so - * it doesn't replay on every reload of an already-enforced file. */ - recent: boolean; - /** True while the policy run is actively in-flight on this file. */ + /** True while a BLOCKING policy run is in-flight on this file (gates actions). */ enforcing?: boolean; + /** True while a non-blocking run (e.g. classification) is in-flight — shows + * the same spinner but never gates anything. */ + background?: boolean; } const MAX_VISIBLE = 3; /** - * The canonical policy badge row: one accent-tinted shield per policy that has - * run on a file, spinning while a run is in flight, glowing briefly after it - * lands. Every surface that shows per-file policy badges (file sidebar, file - * editor thumbnails, files page) renders this so they stay identical. + * The canonical policy badge row: one accent-tinted category icon per policy + * that has run on a file, spinning while a run is in flight. Every surface that + * shows per-file policy badges (file sidebar, file editor thumbnails, files + * page) renders this so they stay identical. */ export function PolicyBadges({ policies, @@ -40,33 +40,40 @@ export function PolicyBadges({ className={`policy-badges${className ? ` ${className}` : ""}`} data-no-select > - {policies.slice(0, MAX_VISIBLE).map((policy) => ( - - { + const running = policy.enforcing || policy.background; + return ( + - {policy.enforcing ? ( - - ) : ( - - )} - - - ))} + + {running ? ( + + ) : ( + policyCategoryIcon(policy.id, { fontSize: "0.7rem" }) + )} + + + ); + })} ); } diff --git a/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx b/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx index cb9e931e80..9d5d2475b3 100644 --- a/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx +++ b/frontend/editor/src/core/components/shared/PolicyEnforcingOverlay.tsx @@ -4,6 +4,8 @@ export function PolicyEnforcingOverlay(_props: { zIndex?: number; /** CSS colour var for the enforcing policy's accent; tints the icon/spinner. */ accentVar?: string; + /** Category of the enforcing policy — picks its icon in the real overlay. */ + categoryId?: string; }) { return null; } diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx index 5cc1e6cabd..55ca01d307 100644 --- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx +++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx @@ -19,7 +19,8 @@ import { } from "@app/components/filesPage/filesPageReturnRoute"; import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext"; import { - useFileState, + useAllFiles, + useFileSelectors, useFileSelection, useFileActions, } from "@app/contexts/FileContext"; @@ -121,10 +122,10 @@ export default function WorkbenchBar({ const { sharingEnabled } = useSharingEnabled(); const viewerContext = React.useContext(ViewerContext); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const { selectedFiles, selectedFileIds } = useFileSelection(); const { actions: fileActions } = useFileActions(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const { activeFileId, setActiveFileId } = useViewer(); const policyFileBadges = usePolicyFileBadges(); // Block print/export while any file the export would touch is under active diff --git a/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx b/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx index cc33ccbbb4..fe4a6f8e48 100644 --- a/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx +++ b/frontend/editor/src/core/components/tools/convert/ConvertSettings.tsx @@ -11,7 +11,7 @@ import { } from "@app/utils/convertUtils"; import { getConversionEndpoints } from "@app/data/toolsTaxonomy"; import { useFileSelection } from "@app/contexts/FileContext"; -import { useFileState } from "@app/contexts/FileContext"; +import { useFileSelector, useFileSelectors } from "@app/contexts/FileContext"; import { detectFileExtension } from "@app/utils/fileUtils"; import { usePreferences } from "@app/contexts/PreferencesContext"; import { useConversionCloudStatus } from "@app/hooks/useConversionCloudStatus"; @@ -62,8 +62,8 @@ const ConvertSettings = ({ const { t } = useTranslation(); const theme = useMantineTheme(); const { setSelectedFiles } = useFileSelection(); - const { state, selectors } = useFileState(); - const activeFiles = state.files.ids; + const selectors = useFileSelectors(); + const activeFiles = useFileSelector((s) => s.files.ids); const { preferences } = usePreferences(); const allEndpoints = useMemo(() => { diff --git a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx index 8c89029913..7918bac7ca 100644 --- a/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/editor/src/core/components/tools/shared/ReviewToolStep.tsx @@ -11,7 +11,7 @@ import { Tooltip } from "@app/components/shared/Tooltip"; import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; import { saveOperationResults } from "@app/services/operationResultsSaveService"; -import { useFileActions, useFileState } from "@app/contexts/FileContext"; +import { useFileActions, useFileSelectors } from "@app/contexts/FileContext"; import { FileId } from "@app/types/fileContext"; import i18n from "@app/i18n"; @@ -40,7 +40,7 @@ function ReviewStepContent({ const DownloadIcon = icons.download; const stepRef = useRef(null); const { actions: fileActions } = useFileActions(); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const handleUndo = async () => { try { diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index cc4a04598e..be4a4a9971 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -12,7 +12,12 @@ import { ActionIcon } from "@app/ui/ActionIcon"; import CloseIcon from "@mui/icons-material/Close"; import LockIcon from "@mui/icons-material/Lock"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { + useAllFiles, + useFileSelector, + useFileSelectors, + useFileActions, +} from "@app/contexts/FileContext"; import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF"; @@ -259,9 +264,9 @@ const EmbedPdfViewerContent = ({ const redactionTrackerRef = useRef(null); // Get current file from FileContext - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const { actions } = useFileActions(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const activeFilesRef = useRef(activeFiles); activeFilesRef.current = activeFiles; const activeFileIds = activeFiles.map((f) => f.fileId); @@ -392,11 +397,11 @@ const EmbedPdfViewerContent = ({ }, [previewFile, fileWithUrl]); // Check if the current file is encrypted (gate the viewer to prevent PDFium crash) - const isCurrentFileEncrypted = React.useMemo(() => { - if (!currentFile || !isStirlingFile(currentFile)) return false; - const stub = selectors.getStirlingFileStub(currentFile.fileId); - return stub?.processedFile?.isEncrypted === true; - }, [currentFile, selectors]); + const isCurrentFileEncrypted = useFileSelector((s) => + currentFile && isStirlingFile(currentFile) + ? s.files.byId[currentFile.fileId]?.processedFile?.isEncrypted === true + : false, + ); const bookmarkCacheKey = React.useMemo(() => { if (currentFile && isStirlingFile(currentFile)) { diff --git a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx index ed4a2e2263..bd2704963a 100644 --- a/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/NonPdfViewer.tsx @@ -4,7 +4,7 @@ import { Button } from "@app/ui/Button"; import ArticleIcon from "@mui/icons-material/Article"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { @@ -126,8 +126,7 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) { // ─── Wrapper that resolves the active file from FileContext ─────────────────── export function NonPdfViewerWrapper(props: ViewerProps) { - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const { activeFileIndex } = useViewer(); const file = diff --git a/frontend/editor/src/core/components/viewer/Viewer.tsx b/frontend/editor/src/core/components/viewer/Viewer.tsx index 08103ca1f5..36a1080dbb 100644 --- a/frontend/editor/src/core/components/viewer/Viewer.tsx +++ b/frontend/editor/src/core/components/viewer/Viewer.tsx @@ -5,7 +5,7 @@ import { NonPdfViewerWrapper, type ViewerProps, } from "@app/components/viewer/NonPdfViewer"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { isStirlingFile } from "@app/types/fileContext"; import { isPdfFile } from "@app/utils/fileUtils"; @@ -26,8 +26,7 @@ type SignatureOverlayPassThrough = Pick< >; const Viewer = (props: ViewerProps & SignatureOverlayPassThrough) => { - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const { activeFileId } = useViewer(); // Determine the active file — previewFile takes priority, then look up by stable ID diff --git a/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx b/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx index 32b4b1ce11..fda6c2d2a5 100644 --- a/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx +++ b/frontend/editor/src/core/components/viewer/ViewerAnnotationControls.tsx @@ -5,7 +5,11 @@ 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"; -import { useFileState, useFileContext } from "@app/contexts/FileContext"; +import { + useAllFiles, + useFileSelectors, + useFileContext, +} from "@app/contexts/FileContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { useNavigationState, @@ -39,9 +43,9 @@ export default function ViewerAnnotationControls({ const { historyApiRef, isPlacementMode } = useSignature(); // File state for save functionality - const { state, selectors } = useFileState(); + const selectors = useFileSelectors(); + const { files: activeFiles, fileIds } = useAllFiles(); const { actions: fileActions } = useFileContext(); - const activeFiles = selectors.getFiles(); // Check if we're in sign mode or redaction mode const { selectedTool } = useNavigationState(); @@ -83,7 +87,7 @@ export default function ViewerAnnotationControls({ !historyApiRef?.current?.canUndo() ) return; - if (activeFiles.length === 0 || state.files.ids.length === 0) return; + if (activeFiles.length === 0 || fileIds.length === 0) return; try { const arrayBuffer = await viewerContext.exportActions.saveAsCopy(); @@ -92,7 +96,7 @@ export default function ViewerAnnotationControls({ const file = new File([new Blob([arrayBuffer])], activeFiles[0].name, { type: "application/pdf", }); - const parentStub = selectors.getStirlingFileStub(state.files.ids[0]); + const parentStub = selectors.getStirlingFileStub(fileIds[0]); if (!parentStub) return; const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( @@ -100,11 +104,7 @@ export default function ViewerAnnotationControls({ parentStub, "redact", ); - await fileActions.consumeFiles( - [state.files.ids[0]], - stirlingFiles, - stubs, - ); + await fileActions.consumeFiles([fileIds[0]], stirlingFiles, stubs); // Clear unsaved changes flags after successful save setHasUnsavedChanges(false); diff --git a/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx b/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx index f063cbffe2..4ef4b30cda 100644 --- a/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx +++ b/frontend/editor/src/core/components/viewer/ViewerShareButton.tsx @@ -9,7 +9,7 @@ import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import { Tooltip } from "@app/components/shared/Tooltip"; import ShareManagementModal from "@app/components/shared/ShareManagementModal"; import { useViewer } from "@app/contexts/ViewerContext"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { useAllFiles, useFileActions } from "@app/contexts/FileContext"; import { uploadHistoryChain } from "@app/services/serverStorageUpload"; import { fileStorage } from "@app/services/fileStorage"; import { alert } from "@app/components/toast"; @@ -39,7 +39,7 @@ export default function ViewerShareButton({ }: ViewerShareButtonProps) { const { t } = useTranslation(); const { activeFileId } = useViewer(); - const { selectors } = useFileState(); + const { fileStubs } = useAllFiles(); const { actions } = useFileActions(); const [confirmOpen, setConfirmOpen] = useState(false); const [saving, setSaving] = useState(false); @@ -49,7 +49,7 @@ export default function ViewerShareButton({ // Resolve strictly to the file shown in the viewer. Never fall back to an // arbitrary file — sharing the wrong document would be worse than not // sharing. If there's no active file, the button is disabled (see isDisabled). - const stubs = selectors.getStirlingFileStubs(); + const stubs = fileStubs; const stub = activeFileId ? stubs.find((s) => s.id === activeFileId) : undefined; diff --git a/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx b/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx index 2501ddfb79..0cb11837cc 100644 --- a/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx +++ b/frontend/editor/src/core/components/viewer/ZoomAPIBridge.tsx @@ -3,7 +3,7 @@ import { useZoom, ZoomMode } from "@embedpdf/plugin-zoom/react"; import { useSpread, SpreadMode } from "@embedpdf/plugin-spread/react"; import { useViewer } from "@app/contexts/ViewerContext"; import { useActiveDocumentId } from "@app/components/viewer/useActiveDocumentId"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { determineAutoZoom, DEFAULT_FALLBACK_ZOOM, @@ -36,7 +36,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { const { provides: zoom, state: zoomState } = useZoom(documentId); const { spreadMode } = useSpread(documentId); const { registerBridge, triggerImmediateZoomUpdate } = useViewer(); - const { selectors } = useFileState(); + const { fileStubs } = useAllFiles(); const hasSetInitialZoom = useRef(false); const lastSpreadMode = useRef(spreadMode ?? SpreadMode.None); @@ -62,7 +62,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) { } }, []); - const stubs = selectors.getStirlingFileStubs(); + const stubs = fileStubs; const firstFileStub = stubs[0]; const firstFileId = firstFileStub?.id; diff --git a/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts b/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts index c2ccdec1be..5352ffc748 100644 --- a/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts +++ b/frontend/editor/src/core/components/viewer/useViewerReadAloud.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { computeReadAloudHighlightRect } from "@app/components/viewer/readAloudHighlight"; -import { useFileState } from "@app/contexts/FileContext"; +import { useFileSelectors } from "@app/contexts/FileContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { useStopReadAloudOnNavigation } from "@app/components/viewer/useStopReadAloudOnNavigation"; import { pdfWorkerManager } from "@app/services/pdfWorkerManager"; @@ -60,7 +60,7 @@ function createHighlightElement( export function useViewerReadAloud(defaultLanguage?: string) { const viewer = useViewer(); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const [isReadingAloud, setIsReadingAloud] = useState(false); const [speechRate, setSpeechRate] = useState(1); diff --git a/frontend/editor/src/core/contexts/FileContext.tsx b/frontend/editor/src/core/contexts/FileContext.tsx index d0808665cd..a982c347ca 100644 --- a/frontend/editor/src/core/contexts/FileContext.tsx +++ b/frontend/editor/src/core/contexts/FileContext.tsx @@ -16,6 +16,7 @@ import { useReducer, useCallback, useEffect, + useLayoutEffect, useRef, useMemo, useState, @@ -23,7 +24,6 @@ import { import { FileContextProviderProps, FileContextSelectors, - FileContextStateValue, FileContextActionsValue, FileContextActions, FileId, @@ -36,6 +36,7 @@ import { import { fileContextReducer, initialFileContextState, + withReducerIdentityGuard, } from "@app/contexts/file/FileReducer"; import { createFileSelectors } from "@app/contexts/file/fileSelectors"; import { @@ -49,8 +50,9 @@ import { } from "@app/contexts/file/fileActions"; import { FileLifecycleManager } from "@app/contexts/file/lifecycle"; import { - FileStateContext, + FileStoreContext, FileActionsContext, + type FileStateStore, } from "@app/contexts/file/contexts"; import { IndexedDBProvider, @@ -75,10 +77,13 @@ function FileContextInner({ children, enablePersistence = true, }: FileContextProviderProps) { - const [state, dispatch] = useReducer( - fileContextReducer, - initialFileContextState, + // Guarded in dev: warns if a reducer case reallocates a slice without changing + // it, which would silently defeat the selector-subscription bail-out. + const guardedReducer = useMemo( + () => withReducerIdentityGuard(fileContextReducer), + [], ); + const [state, dispatch] = useReducer(guardedReducer, initialFileContextState); // Always call the hook unconditionally to satisfy React's rules of hooks. // IndexedDB context is only used when enablePersistence is true. @@ -657,14 +662,28 @@ function FileContextInner({ ], ); - // Split context values to minimize re-renders - const stateValue = useMemo( + // Subscription store bridge: the context value is STABLE, so consumers only + // re-render when the slice they select (via useFileSelector) changes — not on + // every state change. Listeners are notified after each committed state. + const listenersRef = useRef void>>(new Set()); + const store = useMemo( () => ({ - state, + getState: () => stateRef.current, + subscribe: (listener) => { + listenersRef.current.add(listener); + return () => { + listenersRef.current.delete(listener); + }; + }, selectors, }), - [state, selectors], + [selectors], ); + // Layout effect (not passive): subscribers re-render before the browser + // paints, so a state change can never show a frame with stale consumers. + useLayoutEffect(() => { + for (const listener of listenersRef.current) listener(); + }, [state]); const actionsValue = useMemo( () => ({ @@ -698,7 +717,7 @@ function FileContextInner({ }, [lifecycleManager]); return ( - + {children} - + ); } @@ -758,6 +777,10 @@ export function FileContextProvider({ export { useFileState, useFileActions, + useFileSelector, + useFileSelectors, + useFileIndex, + shallowEqual, useCurrentFile, useFileSelection, useFileManagement, diff --git a/frontend/editor/src/core/contexts/ViewerContext.tsx b/frontend/editor/src/core/contexts/ViewerContext.tsx index 22d95606c1..5e5d08dbb4 100644 --- a/frontend/editor/src/core/contexts/ViewerContext.tsx +++ b/frontend/editor/src/core/contexts/ViewerContext.tsx @@ -9,7 +9,11 @@ import React, { useCallback, } from "react"; import { useNavigation } from "@app/contexts/NavigationContext"; -import { useFileState } from "@app/contexts/FileContext"; +import { + useFileIndex, + useFileSelector, + useFileSelectors, +} from "@app/contexts/FileContext"; import { isStirlingFile } from "@app/types/fileContext"; import type { FileId } from "@app/types/file"; import { enforceExportPolicies } from "@app/services/policyExport"; @@ -244,25 +248,21 @@ export const ViewerProvider: React.FC = ({ children }) => { const [activeFileId, setActiveFileId] = useState(null); // activeFileIndex is derived from activeFileId so they can never desync. - // ViewerProvider sits inside FileContextProvider so useFileState is valid here. - const { selectors, state } = useFileState(); + // ViewerProvider sits inside FileContextProvider so these hooks are valid here. + const selectors = useFileSelectors(); + const fileIds = useFileSelector((s) => s.files.ids); // Clear activeFileId when its file is removed from the workbench. // Dep on state.files.ids so the effect re-runs on every add/remove. useEffect(() => { if (!activeFileId) return; - const stillInWorkbench = state.files.ids.some( + const stillInWorkbench = fileIds.some( (id) => (id as string) === activeFileId, ); if (!stillInWorkbench) setActiveFileId(null); - }, [activeFileId, state.files.ids]); + }, [activeFileId, fileIds]); - const activeFileIndex = useMemo(() => { - if (!activeFileId) return 0; - const files = selectors.getFiles(); - const idx = files.findIndex((f) => f.fileId === activeFileId); - return idx >= 0 ? idx : 0; - }, [activeFileId, selectors]); + const activeFileIndex = useFileIndex(activeFileId); const setActiveFileIndex = useCallback( (index: number) => { const files = selectors.getFiles(); diff --git a/frontend/editor/src/core/contexts/file/FileReducer.ts b/frontend/editor/src/core/contexts/file/FileReducer.ts index 91bd62cf4d..2697974823 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.ts @@ -425,3 +425,83 @@ export function fileContextReducer( return state; } } + +// ── Dev-only structural-sharing guard ────────────────────────────────────── +// +// The file hooks bail a consumer out of re-rendering when the slice it selects +// keeps its object identity across a dispatch. That optimisation silently +// breaks if a reducer case returns a NEW identity for a slice it didn't +// actually change (e.g. an unnecessary `{ ...state.files }`): every consumer of +// that slice re-renders for nothing, with no test failure. This wrapper warns +// when that happens. No-op in production. + +function idsUnchanged(a: FileId[], b: FileId[]): boolean { + return a.length === b.length && a.every((id, i) => id === b[i]); +} + +function byIdUnchanged( + a: Record, + b: Record, +): boolean { + const keysA = Object.keys(a); + return ( + keysA.length === Object.keys(b).length && + keysA.every((id) => a[id as FileId] === b[id as FileId]) + ); +} + +function uiUnchanged( + a: FileContextState["ui"], + b: FileContextState["ui"], +): boolean { + return (Object.keys(a) as Array).every( + (k) => a[k] === b[k], + ); +} + +function setUnchanged(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} + +/** + * Wrap a reducer so, outside production, it warns when an action reallocates a + * top-level state slice without changing its contents — which would defeat the + * selector-subscription bail-out in the file hooks. + */ +export function withReducerIdentityGuard( + reducer: (s: FileContextState, a: FileContextAction) => FileContextState, +): (s: FileContextState, a: FileContextAction) => FileContextState { + if (process.env.NODE_ENV === "production") return reducer; + return (state, action) => { + const next = reducer(state, action); + if (next === state) return next; + if ( + next.files !== state.files && + idsUnchanged(next.files.ids, state.files.ids) && + byIdUnchanged(next.files.byId, state.files.byId) + ) { + console.error( + `[FileReducer] '${action.type}' reallocated state.files without changing it — ` + + "this re-renders every file consumer for nothing. Return the existing slice unchanged.", + ); + } + if (next.ui !== state.ui && uiUnchanged(next.ui, state.ui)) { + console.error( + `[FileReducer] '${action.type}' reallocated state.ui without changing it — ` + + "this re-renders every UI consumer for nothing. Return the existing slice unchanged.", + ); + } + if ( + next.pinnedFiles !== state.pinnedFiles && + setUnchanged(next.pinnedFiles, state.pinnedFiles) + ) { + console.error( + `[FileReducer] '${action.type}' reallocated state.pinnedFiles without changing it — ` + + "this re-renders every pinned-files consumer for nothing.", + ); + } + return next; + }; +} diff --git a/frontend/editor/src/core/contexts/file/classificationToolRace.test.ts b/frontend/editor/src/core/contexts/file/classificationToolRace.test.ts new file mode 100644 index 0000000000..faf6501a0c --- /dev/null +++ b/frontend/editor/src/core/contexts/file/classificationToolRace.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { fileContextReducer } from "@app/contexts/file/FileReducer"; +import type { + FileContextAction, + FileContextState, + StirlingFileStub, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +/** + * Classification is non-blocking: while it runs, the user can manually run a + * tool on the same file. Classification's only write is a metadata-only, + * shallow-merged UPDATE_FILE_RECORD stamping `classificationLabels`; a manual + * tool run produces a NEW document via CONSUME_FILES (new id + version). These + * tests drive the REAL reducer through every interleaving (classification lands + * before / during / after the tool run) and prove the invariant the design + * relies on: the tool's output document is byte-for-byte what the tool produced, + * regardless of when classification lands. (Label PLACEMENT in the mid-run race + * is the orchestration's job — usePolicyAutoRun resolves targets at write time; + * see usePolicyAutoRun.race.test.tsx. Here we lock the reducer backstop.) + */ + +const stub = ( + id: string, + extra: Partial = {}, +): StirlingFileStub => + ({ + id: id as FileId, + name: "doc.pdf", + versionNumber: 1, + ...extra, + }) as StirlingFileStub; + +function stateWith(...stubs: StirlingFileStub[]): FileContextState { + return { + files: { + ids: stubs.map((s) => s.id), + byId: Object.fromEntries(stubs.map((s) => [s.id, s])) as Record< + FileId, + StirlingFileStub + >, + }, + pinnedFiles: new Set(), + ui: { + selectedFileIds: [], + selectedPageNumbers: [], + isProcessing: false, + processingProgress: 0, + hasUnsavedChanges: false, + errorFileIds: [], + }, + }; +} + +const LABELS = ["Invoice"]; + +// A manual tool run on `inputId` producing a new versioned document `outputId`. +// Mirrors what useToolOperation dispatches: the reducer stamps provenance +// (derivedFromTool, sourceFileIds) and inherits labels itself. +const toolRun = (inputId: string, outputId: string): FileContextAction => ({ + type: "CONSUME_FILES", + payload: { + inputFileIds: [inputId as FileId], + outputStirlingFileStubs: [stub(outputId, { versionNumber: 2 })], + silent: false, + }, +}); + +// Classification stamping labels onto a target id (the reducer merges shallowly). +const classify = (targetId: string): FileContextAction => ({ + type: "UPDATE_FILE_RECORD", + payload: { + id: targetId as FileId, + updates: { classificationLabels: LABELS }, + }, +}); + +describe("classification landing vs a manually-run tool", () => { + it("PRE: classification lands first — tool output is correct AND inherits the label", () => { + let s = stateWith(stub("orig")); + s = fileContextReducer(s, classify("orig")); + s = fileContextReducer(s, toolRun("orig", "out")); + + const out = s.files.byId["out" as FileId]; + expect(out).toBeDefined(); + expect(out.versionNumber).toBe(2); // the document the tool produced + expect(s.files.byId["orig" as FileId]).toBeUndefined(); // input consumed + // Label carried forward onto the tool's new version. + expect(out.classificationLabels).toEqual(LABELS); + }); + + it("POST: classification lands after the tool run, targeting the new leaf — output untouched, label applied, nothing else clobbered", () => { + let s = stateWith(stub("orig")); + s = fileContextReducer(s, toolRun("orig", "out")); + + const before = s.files.byId["out" as FileId]; + // classificationLabelTargets resolves the run's descendants: "out" matches + // because its sourceFileIds includes "orig". + expect(before.sourceFileIds).toContain("orig" as FileId); + + s = fileContextReducer(s, classify("out")); + const after = s.files.byId["out" as FileId]; + + // The label write is a shallow merge: ONLY classificationLabels changes. + expect(after.classificationLabels).toEqual(LABELS); + expect({ ...after, classificationLabels: undefined }).toEqual({ + ...before, + classificationLabels: undefined, + }); + expect(after.versionNumber).toBe(2); + }); + + it("MID (the race): a label write aimed at an already-consumed id no-ops — output document is CORRECT, nothing is resurrected", () => { + // In production this stale-id write no longer happens: usePolicyAutoRun + // resolves the label targets AT WRITE TIME, so the labels land on the live + // leaf instead (see usePolicyAutoRun.race.test.tsx). This test locks the + // reducer-level BACKSTOP behind that: even if a stale id does get written, + // it cannot corrupt or resurrect anything. + let s = stateWith(stub("orig")); + + const staleTargetId = "orig"; + + // During that window the user runs a tool: orig -> out. orig had no labels + // yet, so the new leaf inherits none. + s = fileContextReducer(s, toolRun("orig", "out")); + const out = s.files.byId["out" as FileId]; + expect(out.versionNumber).toBe(2); + expect(out.classificationLabels).toBeUndefined(); + + // Classification's write finally lands — on the now-consumed snapshot id. + const beforeWrite = s; + s = fileContextReducer(s, classify(staleTargetId)); + + // No-op on a missing record: reducer returns the SAME state reference, so no + // zombie "orig" record is resurrected and nothing is corrupted. + expect(s).toBe(beforeWrite); + expect(s.files.byId["orig" as FileId]).toBeUndefined(); + + // The tool's output document is intact and exactly what the tool produced. + const finalOut = s.files.byId["out" as FileId]; + expect(finalOut.versionNumber).toBe(2); + expect(finalOut.sourceFileIds).toContain("orig" as FileId); + // At the reducer level the stale write leaves the leaf unlabelled — which + // is why the orchestration resolves targets at write time instead. The + // DOCUMENT is unaffected either way. + expect(finalOut.classificationLabels).toBeUndefined(); + }); + + it("classification can never overwrite a tool output's document fields (only the label)", () => { + // Tool output already carries its own state; classification must not disturb it. + let s = stateWith( + stub("out", { + versionNumber: 7, + thumbnailUrl: "blob:thumb", + isPinned: true, + } as Partial), + ); + + s = fileContextReducer(s, classify("out")); + const after = s.files.byId["out" as FileId]; + + expect(after.versionNumber).toBe(7); + expect(after.thumbnailUrl).toBe("blob:thumb"); + expect((after as { isPinned?: boolean }).isPinned).toBe(true); + expect(after.classificationLabels).toEqual(LABELS); + }); +}); diff --git a/frontend/editor/src/core/contexts/file/contexts.ts b/frontend/editor/src/core/contexts/file/contexts.ts index c17a178043..6bc74e11ae 100644 --- a/frontend/editor/src/core/contexts/file/contexts.ts +++ b/frontend/editor/src/core/contexts/file/contexts.ts @@ -4,14 +4,28 @@ import { createContext } from "react"; import { + FileContextState, + FileContextSelectors, FileContextStateValue, FileContextActionsValue, } from "@app/types/fileContext"; -// Split contexts for performance -export const FileStateContext = createContext< - FileContextStateValue | undefined ->(undefined); +/** + * Subscription store for file state. The context VALUE is stable — consumers + * subscribe and select slices (see useFileSelector), re-rendering only when + * their selected slice changes, instead of on every state change. + */ +export interface FileStateStore { + getState: () => FileContextState; + subscribe: (listener: () => void) => () => void; + /** Stable selector API (reads live state via refs). */ + selectors: FileContextSelectors; +} + +export const FileStoreContext = createContext( + undefined, +); + export const FileActionsContext = createContext< FileContextActionsValue | undefined >(undefined); diff --git a/frontend/editor/src/core/contexts/file/fileActions.ts b/frontend/editor/src/core/contexts/file/fileActions.ts index 77447003f6..db99e1c673 100644 --- a/frontend/editor/src/core/contexts/file/fileActions.ts +++ b/frontend/editor/src/core/contexts/file/fileActions.ts @@ -370,32 +370,57 @@ export async function addFiles( // Collect hydrations to schedule after dispatch so updateStirlingFileStub finds files in state. const pendingHydrations: Array<() => Promise> = []; + // Per-chunk persistence promises (kicked off as chunks flush, awaited before + // return). See flushChunk — we stream writes instead of one batch at the end. + const persistPromises: Array> = []; - // Stream the batch into the workspace in chunks. The per-file pre-scan below - // (dedupe, encryption sniff — which reads each PDF's bytes) takes real time - // for a big folder drop; a single end-of-loop dispatch would leave the UI - // frozen-looking for seconds and then dump hundreds of rows in one render. - // Chunked dispatch keeps rows (and their thumbnail hydrations) streaming in, - // and the progress store drives the sidebar's "Adding files…" indicator. - const DISPATCH_CHUNK = 25; + // Dispatch stubs in chunks so rows (and thumbnail hydrations) stream in + // rather than dumping the whole drop in one render. + const DISPATCH_CHUNK = 5; let flushedStubs = 0; let flushedHydrations = 0; - const flushChunk = () => { - if ( - !options.skipWorkspaceDispatch && - stirlingFileStubs.length > flushedStubs - ) { - dispatch({ - type: "ADD_FILES", - payload: { stirlingFileStubs: stirlingFileStubs.slice(flushedStubs) }, - }); + // Flushes the pending chunk and returns this chunk's persistence promises, + // so the caller can await the writes (see the loop's yield) before the policy + // auto-run tries to read the file back from storage. + const flushChunk = (): Array> => { + const chunkWrites: Array> = []; + if (stirlingFileStubs.length > flushedStubs) { + const from = flushedStubs; + const newStubs = stirlingFileStubs.slice(from); flushedStubs = stirlingFileStubs.length; + if (!options.skipWorkspaceDispatch) { + dispatch({ + type: "ADD_FILES", + payload: { stirlingFileStubs: newStubs }, + }); + } + // Persist each chunk as it flushes, not one batch at the end: the policy + // auto-run reads files from IndexedDB with no in-memory fallback. + if (enablePersistence) { + const newFiles = stirlingFiles.slice(from); + for (let i = 0; i < newFiles.length; i++) { + const sf = newFiles[i]; + const stub = newStubs[i]; + const write = fileStorage + .storeStirlingFile(sf, stub) + .catch((error) => { + console.error( + "Failed to persist file to storage:", + sf.name, + error, + ); + }); + chunkWrites.push(write); + persistPromises.push(write); + } + } } // Hydrations only after their chunk is dispatched, so // updateStirlingFileStub finds the files in state. while (flushedHydrations < pendingHydrations.length) { scheduleMetadataHydration(pendingHydrations[flushedHydrations++]); } + return chunkWrites; }; reportBulkAddProgress(0, filesToProcess.length); @@ -554,38 +579,25 @@ export async function addFiles( reportBulkAddProgress(++scannedCount, filesToProcess.length); if (stirlingFileStubs.length - flushedStubs >= DISPATCH_CHUNK) { - flushChunk(); + const chunkWrites = flushChunk(); + // Yield a MACROTASK so React commits this chunk and runs its effects + // (incl. the policy-enforcement dispatch) before the next chunk scans. + // The per-file awaits above are only microtasks, which don't give React + // a turn — without this, all dispatches batch and processing can't begin + // until the whole drop is scanned. Awaiting the chunk's writes first means + // the auto-run finds each file's bytes already committed in storage. + await Promise.all(chunkWrites); + await new Promise((resolve) => setTimeout(resolve)); } } // Flush the remainder (also the sole dispatch for small batches). flushChunk(); - // Persist to storage if enabled using fileStorage service - if (enablePersistence && stirlingFiles.length > 0) { - await Promise.all( - stirlingFiles.map(async (stirlingFile, index) => { - try { - // Get corresponding stub with all metadata - const fileStub = stirlingFileStubs[index]; - - // Store using the cleaner signature - pass StirlingFile + StirlingFileStub directly - await fileStorage.storeStirlingFile(stirlingFile, fileStub); - - if (DEBUG) - console.log( - `📄 addFiles: Stored file ${stirlingFile.name} with metadata:`, - fileStub, - ); - } catch (error) { - console.error( - "Failed to persist file to storage:", - stirlingFile.name, - error, - ); - } - }), - ); + // Wait for the per-chunk writes (streamed in flushChunk) to commit, so + // addFiles only resolves once every file is durably stored. + if (enablePersistence && persistPromises.length > 0) { + await Promise.all(persistPromises); } if (!options.skipUploadTracking && stirlingFiles.length > 0) { diff --git a/frontend/editor/src/core/contexts/file/fileHooks.selector.test.tsx b/frontend/editor/src/core/contexts/file/fileHooks.selector.test.tsx new file mode 100644 index 0000000000..5a8f3243f9 --- /dev/null +++ b/frontend/editor/src/core/contexts/file/fileHooks.selector.test.tsx @@ -0,0 +1,223 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, act } from "@testing-library/react"; +import { useEffect } from "react"; +import { MantineProvider } from "@mantine/core"; +import { FileContextProvider } from "@app/contexts/FileContext"; +import { + useAllFiles, + useFileContext, + useFileSelection, + useFileSelectors, + useStirlingFileStub, + useFileActions, +} from "@app/contexts/file/fileHooks"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; +import type { FileContextAction } from "@app/types/fileContext"; + +/** + * Proves the selector-subscription contract: a consumer re-renders only when + * the slice it selects changes — a single file's update doesn't re-render + * other files' consumers, and selection changes don't re-render list consumers. + */ + +const stub = (id: string): StirlingFileStub => + ({ + id: id as FileId, + name: `${id}.pdf`, + type: "application/pdf", + size: 1, + lastModified: 0, + }) as StirlingFileStub; + +const renders: Record = {}; +let dispatchRef: React.Dispatch | null = null; + +function Controller() { + const { dispatch } = useFileActions(); + dispatchRef = dispatch; + return null; +} + +function StubWatcher({ fileId }: { fileId: string }) { + useStirlingFileStub(fileId as FileId); + renders[`stub-${fileId}`] = (renders[`stub-${fileId}`] ?? 0) + 1; + return null; +} + +function ListWatcher() { + useAllFiles(); + renders.list = (renders.list ?? 0) + 1; + return null; +} + +function SelectionWatcher() { + useFileSelection(); + renders.selection = (renders.selection ?? 0) + 1; + return null; +} + +function setup() { + for (const key of Object.keys(renders)) delete renders[key]; + dispatchRef = null; + render( + + + + + + + + + , + ); + act(() => { + dispatchRef!({ + type: "ADD_FILES", + payload: { stirlingFileStubs: [stub("a"), stub("b")] }, + }); + }); + return { ...renders }; +} + +describe("file hooks — selector subscriptions", () => { + it("updating one file re-renders that file's consumer, not the other's", () => { + const before = setup(); + act(() => { + dispatchRef!({ + type: "UPDATE_FILE_RECORD", + payload: { id: "b" as FileId, updates: { name: "renamed.pdf" } }, + }); + }); + expect(renders["stub-b"]).toBeGreaterThan(before["stub-b"]); + expect(renders["stub-a"]).toBe(before["stub-a"]); + }); + + it("selection changes don't re-render file-list or per-file consumers", () => { + const before = setup(); + act(() => { + dispatchRef!({ + type: "SET_SELECTED_FILES", + payload: { fileIds: ["a" as FileId] }, + }); + }); + expect(renders.selection).toBeGreaterThan(before.selection); + expect(renders.list).toBe(before.list); + expect(renders["stub-a"]).toBe(before["stub-a"]); + expect(renders["stub-b"]).toBe(before["stub-b"]); + }); + + it("file-list changes don't re-render selection-only consumers", () => { + const before = setup(); + act(() => { + dispatchRef!({ + type: "UPDATE_FILE_RECORD", + payload: { id: "a" as FileId, updates: { name: "x.pdf" } }, + }); + }); + expect(renders.selection).toBe(before.selection); + }); +}); + +describe("useFileSelectors — render-phase misuse guard", () => { + const guardErrors = (spy: ReturnType) => + spy.mock.calls.filter((args) => + String(args[0]).includes("[useFileSelectors]"), + ); + + function RenderTimeMisuse() { + const selectors = useFileSelectors(); + selectors.getAllFileIds(); // during render — must be flagged + return null; + } + + function EffectTimeUse() { + const selectors = useFileSelectors(); + useEffect(() => { + selectors.getAllFileIds(); // after commit — legitimate + }, [selectors]); + return null; + } + + it("flags a selector invoked during render", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + + + , + ); + expect(guardErrors(spy).length).toBeGreaterThan(0); + spy.mockRestore(); + }); + + it("does not flag selector reads from effects", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + + + + , + ); + expect(guardErrors(spy)).toHaveLength(0); + spy.mockRestore(); + }); +}); + +describe("useFileContext — render-phase misuse guard", () => { + // useFileContext subscribes to files + pinnedFiles only, so a render-time read + // of the SELECTION slice through its exposed selectors would silently go + // stale. The guard covers exactly those selectors and nothing else. + const guardErrors = (spy: ReturnType) => + spy.mock.calls.filter((args) => + String(args[0]).includes("[useFileContext]"), + ); + + const renderWithGuard = (node: React.ReactNode) => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + render( + + {node} + , + ); + const errors = guardErrors(spy); + spy.mockRestore(); + return errors; + }; + + function SelectionReadDuringRender() { + const { selectors } = useFileContext(); + selectors.getSelectedFiles(); // unsubscribed slice — must be flagged + return null; + } + + function FilesReadDuringRender() { + const { selectors } = useFileContext(); + selectors.getStirlingFileStubs(); // files slice IS subscribed — legitimate + return null; + } + + function SelectionReadFromEffect() { + const { selectors } = useFileContext(); + useEffect(() => { + selectors.getSelectedFiles(); // after commit — legitimate + }, [selectors]); + return null; + } + + it("flags a selection read during render", () => { + expect( + renderWithGuard().length, + ).toBeGreaterThan(0); + }); + + it("does not flag reads of a slice it subscribes to", () => { + expect(renderWithGuard()).toHaveLength(0); + }); + + it("does not flag selection reads from effects", () => { + expect(renderWithGuard()).toHaveLength(0); + }); +}); diff --git a/frontend/editor/src/core/contexts/file/fileHooks.ts b/frontend/editor/src/core/contexts/file/fileHooks.ts index fcd3ba7ef7..f5c1bfc460 100644 --- a/frontend/editor/src/core/contexts/file/fileHooks.ts +++ b/frontend/editor/src/core/contexts/file/fileHooks.ts @@ -1,27 +1,187 @@ /** - * Performant file hooks - Clean API using FileContext + * Performant file hooks — selector subscriptions over the FileStateStore. + * Each hook re-renders its consumer only when the slice it selects changes, + * not on every file-state change. */ -import { useContext, useMemo } from "react"; +import { useContext, useLayoutEffect, useMemo, useRef } from "react"; +import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector.js"; import { - FileStateContext, + FileStoreContext, FileActionsContext, + FileStateStore, FileContextStateValue, FileContextActionsValue, } from "@app/contexts/file/contexts"; -import { StirlingFileStub, StirlingFile } from "@app/types/fileContext"; +import { + StirlingFileStub, + StirlingFile, + FileContextState, + FileContextSelectors, +} from "@app/types/fileContext"; import { FileId } from "@app/types/file"; +const GUARD_MISUSE = process.env.NODE_ENV !== "production"; + +/** Shallow equality over object/array slices assembled by selectors. */ +export function shallowEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if ( + typeof a !== "object" || + a === null || + typeof b !== "object" || + b === null + ) { + return false; + } + const keysA = Object.keys(a); + if (keysA.length !== Object.keys(b).length) return false; + return keysA.every((key) => + Object.is( + (a as Record)[key], + (b as Record)[key], + ), + ); +} + +function useFileStore(): FileStateStore { + const store = useContext(FileStoreContext); + if (!store) { + throw new Error("File hooks must be used within a FileContextProvider"); + } + return store; +} + +/** + * Subscribe to a slice of file state. The component re-renders only when the + * selected value changes (Object.is by default; pass shallowEqual for slices + * assembled into fresh objects/arrays). + */ +export function useFileSelector( + selector: (state: FileContextState) => T, + isEqual?: (a: T, b: T) => boolean, +): T { + const store = useFileStore(); + return useSyncExternalStoreWithSelector( + store.subscribe, + store.getState, + store.getState, + selector, + isEqual, + ); +} + +/** Selectors that read `ui.selectedFileIds`. A hook that doesn't subscribe to + * that slice must not let consumers call these during render. */ +const SELECTION_SELECTORS: ReadonlyArray = [ + "getSelectedFiles", + "getSelectedStirlingFileStubs", +]; + +/** Wrap selectors so a call made during render logs loudly (dev/test only). + * Render-time vs event-time isn't statically lintable, so this is the guard. + * `keys` limits the wrap to the selectors whose slice the calling hook does NOT + * subscribe to — the rest are safe to read during render and pass through. */ +function guardSelectors( + selectors: FileContextSelectors, + isRendering: () => boolean, + hookName: string, + keys?: ReadonlyArray, +): FileContextSelectors { + const guardedKeys = + keys ?? (Object.keys(selectors) as Array); + const guarded: Record = { ...selectors }; + for (const key of guardedKeys) { + const original = selectors[key] as unknown as ( + ...args: unknown[] + ) => unknown; + guarded[key] = (...args: unknown[]) => { + if (isRendering()) { + console.error( + `[${hookName}] ${key}() was called during render. This read doesn't ` + + "subscribe to the state it depends on, so the UI can go stale — use " + + "useFileSelector / useFileSelection / useAllFiles for render-time data.", + ); + } + return original(...args); + }; + } + return guarded as unknown as FileContextSelectors; +} + +/** + * Wrap a hook's exposed selectors in the render-phase misuse guard (no-op in + * production). `keys` names the selectors the calling hook doesn't subscribe to; + * omit it to guard every selector (for hooks that subscribe to nothing). + */ +function useGuardedSelectors( + selectors: FileContextSelectors, + hookName: string, + keys?: ReadonlyArray, +): FileContextSelectors { + // True exactly while this consumer is rendering: set on every render, cleared + // by the layout effect once that render commits. + const renderPhase = useRef(false); + renderPhase.current = GUARD_MISUSE; + useLayoutEffect(() => { + renderPhase.current = false; + }); + return useMemo( + () => + GUARD_MISUSE + ? guardSelectors(selectors, () => renderPhase.current, hookName, keys) + : selectors, + [selectors, hookName, keys], + ); +} + +/** + * Stable selector API with NO state subscription — never re-renders. For + * event-time reads (callbacks/effects), which see live state when invoked. + * Render-time reads need a reactive hook (useAllFiles/useFileSelector) or + * they go stale — calling one during render logs an error outside production. + */ +export function useFileSelectors(): FileContextSelectors { + const { selectors } = useFileStore(); + return useGuardedSelectors(selectors, "useFileSelectors"); +} + +/** + * Position of `fileId` in the resolved file list — the SAME array useAllFiles() + * returns, which drops ids whose bytes haven't hydrated into memory yet, so the + * index lines up with what consumers actually index into. 0 when unset/absent. + * + * Selects a NUMBER, so the consumer re-renders only when the index actually + * moves. useAllFiles() would do the job too, but it re-renders on every + * unrelated stub update (thumbnail hydration, labels, …) — too costly for a + * high-level provider whose context value isn't memoized. + */ +export function useFileIndex(fileId: string | null | undefined): number { + // Raw (unguarded) selectors: the read below runs inside the subscription + // selector, so it IS reactive and the render-phase guard doesn't apply. + const { selectors } = useFileStore(); + return useFileSelector((s) => { + if (!fileId) return 0; + const index = selectors + .getFiles(s.files.ids) + .findIndex((file) => file.fileId === fileId); + return index >= 0 ? index : 0; + }); +} + /** * Hook for accessing file state (will re-render on any state change) * Use individual selector hooks below for better performance */ export function useFileState(): FileContextStateValue { - const context = useContext(FileStateContext); - if (!context) { - throw new Error("useFileState must be used within a FileContextProvider"); - } - return context; + const store = useFileStore(); + const state = useFileSelector((s) => s); + // Selectors are exposed unguarded on purpose: this hook subscribes to the + // WHOLE state, so a render-time selector read can't go stale. + return useMemo( + () => ({ state, selectors: store.selectors }), + [state, store.selectors], + ); } /** @@ -39,21 +199,21 @@ export function useFileActions(): FileContextActionsValue { * Hook for current/primary file (first in list) */ export function useCurrentFile(): { file?: File; record?: StirlingFileStub } { - const { state, selectors } = useFileState(); - - const primaryFileId = state.files.ids[0]; - const primaryFileRecord = primaryFileId - ? state.files.byId[primaryFileId] - : undefined; + const { selectors } = useFileStore(); + const { primaryFileId, record } = useFileSelector( + (s) => ({ + primaryFileId: s.files.ids[0], + record: s.files.ids[0] ? s.files.byId[s.files.ids[0]] : undefined, + }), + shallowEqual, + ); return useMemo( () => ({ file: primaryFileId ? selectors.getFile(primaryFileId) : undefined, - record: primaryFileId - ? selectors.getStirlingFileStub(primaryFileId) - : undefined, + record, }), - [primaryFileId, primaryFileRecord, selectors], + [primaryFileId, record, selectors], ); } @@ -61,27 +221,35 @@ export function useCurrentFile(): { file?: File; record?: StirlingFileStub } { * Hook for file selection state and actions */ export function useFileSelection() { - const { state, selectors } = useFileState(); + const { selectors } = useFileStore(); const { actions } = useFileActions(); + const selectedFileIds = useFileSelector((s) => s.ui.selectedFileIds); + const selectedPageNumbers = useFileSelector((s) => s.ui.selectedPageNumbers); + // Only the SELECTED files' records — an unrelated file's update never + // re-renders selection consumers. + const selectedStubs = useFileSelector( + (s) => s.ui.selectedFileIds.map((id) => s.files.byId[id]), + shallowEqual, + ); // Memoize selected files to avoid recreating arrays const selectedFiles = useMemo(() => { return selectors.getSelectedFiles(); - }, [state.ui.selectedFileIds, state.files.byId, selectors]); + }, [selectedFileIds, selectedStubs, selectors]); return useMemo( () => ({ selectedFiles, - selectedFileIds: state.ui.selectedFileIds, - selectedPageNumbers: state.ui.selectedPageNumbers, + selectedFileIds, + selectedPageNumbers, setSelectedFiles: actions.setSelectedFiles, setSelectedPages: actions.setSelectedPages, clearSelections: actions.clearSelections, }), [ selectedFiles, - state.ui.selectedFileIds, - state.ui.selectedPageNumbers, + selectedFileIds, + selectedPageNumbers, actions.setSelectedFiles, actions.setSelectedPages, actions.clearSelections, @@ -111,57 +279,64 @@ export function useFileManagement() { * Hook for UI state */ export function useFileUI() { - const { state } = useFileState(); const { actions } = useFileActions(); + const ui = useFileSelector( + (s) => ({ + isProcessing: s.ui.isProcessing, + processingProgress: s.ui.processingProgress, + hasUnsavedChanges: s.ui.hasUnsavedChanges, + }), + shallowEqual, + ); return useMemo( () => ({ - isProcessing: state.ui.isProcessing, - processingProgress: state.ui.processingProgress, - hasUnsavedChanges: state.ui.hasUnsavedChanges, + ...ui, setProcessing: actions.setProcessing, setUnsavedChanges: actions.setHasUnsavedChanges, }), - [state.ui, actions], + [ui, actions], ); } /** - * Hook for specific file by ID (optimized for individual file access) + * Hook for specific file by ID (optimized for individual file access): + * re-renders only when THAT file's record changes. */ export function useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub; } { - const { state, selectors } = useFileState(); - const fileRecord = state.files.byId[fileId]; + const { selectors } = useFileStore(); + const record = useFileSelector((s) => s.files.byId[fileId]); return useMemo( () => ({ file: selectors.getFile(fileId), - record: selectors.getStirlingFileStub(fileId), + record, }), - [fileId, fileRecord, selectors], + [fileId, record, selectors], ); } /** - * Hook for all files (use sparingly - causes re-renders on file list changes) + * Hook for all files: re-renders on file-list changes only (not selection/UI). */ export function useAllFiles(): { files: StirlingFile[]; fileStubs: StirlingFileStub[]; fileIds: FileId[]; } { - const { state, selectors } = useFileState(); + const { selectors } = useFileStore(); + const files = useFileSelector((s) => s.files); return useMemo( () => ({ - files: selectors.getFiles(), - fileStubs: selectors.getStirlingFileStubs(), - fileIds: state.files.ids, + files: selectors.getFiles(files.ids), + fileStubs: selectors.getStirlingFileStubs(files.ids), + fileIds: files.ids, }), - [state.files.ids, state.files.byId, selectors], + [files, selectors], ); } @@ -173,30 +348,47 @@ export function useSelectedFiles(): { selectedFileStubs: StirlingFileStub[]; selectedFileIds: FileId[]; } { - const { state, selectors } = useFileState(); + const { selectors } = useFileStore(); + const selectedFileIds = useFileSelector((s) => s.ui.selectedFileIds); + // Only the SELECTED files' records — see useFileSelection. + const selectedStubs = useFileSelector( + (s) => s.ui.selectedFileIds.map((id) => s.files.byId[id]), + shallowEqual, + ); return useMemo( () => ({ selectedFiles: selectors.getSelectedFiles(), selectedFileStubs: selectors.getSelectedStirlingFileStubs(), - selectedFileIds: state.ui.selectedFileIds, + selectedFileIds, }), - [state.ui.selectedFileIds, state.files.byId, selectors], + [selectedFileIds, selectedStubs, selectors], ); } -// Navigation management removed - moved to NavigationContext - /** - * Primary API hook for file context operations - * Used by tools for core file context functionality + * Primary API hook for file context operations. Used by tools for core file + * context functionality. Re-renders only when the slices it exposes reactively + * (files, pinned files) change — not on selection/UI changes. */ export function useFileContext() { - const { state, selectors } = useFileState(); + const store = useFileStore(); const { actions } = useFileActions(); + const { files, pinnedFiles } = useFileSelector( + (s) => ({ files: s.files, pinnedFiles: s.pinnedFiles }), + shallowEqual, + ); + // This hook subscribes to files + pinnedFiles, so those selectors are safe to + // read during render; the SELECTION ones aren't (no subscription to + // ui.selectedFileIds), so they carry the misuse guard. + const selectors = useGuardedSelectors( + store.selectors, + "useFileContext", + SELECTION_SELECTORS, + ); - return useMemo( - () => ({ + return useMemo(() => { + return { // Lifecycle management trackBlobUrl: actions.trackBlobUrl, scheduleCleanup: actions.scheduleCleanup, @@ -213,10 +405,11 @@ export function useFileContext() { _operationId: string, _error: string, ) => {}, // Operation tracking not implemented - // File ID lookup + // File ID lookup (reads live state at call time) findFileId: (file: File) => { - return state.files.ids.find((id) => { - const record = state.files.byId[id]; + const { files: liveFiles } = store.getState(); + return liveFiles.ids.find((id) => { + const record = liveFiles.byId[id]; return ( record && record.name === file.name && @@ -227,19 +420,18 @@ export function useFileContext() { }, // Pinned files - pinnedFiles: state.pinnedFiles, + pinnedFiles, pinFile: actions.pinFile, unpinFile: actions.unpinFile, isFilePinned: selectors.isFilePinned, // Active files - activeFiles: selectors.getFiles(), + activeFiles: selectors.getFiles(files.ids), openEncryptedUnlockPrompt: actions.openEncryptedUnlockPrompt, // Direct access to actions and selectors (for advanced use cases) actions, selectors, - }), - [state, selectors, actions], - ); + }; + }, [files, pinnedFiles, actions, store, selectors]); } diff --git a/frontend/editor/src/core/contexts/file/reducerIdentityGuard.test.ts b/frontend/editor/src/core/contexts/file/reducerIdentityGuard.test.ts new file mode 100644 index 0000000000..c250c3e4dc --- /dev/null +++ b/frontend/editor/src/core/contexts/file/reducerIdentityGuard.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { withReducerIdentityGuard } from "@app/contexts/file/FileReducer"; +import type { + FileContextState, + FileContextAction, + StirlingFileStub, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +const stub = (id: string): StirlingFileStub => + ({ id: id as FileId, name: `${id}.pdf` }) as StirlingFileStub; + +function baseState(): FileContextState { + return { + files: { ids: ["a" as FileId], byId: { ["a" as FileId]: stub("a") } }, + pinnedFiles: new Set(), + ui: { + selectedFileIds: [], + selectedPageNumbers: [], + isProcessing: false, + processingProgress: 0, + hasUnsavedChanges: false, + errorFileIds: [], + }, + }; +} + +const guardErrors = (spy: ReturnType) => + spy.mock.calls.filter((a) => String(a[0]).includes("[FileReducer]")); + +afterEach(() => vi.restoreAllMocks()); + +describe("withReducerIdentityGuard", () => { + it("warns when a slice is reallocated but unchanged", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + // Bad reducer: rebuilds `files` (new ref) with identical contents. + const guarded = withReducerIdentityGuard((s) => ({ + ...s, + files: { ids: [...s.files.ids], byId: { ...s.files.byId } }, + })); + guarded(baseState(), { type: "NOOP" } as unknown as FileContextAction); + expect(guardErrors(spy)).toHaveLength(1); + expect(String(guardErrors(spy)[0][0])).toContain("state.files"); + }); + + it("stays quiet when a slice genuinely changes", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const guarded = withReducerIdentityGuard((s) => ({ + ...s, + files: { + ids: [...s.files.ids, "b" as FileId], + byId: { ...s.files.byId, ["b" as FileId]: stub("b") }, + }, + })); + guarded(baseState(), { type: "NOOP" } as unknown as FileContextAction); + expect(guardErrors(spy)).toHaveLength(0); + }); + + it("stays quiet when the reducer returns the same state reference", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const guarded = withReducerIdentityGuard((s) => s); + const state = baseState(); + expect( + guarded(state, { type: "NOOP" } as unknown as FileContextAction), + ).toBe(state); + expect(guardErrors(spy)).toHaveLength(0); + }); + + it("flags a needless ui reallocation", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const guarded = withReducerIdentityGuard((s) => ({ + ...s, + ui: { ...s.ui }, + })); + guarded(baseState(), { type: "NOOP" } as unknown as FileContextAction); + expect(String(guardErrors(spy)[0][0])).toContain("state.ui"); + }); +}); diff --git a/frontend/editor/src/core/contexts/file/useFileIndex.test.tsx b/frontend/editor/src/core/contexts/file/useFileIndex.test.tsx new file mode 100644 index 0000000000..e10bfed517 --- /dev/null +++ b/frontend/editor/src/core/contexts/file/useFileIndex.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { render, act } from "@testing-library/react"; +import { + FileStoreContext, + type FileStateStore, +} from "@app/contexts/file/contexts"; +import { useFileIndex } from "@app/contexts/file/fileHooks"; +import type { + FileContextSelectors, + FileContextState, +} from "@app/types/fileContext"; +import type { FileId } from "@app/types/file"; + +/** + * useFileIndex replaced a render-time `selectors.getFiles()` read in + * ViewerContext, which never re-subscribed and so survived a file-list change + * that moved the active file. These tests drive a hand-built store (the real one + * needs IndexedDB to populate its File map) and lock the two properties the fix + * depends on: the index tracks the RESOLVED file list, and the consumer + * re-renders only when the index actually moves. + */ + +function makeStore(ids: string[], resolved: string[]) { + let state: FileContextState = { + files: { ids: ids as FileId[], byId: {} }, + } as FileContextState; + let resolvedIds = new Set(resolved); + const listeners = new Set<() => void>(); + + // Mirrors createFileSelectors.getFiles: maps ids through the in-memory File + // map and DROPS the ones whose bytes haven't landed yet. + const selectors = { + getFiles: (requested?: FileId[]) => + (requested ?? state.files.ids) + .filter((id) => resolvedIds.has(id as string)) + .map((id) => ({ fileId: id })), + } as unknown as FileContextSelectors; + + const store: FileStateStore = { + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + selectors, + }; + + const update = (nextIds: string[], nextResolved: string[] = nextIds) => { + act(() => { + state = { + files: { ids: nextIds as FileId[], byId: {} }, + } as FileContextState; + resolvedIds = new Set(nextResolved); + listeners.forEach((listener) => listener()); + }); + }; + + return { store, update }; +} + +function setup(ids: string[], resolved: string[], fileId: string | null) { + const { store, update } = makeStore(ids, resolved); + let renders = 0; + let index = -1; + + function Probe() { + index = useFileIndex(fileId); + renders++; + return null; + } + + render( + + + , + ); + + return { update, get: () => index, renderCount: () => renders }; +} + +describe("useFileIndex", () => { + it("reports the active file's position in the resolved list", () => { + const { get } = setup(["a", "b", "c"], ["a", "b", "c"], "c"); + expect(get()).toBe(2); + }); + + it("skips ids whose bytes haven't hydrated, matching what consumers index into", () => { + // "a" has no File yet, so getFiles() yields [b, c] — "c" sits at 1, not 2. + const { get } = setup(["a", "b", "c"], ["b", "c"], "c"); + expect(get()).toBe(1); + }); + + it("updates when the list reorders under a stable active file", () => { + // The regression this fixes: activeFileId never changed, so the old + // useMemo kept returning the pre-reorder index. + const { get, update } = setup(["a", "b", "c"], ["a", "b", "c"], "c"); + expect(get()).toBe(2); + update(["c", "a", "b"]); + expect(get()).toBe(0); + }); + + it("updates when a file ahead of the active one is removed", () => { + const { get, update } = setup(["a", "b", "c"], ["a", "b", "c"], "c"); + update(["b", "c"]); + expect(get()).toBe(1); + }); + + it("falls back to 0 when the active file leaves the list", () => { + const { get, update } = setup(["a", "b"], ["a", "b"], "b"); + update(["a"]); + expect(get()).toBe(0); + }); + + it("returns 0 with no active file", () => { + const { get } = setup(["a", "b"], ["a", "b"], null); + expect(get()).toBe(0); + }); + + it("does not re-render when a store change leaves the index alone", () => { + // Selecting a NUMBER is the point: appending after the active file, or any + // unrelated stub churn, must not re-render the consumer. + const { get, update, renderCount } = setup(["a", "b"], ["a", "b"], "a"); + const before = renderCount(); + update(["a", "b", "c"]); + expect(get()).toBe(0); + expect(renderCount()).toBe(before); + }); +}); diff --git a/frontend/editor/src/core/tools/Convert.tsx b/frontend/editor/src/core/tools/Convert.tsx index de29e4ae94..b1d214f1a6 100644 --- a/frontend/editor/src/core/tools/Convert.tsx +++ b/frontend/editor/src/core/tools/Convert.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; -import { useFileState } from "@app/contexts/FileContext"; +import { useAllFiles } from "@app/contexts/FileContext"; import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; @@ -14,8 +14,7 @@ import { BaseToolProps, ToolComponent } from "@app/types/tool"; const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { selectors } = useFileState(); - const activeFiles = selectors.getFiles(); + const { files: activeFiles } = useAllFiles(); const selectedFiles = useViewScopedFiles(); const scrollContainerRef = useRef(null); diff --git a/frontend/editor/src/desktop/hooks/useExitWarning.ts b/frontend/editor/src/desktop/hooks/useExitWarning.ts index 1e6b4423fc..4a5b16fb8f 100644 --- a/frontend/editor/src/desktop/hooks/useExitWarning.ts +++ b/frontend/editor/src/desktop/hooks/useExitWarning.ts @@ -1,14 +1,14 @@ import { useEffect, useRef } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { message } from "@tauri-apps/plugin-dialog"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { useFileSelectors, useFileActions } from "@app/contexts/FileContext"; import { downloadFile } from "@app/services/downloadService"; import type { StirlingFileStub } from "@app/types/fileContext"; import { useTranslation } from "react-i18next"; export function useExitWarning() { const { t } = useTranslation(); - const { selectors } = useFileState(); + const selectors = useFileSelectors(); const { actions: fileActions } = useFileActions(); const selectorsRef = useRef(selectors); const isClosingRef = useRef(false); diff --git a/frontend/editor/src/desktop/hooks/useSaveShortcut.ts b/frontend/editor/src/desktop/hooks/useSaveShortcut.ts index 927d5f06cf..b3faa64bb7 100644 --- a/frontend/editor/src/desktop/hooks/useSaveShortcut.ts +++ b/frontend/editor/src/desktop/hooks/useSaveShortcut.ts @@ -1,5 +1,9 @@ import { useEffect } from "react"; -import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { + useFileSelector, + useFileSelectors, + useFileActions, +} from "@app/contexts/FileContext"; // Save through the export gateway so a "run on export" policy enforces before // the file is written out (no-op when no such policy is active). import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy"; @@ -10,7 +14,8 @@ import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWith * Matches WorkbenchBar button behavior: saves selected files if any, otherwise all files */ export function useSaveShortcut() { - const { selectors, state } = useFileState(); + const selectors = useFileSelectors(); + const currentSelectedFileIds = useFileSelector((s) => s.ui.selectedFileIds); const { actions: fileActions } = useFileActions(); useEffect(() => { @@ -20,7 +25,7 @@ export function useSaveShortcut() { event.preventDefault(); // Get selected files or all files if nothing selected - const selectedFileIds = state.ui.selectedFileIds; + const selectedFileIds = currentSelectedFileIds; const filesToSave = selectedFileIds.length > 0 ? selectors.getFiles(selectedFileIds) @@ -63,5 +68,5 @@ export function useSaveShortcut() { document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [selectors, state.ui.selectedFileIds, fileActions]); + }, [selectors, currentSelectedFileIds, fileActions]); } diff --git a/frontend/editor/src/proprietary/components/policies/classificationLabelTargets.test.ts b/frontend/editor/src/proprietary/components/policies/classificationLabelTargets.test.ts new file mode 100644 index 0000000000..74baf59006 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/classificationLabelTargets.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { classificationLabelTargetStubs } from "@app/components/policies/usePolicyAutoRun"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +// Loosely-typed builder: FileId is a branded string, so accept plain string ids +// in tests and cast — classificationLabelTargetStubs only reads id/parent/sources. +const stub = (s: { + id: string; + parentFileId?: string; + sourceFileIds?: string[]; +}): StirlingFileStub => s as unknown as StirlingFileStub; + +const ids = (stubs: StirlingFileStub[]) => stubs.map((s) => s.id as string); + +describe("classificationLabelTargetStubs", () => { + it("targets the run's own file when it's still the leaf", () => { + const stubs = [stub({ id: "a" }), stub({ id: "b" })]; + expect(ids(classificationLabelTargetStubs("a", stubs))).toEqual(["a"]); + }); + + it("targets a descendant leaf when the file was edited during the run", () => { + // "a" was consumed into leaf "a2" (edit forked a new version mid-run). + const stubs = [stub({ id: "a2", sourceFileIds: ["a"] })]; + expect(ids(classificationLabelTargetStubs("a", stubs))).toEqual(["a2"]); + }); + + it("targets a direct child via parentFileId", () => { + const stubs = [stub({ id: "a2", parentFileId: "a" })]; + expect(ids(classificationLabelTargetStubs("a", stubs))).toEqual(["a2"]); + }); + + it("returns the stubs themselves, so the caller can see what's already tagged", () => { + const target = stub({ id: "a" }); + expect(classificationLabelTargetStubs("a", [target])[0]).toBe(target); + }); + + it("is empty when the document has left the workspace (file closed)", () => { + // No fallback to the run's own id: stamping a consumed id would no-op + // anyway, and an empty result lets the caller settle without downloading. + expect(classificationLabelTargetStubs("a", [stub({ id: "z" })])).toEqual( + [], + ); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.test.ts b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.test.ts new file mode 100644 index 0000000000..355d8b5526 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + acquireDispatchSlot, + releaseDispatchSlot, + resetDispatchSemaphoreForTests, +} from "@app/components/policies/dispatchSemaphore"; + +// Drain the microtask queue so an acquire's await-resume AND the caller's .then +// have both run. +const flush = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => resetDispatchSemaphoreForTests()); + +describe("dispatchSemaphore", () => { + it("lets up to 4 acquire without waiting, then blocks the 5th", async () => { + for (let i = 0; i < 4; i++) await acquireDispatchSlot(); + let fifthAcquired = false; + void acquireDispatchSlot().then(() => { + fifthAcquired = true; + }); + await flush(); + expect(fifthAcquired).toBe(false); + releaseDispatchSlot(); + await flush(); + expect(fifthAcquired).toBe(true); + }); + + it("serves a priority (chained) waiter before earlier normal waiters", async () => { + for (let i = 0; i < 4; i++) await acquireDispatchSlot(); // pool full + const order: string[] = []; + // Two normal (new-file) dispatches queue first… + void acquireDispatchSlot(false).then(() => order.push("normal-1")); + void acquireDispatchSlot(false).then(() => order.push("normal-2")); + // …then a chained dispatch arrives — it must jump ahead. + void acquireDispatchSlot(true).then(() => order.push("chained")); + await flush(); + + releaseDispatchSlot(); + await flush(); + releaseDispatchSlot(); + await flush(); + releaseDispatchSlot(); + await flush(); + + expect(order).toEqual(["chained", "normal-1", "normal-2"]); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.ts b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.ts new file mode 100644 index 0000000000..d5119e5923 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/dispatchSemaphore.ts @@ -0,0 +1,42 @@ +/** + * Bounded concurrency for policy run-dispatch uploads. + * + * Each dispatch POSTs a file's bytes; firing a whole drop at once saturates the + * browser's per-origin connection pool, so status polls and output downloads of + * already-running files queue behind the pending uploads and nothing visibly + * progresses. A small window keeps connections free. + * + * `priority` (a chained/downstream dispatch) jumps to the FRONT of the queue, so + * a file already mid-chain finishes its whole policy flow before a brand-new + * file's first policy starts. Without it a chained dispatch would sit behind the + * entire first-policy wave (FIFO) — e.g. classification wouldn't start on any + * file until security had finished on all of them. + */ +const MAX_CONCURRENT_DISPATCHES = 4; + +let slotsInUse = 0; +const waiters: Array<() => void> = []; + +export async function acquireDispatchSlot(priority = false): Promise { + if (slotsInUse < MAX_CONCURRENT_DISPATCHES) { + slotsInUse++; + return; + } + await new Promise((resolve) => { + if (priority) waiters.unshift(resolve); + else waiters.push(resolve); + }); +} + +export function releaseDispatchSlot(): void { + const next = waiters.shift(); + // Hand the slot straight to the next waiter, else free it. + if (next) next(); + else slotsInUse--; +} + +/** Test-only: reset module state between cases. */ +export function resetDispatchSemaphoreForTests(): void { + slotsInUse = 0; + waiters.length = 0; +} diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx index 5837bfb285..bc4fa54dff 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx @@ -2,21 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; /** - * Batch integration test for the policy auto-run orchestration, at the scale the - * user hit the bug: 61 files uploaded at once, two active upload policies - * (Classification → Security) chained. Drives the REAL policyRunStore + the REAL - * hook effects (dispatch → poll → import → chain), mocking only the IO boundaries - * (network, storage, thumbnail/stub creation). - * - * Proves the invariants the user asked for: - * - 61 files ⇒ exactly 122 runs (61 classification, then 61 security). - * - Delivery is SILENT + in place (consumeFiles called with { silent: true }), - * never adding a second copy — the workspace never grows past 61. - * - No runaway: if the loop guard regressed, the run count would blow past 122 - * (or the test would time out), so an exact 122 is a hard regression gate. - * - Closing all files mid-run does NOT re-open them: with the workspace emptied, - * outputs are delivered to storage (persistVersionedOutputs), never re-added - * to the workspace via consumeFiles. + * Batch integration test (61 files, two chained upload policies) driving the real + * store + hook effects, IO mocked. Classification is forced last (see the sort). */ const FILE_COUNT = 61; @@ -25,13 +12,15 @@ const FILE_COUNT = 61; // the workbench, mirrored into useAllFiles. consumeFiles mutates it in place // (input id → output id) exactly as the real silent reducer would. const mocks = vi.hoisted(() => ({ - workspace: [] as Array<{ id: string }>, + workspace: [] as Array<{ id: string; classificationLabels?: string[] }>, consumeSilentCalls: 0, consumeNonSilentCalls: 0, persistCalls: 0, addFilesCalls: 0, stubCounter: 0, backendOutCounter: 0, + dispatchInFlight: 0, + maxDispatchInFlight: 0, bumpRevision: vi.fn(), runStoredPolicy: vi.fn(), getPolicyRun: vi.fn(), @@ -66,7 +55,8 @@ vi.mock("@app/contexts/IndexedDBContext", () => ({ vi.mock("@app/hooks/usePolicies", () => ({ usePolicies: () => ({ policies: { - // Classification runs first (order 0), Security second (order 1). + // Classification is configured first (order 0) but is FORCED to run last + // by the orchestrator; Security (order 1) therefore runs first. classification: { configured: true, status: "active", @@ -107,7 +97,9 @@ vi.mock("@app/services/fileStubHelpers", () => ({ createStirlingFilesAndStubs: mocks.createStirlingFilesAndStubs, })); vi.mock("@app/services/fileClassification", () => ({ - readClassificationLabelsFromFile: vi.fn().mockResolvedValue(null), + // Classification always resolves labels here, so the metadata-only import path + // stamps them onto the stub. + readClassificationLabelsFromFile: vi.fn().mockResolvedValue(["Invoice"]), })); import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; @@ -141,6 +133,8 @@ beforeEach(() => { mocks.addFilesCalls = 0; mocks.stubCounter = 0; mocks.backendOutCounter = 0; + mocks.dispatchInFlight = 0; + mocks.maxDispatchInFlight = 0; mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({ id: `file-${i}`, @@ -155,15 +149,31 @@ beforeEach(() => { mocks.persistVersionedOutputs.mockImplementation(async () => { mocks.persistCalls += 1; }); - mocks.updateFileMetadata.mockResolvedValue(false); + mocks.updateFileMetadata.mockResolvedValue(true); mocks.downloadPolicyOutput.mockResolvedValue( new Blob(["x"], { type: "application/pdf" }), ); + // Apply stub updates to the shared workspace, as the real reducer does — the + // label stamp's second pass reads them back to stay idempotent. + mocks.updateStirlingFileStub.mockImplementation( + (id: string, updates: Record) => { + const stub = mocks.workspace.find((s) => s.id === id); + if (stub) Object.assign(stub, updates); + }, + ); // Each dispatch gets a unique run id; the run's single backend output likewise. - mocks.runStoredPolicy.mockImplementation( - async () => `run-${mocks.stubCounter++}`, - ); + // Takes real time so overlapping dispatches are measurable (the upload window). + mocks.runStoredPolicy.mockImplementation(async () => { + mocks.dispatchInFlight++; + mocks.maxDispatchInFlight = Math.max( + mocks.maxDispatchInFlight, + mocks.dispatchInFlight, + ); + await new Promise((resolve) => setTimeout(resolve, 2)); + mocks.dispatchInFlight--; + return `run-${mocks.stubCounter++}`; + }); mocks.getPolicyRun.mockImplementation(async (runId: string) => ({ runId, policyId: null, @@ -225,8 +235,8 @@ async function runUntilSettled(expectedRuns: number) { }); } -describe("policy auto-run — 61-file batch through a Classification → Security chain", () => { - it("produces exactly 122 runs (61 classification, then 61 security)", async () => { +describe("policy auto-run — 61-file batch through a Security → Classification chain", () => { + it("produces exactly 122 runs (61 security, then 61 classification)", async () => { await runUntilSettled(FILE_COUNT * 2); const classification = latestRuns.filter( @@ -239,15 +249,26 @@ describe("policy auto-run — 61-file batch through a Classification → Securit expect(latestRuns).toHaveLength(FILE_COUNT * 2); }); - it("delivers every output SILENTLY in place — workspace never grows past 61", async () => { + it("bounds concurrent dispatch uploads so polls/downloads keep connections", async () => { + await runUntilSettled(FILE_COUNT * 2); + expect(mocks.maxDispatchInFlight).toBeGreaterThan(1); // still parallel… + expect(mocks.maxDispatchInFlight).toBeLessThanOrEqual(4); // …but windowed + }); + + it("versions on Security in place, tags on Classification — workspace never grows past 61", async () => { await runUntilSettled(FILE_COUNT * 2); - // 122 deliveries, all silent (background), none via the disruptive path. - expect(mocks.consumeSilentCalls).toBe(FILE_COUNT * 2); + // Only the 61 Security runs fork a version, and every one silently in place. + expect(mocks.consumeSilentCalls).toBe(FILE_COUNT); expect(mocks.consumeNonSilentCalls).toBe(0); + // Classification never forks a version — it only stamps labels onto the stub. + expect(mocks.updateStirlingFileStub).toHaveBeenCalledTimes(FILE_COUNT); + for (const call of mocks.updateStirlingFileStub.mock.calls) { + expect(call[1]).toEqual({ classificationLabels: ["Invoice"] }); + } // Never added as brand-new files either. expect(mocks.addFilesCalls).toBe(0); - // In-place versioning: each file replaced twice, count unchanged. + // In-place versioning + metadata-only tagging: count unchanged. expect(mocks.workspace).toHaveLength(FILE_COUNT); }); @@ -273,8 +294,8 @@ describe("policy auto-run — 61-file batch through a Classification → Securit ); }); - // Still fully processed (chain intact), but delivered to STORAGE, never - // re-added to the workbench — the workspace stays empty. + // Still fully processed (chain intact), but Security's versions went to + // STORAGE, never re-added to the workbench — the workspace stays empty. expect(latestRuns).toHaveLength(FILE_COUNT * 2); expect(mocks.workspace).toHaveLength(0); expect(mocks.consumeSilentCalls).toBe(0); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx new file mode 100644 index 0000000000..b67648e7fc --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx @@ -0,0 +1,293 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +/** + * Mid-run race: classification is in flight (its labelled output is still + * downloading) when the user manually runs a tool on the same file — e.g. + * quickly redacting it — which consumes the input and forks a new leaf. + * + * The label targets must be resolved AT WRITE TIME (after the download/parse + * window), not snapshotted at run completion: a stale snapshot points at the + * consumed id, no-ops, and silently loses the labels — the file then shows the + * classification badge (provenance-resolved) but never gets its labels. + */ + +const mocks = vi.hoisted(() => ({ + workspace: [] as Array<{ + id: string; + sourceFileIds?: string[]; + derivedFromTool?: boolean; + classificationLabels?: string[]; + }>, + runStoredPolicy: vi.fn(), + getPolicyRun: vi.fn(), + listPolicyRuns: vi.fn(), + downloadPolicyOutput: vi.fn(), + getStirlingFile: vi.fn(), + getStirlingFileStub: vi.fn(), + persistVersionedOutputs: vi.fn(), + updateFileMetadata: vi.fn(), + createStirlingFilesAndStubs: vi.fn(), + addFiles: vi.fn(), + updateStirlingFileStub: vi.fn(), + consumeFiles: vi.fn(), + bumpRevision: vi.fn(), +})); + +// Classification chains server-side only when the AI engine is on (else it runs +// client-side); this race is in the server import path, so force the engine on. +vi.mock("@app/hooks/useAiEngineEnabled", () => ({ + useAiEngineEnabled: () => true, +})); +vi.mock("@app/contexts/FileContext", () => ({ + useAllFiles: () => ({ fileStubs: mocks.workspace }), + useFileManagement: () => ({ + addFiles: mocks.addFiles, + updateStirlingFileStub: mocks.updateStirlingFileStub, + }), + useFileContext: () => ({ consumeFiles: mocks.consumeFiles }), +})); +vi.mock("@app/contexts/IndexedDBContext", () => ({ + useIndexedDB: () => ({ bumpRevision: mocks.bumpRevision }), +})); +vi.mock("@app/hooks/usePolicies", () => ({ + usePolicies: () => ({ + policies: { + classification: { + configured: true, + status: "active", + backendId: "backend-classification", + runOn: "upload", + order: 0, + outputMode: "new_version", + outputName: "", + }, + }, + }), +})); +vi.mock("@app/services/policyApi", () => ({ + runStoredPolicy: mocks.runStoredPolicy, + getPolicyRun: mocks.getPolicyRun, + listPolicyRuns: mocks.listPolicyRuns, + downloadPolicyOutput: mocks.downloadPolicyOutput, + resolvePolicyRunTarget: () => "saas", +})); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFile: mocks.getStirlingFile, + getStirlingFileStub: mocks.getStirlingFileStub, + persistVersionedOutputs: mocks.persistVersionedOutputs, + updateFileMetadata: mocks.updateFileMetadata, + }, +})); +vi.mock("@app/services/fileStubHelpers", () => ({ + createStirlingFilesAndStubs: mocks.createStirlingFilesAndStubs, +})); +vi.mock("@app/services/fileClassification", () => ({ + readClassificationLabelsFromFile: vi.fn().mockResolvedValue(["Invoice"]), +})); + +import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; +import { + usePolicyRuns, + resetPolicyRuns, +} from "@app/components/policies/policyRunStore"; +import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; + +let latestRuns: PolicyRunRecord[] = []; +function Harness() { + usePolicyAutoRun(); + latestRuns = usePolicyRuns(); + return null; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +beforeEach(() => { + localStorage.clear(); + resetPolicyRuns(); + vi.clearAllMocks(); + + mocks.workspace = [{ id: "file-0" }]; + + mocks.listPolicyRuns.mockResolvedValue([]); + mocks.getStirlingFile.mockResolvedValue( + new File(["x"], "doc.pdf", { type: "application/pdf" }), + ); + mocks.getStirlingFileStub.mockResolvedValue(null); + mocks.updateFileMetadata.mockResolvedValue(true); + // Apply stub updates to the shared workspace, as the real reducer does — the + // label stamp's second pass reads them back to stay idempotent. + mocks.updateStirlingFileStub.mockImplementation( + (id: string, updates: Record) => { + const stub = mocks.workspace.find((s) => s.id === id); + if (stub) Object.assign(stub, updates); + }, + ); + mocks.runStoredPolicy.mockResolvedValue("run-0"); + mocks.getPolicyRun.mockResolvedValue({ + runId: "run-0", + policyId: null, + status: "COMPLETED", + currentStep: 1, + stepCount: 1, + error: null, + outputs: [{ fileId: "backend-out-0", fileName: "doc.pdf" }], + }); +}); + +async function settleImport(timeout = 8000) { + await act(async () => { + await vi.waitFor( + () => { + expect(latestRuns.filter((r) => r.imported)).toHaveLength(1); + }, + { timeout, interval: 20 }, + ); + }); +} + +describe("classification vs a mid-run manual tool edit", () => { + it("labels land on the forked leaf when a tool consumes the file during the label download", async () => { + // The classified output's download hangs until we release it — this is the + // async window the user's edit slips into. + const download = deferred(); + mocks.downloadPolicyOutput.mockReturnValue(download.promise); + + const { rerender } = renderHook(() => Harness()); + + // Run dispatched, completed, import started — now hanging in the window. + await act(async () => { + await vi.waitFor( + () => expect(mocks.downloadPolicyOutput).toHaveBeenCalled(), + { timeout: 8000, interval: 20 }, + ); + }); + + // User quickly redacts: the tool consumes file-0 and forks a new leaf. + // (derivedFromTool + sourceFileIds are what CONSUME_FILES stamps.) + act(() => { + mocks.workspace = [ + { + id: "file-0~redacted", + sourceFileIds: ["file-0"], + derivedFromTool: true, + }, + ]; + rerender(); + }); + + // The download finally lands. + download.resolve(new Blob(["x"], { type: "application/pdf" })); + await settleImport(); + + // Labels stamped onto the LIVE leaf, not no-oped on the consumed id. + const stampedIds = mocks.updateStirlingFileStub.mock.calls.map((c) => c[0]); + expect(stampedIds).toEqual(["file-0~redacted"]); + expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith( + "file-0~redacted", + { classificationLabels: ["Invoice"] }, + ); + // Badge persists on the leaf: the run's outputFileIds are the tagged files. + expect(latestRuns[0].outputFileIds).toEqual(["file-0~redacted"]); + }); + + it("control: with no mid-run edit, labels land on the original file", async () => { + mocks.downloadPolicyOutput.mockResolvedValue( + new Blob(["x"], { type: "application/pdf" }), + ); + + renderHook(() => Harness()); + await settleImport(); + + expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("file-0", { + classificationLabels: ["Invoice"], + }); + expect(latestRuns[0].outputFileIds).toEqual(["file-0"]); + }); + + it("stamps the forked leaf when the consume lands in the same frame as the first stamp", async () => { + // Tighter than the case above: the consume is dispatched but hasn't rendered + // when the labels are stamped, so the workspace snapshot still shows file-0 + // and that stamp no-ops against the real reducer. The post-commit second pass + // is what saves the labels. + mocks.downloadPolicyOutput.mockResolvedValue( + new Blob(["x"], { type: "application/pdf" }), + ); + mocks.updateStirlingFileStub.mockImplementation((id: string) => { + // file-0 is already consumed, so its stamp is lost (no Object.assign) and + // the forked leaf only becomes visible afterwards. Mutate the workspace in + // place: the hook holds it by ref, which is what the second pass re-reads. + if (id === "file-0") { + mocks.workspace.splice(0, mocks.workspace.length, { + id: "file-0~redacted", + sourceFileIds: ["file-0"], + }); + return; + } + const stub = mocks.workspace.find((s) => s.id === id); + if (stub) Object.assign(stub, { classificationLabels: ["Invoice"] }); + }); + + renderHook(() => Harness()); + await settleImport(); + + const stampedIds = mocks.updateStirlingFileStub.mock.calls.map((c) => c[0]); + expect(stampedIds).toEqual(["file-0", "file-0~redacted"]); + // The leaf becoming visible also queues its own classification run, so pick + // the settled one rather than assuming an index. + const imported = latestRuns.find((r) => r.imported); + expect(imported?.outputFileIds).toContain("file-0~redacted"); + }); +}); + +// The label read backs off between attempts (2s, then 4s), so these run on fake +// timers — sleeping for real would hold a worker long enough to starve the suite. +describe("classification label-read failures", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + async function settleOnFakeTime(maxMs = 30_000) { + for (let elapsed = 0; elapsed < maxMs; elapsed += 250) { + if (latestRuns.some((r) => r.imported)) return; + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + } + throw new Error("classification run never settled"); + } + + it("retries a transient failure instead of leaving the run unsettled", async () => { + // The import effect only re-runs when the run store changes, so bailing out + // on a transient failure would leave this run "running" forever. + mocks.downloadPolicyOutput + .mockRejectedValueOnce(new Error("network blip")) + .mockResolvedValue(new Blob(["x"], { type: "application/pdf" })); + + renderHook(() => Harness()); + await settleOnFakeTime(); + + expect(mocks.downloadPolicyOutput).toHaveBeenCalledTimes(2); + expect(mocks.updateStirlingFileStub).toHaveBeenCalledWith("file-0", { + classificationLabels: ["Invoice"], + }); + }); + + it("settles a run whose labels never become readable", async () => { + // Permanent failure: give up after the retry budget and settle unlabelled, + // rather than spinning the file's "running" pill indefinitely. + mocks.downloadPolicyOutput.mockRejectedValue(new Error("network down")); + + renderHook(() => Harness()); + await settleOnFakeTime(); + + expect(mocks.updateStirlingFileStub).not.toHaveBeenCalled(); + expect(latestRuns.find((r) => r.imported)?.outputFileIds).toEqual([]); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx index c25f80ce65..3c54e5cc42 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.retry.test.tsx @@ -69,8 +69,17 @@ afterEach(() => vi.useRealTimers()); describe("auto-run queue-rejection retry", () => { it("relabels a queue-rejected run as retrying, then re-dispatches it in place", async () => { - // The polled run comes back queue-rejected; the retry resolves the file + fires a fresh run. - getRunApi.mockResolvedValue(queueFullView); + // The polled run comes back queue-rejected once; the retry resolves the file + // and fires a fresh run, whose own polls then see it genuinely running. + getRunApi.mockResolvedValueOnce(queueFullView).mockResolvedValue({ + runId: "run-2", + status: "RUNNING", + currentStep: 1, + stepCount: 2, + error: null, + errorCode: null, + outputs: [], + } as never); getFile.mockResolvedValue({ size: 1234 } as never); runStored.mockResolvedValue("run-2"); @@ -92,19 +101,20 @@ describe("auto-run queue-rejection retry", () => { return usePolicyRuns(); }); - // First poll (2s cadence) sees the rejection → relabel as a soft "retrying" row. + // First poll sees the rejection → relabel as a soft "retrying" row. await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(getRun("run-1")?.retrying).toBe(true); expect(runStored).not.toHaveBeenCalled(); - // After the first backoff window (BASE 4s) the rejected record is dropped and a fresh run fires. + // After the first backoff window (BASE 4s) the rejected record is dropped and + // a fresh run fires; its own first poll shows it genuinely running. await act(async () => { - await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(6000); }); expect(runStored).toHaveBeenCalledWith("backend-1", [{ size: 1234 }]); expect(getRun("run-1")).toBeUndefined(); - expect(getRun("run-2")?.status).toBe("PENDING"); + expect(getRun("run-2")?.status).toBe("RUNNING"); }); }); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index ca096b6792..e475d784ea 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -39,6 +39,11 @@ import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge"; import type { FileId } from "@app/types/file"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { readClassificationLabelsFromFile } from "@app/services/fileClassification"; +import { isClassificationCategory } from "@app/data/policyCategories"; +import { + acquireDispatchSlot, + releaseDispatchSlot, +} from "@app/components/policies/dispatchSemaphore"; import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; import type { PoliciesByCategory } from "@app/types/policies"; import { usePolicies } from "@app/hooks/usePolicies"; @@ -59,6 +64,10 @@ import { /** Status poll cadence. */ const POLL_MS = 2000; +/** First poll fires early so a fresh run shows real progress quickly instead of + * sitting on an indeterminate spinner for a full poll interval. */ +const FIRST_POLL_MS = 500; + /** The server aborts any single tool step that runs longer than its internal-API * read timeout, then fails the run — so a run can legitimately stay in flight * for up to this long per step. The client must keep polling at least that long, @@ -175,7 +184,14 @@ export function usePolicyAutoRun(): void { // Classification policy out of the server chain when the AI engine is off. !(id === "classification" && !aiEnabled), ) - .sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0)) + // Classification runs last: it's non-blocking, so an enforcement policy + // running after it would fork a new version and drop the user's edits. + .sort(([idA, a], [idB, b]) => { + const ca = isClassificationCategory(idA) ? 1 : 0; + const cb = isClassificationCategory(idB) ? 1 : 0; + if (ca !== cb) return ca - cb; + return (a.order ?? 0) - (b.order ?? 0); + }) .map(([id]) => id), [policies, aiEnabled], ); @@ -310,6 +326,7 @@ export function usePolicyAutoRun(): void { backendId, outputId as FileId, run.fileName, + true, // chained → jump the dispatch queue ahead of new files ).catch(() => {}); } } @@ -330,15 +347,33 @@ export function usePolicyAutoRun(): void { // so the enforced file appears in the app rather than only on the backend. useEffect(() => { for (const run of runs) { + const classification = isClassificationCategory(run.categoryId); if ( run.status !== "COMPLETED" || run.imported || - !run.outputs?.length || - importing.current.has(run.runId) + importing.current.has(run.runId) || + // Classification settles even with no outputs (nothing to tag); other + // policies need an output to import. + (!run.outputs?.length && !classification) ) { continue; } importing.current.add(run.runId); + // Classification is metadata-only: stamp labels onto the current leaf of + // the file it ran on (no version fork). See importClassificationLabels. + if (classification) { + // Targets are resolved by importClassificationLabels AT WRITE TIME (not + // snapshotted here): its download/parse is an async window during which + // a manual tool run can consume the input and fork a new leaf, and a + // stale snapshot would no-op on the dead id and lose the labels. + void importClassificationLabels( + run, + () => + classificationLabelTargetStubs(run.fileId, fileStubsRef.current), + { updateStirlingFileStub, bumpRevision }, + ).finally(() => importing.current.delete(run.runId)); + continue; + } // Honour the policy's output mode: a new file, or a new version of the // input file it ran on (needs that input's stub, still in the workspace). const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; @@ -501,6 +536,142 @@ function categoryForPolicy( )?.[0]; } +interface ClassificationImportContext { + updateStirlingFileStub: ( + fileId: FileId, + updates: Partial, + ) => void; + bumpRevision: () => void; +} + +/** Workspace stubs to tag with a classification run's labels: the file it ran + * on plus any live descendants, so an edit made during the async run (which + * forks a new leaf) still shows the tags. Empty once the document has left the + * workspace (closed, or a reconciled run with no local input link). */ +export function classificationLabelTargetStubs( + runFileId: string, + stubs: ReadonlyArray, +): StirlingFileStub[] { + return stubs.filter( + (s) => + (s.id as string) === runFileId || + s.parentFileId === runFileId || + s.sourceFileIds?.includes(runFileId as FileId), + ); +} + +/** Attempts to read a completed run's labels before giving up, and the backoff + * between them (delay × attempt). The import effect only re-runs when the run + * store changes, so a transient read failure has to be retried HERE: bailing + * out would leave the run unsettled and the file's "running" pill spinning + * until unrelated policy activity happened to nudge the effect. */ +const LABEL_READ_ATTEMPTS = 3; +const LABEL_READ_RETRY_MS = 2000; + +/** + * Read classification labels out of a completed run's output PDF. A 404 means + * that output aged out, so it's skipped; any other failure is transient and + * retried with backoff. Returns null when there are genuinely no labels to + * apply (including a run with no outputs), so the caller can settle the run. + */ +async function readRunLabels(run: PolicyRunRecord): Promise { + for (let attempt = 0; attempt < LABEL_READ_ATTEMPTS; attempt++) { + if (attempt > 0) await delay(LABEL_READ_RETRY_MS * attempt); + let transientFailure = false; + for (const out of run.outputs) { + try { + const blob = await downloadPolicyOutput(out.fileId, run.target); + const file = new File([blob], out.fileName ?? run.fileName, { + type: blob.type || "application/pdf", + }); + const labels = await readClassificationLabelsFromFile(file); + if (labels && labels.length > 0) return labels; + } catch (err) { + if (!isNotFoundError(err)) transientFailure = true; + } + } + // Every output was read (or had aged out): there are no labels to apply. + if (!transientFailure) return null; + } + // Out of attempts. Settle the run unlabelled rather than spin forever; the + // file keeps its classification badge, just without tags. + return null; +} + +/** + * Stamp `labels` onto the run's live descendants in place (workspace + storage) + * — no versioned child, no history entry, only tags. Returns the tagged ids. + * + * Runs twice, because `resolveTargets` reads a rendered snapshot of the + * workspace: a CONSUME_FILES that was dispatched but not yet rendered when the + * first pass ran leaves its target already gone by the time UPDATE_FILE_RECORD + * is processed, so that stamp no-ops and the labels would be silently lost. The + * second pass sees the forked leaf and tags it. Each id is stamped at most once + * across both passes, so the pass costs nothing when no consume raced. + */ +async function stampClassificationLabels( + labels: string[], + resolveTargets: () => StirlingFileStub[], + ctx: ClassificationImportContext, +): Promise { + const updates = { classificationLabels: labels }; + const tagged = new Set(); + + for (let pass = 0; pass < 2; pass++) { + // Resolve and stamp the store in one synchronous block — no await between + // them, so a target can't be consumed in between. A consume AFTER the stamp + // is safe too: the CONSUME_FILES reducer carries classificationLabels onto + // the new leaf. + const fresh = resolveTargets().filter((s) => !tagged.has(s.id)); + for (const stub of fresh) { + tagged.add(stub.id); + ctx.updateStirlingFileStub(stub.id, updates); + } + + let mutated = false; + for (const stub of fresh) { + if (await fileStorage.updateFileMetadata(stub.id, updates)) + mutated = true; + } + if (mutated) ctx.bumpRevision(); + + // Yield a macrotask so React processes this pass's stamps (and any consume + // that raced them) before the next pass re-resolves. + if (pass === 0) await new Promise((resolve) => setTimeout(resolve)); + } + return Array.from(tagged); +} + +/** + * Deliver a classification run: read its labels and tag the live document with + * them. Metadata-only — nothing is versioned. + */ +async function importClassificationLabels( + run: PolicyRunRecord, + resolveTargets: () => StirlingFileStub[], + ctx: ClassificationImportContext, +): Promise { + if (resolveTargets().length === 0) { + // The document left the workspace (closed, or a server-reconciled run with + // no local input link) — nothing to tag. + updateRun(run.runId, { imported: true }); + return; + } + const labels = await readRunLabels(run); + const targetIds = + labels && labels.length > 0 + ? await stampClassificationLabels(labels, resolveTargets, ctx) + : []; + // Settle either way so it stops re-importing. outputFileIds are the TAGGED + // workspace files (no forked version), so their policy badge persists. Safe + // to chain-key on: classification is always last, so nothing chains off it. + updateRun(run.runId, { + imported: true, + importedFileIds: run.outputs.map((o) => o.fileId), + outputFileIds: targetIds, + }); +} + /** * Fetch a completed run's not-yet-imported output files and deliver them to the * workspace. Per-output, via allSettled: each output is tracked once delivered, @@ -737,6 +908,9 @@ async function runPolicyOnFile( backendId: string, fileId: FileId, fileName: string, + // Chained (downstream) dispatch — jumps the dispatch queue so a file mid-chain + // finishes its flow before new files start (see acquireDispatchSlot). + priority = false, ): Promise { // A freshly-uploaded file's bytes are written to IndexedDB asynchronously, so // its stub can appear in the file list a beat before getStirlingFile resolves @@ -762,6 +936,9 @@ async function runPolicyOnFile( markDispatched(categoryId, fileId); return; } + // Bounded upload window — see MAX_CONCURRENT_DISPATCHES. Only the POST is + // gated; the IDB wait above never holds a slot. + await acquireDispatchSlot(priority); try { const target = resolvePolicyRunTarget(); const runId = await runStoredPolicy(backendId, [file]); @@ -783,6 +960,8 @@ async function runPolicyOnFile( // the absent run simply won't appear in the activity feed. If the backend did // start a run we never recorded, reconcileServerRuns rediscovers it. markDispatched(categoryId, fileId); + } finally { + releaseDispatchSlot(); } } @@ -803,8 +982,10 @@ export async function poll( // would quit while a long step is still legitimately running. let budgetMs = DEFAULT_STEP_COUNT * STEP_TIMEOUT_MS + POLL_GRACE_MS; const startedAt = Date.now(); + let nextDelayMs = FIRST_POLL_MS; while (Date.now() - startedAt < budgetMs) { - await delay(POLL_MS); + await delay(nextDelayMs); + nextDelayMs = POLL_MS; let view; try { view = await getPolicyRun(runId); diff --git a/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx index 45dea92070..048bca15ac 100644 --- a/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx +++ b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx @@ -11,6 +11,7 @@ import { import { ActionIcon } from "@app/ui/ActionIcon"; import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; import CloseIcon from "@mui/icons-material/Close"; +import { policyCategoryIcon } from "@app/components/policies/policyCategoryIcon"; import { useTranslation } from "react-i18next"; interface PolicyEnforcingOverlayProps { @@ -23,6 +24,9 @@ interface PolicyEnforcingOverlayProps { /** CSS colour var of the enforcing policy's accent (e.g. `var(--color-orange)`), * so the icon/spinner match that policy's badge instead of a fixed blue. */ accentVar?: string; + /** Category of the enforcing policy — picks its shared icon (shield for + * security, label for classification, …); generic shield when unknown. */ + categoryId?: string; } /** @@ -35,6 +39,7 @@ export function PolicyEnforcingOverlay({ zIndex = 200, onDismiss, accentVar, + categoryId, }: PolicyEnforcingOverlayProps) { const { t } = useTranslation(); if (!enforcing) return null; @@ -87,7 +92,11 @@ export function PolicyEnforcingOverlay({ : undefined } > - + {categoryId ? ( + policyCategoryIcon(categoryId, { fontSize: 26 }) + ) : ( + + )} {t("policy.enforcingTitle", "Enforcing policy…")} diff --git a/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx b/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx index 75fa9cb952..71d5294f2d 100644 --- a/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx +++ b/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx @@ -71,6 +71,7 @@ export function PolicyEnforcementOverlay({ runs }: Props) { progress={progress} onDismiss={() => setDismissed(true)} accentVar={policyAccentVar(inFlight.categoryId)} + categoryId={inFlight.categoryId} /> ); } diff --git a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx index e1d0cd96ab..1f04ea22ca 100644 --- a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx +++ b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx @@ -8,6 +8,7 @@ import { usePolicyRuns, type PolicyRunRecord, } from "@app/components/policies/policyRunStore"; +import { isClassificationCategory } from "@app/data/policyCategories"; import { PolicyEnforcementOverlay } from "@app/components/viewer/PolicyEnforcementOverlay"; type SignatureOverlayPassThrough = Pick< @@ -29,6 +30,8 @@ const Viewer = (props: ViewerProps & SignatureOverlayPassThrough) => { ? allRuns.filter( (r: PolicyRunRecord) => r.fileId === activeFileId && + // Classification runs async and must never block the viewer. + !isClassificationCategory(r.categoryId) && (POLICY_IN_FLIGHT_STATUSES.includes(r.status) || r.retrying === true), ) : []; diff --git a/frontend/editor/src/proprietary/data/policyCategories.test.ts b/frontend/editor/src/proprietary/data/policyCategories.test.ts new file mode 100644 index 0000000000..4b4fca2b7e --- /dev/null +++ b/frontend/editor/src/proprietary/data/policyCategories.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { + isClassificationCategory, + pinClassificationLast, +} from "@app/data/policyCategories"; + +describe("isClassificationCategory", () => { + it("recognises the classification category and nothing else", () => { + expect(isClassificationCategory("classification")).toBe(true); + expect(isClassificationCategory("security")).toBe(false); + expect(isClassificationCategory("")).toBe(false); + }); +}); + +describe("pinClassificationLast", () => { + it("moves classification to the end, preserving other order", () => { + expect( + pinClassificationLast(["classification", "security", "compliance"]), + ).toEqual(["security", "compliance", "classification"]); + }); + + it("leaves an order without classification untouched", () => { + expect(pinClassificationLast(["security", "compliance"])).toEqual([ + "security", + "compliance", + ]); + }); + + it("is a no-op when classification is already last", () => { + expect(pinClassificationLast(["security", "classification"])).toEqual([ + "security", + "classification", + ]); + }); + + it("handles classification as the only policy", () => { + expect(pinClassificationLast(["classification"])).toEqual([ + "classification", + ]); + }); +}); diff --git a/frontend/editor/src/proprietary/data/policyCategories.ts b/frontend/editor/src/proprietary/data/policyCategories.ts new file mode 100644 index 0000000000..820a93366b --- /dev/null +++ b/frontend/editor/src/proprietary/data/policyCategories.ts @@ -0,0 +1,21 @@ +/** The classification policy's catalog category id. */ +export const CLASSIFICATION_CATEGORY_ID = "classification"; + +/** + * Classification is metadata-only: it runs async (never blocks), never forks a + * version, and always runs last. This predicate gates that special handling. + */ +export function isClassificationCategory(categoryId: string): boolean { + return categoryId === CLASSIFICATION_CATEGORY_ID; +} + +/** + * Move classification to the end of an execution order (others keep their order), + * so a persisted/displayed order can't place it anywhere but last. + */ +export function pinClassificationLast(orderedCategoryIds: string[]): string[] { + return [ + ...orderedCategoryIds.filter((id) => !isClassificationCategory(id)), + ...orderedCategoryIds.filter((id) => isClassificationCategory(id)), + ]; +} diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 8043e6b66a..389f98cd65 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -35,6 +35,7 @@ import { removePolicy, } from "@app/services/policyBackend"; import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi"; +import { pinClassificationLast } from "@app/data/policyCategories"; import type { PolicyToStore } from "@app/services/policyPipeline"; import type { PoliciesByCategory, @@ -326,9 +327,12 @@ export function usePolicies() { * first for an instant re-render; the next reconcile re-reads the server order. */ const reorderPolicies = useCallback((orderedCategoryIds: string[]) => { - persistPolicyOrder(orderedCategoryIds); + // Pin classification last so the persisted/server order matches execution + // (it always runs last — see usePolicyAutoRun). + const ordered = pinClassificationLast(orderedCategoryIds); + persistPolicyOrder(ordered); const current = loadPolicies(); - const backendIds = orderedCategoryIds + const backendIds = ordered .map((categoryId) => current[categoryId]?.backendId) .filter((id): id is string => !!id); if (backendIds.length > 0) { diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts index c5d8b9952b..2364321213 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect } from "vitest"; -import { buildPolicyBadgeMap } from "@app/hooks/usePolicyFileBadges"; +import { + buildPolicyBadgeMap, + reusePolicyBadgeArrays, +} from "@app/hooks/usePolicyFileBadges"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; -const NOW = 1_000_000; const labels = new Map([ ["security", "Security"], ["watermark", "Watermark"], + ["classification", "Classification"], ]); function run(overrides: Partial): PolicyRunRecord { @@ -20,29 +23,24 @@ function run(overrides: Partial): PolicyRunRecord { outputs: [], outputFileIds: ["out"], error: null, - startedAt: NOW - 1_000, // recent by default + startedAt: 0, ...overrides, }; } describe("buildPolicyBadgeMap — badge follows the document onto derived files", () => { - it("badges a policy's direct output, and marks it recent within the window", () => { - const map = buildPolicyBadgeMap([run({})], [{ id: "out" }], labels, NOW); - const badges = map.get("out") ?? []; - expect(badges.map((b) => b.id)).toEqual(["security"]); - expect(badges[0].recent).toBe(true); + it("badges a policy's direct output", () => { + const map = buildPolicyBadgeMap([run({})], [{ id: "out" }], labels); + expect((map.get("out") ?? []).map((b) => b.id)).toEqual(["security"]); }); - it("a versioned edit inherits the badge via parentFileId (never glows)", () => { + it("a versioned edit inherits the badge via parentFileId", () => { const map = buildPolicyBadgeMap( [run({})], [{ id: "out" }, { id: "edit", parentFileId: "out" }], labels, - NOW, ); - const edit = map.get("edit") ?? []; - expect(edit.map((b) => b.id)).toEqual(["security"]); - expect(edit[0].recent).toBe(false); + expect((map.get("edit") ?? []).map((b) => b.id)).toEqual(["security"]); }); it("SPLIT parts inherit the badge via sourceFileIds, though they have no parent", () => { @@ -56,11 +54,9 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" { id: "part2", sourceFileIds: ["out"] }, ], labels, - NOW, ); expect((map.get("part1") ?? []).map((b) => b.id)).toEqual(["security"]); expect((map.get("part2") ?? []).map((b) => b.id)).toEqual(["security"]); - expect((map.get("part1") ?? [])[0].recent).toBe(false); }); it("resolves transitively when an intermediate edit was consumed/removed", () => { @@ -70,7 +66,6 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" [run({})], [{ id: "part", sourceFileIds: ["editGone", "out"] }], labels, - NOW, ); expect((map.get("part") ?? []).map((b) => b.id)).toEqual(["security"]); }); @@ -83,7 +78,6 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" ], [{ id: "merged", sourceFileIds: ["a", "b"] }], labels, - NOW, ); expect((map.get("merged") ?? []).map((b) => b.id).sort()).toEqual([ "security", @@ -96,24 +90,33 @@ describe("buildPolicyBadgeMap — badge follows the document onto derived files" [run({})], [{ id: "out" }, { id: "unrelated", sourceFileIds: ["someUpload"] }], labels, - NOW, ); expect(map.has("unrelated")).toBe(false); }); - it("inherited badges never glow even when the source run is recent", () => { + it("a completed classification run badges the files it tagged", () => { + // Classification is metadata-only: its outputFileIds are the tagged + // workspace files (no forked version), so the label badge persists there. const map = buildPolicyBadgeMap( - [run({ startedAt: NOW })], // maximally recent - [{ id: "out" }, { id: "part", sourceFileIds: ["out"] }], + [ + run({ + categoryId: "classification", + fileId: "in", + outputFileIds: ["in"], + imported: true, + }), + ], + [{ id: "in" }], labels, - NOW, ); - expect((map.get("out") ?? [])[0].recent).toBe(true); - expect((map.get("part") ?? [])[0].recent).toBe(false); + const badges = map.get("in") ?? []; + expect(badges.map((b) => b.id)).toEqual(["classification"]); + expect(badges[0].enforcing).toBeUndefined(); + expect(badges[0].background).toBeUndefined(); }); }); -describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", () => { +describe("buildPolicyBadgeMap — in-flight indicators", () => { const enforcingOn = ( map: Map, id: string, @@ -124,7 +127,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "RUNNING", outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(true); }); @@ -136,7 +138,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "COMPLETED" })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(before, "in")).toBe(true); @@ -144,7 +145,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "COMPLETED", imported: true })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(after, "in")).toBe(false); }); @@ -155,7 +155,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status, outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(false); } @@ -166,7 +165,6 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "FAILED", retrying: true, outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(true); }); @@ -176,8 +174,97 @@ describe("buildPolicyBadgeMap — enforcing spinner while a run is in flight", ( [run({ status: "RUNNING", fileId: "", outputFileIds: [] })], [{ id: "in" }], labels, - NOW, ); expect(enforcingOn(map, "in")).toBe(false); }); + + it("an in-flight classification run is background, never enforcing", () => { + // Non-blocking: shows a spinner but must never trip the enforcing flag + // that gates actions and overlays. + const map = buildPolicyBadgeMap( + [ + run({ + categoryId: "classification", + status: "RUNNING", + outputFileIds: [], + }), + ], + [{ id: "in" }], + labels, + ); + const badges = map.get("in") ?? []; + expect(badges.map((b) => b.id)).toEqual(["classification"]); + expect(badges[0].background).toBe(true); + expect(enforcingOn(map, "in")).toBe(false); + }); +}); + +describe("reusePolicyBadgeArrays — per-file identity across rebuilds", () => { + // buildPolicyBadgeMap allocates fresh arrays every call and the run store hands + // back a new `runs` array on every status poll, so without this the memoized + // sidebar rows get a new `policies` prop for EVERY badged file on each tick. + const build = (runs: PolicyRunRecord[], stubs: { id: string }[]) => + buildPolicyBadgeMap(runs, stubs, labels); + + const twoFiles = [{ id: "a" }, { id: "b" }]; + // Settled + imported, so the badge is a plain one (a COMPLETED run keeps + // `enforcing` until its outputs land — see the in-flight tests above). + const settled = (id: string) => + run({ + runId: `r${id}`, + fileId: id, + outputFileIds: [id], + status: "COMPLETED", + imported: true, + }); + const bothSettled = () => [settled("a"), settled("b")]; + + it("returns the same map when nothing changed", () => { + const first = build(bothSettled(), twoFiles); + const second = reusePolicyBadgeArrays( + first, + build(bothSettled(), twoFiles), + ); + expect(second).toBe(first); + }); + + it("keeps the untouched file's array identity when another file changes", () => { + const first = build(bothSettled(), twoFiles); + // "a" goes in-flight; "b" is unaffected and must keep its exact array. + const next = build( + [ + run({ + runId: "ra", + fileId: "a", + outputFileIds: ["a"], + status: "RUNNING", + }), + settled("b"), + ], + twoFiles, + ); + const second = reusePolicyBadgeArrays(first, next); + expect(second).not.toBe(first); + expect(second.get("b")).toBe(first.get("b")); + expect(second.get("a")).not.toBe(first.get("a")); + expect((second.get("a") ?? [])[0].enforcing).toBe(true); + expect((first.get("a") ?? [])[0].enforcing).toBeUndefined(); + }); + + it("a new badged file doesn't disturb the existing files' arrays", () => { + const first = build(bothSettled(), twoFiles); + const next = build( + [...bothSettled(), settled("c")], + [...twoFiles, { id: "c" }], + ); + const second = reusePolicyBadgeArrays(first, next); + expect(second.get("a")).toBe(first.get("a")); + expect(second.get("b")).toBe(first.get("b")); + expect((second.get("c") ?? []).map((b) => b.id)).toEqual(["security"]); + }); + + it("passes the fresh map straight through on the first build", () => { + const map = build(bothSettled(), twoFiles); + expect(reusePolicyBadgeArrays(null, map)).toBe(map); + }); }); diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index 40fcd0399e..52779b9cf8 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -1,17 +1,12 @@ -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; import { usePolicyRuns } from "@app/components/policies/policyRunStore"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; import { useAllFiles } from "@app/contexts/FileContext"; import { loadPolicyCatalog } from "@app/services/policyCatalog"; import { policyAccentVar } from "@app/components/policies/policyStatus"; +import { isClassificationCategory } from "@app/data/policyCategories"; import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; -/** How long after a run a badge counts as "recent" (drives the one-off glow). - * Measured from run start — must exceed the longest realistic policy wall-clock - * time so the glow still fires after a slow run completes and imports. Old or - * reloaded runs fall outside this window, suppressing the glow on page reload. */ -const RECENT_MS = 5 * 60 * 1000; - /** Minimal provenance shape needed to resolve a file's inherited badges. */ type LineageStub = { id: string; @@ -19,14 +14,10 @@ type LineageStub = { sourceFileIds?: string[]; }; -/** Merge a ref into a list, deduping by policy id. A direct (recent) hit wins - * the glow over an inherited one for the same policy. */ +/** Merge a ref into a list, deduping by policy id. */ function mergeRef(list: FileItemPolicyRef[], ref: FileItemPolicyRef): void { - const existing = list.find((p) => p.id === ref.id); - if (!existing) { + if (!list.some((p) => p.id === ref.id)) { list.push(ref); - } else if (ref.recent) { - existing.recent = true; } } @@ -42,21 +33,18 @@ function mergeRef(list: FileItemPolicyRef[], ref: FileItemPolicyRef): void { * from: its transitive `sourceFileIds` (recorded at the consume boundary, so it * covers split/merge/convert too) plus, defensively, its `parentFileId`. * Because `sourceFileIds` is transitive, a flat lookup suffices — no chain walk, - * and it survives a consumed intermediate. Inherited badges never glow - * (recent=false): only the original application does. + * and it survives a consumed intermediate. */ export function buildPolicyBadgeMap( runs: ReadonlyArray, stubs: ReadonlyArray, labelById: ReadonlyMap, - now: number, ): Map { // Direct badges: a file that IS a policy run's output. const directByFile = new Map(); for (const run of runs) { const name = labelById.get(run.categoryId); if (!name) continue; - const recent = now - run.startedAt < RECENT_MS; for (const fileId of run.outputFileIds ?? []) { const list = directByFile.get(fileId) ?? []; if (!list.some((p) => p.id === run.categoryId)) { @@ -64,7 +52,6 @@ export function buildPolicyBadgeMap( id: run.categoryId, name, accentColor: policyAccentVar(run.categoryId), - recent, }); directByFile.set(fileId, list); } @@ -86,7 +73,7 @@ export function buildPolicyBadgeMap( // from. `sourceFileIds` is the transitive provenance set (so a flat lookup // catches even ancestors whose intermediate edits were consumed), and // `parentFileId` is included defensively for any child not created via a - // consume. Inherited badges are marked recent=false (carried, not applied). + // consume. for (const stub of stubs) { const sources = new Set(stub.sourceFileIds ?? []); if (stub.parentFileId) sources.add(stub.parentFileId); @@ -94,14 +81,17 @@ export function buildPolicyBadgeMap( const srcBadges = directByFile.get(src); if (!srcBadges?.length) continue; const list = result.get(stub.id) ?? []; - for (const ref of srcBadges) mergeRef(list, { ...ref, recent: false }); + for (const ref of srcBadges) mergeRef(list, { ...ref }); result.set(stub.id, list); } } // In-flight pass: add (or upgrade) a badge on the input file for any run that // is currently being processed, so the sidebar shows a spinning indicator - // while the policy is actively enforcing — not just after it completes. + // while the policy is actively running — not just after it completes. + // Blocking policies set `enforcing` (which gates actions/overlays); + // classification is non-blocking, so it sets `background` instead — same + // spinner, but nothing is ever gated on it. // Keep the spinner until `imported` is true: the status reaches COMPLETED // before the output files are imported into the workspace, so gating on // status alone would drop the badge during that async gap. @@ -112,17 +102,19 @@ export function buildPolicyBadgeMap( if (settled && !run.retrying) continue; const name = labelById.get(run.categoryId); if (!name) continue; + const inFlightFlag = isClassificationCategory(run.categoryId) + ? ("background" as const) + : ("enforcing" as const); const list = result.get(run.fileId) ?? []; const existing = list.find((p) => p.id === run.categoryId); if (existing) { - existing.enforcing = true; + existing[inFlightFlag] = true; } else { list.push({ id: run.categoryId, name, accentColor: policyAccentVar(run.categoryId), - recent: false, - enforcing: true, + [inFlightFlag]: true, }); result.set(run.fileId, list); } @@ -131,20 +123,70 @@ export function buildPolicyBadgeMap( return result; } +/** Field-wise equality for a badge ref — the whole shape `PolicyBadges` renders. */ +function sameRef(a: FileItemPolicyRef, b: FileItemPolicyRef): boolean { + return ( + a.id === b.id && + a.name === b.name && + a.accentColor === b.accentColor && + !!a.enforcing === !!b.enforcing && + !!a.background === !!b.background + ); +} + +function sameRefs(a: FileItemPolicyRef[], b: FileItemPolicyRef[]): boolean { + return a.length === b.length && a.every((ref, i) => sameRef(ref, b[i])); +} + +/** + * Carry the previous map's array references over to files whose badges didn't + * change, and return the previous MAP itself when none did. + * + * {@link buildPolicyBadgeMap} allocates a fresh array per badged file on every + * call, and the run store hands back a new `runs` array on every status poll — + * so without this, one file's poll tick gives EVERY badged file a new `policies` + * identity, and the memoized sidebar rows can never bail out (the case the + * memoization exists for). `NO_POLICIES` in FileSidebar only covers the rows + * with no badges at all. + */ +export function reusePolicyBadgeArrays( + previous: Map | null, + next: Map, +): Map { + if (!previous) return next; + let changed = previous.size !== next.size; + for (const [fileId, refs] of next) { + const before = previous.get(fileId); + if (before && sameRefs(before, refs)) next.set(fileId, before); + else changed = true; + } + return changed ? next : previous; +} + /** * Distinct policies that have produced each file, keyed by fileId, derived from * the reactive policy run store. Drives the file sidebar's shield badges. The * badge follows a document down its tool-edit chain — see * {@link buildPolicyBadgeMap}. Shadows the core stub via the {@code @app/*} * alias cascade. + * + * Per-file array identity is preserved across rebuilds so memoized consumers + * (the sidebar rows) only re-render for the file that actually changed — see + * {@link reusePolicyBadgeArrays}. */ export function usePolicyFileBadges(): Map { const runs = usePolicyRuns(); const { fileStubs } = useAllFiles(); + const previous = useRef | null>(null); return useMemo(() => { const labelById = new Map( loadPolicyCatalog().categories.map((c) => [c.id, c.label]), ); - return buildPolicyBadgeMap(runs, fileStubs, labelById, Date.now()); + const map = reusePolicyBadgeArrays( + previous.current, + buildPolicyBadgeMap(runs, fileStubs, labelById), + ); + previous.current = map; + return map; }, [runs, fileStubs]); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 970d6703cd..7db904ddf6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -84,6 +84,7 @@ "signature_pad": "^5.0.4", "smol-toml": "^1.4.2", "tailwindcss": "^4.1.13", + "use-sync-external-store": "^1.6.0", "web-vitals": "^5.1.0" }, "devDependencies": { @@ -109,6 +110,7 @@ "@types/node": "^24.5.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.1.9", + "@types/use-sync-external-store": "^0.0.6", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/frontend/package.json b/frontend/package.json index 1763a2126a..dfcfa2842c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -81,6 +81,7 @@ "signature_pad": "^5.0.4", "smol-toml": "^1.4.2", "tailwindcss": "^4.1.13", + "use-sync-external-store": "^1.6.0", "web-vitals": "^5.1.0" }, "scripts": { @@ -131,6 +132,7 @@ "@types/node": "^24.5.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.1.9", + "@types/use-sync-external-store": "^0.0.6", "@typescript-eslint/eslint-plugin": "^8.65.0", "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", From a2dd0298dc7931c1e7f202343a75937a4edd889a Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:04 +0200 Subject: [PATCH 06/99] refactor(api): replace length checks with isEmpty (#7214) # Description of Changes Stylistic problem reported by static analyzer. Changes: * Replaced `sb.length() > 0` and `sb.length() == 0` with `!sb.isEmpty()` and `sb.isEmpty()` for `StringBuilder`, `String`, and collections throughout the codebase, improving readability and aligning with modern Java best practices. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../common/pdf/PdfMarkdownConverter.java | 2 +- .../software/common/util/GeneralUtils.java | 2 +- .../common/pdf/PdfMarkdownConverterTest.java | 2 +- .../SPDF/config/ExternalAppDepConfig.java | 2 +- .../SPDF/controller/api/UIDataController.java | 2 +- .../api/security/PasswordController.java | 8 ++++---- .../api/security/RedactExecuteService.java | 18 +++++++++--------- .../SPDF/controller/web/MetricsController.java | 6 +++--- .../SPDF/service/HardwareKeyStoreService.java | 4 ++-- .../controller/api/UserController.java | 2 +- .../security/service/UserService.java | 2 +- .../service/PortalInfraAuditService.java | 2 +- .../service/UserLicenseSettingsService.java | 4 ++-- 13 files changed, 28 insertions(+), 28 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java b/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java index c19468b5ed..73f2d7f5ad 100644 --- a/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java +++ b/app/common/src/main/java/stirling/software/common/pdf/PdfMarkdownConverter.java @@ -983,7 +983,7 @@ public class PdfMarkdownConverter { ordered.sort(Comparator.comparingDouble((Line l) -> l.y).reversed()); StringBuilder sb = new StringBuilder(); for (Line l : ordered) { - if (sb.length() > 0) { + if (!sb.isEmpty()) { sb.append(' '); } sb.append(l.text); diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index 4a9ef0834b..52f79f733a 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -941,7 +941,7 @@ public class GeneralUtils { } // If no MAC address found, use hostname as fallback - if (sb.length() == 0) { + if (sb.isEmpty()) { String hostname = InetAddress.getLocalHost().getHostName(); sb.append(hostname != null ? hostname : "unknown-host"); log.warn("No MAC address found, using hostname for fingerprint generation"); diff --git a/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java b/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java index b3c104da85..7e1d3d2e35 100644 --- a/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java +++ b/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterTest.java @@ -154,7 +154,7 @@ class PdfMarkdownConverterTest { || isTableSeparatorRow(line)) { continue; } - if (sb.length() > 0) { + if (!sb.isEmpty()) { sb.append('\n'); } sb.append(line); diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index 8755dfe2ef..46b1976ca7 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -321,7 +321,7 @@ public class ExternalAppDepConfig { new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { String line; while ((line = br.readLine()) != null) { - if (sb.length() > 0) sb.append('\n'); + if (!sb.isEmpty()) sb.append('\n'); sb.append(line); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index de391c7c32..a3ed09fe5d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -130,7 +130,7 @@ public class UIDataController { objectMapper.readValue( config, new TypeReference>() {}); String name = (String) jsonContent.get("name"); - if (name == null || name.length() < 1) { + if (name == null || name.isEmpty()) { String filename = jsonFiles .get(pipelineConfigs.indexOf(config)) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java index 690c82ca8f..2ad494fc63 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/PasswordController.java @@ -124,15 +124,15 @@ public class PasswordController { StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPassword, password, ap); - if ((ownerPassword != null && ownerPassword.length() > 0) - || (password != null && password.length() > 0)) { + if ((ownerPassword != null && !ownerPassword.isEmpty()) + || (password != null && !password.isEmpty())) { spp.setEncryptionKeyLength(keyLength); } spp.setPermissions(ap); document.protect(spp); - if ((ownerPassword == null || ownerPassword.length() == 0) - && (password == null || password.length() == 0)) + if ((ownerPassword == null || ownerPassword.isEmpty()) + && (password == null || password.isEmpty())) return WebResponseUtils.pdfDocToWebResponse( document, GeneralUtils.generateFilename( diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java index 4a53be97b6..c43abcf666 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/RedactExecuteService.java @@ -760,12 +760,12 @@ class RedactExecuteService { char ch = raw.charAt(i); if (Character.isLetterOrDigit(ch)) { current.append(ch); - } else if (current.length() > 0) { + } else if (!current.isEmpty()) { tokens.add(current.toString()); current.setLength(0); } } - if (current.length() > 0) tokens.add(current.toString()); + if (!current.isEmpty()) tokens.add(current.toString()); if (tokens.size() < 2) return null; StringBuilder out = new StringBuilder(); for (int i = 0; i < tokens.size(); i++) { @@ -788,25 +788,25 @@ class RedactExecuteService { StringBuilder current = new StringBuilder(); for (String token : tokens) { if (token.isEmpty()) { - if (current.length() > 0) { - if (result.length() > 0) result.append(' '); + if (!current.isEmpty()) { + if (!result.isEmpty()) result.append(' '); result.append(current); current.setLength(0); } } else if (token.length() == 1) { current.append(token); } else { - if (current.length() > 0) { - if (result.length() > 0) result.append(' '); + if (!current.isEmpty()) { + if (!result.isEmpty()) result.append(' '); result.append(current); current.setLength(0); } - if (result.length() > 0) result.append(' '); + if (!result.isEmpty()) result.append(' '); result.append(token); } } - if (current.length() > 0) { - if (result.length() > 0) result.append(' '); + if (!current.isEmpty()) { + if (!result.isEmpty()) result.append(' '); result.append(current); } return result.toString().trim(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index de28d66ca9..4bbf3dfb82 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -251,7 +251,7 @@ public class MetricsController { // For GET requests, validate if we have a list of valid endpoints final boolean validateGetEndpoints = - endpointInspector.getValidGetEndpoints().size() != 0; + !endpointInspector.getValidGetEndpoints().isEmpty(); if ("GET".equals(method) && validateGetEndpoints && !endpointInspector.isValidGetEndpoint(uri)) { @@ -292,7 +292,7 @@ public class MetricsController { // For GET requests, validate if we have a list of valid endpoints final boolean validateGetEndpoints = - endpointInspector.getValidGetEndpoints().size() != 0; + !endpointInspector.getValidGetEndpoints().isEmpty(); if ("GET".equals(method) && validateGetEndpoints && !endpointInspector.isValidGetEndpoint(uri)) { @@ -332,7 +332,7 @@ public class MetricsController { // For GET requests, validate if we have a list of valid endpoints final boolean validateGetEndpoints = - endpointInspector.getValidGetEndpoints().size() != 0; + !endpointInspector.getValidGetEndpoints().isEmpty(); if ("GET".equals(method) && validateGetEndpoints && !endpointInspector.isValidGetEndpoint(uri)) { diff --git a/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java b/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java index 988b935f27..93ca36d08e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/HardwareKeyStoreService.java @@ -237,12 +237,12 @@ public class HardwareKeyStoreService { combined.append(env); } if (prop != null && !prop.isBlank()) { - if (combined.length() > 0) { + if (!combined.isEmpty()) { combined.append(java.io.File.pathSeparator); } combined.append(prop); } - if (combined.length() == 0) { + if (combined.isEmpty()) { return List.of(); } return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]")) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index b19c052ff1..fdacda72b2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -199,7 +199,7 @@ public class UserController { return ResponseEntity.status(HttpStatus.CONFLICT) .body(Map.of("error", "usernameExists", "message", "Username already exists")); } - if (newUsername != null && newUsername.length() > 0) { + if (newUsername != null && !newUsername.isEmpty()) { try { userService.changeUsername(user, newUsername); } catch (IllegalArgumentException e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 0cb4653ef1..e08dd52d07 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -205,7 +205,7 @@ public class UserService implements UserServiceInterface { User user = findByUsernameIgnoreCase(username) .orElseThrow(() -> new UsernameNotFoundException("User not found")); - if (user.getApiKey() == null || user.getApiKey().length() == 0) { + if (user.getApiKey() == null || user.getApiKey().isEmpty()) { user = addApiKeyToUser(username); } return user.getApiKey(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java index c47df3649f..86f944ca9d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/PortalInfraAuditService.java @@ -224,7 +224,7 @@ public class PortalInfraAuditService { if (word.isEmpty()) { continue; } - if (sb.length() > 0) { + if (!sb.isEmpty()) { sb.append(' '); } String lower = word.toLowerCase(Locale.ROOT); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index 54660a1ccb..085a9ffcf1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -515,7 +515,7 @@ public class UserLicenseSettingsService { appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getUUID()); appendIfPresent(builder, applicationProperties.getPremium().getKey()); - if (builder.length() == 0) { + if (builder.isEmpty()) { builder.append(DEFAULT_INTEGRITY_SECRET); } @@ -524,7 +524,7 @@ public class UserLicenseSettingsService { private void appendIfPresent(StringBuilder builder, String value) { if (value != null && !value.isBlank()) { - if (builder.length() > 0) { + if (!builder.isEmpty()) { builder.append(SIGNATURE_SEPARATOR); } builder.append(value); From e5c6ceedc52a3aee263ffb20ca2ff4813ca9799c Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:25 +0200 Subject: [PATCH 07/99] fix(ui): resolve double scrollbar issue in Sidebar Categories modal (#7142) # Description of Changes Resolves double scrollbar design bug. ### New image ### Old image --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../proprietary/components/shared/FileSidebarGroupControls.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css b/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css index 16712e351a..0889dfe3d1 100644 --- a/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css +++ b/frontend/editor/src/proprietary/components/shared/FileSidebarGroupControls.css @@ -3,9 +3,6 @@ display: flex; flex-direction: column; gap: 12px; - max-height: 60vh; - overflow-y: auto; - padding-right: 4px; } .fsg-footer { From 732a6025038edfa6250b11b32ce100f9116d5273 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:43 +0200 Subject: [PATCH 08/99] style(scanner-effect): remove padding from ToolButton of Scanner-effect (#7205) # Description of Changes ### New image ### Old image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../editor/src/core/components/tools/toolPicker/ToolButton.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx index 78c573bb38..4792fd721a 100644 --- a/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/editor/src/core/components/tools/toolPicker/ToolButton.tsx @@ -286,7 +286,7 @@ const ToolButton: React.FC = ({ accent="neutral" onClick={() => handleClick(id)} size="sm" - p="sm" + p="none" fullWidth justify="start" className="tool-button" @@ -297,6 +297,7 @@ const ToolButton: React.FC = ({ borderRadius: 0, cursor: visuallyUnavailable ? "not-allowed" : undefined, overflow: "visible", + ...selectedBg, }} > {buttonContent} From cc1c6bc9e37ecc4904b74a489fa6f0d4a6f98d4e Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:01:46 +0200 Subject: [PATCH 09/99] style(people): Fix convert dropdown single-category formatting and admin table header styling (#7139) # Description of Changes The dropdown table with the people looked out-place mainly due to the blue, i think... this simplifies and make more consistent with the rest of the "new" UI and not so aggresive with the colour schema. ### New: image ### Old: image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../config/configSections/PeopleSection.tsx | 24 ++++--------------- .../configSections/TeamDetailsSection.tsx | 24 ++++--------------- .../config/configSections/TeamsSection.tsx | 9 +------ 3 files changed, 9 insertions(+), 48 deletions(-) diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 10e9f39fb0..d955cb341b 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -588,38 +588,22 @@ export default function PeopleSection() { {/* Members Table */} - +
    - - + + {t("workspace.people.user")} {t("workspace.people.role")} - + {t("workspace.people.team")} 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 cabdfb642c..d3702e7f80 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx @@ -408,29 +408,13 @@ export default function TeamDetailsSection({ {/* Members Table */} -
    +
    - - + + {t("workspace.people.user")} - + {t("workspace.people.role")} 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 4559296afe..d4da200d59 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.tsx @@ -298,19 +298,13 @@ export default function TeamsSection() { verticalSpacing="sm" withRowBorders highlightOnHover - style={ - { - "--table-border-color": "var(--mantine-color-gray-3)", - } as React.CSSProperties - } > - + {t("workspace.teams.teamName")} @@ -319,7 +313,6 @@ export default function TeamsSection() { style={{ fontWeight: 600, fontSize: "0.875rem", - color: "var(--mantine-color-gray-7)", }} > {t("workspace.teams.totalMembers")} From 9aaf4173036f4ebca74c8ea8379e4362a17bd969 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:11:26 +0200 Subject: [PATCH 10/99] perf(ui): lazy-load MobileScannerPage and optimize bundle splitting (#7122) # Description of Changes This pull request improves the frontend's performance and code organization by optimizing how certain pages are loaded and by updating the application's bundle splitting strategy. Changes: * Updated the `manualChunks` configuration in `vite.config.ts` to more granularly split vendor dependencies into separate chunks based on their library or usage, which can improve caching and load performance. * Updated MobileScanner code to be lazy loaded --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- frontend/editor/src/core/App.tsx | 5 ++-- frontend/editor/src/proprietary/App.tsx | 5 ++-- frontend/editor/src/saas/App.tsx | 5 ++-- frontend/editor/vite.config.ts | 33 ++++++++++++++++++++++--- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 566e861520..6fd23e5510 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, lazy } from "react"; import { Routes, Route } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; import { AppLayout } from "@app/components/AppLayout"; @@ -6,9 +6,10 @@ import { LoadingFallback } from "@app/components/shared/LoadingFallback"; import { ThemeProvider } from "@app/components/shared/ThemeProvider"; import { PreferencesProvider } from "@app/contexts/PreferencesContext"; import HomePage from "@app/pages/HomePage"; -import MobileScannerPage from "@app/pages/MobileScannerPage"; import Onboarding from "@app/components/onboarding/Onboarding"; +const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); + // Import global styles import "@app/styles/tailwind.css"; import "@app/styles/cookieconsent.css"; diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index e98064ebc8..a2103bd60a 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, lazy } from "react"; import { Routes, Route, useParams } from "react-router-dom"; import { AppProviders } from "@app/components/AppProviders"; import { AppLayout } from "@app/components/AppLayout"; @@ -12,9 +12,10 @@ import AuthCallback from "@app/routes/AuthCallback"; import InviteAccept from "@app/routes/InviteAccept"; import ShareLinkPage from "@app/routes/ShareLinkPage"; import ParticipantView from "@app/components/workflow/ParticipantView"; -import MobileScannerPage from "@app/pages/MobileScannerPage"; import Onboarding from "@app/components/onboarding/Onboarding"; import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration"; + +const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 32af9a6782..9509f630b0 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -1,4 +1,4 @@ -import { Suspense, type ReactNode } from "react"; +import { Suspense, lazy, type ReactNode } from "react"; import { Routes, Route, useLocation } from "react-router-dom"; import { isAuthRoute } from "@app/utils/pathUtils"; import { AppProviders } from "@app/components/AppProviders"; @@ -16,13 +16,14 @@ import AuthCallback from "@app/routes/AuthCallback"; import ResetPassword from "@app/routes/ResetPassword"; import OAuthConsent from "@app/routes/OAuthConsent"; import ShareLinkPage from "@app/routes/ShareLinkPage"; -import MobileScannerPage from "@app/pages/MobileScannerPage"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; +const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); + // Import global styles import "@app/styles/tailwind.css"; import "@app/auth/ui/auth-theme.css"; diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 0d367d2465..2e72d1efec 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -358,9 +358,36 @@ export default defineConfig(async ({ mode, command }) => { target: "esnext", rollupOptions: { output: { - manualChunks: { - "vendor-react": ["react", "react-dom"], - "pdf-engine": ["@embedpdf/engines", "@embedpdf/pdfium"], + manualChunks(id) { + if (id.includes("material-symbols-icons.json")) + return "vendor-iconset"; + if (id.includes("node_modules")) { + if (id.includes("pdfjs-dist")) return "vendor-pdfjs"; + if (id.includes("@embedpdf")) return "vendor-embedpdf"; + if ( + id.includes("react") || + id.includes("@mantine") || + id.includes("@emotion") || + id.includes("@mui") || + id.includes("@iconify") + ) { + return "vendor-ui"; + } + if (id.includes("@supabase")) return "vendor-supabase"; + if (id.includes("posthog-js") || id.includes("@posthog")) + return "vendor-posthog"; + if (id.includes("@cantoo/pdf-lib") || id.includes("pdf-lib")) + return "vendor-pdflib"; + if ( + id.includes("recharts") || + id.includes("d3") || + id.includes("decimal.js") + ) + return "vendor-charts"; + if (id.includes("jszip") || id.includes("pako")) + return "vendor-zip"; + if (id.includes("i18next")) return "vendor-i18n"; + } }, }, }, From 934ad180cb9cf87aa2709c1b40f4e5ca4ee2f223 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:16:15 +0200 Subject: [PATCH 11/99] fix(ui): handle 404 policy error on upload without toast popup (#7254) # Description of Changes Fixes policy error pop-ups that sometimes happen upon upload. Changes: - Improved the error handling in `runPolicyOnFile` to log detailed debug information when policy dispatch fails, making it easier to trace issues such as missing policies or backend errors. - Updated the `runStoredPolicy` function to pass `{ suppressErrorToast: true }` to the API client, preventing error toasts from appearing in the UI when the policy run fails. --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../proprietary/components/policies/usePolicyAutoRun.ts | 8 ++++++-- frontend/editor/src/proprietary/services/policyApi.ts | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index e475d784ea..597136a32f 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -955,10 +955,14 @@ async function runPolicyOnFile( error: null, startedAt: Date.now(), }); - } catch { - // Dispatch failed (offline / backend error). Mark dispatched so we don't hammer; + } catch (err) { + // Dispatch failed (e.g. policy deleted/404 or backend offline). Mark dispatched so we don't hammer; // the absent run simply won't appear in the activity feed. If the backend did // start a run we never recorded, reconcileServerRuns rediscovers it. + console.debug( + `[PolicyAutoRun] Failed to dispatch policy ${categoryId} (${backendId}):`, + err, + ); markDispatched(categoryId, fileId); } finally { releaseDispatchSlot(); diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts index a10c7a8382..b0d78f36f7 100644 --- a/frontend/editor/src/proprietary/services/policyApi.ts +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -73,6 +73,7 @@ export async function runStoredPolicy( const res = await apiClient.post( `/api/v1/policies/${encodeURIComponent(id)}/run`, form, + { suppressErrorToast: true }, ); return res.data.jobId; } From 7cccee4c346c5bf10058bb7e570c23f7374bb9f5 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:17:27 +0200 Subject: [PATCH 12/99] refactor(get-info): remove redundant PDF validation logic (#7213) # Description of Changes Could not get past validation, since very few endpoint have such validation, i think redundant. Changes: * Removed the `validatePdfFile` method, which previously checked for file presence, size limits, and content type, from `GetInfoOnPDF.java`. * Deleted the invocation of `validatePdfFile` and its associated error handling from the `getPdfInfo` method, so uploaded files are no longer validated at this layer. --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../controller/api/security/GetInfoOnPDF.java | 28 ------- .../api/security/GetInfoOnPDFMoreTest.java | 15 ---- .../api/security/GetInfoOnPDFTest.java | 76 ------------------- 3 files changed, 119 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java index d29480fd3c..d2775e910b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java @@ -61,7 +61,6 @@ import stirling.software.common.model.api.PDFFile; import stirling.software.common.model.tool.ToolFormat; import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; -import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.WebResponseUtils; @@ -270,25 +269,6 @@ public class GetInfoOnPDF { } } - private static void validatePdfFile(MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new IllegalArgumentException("PDF file is required"); - } - - if (file.getSize() > MAX_FILE_SIZE) { - throw ExceptionUtils.createIllegalArgumentException( - "error.fileSizeLimit", - "File size ({0} bytes) exceeds maximum allowed size ({1} bytes)", - file.getSize(), - MAX_FILE_SIZE); - } - - String contentType = file.getContentType(); - if (contentType != null && !"application/pdf".equals(contentType)) { - log.warn("File content type is {}, expected application/pdf", contentType); - } - } - private static ResponseEntity createErrorResponse(String errorMessage) { try { ObjectNode errorNode = objectMapper.createObjectNode(); @@ -1104,14 +1084,6 @@ public class GetInfoOnPDF { public ResponseEntity getPdfInfo(@ModelAttribute PDFFile request) throws IOException { MultipartFile inputFile = request.getFileInput(); - // Validate input - try { - validatePdfFile(inputFile); - } catch (IllegalArgumentException e) { - log.error("Invalid PDF file: {}", e.getMessage()); - return createErrorResponse("Invalid PDF file: " + e.getMessage()); - } - List verificationResults = null; try { verificationResults = veraPDFService.validatePDF(inputFile.getInputStream()); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java index 7e2aa388fa..1df66f739e 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java @@ -264,21 +264,6 @@ class GetInfoOnPDFMoreTest { @DisplayName("error handling") class Errors { - @Test - @DisplayName("empty file input yields an error response") - void emptyFile() throws Exception { - MockMultipartFile mf = - new MockMultipartFile("fileInput", "x.pdf", "application/pdf", new byte[0]); - PDFFile request = new PDFFile(); - request.setFileInput(mf); - ResponseEntity resp = getInfoOnPDF.getPdfInfo(request); - // createErrorResponse returns HTTP 200 with a JSON body carrying an "error" field. - assertThat(resp.getBody()).isNotNull(); - JsonNode body = om.readTree(resp.getBody()); - assertThat(body.has("error")).isTrue(); - assertThat(body.get("error").asText("")).contains("Invalid"); - } - @Test @DisplayName("veraPDF failure is swallowed and a report is still produced") void veraPdfFailureSwallowed() throws Exception { diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java index efc09cf190..8d17d729ea 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java @@ -556,24 +556,6 @@ class GetInfoOnPDFTest { @DisplayName("Validation and Error Handling Tests") class ValidationErrorTests { - @Test - @DisplayName("Should reject null file") - void testValidation_NullFile() throws IOException { - PDFFile request = new PDFFile(); - request.setFileInput(null); - - ResponseEntity response = getInfoOnPDF.getPdfInfo(request); - - Assertions.assertEquals( - HttpStatus.OK, response.getStatusCode()); // Returns error JSON with 200 - String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - Assertions.assertTrue(jsonNode.has("error")); - Assertions.assertTrue( - jsonNode.get("error").asText("").contains("PDF file is required")); - } - @Test @DisplayName("Should reject empty file") void testValidation_EmptyFile() throws IOException { @@ -591,64 +573,6 @@ class GetInfoOnPDFTest { Assertions.assertTrue(jsonNode.has("error")); } - - @Test - @DisplayName("Should reject file that exceeds max size") - void testValidation_TooLargeFile() throws IOException { - MultipartFile largeFile = - new MultipartFile() { - @Override - public String getName() { - return "file"; - } - - @Override - public String getOriginalFilename() { - return "large.pdf"; - } - - @Override - public String getContentType() { - return MediaType.APPLICATION_PDF_VALUE; - } - - @Override - public boolean isEmpty() { - return false; - } - - @Override - public long getSize() { - // Report 101 MB without allocating memory - return 101L * 1024L * 1024L; - } - - @Override - public byte[] getBytes() { - return new byte[0]; - } - - @Override - public java.io.InputStream getInputStream() { - return java.io.InputStream.nullInputStream(); - } - - @Override - public void transferTo(java.io.File dest) throws IllegalStateException {} - }; - - PDFFile request = new PDFFile(); - request.setFileInput(largeFile); - - ResponseEntity response = getInfoOnPDF.getPdfInfo(request); - - String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8); - JsonNode jsonNode = objectMapper.readTree(jsonResponse); - - Assertions.assertTrue(jsonNode.has("error")); - Assertions.assertTrue( - jsonNode.get("error").asText("").contains("exceeds maximum allowed size")); - } } @Nested From e560ee4cc45e1bde1e71012f888e05f83add8b86 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:25:22 +0200 Subject: [PATCH 13/99] chore(deps): update junrar dependency to version 8.0.0 (#7210) # Description of Changes Since this was a major version update i tested manually, afterwards figured i'll submit as PR. This version of junrar adds long-awaited (by me) RAR 5 support to the library. RAR 5 is newest version of the RAR file format and was not available in previous Junrar version, but is somewhat common for CBR files to be RAR 5. For junrar release notes see: https://github.com/junrar/junrar/releases/tag/v8.0.0 Changes: - Bumped junrar dep to version 8.0.0 --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- app/common/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/common/build.gradle b/app/common/build.gradle index 73be441940..942dddc5fd 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -16,7 +16,7 @@ dependencies { api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion" api "org.apache.pdfbox:xmpbox:$pdfboxVersion" api "org.apache.pdfbox:preflight:$pdfboxVersion" - api 'com.github.junrar:junrar:7.6.0' // RAR archive support for CBR files + api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' api 'org.snakeyaml:snakeyaml-engine:3.0.1' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" From 866e56728d11a8e9e6c5313cdb860e5e814abac1 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:06:16 +0200 Subject: [PATCH 14/99] fix(storage): delete share access records before expired share links (#7161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - Updated expired share-link cleanup to delete related `FileShareAccess` records before deleting their parent `FileShare` records. - Wrapped the cleanup operation in a transaction to ensure the deletion order is enforced atomically. - Prevents foreign-key constraint violations and scheduled-task failures during cleanup. - The full backend check was limited by a Gradle distribution download/network error. ```cmd [backend:dev:proprietary] 16:25:43.362 [scheduled-vt-2] WARN org.hibernate.orm.jdbc.error - HHH000247: ErrorCode: 23503, SQLState: 23503 [backend:dev:proprietary] 16:25:43.362 [scheduled-vt-2] WARN org.hibernate.orm.jdbc.error - Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240] [backend:dev:proprietary] 16:25:43.380 [scheduled-vt-2] ERROR o.s.s.s.TaskUtils$LoggingErrorHandler - Unexpected error occurred in scheduled task [backend:dev:proprietary] org.springframework.dao.DataIntegrityViolationException: could not execute statement [Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240]] [delete from file_shares where file_share_id=?]; SQL [delete from file_shares where file_share_id=?]; constraint [FKQ6V4QH5LFCAWII0ABRVSJO5SG] [backend:dev:proprietary] at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.convertHibernateAccessException(HibernateExceptionTranslator.java:169) [backend:dev:proprietary] at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.convertHibernateAccessException(HibernateExceptionTranslator.java:131) [backend:dev:proprietary] at org.springframework.orm.jpa.hibernate.HibernateExceptionTranslator.translateExceptionIfPossible(HibernateExceptionTranslator.java:105) [backend:dev:proprietary] at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:223) [backend:dev:proprietary] at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:557) [backend:dev:proprietary] at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:794) [backend:dev:proprietary] at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:757) [backend:dev:proprietary] at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:687) [backend:dev:proprietary] at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:408) [backend:dev:proprietary] at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:130) [backend:dev:proprietary] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) [backend:dev:proprietary] at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:135) [backend:dev:proprietary] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) [backend:dev:proprietary] at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:166) [backend:dev:proprietary] at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) [backend:dev:proprietary] at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:222) [backend:dev:proprietary] at jdk.proxy4/jdk.proxy4.$Proxy246.deleteAll(Unknown Source) [backend:dev:proprietary] at stirling.software.proprietary.storage.service.StorageCleanupService.cleanupExpiredShareLinks(StorageCleanupService.java:71) [backend:dev:proprietary] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) [backend:dev:proprietary] at java.base/java.lang.reflect.Method.invoke(Method.java:565) [backend:dev:proprietary] at org.springframework.scheduling.support.ScheduledMethodRunnable.runInternal(ScheduledMethodRunnable.java:128) [backend:dev:proprietary] at org.springframework.scheduling.support.ScheduledMethodRunnable.lambda$run$1(ScheduledMethodRunnable.java:122) [backend:dev:proprietary] at io.micrometer.observation.Observation.observe(Observation.java:569) [backend:dev:proprietary] at org.springframework.scheduling.support.ScheduledMethodRunnable.run(ScheduledMethodRunnable.java:122) [backend:dev:proprietary] at org.springframework.scheduling.config.Task$OutcomeTrackingRunnable.run(Task.java:88) [backend:dev:proprietary] at org.springframework.scheduling.support.DelegatingErrorHandlingRunnable.run(DelegatingErrorHandlingRunnable.java:54) [backend:dev:proprietary] at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:545) [backend:dev:proprietary] at java.base/java.util.concurrent.FutureTask.runAndReset(FutureTask.java:369) [backend:dev:proprietary] at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:310) [backend:dev:proprietary] at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090) [backend:dev:proprietary] at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614) [backend:dev:proprietary] at java.base/java.lang.VirtualThread.run(VirtualThread.java:460) [backend:dev:proprietary] Caused by: org.hibernate.exception.ConstraintViolationException: could not execute statement [Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240]] [delete from file_shares where file_share_id=?] [backend:dev:proprietary] at org.hibernate.dialect.H2Dialect.lambda$buildSQLExceptionConversionDelegate$0(H2Dialect.java:840) [backend:dev:proprietary] at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:34) [backend:dev:proprietary] at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:115) [backend:dev:proprietary] at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:184) [backend:dev:proprietary] at org.hibernate.engine.jdbc.mutation.internal.AbstractMutationExecutor.performNonBatchedMutation(AbstractMutationExecutor.java:145) [backend:dev:proprietary] at org.hibernate.engine.jdbc.mutation.internal.MutationExecutorSingleNonBatched.performNonBatchedOperations(MutationExecutorSingleNonBatched.java:53) [backend:dev:proprietary] at org.hibernate.engine.jdbc.mutation.internal.AbstractMutationExecutor.execute(AbstractMutationExecutor.java:66) [backend:dev:proprietary] at org.hibernate.persister.entity.mutation.AbstractDeleteCoordinator.doStaticDelete(AbstractDeleteCoordinator.java:268) [backend:dev:proprietary] at org.hibernate.persister.entity.mutation.AbstractDeleteCoordinator.delete(AbstractDeleteCoordinator.java:79) [backend:dev:proprietary] at org.hibernate.action.internal.EntityDeleteAction.execute(EntityDeleteAction.java:119) [backend:dev:proprietary] at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:634) [backend:dev:proprietary] at org.hibernate.engine.spi.ActionQueue.executeActions(ActionQueue.java:505) [backend:dev:proprietary] at org.hibernate.event.internal.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:381) [backend:dev:proprietary] at org.hibernate.event.internal.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:40) [backend:dev:proprietary] at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:138) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.fireFlush(SessionImpl.java:1484) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.managedFlush(SessionImpl.java:481) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.flushBeforeTransactionCompletion(SessionImpl.java:2111) [backend:dev:proprietary] at org.hibernate.internal.SessionImpl.beforeTransactionCompletion(SessionImpl.java:2033) [backend:dev:proprietary] at org.hibernate.engine.jdbc.internal.JdbcCoordinatorImpl.beforeTransactionCompletion(JdbcCoordinatorImpl.java:410) [backend:dev:proprietary] at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl.beforeCompletionCallback(JdbcResourceLocalTransactionCoordinatorImpl.java:166) [backend:dev:proprietary] at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commitNoRollbackOnly(JdbcResourceLocalTransactionCoordinatorImpl.java:248) [backend:dev:proprietary] at org.hibernate.resource.transaction.backend.jdbc.internal.JdbcResourceLocalTransactionCoordinatorImpl$TransactionDriverControlImpl.commit(JdbcResourceLocalTransactionCoordinatorImpl.java:242) [backend:dev:proprietary] at org.hibernate.engine.transaction.internal.TransactionImpl.commit(TransactionImpl.java:89) [backend:dev:proprietary] at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:553) [backend:dev:proprietary] ... 27 common frames omitted [backend:dev:proprietary] Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Referentielle Integrität verletzt: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))" [backend:dev:proprietary] Referential integrity constraint violation: "FKQ6V4QH5LFCAWII0ABRVSJO5SG: PUBLIC.FILE_SHARE_ACCESSES FOREIGN KEY(FILE_SHARE_ID) REFERENCES PUBLIC.FILE_SHARES(FILE_SHARE_ID) (CAST(97 AS BIGINT))"; SQL statement: [backend:dev:proprietary] delete from file_shares where file_share_id=? [23503-240] [backend:dev:proprietary] at org.h2.message.DbException.getJdbcSQLException(DbException.java:520) [backend:dev:proprietary] at org.h2.message.DbException.getJdbcSQLException(DbException.java:489) [backend:dev:proprietary] at org.h2.message.DbException.get(DbException.java:223) [backend:dev:proprietary] at org.h2.message.DbException.get(DbException.java:199) [backend:dev:proprietary] at org.h2.constraint.ConstraintReferential.checkRow(ConstraintReferential.java:363) [backend:dev:proprietary] at org.h2.constraint.ConstraintReferential.checkRowRefTable(ConstraintReferential.java:380) [backend:dev:proprietary] at org.h2.constraint.ConstraintReferential.checkRow(ConstraintReferential.java:254) [backend:dev:proprietary] at org.h2.table.Table.fireConstraints(Table.java:1208) [backend:dev:proprietary] at org.h2.table.Table.fireAfterRow(Table.java:1226) [backend:dev:proprietary] at org.h2.command.dml.Delete.update(Delete.java:81) [backend:dev:proprietary] at org.h2.command.dml.DataChangeStatement.update(DataChangeStatement.java:77) [backend:dev:proprietary] at org.h2.command.CommandContainer.update(CommandContainer.java:139) [backend:dev:proprietary] at org.h2.command.Command.executeUpdate(Command.java:306) [backend:dev:proprietary] at org.h2.command.Command.executeUpdate(Command.java:250) [backend:dev:proprietary] at org.h2.jdbc.JdbcPreparedStatement.executeUpdateInternal(JdbcPreparedStatement.java:213) [backend:dev:proprietary] at org.h2.jdbc.JdbcPreparedStatement.executeUpdate(JdbcPreparedStatement.java:172) [backend:dev:proprietary] at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeUpdate(ProxyPreparedStatement.java:61) [backend:dev:proprietary] at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeUpdate(HikariProxyPreparedStatement.java) [backend:dev:proprietary] at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:181) [backend:dev:proprietary] ... 48 common frames omitted ``` --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../proprietary/storage/service/StorageCleanupService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java index 5ea5f28def..32c54c6298 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java @@ -7,12 +7,14 @@ import java.util.concurrent.TimeUnit; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.storage.model.StorageCleanupEntry; import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.repository.FileShareAccessRepository; import stirling.software.proprietary.storage.repository.FileShareRepository; import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; @@ -25,6 +27,7 @@ public class StorageCleanupService { private final StorageProvider storageProvider; private final StorageCleanupEntryRepository cleanupEntryRepository; + private final FileShareAccessRepository fileShareAccessRepository; private final FileShareRepository fileShareRepository; @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) @@ -62,12 +65,14 @@ public class StorageCleanupService { } @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + @Transactional public void cleanupExpiredShareLinks() { List expired = fileShareRepository.findByExpiresAtBeforeAndShareTokenNotNull(LocalDateTime.now()); if (expired.isEmpty()) { return; } + expired.forEach(fileShareAccessRepository::deleteByFileShare); fileShareRepository.deleteAll(expired); } } From b10fc1b2de79ced7e7b26d89191c27868619dd80 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:06:28 +0200 Subject: [PATCH 15/99] fix(java): prevent executor, task, regex, and stream resource leaks (#7284) # Description of Changes - Added graceful shutdown handling for service-owned executors in `JobExecutorService`, `PolicyEngine`, and `AsyncConfig`. - Added expiration and cleanup for abandoned pending jobs in `TaskManager`. - Replaced the unbounded regex pattern cache with a bounded cache limited to 512 entries. - Ensured `Files.walk()` is closed correctly in `MobileScannerService`. - These changes prevent unbounded heap growth, lingering virtual-thread executors, and file-descriptor leaks. - Added configurable pending-job expiration through `stirling.job.pendingExpiryMinutes`, defaulting to 24 hours. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../common/service/JobExecutorService.java | 16 ++++++++ .../common/service/MobileScannerService.java | 28 +++++++------ .../software/common/service/TaskManager.java | 27 ++++++++++--- .../common/util/RegexPatternUtils.java | 40 +++++++++++++++---- .../proprietary/config/AsyncConfig.java | 30 ++++++++++++-- .../policy/engine/PolicyEngine.java | 17 ++++++++ 6 files changed, 128 insertions(+), 30 deletions(-) diff --git a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java index 23a23e868b..f283f65763 100644 --- a/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java +++ b/app/common/src/main/java/stirling/software/common/service/JobExecutorService.java @@ -19,6 +19,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; +import jakarta.annotation.PreDestroy; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; @@ -63,6 +64,21 @@ public class JobExecutorService { "Job executor configured with effective timeout of {} ms", this.effectiveTimeoutMs); } + /** Stop the service-owned executor when the application context is closed or restarted. */ + @PreDestroy + public void shutdown() { + log.debug("Shutting down job executor"); + executor.shutdown(); + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + executor.shutdownNow(); + } + } + public ResponseEntity runJobGeneric(boolean async, Supplier work) { return runJobGeneric(async, work, -1); } diff --git a/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java b/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java index 7c544b6242..18958841b2 100644 --- a/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java +++ b/app/common/src/main/java/stirling/software/common/service/MobileScannerService.java @@ -225,19 +225,21 @@ public class MobileScannerService { Path sessionDir = getSafeSessionDirectory(sessionId); if (Files.exists(sessionDir)) { // Delete all files in session directory - Files.walk(sessionDir) - .sorted( - (a, b) -> - -a.compareTo(b)) // Reverse order to delete files before - // directory - .forEach( - path -> { - try { - Files.deleteIfExists(path); - } catch (IOException e) { - log.warn("Failed to delete file: {}", path, e); - } - }); + try (var paths = Files.walk(sessionDir)) { + paths.sorted( + (a, b) -> + -a.compareTo( + b)) // Reverse order to delete files before + // directory + .forEach( + path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn("Failed to delete file: {}", path, e); + } + }); + } } log.info("Deleted session: {}", sessionId); } catch (IllegalArgumentException e) { diff --git a/app/common/src/main/java/stirling/software/common/service/TaskManager.java b/app/common/src/main/java/stirling/software/common/service/TaskManager.java index 3fd9ac3fe4..f504b39395 100644 --- a/app/common/src/main/java/stirling/software/common/service/TaskManager.java +++ b/app/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -48,6 +48,10 @@ public class TaskManager { @Value("${stirling.jobResultExpiryMinutes:30}") private int jobResultExpiryMinutes = 30; + /** Maximum age of a task that never reached a terminal state. */ + @Value("${stirling.job.pendingExpiryMinutes:1440}") + private int pendingJobExpiryMinutes = 1440; + private final FileStorage fileStorage; private final JobStore jobStore; private final ClusterBackplane clusterBackplane; @@ -332,19 +336,32 @@ public class TaskManager { } LocalDateTime expiryThreshold = LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES); + LocalDateTime pendingExpiryThreshold = + LocalDateTime.now().minus(pendingJobExpiryMinutes, ChronoUnit.MINUTES); int removedCount = 0; try { for (Map.Entry entry : jobResults.entrySet()) { JobResult result = entry.getValue(); - // Remove completed jobs that are older than the expiry threshold - if (result.isComplete() - && result.getCompletedAt() != null - && result.getCompletedAt().isBefore(expiryThreshold)) { + boolean expiredCompletedJob = + result.isComplete() + && result.getCompletedAt() != null + && result.getCompletedAt().isBefore(expiryThreshold); + boolean abandonedPendingJob = + !result.isComplete() + && result.getCreatedAt() != null + && result.getCreatedAt().isBefore(pendingExpiryThreshold); + + // Remove old terminal results and abandoned pending jobs. Without the second + // branch, a client that starts a task and never completes it keeps its result in + // memory forever. + if (expiredCompletedJob || abandonedPendingJob) { // Clean up file results - cleanupJobFiles(result, entry.getKey()); + if (expiredCompletedJob) { + cleanupJobFiles(result, entry.getKey()); + } // Remove the job result jobResults.remove(entry.getKey()); diff --git a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java index b4821edd9c..9d2d1b74db 100644 --- a/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RegexPatternUtils.java @@ -1,17 +1,22 @@ package stirling.software.common.util; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.util.concurrent.UncheckedExecutionException; + import lombok.extern.slf4j.Slf4j; @Slf4j public final class RegexPatternUtils { private static final RegexPatternUtils INSTANCE = new RegexPatternUtils(); - private final ConcurrentHashMap patternCache = new ConcurrentHashMap<>(); + private static final long MAX_CACHED_PATTERNS = 512; + private final Cache patternCache = + CacheBuilder.newBuilder().maximumSize(MAX_CACHED_PATTERNS).build(); private static final String WHITESPACE_REGEX = "\\s++"; private static final String EXTENSION_REGEX = "\\.(?:[^.]*+)?$"; @@ -51,7 +56,7 @@ public final class RegexPatternUtils { throw new IllegalArgumentException("Regex pattern cannot be null"); } - return patternCache.computeIfAbsent(new PatternKey(regex, 0), this::compilePattern); + return getOrCompile(new PatternKey(regex, 0)); } /** @@ -77,7 +82,7 @@ public final class RegexPatternUtils { throw new IllegalArgumentException("Regex pattern cannot be null"); } - return patternCache.computeIfAbsent(new PatternKey(regex, flags), this::compilePattern); + return getOrCompile(new PatternKey(regex, flags)); } /** @@ -98,7 +103,7 @@ public final class RegexPatternUtils { * @return true if pattern is cached, false otherwise */ public boolean isCached(String regex, int flags) { - return regex != null && patternCache.containsKey(new PatternKey(regex, flags)); + return regex != null && patternCache.getIfPresent(new PatternKey(regex, flags)) != null; } /** @@ -107,7 +112,7 @@ public final class RegexPatternUtils { * @return number of patterns currently cached */ public int getCacheSize() { - return patternCache.size(); + return (int) patternCache.size(); } /** @@ -115,7 +120,7 @@ public final class RegexPatternUtils { * useful for testing or memory cleanup in long-running applications. */ public void clearCache() { - patternCache.clear(); + patternCache.invalidateAll(); log.debug("Regex pattern cache cleared"); } @@ -141,13 +146,32 @@ public final class RegexPatternUtils { return false; } PatternKey key = new PatternKey(regex, flags); - boolean removed = patternCache.remove(key) != null; + boolean removed = patternCache.getIfPresent(key) != null; + patternCache.invalidate(key); if (removed) { log.debug("Removed regex pattern from cache: {} (flags: {})", regex, flags); } return removed; } + private Pattern getOrCompile(PatternKey key) { + try { + return patternCache.get(key, () -> compilePattern(key)); + } catch (UncheckedExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof PatternSyntaxException patternSyntaxException) { + throw patternSyntaxException; + } + throw e; + } catch (java.util.concurrent.ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof PatternSyntaxException patternSyntaxException) { + throw patternSyntaxException; + } + throw new IllegalStateException("Failed to compile regex pattern", cause); + } + } + /** * Internal method to compile a pattern and handle errors consistently. * diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java index ea096a8d23..3ba4adcbee 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AsyncConfig.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.config; import java.util.Map; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.slf4j.MDC; @@ -12,10 +13,15 @@ import org.springframework.core.task.support.TaskExecutorAdapter; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.security.concurrent.DelegatingSecurityContextExecutor; +import jakarta.annotation.PreDestroy; + @Configuration @EnableAsync public class AsyncConfig { + private ExecutorService auditExecutorService; + private ExecutorService aiStreamExecutorService; + /** * MDC context-propagating task decorator. Copies MDC context from the caller thread to the * virtual thread executing the task. @@ -44,8 +50,8 @@ public class AsyncConfig { @Bean(name = "auditExecutor") public Executor auditExecutor() { - TaskExecutorAdapter adapter = - new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); + auditExecutorService = Executors.newVirtualThreadPerTaskExecutor(); + TaskExecutorAdapter adapter = new TaskExecutorAdapter(auditExecutorService); adapter.setTaskDecorator(new MDCContextTaskDecorator()); return adapter; } @@ -53,9 +59,25 @@ public class AsyncConfig { /** Propagates the request's SecurityContext onto background AI-orchestration threads. */ @Bean(name = "aiStreamExecutor") public Executor aiStreamExecutor() { - TaskExecutorAdapter adapter = - new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); + aiStreamExecutorService = Executors.newVirtualThreadPerTaskExecutor(); + TaskExecutorAdapter adapter = new TaskExecutorAdapter(aiStreamExecutorService); adapter.setTaskDecorator(new MDCContextTaskDecorator()); return new DelegatingSecurityContextExecutor(adapter); } + + /** + * Close the underlying executors because the exposed Spring adapters do not own their + * lifecycle. + */ + @PreDestroy + void shutdown() { + shutdownExecutor(auditExecutorService); + shutdownExecutor(aiStreamExecutorService); + } + + private void shutdownExecutor(ExecutorService executor) { + if (executor != null) { + executor.shutdownNow(); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index e6dce0ee7b..1f31d153a6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -16,6 +16,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClientResponseException; +import jakarta.annotation.PreDestroy; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -79,6 +81,21 @@ public class PolicyEngine { private final ExecutorService asyncExecutor = ExecutorFactory.newVirtualThreadExecutor(); + /** Stop the service-owned executor when the application context is closed or restarted. */ + @PreDestroy + void shutdown() { + log.debug("Shutting down policy engine executor"); + asyncExecutor.shutdown(); + try { + if (!asyncExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) { + asyncExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + asyncExecutor.shutdownNow(); + } + } + /** * Submit a pipeline to run asynchronously. The handle's run id scopes a {@link TaskManager} job * (status/notes/results observable via the job endpoints); its future resolves when the run From 2265e48b3215f77a3b52cf0c6ce47f94f1dcc3c2 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:43:53 +0200 Subject: [PATCH 16/99] chore(ci): optimize GitHub Actions Gradle caching across workflows (#7299) # Description of Changes This PR refactors Gradle caching across the GitHub Actions workflows to improve cache reuse, reduce dependency resolution overhead, and shorten CI execution times. ### What was changed - Replaced multiple `gradle/actions/setup-gradle` steps with a unified `actions/cache`-based Gradle User Home cache strategy. - Standardized cache paths across workflows to include: - `~/.gradle/caches` - `~/.gradle/wrapper` - Introduced consistent cache keys using: - Runner OS - Runner architecture - JDK version - Hashes of Gradle wrapper, version catalog, Gradle build files, and project build scripts. - Added restore keys to maximize cache hit rates across similar environments. - Added a new **`gradle-cache-prime`** job in the main build workflow that: - Restores or creates the shared Gradle cache. - Resolves backend dependencies before downstream jobs execute. - Makes the populated cache available to subsequent jobs. - Updated workflow dependencies so Gradle-based jobs wait for the cache priming job before execution. - Simplified and unified Gradle cache handling across numerous CI workflows, including backend builds, OpenAPI generation, database migration tests, Docker tests, Tauri builds, Swagger generation, enterprise builds, release workflows, and license generation. - Updated workflow comments to reflect the new caching strategy and shared cache behavior. ### Why the change was made The previous workflows used a mixture of Gradle setup actions and partial dependency caches, leading to duplicated dependency downloads, inconsistent cache behavior, and longer CI runtimes. Consolidating all workflows onto a shared Gradle User Home cache with a dedicated cache priming job improves cache reuse, reduces unnecessary dependency resolution, and makes CI execution more consistent. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../workflows/PR-Demo-Comment-with-react.yml | 12 +++-- .github/workflows/backend-build.yml | 16 +++--- .github/workflows/build-enterprise.yml | 10 ++++ .github/workflows/build.yml | 53 +++++++++++++++---- .github/workflows/check-generated-models.yml | 12 +++-- .github/workflows/check-licence.yml | 16 +++--- .github/workflows/check-openapi.yml | 16 +++--- .github/workflows/coverage-aggregate.yml | 22 ++++---- .github/workflows/db-migration-test.yml | 20 +++---- .github/workflows/docker-compose-tests.yml | 16 +++--- .github/workflows/e2e-live.yml | 17 +++--- .../frontend-backend-licenses-update.yml | 12 +++-- .github/workflows/multiOSReleases.yml | 36 ++++++++----- .github/workflows/push-docker.yml | 12 ++--- .github/workflows/swagger.yml | 12 +++-- .github/workflows/tauri-build.yml | 12 +++-- .github/workflows/test-build-docker.yml | 16 +++--- .github/workflows/testdriver.yml | 12 +++-- 18 files changed, 187 insertions(+), 135 deletions(-) diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index e48a6170da..3826897a39 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -211,10 +211,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 4d133593d3..47b5591e19 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -40,20 +40,16 @@ jobs: java-version: ${{ matrix.jdk-version }} distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-${{ matrix.jdk-version }}- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 0fb02ad90f..398cca3144 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -60,6 +60,16 @@ jobs: with: java-version: "25" distribution: "temurin" + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d4aee192d..4ad4b046e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -60,8 +60,43 @@ jobs: with: filters: .github/config/.files.yaml - build: + gradle-cache-prime: + name: Prime shared Gradle cache needs: [files-changed] + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Set up JDK 25 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: "25" + distribution: "temurin" + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- + - name: Resolve backend dependencies + run: ./gradlew :stirling-pdf:classes -PnoSpotless --no-daemon + env: + STIRLING_FLAVOR: saas + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + + build: + needs: [files-changed, gradle-cache-prime] permissions: actions: read contents: read @@ -76,7 +111,7 @@ jobs: # works after Hibernate's ddl-auto=update migrates the schema. Gated on # the `project` filter so doc-only PRs skip this ~5-minute job. if: needs.files-changed.outputs.project == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/db-migration-test.yml @@ -84,7 +119,7 @@ jobs: check-generateOpenApiDocs: if: needs.files-changed.outputs.openapi == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/check-openapi.yml @@ -120,7 +155,7 @@ jobs: playwright-e2e-live: if: needs.files-changed.outputs.frontend == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/e2e-live.yml @@ -128,7 +163,7 @@ jobs: playwright-e2e-enterprise: if: needs.files-changed.outputs.proprietary == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/build-enterprise.yml @@ -136,7 +171,7 @@ jobs: check-licence: if: needs.files-changed.outputs.build == 'true' - needs: [files-changed, build] + needs: [files-changed, build, gradle-cache-prime] permissions: contents: read uses: ./.github/workflows/check-licence.yml @@ -144,7 +179,7 @@ jobs: docker-compose-tests: if: needs.files-changed.outputs.project == 'true' - needs: [files-changed] + needs: [files-changed, gradle-cache-prime] permissions: actions: write contents: read @@ -156,7 +191,7 @@ jobs: test-build-docker-images: if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' - needs: [files-changed, build, check-generateOpenApiDocs, check-licence] + needs: [files-changed, build, check-generateOpenApiDocs, check-licence, gradle-cache-prime] permissions: contents: read packages: read @@ -199,7 +234,7 @@ jobs: # 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] + needs: [files-changed, gradle-cache-prime] permissions: contents: read pull-requests: write diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index a758c4268e..a8559a7d78 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -42,10 +42,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.0 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index c9f64a0b93..61d21c0501 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -26,20 +26,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index eb89d32629..c188e8ff23 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -27,20 +27,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index e3a871b08e..1ece083252 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -46,20 +46,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -81,7 +77,7 @@ jobs: # Each lands as a sibling dir under coverage-execs/, with the .exec # files preserving their original relative paths. - name: Download all .exec artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: jacoco-exec-* path: coverage-execs/ @@ -206,7 +202,7 @@ jobs: # absence on backend-only runs by skipping the download entirely # when the producer job was not part of this workflow run. if: inputs.frontend-validation-result == 'success' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: frontend-coverage path: matrix-inputs/vitest/ @@ -216,7 +212,7 @@ jobs: # e2e-live uploads the artifact with a stable name. Skip the # download entirely when the producer job did not run. if: inputs.playwright-e2e-live-result == 'success' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: playwright-frontend-coverage path: matrix-inputs/playwright/ diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 8bd28fc060..edb2d0547c 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -30,23 +30,19 @@ jobs: java-version: 25 distribution: temurin - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true - - # No `-PnoSpotless` here yet because the upstream cache layer matches the - # backend build's; reuse keeps cold-cache cost identical. + # Keep the normal formatting path here so this smoke test exercises the + # same Gradle configuration as the backend build. - name: Build Stirling-PDF JAR env: MAVEN_USER: ${{ secrets.MAVEN_USER }} diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 9b81f774ef..049db8adc0 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -38,20 +38,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- # When the PR changes the base image, test.sh builds it locally # (stirling-pdf-base:local) into the daemon image store. A buildx diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 302663cbf9..0e64b89099 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -25,21 +25,16 @@ jobs: with: java-version: "25" distribution: "temurin" - # Same cache layer as backend-build.yml. Without it every run resolved the - # whole classpath cold and eventually got HTTP 429 from Maven Central. - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- # Gradle does not retry 429s, and a cold cache resolving the buildscript # classpath is exactly where Maven Central rate-limits us. Retry it here, # where a failure is cheap, instead of inside the backgrounded bootRun. diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 8c852ae08f..1f901a7f05 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -350,10 +350,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 8303cf102a..f5a99a0ef2 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -57,20 +57,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependencies + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} restore-keys: | - gradle-${{ runner.os }}- - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 @@ -151,10 +147,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Setup Node.js if: matrix.variant.build_frontend == true @@ -255,10 +257,16 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 12fcd23f6c..f4416a726d 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -65,20 +65,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependencies + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} restore-keys: | - gradle-${{ runner.os }}- - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Set up Docker Buildx id: buildx diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index ffbeafdd1e..76b28c4566 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -39,10 +39,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Generate Swagger documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 415e8be4e5..09422a8093 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -179,10 +179,16 @@ jobs: java-version: "25" distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Setup Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index d935cac53d..7f8c72d41e 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -84,20 +84,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Cache Gradle dependency artifacts + - name: Cache Gradle User Home uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | + ~/.gradle/caches ~/.gradle/wrapper - ~/.gradle/caches/modules-2/files-2.1 - ~/.gradle/caches/modules-2/metadata-2.* - key: gradle-deps-${{ runner.os }}-jdk-25-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties', '**/*.gradle', '**/*.gradle.kts', 'settings.gradle', 'settings.gradle.kts', 'gradle/libs.versions.toml') }} - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 - with: - gradle-version: 9.6.1 - cache-disabled: true + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index a7f74df4a7..a8d777744a 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -38,10 +38,16 @@ jobs: java-version: "25" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Cache Gradle User Home + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - gradle-version: 9.6.1 + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties', 'gradle/libs.versions.toml', 'settings.gradle', 'build.gradle', 'app/**/build.gradle', 'gradle/**/*.gradle') }} + restore-keys: | + gradle-${{ runner.os }}-${{ runner.arch }}-jdk-25- + gradle-${{ runner.os }}-${{ runner.arch }}- - name: Build with Gradle run: ./gradlew build From 8094765babe12b96d46e5d61f1d258fa29b1eb81 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 13:44:12 +0200 Subject: [PATCH 17/99] build(licenses): Module-specific license. Add dependency overrides. (#7049) # Description of Changes This change adds a version-scoped override mechanism for dependencies whose published metadata does not expose a detectable license. - Added `app/license-overrides.json` with verified Apache License 2.0 metadata for: - `com.hubspot.immutables:immutables-exceptions:1.9` - `com.hubspot:algebra:1.5` - Added `ModuleLicenseOverrideFilter` as custom `buildSrc` logic for the Gradle dependency license report plugin. - Applied overrides only when the exact `group:artifact:version` matches and no usable license metadata was detected. - Added automatic maintenance of the override file: - Removes overrides when the dependency is no longer resolved. - Removes overrides when the dependency starts publishing valid license metadata. - Migrates stale overrides to newer unresolved versions and clears their metadata for re-verification. - Adds null-valued placeholders for newly detected dependencies without license metadata. - Preserves populated overrides for newer versions when already present. - Added Gradle version-aware dependency ordering for override migration. - Registered `app/license-overrides.json` as an input for license-report and license-check preparation tasks. - Centralized the dependency license report plugin version in `buildSrc`. - Added unit tests covering override application, cleanup, migration, exact-version matching, concurrent versions, placeholder generation, and numeric version ordering. - Added documentation describing the override lifecycle, verification requirements, maintenance workflow, and validation commands. - Replaced broad null-license allowances for the two HubSpot modules with explicit Apache License 2.0 metadata. - Added accepted GNU Lesser General Public License name variants encountered in dependency metadata. The change was made because some dependencies have known upstream licenses but do not publish license metadata in a form detected by the Gradle license report plugin. Previously, these dependencies were permitted through module-specific null-license exceptions, leaving incomplete information in the generated report. The new mechanism supplies verified metadata without overriding valid metadata published by dependencies. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- app/allowed-licenses.json | 16 +- app/license-overrides.json | 12 + build.gradle | 13 +- buildSrc/README.md | 168 +++++++++++ buildSrc/build.gradle | 22 ++ .../gradle/ModuleLicenseOverrideFilter.groovy | 173 ++++++++++++ .../ModuleLicenseOverrideFilterTest.groovy | 265 ++++++++++++++++++ docker/backend/Dockerfile | 2 + docker/embedded/Dockerfile | 2 + docker/embedded/Dockerfile.fat | 2 + docker/embedded/Dockerfile.ultra-lite | 2 + 11 files changed, 668 insertions(+), 9 deletions(-) create mode 100644 app/license-overrides.json create mode 100644 buildSrc/README.md create mode 100644 buildSrc/build.gradle create mode 100644 buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy create mode 100644 buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index 9b5ef66556..033661629f 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -156,6 +156,14 @@ "moduleName": ".*", "moduleLicense": "GNU GENERAL PUBLIC LICENSE, Version 2 + Classpath Exception" }, + { + "moduleName": ".*", + "moduleLicense": "GNU Lesser Public License" + }, + { + "moduleName": ".*", + "moduleLicense": "The GNU Lesser General Public License" + }, { "moduleName": "com.martiansoftware:jsap", "moduleLicense": "LGPL" @@ -224,14 +232,6 @@ "moduleName": "com.google.re2j:re2j", "moduleLicense": "Go License" }, - { - "moduleName": "com.hubspot:algebra", - "moduleLicense": null - }, - { - "moduleName": "com.hubspot.immutables:immutables-exceptions", - "moduleLicense": null - }, { "moduleName": ".*", "moduleLicense": "UnRar License" diff --git a/app/license-overrides.json b/app/license-overrides.json new file mode 100644 index 0000000000..0ea43fcaf9 --- /dev/null +++ b/app/license-overrides.json @@ -0,0 +1,12 @@ +{ + "com.hubspot.immutables:immutables-exceptions:1.9": { + "name": "The Apache License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", + "projectUrl": "https://github.com/HubSpot/hubspot-immutables/tree/58628096ac99b286fe4f8bfe12aa3cff0f0589d3" + }, + "com.hubspot:algebra:1.5": { + "name": "The Apache License, Version 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.txt", + "projectUrl": "https://github.com/HubSpot/algebra/tree/5d42983fd3a26539df9ba2cbeac32a1bddce0494" + } +} diff --git a/build.gradle b/build.gradle index 2af5522a73..31fa6b1bdb 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { id "org.springdoc.openapi-gradle-plugin" version "1.9.0" id "io.swagger.swaggerhub" version "1.3.2" id "com.diffplug.spotless" version "8.8.0" - id "com.github.jk1.dependency-license-report" version "3.1.2" + id "com.github.jk1.dependency-license-report" //id "nebula.lint" version "19.0.3" id "org.sonarqube" version "7.2.3.7755" } @@ -18,6 +18,7 @@ import groovy.xml.XmlSlurper import org.gradle.api.JavaVersion import org.gradle.api.tasks.testing.Test import org.gradle.jvm.toolchain.JavaLanguageVersion +import stirling.software.gradle.ModuleLicenseOverrideFilter ext { springBootVersion = "4.0.6" @@ -550,6 +551,7 @@ gradle.taskGraph.whenReady { graph -> } def allProjects = ((subprojects as Set) + project) as Set +def moduleLicenseOverridesFile = project.layout.projectDirectory.file("app/license-overrides.json").asFile licenseReport { projects = allProjects @@ -557,6 +559,15 @@ licenseReport { allowedLicensesFile = project.layout.projectDirectory.file("app/allowed-licenses.json").asFile outputDir = project.layout.buildDirectory.dir("reports/dependency-license").get().asFile.path configurations = [ "productionRuntimeClasspath", "runtimeClasspath" ] + filters = [new ModuleLicenseOverrideFilter(moduleLicenseOverridesFile)] +} + +tasks.named('generateLicenseReport') { + inputs.file(moduleLicenseOverridesFile) +} + +tasks.named('checkLicensePreparation') { + inputs.file(moduleLicenseOverridesFile) } // Configure the forked spring boot run task to properly delegate to the stirling-pdf module diff --git a/buildSrc/README.md b/buildSrc/README.md new file mode 100644 index 0000000000..bbcfb0de6c --- /dev/null +++ b/buildSrc/README.md @@ -0,0 +1,168 @@ +# Dependency license overrides + +The backend dependency license report is generated by the +[`com.github.jk1.dependency-license-report`](https://github.com/jk1/Gradle-License-Report) +Gradle plugin. Most license information is read from dependency POM files, manifests, or packaged +license files. Some artifacts do not publish license metadata in a form the plugin can detect, even +though the artifact has a known license. + +This directory contains the build logic used to provide narrowly scoped fallback license metadata +for those artifacts. + +## Files + +- `build.gradle` makes version 3.1.4 of the license report plugin available to the custom build + logic. The root build applies that plugin without a second version declaration so both use the + same classpath. +- `src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy` implements the + plugin's `DependencyFilter` interface. +- `../app/license-overrides.json` contains the actual module-specific fallback values. +- `../app/allowed-licenses.json` defines which detected or supplied licenses are accepted by + `checkLicense`. + +## How it works + +The root `build.gradle` passes `app/license-overrides.json` to +`ModuleLicenseOverrideFilter`: + +```groovy +filters = [new ModuleLicenseOverrideFilter(moduleLicenseOverridesFile)] +``` + +For every dependency discovered by the license plugin, the filter builds an identifier in this +format: + +```text +group:artifact:version +``` + +The filter applies a populated override only when both conditions are true: + +1. The complete identifier, including the version, exists in `app/license-overrides.json`. +2. The plugin did not discover a non-empty license name for that dependency. + +When both conditions match, the filter adds the configured license as fallback manifest metadata. +The normal report renderer and `checkLicense` then consume that metadata in the same way as +metadata discovered from the dependency itself. + +An override never replaces a license that the plugin already detected. Updating a dependency also +does not silently reuse the override because a different version produces a different identifier. + +Overrides are temporary fallbacks, not a permanent license catalog. If the plugin starts detecting +the original license for an overridden module, the filter automatically removes that exact entry +from `app/license-overrides.json` and logs the cleanup. When the overridden version is no longer +resolved, a newer resolved version takes its place: if it declares a license, the stale entry is +removed; otherwise the entry moves to the new exact version and its values are cleared for +re-verification. An already populated entry for the new version is preserved. If no higher version +is resolved, the unused override is removed instead. + +Because the report aggregates several projects and configurations, multiple versions of the same +`group:artifact` can be present at once. An override is retained whenever its exact version is still +resolved. Only when that exact version is absent may the filter treat a higher version as an update; +version ordering then follows Gradle's own dependency version comparator. Overrides for dependency +versions that are no longer resolved and have no higher replacement are deleted automatically. + +The filter also records every resolved dependency without detected license metadata that has no +override yet. It writes a placeholder with `null` values for `name`, `url`, and `projectUrl`. +Placeholders deliberately do not affect the generated report until `name` is filled in. This makes +new missing metadata visible in the source-controlled override file instead of only in a generated +report. Review and fill or remove every new placeholder before committing the resulting JSON. + +## Adding an override + +First verify the license from an authoritative source such as the upstream repository, the +published artifact metadata, or the license file shipped inside the artifact. Do not infer a +license from the organization name or from a related artifact. + +Add an entry to `app/license-overrides.json`: + +```json +{ + "com.example:example-library:1.2.3": { + "name": "Apache License, Version 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0", + "projectUrl": "https://github.com/example/example-library/tree/0123456789abcdef0123456789abcdef01234567" + } +} +``` + +The key must contain the exact resolved version. `name` must be non-empty for the override to be +applied. `url` should point to the canonical license text. `projectUrl` must point to the immutable +Git tree for the exact module version, using the commit hash at which that version was introduced: + +```text +https://github.com///tree/ +``` + +Do not use the repository's default branch or another moving URL. See the existing entries in +`app/license-overrides.json` for concrete examples. + +If the license name is not already accepted, add a suitably narrow rule to +`app/allowed-licenses.json`. Adding an override and allowing a license are separate operations: + +- `license-overrides.json` supplies missing metadata for a specific artifact version. +- `allowed-licenses.json` defines the policy enforced by `checkLicense`. + +## Updating a dependency + +When an overridden dependency changes version: + +1. Verify the license for the new version again. +2. Run the license report so the filter can move the old key or add a placeholder for the new full + `group:artifact:version` key. +3. Re-verify and fill the license values and the version's immutable Git-tree `projectUrl`; moved + values are intentionally cleared because a license conclusion for one release is not assumed + for another. +4. Regenerate and inspect the report. + +If the new artifact publishes usable license metadata, no override is necessary. The next license +report or license check removes the old entry from `app/license-overrides.json` automatically. The +file must contain only overrides that are still needed. + +## Verification + +Run the filter unit tests: + +```powershell +.\gradlew.bat -p buildSrc test +``` + +The tests use `com.example:example-library` versions 1.4 and 1.7 to cover the missing +metadata fallback, placeholder creation, version migration, preservation of a populated newer +override, automatic cleanup after license metadata appears, exact-version matching, and concurrent +resolved versions. They also verify removal when a dependency version disappears. A separate `1.9` +to `1.11.0` case verifies numeric Gradle version ordering. + +Run the normal backend license workflow from the repository root: + +```powershell +task backend:licenses:generate +``` + +Then inspect: + +- `build/reports/dependency-license/index.json` for the rendered module, version, license name, and + URL. +- `build/reports/dependency-license/dependencies-without-allowed-license.json` when `checkLicense` + reports a policy failure. + +Also run the backend quality gate after changing the filter or its build wiring: + +```powershell +task backend:check +``` + +The override JSON is registered as an input of `generateLicenseReport` and +`checkLicensePreparation`, so changing the file invalidates the corresponding Gradle task outputs. + +## What not to do + +- Do not use an unversioned key. It cannot match the filter and would make the intended scope + ambiguous. +- Do not use an override to replace valid license metadata published by a dependency. +- Do not add an empty license to `allowed-licenses.json` merely to silence `checkLicense`; that + would still leave the generated report without useful license information. +- Do not exclude a dependency from the report solely because it is transitive. Runtime transitive + dependencies are still distributed components and their licenses remain relevant. +- Do not edit generated files under `build/reports/dependency-license` or the copied static license + report by hand. diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle new file mode 100644 index 0000000000..25eee5c0f5 --- /dev/null +++ b/buildSrc/build.gradle @@ -0,0 +1,22 @@ +plugins { + id 'groovy' +} + +repositories { + gradlePluginPortal() +} + +dependencies { + implementation localGroovy() + implementation gradleApi() + implementation 'com.github.jk1:gradle-license-report:3.1.4' + testImplementation platform('org.junit:junit-bom:6.1.2') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() + jvmArgs '--add-opens=java.base/java.lang=ALL-UNNAMED' + testLogging.showStandardStreams = true +} diff --git a/buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy b/buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy new file mode 100644 index 0000000000..df0d76158c --- /dev/null +++ b/buildSrc/src/main/groovy/stirling/software/gradle/ModuleLicenseOverrideFilter.groovy @@ -0,0 +1,173 @@ +package stirling.software.gradle + +import com.github.jk1.license.License +import com.github.jk1.license.ManifestData +import com.github.jk1.license.ModuleData +import com.github.jk1.license.ProjectData +import com.github.jk1.license.filter.DependencyFilter +import com.github.jk1.license.render.LicenseDataCollector +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.DefaultVersionComparator +import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.Version +import org.gradle.api.internal.artifacts.ivyservice.ivyresolve.strategy.VersionParser + +class ModuleLicenseOverrideFilter implements DependencyFilter { + private static final VersionParser VERSION_PARSER = new VersionParser() + private static final Comparator VERSION_COMPARATOR = + new DefaultVersionComparator().asVersionComparator() + + private final File overridesFile + + ModuleLicenseOverrideFilter(File overridesFile) { + this.overridesFile = overridesFile + } + + @Override + ProjectData filter(ProjectData projectData) { + Map> overrides = loadOverrides() + List modules = projectData.configurations + .collectMany { configuration -> configuration.dependencies } + Map> modulesByCoordinate = modules + .groupBy { module -> moduleCoordinate(module) } + + boolean overridesChanged = false + overrides.keySet().toList().each { overrideId -> + ModuleCoordinates overrideModule = parseModuleId(overrideId) + List coordinateModules = modulesByCoordinate[overrideModule.coordinate] + ModuleData currentModule = coordinateModules + ?.find { module -> module.version == overrideModule.version } + if (currentModule == null) { + currentModule = newestModule(coordinateModules, overrideModule.version) + } + if (currentModule == null) { + overrides.remove(overrideId) + overridesChanged = true + projectData.project.logger.lifecycle( + "Removed unused license override for ${overrideId}: " + + 'dependency version is no longer resolved') + return + } + + if (hasDeclaredLicense(currentModule)) { + overrides.remove(overrideId) + overridesChanged = true + projectData.project.logger.lifecycle( + "Removed stale license override for ${overrideId}: " + + "${moduleId(currentModule)} now declares a license") + return + } + + if (compareVersions(currentModule.version, overrideModule.version) > 0) { + String currentModuleId = moduleId(currentModule) + overrides.remove(overrideId) + if (!overrides.containsKey(currentModuleId)) { + overrides[currentModuleId] = [name: null, url: null, projectUrl: null] + } + overridesChanged = true + projectData.project.logger.lifecycle( + "Updated license override from ${overrideId} to ${currentModuleId}: " + + 'newer dependency still declares no license') + } + } + + modules.groupBy { module -> moduleId(module) }.each { currentModuleId, matchingModules -> + ModuleData module = matchingModules.first() + if (!overrides.containsKey(currentModuleId) && !hasDeclaredLicense(module)) { + overrides[currentModuleId] = [name: null, url: null, projectUrl: null] + overridesChanged = true + projectData.project.logger.lifecycle( + "Added missing license override for ${currentModuleId}. " + + "Set 'name' and 'url' in ${overridesFile}.") + } + } + if (overridesChanged) { + saveOverrides(overrides) + } + + projectData.configurations.each { configuration -> + configuration.dependencies.each { module -> applyOverride(module, overrides) } + } + return projectData + } + + private void applyOverride( + ModuleData module, Map> overrides) { + String moduleId = moduleId(module) + Map override = overrides[moduleId] + if (override == null) { + return + } + + String licenseName = override.name + String licenseUrl = override.url + String projectUrl = override.projectUrl + if (licenseName == null || licenseName.isBlank()) { + return + } + + Set licenses = [new License(licenseName, licenseUrl)] as LinkedHashSet + ManifestData manifest = + new ManifestData(module.name, module.version, null, null, projectUrl, licenses, false) + Set manifests = new LinkedHashSet<>(module.manifests ?: []) + manifests.add(manifest) + module.manifests = manifests + } + + private Map> loadOverrides() { + Object parsed = new JsonSlurper().parse(overridesFile) + if (!(parsed instanceof Map)) { + throw new IllegalArgumentException( + "License overrides file ${overridesFile} must contain a JSON object") + } + return parsed as Map> + } + + private void saveOverrides(Map> overrides) { + String json = JsonOutput.prettyPrint(JsonOutput.toJson(overrides)) + System.lineSeparator() + overridesFile.setText(json, 'UTF-8') + } + + private static String moduleId(ModuleData module) { + return "${module.group}:${module.name}:${module.version}" + } + + private static String moduleCoordinate(ModuleData module) { + return "${module.group}:${module.name}" + } + + private static ModuleCoordinates parseModuleId(String moduleId) { + List parts = moduleId.split(':', 3) as List + if (parts.size() != 3 || parts.any { part -> part.isBlank() }) { + throw new IllegalArgumentException( + "License override key ${moduleId} must use group:module:version") + } + return new ModuleCoordinates("${parts[0]}:${parts[1]}", parts[2]) + } + + private static ModuleData newestModule(List modules, String minimumVersion) { + return modules + ?.findAll { module -> compareVersions(module.version, minimumVersion) > 0 } + ?.max { left, right -> compareVersions(left.version, right.version) } + } + + private static int compareVersions(String left, String right) { + return VERSION_COMPARATOR.compare( + VERSION_PARSER.transform(left), VERSION_PARSER.transform(right)) + } + + private static boolean hasDeclaredLicense(ModuleData module) { + Set licenses = LicenseDataCollector.multiModuleLicenseInfo(module).licenses + return licenses.any { license -> license.name != null && !license.name.isBlank() } + } + + private static class ModuleCoordinates { + final String coordinate + final String version + + ModuleCoordinates(String coordinate, String version) { + this.coordinate = coordinate + this.version = version + } + } +} diff --git a/buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy b/buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy new file mode 100644 index 0000000000..7536d08af0 --- /dev/null +++ b/buildSrc/src/test/groovy/stirling/software/gradle/ModuleLicenseOverrideFilterTest.groovy @@ -0,0 +1,265 @@ +package stirling.software.gradle + +import com.github.jk1.license.ConfigurationData +import com.github.jk1.license.License +import com.github.jk1.license.ManifestData +import com.github.jk1.license.ModuleData +import com.github.jk1.license.ProjectData +import com.github.jk1.license.render.LicenseDataCollector +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import java.nio.file.Path +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertFalse +import static org.junit.jupiter.api.Assertions.assertTrue + +class ModuleLicenseOverrideFilterTest { + private static final String GROUP = 'com.example' + private static final String MODULE = 'example-library' + private static final String VERSION_WITHOUT_LICENSE = '1.4' + private static final String VERSION_WITH_LICENSE = '1.7' + private static final String APACHE_NAME = 'Apache License, Version 2.0' + private static final String APACHE_URL = 'https://www.apache.org/licenses/LICENSE-2.0' + private static final String PROJECT_URL = 'https://github.com/HubSpot/hubspot-immutables' + + @TempDir + Path temporaryDirectory + + @Test + void keepsOverrideForVersionWithoutLicenseMetadata() { + ModuleData module = createModule(VERSION_WITHOUT_LICENSE, null) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertTrue(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void removesOverrideWhenLaterVersionDeclaresLicense() { + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData module = createModule(VERSION_WITH_LICENSE, publishedLicense) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void removesOverrideWhenOnlyOlderVersionIsResolved() { + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData module = createModule(VERSION_WITHOUT_LICENSE, publishedLicense) + File overridesFile = createOverridesFile(moduleId(VERSION_WITH_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITH_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void removesOverrideWhenModuleIsNoLongerResolved() { + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData()) + + assertTrue(readOverrides(overridesFile).isEmpty()) + } + + @Test + void keepsOverrideWhenExactAndNewerVersionsAreBothResolved() { + ModuleData olderModule = createModule(VERSION_WITHOUT_LICENSE, null) + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData newerModule = createModule(VERSION_WITH_LICENSE, publishedLicense) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + new ModuleLicenseOverrideFilter(overridesFile) + .filter(createProjectData(olderModule, newerModule)) + + Map overrides = readOverrides(overridesFile) + assertTrue(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals([APACHE_NAME], licenseNames(olderModule)) + assertEquals([APACHE_NAME], licenseNames(newerModule)) + } + + @Test + void movesOverrideUsingGradleNumericVersionOrdering() { + String oldVersion = '1.9' + String newVersion = '1.11.0' + ModuleData module = createModule(newVersion, null) + File overridesFile = createOverridesFile(moduleId(oldVersion)) + + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(oldVersion))) + assertEquals( + [name: null, url: null, projectUrl: null], overrides[moduleId(newVersion)]) + } + + @Test + void movesOverrideToLaterVersionWithoutLicenseAndClearsLicenseData() { + ModuleData module = createModule(VERSION_WITH_LICENSE, null) + File overridesFile = createOverridesFile(moduleId(VERSION_WITHOUT_LICENSE)) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals( + [name: null, url: null, projectUrl: null], + overrides[moduleId(VERSION_WITH_LICENSE)]) + assertTrue(licenseNames(module).isEmpty()) + } + + @Test + void preservesExistingOverrideWhenRemovingOlderVersion() { + ModuleData module = createModule(VERSION_WITH_LICENSE, null) + File overridesFile = createOverridesFile( + [ + (moduleId(VERSION_WITHOUT_LICENSE)): [ + name: APACHE_NAME, url: APACHE_URL + ], + (moduleId(VERSION_WITH_LICENSE)): [ + name: APACHE_NAME, url: APACHE_URL, projectUrl: PROJECT_URL + ] + ]) + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertFalse(overrides.containsKey(moduleId(VERSION_WITHOUT_LICENSE))) + assertEquals( + [name: APACHE_NAME, url: APACHE_URL, projectUrl: PROJECT_URL], + overrides[moduleId(VERSION_WITH_LICENSE)]) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + @Test + void addsMissingOverrideForModuleWithoutLicense() { + ModuleData module = createModule(VERSION_WITHOUT_LICENSE, null) + File overridesFile = createEmptyOverridesFile() + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + Map overrides = readOverrides(overridesFile) + assertEquals( + [name: null, url: null, projectUrl: null], + overrides[moduleId(VERSION_WITHOUT_LICENSE)]) + assertTrue(licenseNames(module).isEmpty()) + } + + @Test + void doesNotAddOverrideForModuleWithLicense() { + License publishedLicense = new License(APACHE_NAME, APACHE_URL) + ModuleData module = createModule(VERSION_WITH_LICENSE, publishedLicense) + File overridesFile = createEmptyOverridesFile() + + debugState('before filter', module, overridesFile) + new ModuleLicenseOverrideFilter(overridesFile).filter(createProjectData(module)) + debugState('after filter', module, overridesFile) + + assertTrue(readOverrides(overridesFile).isEmpty()) + assertEquals([APACHE_NAME], licenseNames(module)) + } + + private File createOverridesFile(String moduleId) { + Map> overrides = [ + (moduleId): [name: APACHE_NAME, url: APACHE_URL] + ] + return createOverridesFile(overrides) + } + + private File createOverridesFile(Map> overrides) { + File overridesFile = temporaryDirectory.resolve('license-overrides.json').toFile() + overridesFile.setText(JsonOutput.prettyPrint(JsonOutput.toJson(overrides)), 'UTF-8') + return overridesFile + } + + private File createEmptyOverridesFile() { + File overridesFile = temporaryDirectory.resolve('license-overrides.json').toFile() + overridesFile.setText('{}', 'UTF-8') + return overridesFile + } + + private static Map readOverrides(File overridesFile) { + return new JsonSlurper().parse(overridesFile) as Map + } + + private static void debugState(String stage, ModuleData module, File overridesFile) { + String resolvedModuleId = "${module.group}:${module.name}:${module.version}" + Map overrides = readOverrides(overridesFile) + System.out.println( + "[license-override-test] ${stage}: module=${resolvedModuleId}, " + + "licenses=${licenseNames(module)}, " + + "matchingOverride=${overrides.containsKey(resolvedModuleId)}, " + + "overrideKeys=${overrides.keySet().sort()}") + } + + private static ProjectData createProjectData(ModuleData module) { + return createProjectData(module as ModuleData[]) + } + + private static ProjectData createProjectData(ModuleData... modules) { + ConfigurationData configuration = + new ConfigurationData( + 'runtimeClasspath', modules as LinkedHashSet) + return new ProjectData( + ProjectBuilder.builder().build(), + [configuration] as LinkedHashSet) + } + + private static ModuleData createModule(String version, License license) { + Set manifests = new LinkedHashSet<>() + if (license != null) { + manifests.add( + new ManifestData( + MODULE, + version, + null, + null, + null, + [license] as LinkedHashSet, + false)) + } + return new ModuleData( + GROUP, + MODULE, + version, + true, + manifests, + new LinkedHashSet<>(), + new LinkedHashSet<>()) + } + + private static String moduleId(String version) { + return "${GROUP}:${MODULE}:${version}" + } + + private static List licenseNames(ModuleData module) { + Set licenses = LicenseDataCollector.multiModuleLicenseInfo(module).licenses + return licenses.collect { license -> license.name }.sort() + } +} diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 5c6680661f..ab79caf9f0 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -17,6 +17,8 @@ WORKDIR /app COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 54458667be..4ae0ee81e7 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -30,6 +30,8 @@ WORKDIR /app COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 9b2655987f..6e679b97c8 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -31,6 +31,8 @@ WORKDIR /app COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index bcf189e9ee..14f9e934d0 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -23,6 +23,8 @@ WORKDIR /app # Copy gradle files for dependency resolution COPY build.gradle settings.gradle gradlew ./ COPY gradle/ gradle/ +COPY buildSrc/build.gradle buildSrc/ +COPY buildSrc/src/main/ buildSrc/src/main/ COPY app/core/build.gradle app/core/ COPY app/common/build.gradle app/common/ COPY app/proprietary/build.gradle app/proprietary/ From ddc0baa41daa863c8952f071a5a275cf33f51f1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:00:50 +0000 Subject: [PATCH 18/99] build(deps-dev): bump ip-address from 10.2.0 to 10.4.0 in /frontend (#7278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0.
    Release notes

    Sourced from ip-address's releases.

    v10.4.0

    What's Changed

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.3.1...v10.4.0

    v10.3.1

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.3.0...v10.3.1

    v10.3.0

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.2.2...v10.3.0

    v10.2.2

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.2.1...v10.2.2

    v10.2.1

    Full Changelog: https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.2.1

    Commits
    • fbb8db2 10.4.0
    • 45a2b11 Validate the byte arrays Address6 is given (#217)
    • bac8810 Keep the package loadable on node 12, and enforce it (#216)
    • 9b3d848 Add a security policy and a README section on security posture
    • e84a7b3 Order the README API reference Address4, Address6, AddressError
    • 015160b Collapse each class in the README API reference
    • 34061a8 Pin checkout and setup-node to commits in the release job
    • c5fae5d Pin action-gh-release to a commit and move it to 3.0.2
    • e0ef048 Replace CircleCI with GitHub Actions
    • 5e3ceb7 Add GitHub Actions CI across Node 20, 22, 24 and 25 (#213)
    • Additional commits viewable in compare view
    Maintainer changes

    This version was pushed to npm by GitHub Actions, a new releaser for ip-address since your current version.

    Install script changes

    This version adds prepare script that runs during installation. Review the package contents before updating.


    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ip-address&package-manager=npm_and_yarn&previous-version=10.2.0&new-version=10.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7db904ddf6..a5abc659e3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10774,9 +10774,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "dev": true, "license": "MIT", "engines": { From 7aeec93032a952c78cf10e77804e71052df1dc38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:01:20 +0100 Subject: [PATCH 19/99] build(deps): bump softprops/action-gh-release from 3.0.0 to 3.0.2 (#7273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.0 to 3.0.2.
    Release notes

    Sourced from softprops/action-gh-release's releases.

    v3.0.2

    3.0.2 is a patch release focused on release reliability and compatibility. It reuses existing draft releases when publishing prereleases, supports replacing release assets on Gitea, hardens streamed asset uploads, and provides clearer release-creation diagnostics. It also includes TypeScript, coverage, and tooling maintenance merged since 3.0.1.

    This release fixes #795, #438, and #803. The upload transport hardening covers the historical failure reported in #790, although current hosted Node 24 runners did not reproduce it naturally. The diagnostics work is related to #786 and does not claim a reproducible release-creation fix.

    What's Changed

    Exciting New Features 🎉

    Bug fixes 🐛

    Other Changes 🔄

    v3.0.1

    3.0.1

    • maintenance release with updated dependencies
    Changelog

    Sourced from softprops/action-gh-release's changelog.

    3.0.2

    3.0.2 is a patch release focused on release reliability and compatibility. It reuses existing draft releases when publishing prereleases, supports replacing release assets on Gitea, hardens streamed asset uploads, and provides clearer release-creation diagnostics. It also includes TypeScript, coverage, and tooling maintenance merged since 3.0.1.

    This release fixes #795, #438, and #803. The upload transport hardening covers the historical failure reported in #790, although current hosted Node 24 runners did not reproduce it naturally. The diagnostics work is related to #786 and does not claim a reproducible release-creation fix.

    What's Changed

    Exciting New Features 🎉

    Bug fixes 🐛

    Other Changes 🔄

    3.0.1

    • maintenance release with updated dependencies

    3.0.0

    3.0.0 is a major release that moves the action runtime from Node 20 to Node 24. Use v3 on GitHub-hosted runners and self-hosted fleets that already support the Node 24 Actions runtime. v2.6.2 was the final Node 20-compatible release and is no longer maintained or supported.

    What's Changed

    Other Changes 🔄

    • Move the action runtime and bundle target to Node 24
    • Update @types/node to the Node 24 line and allow future Dependabot updates
    • Keep the floating major tag on v3; freeze v2 at the final v2.6.2 release

    ... (truncated)

    Commits
    • 3d0d988 release 3.0.2 (#818)
    • 7e13ed4 fix: clarify release creation 404 errors (#817)
    • e6c70a5 fix: replace existing release assets on Gitea (#816)
    • f345337 fix: publish existing draft releases as prereleases (#801)
    • d8a89a2 fix: upload small checksum assets reliably (#815)
    • 45ece40 chore(deps): remove unused TypeScript tooling (#814)
    • f6b913c feat: improve release error reporting and test coverage (#813)
    • 15f193d chore(deps): upgrade TypeScript to 7 (#812)
    • cc8268d chore(deps): bump actions/checkout in the github-actions group (#810)
    • fd0ed1e chore(deps): bump the npm group with 3 updates (#811)
    • Additional commits viewable in compare view

    Most Recent Ignore Conditions Applied to This Pull Request | Dependency Name | Ignore Conditions | | --- | --- | | softprops/action-gh-release | [>= 2.2.a, < 2.3] |
    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=softprops/action-gh-release&package-manager=github_actions&previous-version=3.0.0&new-version=3.0.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/multiOSReleases.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index f5a99a0ef2..ebf2585bb1 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -902,7 +902,7 @@ jobs: # instead of silently shipping a broken auto-update. - name: Upload binaries to Release if: (github.event_name == 'workflow_dispatch' && github.event.inputs.test_mode != 'true' && github.event.inputs.platform == 'all') || github.event_name == 'release' || github.ref == 'refs/heads/V2-master' - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: tag_name: v${{ needs.determine-matrix.outputs.version }} # Don't regenerate/append notes on re-runs, and don't force this into the From 5c319f13cbbc72c48dcad93eda7a36e6cf44a4aa Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:13:30 +0000 Subject: [PATCH 20/99] Fix pt-BR download label mislabeled as "Baixar (JSON)" (#7043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - **What:** Corrected the pt-BR (Brazilian Portuguese) `download` translation from `"Baixar (JSON)"` to `"Baixar"` in `frontend/editor/public/locales/pt-BR/translation.toml`, in both the root table (line 32) and the `[fileManager]` section (line 3577). - **Why:** The generic `download` key flows through `useFileActionTerminology` (`download: t("download", "Download")`) into the shared download button rendered on tool-result screens (e.g. `ReviewToolStep`). Because the string was `"Baixar (JSON)"`, every tool's Download button showed "Baixar (JSON)" for pt-BR users — implying a JSON export regardless of the actual output format. This mislabeling was locale-wide (all pt-BR users, all tool downloads). Session autocapture confirmed the confusion: a pt-BR user on `/convert` repeatedly clicked a button whose text was exactly "Baixar (JSON)", then abandoned the flow. Nothing crashed — it's a confusing label, not a functional break. - **Scope / verification:** en-US uses plain `"Download"` for this key and pt-PT already uses `"Transferir"`; no other locale carried the `"(JSON)"` suffix on the download key, so the defect was isolated to pt-BR. Only translation values changed — no keys added/removed, so translation counts are unaffected. Note: I scoped this to the mislabel — the exact symptom users observed. The report also mentions the download being a silent anchor-click with no success toast; that's a separate, broader UX enhancement in `ReviewToolStep`/`WorkbenchBar`/`downloadService`, so it's intentionally left out of this focused translation fix. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Translations (if applicable) - [x] Only a value correction in `pt-BR`; no translation tags added or removed. --- *Created with [PostHog Code](https://posthog.com/code?ref=pr) from [an inbox report](posthog-code://inbox/019f655d-bd60-78c2-ba59-98c23243ed57).* Co-authored-by: posthog-eu[bot] <226701856+posthog-eu[bot]@users.noreply.github.com> --- frontend/editor/public/locales/pt-BR/translation.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/editor/public/locales/pt-BR/translation.toml b/frontend/editor/public/locales/pt-BR/translation.toml index 6fbc440d20..689d72b58c 100644 --- a/frontend/editor/public/locales/pt-BR/translation.toml +++ b/frontend/editor/public/locales/pt-BR/translation.toml @@ -29,7 +29,7 @@ customTextTooltip = "Formato personalizado opcional para os números de página. delete = "Apagar" details = "Detalhes" discardChanges = "Descartar alterações" -download = "Baixar (JSON)" +download = "Baixar" downloadPdf = "Baixar PDF" downloadUnavailable = "Download indisponível para este item" edit = "Editar" @@ -3574,7 +3574,7 @@ deleteAll = "Excluir tudo" deleteSelected = "Apagar Selecionados" deselectAll = "Desselecionar Tudo" details = "Detalhes do arquivo" -download = "Baixar (JSON)" +download = "Baixar" downloadSelected = "Baixar selecionados" dropFilesHere = "Solte os arquivos aqui" fileFormat = "Formato" From 94fbc74271a44f4817672e71580df45f5f8c156f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:20:17 +0100 Subject: [PATCH 21/99] build(deps): bump the uv group across 1 directory with 3 updates (#7287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the uv group with 3 updates in the /engine directory: [cryptography](https://github.com/pyca/cryptography), [aiohttp](https://github.com/aio-libs/aiohttp) and [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator). Updates `cryptography` from 49.0.0 to 50.0.0
    Changelog

    Sourced from cryptography's changelog.

    50.0.0 - 2026-07-31

    
    * **SECURITY ISSUE**:
    
    :func:`~cryptography.hazmat.primitives.serialization.pkcs7.pkcs7_decrypt_der`
    and its PEM and S/MIME variants no longer expose distinguishable errors
    or
    timing when unwrapping a ``RecipientInfo``'s ``encryptedKey``, which
    could
    act as a Bleichenbacher oracle for callers that decrypt untrusted
    messages.
    A random key is now substituted on failure, as described in :rfc:`3218`.
      Credit to **@X1AOxiang** for reporting the issue. **CVE-2026-69247**
    * Deprecated Diffie-Hellman key exchange over finite fields (FFDH).
      Everything FFDH is deprecated, including the types in
    ``cryptography.hazmat.primitives.asymmetric.dh`` and loading FFDH keys
    or
      parameters with the key loading APIs. Users should migrate to a more
      modern key exchange algorithm.
    * Added ``xof()`` class methods to
      :class:`~cryptography.hazmat.primitives.hashes.SHAKE128` and
    :class:`~cryptography.hazmat.primitives.hashes.SHAKE256` for
    constructing
      algorithm instances configured for use with
      :class:`~cryptography.hazmat.primitives.hashes.XOFHash`.
    * The :mod:`X.509 verification <cryptography.x509.verification>`
    APIs are now
      considered stable and are subject to our API stability policy.
    * Added the :doc:`/cobblestone` recipe, an implementation of the
      Cobblestone-128 and Cobblestone-256 instantiations of the `C2SP
      chunked-encryption specification
    <https://c2sp.org/chunked-encryption>`_ for streaming
    authenticated
      encryption of large messages.
    * Parsing a Signed Certificate Timestamp list now rejects encodings that
    carry trailing bytes after the list or after an individual SCT, instead
    of
      silently ignoring them.
    * Added support for using :class:`~cryptography.x509.Name` as a field
    type in
      the :doc:`/hazmat/asn1/index` module.
    * Loading a public key or an EC private key now rejects DER where the
    ``subjectPublicKey`` (or EC ``publicKey``) ``BIT STRING`` declares a
    non-zero
      number of unused bits, instead of silently ignoring it.
    * Parsing a CRL entry's ``InvalidityDate`` extension now rejects a
    ``GeneralizedTime`` that carries fractional seconds or another non-DER
    form,
    matching the strict encoding already required for every other X.509 time
      field.
    * :func:`~cryptography.x509.ocsp.load_der_ocsp_request` and
    :func:`~cryptography.x509.ocsp.load_der_ocsp_response` now reject a
    request
    or response whose ``version`` field is not ``v1``, the only version
    defined
    by RFC 6960, matching the version validation already performed when
    loading
      certificates, CSRs and CRLs.
    * :class:`~cryptography.hazmat.primitives.hashes.XOFHash` is now
    supported
      when building against AWS-LC.
    * HMAC (and therefore PBKDF2-HMAC) with SHA-3 hashes is now supported
    when
      building against AWS-LC.
    * Diffie-Hellman (:doc:`/hazmat/primitives/asymmetric/dh`) is now
    supported
      when building against AWS-LC.
    </tr></table>
    

    ... (truncated)

    Commits

    Updates `aiohttp` from 3.14.1 to 3.14.3
    Changelog

    Sourced from aiohttp's changelog.

    3.14.3 (2026-07-22)

    Bug fixes

    • Fixed the client dropping only the first Authorization, Cookie and Proxy-Authorization header when a redirect crossed an origin -- by :user:arshsmith1.

      Related issues and pull requests on GitHub: :issue:13180.

    • Fixed error message construction in the C HTTP parser -- by :user:bdraco.

      Related issues and pull requests on GitHub: :issue:13222.


    3.14.2 (2026-07-20)

    Bug fixes

    • Fixed :py:attr:~aiohttp.web.StreamResponse.last_modified rounding a :class:datetime.datetime with a fractional second down.

      Related issues and pull requests on GitHub: :issue:5303.

    • Fixed resolving localhost on Windows to fall back without AI_ADDRCONFIG when the first lookup fails, so localhost still works without an active network.

      Related issues and pull requests on GitHub: :issue:5357.

    ... (truncated)

    Commits

    Updates `datamodel-code-generator` from 0.56.0 to 0.64.0
    Release notes

    Sourced from datamodel-code-generator's releases.

    0.64.0

    Breaking Changes

    Code Generation Changes

    • Self-referencing fields are now quoted with --disable-future-imports - When --disable-future-imports is set (no from __future__ import annotations and no native PEP 649 deferred evaluation on Python < 3.14), self-referencing and forward-referencing field annotations in regular BaseModel classes are now emitted as quoted forward references instead of bare names. Previously such annotations were left unquoted, producing invalid code that raised NameError (Ruff F821) at class-evaluation time. Output for the common case (with from __future__ import annotations or Python 3.14 native deferred annotations) is unchanged. Users who snapshot/golden-file generated output for the --disable-future-imports configuration with self-referencing models will see the annotation change from unquoted to quoted, e.g. children: Optional[List[Node]]children: Optional[List["Node"]]. (#3387)

    What's Changed

    ... (truncated)

    Changelog

    Sourced from datamodel-code-generator's changelog.

    0.64.0 - 2026-06-14

    Breaking Changes

    Code Generation Changes

    • Self-referencing fields are now quoted with --disable-future-imports - When --disable-future-imports is set (no from __future__ import annotations and no native PEP 649 deferred evaluation on Python < 3.14), self-referencing and forward-referencing field annotations in regular BaseModel classes are now emitted as quoted forward references instead of bare names. Previously such annotations were left unquoted, producing invalid code that raised NameError (Ruff F821) at class-evaluation time. Output for the common case (with from __future__ import annotations or Python 3.14 native deferred annotations) is unchanged. Users who snapshot/golden-file generated output for the --disable-future-imports configuration with self-referencing models will see the annotation change from unquoted to quoted, e.g. children: Optional[List[Node]]children: Optional[List["Node"]]. (#3387)

    What's Changed

    ... (truncated)

    Commits

    Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- engine/pyproject.toml | 2 +- engine/uv.lock | 302 +++++++++++++++++++++--------------------- 2 files changed, 152 insertions(+), 152 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 8cbb94daf8..29f2dcbdf7 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "AI Document Engine" requires-python = ">=3.13" dependencies = [ - "cryptography>=44.0.0", + "cryptography>=50.0.0", "fastapi>=0.116.0", "pgvector>=0.3.6", "psycopg[binary,pool]>=3.2", diff --git a/engine/uv.lock b/engine/uv.lock index fd10a81182..4f0ff52acd 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -41,7 +41,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -52,72 +52,72 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -473,52 +473,52 @@ wheels = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, ] [[package]] @@ -538,21 +538,21 @@ wheels = [ [[package]] name = "datamodel-code-generator" -version = "0.56.0" +version = "0.64.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, - { name = "black" }, + { name = "black", marker = "sys_platform != 'emscripten'" }, { name = "genson" }, { name = "inflect" }, - { name = "isort" }, + { name = "isort", marker = "sys_platform != 'emscripten'" }, { name = "jinja2" }, { name = "pydantic" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/7d/7fc2bb3d8946ca45851da3f23497a2c6e252e92558ccbd89d609cf1e13d4/datamodel_code_generator-0.56.0.tar.gz", hash = "sha256:e7c003fb5421b890aabe12f66ae65b57198b04cfe1da7c40810798020835b3a8", size = 837708, upload-time = "2026-04-04T09:46:19.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/d2/86c94a2836ed42231653a7ddaefa0a5bc23418167a876bba7376c96b3a35/datamodel_code_generator-0.64.0.tar.gz", hash = "sha256:9c592900a00b20e416494273c22435f5a9aef6ea8c7b9190747522a60497a1cb", size = 1316440, upload-time = "2026-06-14T17:24:50.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/23/94/71338e2f0146ac10747a5537b3a1e45256e66b7c229869eb0ee787111b41/datamodel_code_generator-0.64.0-py3-none-any.whl", hash = "sha256:b7cd8bd41a312aa997aec6150670bad781847c5b674f17e4d70e78208a0fb990", size = 374698, upload-time = "2026-06-14T17:24:48.809Z" }, ] [package.optional-dependencies] @@ -632,7 +632,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "cryptography", specifier = ">=44.0.0" }, + { name = "cryptography", specifier = ">=50.0.0" }, { name = "fastapi", specifier = ">=0.116.0" }, { name = "opentelemetry-sdk", specifier = ">=1.39.0" }, { name = "pgvector", specifier = ">=0.3.6" }, @@ -800,7 +800,7 @@ name = "ffmpeg-python" version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "future", marker = "python_full_version < '3.14'" }, + { name = "future" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dd/5e/d5f9105d59c1325759d838af4e973695081fbbc97182baf73afc78dec266/ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127", size = 21543, upload-time = "2019-07-06T00:19:08.989Z" } wheels = [ @@ -1346,7 +1346,7 @@ name = "jsonpatch" version = "1.33" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jsonpointer", marker = "python_full_version < '3.14'" }, + { name = "jsonpointer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } wheels = [ @@ -1444,15 +1444,15 @@ name = "langchain-core" version = "1.4.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "jsonpatch", marker = "python_full_version < '3.14'" }, - { name = "langchain-protocol", marker = "python_full_version < '3.14'" }, - { name = "langsmith", marker = "python_full_version < '3.14'" }, - { name = "packaging", marker = "python_full_version < '3.14'" }, - { name = "pydantic", marker = "python_full_version < '3.14'" }, - { name = "pyyaml", marker = "python_full_version < '3.14'" }, - { name = "tenacity", marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, - { name = "uuid-utils", marker = "python_full_version < '3.14'" }, + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } wheels = [ @@ -1464,7 +1464,7 @@ name = "langchain-protocol" version = "0.0.18" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } wheels = [ @@ -1476,7 +1476,7 @@ name = "langchain-text-splitters" version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "langchain-core", marker = "python_full_version < '3.14'" }, + { name = "langchain-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" } wheels = [ @@ -1488,20 +1488,20 @@ name = "langsmith" version = "0.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version < '3.14'" }, - { name = "distro", marker = "python_full_version < '3.14'" }, - { name = "httpx", marker = "python_full_version < '3.14'" }, - { name = "orjson", marker = "python_full_version < '3.14' and platform_python_implementation != 'PyPy'" }, - { name = "packaging", marker = "python_full_version < '3.14'" }, - { name = "pydantic", marker = "python_full_version < '3.14'" }, - { name = "requests", marker = "python_full_version < '3.14'" }, - { name = "requests-toolbelt", marker = "python_full_version < '3.14'" }, - { name = "sniffio", marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, - { name = "uuid-utils", marker = "python_full_version < '3.14'" }, - { name = "websockets", marker = "python_full_version < '3.14'" }, - { name = "xxhash", marker = "python_full_version < '3.14'" }, - { name = "zstandard", marker = "python_full_version < '3.14'" }, + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "sniffio" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/26/b72987d947278f63ec1e85f01ce85ca7ab2621c7efc0845d4a3a8e5d5dfb/langsmith-0.9.1.tar.gz", hash = "sha256:e5eb905224d156bcece4985285c55b51fffcb06c9353b2c4adb42e1c48b0d05d", size = 4557557, upload-time = "2026-06-23T17:04:23.233Z" } wheels = [ @@ -2879,7 +2879,7 @@ name = "requests-toolbelt" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests", marker = "python_full_version < '3.14'" }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } wheels = [ @@ -3343,16 +3343,16 @@ name = "voyageai" version = "0.3.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiohttp", marker = "python_full_version < '3.14'" }, - { name = "aiolimiter", marker = "python_full_version < '3.14'" }, - { name = "ffmpeg-python", marker = "python_full_version < '3.14'" }, - { name = "langchain-text-splitters", marker = "python_full_version < '3.14'" }, - { name = "numpy", marker = "python_full_version < '3.14'" }, - { name = "pillow", marker = "python_full_version < '3.14'" }, - { name = "pydantic", marker = "python_full_version < '3.14'" }, - { name = "requests", marker = "python_full_version < '3.14'" }, - { name = "tenacity", marker = "python_full_version < '3.14'" }, - { name = "tokenizers", marker = "python_full_version < '3.14'" }, + { name = "aiohttp" }, + { name = "aiolimiter" }, + { name = "ffmpeg-python" }, + { name = "langchain-text-splitters" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "tenacity" }, + { name = "tokenizers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/16/1b46b3cd401e1717a68197c1fe336d7bb4e0a1833f8105e1738f5b1add05/voyageai-0.3.7.tar.gz", hash = "sha256:826cd97f97223f42b5babc5c459c9c80f3a8215ce5c0e007b0b276550f790d24", size = 26485, upload-time = "2025-12-16T18:43:05.26Z" } wheels = [ From 8476d3cdec02ce59a51b055c8ade1e4405a322a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:20:27 +0000 Subject: [PATCH 22/99] build(deps-dev): bump eslint from 10.1.0 to 10.8.0 in /frontend in the eslint group across 1 directory (#7274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the eslint group with 1 update in the /frontend directory: [eslint](https://github.com/eslint/eslint). Updates `eslint` from 10.1.0 to 10.8.0
    Release notes

    Sourced from eslint's releases.

    v10.8.0

    Features

    • 2fee9bb feat: export ConfigObject from eslint/config (#21082) (sethamus)

    Bug Fixes

    • 6b8d2f7 fix: escape reserved characters in rule id in html formatter (#21129) (Francesco Trotta)
    • 9091071 fix: prevent no-unreachable-loop crash when all loop types are ignored (#21116) (Pixel)
    • e23fafe fix: prefer-object-spread add semicolon when adding parenthesis (#21081) (synthex-byte)
    • 20b5ad0 fix: quadratic-time regex in prefer-template (#21096) (Milos Djermanovic)
    • 8b6f6c0 fix: apply ignore configs to computed methods in class-methods-use-this (#21094) (Pixel)
    • b2c608c fix: NewExpression with parenthesized callee in preserve-caught-error (#21083) (Francesco Trotta)

    Documentation

    • 6ddf858 docs: fix broken Specify Parser Options anchor link (#21106) (Minsu)
    • 784dfbe docs: Clarify no-eq-null description (#21120) (Park Harin)
    • 7ec733a docs: Fix typos and grammar in glossary (#21095) (Marry (Subin Yang))
    • 92bb13f docs: replace quake link (#21108) (Jung Hyeon Jun)
    • 68eb4a5 docs: fix broken Specify Globals anchor links in rule pages (#21103) (Minsu)
    • d28f697 docs: replace Code Climate CLI links with Qlty CLI links (#21099) (Jung Hyeon Jun)
    • eccc68d docs: correct --suppressions-location option description (#21093) (Ga eun Lee)
    • c5963f7 docs: Update README (GitHub Actions Bot)

    Chores

    • 4fbf46d test: pin webpack version to 5.108.4 (#21137) (Francesco Trotta)
    • 2d063e2 chore: update HTTP URLs to HTTPS in JSDoc and comments (#21101) (Bo Hyun Kim)
    • eccbe7b test: add error locations to no-class-assign (#21123) (devoil)
    • e7d1e43 ci: bump actions/setup-go from 6 to 7 (#21118) (dependabot[bot])
    • e9d66d0 ci: bump actions/setup-node from 6 to 7 (#21119) (dependabot[bot])
    • ee225b6 test: Add error location details to no-eq-null rule (#21117) (Park Harin)
    • 044a627 chore: update minimatch to ^10.2.5 (#21107) (김채영)
    • fb09aa8 chore: update ecosystem plugins (#21115) (ESLint Bot)
    • 5abd878 test: add error locations to no-proto (#21114) (Gihyeon Jeong / 정기현)
    • 9715887 test: Add error location details to no-div-regex (#21110) (Park Harin)
    • a746ec6 test: add error locations to no-new-wrappers (#21109) (Gihyeon Jeong / 정기현)
    • 8dde645 test: add error locations to no-ex-assign (#21102) (devoil)
    • 13ab0ec test: add error locations to no-label-var (#21098) (Gihyeon Jeong / 정기현)
    • a99906f test: Add error location details to no-delete-var rule (#21105) (Park Harin)
    • c47e8dc chore: add missing backticks to languages/js/index.js (#21104) (beeen)
    • 0174428 chore: add missing backticks to translate-cli-options.js (#21097) (dongkyu lee)
    • 3d36589 chore: add missing backticks to serialization.js (#21091) (이규환)
    • dcc9312 test: add error locations to eqeqeq (#21090) (Ga eun Lee)
    • 2710b18 ci: Add explicit permissions to rebuild-docs-sites workflow (#21089) (Marry (Subin Yang))
    • 5d2f866 chore: update dependency prettier to v3.9.5 (#21086) (renovate[bot])
    • d584e31 chore: fix failing ecosystem test for eslint-plugin-unicorn (#21084) (Francesco Trotta)
    • bf3eda0 chore: update ecosystem plugins (#21079) (ESLint Bot)

    v10.7.0

    Features

    • cf2a9bf feat: add errorClassNames option to preserve-caught-error rule (#21032) (sethamus)
    • f8b873a feat: max-nested-callbacks option for constructor callbacks (#21063) (fnx)

    ... (truncated)

    Commits

    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 65 ++++++++++++++++++++------------------ frontend/package.json | 2 +- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5abc659e3..300a409229 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -119,7 +119,7 @@ "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", "dpdm": "^3.14.0", - "eslint": "^10.0.2", + "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", @@ -1961,13 +1961,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.3", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", - "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.3", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" }, @@ -1976,22 +1976,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", - "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1" + "@eslint/core": "^1.2.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", - "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2023,9 +2023,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", - "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2033,13 +2033,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", - "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { @@ -9286,18 +9286,21 @@ } }, "node_modules/eslint": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", - "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.3", - "@eslint/core": "^1.1.1", - "@eslint/plugin-kit": "^0.6.1", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -9319,7 +9322,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -12905,13 +12908,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" diff --git a/frontend/package.json b/frontend/package.json index dfcfa2842c..1798abc6b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -141,7 +141,7 @@ "@vitest/coverage-v8": "^3.2.4", "dotenv": "^16.4.7", "dpdm": "^3.14.0", - "eslint": "^10.0.2", + "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", From de93a1b5e2685f2a6022f54ed1eb888c9b5de469 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:20:33 +0000 Subject: [PATCH 23/99] build(deps): bump org.sonarqube from 7.2.3.7755 to 7.3.1.8318 (#7271) Bumps org.sonarqube from 7.2.3.7755 to 7.3.1.8318. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.sonarqube&package-manager=gradle&previous-version=7.2.3.7755&new-version=7.3.1.8318)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 31fa6b1bdb..de0861d8fa 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { id "com.diffplug.spotless" version "8.8.0" id "com.github.jk1.dependency-license-report" //id "nebula.lint" version "19.0.3" - id "org.sonarqube" version "7.2.3.7755" + id "org.sonarqube" version "7.3.1.8318" } import com.github.jk1.license.render.* From ad8830b6459a74fd4f6b3b3cb479b97ae8dc9df4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:20:38 +0000 Subject: [PATCH 24/99] build(deps): bump com.sun.xml.bind:jaxb-core from 4.0.7 to 4.0.9 (#7270) Bumps com.sun.xml.bind:jaxb-core from 4.0.7 to 4.0.9. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=com.sun.xml.bind:jaxb-core&package-manager=gradle&previous-version=4.0.7&new-version=4.0.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/core/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/build.gradle b/app/core/build.gradle index 33aa679994..5e90672f75 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -57,7 +57,7 @@ dependencies { // veraPDF still uses javax.xml.bind, not the new jakarta namespace implementation 'javax.xml.bind:jaxb-api:2.3.1' implementation 'com.sun.xml.bind:jaxb-impl:2.3.9' - implementation 'com.sun.xml.bind:jaxb-core:4.0.7' + implementation 'com.sun.xml.bind:jaxb-core:4.0.9' // CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7) implementation "com.google.code.gson:gson:${gsonVersion}" From 74c53001cf4e652d96d2948d0b1058a827b98e24 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:05:24 +0100 Subject: [PATCH 25/99] fix(saas): give auth-bootstrap data fetching a single owner (#7194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The problem Three pieces of user data (pro status, avatar metadata, profile picture) were being fetched from four different places: `initializeAuth` on mount, the `SIGNED_IN` handler, the `TOKEN_REFRESHED` handler, and the post-upgrade path. On a fresh login the first two both see a session, so everything got fetched twice. It didn't stop after login either — Supabase re-fires `SIGNED_IN` on token refresh and tab-visibility wakeups, so a refresh that emitted both events cost around 7 Supabase reads. ## The fix All four call sites now go through one `loadUserData(session)` that is idempotent per identity. The guard key is `user.id` + `is_anonymous`: - not the access token, which changes on every refresh and would defeat the guard entirely - the anonymous flag matters because a guest to authenticated upgrade keeps the same user id, and that is the one case where the data genuinely does need reloading **Per login: 6 fetches to 3. A repeat `SIGNED_IN` or `TOKEN_REFRESHED` fetches nothing.** The tests count real calls rather than asserting on shape. ## Two behaviour changes worth naming - `initializeAuth` now awaits the full load, so the initial spinner also waits on the profile-picture URL. Net login is still faster, since an entire duplicate pass is gone. - A tab-wake `SIGNED_IN` no longer revalidates entitlements. That revalidation was accidental rather than designed — `refreshProStatus()` is the intended path, and post-checkout is already handled by `CheckoutContext`. ## Scope Supabase-origin traffic only. This does not touch the ~20 authenticated requests hitting `SupabaseAuthenticationFilter`, because those go to the Stirling backend rather than the hosted Supabase project. That is a separate problem and is unmeasured, so it needs measuring before anything is optimised. Remaining items (a double `/api/v1/team/my` fetch, an effect keyed on `[user]` identity in `FolderContext`, the `portalAccess` spinner flash, and caching the auth filter's per-request Postgres round-trips) are tracked separately. ## Verification ``` npx tsc --noEmit --project editor/src/saas/tsconfig.json # exit 0 npx eslint --max-warnings=0 editor/src/saas/auth # exit 0 npx prettier --check editor/src/saas/auth/ # clean npx vitest run --project saas # 75 passed (20 files) ``` --- .../src/saas/auth/AuthProvider.test.tsx | 339 ++++++++++++++++++ frontend/editor/src/saas/auth/UseSession.tsx | 165 +++++---- 2 files changed, 427 insertions(+), 77 deletions(-) create mode 100644 frontend/editor/src/saas/auth/AuthProvider.test.tsx diff --git a/frontend/editor/src/saas/auth/AuthProvider.test.tsx b/frontend/editor/src/saas/auth/AuthProvider.test.tsx new file mode 100644 index 0000000000..588507d59c --- /dev/null +++ b/frontend/editor/src/saas/auth/AuthProvider.test.tsx @@ -0,0 +1,339 @@ +import { act, render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Session, User } from "@supabase/supabase-js"; + +/** + * Request-count tests for {@link AuthProvider}'s data loading. It used to fetch + * pro status, avatar metadata and the picture from two places at once, and + * Supabase re-fires SIGNED_IN on token refresh and tab wakeups, so it kept + * happening. These pin the call counts. + */ + +type AuthCallback = (event: string, session: Session | null) => void; + +const rpc = vi.fn(); +const createSignedUrl = vi.fn(); +const storageFrom = vi.fn((_bucket: string) => ({ createSignedUrl })); +const getSession = vi.fn(); +const onAuthStateChange = vi.fn(); +const unsubscribe = vi.fn(); + +vi.mock("@app/auth/supabase", () => ({ + supabase: { + auth: { + getSession: () => getSession(), + onAuthStateChange: (cb: AuthCallback) => onAuthStateChange(cb), + refreshSession: vi + .fn() + .mockResolvedValue({ data: { session: null }, error: null }), + signOut: vi.fn().mockResolvedValue({ error: null }), + }, + rpc: (...args: unknown[]) => rpc(...args), + storage: { from: (bucket: string) => storageFrom(bucket) }, + }, + debugAuthEvents: vi.fn(), +})); + +const syncOAuthAvatar = vi.fn(); +const getProfilePictureMetadata = vi.fn(); + +vi.mock("@app/services/avatarSyncService", () => ({ + syncOAuthAvatar: (...args: unknown[]) => syncOAuthAvatar(...args), + getProfilePictureMetadata: (...args: unknown[]) => + getProfilePictureMetadata(...args), + getProviderAvatarUrl: () => null, +})); + +const synchronizeUserUpgrade = vi.fn(); + +vi.mock("@app/services/userService", () => ({ + synchronizeUserUpgrade: (...args: unknown[]) => + synchronizeUserUpgrade(...args), +})); + +// Imported after the mocks so the provider picks them up. +const { AuthProvider, useAuth } = await import("./UseSession"); + +/** Surfaces `loading` so a test can assert on it rather than on the container. */ +function LoadingProbe() { + const { loading } = useAuth(); + return {String(loading)}; +} + +const USER_ID = "11111111-2222-3333-4444-555555555555"; + +function makeSession( + overrides: { token?: string; userId?: string; anonymous?: boolean } = {}, +): Session { + const user = { + id: overrides.userId ?? USER_ID, + email: "someone@example.com", + is_anonymous: overrides.anonymous ?? false, + app_metadata: { provider: "google" }, + user_metadata: { full_name: "Some One" }, + } as unknown as User; + + return { + access_token: overrides.token ?? "token-1", + refresh_token: "refresh-1", + expires_in: 3600, + token_type: "bearer", + user, + } as unknown as Session; +} + +/** Total requests the provider makes per user-data load. */ +function callCounts() { + return { + proStatus: rpc.mock.calls.length, + metadata: getProfilePictureMetadata.mock.calls.length, + picture: createSignedUrl.mock.calls.length, + avatarSync: syncOAuthAvatar.mock.calls.length, + }; +} + +function renderProvider() { + let authCallback: AuthCallback = () => {}; + onAuthStateChange.mockImplementation((cb: AuthCallback) => { + authCallback = cb; + return { data: { subscription: { unsubscribe } } }; + }); + + const utils = render( + + + , + ); + + /** + * Deliver an auth event and let its work finish. The provider defers with + * setTimeout(0), so a microtask flush is not enough: without draining real + * macrotasks the assertions run before any refetch and prove nothing. + */ + const fire = async (event: string, s: Session | null) => { + await act(async () => { + authCallback(event, s); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }; + + return { ...utils, fire }; +} + +describe("AuthProvider user-data loading", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + sessionStorage.clear(); + + rpc.mockResolvedValue({ data: true, error: null }); + createSignedUrl.mockResolvedValue({ + data: { signedUrl: "https://example.test/avatar" }, + error: null, + }); + getProfilePictureMetadata.mockResolvedValue(null); + syncOAuthAvatar.mockResolvedValue(false); + synchronizeUserUpgrade.mockResolvedValue(undefined); + getSession.mockResolvedValue({ + data: { session: makeSession() }, + error: null, + }); + }); + + it("fetches each piece of user data exactly once on login", async () => { + const { fire } = renderProvider(); + + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + // The SIGNED_IN that follows a fresh login must not repeat the work. + await fire("SIGNED_IN", makeSession()); + + expect(callCounts()).toEqual({ + proStatus: 1, + metadata: 1, + picture: 1, + avatarSync: 1, + }); + }); + + it("does not refetch when SIGNED_IN repeats with a new access token", async () => { + const { fire } = renderProvider(); + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + const before = callCounts(); + + // What a tab-visibility wakeup or token refresh looks like: same user, + // different token. + await fire("SIGNED_IN", makeSession({ token: "token-2" })); + + expect(callCounts()).toEqual(before); + }); + + it("does not refetch on TOKEN_REFRESHED for the same identity", async () => { + const { fire } = renderProvider(); + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + const before = callCounts(); + + await fire("TOKEN_REFRESHED", makeSession({ token: "token-3" })); + + expect(callCounts()).toEqual(before); + }); + + it("keeps loading false across repeat auth events", async () => { + // Guards the Landing -> HomePage unmount: toggling `loading` on a wakeup + // would tear down the tree on every tab switch. + const { fire, getByTestId } = renderProvider(); + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false"), + ); + + await fire("SIGNED_IN", makeSession({ token: "token-4" })); + expect(getByTestId("loading").textContent).toBe("false"); + + await fire("TOKEN_REFRESHED", makeSession({ token: "token-5" })); + expect(getByTestId("loading").textContent).toBe("false"); + + expect(callCounts().proStatus).toBe(1); + }); + + it("clears the initial spinner without waiting for the avatar upload", async () => { + // syncOAuthAvatar re-uploads the provider image on a first login. Gating + // `loading` on it would stall account creation behind an image upload. + let releaseSync = () => {}; + syncOAuthAvatar.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSync = () => resolve(false); + }), + ); + + const { getByTestId } = renderProvider(); + + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false"), + ); + // The picture read chains behind the sync, so it has not run yet either. + expect(createSignedUrl).not.toHaveBeenCalled(); + + await act(async () => { + releaseSync(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }); + + it("refetches after a guest upgrade, which keeps the same user id", async () => { + // The upgrade path is the one case where the id is unchanged but the data + // must be reloaded - hence keying on is_anonymous, not the id alone. + getSession.mockResolvedValue({ + data: { session: makeSession({ anonymous: true }) }, + error: null, + }); + sessionStorage.setItem("pendingUpgrade", "true"); + sessionStorage.setItem("upgradeProvider", "google"); + + const { fire } = renderProvider(); + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(1)); + + await fire("USER_UPDATED", makeSession({ anonymous: false })); + + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(2)); + expect(synchronizeUserUpgrade).toHaveBeenCalledWith("google"); + }); + + it("does not drop an upgrade that lands while the guest load is in flight", async () => { + // Coalescing on "something is in flight" alone would hand the upgrade the + // guest's promise and never fetch the real user's data. + getSession.mockResolvedValue({ + data: { session: makeSession({ anonymous: true }) }, + error: null, + }); + sessionStorage.setItem("pendingUpgrade", "true"); + sessionStorage.setItem("upgradeProvider", "google"); + + let releaseGuestLoad = () => {}; + const guestLoadBlocked = new Promise((resolve) => { + releaseGuestLoad = resolve; + }); + rpc.mockImplementationOnce(async () => { + await guestLoadBlocked; + return { data: false, error: null }; + }); + + const { fire } = renderProvider(); + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(1)); + + // Deliver the upgrade with the guest load still deliberately unsettled, so + // the guard genuinely has an in-flight load to reason about. + await fire("USER_UPDATED", makeSession({ anonymous: false })); + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(2), { + timeout: 1000, + }); + + // Let the abandoned guest load settle inside act, so its trailing state + // updates do not land after the test finishes. + await act(async () => { + releaseGuestLoad(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }); + + it("reloads for the same user after a sign-out", async () => { + const { fire } = renderProvider(); + await waitFor(() => expect(createSignedUrl).toHaveBeenCalled()); + + await fire("SIGNED_OUT", null); + await fire("SIGNED_IN", makeSession()); + + await waitFor(() => expect(rpc).toHaveBeenCalledTimes(2)); + }); + + it("keeps the initial spinner up when SIGNED_IN wins the race with initializeAuth", async () => { + // initializeAuth yields at `await getSession()`, so SIGNED_IN can land + // first. Marking the identity loaded up front would then clear the spinner + // while pro status was still in flight. + const session = makeSession(); + + let releaseSession = () => {}; + getSession.mockReturnValueOnce( + new Promise<{ data: { session: Session }; error: null }>((resolve) => { + releaseSession = () => resolve({ data: { session }, error: null }); + }), + ); + + let releaseProStatus = () => {}; + rpc.mockImplementationOnce( + () => + new Promise<{ data: boolean; error: null }>((resolve) => { + releaseProStatus = () => resolve({ data: true, error: null }); + }), + ); + + const { getByTestId, fire } = renderProvider(); + + // SIGNED_IN lands first and starts the load; pro status stays unsettled. + await fire("SIGNED_IN", session); + expect(rpc).toHaveBeenCalledTimes(1); + + // initializeAuth must now adopt that in-flight load, not short-circuit. + await act(async () => { + releaseSession(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(getByTestId("loading").textContent).toBe("true"); + // Adopted, not restarted. + expect(rpc).toHaveBeenCalledTimes(1); + + await act(async () => { + releaseProStatus(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await waitFor(() => + expect(getByTestId("loading").textContent).toBe("false"), + ); + expect(rpc).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index 087fa99b65..101ed58d06 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, + useRef, useState, ReactNode, useCallback, @@ -247,6 +248,78 @@ export function AuthProvider({ children }: { children: ReactNode }) { await fetchProfilePictureMetadata(); }, [fetchProfilePictureMetadata]); + // Refs, not state: the auth effect below has an empty dep array. + const loadedForRef = useRef(null); + const inFlightRef = useRef<{ key: string; promise: Promise } | null>( + null, + ); + + /** + * Sole owner of the per-user data fetching: mount-time init and every auth + * event route through here. Idempotent per identity, since Supabase re-fires + * SIGNED_IN and TOKEN_REFRESHED on token refresh and tab wakeups; pass + * `force` for a genuine reload. The returned promise excludes the profile + * picture, so awaiting it never blocks on an image download. + */ + const loadUserData = useCallback( + ( + sessionToLoad: Session | null, + opts?: { force?: boolean }, + ): Promise => { + const user = sessionToLoad?.user; + if (!user) { + loadedForRef.current = null; + return Promise.resolve(); + } + + // Not the access token, which changes every refresh. The anonymous flag + // matters: a guest upgrade keeps the same id and must still refetch. + const key = `${user.id}:${Boolean(user.is_anonymous)}`; + if (!opts?.force && loadedForRef.current === key) + return Promise.resolve(); + + // The second of a concurrent init/SIGNED_IN pair adopts this promise so it + // still awaits the load. A forced reload must not: the guest upgrade + // would be silently dropped. + if (!opts?.force && inFlightRef.current?.key === key) + return inFlightRef.current.promise; + + const run = (async () => { + // Off the awaited path: a first login re-uploads the provider avatar. + // The signed-URL read chains behind it because reading first 404s and + // silently falls back to the provider photo. + const avatarSync = syncOAuthAvatar(user).catch((err) => { + console.debug("[Auth Debug] Failed to sync OAuth avatar:", err); + return false; + }); + void avatarSync + .then(() => fetchProfilePicture(sessionToLoad)) + .catch((err) => { + console.debug("[Auth Debug] Failed to fetch profile picture:", err); + }); + + await Promise.all([ + fetchProStatus(sessionToLoad), + fetchProfilePictureMetadata(sessionToLoad), + ]); + // Only on success: set up front, a concurrent caller short-circuits on + // it and returns to a still-empty state. Also lets a failure retry. + loadedForRef.current = key; + })() + .catch((err) => { + console.debug("[Auth Debug] Failed to load user data:", err); + }) + .finally(() => { + // Only clear our own entry; a newer load may have superseded us. + if (inFlightRef.current?.promise === run) inFlightRef.current = null; + }); + + inFlightRef.current = { key, promise: run }; + return run; + }, + [fetchProStatus, fetchProfilePictureMetadata, fetchProfilePicture], + ); + const refreshSession = async () => { try { setLoading(true); @@ -312,23 +385,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); setSession(data.session); - // Fetch pro status, profile picture metadata, and profile picture using the session from the response - if (data.session?.user) { - // Sync OAuth avatar in background; fetch the picture once the - // sync settles instead of guessing with a fixed delay. - syncOAuthAvatar(data.session.user) - .catch((err) => { - console.debug( - "[Auth Debug] Failed to sync OAuth avatar on init:", - err, - ); - return false; - }) - .then(() => fetchProfilePicture(data.session)); - - await fetchProStatus(data.session); - await fetchProfilePictureMetadata(data.session); - } + // Awaited so the spinner does not clear before pro status is known. + await loadUserData(data.session); } } catch (err) { console.error( @@ -374,58 +432,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { setIsPro(null); setProfilePictureUrl(null); setProfilePictureMetadata(null); - } else if (event === "SIGNED_IN") { - console.debug("[Auth Debug] User signed in successfully"); - if (newSession?.user) { - // Note: we deliberately do NOT toggle `loading` here. Supabase - // also fires SIGNED_IN on tab visibility / token-refresh wakeups - // (per its docs: "SIGNED_IN is fired when a user signs in OR - // when the access token is refreshed"), and gating the UI on - // `loading` would unmount Landing -> HomePage every time the - // user switches tabs back. Initial-mount loading is handled by - // `initializeAuth` above; downstream fetches expose their own - // null/loading states. - - // Sync OAuth avatar in background (don't block other fetches) - const avatarSync = syncOAuthAvatar(newSession.user).catch( - (err) => { - console.debug( - "[Auth Debug] Failed to sync OAuth avatar:", - err, - ); - return false; - }, - ); - - // Fetch user data in parallel - Promise.all([ - fetchProStatus(newSession), - fetchProfilePictureMetadata(newSession), - ]).then(() => { - // Fetch the picture once the avatar sync settles. - avatarSync.then(() => { - fetchProfilePicture(newSession).finally(() => { - console.debug( - "[Auth Debug] User data fully loaded after sign in", - ); - }); - }); - }); - } - } else if (event === "TOKEN_REFRESHED") { - console.debug("[Auth Debug] Token refreshed"); - // Optionally refresh pro status, profile picture metadata, and profile picture on token refresh - if (newSession?.user) { - Promise.all([ - fetchProStatus(newSession), - fetchProfilePictureMetadata(newSession), - fetchProfilePicture(newSession), - ]).then(() => { - console.debug( - "[Auth Debug] User data refreshed after token refresh", - ); - }); - } + loadedForRef.current = null; + } else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") { + console.debug("[Auth Debug] Signed in or token refreshed"); + // Deliberately does not touch `loading`: Supabase also fires + // SIGNED_IN on tab wakeups, and gating the UI on it would unmount + // Landing -> HomePage on every tab switch. Pinned by a test. + void loadUserData(newSession); } else if (event === "USER_UPDATED") { console.debug("[Auth Debug] User updated"); @@ -454,14 +467,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { "[Auth Debug] User upgrade synchronized successfully", ); - // Refresh pro status, profile picture metadata, and profile picture after upgrade - if (newSession?.user) { - return Promise.all([ - fetchProStatus(newSession), - fetchProfilePictureMetadata(newSession), - fetchProfilePicture(newSession), - ]); - } + // Forced: same user id, so the guest's data must be replaced. + return loadUserData(newSession, { force: true }); }) .then(() => { console.debug( @@ -484,6 +491,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { mounted = false; subscription.unsubscribe(); }; + // Empty and load-bearing: must subscribe once. The closures are recreated + // when `session` changes, so listing deps would re-subscribe on every auth + // event; every call above passes its session explicitly instead. No lint + // rule enforces this, so do not "fix" these deps. }, []); const { t } = useTranslation(); From 86d4a344767b183a395dc12495eda85fb10958c6 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 15:54:44 +0200 Subject: [PATCH 26/99] deps: Upgrade posthog-js to 1.405.2 (#7115) # Description of Changes - Updated the frontend `posthog-js` dependency from `^1.268.0` to `^1.405.2`. - Refreshed `frontend/package-lock.json` and updated related PostHog transitive dependencies. - Removed obsolete OpenTelemetry and protobuf-related transitive packages no longer required by the newer PostHog version. - The existing PostHog APIs used by Stirling-PDF remain compatible. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- frontend/package-lock.json | 46 ++++++++++++++++++++++---------------- frontend/package.json | 2 +- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 300a409229..546e2ef705 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -70,7 +70,7 @@ "pdfjs-dist": "^5.4.149", "peerjs": "^1.5.5", "pixelmatch": "^7.1.0", - "posthog-js": "^1.268.0", + "posthog-js": "^1.405.2", "qrcode.react": "^4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -3154,12 +3154,12 @@ } }, "node_modules/@posthog/core": { - "version": "1.39.3", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.3.tgz", - "integrity": "sha512-oR+B8Q5O61N+W2+HVOBG9dbxAT/+OVxX+XvpNj6KYgptN2EB14JiKQ9Rm7DNpErNTLV7WApZEK/URkZgldOxfg==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.44.0.tgz", + "integrity": "sha512-uE+mdKvetxNQC6gWQf4MHIH8bGt+JN6z7ho0A4G3t9G1VGQTx9wHYHNwkKf3gzi+1oAXS9ej2VVY4pjWUU2PWg==", "license": "MIT", "dependencies": { - "@posthog/types": "^1.392.0" + "@posthog/types": "^1.397.0" } }, "node_modules/@posthog/react": { @@ -3179,9 +3179,9 @@ } }, "node_modules/@posthog/types": { - "version": "1.392.0", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz", - "integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==", + "version": "1.397.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.397.1.tgz", + "integrity": "sha512-W/LpWbKVaaUnfZKuFuHa+Dg03D+fC87cM+PQbG+59JcSPW8F0JcBtSoXmpfrqbpuxUToMo+gktutrUkAb/KQBw==", "license": "MIT" }, "node_modules/@puppeteer/browsers": { @@ -14053,17 +14053,17 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.396.4", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.396.4.tgz", - "integrity": "sha512-PycBmwKQD1T7YFYrGRb8rjQET/UVnexgUy8gVe6UBEhwHXEIhZF4na5VakJbn4zu1wg4tzjt8r7PA4VLu6bDjg==", - "license": "SEE LICENSE IN LICENSE", + "version": "1.405.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.405.2.tgz", + "integrity": "sha512-KPbbAX4EKM8UTU13gW/fZHyonfVee+GIj9QyiXtI2N4ooku69eZzfgk3CxQOUcnuvCz6xaQaUlvRmIsROxTCvw==", + "license": "(Apache-2.0 AND MIT)", "dependencies": { - "@posthog/core": "^1.39.3", - "@posthog/types": "^1.392.0", - "core-js": "^3.38.1", + "@posthog/core": "^1.44.0", + "@posthog/types": "^1.397.1", + "core-js": "^3.49.0", "dompurify": "^3.3.2", "fflate": "^0.4.8", - "preact": "^10.29.2", + "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0" } @@ -14082,13 +14082,21 @@ } }, "node_modules/preact": { - "version": "10.29.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.3.tgz", - "integrity": "sha512-D9NL1GAnJZhc3RndVs4gDdxEeU9TcHgywMrhhOsnpdlvFjdbx0gAsLUnH6JEhlJH5giL7Tx5biWPUSEXE/HPzw==", + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } } }, "node_modules/prelude-ls": { diff --git a/frontend/package.json b/frontend/package.json index 1798abc6b2..f48fad8571 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -67,7 +67,7 @@ "pdfjs-dist": "^5.4.149", "peerjs": "^1.5.5", "pixelmatch": "^7.1.0", - "posthog-js": "^1.268.0", + "posthog-js": "^1.405.2", "qrcode.react": "^4.2.0", "react": "^19.2.8", "react-dom": "^19.2.8", From fa2eb6712445c741ff7b5167420ed5b28505bd96 Mon Sep 17 00:00:00 2001 From: Ludy Date: Thu, 6 Aug 2026 16:56:12 +0200 Subject: [PATCH 27/99] deps(frontend): align dependency scopes and remove redundant ESLint packages (#6991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes This change reorganizes frontend dependencies by moving development-only packages into `devDependencies`, removing obsolete packages, and updating several development tooling dependencies to newer versions. ### What was changed - Moved runtime-independent packages to `devDependencies`: - `@iconify/react` - `globals` - Removed unused TypeScript ESLint packages: - `@typescript-eslint/eslint-plugin` - `@typescript-eslint/parser` - Updated development dependencies: - `@iconify-json/material-symbols` → `1.2.83` - `@iconify/utils` → `3.1.4` - `globals` → `17.7.0` These changes reduce redundant dependency declarations and ensure packages are classified according to their actual usage. The main challenge was distinguishing direct dependencies from packages already provided transitively by frontend tooling. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- frontend/package-lock.json | 716 ++++++++++++++++++++++++++++++------- frontend/package.json | 14 +- 2 files changed, 594 insertions(+), 136 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 546e2ef705..6f19db35b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,7 +37,6 @@ "@embedpdf/plugin-zoom": "^2.14.4", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@iconify/react": "^6.0.2", "@mantine/core": "^8.3.1", "@mantine/dates": "^8.3.1", "@mantine/dropzone": "^8.3.1", @@ -62,7 +61,6 @@ "autoprefixer": "^10.4.21", "axios": "^1.15.0", "d3": "^7.9.0", - "globals": "^17.5.0", "i18next": "^25.10.10", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", @@ -89,8 +87,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@iconify-json/material-symbols": "^1.2.53", - "@iconify/utils": "^3.1.0", + "@iconify-json/material-symbols": "^1.2.83", + "@iconify/react": "^6.0.2", + "@iconify/utils": "^3.1.4", "@playwright/test": "^1.55.0", "@storybook/addon-a11y": "^9.1.20", "@storybook/addon-docs": "^9.1.20", @@ -115,12 +114,13 @@ "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react-swc": "^4.1.0", - "@vitest/browser": "^3.2.6", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/browser": "3.2.7", + "@vitest/coverage-v8": "3.2.7", "dotenv": "^16.4.7", "dpdm": "^3.14.0", "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", + "globals": "^17.7.0", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", "license-checker": "^25.0.1", @@ -142,7 +142,7 @@ "vite-plugin-compression2": "^2.5.3", "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" + "vitest": "3.2.7" } }, "node_modules/@acemir/cssom": { @@ -2170,9 +2170,9 @@ } }, "node_modules/@iconify-json/material-symbols": { - "version": "1.2.63", - "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.63.tgz", - "integrity": "sha512-R4PS/l8K6j+dk2P2MoYFLJgfbZ4YDo6XjCOpX6b1tvX+BhiSpSOjc1b6cnb/mvWe+JWBKlt4pcvPNiAijFLPnA==", + "version": "1.2.83", + "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.83.tgz", + "integrity": "sha512-4I2rfNlaoyn4zIcdJDxUMuPV1pVp8Tgwy+eJvyAZuOpmPtOsniQ8Dug6wzRD5s9KLyQA3smFvuBphVdYG7NWQA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2183,6 +2183,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@iconify/react/-/react-6.0.2.tgz", "integrity": "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg==", + "dev": true, "license": "MIT", "dependencies": { "@iconify/types": "^2.0.0" @@ -2198,18 +2199,19 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, "license": "MIT" }, "node_modules/@iconify/utils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", - "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", "dev": true, "license": "MIT", "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", - "mlly": "^1.8.0" + "import-meta-resolve": "^4.2.0" } }, "node_modules/@inquirer/ansi": { @@ -6433,16 +6435,16 @@ } }, "node_modules/@vitest/browser": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.6.tgz", - "integrity": "sha512-CNjSynGBtAVOMTfQITv6Bc8da4/XTU1izorocbDStjUsynXcgx2FHVssh+10a8bKd/BxoqDdQtuSbYHfk302Wg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.7.tgz", + "integrity": "sha512-gIzazUkbQfv6T1rJHOLhMMKQnplKAvvQ7QNGaFwI6oCsp4z2aSDZCojGpX3QX3+MYsvJdyy/8BRIYVEbAkMkEA==", "dev": true, "license": "MIT", "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", - "@vitest/mocker": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/mocker": "3.2.7", + "@vitest/utils": "3.2.7", "magic-string": "^0.30.17", "sirv": "^3.0.1", "tinyrainbow": "^2.0.0", @@ -6453,7 +6455,7 @@ }, "peerDependencies": { "playwright": "*", - "vitest": "3.2.6", + "vitest": "3.2.7", "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" }, "peerDependenciesMeta": { @@ -6469,13 +6471,13 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -6496,9 +6498,9 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -6509,9 +6511,9 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6522,13 +6524,13 @@ } }, "node_modules/@vitest/browser/node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -6537,9 +6539,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", - "integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz", + "integrity": "sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==", "dev": true, "license": "MIT", "dependencies": { @@ -6561,8 +6563,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.2.6", - "vitest": "3.2.6" + "@vitest/browser": "3.2.7", + "vitest": "3.2.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6628,13 +6630,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", - "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.6", + "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -6643,9 +6645,9 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -6656,13 +6658,13 @@ } }, "node_modules/@vitest/runner/node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -6671,13 +6673,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", - "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -6686,9 +6688,9 @@ } }, "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -8078,13 +8080,6 @@ "dev": true, "license": "MIT" }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -10193,9 +10188,10 @@ } }, "node_modules/globals": { - "version": "17.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", - "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -12962,17 +12958,14 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" + "engines": { + "node": ">=10" } }, "node_modules/mrmime": { @@ -13716,18 +13709,6 @@ "pixelmatch": "bin/pixelmatch" } }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -17409,9 +17390,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -17427,6 +17408,490 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/tsx/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -17512,13 +17977,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", - "dev": true, - "license": "MIT" - }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -18097,20 +18555,20 @@ } }, "node_modules/vitest": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", - "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.6", - "@vitest/mocker": "3.2.6", - "@vitest/pretty-format": "^3.2.6", - "@vitest/runner": "3.2.6", - "@vitest/snapshot": "3.2.6", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -18140,8 +18598,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.6", - "@vitest/ui": "3.2.6", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, @@ -18170,15 +18628,15 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", - "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.6", - "@vitest/utils": "3.2.6", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -18187,13 +18645,13 @@ } }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", - "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.6", + "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -18214,9 +18672,9 @@ } }, "node_modules/vitest/node_modules/@vitest/pretty-format": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", - "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", "dev": true, "license": "MIT", "dependencies": { @@ -18227,9 +18685,9 @@ } }, "node_modules/vitest/node_modules/@vitest/spy": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", - "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18240,13 +18698,13 @@ } }, "node_modules/vitest/node_modules/@vitest/utils": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", - "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.6", + "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, diff --git a/frontend/package.json b/frontend/package.json index f48fad8571..bc01367cef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,7 +34,6 @@ "@embedpdf/plugin-zoom": "^2.14.4", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@iconify/react": "^6.0.2", "@mantine/core": "^8.3.1", "@mantine/dates": "^8.3.1", "@mantine/dropzone": "^8.3.1", @@ -59,7 +58,6 @@ "autoprefixer": "^10.4.21", "axios": "^1.15.0", "d3": "^7.9.0", - "globals": "^17.5.0", "i18next": "^25.10.10", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", @@ -111,8 +109,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@iconify-json/material-symbols": "^1.2.53", - "@iconify/utils": "^3.1.0", + "@iconify-json/material-symbols": "^1.2.83", + "@iconify/react": "^6.0.2", + "@iconify/utils": "^3.1.4", "@playwright/test": "^1.55.0", "@storybook/addon-a11y": "^9.1.20", "@storybook/addon-docs": "^9.1.20", @@ -137,12 +136,13 @@ "@typescript-eslint/parser": "^8.65.0", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react-swc": "^4.1.0", - "@vitest/browser": "^3.2.6", - "@vitest/coverage-v8": "^3.2.4", + "@vitest/browser": "3.2.7", + "@vitest/coverage-v8": "3.2.7", "dotenv": "^16.4.7", "dpdm": "^3.14.0", "eslint": "^10.8.0", "fake-indexeddb": "^6.2.5", + "globals": "^17.7.0", "jsdom": "^27.0.0", "json-schema-to-typescript": "^15.0.4", "license-checker": "^25.0.1", @@ -164,7 +164,7 @@ "vite-plugin-compression2": "^2.5.3", "vite-plugin-static-copy": "^3.1.4", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" + "vitest": "3.2.7" }, "depcheck": { "ignoreMatches": [ From 2cf6db99ceb0b8321f29f4a4cde18dbb4baec1e0 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:47:09 +0100 Subject: [PATCH 28/99] Fix timing-fragile Valkey rate-limit boundary test (#7302) # Description of Changes Fix timing-fragile Valkey rate-limit boundary test --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../valkey/LiveValkeyIntegrationTest.java | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java index c26528a715..0e6cb2f4de 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/cluster/valkey/LiveValkeyIntegrationTest.java @@ -298,24 +298,44 @@ class LiveValkeyIntegrationTest { ValkeyRateLimitStore store = newRateLimitStore(factoryA); String key = "boundary-" + java.util.UUID.randomUUID(); long capacity = 5; - Duration window = Duration.ofMillis(500); + // refillGreedy tops the bucket up continuously, one token every window/capacity. A 500ms + // window left the drain loop only 100ms before a 6th token appeared, so a slow Valkey + // round-trip broke the count; 4s spaces refills 800ms apart, clear of any burst. + Duration window = Duration.ofSeconds(4); + long refillIntervalMs = window.toMillis() / capacity; + long drainStart = System.nanoTime(); int firstAllowed = 0; for (int i = 0; i < 10; i++) { if (store.tryConsume(key, capacity, window).allowed()) firstAllowed++; } - assertEquals(capacity, firstAllowed, "must allow exactly capacity tokens initially"); + long drainMs = (System.nanoTime() - drainStart) / 1_000_000; + // Refill never pauses, so a slow drain earns extra tokens honestly - allow exactly the + // number the elapsed time can have produced and no more. + long earned = drainMs / refillIntervalMs; + assertTrue( + firstAllowed >= capacity && firstAllowed <= capacity + earned, + "initial burst must be capacity (" + + capacity + + ") plus at most the " + + earned + + " token(s) refilled during a " + + drainMs + + "ms drain, got " + + firstAllowed); - Thread.sleep(window.toMillis() + 50); + // A fixed-window limiter would hand back a whole fresh capacity at the boundary; a token + // bucket hands back one token per refill interval. + Thread.sleep(refillIntervalMs + 200); int secondAllowed = 0; - long start = System.nanoTime(); - for (int i = 0; i < 20 && (System.nanoTime() - start) < 20_000_000L; i++) { + for (int i = 0; i < 10; i++) { if (store.tryConsume(key, capacity, window).allowed()) secondAllowed++; } assertTrue( - secondAllowed <= capacity, - "token-bucket must not let a fresh full capacity be consumed instantly across" - + " the boundary; got " + secondAllowed >= 1 && secondAllowed < capacity, + "one refill interval must yield about one token, not a fresh full window of " + + capacity + + "; got " + secondAllowed); } From fd1c955648c8c2ba28d382d1ba0db01b260e8c93 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:52:13 +0200 Subject: [PATCH 29/99] refactor(ui): redesign VersionTimeline UI (#7162) # Description of Changes I felt the old VersionTimeline was a bit too crowded/not very "good" looking so i had a crack at redesigning it. Mainly aimed for: - less info - less crowding - more spacing ### New image ### Old image --- ## Checklist ### General - [X] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [X] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [X] I have performed a self-review of my own code - [X] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [X] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [X] I have run `task check` to verify linters, typechecks, and tests pass - [X] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../public/locales/en-US/translation.toml | 3 +- .../core/components/filesPage/FilesPage.css | 174 ++++++----- .../filesPage/VersionHistoryModal.tsx | 16 +- .../components/filesPage/VersionTimeline.tsx | 275 +++++++++--------- 4 files changed, 256 insertions(+), 212 deletions(-) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index feb42149f7..ea4f0399b9 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3977,6 +3977,7 @@ refresh = "Refresh from server" remove = "Delete" removeVersion = "Remove this version" rename = "Rename" +renamed = "Renamed" renameFolder = "Rename folder" resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)" save = "Save" @@ -4079,9 +4080,7 @@ count = "Files" folder = "Folder" labels = "Labels" modified = "Modified" -name = "Name" size = "Size" -toolHistoryAtVersion = "Cumulative tool chain" totalSize = "Total size" type = "Type" versionHistory = "Version journey" diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 9de4a276aa..4815a1b31e 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -956,178 +956,204 @@ like a commit graph; clicking a row's summary toggles its expanded detail (cumulative tool chain, full meta). Long chains (> 6) collapse the middle behind a "Show N earlier versions" button. */ +/* Version Timeline styling */ .files-page-details-version-timeline { display: flex; flex-direction: column; - gap: 0.4rem; - padding: 0.55rem 0.7rem 0.7rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: 0.5rem; + gap: 0.6rem; + padding: 0.5rem 0; + background: transparent; } + .files-page-details-version-timeline-label { display: flex; align-items: center; - gap: 0.35rem; - font-size: 0.72rem; + gap: 0.4rem; + font-size: 0.75rem; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--c-text-subtle); + margin-bottom: 0.85rem; } + .files-page-details-version-timeline-count { margin-left: auto; font-weight: 600; - color: var(--c-text-muted, var(--c-text-subtle)); + color: var(--c-primary); text-transform: none; letter-spacing: 0; } + .files-page-details-version-timeline-list { - list-style: none; - margin: 0; - padding: 0; + list-style: none !important; + margin: 0 !important; + padding: 1.1rem 0 0 0 !important; display: flex; flex-direction: column; + gap: 1.25rem; } + .files-page-details-version-timeline-row, .files-page-details-version-timeline-ellipsis { display: flex; - gap: 0.6rem; - padding: 0.15rem 0; + gap: 0.85rem; + padding: 0; position: relative; + list-style: none !important; } + +.files-page-details-version-timeline-row::before, +.files-page-details-version-timeline-ellipsis::before { + content: none !important; +} + .files-page-details-version-timeline-rail { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; - width: 0.8rem; - padding-top: 0.45rem; + width: 1rem; + padding-top: 0.85rem; } + .files-page-details-version-timeline-rail-dot { - width: 0.55rem; - height: 0.55rem; + width: 0.65rem; + height: 0.65rem; border-radius: 50%; background: var(--c-bg-raised); border: 2px solid var(--c-border-strong, var(--c-border-subtle)); - z-index: 1; + z-index: 2; flex-shrink: 0; + transition: all 0.2s ease; } + .files-page-details-version-timeline-rail-dot.is-active { background: var(--c-primary); border-color: var(--c-primary); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 25%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 30%, transparent); } + .files-page-details-version-timeline-rail-dot.is-ellipsis { - width: 0.35rem; - height: 0.35rem; + width: 0.4rem; + height: 0.4rem; background: var(--c-text-subtle); border-color: transparent; } + .files-page-details-version-timeline-rail-line { width: 2px; flex: 1; background: var(--c-border-subtle); - min-height: 0.6rem; - margin-top: 2px; + min-height: 1.25rem; + margin-top: 6px; } + .files-page-details-version-timeline-body { flex: 1; min-width: 0; display: flex; flex-direction: column; - gap: 0.2rem; - padding: 0.25rem 0.3rem 0.4rem; - border-radius: 0.35rem; - transition: background-color 0.12s ease; + gap: 0.5rem; + padding: 1rem 1.25rem; + border-radius: 0.65rem; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + transition: + border-color 0.15s ease, + background-color 0.15s ease, + box-shadow 0.15s ease; } + +.files-page-details-version-timeline-body:hover { + border-color: var(--c-border-strong, var(--c-border-subtle)); +} + .files-page-details-version-timeline-row.is-active .files-page-details-version-timeline-body { - background: color-mix(in srgb, var(--c-primary) 10%, transparent); + border-color: color-mix(in srgb, var(--c-primary) 40%, transparent); + background: color-mix(in srgb, var(--c-primary) 5%, var(--c-surface)); + box-shadow: 0 2px 6px color-mix(in srgb, var(--c-primary) 12%, transparent); } -.files-page-details-version-timeline-summary { + +.files-page-details-version-timeline-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.files-page-details-version-timeline-tool-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--c-text); +} + +.files-page-details-version-timeline-card-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding-top: 0.1rem; +} + +.files-page-details-version-timeline-expand-btn { appearance: none; background: none; border: 0; padding: 0; - margin: 0; + cursor: pointer; display: flex; align-items: center; - gap: 0.4rem; - cursor: pointer; - text-align: left; - color: inherit; - font: inherit; } -.files-page-details-version-timeline-summary:hover - .files-page-details-version-timeline-chevron { + +.files-page-details-version-timeline-expand-btn:hover span { color: var(--c-text); } -.files-page-details-version-timeline-delta { - font-size: 0.82rem; - color: var(--c-text); - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: inline-flex; - align-items: baseline; - gap: 0.25rem; -} -.files-page-details-version-timeline-delta.is-origin { - font-weight: 400; - color: var(--c-text-subtle); - font-style: italic; -} -.files-page-details-version-timeline-delta-plus { - color: var(--c-primary); - font-weight: 700; -} -.files-page-details-version-timeline-spacer { - flex: 1; -} + .files-page-details-version-timeline-chevron { color: var(--c-text-subtle); transition: transform 0.15s ease; } + .files-page-details-version-timeline-chevron.is-expanded { transform: rotate(180deg); - color: var(--c-text); -} -.files-page-details-version-timeline-meta-line { - display: flex; - align-items: center; - gap: 0.35rem; - font-size: 0.7rem; - color: var(--c-text-subtle); + color: var(--c-primary); } + .files-page-details-version-timeline-expanded { display: flex; flex-direction: column; gap: 0.4rem; - margin-top: 0.45rem; + margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px dashed var(--c-border-subtle); } + .files-page-details-version-timeline-toolchain { display: flex; flex-direction: column; gap: 0.2rem; } + .files-page-details-version-timeline-toolchain-label { font-size: 0.65rem; color: var(--c-text-subtle); text-transform: uppercase; letter-spacing: 0.05em; } + .files-page-details-version-timeline-ellipsis-btn, .files-page-details-version-timeline-collapse-btn { appearance: none; background: none; border: 1px dashed var(--c-border-subtle); - border-radius: 0.3rem; - padding: 0.25rem 0.5rem; + border-radius: 0.4rem; + padding: 0.35rem 0.7rem; margin: 0.1rem 0; - font-size: 0.72rem; + font-size: 0.75rem; color: var(--c-text-subtle); cursor: pointer; text-align: left; @@ -1135,11 +1161,13 @@ border-color 0.12s ease, color 0.12s ease; } + .files-page-details-version-timeline-ellipsis-btn:hover, .files-page-details-version-timeline-collapse-btn:hover { color: var(--c-text); - border-color: var(--c-border-strong, var(--c-text-subtle)); + border-color: var(--c-primary); } + .files-page-details-version-timeline-collapse-btn { align-self: flex-start; margin-top: 0.2rem; diff --git a/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx index 077924155d..2bef30c763 100644 --- a/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Center, Loader, Modal, Text } from "@mantine/core"; +import HistoryIcon from "@mui/icons-material/History"; +import { Center, Group, Loader, Modal, Text } from "@mantine/core"; import type { FileId } from "@app/types/file"; import type { StirlingFileStub } from "@app/types/fileContext"; @@ -99,7 +100,17 @@ export function VersionHistoryModal({ onClose={onClose} centered size="md" - title={t("filesPage.field.versionHistory", "Version journey")} + title={ + + + + {t("filesPage.field.versionHistory", "Version journey")} + + + } > {loading ? (
    @@ -111,6 +122,7 @@ export function VersionHistoryModal({ currentId={file.id} onAddToWorkspace={handleAddToWorkspace} onRemove={handleRemove} + hideHeader /> ) : ( diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx index 47495fe61b..049fc2365a 100644 --- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Badge, Menu } from "@mantine/core"; +import { Badge, Group, Menu, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; @@ -8,16 +8,14 @@ import DeleteIcon from "@mui/icons-material/Delete"; import DownloadIcon from "@mui/icons-material/Download"; import HistoryIcon from "@mui/icons-material/History"; import MoreVertIcon from "@mui/icons-material/MoreVert"; -import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { FileId, ToolOperation } from "@app/types/file"; import { ToolId } from "@app/types/toolId"; import { StirlingFileStub } from "@app/types/fileContext"; import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; import { downloadFileFromStorage } from "@app/utils/downloadUtils"; -import ToolChain from "@app/components/shared/ToolChain"; -/** Small label/value row; shared with FileDetailsPanel. */ +/** Small label/value row with crisp flex alignment and colon separation. */ export function DetailField({ label, value, @@ -26,9 +24,31 @@ export function DetailField({ value: string; }) { return ( -
    - {label} - {value} +
    + + {label}: + + + {value} +
    ); } @@ -60,7 +80,7 @@ export interface VersionTimelineProps { hideHeader?: boolean; } -/** Version timeline with per-row tool deltas and collapse-when-long. */ +/** Clean, spacious version timeline with minimal clutter. */ export function VersionTimeline({ chain, currentId, @@ -69,7 +89,6 @@ export function VersionTimeline({ hideHeader = false, }: VersionTimelineProps) { const { t } = useTranslation(); - const [expandedIds, setExpandedIds] = useState>(new Set()); const [showAllCollapsed, setShowAllCollapsed] = useState(false); // Newest-first ordering. @@ -113,15 +132,6 @@ export function VersionTimeline({ return [...head, { kind: "ellipsis", hidden }, ...tail]; }, [collapsible, showAllCollapsed, ordered]); - const toggleExpand = (id: FileId) => { - setExpandedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - return (
    {!hideHeader && ( @@ -135,7 +145,10 @@ export function VersionTimeline({
    )} -
      +
        {rows.map((row, idx) => { const isLast = idx === rows.length - 1; if (row.kind === "ellipsis") { @@ -143,6 +156,7 @@ export function VersionTimeline({
      • @@ -152,7 +166,7 @@ export function VersionTimeline({
        -
        - {formatFileSize(v.size)} - {v.lastModified ? ( - <> - · - - {getFileDate({ lastModified: v.lastModified })} - - - ) : null} - {/* Kebab on every row - the original/active version also - needs download + open-in-workspace. */} - - - - e.stopPropagation()} - > - - - - - } - onClick={() => onAddToWorkspace([v.id])} - > - {t( - "filesPage.openVersionInWorkspace", - "Open in workspace", - )} - - } - onClick={() => { - void downloadFileFromStorage(v); - }} - > - {t( - "filesPage.downloadVersion", - "Download this version", - )} - - - } - onClick={() => onRemove([v.id])} - > - {t("filesPage.removeVersion", "Remove this version")} - - - -
        - {isExpanded && ( - // Filename + full cumulative tool chain. -
        - - {v.toolHistory && v.toolHistory.length > 0 && ( -
        - - {t( - "filesPage.field.toolHistoryAtVersion", - "Cumulative tool chain", + + {delta ? ( + + ) : ( + t("filesPage.versionOrigin", "Original upload") + )} + + + + {!isActive && ( + + + - -
        - )} -
        + onClick={(e) => e.stopPropagation()} + > + + + + + } + onClick={() => onAddToWorkspace([v.id])} + > + {t( + "filesPage.openVersionInWorkspace", + "Open in workspace", + )} + + } + onClick={() => { + void downloadFileFromStorage(v); + }} + > + {t( + "filesPage.downloadVersion", + "Download this version", + )} + + + } + onClick={() => onRemove([v.id])} + > + {t("filesPage.removeVersion", "Remove this version")} + + + + )} +
    + + {/* Quiet Meta Line: File Size · Date */} + + {formatFileSize(v.size)} + {v.lastModified && ( + <> · {getFileDate({ lastModified: v.lastModified })} + )} + + + {/* Show filename ONLY if original upload or if name changed */} + {(isOriginal || nameChanged) && ( + + {nameChanged + ? `${t("filesPage.renamed", "Renamed")}: ` + : `${t("filesPage.file", "File")}: `} + + {v.name} + + )} ); })} - + {collapsible && showAllCollapsed && ( + + {/* Active Scale Display */} +
    + + {t("scaleSettings.activeScale", "Active Scale")}:{" "} + {currentScale && currentScale.ratio + ? generateScaleLabel(currentScale.ratio, currentScale.unit) + : currentScale && !currentScale.ratio + ? `${currentScale.unit} (custom)` + : t("scaleSettings.noneSet", "No custom scale set")} + +
    + + {/* Calibration Mode */} + + + {/* Reset Button */} + {currentScale && ( + + )} + + ); +} diff --git a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx index 14a308e0f3..d5d39c4c0c 100644 --- a/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx +++ b/frontend/editor/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx @@ -26,11 +26,19 @@ import StraightenIcon from "@mui/icons-material/Straighten"; import LayersIcon from "@mui/icons-material/Layers"; import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import StopIcon from "@mui/icons-material/Stop"; +import SettingsIcon from "@mui/icons-material/Settings"; import { useViewerReadAloud } from "@app/components/viewer/useViewerReadAloud"; +import { RulerScaleSettingsButton } from "@app/components/viewer/RulerScaleSettingsButton"; +import type { MeasureScale } from "@app/utils/measurementTypes"; export function useViewerWorkbenchBarButtons( isRulerActive?: boolean, setIsRulerActive?: (v: boolean) => void, + customScale?: MeasureScale | null, + setCustomScale?: (scale: MeasureScale | null) => void, + isScaleCalibrationActive?: boolean, + startScaleCalibration?: () => void, + cancelScaleCalibration?: () => void, ) { const { t, i18n } = useTranslation(); const viewer = useViewer(); @@ -118,11 +126,36 @@ export function useViewerWorkbenchBarButtons( const annotationsLabel = t("workbenchBar.annotations", "Annotations"); const formFillLabel = t("workbenchBar.formFill", "Fill Form"); const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure"); + const rulerSettingsLabel = t("workbenchBar.rulerSettings", "Scale Settings"); const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud"); const readAloudSpeedLabel = t("workbenchBar.readAloudSpeed", "Speed"); const isFormFillActive = (selectedTool as string) === "formFill"; + const handleStartScaleCalibration = useCallback(() => { + startScaleCalibration?.(); + setIsRulerActive?.(true); + if (isPanning) { + viewer.panActions.disablePan(); + setIsPanning(false); + } + }, [isPanning, setIsRulerActive, startScaleCalibration, viewer.panActions]); + + const handleCancelScaleCalibration = useCallback(() => { + cancelScaleCalibration?.(); + }, [cancelScaleCalibration]); + + const handleApplyRulerScale = useCallback( + (scale: MeasureScale) => { + setCustomScale?.(scale); + }, + [setCustomScale], + ); + + const handleResetRulerScale = useCallback(() => { + setCustomScale?.(null); + }, [setCustomScale]); + // Filter languages based on available voices const filteredLanguages = useMemo( () => @@ -234,6 +267,32 @@ export function useViewerWorkbenchBarButtons( } }, }, + // Ruler scale settings button - only visible when ruler is active + ...(isRulerActive + ? [ + { + id: "viewer-ruler-settings", + icon: , + tooltip: rulerSettingsLabel, + ariaLabel: rulerSettingsLabel, + section: "top" as const, + order: 25.5, + render: ({ disabled }: { disabled?: boolean }) => ( + + ), + }, + ] + : []), { id: "viewer-rotate-left", icon: , @@ -553,8 +612,15 @@ export function useViewerWorkbenchBarButtons( formFillLabel, isFormFillActive, rulerLabel, + rulerSettingsLabel, isRulerActive, setIsRulerActive, + handleStartScaleCalibration, + handleCancelScaleCalibration, + handleApplyRulerScale, + handleResetRulerScale, + customScale, + isScaleCalibrationActive, readAloudLabel, readAloudSpeedLabel, isReadingAloud, diff --git a/frontend/editor/src/core/contexts/ViewerContext.tsx b/frontend/editor/src/core/contexts/ViewerContext.tsx index 5e5d08dbb4..78535b41ac 100644 --- a/frontend/editor/src/core/contexts/ViewerContext.tsx +++ b/frontend/editor/src/core/contexts/ViewerContext.tsx @@ -176,6 +176,9 @@ export interface ViewerContextType { registerImmediatePanUpdate: ( callback: (isPanning: boolean) => void, ) => () => void; + registerImmediateRotationUpdate: ( + callback: (rotation: number) => void, + ) => () => void; // Internal - for bridges to trigger immediate updates triggerImmediateScrollUpdate: ( @@ -188,6 +191,7 @@ export interface ViewerContextType { isDualPage?: boolean, ) => void; triggerImmediatePanUpdate: (isPanning: boolean) => void; + triggerImmediateRotationUpdate: (rotation: number) => void; // Action handlers - call EmbedPDF APIs directly scrollActions: ScrollActions; @@ -310,6 +314,10 @@ export const ViewerProvider: React.FC = ({ children }) => { register: registerImmediatePanUpdate, trigger: triggerImmediatePanInternal, } = useImmediateNotifier<[boolean]>(); + const { + register: registerImmediateRotationUpdate, + trigger: triggerImmediateRotationInternal, + } = useImmediateNotifier<[number]>(); const triggerImmediateZoomUpdate = useCallback( (percent: number) => { @@ -339,6 +347,13 @@ export const ViewerProvider: React.FC = ({ children }) => { [triggerImmediatePanInternal], ); + const triggerImmediateRotationUpdate = useCallback( + (rotation: number) => { + triggerImmediateRotationInternal(rotation); + }, + [triggerImmediateRotationInternal], + ); + const registerBridge = useCallback( ( type: K, @@ -638,10 +653,12 @@ export const ViewerProvider: React.FC = ({ children }) => { registerImmediateScrollUpdate, registerImmediateSpreadUpdate, registerImmediatePanUpdate, + registerImmediateRotationUpdate, triggerImmediateScrollUpdate, triggerImmediateZoomUpdate, triggerImmediateSpreadUpdate, triggerImmediatePanUpdate, + triggerImmediateRotationUpdate, // Actions scrollActions, diff --git a/frontend/editor/src/core/hooks/useMeasurementManager.ts b/frontend/editor/src/core/hooks/useMeasurementManager.ts new file mode 100644 index 0000000000..f6f6b9d6b1 --- /dev/null +++ b/frontend/editor/src/core/hooks/useMeasurementManager.ts @@ -0,0 +1,293 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type RefObject, +} from "react"; +import type { + Measurement, + MeasureScale, + PageMeasureScales, +} from "@app/utils/measurementTypes"; +import type { RulerOverlayHandle } from "@app/components/viewer/RulerOverlay"; +import { + loadSessionMap, + saveSessionMap, + validateMeasureScale, + validateMeasurement, +} from "@app/utils/measurementUtils"; +import type { StirlingFile } from "@app/types/fileContext"; +import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext"; +import { extractPageMeasureScales } from "@app/utils/pdfMeasurementExtraction"; +import type { ScaleCalibrationMeasurement } from "@app/components/viewer/ScaleCalibrationDialog"; + +// ─── Hook: useMeasurementManager ────────────────────────────────────────────── + +interface EffectiveFileLike { + file: Blob | File; + url: string | null; +} + +type ViewerFile = StirlingFile | File | null | undefined; + +interface UseMeasurementManagerProps { + currentFile: ViewerFile; + effectiveFile: EffectiveFileLike | null | undefined; + rulerOverlayRef: RefObject; +} + +interface UseMeasurementManagerReturn { + isRulerActive: boolean; + setIsRulerActive: (v: boolean) => void; + pageMeasureScales: PageMeasureScales | null; + customScale: MeasureScale | null; + handleSetCustomScale: (scale: MeasureScale | null) => void; + isScaleCalibrationActive: boolean; + scaleCalibrationMeasurement: ScaleCalibrationMeasurement | null; + startScaleCalibration: () => void; + cancelScaleCalibration: () => void; + handleScaleCalibrationMeasurement: ( + measurement: ScaleCalibrationMeasurement, + ) => void; + applyScaleCalibration: (scale: MeasureScale) => void; +} + +export function useMeasurementManager({ + currentFile, + effectiveFile, + rulerOverlayRef, +}: UseMeasurementManagerProps): UseMeasurementManagerReturn { + const [isRulerActive, setIsRulerActive] = useState(false); + const [pageMeasureScales, setPageMeasureScales] = + useState(null); + const [customScale, setCustomScale] = useState(null); + const [isScaleCalibrationActive, setIsScaleCalibrationActive] = + useState(false); + const [scaleCalibrationMeasurement, setScaleCalibrationMeasurement] = + useState(null); + const [scalesByFileId, setScalesByFileId] = useState< + Map + >(new Map()); + const [measurementsByFileId, setMeasurementsByFileId] = useState< + Map + >(new Map()); + + const restoredFileKeyRef = useRef(null); + + const getStableFileKey = useCallback((file: ViewerFile): string | null => { + if (!file) return null; + if (isStirlingFile(file)) { + return file.fileId; + } + return getFormFillFileId(file); + }, []); + + const currentFileKey = getStableFileKey(currentFile); + + function persistSessionValue( + storageKey: string, + fileKey: string, + value: MeasureScale | Measurement[] | null, + label: string, + ) { + try { + saveSessionMap(storageKey, fileKey, value); + } catch (error) { + console.error(`[Measurement] Failed to persist ${label}:`, error); + } + } + + function readStoredScale(fileKey: string): MeasureScale | null | undefined { + const storedMap = loadSessionMap("stirling_scales"); + if (!(fileKey in storedMap)) { + return undefined; + } + + const storedValue = storedMap[fileKey]; + return validateMeasureScale(storedValue) ? storedValue : null; + } + + function readStoredMeasurements(fileKey: string): Measurement[] | undefined { + const storedMap = loadSessionMap("stirling_measurements"); + if (!(fileKey in storedMap)) { + return undefined; + } + + const storedValue = storedMap[fileKey]; + if (!Array.isArray(storedValue)) { + return []; + } + + return storedValue.filter((measurement) => + validateMeasurement(measurement), + ); + } + + function persistScale(fileKey: string, scale: MeasureScale | null) { + persistSessionValue("stirling_scales", fileKey, scale, "scale"); + } + + function persistMeasurements(fileKey: string, value: Measurement[]) { + persistSessionValue( + "stirling_measurements", + fileKey, + value, + "measurements", + ); + } + + const handleSetCustomScale = useCallback( + (scale: MeasureScale | null) => { + const fileKey = currentFileKey; + + if (fileKey) { + setScalesByFileId((prev) => new Map(prev).set(fileKey, scale)); + persistScale(fileKey, scale); + } + + setCustomScale(scale); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, + [currentFileKey], + ); + + const handleSetRulerActive = useCallback((active: boolean) => { + setIsRulerActive(active); + if (!active) { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + } + }, []); + + const startScaleCalibration = useCallback(() => { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(true); + setIsRulerActive(true); + }, []); + + const cancelScaleCalibration = useCallback(() => { + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, []); + + const handleScaleCalibrationMeasurement = useCallback( + (measurement: ScaleCalibrationMeasurement) => { + setScaleCalibrationMeasurement(measurement); + setIsScaleCalibrationActive(false); + }, + [], + ); + + const applyScaleCalibration = useCallback( + (scale: MeasureScale) => { + handleSetCustomScale(scale); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + }, + [handleSetCustomScale], + ); + + useEffect(() => { + if (!currentFileKey) { + setPageMeasureScales(null); + setCustomScale(null); + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + setIsRulerActive(false); + rulerOverlayRef.current?.clearAll(true); + restoredFileKeyRef.current = null; + return; + } + + if (restoredFileKeyRef.current === currentFileKey) { + return; + } + restoredFileKeyRef.current = currentFileKey; + setScaleCalibrationMeasurement(null); + setIsScaleCalibrationActive(false); + + const storedScale = readStoredScale(currentFileKey); + const savedScale = + storedScale === undefined + ? (scalesByFileId.get(currentFileKey) ?? null) + : storedScale; + + setCustomScale(savedScale); + + const storedMeasurements = readStoredMeasurements(currentFileKey); + const savedMeasurements = + storedMeasurements === undefined + ? (measurementsByFileId.get(currentFileKey) ?? []) + : storedMeasurements; + + rulerOverlayRef.current?.clearAll(true); + rulerOverlayRef.current?.restoreMeasurements(savedMeasurements); + }, [currentFileKey, measurementsByFileId, rulerOverlayRef, scalesByFileId]); + + useEffect(() => { + const fileBlob = effectiveFile?.file; + if (!fileBlob || !currentFileKey) { + setPageMeasureScales(null); + return; + } + + setPageMeasureScales(null); + + let cancelled = false; + extractPageMeasureScales(fileBlob) + .then((scales) => { + if (!cancelled) { + setPageMeasureScales(scales); + } + }) + .catch((error) => { + if (!cancelled) { + console.warn("[Measurement] Failed to load PDF scales", error); + setPageMeasureScales(null); + } + }); + + return () => { + cancelled = true; + }; + }, [currentFileKey, effectiveFile?.file]); + + useEffect(() => { + if (!rulerOverlayRef.current || !currentFileKey) return; + + const unsubscribe = rulerOverlayRef.current.onMeasurementsChange( + (newMeasurements: Measurement[]) => { + const validMeasurements = newMeasurements.filter((measurement) => + validateMeasurement(measurement), + ); + + setMeasurementsByFileId((prev) => + new Map(prev).set(currentFileKey, validMeasurements), + ); + persistMeasurements(currentFileKey, validMeasurements); + }, + ); + + return () => { + if (typeof unsubscribe === "function") { + unsubscribe(); + } + }; + }, [currentFileKey, rulerOverlayRef]); + + return { + isRulerActive, + setIsRulerActive: handleSetRulerActive, + pageMeasureScales, + customScale, + handleSetCustomScale, + isScaleCalibrationActive, + scaleCalibrationMeasurement, + startScaleCalibration, + cancelScaleCalibration, + handleScaleCalibrationMeasurement, + applyScaleCalibration, + }; +} diff --git a/frontend/editor/src/core/utils/measurementPreferences.ts b/frontend/editor/src/core/utils/measurementPreferences.ts new file mode 100644 index 0000000000..c339ed64f8 --- /dev/null +++ b/frontend/editor/src/core/utils/measurementPreferences.ts @@ -0,0 +1,24 @@ +// Persist calibration unit preference across sessions +const STORAGE_KEY_LAST_CALIBRATION_UNIT = "stirling_calibration_last_unit"; + +export function getLastCalibrationUnit(defaultUnit: string): string { + try { + const stored = localStorage.getItem(STORAGE_KEY_LAST_CALIBRATION_UNIT); + return stored && stored.trim() ? stored : defaultUnit; + } catch { + // Storage unavailable - private browsing or quota exceeded + return defaultUnit; + } +} + +export function setLastCalibrationUnit(unit: string): void { + try { + localStorage.setItem(STORAGE_KEY_LAST_CALIBRATION_UNIT, unit); + } catch (error) { + // Storage unavailable - preference won't be retained + console.debug( + "[MeasurementPreferences] Unable to persist unit preference:", + error, + ); + } +} diff --git a/frontend/editor/src/core/utils/measurementTypes.ts b/frontend/editor/src/core/utils/measurementTypes.ts new file mode 100644 index 0000000000..caa47ff876 --- /dev/null +++ b/frontend/editor/src/core/utils/measurementTypes.ts @@ -0,0 +1,45 @@ +// Page coordinates with absolute page index +export interface PagePoint { + pageIndex: number; + x: number; + y: number; +} + +// Real-world units per PDF point (factor) vs. architectural ratio for display +export interface MeasureScale { + factor: number; // Real-world units per PDF point + ratio: number | null; // Architectural ratio (e.g., 100 for "1:100") - display only + unit: string; // m, cm, mm, km, ft, in, yd, mi +} + +export type MeasureScaleLike = MeasureScale; + +// Calibration result with full context for audit trail +export interface CalibrationMetadata { + pdfDistancePts: number; // PDF space distance in points + realDistance: number; // User-specified real-world distance + scale: MeasureScale; // Resulting calculated scale + timestamp: string; // ISO 8601 format + unitUsed: string; // Unit active during calibration +} + +// Single measurement between two page points on same page +export interface Measurement { + id: string; + start: PagePoint; + end: PagePoint; +} + +// Viewport area with its own scale (for multi-region PDFs) +export interface ViewportScale { + bbox: [number, number, number, number] | null; // PDF user space or null for entire page + scale: MeasureScale; +} + +// Scale information for a single page with all viewports +export interface PageScaleInfo { + viewports: ViewportScale[]; + pageHeight: number; // PDF points - used to flip screen-y to PDF-y +} + +export type PageMeasureScales = Map; diff --git a/frontend/editor/src/core/utils/measurementUtils.test.ts b/frontend/editor/src/core/utils/measurementUtils.test.ts new file mode 100644 index 0000000000..33cf3f5c2d --- /dev/null +++ b/frontend/editor/src/core/utils/measurementUtils.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import { + POINT_TO_UNIT, + calculateCalibratedScale, + calculateScaleFactor, + convertUnit, + deriveRatioFromFactor, + parsePresetRatio, +} from "@app/utils/measurementUtils"; + +describe("measurementUtils", () => { + describe("calculateScaleFactor", () => { + test("calculates real-world units per PDF point from a scale ratio", () => { + expect(calculateScaleFactor(100, "m")).toBeCloseTo(POINT_TO_UNIT.m * 100); + expect(calculateScaleFactor(50, " cm ")).toBeCloseTo( + POINT_TO_UNIT.cm * 50, + ); + expect(calculateScaleFactor(12, "FT")).toBeCloseTo(POINT_TO_UNIT.ft * 12); + }); + + test("rejects invalid scale ratios", () => { + expect(() => calculateScaleFactor(0, "m")).toThrow("Invalid scale ratio"); + expect(() => calculateScaleFactor(-1, "m")).toThrow( + "Invalid scale ratio", + ); + expect(() => calculateScaleFactor(Number.NaN, "m")).toThrow( + "Invalid scale ratio", + ); + expect(() => calculateScaleFactor(Number.POSITIVE_INFINITY, "m")).toThrow( + "Invalid scale ratio", + ); + }); + + test("rejects unsupported units", () => { + expect(() => calculateScaleFactor(100, "px")).toThrow("Unsupported unit"); + }); + }); + + describe("convertUnit", () => { + test("converts representative metric and imperial values", () => { + expect(convertUnit(1, "m", "cm")).toBeCloseTo(100); + expect(convertUnit(12, "in", "ft")).toBeCloseTo(1); + expect(convertUnit(3, "ft", "yd")).toBeCloseTo(1); + expect(convertUnit(1, "ft", "m")).toBeCloseTo(0.3048); + }); + + test("returns null for invalid values or unsupported units", () => { + expect(convertUnit(Number.NaN, "m", "cm")).toBeNull(); + expect(convertUnit(Number.POSITIVE_INFINITY, "m", "cm")).toBeNull(); + expect(convertUnit(1, "px", "cm")).toBeNull(); + expect(convertUnit(1, "m", "px")).toBeNull(); + }); + }); + + describe("parsePresetRatio", () => { + test("parses supported preset ratios", () => { + expect(parsePresetRatio("1:5")).toBe(5); + expect(parsePresetRatio("1:100")).toBe(100); + expect(parsePresetRatio(" 1 : 150 ")).toBe(150); + }); + + test("returns null for malformed or non-positive presets", () => { + expect(parsePresetRatio("2:100")).toBeNull(); + expect(parsePresetRatio("1:0")).toBeNull(); + expect(parsePresetRatio("1:-10")).toBeNull(); + expect(parsePresetRatio("1:not-a-number")).toBeNull(); + expect(parsePresetRatio("bad")).toBeNull(); + expect(parsePresetRatio("1:10:20")).toBeNull(); + }); + }); + + describe("deriveRatioFromFactor", () => { + test("recovers the scale ratio from a factor and unit", () => { + const factor = calculateScaleFactor(100, "m"); + + expect(deriveRatioFromFactor(factor, "m")).toBeCloseTo(100); + }); + + test("returns null for invalid factors or unsupported units", () => { + expect(deriveRatioFromFactor(0, "m")).toBeNull(); + expect(deriveRatioFromFactor(-1, "m")).toBeNull(); + expect(deriveRatioFromFactor(Number.NaN, "m")).toBeNull(); + expect(deriveRatioFromFactor(1, "px")).toBeNull(); + }); + }); + + describe("calculateCalibratedScale", () => { + test("calculates a calibrated scale from a known physical distance", () => { + const scale = calculateCalibratedScale(72, 1, "in"); + + expect(scale.factor).toBeCloseTo(POINT_TO_UNIT.in); + expect(scale.ratio).toBeCloseTo(1); + expect(scale.unit).toBe("in"); + }); + + test("calculates architectural ratios for metric calibration", () => { + const scale = calculateCalibratedScale(72, 0.0254, "m"); + + expect(scale.factor).toBeCloseTo(POINT_TO_UNIT.m); + expect(scale.ratio).toBeCloseTo(1); + expect(scale.unit).toBe("m"); + }); + + test("rejects invalid calibration inputs", () => { + expect(() => calculateCalibratedScale(0, 1, "m")).toThrow( + "Invalid PDF distance", + ); + expect(() => calculateCalibratedScale(72, 0, "m")).toThrow( + "Invalid real-world distance", + ); + expect(() => calculateCalibratedScale(72, 1, "px")).toThrow( + "Unsupported unit", + ); + }); + }); +}); diff --git a/frontend/editor/src/core/utils/measurementUtils.ts b/frontend/editor/src/core/utils/measurementUtils.ts new file mode 100644 index 0000000000..e35645fb2b --- /dev/null +++ b/frontend/editor/src/core/utils/measurementUtils.ts @@ -0,0 +1,398 @@ +// PDF point to real-world unit conversions + +import type { + Measurement, + MeasureScale, + PagePoint, + CalibrationMetadata, +} from "@app/utils/measurementTypes"; + +// 1 PDF point in meters (1/72 inch) +const POINT_TO_METERS = 0.0254 / 72; + +// Conversion factors: units per PDF point +export const POINT_TO_UNIT = { + m: POINT_TO_METERS, + cm: POINT_TO_METERS * 100, + mm: POINT_TO_METERS * 1000, + km: POINT_TO_METERS / 1000, + ft: POINT_TO_METERS / 0.3048, + in: POINT_TO_METERS / 0.0254, + yd: POINT_TO_METERS / 0.9144, + mi: POINT_TO_METERS / 1609.344, +} as const; + +// Valid measurement units from POINT_TO_UNIT +export type MeasurementUnit = keyof typeof POINT_TO_UNIT; + +function normalizeUnit(unit: string): string { + return unit.toLowerCase().trim(); +} + +function isMeasurementUnit(unit: string): unit is MeasurementUnit { + return Object.hasOwn(POINT_TO_UNIT, unit); +} + +export function getUnitFactor(unit: string): number | undefined { + const normalized = normalizeUnit(unit); + if (!isMeasurementUnit(normalized)) { + return undefined; + } + return POINT_TO_UNIT[normalized]; +} + +export function calculateScaleFactor(ratio: number, unit: string): number { + if (!Number.isFinite(ratio) || ratio <= 0) { + throw new Error(`Invalid scale ratio: ${ratio}`); + } + + const normalized = normalizeUnit(unit); + if (!isMeasurementUnit(normalized)) { + throw new Error(`Unsupported unit: ${unit}`); + } + + return POINT_TO_UNIT[normalized] * ratio; +} + +export function generateScaleLabel(ratio: number | null, unit: string): string { + if (ratio === null || ratio === undefined) { + return unit; + } + const display = Number.isInteger(ratio) + ? ratio.toString() + : ratio.toFixed(2).replace(/\.?0+$/, ""); + return `1:${display} (${unit})`; +} + +// Imperial units +const IMPERIAL_UNITS = ["ft", "in", "yd", "mi"] as const; +export function isImperialUnit(unit: string): boolean { + const normalized = normalizeUnit(unit); + return isMeasurementUnit(normalized) + ? (IMPERIAL_UNITS as readonly MeasurementUnit[]).includes(normalized) + : false; +} + +export function convertUnit( + value: number, + sourceUnit: string, + targetUnit: string, +): number | null { + if (!Number.isFinite(value)) { + return null; + } + + const src = normalizeUnit(sourceUnit); + const tgt = normalizeUnit(targetUnit); + + if (!isMeasurementUnit(src) || !isMeasurementUnit(tgt)) { + return null; + } + + const sourceFactor = POINT_TO_UNIT[src]; + const targetFactor = POINT_TO_UNIT[tgt]; + + return value * (targetFactor / sourceFactor); +} + +export function parsePresetRatio(preset: string): number | null { + const parts = preset.split(":"); + + // Must have exactly 2 parts and first part must be "1" + if (parts.length !== 2 || parts[0].trim() !== "1") { + return null; + } + + const value = Number(parts[1]); + return Number.isFinite(value) && value > 0 ? value : null; +} + +// UI dropdown options - shared across components +export const UNIT_OPTIONS = [ + { value: "m", label: "Meters (m)" }, + { value: "cm", label: "Centimeters (cm)" }, + { value: "mm", label: "Millimeters (mm)" }, + { value: "km", label: "Kilometers (km)" }, + { value: "ft", label: "Feet (ft)" }, + { value: "in", label: "Inches (in)" }, + { value: "yd", label: "Yards (yd)" }, + { value: "mi", label: "Miles (mi)" }, +] as const; + +const MAX_SESSION_ENTRIES = 50; +const TRIMMED_SESSION_ENTRIES = 40; + +/** + * Detect quota exceeded errors across browser implementations. + * Handles: name "QuotaExceededError", code 22 (legacy), "NS_ERROR_DOM_QUOTA_REACHED" + * + * Note: DOMException may not be instanceof Error in all browsers, + * so we check by shape and properties rather than type. + * Note: DOMException.code is deprecated but kept for legacy browser support. + */ +function isQuotaExceededError(error: unknown): boolean { + if (error === null || error === undefined) return false; + + // Check if it's a DOMException when available (standard) + if (typeof DOMException !== "undefined" && error instanceof DOMException) { + if (error.name === "QuotaExceededError") return true; + } + + // Fallback: check by shape for any object with name/code properties + if (typeof error === "object") { + const err = error as Record; + + // Modern standard: check name property (works in all modern browsers) + if (err.name === "QuotaExceededError") return true; + if (err.name === "NS_ERROR_DOM_QUOTA_REACHED") return true; + + // Legacy support: check deprecated code property for very old browsers + // Use Object.hasOwn for safe own-property check + if (Object.hasOwn(err, "code") && err.code === 22) return true; + } + + return false; +} + +// Load entries from sessionStorage +export function loadSessionMap(key: string): Record { + try { + const raw = sessionStorage.getItem(key); + if (!raw) return {}; + + const data = JSON.parse(raw); + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return {}; + } + + return data as Record; + } catch { + // Silently return empty object on parse error + try { + sessionStorage.removeItem(key); + } catch { + // Ignore cleanup errors + } + return {}; + } +} + +// Save entry to sessionStorage with quota management. +export function saveSessionMap( + key: string, + fileKey: string, + value: MeasureScale | Measurement[] | null, +): void { + if (!fileKey) return; + + try { + const existing: Record = { + ...loadSessionMap(key), + }; + + // Delete first to move fileKey to end (maintains insertion order recency) + delete existing[fileKey]; + existing[fileKey] = value; + + // Trim back below the max to avoid pruning again on every subsequent save. + const keys = Object.keys(existing); + if (keys.length > MAX_SESSION_ENTRIES) { + const entriesToDelete = keys.slice( + 0, + keys.length - TRIMMED_SESSION_ENTRIES, + ); + entriesToDelete.forEach((k) => delete existing[k]); + } + + sessionStorage.setItem(key, JSON.stringify(existing)); + } catch (e) { + // Quota exceeded - try clearing and retrying (handles cross-browser error variants) + if (isQuotaExceededError(e)) { + try { + sessionStorage.removeItem(key); + // Retry with fresh storage + const fresh: Record = { [fileKey]: value }; + sessionStorage.setItem(key, JSON.stringify(fresh)); + } catch { + // Silently ignore if retry fails - data loss is acceptable + } + } + // Silently ignore other storage errors + } +} + +// Validation helpers + +export function validatePagePoint(obj: unknown): obj is PagePoint { + if (typeof obj !== "object" || obj === null) return false; + + const pt = obj as Record; + return ( + typeof pt.pageIndex === "number" && + Number.isFinite(pt.pageIndex) && + pt.pageIndex >= 0 && + typeof pt.x === "number" && + Number.isFinite(pt.x) && + typeof pt.y === "number" && + Number.isFinite(pt.y) + ); +} + +// MeasureScale can be null (reset) or valid object +export function validateMeasureScale(obj: unknown): obj is MeasureScale | null { + // null is allowed (reset to default) + if (obj === null) return true; + + if (typeof obj !== "object") return false; + + const s = obj as Record; + + // Validate factor: must be positive finite number + if ( + typeof s.factor !== "number" || + !Number.isFinite(s.factor) || + s.factor <= 0 + ) { + return false; + } + + // Validate ratio: optional, but if present must be positive finite number + if ( + s.ratio !== null && + (typeof s.ratio !== "number" || !Number.isFinite(s.ratio) || s.ratio <= 0) + ) { + return false; + } + + // Validate unit: must be non-empty string and exist in POINT_TO_UNIT + if (typeof s.unit !== "string" || s.unit.trim().length === 0) { + return false; + } + + const normalized = normalizeUnit(s.unit); + if (!isMeasurementUnit(normalized)) { + return false; + } + + return true; +} + +// Reject cross-page measurements +export function validateMeasurement(obj: unknown): obj is Measurement { + if (typeof obj !== "object" || obj === null) return false; + + const m = obj as Record; + + // Validate structure + if ( + !( + typeof m.id === "string" && + m.id.trim().length > 0 && + validatePagePoint(m.start) && + validatePagePoint(m.end) + ) + ) { + return false; + } + + // Reject cross-page measurements + const start = m.start as PagePoint; + const end = m.end as PagePoint; + if (start.pageIndex !== end.pageIndex) { + return false; + } + + return true; +} + +export function formatPaperDistance(distancePts: number): string { + if (!Number.isFinite(distancePts) || distancePts < 0) { + return "0 mm"; + } + + const inches = distancePts / 72; + const mm = inches * 25.4; + + if (mm < 100) { + return `${mm.toFixed(1)} mm`; + } + if (mm < 1000) { + return `${(mm / 10).toFixed(1)} cm`; + } + return `${(mm / 1000).toFixed(2)} m`; +} + +export function validateRealDistance(value: unknown): number | null { + if (value === null || value === undefined || value === "") { + return null; + } + + const num = typeof value === "number" ? value : Number(value); + + if (!Number.isFinite(num) || num <= 0) { + return null; + } + + return num; +} + +export function deriveRatioFromFactor( + factor: number, + unit: string, +): number | null { + if (!Number.isFinite(factor) || factor <= 0) { + return null; + } + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) { + return null; + } + + // ratio = factor / baseFactor + const ratio = factor / baseFactor; + return Number.isFinite(ratio) && ratio > 0 ? ratio : null; +} + +export function calculateCalibratedScale( + pdfDistancePts: number, + realDistance: number, + unit: string, +): MeasureScale { + if (!Number.isFinite(pdfDistancePts) || pdfDistancePts <= 0) { + throw new Error("Invalid PDF distance (must be positive)"); + } + + if (!Number.isFinite(realDistance) || realDistance <= 0) { + throw new Error("Invalid real-world distance (must be positive)"); + } + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) { + throw new Error(`Unsupported unit: ${unit}`); + } + + const factor = realDistance / pdfDistancePts; + const ratio = deriveRatioFromFactor(factor, unit); + + return { + factor, + ratio, + unit, + }; +} + +export function createCalibrationMetadata( + pdfDistancePts: number, + realDistance: number, + scale: MeasureScale, + unitUsed: string, +): CalibrationMetadata { + return { + pdfDistancePts, + realDistance, + scale, + timestamp: new Date().toISOString(), + unitUsed, + }; +} diff --git a/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts b/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts new file mode 100644 index 0000000000..3c40e3209f --- /dev/null +++ b/frontend/editor/src/core/utils/pdfMeasurementExtraction.ts @@ -0,0 +1,215 @@ +import type { + PDFArray, + PDFDict, + PDFHexString, + PDFName, + PDFNumber, + PDFString, +} from "@cantoo/pdf-lib"; +import type { + MeasureScale, + PageMeasureScales, + PageScaleInfo, + ViewportScale, +} from "@app/utils/measurementTypes"; +import { getUnitFactor } from "@app/utils/measurementUtils"; + +type PdfMeasurementObjects = Pick< + typeof import("@cantoo/pdf-lib"), + | "PDFArray" + | "PDFDict" + | "PDFHexString" + | "PDFName" + | "PDFNumber" + | "PDFString" +>; + +function asPdfArray( + value: unknown, + { PDFArray }: PdfMeasurementObjects, +): PDFArray | null { + return value instanceof PDFArray ? value : null; +} + +function asPdfDict( + value: unknown, + { PDFDict }: PdfMeasurementObjects, +): PDFDict | null { + return value instanceof PDFDict ? value : null; +} + +function asPdfNumber( + value: unknown, + { PDFNumber }: PdfMeasurementObjects, +): PDFNumber | null { + return value instanceof PDFNumber ? value : null; +} + +function asPdfText( + value: unknown, + { PDFHexString, PDFName, PDFString }: PdfMeasurementObjects, +): PDFHexString | PDFName | PDFString | null { + if ( + value instanceof PDFString || + value instanceof PDFHexString || + value instanceof PDFName + ) { + return value; + } + return null; +} + +function lookupArray( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): PDFArray | null { + return asPdfArray(dict.lookup(pdfObjects.PDFName.of(key)), pdfObjects); +} + +function lookupDict( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): PDFDict | null { + return asPdfDict(dict.lookup(pdfObjects.PDFName.of(key)), pdfObjects); +} + +function lookupNumber( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): number | null { + return ( + asPdfNumber( + dict.lookup(pdfObjects.PDFName.of(key)), + pdfObjects, + )?.asNumber() ?? null + ); +} + +function lookupText( + dict: PDFDict, + key: string, + pdfObjects: PdfMeasurementObjects, +): string | null { + return ( + asPdfText( + dict.lookup(pdfObjects.PDFName.of(key)), + pdfObjects, + )?.decodeText() ?? null + ); +} + +function readArrayNumber( + array: PDFArray, + index: number, + pdfObjects: PdfMeasurementObjects, +): number | null { + return asPdfNumber(array.lookup(index), pdfObjects)?.asNumber() ?? null; +} + +function readBBox( + bboxArray: PDFArray | null, + pdfObjects: PdfMeasurementObjects, +): ViewportScale["bbox"] { + if (!bboxArray || bboxArray.size() < 4) { + return null; + } + + const x0 = readArrayNumber(bboxArray, 0, pdfObjects); + const y0 = readArrayNumber(bboxArray, 1, pdfObjects); + const x1 = readArrayNumber(bboxArray, 2, pdfObjects); + const y1 = readArrayNumber(bboxArray, 3, pdfObjects); + + if (x0 === null || y0 === null || x1 === null || y1 === null) { + return null; + } + + return [x0, y0, x1, y1]; +} + +function parseScale( + measureDict: PDFDict | null, + pdfObjects: PdfMeasurementObjects, +): MeasureScale | null { + if (!measureDict) return null; + + const fmtArray = + lookupArray(measureDict, "D", pdfObjects) ?? + lookupArray(measureDict, "X", pdfObjects); + if (!fmtArray || fmtArray.size() === 0) return null; + + const firstFmt = asPdfDict(fmtArray.lookup(0), pdfObjects); + if (!firstFmt) return null; + + const factor = lookupNumber(firstFmt, "C", pdfObjects); + if (factor === null || factor <= 0) return null; + + const unit = lookupText(firstFmt, "U", pdfObjects)?.trim().toLowerCase(); + if (!unit) return null; + + const baseFactor = getUnitFactor(unit); + if (!baseFactor) return null; + + const ratio = factor / baseFactor; + return { factor, ratio, unit }; +} + +export async function extractPageMeasureScales( + file: Blob, +): Promise { + try { + const pdfLib = await import("@cantoo/pdf-lib"); + const { PDFDocument, PDFArray, PDFDict, PDFName } = pdfLib; + const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), { + ignoreEncryption: true, + }); + + const result: PageMeasureScales = new Map(); + + for (let i = 0; i < pdfDoc.getPageCount(); i++) { + const page = pdfDoc.getPage(i); + const pageHeight = page.getHeight(); + const viewports: ViewportScale[] = []; + + const vpObj = page.node.lookup(PDFName.of("VP")); + if (vpObj instanceof PDFArray) { + for (let j = 0; j < vpObj.size(); j++) { + const vpEntry = vpObj.lookup(j); + if (!(vpEntry instanceof PDFDict)) continue; + + const scale = parseScale( + lookupDict(vpEntry, "Measure", pdfLib), + pdfLib, + ); + if (!scale) continue; + + viewports.push({ + bbox: readBBox(lookupArray(vpEntry, "BBox", pdfLib), pdfLib), + scale, + }); + } + } + + if (viewports.length === 0) { + const scale = parseScale( + lookupDict(page.node, "Measure", pdfLib), + pdfLib, + ); + if (scale) { + viewports.push({ bbox: null, scale }); + } + } + + if (viewports.length > 0) { + result.set(i, { viewports, pageHeight } satisfies PageScaleInfo); + } + } + + return result.size > 0 ? result : null; + } catch (error) { + console.warn("[Measurement] Failed to extract PDF scales", error); + return null; + } +} From 82e1bd62a2ad166312656b657f35978dbe59970a Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:27:23 +0100 Subject: [PATCH 37/99] Move CODEOWNERS to review teams (#7325) # Description of Changes CODEOWNERS now points at review teams (`maintainers`, `backend-reviewers`, `frontend-reviewers`, `devops-reviewers`, `all`) instead of individual usernames, so membership is managed in the org rather than in this file. Ludy87 and balazs-szucs stay listed by hand since outside collaborators cannot be team members. including the deploy and demo-comment allowlists. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .github/CODEOWNERS | 36 ++++++++++++------- .github/config/repo_devs.json | 1 - .github/workflows/PR-Auto-Deploy-V2.yml | 2 +- .../workflows/PR-Demo-Comment-with-react.yml | 1 - 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 14f6ca750f..a2e3241c65 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,18 +1,28 @@ -# All PRs must be approved by Frooodle or Ludy87 -* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh +# Review ownership is assigned to teams where possible. +# Teams can only contain org members, so outside collaborators are listed by hand. +# +# @Stirling-Tools/maintainers - Frooodle, jbrunton96, ConnorYoh +# @Stirling-Tools/backend-reviewers - Frooodle, jbrunton96, ConnorYoh +# @Stirling-Tools/frontend-reviewers - Frooodle, jbrunton96, ConnorYoh, reecebrowne, EthanHealy01 +# @Stirling-Tools/devops-reviewers - Frooodle, jbrunton96, ConnorYoh +# @Stirling-Tools/all - all of the above +# +# Outside collaborators (need Write access to count as owners): @Ludy87 @balazs-szucs + +# Default owners for everything +* @Stirling-Tools/maintainers @Ludy87 # Backend -/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs +/app/** @Stirling-Tools/backend-reviewers @Ludy87 @balazs-szucs -#V2 frontend -/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs -/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs +# V2 frontend +/frontend/** @Stirling-Tools/frontend-reviewers @balazs-szucs +/app/core/src/main/resources/static/** @Stirling-Tools/frontend-reviewers @Ludy87 @balazs-szucs -#V2 docker -/docker/backend/** @Frooodle @Ludy87 @DarioGii -/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 -/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 +# V2 docker +/docker/backend/** @Stirling-Tools/devops-reviewers @Ludy87 +/docker/frontend/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87 +/docker/compose/** @Stirling-Tools/frontend-reviewers @Stirling-Tools/devops-reviewers @Ludy87 - -#GHA (All users) -/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs +# GHA (all users) +/.github/** @Stirling-Tools/all @Ludy87 @balazs-szucs diff --git a/.github/config/repo_devs.json b/.github/config/repo_devs.json index 8b0bb97a81..597a84dade 100644 --- a/.github/config/repo_devs.json +++ b/.github/config/repo_devs.json @@ -11,7 +11,6 @@ "LaserKaspar", "sbplat", "reecebrowne", - "DarioGii", "ConnorYoh", "EthanHealy01", "jbrunton96", diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 6b37029f90..cd99f6a4cc 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -86,7 +86,7 @@ jobs: fi fi else - auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs") + auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "ConnorYoh" "EthanHealy01" "jbrunton96" "balazs-szucs") is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done if [ "$is_auth" = true ]; then should=true diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 3826897a39..d1e2000b82 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -54,7 +54,6 @@ jobs: github.event.comment.user.login == 'Ludy87' || github.event.comment.user.login == 'balazs-szucs' || github.event.comment.user.login == 'reecebrowne' || - github.event.comment.user.login == 'DarioGii' || github.event.comment.user.login == 'EthanHealy01' || github.event.comment.user.login == 'jbrunton96' || github.event.comment.user.login == 'ConnorYoh' From 2c74b5bf81be0853e0e8503402834e21b7508a42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:01:07 +0100 Subject: [PATCH 38/99] build(deps): bump js-yaml from 4.2.0 to 4.3.1 in /devTools (#7323) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.1.
    Changelog

    Sourced from js-yaml's changelog.

    4.3.1 - 2026-07-31

    Security

    • [backport] Remove quadratic complexity from !!omap duplicate key detection.

    4.3.0 - 2026-06-27

    Added

    • [backport] Added maxTotalMergeKeys (10000) loader option to limit the total number of keys processed by YAML merge (<<) across one load() / loadAll() call.

    Fixed

    • Restore umd builds back to es5.

    Removed

    • [backport] maxMergeSeqLength replaced with maxTotalMergeKeys for limiting YAML merge processing.
    Commits
    • 86e91b8 4.3.1 released
    • c3cc4b0 Backport quadratic complexity fix for !!omap
    • 33d05b5 4.3.0 released
    • 663bfab Drop demo publish, to not override new v5 one.
    • 1cb8c7b Add v4-legacy tag for publish
    • 02f27af Restore umd builds back to es5
    • 8be84ed Fix es5 compatibility
    • 59423c6 Replace maxMergeSeqLength option with maxTotalMergeKeys (more robust). Ba...
    • 6842ef6 doc polish
    • See full diff in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.2.0&new-version=4.3.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- devTools/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devTools/package-lock.json b/devTools/package-lock.json index 39db3d2046..394237a684 100644 --- a/devTools/package-lock.json +++ b/devTools/package-lock.json @@ -894,9 +894,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { From 1867c8f285e92adf7fa7dc3f50c41b05cbd572e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:01:11 +0100 Subject: [PATCH 39/99] build(deps): bump js-yaml from 4.2.0 to 4.3.1 in /frontend (#7322) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.1.
    Changelog

    Sourced from js-yaml's changelog.

    4.3.1 - 2026-07-31

    Security

    • [backport] Remove quadratic complexity from !!omap duplicate key detection.

    4.3.0 - 2026-06-27

    Added

    • [backport] Added maxTotalMergeKeys (10000) loader option to limit the total number of keys processed by YAML merge (<<) across one load() / loadAll() call.

    Fixed

    • Restore umd builds back to es5.

    Removed

    • [backport] maxMergeSeqLength replaced with maxTotalMergeKeys for limiting YAML merge processing.
    Commits
    • 86e91b8 4.3.1 released
    • c3cc4b0 Backport quadratic complexity fix for !!omap
    • 33d05b5 4.3.0 released
    • 663bfab Drop demo publish, to not override new v5 one.
    • 1cb8c7b Add v4-legacy tag for publish
    • 02f27af Restore umd builds back to es5
    • 8be84ed Fix es5 compatibility
    • 59423c6 Replace maxMergeSeqLength option with maxTotalMergeKeys (more robust). Ba...
    • 6842ef6 doc polish
    • See full diff in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.2.0&new-version=4.3.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 500 +------------------------------------ 1 file changed, 3 insertions(+), 497 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6f19db35b7..762cee13c5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11150,9 +11150,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -12968,16 +12968,6 @@ "node": ">=10" } }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -17408,490 +17398,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/tsx/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", From cff6549a40340b01e8be7211af5be3ab34ee3ba6 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:36:46 +0100 Subject: [PATCH 40/99] fix(frontend): keep file persistence working when IndexedDB refuses blobs (#7314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Fixes the WebKit nightly failures ([run 31067620195](https://github.com/Stirling-Tools/Stirling-PDF/actions/runs/31067620195/attempts/1)): 8 tests failed on `stubbed-webkit` only, and every one of them logs the same thing in its trace: ``` IndexedDB add error: UnknownError: Error preparing Blob/File data to be stored in object store ``` ## What broke `storeStirlingFile` stores the `File` itself in IndexedDB, so multi-GB uploads are persisted by reference and never materialize in JS memory. That came in with #7175 (`data: stirlingFile` replacing `data: await stirlingFile.arrayBuffer()`), which is a real memory win and worth keeping. WebKit refuses blob values whenever it can't write the blob's backing file, and rejects the request with the error above. The rejection was only `console.error`d, so on WebKit **no upload ever persisted**, and everything that reads the bytes back behaved as if the upload never happened: - `file-state-across-tools` — file gone after navigating; the sidebar shows "No files yet" - `compare` — `FileSelectorPicker: upload failed`, so the slot stays `data-slot-state="empty"` - `classification-grouping` / `classification-heuristic-upload` — the label backfill and thumbnails read from IDB (`not in IndexedDB (likely remote-only stub)`), so files land in "Recent" with no category headers Chromium and Firefox store blobs fine, and PR CI only runs the `stubbed` (chromium) project, so nightly was the only gate that could catch it. ## The fix Try the blob first, keep a fallback: - `storeStirlingFile`'s `add` is extracted into `addFileRecord` so it can run twice - if the value was a Blob and the failure is `UnknownError` / `DataCloneError`, re-add the record with an `ArrayBuffer` copy and set `blobValuesSupported = false`, so later files in that session go straight to the copy path instead of losing the blob attempt every time - deliberately narrow: `QuotaExceededError` and `ConstraintError` still propagate, because a copy would fail the same way and retrying would hide the real cause - dropped two internal `console.error`s: every caller already reports (`addFiles`, `FileSelectorPicker`, `zipFileService` collects into `result.errors`), so they were duplicate noise Every writer goes through `storeStirlingFile` (uploads, the file picker, zip extraction, folder automation, `IndexedDBContext`), so this one seam covers all of them. The read paths already accept either shape (`new Blob([record.data], ...)`). Net effect: Chromium and Firefox keep the no-copy path; engines that refuse blobs degrade to the pre-#7175 behaviour instead of silently losing files. On such an engine a very large file can still exhaust renderer memory — the fallback warns about exactly that. Fixing that properly means chunked storage, which is out of scope here. ## Verification Reproduced and confirmed the cause by A/B on a branch that predates #7175: as-is 8/8 pass on WebKit, and applying only #7175's `data: stirlingFile` line reproduces the exact CI failure set. | Check | Result | |---|---| | `stubbed-webkit`: the 8 nightly failures + `classification-heuristic-upload` | 9 passed | | `stubbed-webkit`: `files-page`, `page-editor-rotation`, `encrypted-pdf-unlock` | 32 passed, 1 skipped | | `stubbed` (chromium): the same specs + `files-page` | 35 passed, 1 skipped | | Frontend unit suite | 210 files, 1797 passed | | `typecheck:core`, `typecheck:proprietary`, eslint, prettier | clean | New unit coverage in `fileStorage.blobFallback.test.ts` pins the contract over `fake-indexeddb` with `add` instrumented to count blob vs copy attempts: blob path when accepted, blob-then-copy when refused (and readable back), one attempt only for later files, and quota not retried. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Testing (if applicable) - [x] Frontend typecheck (core + proprietary), eslint, prettier, the unit suite, and the affected Playwright specs on chromium and webkit all pass Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../services/fileStorage.blobFallback.test.ts | 146 ++++++++++++++++++ .../editor/src/core/services/fileStorage.ts | 57 +++++-- 2 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 frontend/editor/src/core/services/fileStorage.blobFallback.test.ts diff --git a/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts b/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts new file mode 100644 index 0000000000..d8530af026 --- /dev/null +++ b/frontend/editor/src/core/services/fileStorage.blobFallback.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test, afterEach, beforeEach, vi } from "vitest"; +import "fake-indexeddb/auto"; +import { expectConsole } from "@app/tests/failOnConsole"; + +/** + * Regression test for the WebKit nightly breakage introduced with the + * large-file OOM fix (#7175): `storeStirlingFile` began putting the `File` + * itself into IndexedDB (persisted by reference, so multi-GB uploads never + * materialize in JS memory). WebKit refuses blob values whenever it can't write + * the blob's backing file and rejects the request with `UnknownError: Error + * preparing Blob/File data to be stored in object store`, so on WebKit every + * upload silently failed to persist: files vanished on navigation, Compare + * slots never filled, and the classification backfill had no bytes to read. + * + * The service now retries such a rejection with an ArrayBuffer copy and stops + * offering blobs for the rest of the session. + */ + +const nativeAdd = IDBObjectStore.prototype.add; + +/** What each `add` attempt carried in `data` — the blob path or the copy path. */ +let attempts: Array<"blob" | "copy"> = []; + +/** An IDBRequest that fails asynchronously, the way WebKit rejects blob puts. */ +class FailingRequest extends EventTarget { + onerror: ((event: Event) => void) | null = null; + onsuccess: ((event: Event) => void) | null = null; + + constructor(readonly error: DOMException) { + super(); + queueMicrotask(() => this.onerror?.(new Event("error"))); + } +} + +/** + * Record every add attempt, optionally failing the blob-valued ones the way an + * engine without blob storage does. + */ +function instrumentAdd(options: { rejectBlobs: boolean }) { + IDBObjectStore.prototype.add = function ( + this: IDBObjectStore, + value: unknown, + key?: IDBValidKey, + ) { + const isBlob = (value as { data?: unknown } | null)?.data instanceof Blob; + attempts.push(isBlob ? "blob" : "copy"); + if (isBlob && options.rejectBlobs) { + return new FailingRequest( + new DOMException( + "Error preparing Blob/File data to be stored in object store", + "UnknownError", + ), + ) as unknown as IDBRequest; + } + return key === undefined + ? nativeAdd.call(this, value) + : nativeAdd.call(this, value, key); + } as typeof IDBObjectStore.prototype.add; +} + +/** + * A fresh service per test: whether the engine accepts blobs is remembered for + * the process lifetime by design, so tests must not inherit that decision from + * each other. + */ +async function freshFileStorage() { + vi.resetModules(); + const [{ fileStorage }, { createStirlingFile, createNewStirlingFileStub }] = + await Promise.all([ + import("@app/services/fileStorage"), + import("@app/types/fileContext"), + ]); + const store = async (name: string) => { + const file = new File(["%PDF-1.7 stirling"], name, { + type: "application/pdf", + }); + const stub = createNewStirlingFileStub(file); + await fileStorage.storeStirlingFile( + createStirlingFile(file, stub.id), + stub, + ); + return stub.id; + }; + return { fileStorage, store }; +} + +beforeEach(() => { + attempts = []; +}); + +afterEach(() => { + IDBObjectStore.prototype.add = nativeAdd; +}); + +describe("storeStirlingFile — blob-value fallback", () => { + test("stores the File by reference when the engine accepts blob values", async () => { + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: false }); + + const id = await store("by-reference.pdf"); + + expect(attempts).toEqual(["blob"]); + expect((await fileStorage.getStirlingFile(id))?.name).toBe( + "by-reference.pdf", + ); + }); + + test("falls back to a copy when the engine rejects blob values, and the file stays readable", async () => { + expectConsole.warn(/IndexedDB rejected a Blob value/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: true }); + + const id = await store("webkit.pdf"); + + expect(attempts).toEqual(["blob", "copy"]); + // Readable back is what every downstream consumer depends on: rehydration + // after navigation, thumbnails, the classification backfill. + expect((await fileStorage.getStirlingFile(id))?.name).toBe("webkit.pdf"); + }); + + test("remembers the rejection, so later files skip the doomed blob attempt", async () => { + expectConsole.warn(/IndexedDB rejected a Blob value/); + const { fileStorage, store } = await freshFileStorage(); + instrumentAdd({ rejectBlobs: true }); + + await store("first.pdf"); + attempts = []; + const id = await store("second.pdf"); + + // Straight to the copy path — no repeated blob probe, and only the single + // warning expected above. + expect(attempts).toEqual(["copy"]); + expect((await fileStorage.getStirlingFile(id))?.name).toBe("second.pdf"); + }); + + test("does not retry a failure a copy can't fix (quota)", async () => { + const { store } = await freshFileStorage(); + IDBObjectStore.prototype.add = function (this: IDBObjectStore) { + attempts.push("blob"); + throw new DOMException("no space left", "QuotaExceededError"); + } as typeof IDBObjectStore.prototype.add; + + await expect(store("too-big.pdf")).rejects.toThrow(/no space left/); + expect(attempts).toEqual(["blob"]); + }); +}); diff --git a/frontend/editor/src/core/services/fileStorage.ts b/frontend/editor/src/core/services/fileStorage.ts index 4f82af3a8f..40f62642f0 100644 --- a/frontend/editor/src/core/services/fileStorage.ts +++ b/frontend/editor/src/core/services/fileStorage.ts @@ -63,9 +63,27 @@ export function legacyDerivedFromTool( return undefined; } +/** + * Can't persist a Blob/File value, so a copy would work? WebKit reports + * `UnknownError` ("Error preparing Blob/File data...") when it can't write the + * blob's backing file; a refused structured clone is `DataCloneError`. + * Narrow on purpose: retrying quota or duplicate-key failures would fail again + * and hide the real cause. + */ +function isBlobValueRejection(error: unknown): boolean { + const name = (error as DOMException | null)?.name; + return name === "UnknownError" || name === "DataCloneError"; +} + class FileStorageService { private readonly dbConfig = DATABASE_CONFIGS.FILES; private readonly storeName = "files"; + /** + * Whether this engine accepts Blob/File values in IndexedDB. Optimistic: the + * blob path avoids copying multi-GB files into JS memory, so we try it and + * remember the answer, rather than pre-emptively degrading everywhere. + */ + private blobValuesSupported = true; /** * Get database connection using centralized manager @@ -132,7 +150,10 @@ class FileStorageService { createdAt: stub.createdAt, // Store the File (a Blob) itself: IndexedDB persists it by reference and // streams to disk, so multi-GB files never materialize in JS memory. - data: stirlingFile, + // Engines that reject blob values fall back to a copy — see addFileRecord. + data: this.blobValuesSupported + ? stirlingFile + : await stirlingFile.arrayBuffer(), thumbnail: stub.thumbnailUrl, thumbnailStoredAt: stub.thumbnailUrl ? Date.now() : undefined, isLeaf: stub.isLeaf ?? true, @@ -160,6 +181,30 @@ class FileStorageService { classificationLabels: stub.classificationLabels, }; + try { + await this.addFileRecord(db, record); + } catch (error) { + // Recoverable: re-add as a copy, and stop offering blobs this session. + // Anything else is the caller's to report. + if (!(record.data instanceof Blob) || !isBlobValueRejection(error)) { + throw error; + } + this.blobValuesSupported = false; + console.warn( + "IndexedDB rejected a Blob value; falling back to in-memory copies for this session. " + + "Very large files may now exhaust renderer memory.", + error, + ); + record.data = await record.data.arrayBuffer(); + await this.addFileRecord(db, record); + } + } + + /** Single `add` of a file record. Rejects with the underlying IDB error. */ + private addFileRecord( + db: IDBDatabase, + record: StoredStirlingFileRecord, + ): Promise { return new Promise((resolve, reject) => { try { // Verify store exists before creating transaction @@ -174,15 +219,9 @@ class FileStorageService { const request = store.add(record); - request.onerror = () => { - console.error("IndexedDB add error:", request.error); - reject(request.error); - }; - request.onsuccess = () => { - resolve(); - }; + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); } catch (error) { - console.error("Transaction error:", error); reject(error); } }); From 408f9ef1488cdd6a8f70e8dec122ad2e92f9318e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 7 Aug 2026 14:49:19 +0100 Subject: [PATCH 41/99] Fix `any` type usages in frontend code (#7326) # Description of Changes Continued effort towards removing all uses of the `any` type in our frontend code. This PR fixes 10 more folders and removes them from the exclude list. All of them were really simple fixes. --- .../components/pageEditor/commands/pageCommands.ts | 1 - .../components/pageEditor/hooks/useEditorCommands.ts | 9 ++++----- .../pageEditor/hooks/useUndoManagerState.ts | 7 +++++-- .../components/shared/config/SettingsSearchBar.tsx | 2 +- .../shared/pageEditor/useFileItemDragDrop.ts | 8 +++++--- .../bookletImposition/BookletImpositionSettings.tsx | 11 +++++++---- .../components/tools/shared/ToolWorkflowTitle.tsx | 3 ++- .../components/tools/shared/renderToolButtons.tsx | 10 ++++++++-- .../core/hooks/signing/useSigningSessionController.ts | 5 +++-- .../adjustContrast/useAdjustContrastOperation.ts | 5 +++-- .../core/hooks/tools/convert/useConvertOperation.ts | 4 ++-- .../removePassword/useRemovePasswordOperation.test.ts | 2 +- frontend/eslint.config.mjs | 10 ---------- 13 files changed, 41 insertions(+), 36 deletions(-) diff --git a/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts b/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts index 1f80b940cd..db9385fc37 100644 --- a/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts +++ b/frontend/editor/src/core/components/pageEditor/commands/pageCommands.ts @@ -686,7 +686,6 @@ export class InsertFilesCommand extends DOMCommand { private insertedPages: PDFPage[] = []; private originalDocument: PDFDocument | null = null; private fileDataMap = new Map(); // Store file data for thumbnail generation - private originalProcessedFile: any = null; // Store original ProcessedFile for undo private insertedFileMap = new Map(); // Store inserted files for export constructor( diff --git a/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts b/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts index a790e94085..aca8390d64 100644 --- a/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts +++ b/frontend/editor/src/core/components/pageEditor/hooks/useEditorCommands.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react"; import { BulkRotateCommand, DeletePagesCommand, + DOMCommand, PageBreakCommand, ReorderPagesCommand, SplitCommand, @@ -24,7 +25,7 @@ interface UsePageEditorCommandsParams { selectedPageIds: string[]; setSelectedPageIds: (ids: string[]) => void; getPageNumbersFromIds: (pageIds: string[]) => number[]; - executeCommandWithTracking: (command: any) => void; + executeCommandWithTracking: (command: DOMCommand) => void; updateFileOrderFromPages: (pages: PDFPage[]) => void; actions: FileActions; selectors: FileSelectors; @@ -145,10 +146,8 @@ export const usePageEditorCommands = ({ [executeCommandWithTracking, setSplitPositions], ); - const executeCommand = useCallback((command: any) => { - if (command && typeof command.execute === "function") { - command.execute(); - } + const executeCommand = useCallback((command: { execute: () => void }) => { + command.execute(); }, []); const handleRotate = useCallback( diff --git a/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts b/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts index 86fdf347d2..0e11aa1e80 100644 --- a/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts +++ b/frontend/editor/src/core/components/pageEditor/hooks/useUndoManagerState.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { UndoManager } from "@app/components/pageEditor/commands/pageCommands"; +import { + DOMCommand, + UndoManager, +} from "@app/components/pageEditor/commands/pageCommands"; interface UseUndoManagerStateParams { setHasUnsavedChanges: (dirty: boolean) => void; @@ -29,7 +32,7 @@ export const useUndoManagerState = ({ }, [updateUndoRedoState]); const executeCommandWithTracking = useCallback( - (command: any) => { + (command: DOMCommand) => { undoManagerRef.current.executeCommand(command); setHasUnsavedChanges(true); }, diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx index ea1819dfb2..397347dcf6 100644 --- a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx +++ b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.tsx @@ -138,7 +138,7 @@ export const SettingsSearchBar: React.FC = ({ const translationPrefixes = getTranslationPrefixesForNavKey(item.key); const translationContent = translationPrefixes.flatMap((prefix) => flattenTranslationStrings( - t(prefix, { returnObjects: true, defaultValue: {} } as any), + t(prefix, { returnObjects: true, defaultValue: {} }), ), ); diff --git a/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts b/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts index b51b90737a..c1f5d9aaf3 100644 --- a/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts +++ b/frontend/editor/src/core/components/shared/pageEditor/useFileItemDragDrop.ts @@ -111,8 +111,7 @@ export const useFileItemDragDrop = ({ if (!element) return; const rect = element.getBoundingClientRect(); - const clientY = - (source as any).element?.getBoundingClientRect().top || 0; + const clientY = source.element?.getBoundingClientRect().top || 0; const midpoint = rect.top + rect.height / 2; setDropPosition(clientY < midpoint ? "below" : "above"); @@ -121,7 +120,10 @@ export const useFileItemDragDrop = ({ setIsDragOver(false); const dropPos = dropPositionRef.current; setDropPosition("below"); - const sourceData = source.data as any; + const sourceData = source.data as { + type?: string; + fromIndex?: number; + }; if (sourceData?.type === "file-item") { const fromIndex = sourceData.fromIndex as number; let toIndex = indexRef.current; diff --git a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx index fab1f3ff5e..1fbf447a21 100644 --- a/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx +++ b/frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.tsx @@ -14,9 +14,9 @@ import ButtonSelector from "@app/components/shared/ButtonSelector"; interface BookletImpositionSettingsProps { parameters: BookletImpositionParameters; - onParameterChange: ( - key: keyof BookletImpositionParameters, - value: any, + onParameterChange: ( + key: K, + value: BookletImpositionParameters[K], ) => void; disabled?: boolean; } @@ -214,7 +214,10 @@ const BookletImpositionSettings = ({ )} value={parameters.gutterSize} onChange={(value) => - onParameterChange("gutterSize", value || 12) + onParameterChange( + "gutterSize", + typeof value === "number" ? value : 12, + ) } min={6} max={72} diff --git a/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx b/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx index bc5d362666..60f7448f7e 100644 --- a/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx +++ b/frontend/editor/src/core/components/tools/shared/ToolWorkflowTitle.tsx @@ -2,13 +2,14 @@ import React from "react"; import { Flex, Text, Divider } from "@mantine/core"; import LocalIcon from "@app/components/shared/LocalIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { TooltipTip } from "@app/types/tips"; export interface ToolWorkflowTitleProps { title: string; description?: string; tooltip?: { content?: React.ReactNode; - tips?: any[]; + tips?: TooltipTip[]; header?: { title: string; logo?: React.ReactNode; diff --git a/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx b/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx index b17cad7c61..d63230914e 100644 --- a/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx +++ b/frontend/editor/src/core/components/tools/shared/renderToolButtons.tsx @@ -2,7 +2,10 @@ import { Box } from "@mantine/core"; import ToolButton from "@app/components/tools/toolPicker/ToolButton"; import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader"; -import { getSubcategoryLabel } from "@app/data/toolsTaxonomy"; +import { + getSubcategoryLabel, + type ToolRegistryEntry, +} from "@app/data/toolsTaxonomy"; import { TFunction } from "i18next"; import { SubcategoryGroup } from "@app/hooks/useToolSections"; import { ToolId } from "@app/types/toolId"; @@ -15,7 +18,10 @@ export const renderToolButtons = ( onSelect: (id: ToolId) => void, showSubcategoryHeader: boolean = true, disableNavigation: boolean = false, - searchResults?: Array<{ item: [string, any]; matchedText?: string }>, + searchResults?: Array<{ + item: [ToolId, ToolRegistryEntry]; + matchedText?: string; + }>, hasStars: boolean = false, ) => { // Create a map of matched text for quick lookup diff --git a/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts b/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts index 8fb1ddd5b6..e114a16f9d 100644 --- a/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts +++ b/frontend/editor/src/core/hooks/signing/useSigningSessionController.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { isAxiosError } from "axios"; import apiClient from "@app/services/apiClient"; import { alert } from "@app/components/toast"; import { fileStorage } from "@app/services/fileStorage"; @@ -333,8 +334,8 @@ export function useSigningSessionController(enabled: boolean) { pdfFile = new File([pdfResponse.data], session.documentName, { type: "application/pdf", }); - } catch (pdfError: any) { - if (pdfError?.response?.status === 404) { + } catch (pdfError) { + if (isAxiosError(pdfError) && pdfError.response?.status === 404) { alert({ alertType: "warning", title: t("certSign.sessions.pdfNotReady", "PDF Not Ready"), diff --git a/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts b/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts index 18981b58d0..858ab8c7ef 100644 --- a/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts +++ b/frontend/editor/src/core/hooks/tools/adjustContrast/useAdjustContrastOperation.ts @@ -13,9 +13,10 @@ import { pdfWorkerManager } from "@app/services/pdfWorkerManager"; import { createFileFromApiResponse } from "@app/utils/fileResponseUtils"; import { getPdfiumModule, saveRawDocument } from "@app/services/pdfiumService"; import { copyRgbaToBgraHeap } from "@app/utils/pdfiumBitmapUtils"; +import type { PDFDocumentProxy } from "pdfjs-dist"; async function renderPdfPageToCanvas( - pdf: any, + pdf: PDFDocumentProxy, pageNumber: number, scale: number, ): Promise { @@ -26,7 +27,7 @@ async function renderPdfPageToCanvas( canvas.height = viewport.height; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas 2D context unavailable"); - await page.render({ canvasContext: ctx, viewport }).promise; + await page.render({ canvasContext: ctx, canvas, viewport }).promise; return canvas; } diff --git a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts index d6175e2b8b..d912cc4ede 100644 --- a/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/editor/src/core/hooks/tools/convert/useConvertOperation.ts @@ -210,8 +210,8 @@ export const buildConvertFormData = ( // Static function that can be used by both the hook and automation executor export const createFileFromResponse = ( - responseData: any, - headers: any, + responseData: Blob, + headers: Record, originalFileName: string, targetExtension: string, ): File => { diff --git a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts index b60aa09762..8f2953afde 100644 --- a/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/removePassword/useRemovePasswordOperation.test.ts @@ -85,7 +85,7 @@ describe("useRemovePasswordOperation", () => { const testFile = new File(["test content"], "test.pdf", { type: "application/pdf", }); - const formData = buildFormData(testParameters, testFile as any); + const formData = buildFormData(testParameters, testFile); // Verify the form data contains the file expect(formData.get("fileInput")).toBe(testFile); diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 256bdf640a..43fe732a4e 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -284,28 +284,18 @@ export default defineConfig( ignores: [ "editor/src/core/components/annotation/**/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/pageEditor/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/pageEditor/commands/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/pageEditor/hooks/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/shared/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/shared/config/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/shared/config/configSections/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/shared/pageEditor/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/addStamp/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/automate/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/tools/bookletImposition/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/certSign/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/pdfTextEditor/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/tools/shared/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/file/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/hooks/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/signing/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/tools/adjustContrast/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/tools/convert/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/hooks/tools/removePassword/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/hooks/tools/shared/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/services/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/tools/annotate/useAnnotationSelection.ts", From 37a48aa7a700fc0d9e33a8dad87da4a0857b3e3a Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 7 Aug 2026 17:02:11 +0100 Subject: [PATCH 42/99] Replace ESLint and dpdm with Oxlint (#7330) # Description of Changes Smaller scope than #6689 to try and get this finished. Replace ESLint and dpdm with Oxlint, a TS linter written in Rust so its performance is dramatically better than the existing tools we use. ## Speed improvement - Current ESLint run: 13.76s - Current dpdm run: 3.59s - Total time: 17.35s - New Oxlint run: 0.90s So Oxlint is about a 20x speed improvement. ## Differences When I last tried to do this, we could recreate our rules identically with Oxlint, but that's not true any more. Oxlint has no current equivalent for ESLint's `no-restricted-syntax` rule, which we were using to ban usages of ` @@ -90,8 +90,8 @@ function MockChatContent({ maxWidth: "82%", background: m.role === "user" - ? "#3b82f6" - : "var(--c-surface-sunken, #f3f4f6)", + ? "var(--c-primary)" + : "var(--c-surface-sunken)", color: m.role === "user" ? "#fff" : "inherit", borderRadius: 10, padding: "8px 12px", @@ -108,17 +108,17 @@ function MockChatContent({
    What do you want to do? @@ -149,7 +149,7 @@ function ChatFABWidgetDemo({ width: "100%", height: "100%", overflow: "hidden", - background: "var(--c-bg, #f8f9fb)", + background: "var(--c-bg)", }} > {/* FAB button */} @@ -249,7 +249,7 @@ function ChatFABFullFlowDemo() { padding: "4px 10px", borderRadius: 6, background: - step === s ? "#3b82f6" : "var(--c-surface-sunken, #f3f4f6)", + step === s ? "var(--c-primary)" : "var(--c-surface-sunken)", color: step === s ? "#fff" : "inherit", fontWeight: step === s ? 600 : 400, }} diff --git a/frontend/editor/src/portal/data/Ops.stories.tsx b/frontend/editor/src/portal/data/Ops.stories.tsx index 8e59e5f5ef..502791e2d1 100644 --- a/frontend/editor/src/portal/data/Ops.stories.tsx +++ b/frontend/editor/src/portal/data/Ops.stories.tsx @@ -27,7 +27,7 @@ const STAGE_ORDER: OpKind[] = [ const STAGE_COLOUR: Record = { ingest: "var(--color-green)", validate: "var(--c-primary)", - modify: "#F97316", + modify: "var(--color-orange)", secure: "var(--color-red)", store: "var(--color-purple)", alert: "var(--color-amber)", From 59ed4f5fd117cac60997cc3148c02c09e0743f41 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:14:56 +0100 Subject: [PATCH 60/99] Fix automate unrunnable tools (#7311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Fix automate unrunnable tools ## Problem - Remove Image failed in Automate with `Tool operation not supported: removeImage` - Its registry entry had `operationConfig: undefined` even though the config existed and was already tested - The Automate picker only filtered on `supportsAutomate`, never on `operationConfig` — so broken tools were selectable and failed only at run time ## Fixes - Wire up `removeImage` and `pageLayout` operation configs (both already existed, just never registered) - Exclude `validateSignature` (report tool, not on the operationConfig seam) and `scannerEffect` (no frontend implementation) via `supportsAutomate: false` - Picker now also filters on `operationConfig`, so this class of bug can't reach users again - `overlay-pdfs` returns 400 instead of 500 when overlay files or mode are missing - Fix `new URL().pathname` Windows path bug that stopped 2 test suites from loading --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .../controller/api/PdfOverlayController.java | 20 +++++++++++ .../tools/automate/ToolSelector.tsx | 8 +++-- ...tomatableToolsHaveOperationConfig.test.tsx | 33 +++++++++++++++++++ .../core/data/useTranslatedToolRegistry.tsx | 11 ++++++- .../src/core/utils/toolIOCompat.test.ts | 5 ++- .../src/core/utils/toolIOLabels.test.ts | 5 ++- 6 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java index 7f17738d98..1d369d282d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/PdfOverlayController.java @@ -61,6 +61,7 @@ public class PdfOverlayController { int overlayPos = request.getOverlayPosition(); MultipartFile[] overlayFiles = request.getOverlayFiles(); + validateOverlayFiles(overlayFiles); File[] overlayPdfFiles = new File[overlayFiles.length]; List tempFiles = new ArrayList<>(); // List to keep track of temporary files @@ -120,10 +121,29 @@ public class PdfOverlayController { } } + // Both fields are declared required, but @ModelAttribute binding leaves them null when the + // caller omits them, which would otherwise surface as a 500 instead of a 400. + private void validateOverlayFiles(MultipartFile[] overlayFiles) { + if (overlayFiles == null || overlayFiles.length == 0) { + throw ExceptionUtils.createIllegalArgumentException( + "error.overlayFilesRequired", "At least one overlay file is required"); + } + for (MultipartFile overlayFile : overlayFiles) { + if (overlayFile == null || overlayFile.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.overlayFileEmpty", "Overlay files must not be empty"); + } + } + } + private Map prepareOverlayGuide( int basePageCount, File[] overlayFiles, String mode, int[] counts, List tempFiles) throws IOException { Map overlayGuide = new HashMap<>(); + if (mode == null) { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", "null"); + } switch (mode) { case "SequentialOverlay": sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles); diff --git a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx index c5c3998bc0..ad7bb33a6e 100644 --- a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx +++ b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx @@ -34,13 +34,17 @@ export default function ToolSelector({ const [shouldAutoFocus, setShouldAutoFocus] = useState(false); const containerRef = useRef(null); - // Filter out excluded tools (like 'automate' itself) and tools that don't support automation + // Filter out excluded tools (like 'automate' itself), tools that don't support + // automation, and tools with no operationConfig - the executor resolves a step + // through operationConfig, so offering one without it fails only at run time. const baseFilteredTools = useMemo(() => { return ( Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][] ).filter( ([key, tool]) => - !excludeTools.includes(key) && getToolSupportsAutomate(tool), + !excludeTools.includes(key) && + getToolSupportsAutomate(tool) && + Boolean(tool.operationConfig), ); }, [toolRegistry, excludeTools]); diff --git a/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx new file mode 100644 index 0000000000..461c07ebdf --- /dev/null +++ b/frontend/editor/src/core/data/automatableToolsHaveOperationConfig.test.tsx @@ -0,0 +1,33 @@ +/** + * Registry invariant: the Automate picker offers a tool whenever it doesn't opt out via + * `supportsAutomate: false`, but automationExecutor resolves each step through the tool's + * `operationConfig`. A tool that is offered without one is selectable in the builder and + * only fails when the automation runs, with "Tool operation not supported: ". + * + * So a tool must either carry an operationConfig or declare supportsAutomate: false. + */ +import { describe, expect, test, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry"; +import { getToolSupportsAutomate } from "@app/data/toolsTaxonomy"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + i18n: { changeLanguage: vi.fn(), language: "en-US" }, + }), + Trans: ({ children }: { children?: unknown }) => children, +})); + +describe("automatable tools", () => { + test("every tool offered to Automate can be executed as a step", () => { + const { result } = renderHook(() => useTranslatedToolCatalog()); + + const offeredWithoutConfig = Object.entries(result.current.regularTools) + .filter(([, entry]) => entry && getToolSupportsAutomate(entry)) + .filter(([, entry]) => !entry.operationConfig) + .map(([id]) => id); + + expect(offeredWithoutConfig).toEqual([]); + }); +}); diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index 16b69d6983..0a39da2fb9 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -50,6 +50,8 @@ import { changeMetadataOperationConfig } from "@app/hooks/tools/changeMetadata/u import { signOperationConfig } from "@app/hooks/tools/sign/useSignOperation"; import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation"; import { removeAnnotationsOperationConfig } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation"; +import { removeImageOperationConfig } from "@app/hooks/tools/removeImage/useRemoveImageOperation"; +import { pageLayoutOperationConfig } from "@app/hooks/tools/pageLayout/usePageLayoutOperation"; import { extractImagesOperationConfig } from "@app/hooks/tools/extractImages/useExtractImagesOperation"; import { replaceColorOperationConfig } from "@app/hooks/tools/replaceColor/useReplaceColorOperation"; import { removePagesOperationConfig } from "@app/hooks/tools/removePages/useRemovePagesOperation"; @@ -526,6 +528,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { maxFiles: -1, endpoints: ["validate-signature"], synonyms: getSynonyms(t, "validateSignature"), + // Reports on signatures rather than transforming the PDF, and its hook is + // not on the operationConfig seam, so it cannot run as an automation step. + supportsAutomate: false, automationSettings: null, }, @@ -755,6 +760,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { subcategoryId: SubcategoryId.PAGE_FORMATTING, maxFiles: -1, endpoints: ["multi-page-layout"], + operationConfig: asRegistryConfig(pageLayoutOperationConfig), automationSettings: lazySettings( () => import("@app/components/tools/pageLayout/PageLayoutSettings"), ), @@ -967,7 +973,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { subcategoryId: SubcategoryId.REMOVAL, maxFiles: -1, endpoints: ["remove-image-pdf"], - operationConfig: undefined, + operationConfig: asRegistryConfig(removeImageOperationConfig), synonyms: getSynonyms(t, "removeImage"), automationSettings: null, }, @@ -1196,6 +1202,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { subcategoryId: SubcategoryId.ADVANCED_FORMATTING, endpoints: ["scanner-effect"], synonyms: getSynonyms(t, "scannerEffect"), + // No frontend implementation yet (component is null), so it has no + // operationConfig to execute as an automation step. + supportsAutomate: false, automationSettings: null, }, diff --git a/frontend/editor/src/core/utils/toolIOCompat.test.ts b/frontend/editor/src/core/utils/toolIOCompat.test.ts index c0e9d9efa3..b3fb117705 100644 --- a/frontend/editor/src/core/utils/toolIOCompat.test.ts +++ b/frontend/editor/src/core/utils/toolIOCompat.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { validateToolChain, @@ -24,7 +25,9 @@ interface SharedCase { /** Shared with the backend and engine, so it lives at the repo root. */ function casesFile(): string { - let current = dirname(new URL(import.meta.url).pathname); + // fileURLToPath, not URL.pathname: on Windows the latter yields "/C:/..." and + // resolving against it produces a "C:\C:\..." path that never matches. + let current = dirname(fileURLToPath(import.meta.url)); for (let i = 0; i < 12; i++) { const candidate = resolve(current, "testing/tool-io-cases.json"); try { diff --git a/frontend/editor/src/core/utils/toolIOLabels.test.ts b/frontend/editor/src/core/utils/toolIOLabels.test.ts index ac6b4c155b..98c529e677 100644 --- a/frontend/editor/src/core/utils/toolIOLabels.test.ts +++ b/frontend/editor/src/core/utils/toolIOLabels.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { TOOL_FORMATS, type ToolFormat } from "@app/types/toolIO"; import { @@ -9,7 +10,9 @@ import { /** The en-US `[toolFormat]` block, read straight from the locale file. */ function toolFormatLabels(): Record { - let current = dirname(new URL(import.meta.url).pathname); + // fileURLToPath, not URL.pathname: on Windows the latter yields "/C:/..." and + // resolving against it produces a "C:\C:\..." path that never matches. + let current = dirname(fileURLToPath(import.meta.url)); for (let i = 0; i < 12; i++) { try { const toml = readFileSync( From 35a861f4f80f2816b22ec57fef2c1a0a6b9906b2 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:42:44 +0100 Subject: [PATCH 61/99] feat(editor): move endpoint availability onto TanStack Query (#7285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes > Stacked on #7264, sibling of #7283. Independent of #7283 — the only overlap is two additive lines in `core/query/keys.ts` and `core/api/config.ts`. Either can merge first. ## The problem `useEndpointConfig` kept its own cache: a module-level `globalFetchDone` boolean, a mutable `globalEndpointCache` object, and a `resetGlobalCache()` called from the JWT listener. Which consumer mounted first decided who paid for the request, and nothing invalidated it except a page reload. ## End state One shared query for the whole availability map; each of the 12 consumers projects the endpoints it asked for. **251 lines to 101**, same return shape, no consumer changes. | | Before | After | |---|---|---| | Cross-consumer cache | `globalFetchDone` + mutable module object | query key | | Invalidation | `resetGlobalCache()` mutating that object | `invalidateQueries` | | Per-endpoint check | own `useState` triple | query keyed by endpoint | Behaviour kept deliberately: - **Unknown endpoints and any failure still read as enabled.** This fires before auth settles, and disabling every tool on a hiccup is worse than letting one call fail later. - **`retry` is off for the availability map.** The fallback *is* the answer, so retrying only doubles a request every logged-out visitor makes on load. ## Desktop is untouched `desktop/hooks/useEndpointConfig.ts` shadows this module entirely — no shared code, so core converting doesn't affect it and there's no half-migrated state. It's 482 lines of orchestration rather than fetching: dependency-ready gating, `tauriBackendService` and `selfHostedServerMonitor` subscriptions, a 2.5s timeout retry for backend startup, a legacy `?endpoints=` fallback for old servers, and SaaS-routing optimism that rewrites disabled endpoints to enabled. It also has no test coverage to convert against, and it decides whether tools appear at all in the desktop app. That's a different job from this one and wants its own review. Next PR. ## Testing 9 new tests: projection onto the requested subset, one request across consumers, unknown-endpoint fallback, failure fallback with no retry, empty-list no-fetch, JWT invalidation, and the three single-endpoint cases. `task frontend:check` green: 1677 tests across 192 files, typecheck on all five flavours, eslint, dpdm, prettier. Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> --- frontend/editor/src/core/api/config.ts | 30 ++ .../src/core/hooks/useEndpointConfig.test.tsx | 149 +++++++++ .../src/core/hooks/useEndpointConfig.ts | 282 ++++-------------- frontend/editor/src/core/query/keys.ts | 3 + 4 files changed, 248 insertions(+), 216 deletions(-) create mode 100644 frontend/editor/src/core/hooks/useEndpointConfig.test.tsx diff --git a/frontend/editor/src/core/api/config.ts b/frontend/editor/src/core/api/config.ts index f0ba730e8c..94caba2c82 100644 --- a/frontend/editor/src/core/api/config.ts +++ b/frontend/editor/src/core/api/config.ts @@ -1,6 +1,7 @@ import apiClient from "@app/services/apiClient"; import { getSimulatedAppConfig } from "@app/testing/serverExperienceSimulations"; import type { AppConfig } from "@app/types/appConfig"; +import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability"; /** Unauthenticated and unreachable both mean "assume login is on". */ export const DEFAULT_APP_CONFIG: AppConfig = { enableLogin: true }; @@ -23,6 +24,35 @@ export async function fetchAppConfig(): Promise { } } +export type EndpointAvailabilityMap = Record< + string, + EndpointAvailabilityDetails +>; + +/** + * Fires on app load before auth settles, so a 401 must not trigger the global + * login redirect. Callers treat a failure as "assume enabled". + */ +export async function fetchEndpointsAvailability(): Promise { + const response = await apiClient.get( + "/api/v1/config/endpoints-availability", + { suppressErrorToast: true, skipAuthRedirect: true }, + ); + return Object.fromEntries( + Object.entries(response.data).map(([name, detail]) => [ + name, + { enabled: detail?.enabled ?? true, reason: detail?.reason ?? null }, + ]), + ); +} + +export async function fetchEndpointEnabled(endpoint: string): Promise { + const response = await apiClient.get( + `/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, + ); + return response.data; +} + export interface FooterInfo { analyticsEnabled?: boolean; termsAndConditions?: string; diff --git a/frontend/editor/src/core/hooks/useEndpointConfig.test.tsx b/frontend/editor/src/core/hooks/useEndpointConfig.test.tsx new file mode 100644 index 0000000000..59010dacd5 --- /dev/null +++ b/frontend/editor/src/core/hooks/useEndpointConfig.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; +import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider"; +import { + useEndpointEnabled, + useMultipleEndpointsEnabled, +} from "@app/hooks/useEndpointConfig"; +import { + fetchEndpointEnabled, + fetchEndpointsAvailability, +} from "@app/api/config"; + +vi.mock("@app/api/config", () => ({ + fetchEndpointEnabled: vi.fn(), + fetchEndpointsAvailability: vi.fn(), +})); + +const mockOne = vi.mocked(fetchEndpointEnabled); +const mockAll = vi.mocked(fetchEndpointsAvailability); + +describe("useEndpointEnabled", () => { + beforeEach(() => vi.clearAllMocks()); + + it("reports null while loading, then the server's answer", async () => { + mockOne.mockResolvedValue(false); + + const { result } = renderHook(() => useEndpointEnabled("ocr-pdf"), { + wrapper: TestQueryProvider, + }); + + expect(result.current.enabled).toBeNull(); + await waitFor(() => expect(result.current.enabled).toBe(false)); + }); + + it("stays null on failure rather than claiming disabled", async () => { + mockOne.mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useEndpointEnabled("ocr-pdf"), { + wrapper: TestQueryProvider, + }); + + await waitFor(() => expect(result.current.error).toBe("boom")); + expect(result.current.enabled).toBeNull(); + }); + + it("does not fetch without an endpoint", () => { + const { result } = renderHook(() => useEndpointEnabled(""), { + wrapper: TestQueryProvider, + }); + + expect(result.current.loading).toBe(false); + expect(mockOne).not.toHaveBeenCalled(); + }); +}); + +describe("useMultipleEndpointsEnabled", () => { + beforeEach(() => vi.clearAllMocks()); + + it("projects the shared map onto the requested endpoints", async () => { + mockAll.mockResolvedValue({ + "ocr-pdf": { enabled: false, reason: "DEPENDENCY" }, + "add-stamp": { enabled: true, reason: null }, + }); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["ocr-pdf"]), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => + expect(result.current.endpointStatus).toEqual({ "ocr-pdf": false }), + ); + expect(result.current.endpointDetails["ocr-pdf"].reason).toBe("DEPENDENCY"); + }); + + it("serves every consumer from one request", async () => { + mockAll.mockResolvedValue({ "ocr-pdf": { enabled: true, reason: null } }); + + const { result } = renderHook( + () => ({ + a: useMultipleEndpointsEnabled(["ocr-pdf"]), + b: useMultipleEndpointsEnabled(["ocr-pdf", "add-stamp"]), + }), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => expect(result.current.a.loading).toBe(false)); + expect(mockAll).toHaveBeenCalledTimes(1); + }); + + it("treats unknown endpoints as enabled", async () => { + mockAll.mockResolvedValue({}); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["brand-new-tool"]), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => + expect(result.current.endpointStatus).toEqual({ "brand-new-tool": true }), + ); + }); + + it("falls back to enabled when the check fails", async () => { + mockAll.mockRejectedValue( + Object.assign(new Error("unauthorised"), { response: { status: 401 } }), + ); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["ocr-pdf", "add-stamp"]), + { wrapper: TestQueryProvider }, + ); + + await waitFor(() => + expect(result.current.endpointStatus).toEqual({ + "ocr-pdf": true, + "add-stamp": true, + }), + ); + // The fallback is the answer, so no retry. + expect(mockAll).toHaveBeenCalledTimes(1); + }); + + it("does not fetch for an empty endpoint list", () => { + const { result } = renderHook(() => useMultipleEndpointsEnabled([]), { + wrapper: TestQueryProvider, + }); + + expect(result.current.loading).toBe(false); + expect(mockAll).not.toHaveBeenCalled(); + }); + + it("refetches when a JWT becomes available", async () => { + mockAll.mockResolvedValue({ "ocr-pdf": { enabled: true, reason: null } }); + + const { result } = renderHook( + () => useMultipleEndpointsEnabled(["ocr-pdf"]), + { wrapper: TestQueryProvider }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + window.dispatchEvent(new CustomEvent("jwt-available")); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await waitFor(() => expect(mockAll).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/frontend/editor/src/core/hooks/useEndpointConfig.ts b/frontend/editor/src/core/hooks/useEndpointConfig.ts index 4615164787..488375c853 100644 --- a/frontend/editor/src/core/hooks/useEndpointConfig.ts +++ b/frontend/editor/src/core/hooks/useEndpointConfig.ts @@ -1,75 +1,50 @@ -import { useCallback, useEffect, useState } from "react"; -import { isAxiosError } from "axios"; -import apiClient from "@app/services/apiClient"; +import { useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + fetchEndpointEnabled, + fetchEndpointsAvailability, +} from "@app/api/config"; +import { qk } from "@app/query/keys"; +import { CONFIG_STALE_TIME } from "@app/query/staleTime"; import { useJwtConfigSync } from "@app/hooks/useJwtConfigSync"; import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability"; -// Track whether we've done the global fetch to prevent duplicate requests -let globalFetchDone = false; -const globalEndpointCache: Record = {}; +const OPTIMISTIC: EndpointAvailabilityDetails = { enabled: true, reason: null }; -function resetGlobalCache() { - globalFetchDone = false; - Object.keys(globalEndpointCache).forEach( - (key) => delete globalEndpointCache[key], - ); +function message(error: unknown): string | null { + if (!error) return null; + return error instanceof Error ? error.message : "Unknown error occurred"; } -/** - * Hook to check if a specific endpoint is enabled - * This wraps the context for single endpoint checks - */ +/** Whether one endpoint is enabled. `null` while loading and on failure. */ export function useEndpointEnabled(endpoint: string): { enabled: boolean | null; loading: boolean; error: string | null; refetch: () => Promise; } { - const [enabled, setEnabled] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchEndpointStatus = async () => { - if (!endpoint) { - setEnabled(null); - setLoading(false); - return; - } - - try { - setLoading(true); - setError(null); - console.debug("[useEndpointConfig] Fetch endpoint status", { endpoint }); - - const response = await apiClient.get( - `/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`, - ); - const isEnabled = response.data; - setEnabled(isEnabled); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(errorMessage); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - fetchEndpointStatus(); - }, [endpoint]); + const { data, isPending, error, refetch } = useQuery({ + queryKey: qk.endpointEnabled(endpoint), + queryFn: () => fetchEndpointEnabled(endpoint), + enabled: Boolean(endpoint), + staleTime: CONFIG_STALE_TIME, + }); return { - enabled, - loading, - error, - refetch: fetchEndpointStatus, + enabled: data ?? null, + loading: Boolean(endpoint) && isPending, + error: message(error), + refetch: useCallback(async () => { + await refetch(); + }, [refetch]), }; } /** - * Hook to check multiple endpoints at once using batch API - * Returns a map of endpoint -> enabled status + * Availability for a set of endpoints, projected from one shared request for + * the whole map. Unknown endpoints and any failure read as enabled — this runs + * before auth settles, and disabling every tool on a hiccup is worse than + * letting a call fail later. */ export function useMultipleEndpointsEnabled(endpoints: string[]): { endpointStatus: Record; @@ -78,174 +53,49 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { error: string | null; refetch: () => Promise; } { - const [endpointStatus, setEndpointStatus] = useState>( - {}, - ); - const [endpointDetails, setEndpointDetails] = useState< - Record - >({}); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + const wanted = endpoints ?? []; - const fetchAllEndpointStatuses = useCallback( - async (force = false) => { - // Skip if already fetched globally and not forced - if (!force && globalFetchDone) { - console.debug("[useEndpointConfig] Using global cache"); - const cached = endpoints.reduce( - (acc, endpoint) => { - const cachedDetails = globalEndpointCache[endpoint]; - if (cachedDetails) { - acc.status[endpoint] = cachedDetails.enabled; - acc.details[endpoint] = cachedDetails; - } else { - acc.status[endpoint] = true; - } - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(cached.status); - setEndpointDetails((prev) => ({ ...prev, ...cached.details })); - setLoading(false); - return; - } + const { data, isPending, error, refetch } = useQuery({ + queryKey: qk.endpointsAvailability(), + queryFn: fetchEndpointsAvailability, + enabled: wanted.length > 0, + staleTime: CONFIG_STALE_TIME, + // A failure already falls back to enabled, so a retry buys nothing and + // doubles a request that fires on load for every logged-out visitor. + retry: false, + }); - if (!endpoints || endpoints.length === 0) { - setEndpointStatus({}); - setEndpointDetails({}); - setLoading(false); - return; - } + const reload = useCallback(async () => { + await refetch(); + }, [refetch]); - try { - setLoading(true); - setError(null); - console.debug( - "[useEndpointConfig] Fetching all endpoint statuses from server", - ); - - // Fetch all endpoints at once; auto-fires on app load, so a 401 must - // fail silently instead of triggering the global login redirect. - const response = await apiClient.get< - Record - >(`/api/v1/config/endpoints-availability`, { - suppressErrorToast: true, - skipAuthRedirect: true, - }); - - // Populate global cache with all results - Object.entries(response.data).forEach(([endpoint, details]) => { - globalEndpointCache[endpoint] = { - enabled: details?.enabled ?? true, - reason: details?.reason ?? null, - }; - }); - globalFetchDone = true; - - // Return status for the requested endpoints - const fullStatus = endpoints.reduce( - (acc, endpoint) => { - const cachedDetails = globalEndpointCache[endpoint]; - if (cachedDetails) { - acc.status[endpoint] = cachedDetails.enabled; - acc.details[endpoint] = cachedDetails; - } else { - acc.status[endpoint] = true; - } - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - - setEndpointStatus(fullStatus.status); - setEndpointDetails((prev) => ({ ...prev, ...fullStatus.details })); - } catch (err: unknown) { - // On 401 (auth error), use optimistic fallback instead of disabling - if (isAxiosError(err) && err.response?.status === 401) { - console.warn( - "[useEndpointConfig] 401 error - using optimistic fallback", - ); - endpoints.forEach((endpoint) => { - globalEndpointCache[endpoint] = { enabled: true, reason: null }; - }); - const optimisticStatus = endpoints.reduce( - (acc, endpoint) => { - acc.status[endpoint] = true; - acc.details[endpoint] = { enabled: true, reason: null }; - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(optimisticStatus.status); - setEndpointDetails((prev) => ({ - ...prev, - ...optimisticStatus.details, - })); - setLoading(false); - return; - } - - const errorMessage = - err instanceof Error ? err.message : "Unknown error occurred"; - setError(errorMessage); - console.error("[EndpointConfig] Failed to check endpoints:", err); - - // Fallback: assume all endpoints are enabled on error (optimistic) - const optimisticStatus = endpoints.reduce( - (acc, endpoint) => { - acc.status[endpoint] = true; - acc.details[endpoint] = { enabled: true, reason: null }; - return acc; - }, - { - status: {} as Record, - details: {} as Record, - }, - ); - setEndpointStatus(optimisticStatus.status); - setEndpointDetails((prev) => ({ - ...prev, - ...optimisticStatus.details, - })); - } finally { - setLoading(false); - } - }, - [endpoints.join(",")], + useJwtConfigSync( + useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: qk.endpointsAvailability(), + }); + }, [queryClient]), ); - useEffect(() => { - fetchAllEndpointStatuses(); - }, [fetchAllEndpointStatuses]); - - // Re-fetch when auth state changes. Core implementation listens for the - // proprietary `jwt-available` event; the SaaS no-op override means the - // cache simply isn't invalidated on Supabase auth changes (today's behavior). - // If SaaS later needs that, wire it up inside saas/hooks/useJwtConfigSync.ts. - const handleAuthChange = useCallback(() => { - console.debug( - "[useEndpointConfig] Auth changed - clearing cache for refetch", - ); - resetGlobalCache(); - fetchAllEndpointStatuses(true); - }, [fetchAllEndpointStatuses]); - useJwtConfigSync(handleAuthChange); + const key = wanted.join(","); + const projected = useMemo(() => { + const status: Record = {}; + const details: Record = {}; + if (!data && !error) return { status, details }; + for (const endpoint of key ? key.split(",") : []) { + const detail = data?.[endpoint] ?? OPTIMISTIC; + status[endpoint] = detail.enabled; + details[endpoint] = detail; + } + return { status, details }; + }, [data, error, key]); return { - endpointStatus, - endpointDetails, - loading, - error, - refetch: () => fetchAllEndpointStatuses(true), + endpointStatus: projected.status, + endpointDetails: projected.details, + loading: wanted.length > 0 && isPending, + error: message(error), + refetch: reload, }; } diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index 6c6bacd552..a7a68ea256 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -1,6 +1,9 @@ /** Editor query keys: ["editor", , ...params]. */ export const qk = { appConfig: () => ["editor", "appConfig"] as const, + endpointsAvailability: () => ["editor", "endpointsAvailability"] as const, + endpointEnabled: (endpoint: string) => + ["editor", "endpointEnabled", endpoint] as const, footerInfo: () => ["editor", "footerInfo"] as const, groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const, users: () => ["editor", "users"] as const, From 0ff4ef629cf294bf474700ffb505f748bbefb4aa Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 10 Aug 2026 17:05:05 +0100 Subject: [PATCH 62/99] New Pipeline UI redesign (#7202) # Description of Changes Supersedes #7144. Redesign the Processor New Pipeline page to use a graph-based interface. Far from perfect at this stage but I'm pretty happy with the interactions on the graph itself. The bar at the top needs some work to make it prettier and more clear what everything is for, but I'd rather get this in and do changes in a follow-up PR because this is big enough on its own and leaves us better than where we were before. image image image image image --- frontend/.storybook/a11y-baseline.dark.json | 29 +- frontend/.storybook/a11y-baseline.json | 38 +- .../public/locales/en-US/translation.toml | 75 +- .../useAddPasswordOperation.test.ts | 9 + .../addPassword/useAddPasswordOperation.ts | 2 +- .../hooks/tools/shared/toolAutomation.test.ts | 15 + .../core/hooks/tools/shared/toolAutomation.ts | 22 +- .../src/core/tests/stubbed/files-page.spec.ts | 6 +- frontend/editor/src/core/ui/CodeBlock.tsx | 2 +- frontend/editor/src/core/ui/NodeCard.css | 87 ++ .../editor/src/core/ui/NodeCard.stories.tsx | 53 + frontend/editor/src/core/ui/NodeCard.tsx | 81 ++ frontend/editor/src/core/ui/index.ts | 1 + frontend/editor/src/portal/api/http.ts | 15 + frontend/editor/src/portal/api/pipelines.ts | 52 +- .../editor/src/portal/components/AppShell.css | 4 + .../pipelines/DestinationPicker.tsx | 65 +- .../pipelines/PipelineDefinitionModal.css | 26 + .../PipelineDefinitionModal.stories.tsx | 48 + .../PipelineDefinitionModal.test.tsx | 37 + .../pipelines/PipelineDefinitionModal.tsx | 42 + .../components/pipelines/PipelineHeader.css | 133 +++ .../pipelines/PipelineHeader.stories.tsx | 118 +++ .../pipelines/PipelineHeader.test.tsx | 200 ++++ .../components/pipelines/PipelineHeader.tsx | 301 ++++++ .../pipelines/PipelineInspector.css | 34 + .../pipelines/PipelineInspector.stories.tsx | 71 ++ .../pipelines/PipelineInspector.test.tsx | 60 ++ .../pipelines/PipelineInspector.tsx | 79 ++ .../pipelines/ToolPicker.stories.tsx | 28 + .../components/pipelines/ToolPicker.tsx | 84 +- .../components/pipelines/graph/GraphEdge.css | 180 ++++ .../components/pipelines/graph/GraphEdge.tsx | 109 ++ .../components/pipelines/graph/GraphNode.css | 140 +++ .../components/pipelines/graph/GraphNode.tsx | 183 ++++ .../pipelines/graph/GraphPlaceholderNode.css | 44 + .../pipelines/graph/GraphPlaceholderNode.tsx | 30 + .../pipelines/graph/PipelineGraph.css | 79 ++ .../pipelines/graph/PipelineGraph.stories.tsx | 207 ++++ .../pipelines/graph/PipelineGraph.test.tsx | 455 ++++++++ .../pipelines/graph/PipelineGraph.tsx | 379 +++++++ .../pipelines/graph/pipelineLayout.test.ts | 147 +++ .../pipelines/graph/pipelineLayout.ts | 184 ++++ .../pipelines/graph/useChainDragDrop.test.ts | 91 ++ .../pipelines/graph/useChainDragDrop.ts | 230 ++++ .../src/portal/mocks/handlers/pipelines.ts | 23 + .../src/portal/views/PipelineBuilder.css | 446 +++----- .../portal/views/PipelineBuilder.stories.tsx | 26 +- .../src/portal/views/PipelineBuilder.test.tsx | 451 +++++++- .../src/portal/views/PipelineBuilder.tsx | 985 ++++++++++-------- .../editor/src/portal/views/Pipelines.css | 17 - 51 files changed, 5310 insertions(+), 913 deletions(-) create mode 100644 frontend/editor/src/core/ui/NodeCard.css create mode 100644 frontend/editor/src/core/ui/NodeCard.stories.tsx create mode 100644 frontend/editor/src/core/ui/NodeCard.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.css create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInspector.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphNode.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts create mode 100644 frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts create mode 100644 frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts create mode 100644 frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts diff --git a/frontend/.storybook/a11y-baseline.dark.json b/frontend/.storybook/a11y-baseline.dark.json index fa08401d09..fb461fe6d2 100644 --- a/frontend/.storybook/a11y-baseline.dark.json +++ b/frontend/.storybook/a11y-baseline.dark.json @@ -1828,6 +1828,21 @@ "editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Short Sub": [ "color-contrast" ], + "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Multiple Selected": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Nothing Selected": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ + "color-contrast" + ], "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ "color-contrast" ], @@ -1845,6 +1860,12 @@ "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Default": [ "color-contrast" ], + "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Incompatible Preceding Output": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: No Matches": [ + "color-contrast" + ], "editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Default": [ "color-contrast" ], @@ -2235,9 +2256,13 @@ "color-contrast" ], "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast" + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"], "editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"], "editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"], "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], diff --git a/frontend/.storybook/a11y-baseline.json b/frontend/.storybook/a11y-baseline.json index 91db120c19..df46eb9dc4 100644 --- a/frontend/.storybook/a11y-baseline.json +++ b/frontend/.storybook/a11y-baseline.json @@ -1404,8 +1404,7 @@ "color-contrast" ], "editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [ - "color-contrast", - "scrollable-region-focusable" + "color-contrast" ], "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], "editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [ @@ -1977,6 +1976,30 @@ "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ "color-contrast" ], + "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Editing": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Paused": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Testing": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Failed Run": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Run Result": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ + "color-contrast" + ], + "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ + "color-contrast" + ], "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ "color-contrast" ], @@ -2294,13 +2317,14 @@ "editor/src/portal/views/Integrations.stories.tsx :: No Connections": [ "color-contrast" ], - "editor/src/portal/views/PipelineBuilder.stories.tsx :: Default": [ - "color-contrast" - ], "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast" + "color-contrast", + "empty-table-header" + ], + "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ + "color-contrast", + "empty-table-header" ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"], "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], "editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"], "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 0fffc04dbc..4f70f2b813 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7800,7 +7800,6 @@ title = "Pipelines" newPipeline = "New pipeline" [portal.pipelines.builder] -addStep = "Add tool" back = "Back to pipelines" cannotFollow = "Can't take {{produced}}" chooseAccount = "Choose an account" @@ -7813,59 +7812,64 @@ inputs = "Input" inputSource = "Input source" inputTrigger = "Trigger" keepEditing = "Keep editing" +moreActions = "More actions" needsConfiguring = "Needs setting up" +needsDestination = "No destination chosen" +needsSource = "No source chosen" needsUpload = "Needs an uploaded file" -noSources = "No sources connected yet. Create one to use it as an input." noToolMatches = "No tools match your search." -pipelineSettings = "Pipeline settings" searchTools = "Search tools" -selectToolBody = "Add a tool to build your pipeline." -selectToolTitle = "No tools yet" sendToSystem = "Send to another system" stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." stepsNeedSetup = "These steps still need setting up before saving: {{tools}}." -toolSettings = "Tool settings" +testRun = "Test with a file" unknownStep = "Unrecognized operation, kept as-is." unsavedBody = "You have unsaved changes. Save them before leaving, or discard them?" unsavedTitle = "Unsaved changes" uploadUnsupported = "Uploaded files aren't supported in pipelines yet, so these steps can't be saved: {{tools}}." usesDefaults = "Runs with default settings" +viewDefinition = "View definition" [portal.pipelines.builder.diagnostic] -fan-in = "Combines every file from the previous step" -fan-out = "Runs once per file from the previous step" -format-mismatch = "Needs {{accepts}}, but the previous step produces {{produced}}" -output-uncertain = "May not run: the previous step's output depends on how it's set up" -source-mismatch = "Needs {{accepts}}, but this pipeline's input is {{produced}}" +fan-in = "Combines every incoming file" +fan-out = "Runs once per incoming file" +format-mismatch = "Sends {{produced}}, needs {{accepts}}" +output-uncertain = "May not run: output depends on setup" +source-mismatch = "Input is {{produced}}, needs {{accepts}}" undeclared-operation = "Can't check what this step accepts" [portal.pipelines.composer] -addTool = "Add tool" +addTool = "Add a tool" cancel = "Cancel" -chainEmpty = "Add a tool to start building your pipeline." create = "Create pipeline" editingUnsupported = "Displaying these tool params for editing is not supported yet." -moveDown = "Move down" -moveUp = "Move up" +editSource = "Edit source" name = "Name" namePlaceholder = "e.g. Redaction sweep" noToolSettings = "This tool has no configurable settings." -operations_one = "Operation ({{count}})" -operations_other = "Operations ({{count}})" output = "Destination" -removeStep = "Remove operation" save = "Save changes" scheduleEvery = "Run every" -sources = "Sources" -sourcesLoading = "Loading sources..." trigger = "Trigger" triggerManual = "Manual only" +[portal.pipelines.composer.runsEvery] +days_one = "Runs every day" +days_other = "Runs every {{count}} days" +hours_one = "Runs every hour" +hours_other = "Runs every {{count}} hours" +minutes_one = "Runs every minute" +minutes_other = "Runs every {{count}} minutes" + [portal.pipelines.composer.unit] days = "days" hours = "hours" minutes = "minutes" +[portal.pipelines.definition] +subtitle = "The pipeline as it would be saved." +title = "Definition" + [portal.pipelines.delete] body = "Delete \"{{name}}\"? This can't be undone." cancel = "Cancel" @@ -7883,6 +7887,37 @@ connectSource = "Connect a source" description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes." title = "No pipelines yet" +[portal.pipelines.graph] +addFirstTool = "Add a tool" +dragHint = "Drop on a line to move it" +insertHere = "Add a tool here" +removeNode = "Remove {{name}}" +showError = "Show why {{name}} failed" + +[portal.pipelines.graph.add] +input = "Add a source" +output = "Add a destination" + +[portal.pipelines.graph.run] +done = "Done" +failed = "Failed" +running = "Running" + +[portal.pipelines.inspector] +multipleBody = "Drag any of them onto a line to move them together, or press Delete to remove them." +multipleSelected_one = "{{count}} step selected" +multipleSelected_other = "{{count}} steps selected" +noSelectionBody = "Pick a node in the graph to change what it does." +noSelectionTitle = "Nothing selected" + +[portal.pipelines.inspector.status] +completed_one = "Finished the only step" +completed_other = "Finished all {{count}} steps" +failed_one = "Failed on the only step" +failed_other = "Failed after {{done}} of {{count}} steps" +running_one = "Running the only step" +running_other = "Running step {{done}} of {{count}}" + [portal.pipelines.kpi] active = "Active" paused = "Paused" 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 77958629b0..28e8e1baa6 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.test.ts @@ -147,6 +147,15 @@ describe("useAddPasswordOperation", () => { }); describe("addPassword mappers", () => { + test("falls back to the default key length when the stored step omits it", () => { + // A pipeline step saved without keyLength must not deserialize to + // undefined: the settings UI calls keyLength.toString() on it. + const restored = addPasswordFromApiParams({ + password: "user-pw", + } as never); + expect(restored.keyLength).toBe(128); + }); + 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. diff --git a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts index d68154c9b1..4e843c8da7 100644 --- a/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts +++ b/frontend/editor/src/core/hooks/tools/addPassword/useAddPasswordOperation.ts @@ -48,7 +48,7 @@ export const addPasswordFromApiParams = ( ): Partial => ({ password: apiParams.password ?? defaultParameters.password, ownerPassword: apiParams.ownerPassword ?? defaultParameters.ownerPassword, - keyLength: apiParams.keyLength, + keyLength: apiParams.keyLength ?? defaultParameters.keyLength, permissions: { preventAssembly: apiParams.preventAssembly ?? permissionsDefaults.preventAssembly, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts index 467e3ea5f7..e8d34ef9ad 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.test.ts @@ -153,6 +153,21 @@ describe("serialize/deserialize round-trip", () => { }); }); + test("a stored step missing fields falls back to defaults, not undefined", () => { + // Mappers echo absent stored fields as explicit undefined; settings UIs + // then crash on things like keyLength.toString(). Defaults must win. + const back = deserializeToolStep( + { operation: "/api/v1/misc/compress-pdf", parameters: {} }, + registry, + ); + expect(back.params.compressionLevel).toBe( + compressDefaults.compressionLevel, + ); + expect( + Object.values(back.params).every((value) => value !== undefined), + ).toBe(true); + }); + test("an unknown endpoint is preserved as an unmapped step", () => { const step = deserializeToolStep( { operation: "/api/v1/unknown/thing", parameters: { keep: true } }, diff --git a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts index aab9e1ab10..b32f783b06 100644 --- a/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/toolAutomation.ts @@ -291,12 +291,22 @@ export function deserializeToolStep( if (!match) return unmappedStep(step); const [toolId, entry] = match; const config = entry.operationConfig; - const params: ErasedToolParams = config?.fromApiParams - ? { - ...(config.defaultParameters ?? {}), - ...config.fromApiParams(step.parameters as never), - } - : { ...(config?.defaultParameters ?? {}) }; + // Mappers echo missing stored fields as explicit `undefined`, which would + // clobber the default underneath; strip those so defaults always win. + const mapped = config?.fromApiParams + ? Object.fromEntries( + Object.entries( + config.fromApiParams(step.parameters as never) as Record< + string, + unknown + >, + ).filter(([, value]) => value !== undefined), + ) + : {}; + const params: ErasedToolParams = { + ...(config?.defaultParameters ?? {}), + ...mapped, + } as ErasedToolParams; // Validate against the generated endpoint set instead of casting the matched string. const operation = resolveEndpoint(config, params) ?? 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 56687b2217..4c6e714ec0 100644 --- a/frontend/editor/src/core/tests/stubbed/files-page.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/files-page.spec.ts @@ -173,7 +173,7 @@ test.describe("Files page", () => { await gotoFilesPage(page); const cards = page.locator(".files-page-card:not(.is-folder)"); await cards.nth(0).click(); - await cards.nth(1).click({ modifiers: ["Control"] }); + await cards.nth(1).click({ modifiers: ["ControlOrMeta"] }); await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2); // In multi-select (2+), plain-click ADDS instead of replacing. @@ -198,7 +198,7 @@ test.describe("Files page", () => { await expect(page.locator(".files-page-card-selector")).toHaveCount(0); // 2+ selected: checkboxes appear on every file card. - await cards.nth(1).click({ modifiers: ["Control"] }); + await cards.nth(1).click({ modifiers: ["ControlOrMeta"] }); await expect( page.locator(".files-page-card-selector").first(), ).toBeVisible(); @@ -488,7 +488,7 @@ test.describe("Files page", () => { const cards = page.locator(".files-page-card:not(.is-folder)"); await cards.nth(0).click(); // Drawer stays closed so the second click reaches the card. - await cards.nth(1).click({ modifiers: ["Control"] }); + await cards.nth(1).click({ modifiers: ["ControlOrMeta"] }); await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2); }); }); diff --git a/frontend/editor/src/core/ui/CodeBlock.tsx b/frontend/editor/src/core/ui/CodeBlock.tsx index cff078057d..2d66bd2353 100644 --- a/frontend/editor/src/core/ui/CodeBlock.tsx +++ b/frontend/editor/src/core/ui/CodeBlock.tsx @@ -70,7 +70,7 @@ export function CodeBlock({ )}
    -
    +      
             {code}
           
    diff --git a/frontend/editor/src/core/ui/NodeCard.css b/frontend/editor/src/core/ui/NodeCard.css new file mode 100644 index 0000000000..7982e2581a --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.css @@ -0,0 +1,87 @@ +/** + * NodeCard — a selectable labelled tile (icon badge + title + sub-line) on a raised surface. + * Shared surface, selection ring and content layout; feature-specific state is layered by callers. + */ + +.sui-node-card { + position: relative; + display: flex; + align-items: stretch; + box-sizing: border-box; + background: var(--c-surface); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + transition: + border-color var(--motion-fast), + box-shadow var(--motion-fast), + opacity var(--motion-fast); +} + +/* The whole card selects. Re-assert the tile look over the shared Button base (which otherwise + imposes a fixed height, its own padding and an accent text colour). */ +.sui-node-card__select.sui-btn { + flex: 1; + min-width: 0; + height: auto; + min-height: 0; + border: none; + background: none; + padding: 0.625rem 0.75rem; + text-align: left; + font-weight: 400; + color: var(--c-text); + border-radius: inherit; +} + +/* Mantine wraps a button's children in its label element, so the glyph and text are laid out + there - a gap on the button root would only space the wrapper, not what is inside it. */ +.sui-node-card__select.sui-btn .mantine-Button-label { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.625rem; + min-width: 0; + overflow: visible; +} + +.sui-node-card__text { + display: flex; + flex-direction: column; + min-width: 0; + gap: 0.0625rem; +} + +.sui-node-card__title { + font-size: 0.875rem; + font-weight: 500; + color: var(--c-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sui-node-card__detail { + font-size: 0.6875rem; + /* --c-text-subtle does not clear 4.5:1 at this size in either theme (axe: 4.39 light, 3.66 + dark); --c-text-muted is the next rung up and does. */ + color: var(--c-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Selected: the primary ring. Wins over hover and the warning tone. */ +.sui-node-card.is-selected { + border-color: var(--c-primary); + box-shadow: 0 0 0 1px var(--c-primary); +} + +.sui-node-card:hover:not(.is-selected) { + border-color: var(--c-border-strong); +} + +.sui-node-card--warning:not(.is-selected) { + border-color: var(--c-warning); +} diff --git a/frontend/editor/src/core/ui/NodeCard.stories.tsx b/frontend/editor/src/core/ui/NodeCard.stories.tsx new file mode 100644 index 0000000000..acfe28f71d --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import TuneRoundedIcon from "@mui/icons-material/TuneRounded"; +import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; +import { NodeCard } from "@app/ui/NodeCard"; +import { ActionIcon } from "@app/ui/ActionIcon"; + +const meta = { + title: "UI/NodeCard", + component: NodeCard, + parameters: { layout: "padded" }, + args: { + icon: , + title: "Compress", + detail: "level 7", + onSelect: () => {}, + }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** The default tile: icon badge, title, one-line sub-detail, selectable. */ +export const Default: Story = {}; + +/** Selected — the primary ring the inspector points at. */ +export const Selected: Story = { args: { selected: true } }; + +/** Warning tone — an amber border, for a tile that needs attention. */ +export const Warning: Story = { + args: { tone: "warning", detail: "Needs setting up" }, +}; + +/** A trailing control (here a remove button) sits beside the select target, not nested in it. */ +export const WithTrailing: Story = { + args: { + trailing: ( + + + + ), + }, +}; diff --git a/frontend/editor/src/core/ui/NodeCard.tsx b/frontend/editor/src/core/ui/NodeCard.tsx new file mode 100644 index 0000000000..451cc8823b --- /dev/null +++ b/frontend/editor/src/core/ui/NodeCard.tsx @@ -0,0 +1,81 @@ +import type { HTMLAttributes, MouseEvent, ReactNode, Ref } from "react"; +import { Button } from "@app/ui/Button"; +import { IconBadge, type IconBadgeAccent } from "@app/ui/IconBadge"; +import "@app/ui/NodeCard.css"; + +/** Border tone. `selected` (a separate prop) overrides this with the primary ring. */ +export type NodeCardTone = "default" | "warning"; + +export interface NodeCardProps extends Omit< + HTMLAttributes, + "title" | "onSelect" +> { + /** Glyph shown in a tone-tinted badge at the leading edge. */ + icon: ReactNode; + iconAccent?: IconBadgeAccent; + title: ReactNode; + /** One-line summary under the title. Any node - a plain string, or a richer line. */ + detail?: ReactNode; + tone?: NodeCardTone; + selected?: boolean; + /** + * When given, the whole card is a single select button (aria-pressed tracks `selected`). Trailing + * controls stay siblings of that button, never nested inside it, so the card holds no invalid + * nested interactive elements. + */ + onSelect?: (event: MouseEvent) => void; + /** Controls rendered over the card's trailing edge (a remove button, a status glyph, ...). */ + trailing?: ReactNode; + ref?: Ref; +} + +/** + * A labelled tile: an icon badge, a title, and an optional sub-line, on a raised card surface that + * can be selected. The recurring "node" motif - a step in a graph, an item in a board - lifted into + * a primitive so its surface, selection ring and content layout are shared rather than re-styled per + * feature. Callers layer their own state (drag, run status, ...) via `className` and `trailing`. + */ +export function NodeCard({ + icon, + iconAccent, + title, + detail, + tone = "default", + selected = false, + onSelect, + trailing, + className, + ref, + ...rest +}: NodeCardProps) { + return ( +
    + + {trailing} +
    + ); +} diff --git a/frontend/editor/src/core/ui/index.ts b/frontend/editor/src/core/ui/index.ts index 45212d8038..b3e2ca6ac0 100644 --- a/frontend/editor/src/core/ui/index.ts +++ b/frontend/editor/src/core/ui/index.ts @@ -8,6 +8,7 @@ export * from "@app/ui/MethodBadge"; export * from "@app/ui/ToggleSwitch"; export * from "@app/ui/ProgressBar"; export * from "@app/ui/MetricCard"; +export * from "@app/ui/NodeCard"; export * from "@app/ui/NavItem"; export * from "@app/ui/NavSurface"; export * from "@app/ui/PanelHeader"; diff --git a/frontend/editor/src/portal/api/http.ts b/frontend/editor/src/portal/api/http.ts index 5c8afcd2cb..2ec00e9818 100644 --- a/frontend/editor/src/portal/api/http.ts +++ b/frontend/editor/src/portal/api/http.ts @@ -212,6 +212,20 @@ async function localForm( return unwrap(res); } +/** POST a multipart/form-data body (file uploads), via the localBackend seam. The Content-Type is + * deliberately left unset so the browser writes it with the multipart boundary. */ +async function localMultipart(path: string, body: FormData): Promise { + const res = await fetch(`${localBaseUrl()}${path}`, { + method: "POST", + headers: { Accept: "application/json", ...(await localAuthHeader()) }, + body, + }); + if (res.status === 401) { + onLocalUnauthorized(); + } + return unwrap(res); +} + // ──────────────────────────────────────────────────────────────────────────── // saas — hosted SaaS Java, admin's Supabase JWT // ──────────────────────────────────────────────────────────────────────────── @@ -302,6 +316,7 @@ export const apiClient = { local: { json: localJson, form: localForm, + multipart: localMultipart, blob: localBlob, }, /** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */ diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index d6088d06ff..50bdc173c4 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -1,4 +1,5 @@ import { apiClient } from "@portal/api/http"; +import { type ToolApiStep } from "@app/hooks/tools/shared/toolAutomation"; /** * Pipelines service layer: the backend contract. @@ -122,7 +123,13 @@ export type PolicyRunStatus = | "FAILED" | "CANCELLED"; -/** A run's current state. Mirrors the backend `PolicyRunView` (outputs elided). */ +/** One file a run produced, downloadable via /api/v1/general/files/{fileId}. */ +export interface RunOutputFile { + fileId: string; + fileName: string | null; +} + +/** A run's current state. Mirrors the backend `PolicyRunView`. */ export interface PolicyRunView { runId: string; policyId: string | null; @@ -132,6 +139,11 @@ export interface PolicyRunView { /** Human-readable failure message; set when status is FAILED. */ error: string | null; errorCode: string | null; + /** + * Files the run produced, present once it completes. Whole-run, not per step: the backend keeps + * one flat list, so nothing here can be attributed to an individual step. + */ + outputs?: RunOutputFile[] | null; createdAt: number; } @@ -198,6 +210,44 @@ export async function triggerPipeline(id: string): Promise { ); } +/** What an ad-hoc test run posts: the steps as they stand, with no source and no trigger. */ +export interface TestRunDefinition { + name: string; + steps: ToolApiStep[]; + output: OutputSpec; +} + +/** + * POST /api/v1/policies/run: run a definition against one uploaded file now. The builder's test + * path - callers force an inline output so nothing reaches the pipeline's real destination, and + * the pipeline need not be saved first. + */ +export async function runPipelineTest( + definition: TestRunDefinition, + file: File, +): Promise<{ runId: string }> { + const form = new FormData(); + form.append( + "json", + new Blob([JSON.stringify(definition)], { type: "application/json" }), + ); + form.append("fileInput", file); + // The POST returns the identifier as `jobId`, but it is the same run id every other endpoint + // (fetchRun, fetchRunOutput) calls `runId`; normalise to that here so callers see one name. + const res = await apiClient.local.multipart<{ jobId: string }>( + "/api/v1/policies/run", + form, + ); + return { runId: res.jobId }; +} + +/** GET /api/v1/general/files/{id}: download one of a run's outputs. */ +export async function fetchRunOutput(fileId: string): Promise { + return apiClient.local.blob( + `/api/v1/general/files/${encodeURIComponent(fileId)}`, + ); +} + /** GET /api/v1/policies/run/{runId}: current status, error, and step cursor of a run. */ export async function fetchRun(runId: string): Promise { return apiClient.local.json( diff --git a/frontend/editor/src/portal/components/AppShell.css b/frontend/editor/src/portal/components/AppShell.css index 152c45f7c5..1657b56bdf 100644 --- a/frontend/editor/src/portal/components/AppShell.css +++ b/frontend/editor/src/portal/components/AppShell.css @@ -26,6 +26,10 @@ flex: 1 1 auto; min-height: 0; /* scroll instead of growing past the viewport */ overflow-y: auto; + /* Hold the scrollbar's width whether or not it is showing. Without this, a page that grows past + the viewport (an editor panel filling in, say) makes the bar appear and shunts everything + sideways as it does. */ + scrollbar-gutter: stable; animation: fadeInUp var(--motion-enter) both; } diff --git a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx index de1187c3da..864668b764 100644 --- a/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx +++ b/frontend/editor/src/portal/components/pipelines/DestinationPicker.tsx @@ -1,15 +1,16 @@ import { useTranslation } from "react-i18next"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import { Button, Select } from "@app/ui"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import { ActionIcon, Button, FormField, Select } from "@app/ui"; /** * Picks the saved source a pipeline delivers its output to. A destination is just a * source used as a write target. The value stays a list ({@code outputIds}) because * the model supports several, but the product caps a pipeline at one destination * today, so this renders a single dropdown over the same locations the builder - * loaded (filtered to writable types by the caller). Creating a new one is delegated - * to {@code onCreateNew} (the builder navigates to the source builder, prompting - * about unsaved edits first). + * loaded (filtered to writable types by the caller). Creating and editing one are + * delegated to {@code onCreateNew} / {@code onEdit}, which open the source modal + * over the builder - mirroring the input row. */ interface DestinationOption { id: string; @@ -20,8 +21,10 @@ interface DestinationPickerProps { sources: DestinationOption[]; value: string[]; onChange: (outputIds: string[]) => void; - /** Leave the builder to create a new source location (navigate-away, like inputs). */ + /** Create a new source location to write to (opens the source modal). */ onCreateNew: () => void; + /** Edit the chosen destination's own settings (opens the source modal on it). */ + onEdit: (sourceId: string) => void; } export function DestinationPicker({ @@ -29,25 +32,45 @@ export function DestinationPicker({ value, onChange, onCreateNew, + onEdit, }: DestinationPickerProps) { const { t } = useTranslation(); + const chosen = value[0] ?? ""; + const hasSources = sources.length > 0; + // Mirrors the input row: the dropdown-plus-edit sits in a field, and "Connect source" lives on its + // own line below rather than inline. With nowhere to write to yet, only the connect button shows. return ( -
    -
    - onChange(id ? [id] : [])} + options={sources.map((source) => ({ + value: source.id, + label: source.name, + }))} + /> +
    + onEdit(chosen)} + > + + +
    + + )} - + ); } diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css new file mode 100644 index 0000000000..7f1984b4b1 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.css @@ -0,0 +1,26 @@ +/* The code is this modal's entire content, so it *is* the body rather than a window sitting inside + one. Framed, it drew a second box inside the panel's box - and the panel's own header already + says what the code is, which the code window's chrome was repeating. */ +.portal-definition__modal .sui-modal__body { + padding: 0; +} + +.portal-definition__code { + border: none; + border-radius: 0; + box-shadow: none; +} + +/* Traffic-light dots imitate a window frame; this code already sits in a real one. */ +.portal-definition__code .sui-code__dots { + display: none; +} + +/* Line the toolbar and the code up with the modal header's text. */ +.portal-definition__code .sui-code__chrome { + padding: 0.5rem 1.125rem; +} + +.portal-definition__code .sui-code__pre { + padding: 0.875rem 1.125rem; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx new file mode 100644 index 0000000000..4800689d7d --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button } from "@app/ui"; +import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineDefinitionModal", + component: PipelineDefinitionModal, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const JSON_BODY = JSON.stringify( + { + name: "Claims redaction", + enabled: true, + inputs: [{ sourceId: "src-in", trigger: { type: "schedule" } }], + steps: [ + { operation: "/api/v1/misc/ocr-pdf", parameters: { language: "eng" } }, + { operation: "/api/v1/security/redact", parameters: { terms: 2 } }, + ], + outputIds: ["src-out"], + }, + null, + 2, +); + +/** Starts closed so the trigger can be exercised; click through to the tabs. */ +function Playground({ initialOpen = false }: { initialOpen?: boolean }) { + const [open, setOpen] = useState(initialOpen); + return ( + <> + + setOpen(false)} + json={JSON_BODY} + /> + + ); +} + +/** The definition as it opens from the header: JSON first, cURL a tab away. */ +export const Default: Story = { render: () => }; + +/** The trigger it opens from, so the closed state can be exercised too. */ +export const FromTrigger: Story = { render: () => }; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx new file mode 100644 index 0000000000..32e55e3826 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { render as baseRender, screen } from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const JSON_BODY = '{\n "name": "Claims"\n}'; + +describe("PipelineDefinitionModal", () => { + it("renders nothing while closed", () => { + render( + , + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("opens on the JSON tab", () => { + render(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText(/"name": "Claims"/)).toBeInTheDocument(); + }); + + it("shows the definition alone - no tab strip to choose between", () => { + render(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx new file mode 100644 index 0000000000..adcd19f5f5 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineDefinitionModal.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from "react-i18next"; +import { CodeBlock, Modal } from "@app/ui"; +import "@portal/components/pipelines/PipelineDefinitionModal.css"; + +export interface PipelineDefinitionModalProps { + open: boolean; + onClose: () => void; + /** The pipeline as it would be saved, pretty-printed. Re-read while the modal is open. */ + json: string; +} + +/** + * The pipeline's definition as it would be saved. + * + * Pipeline-scoped, so it opens from the header rather than the node inspector, and a modal rather + * than a panel because a definition grows with the chain and needs the width. + */ +export function PipelineDefinitionModal({ + open, + onClose, + json, +}: PipelineDefinitionModalProps) { + const { t } = useTranslation(); + + return ( + + + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.css b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css new file mode 100644 index 0000000000..f559e42795 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.css @@ -0,0 +1,133 @@ +/** + * The builder's opening section: identity above the rule, actions below it. + */ + +.portal-pipeline-header { + display: flex; + flex-direction: column; + gap: 0.875rem; + padding: 1.125rem; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); +} + +/* Leaving the page and saving it are the same kind of decision, so they share a row - and the back + link is short, so the save pair always has room beside it. */ +.portal-pipeline-header__top { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +/* The back link is the shared Button restyled to a plain link, so re-assert that over the + design-system base (which imposes a fixed height, its own padding and an accent colour). */ +.portal-pipeline-header__back.sui-btn { + height: auto; + min-height: 0; + padding: 0; + font-size: 0.8125rem; + font-weight: 400; + color: var(--c-text-muted); +} + +.portal-pipeline-header__back.sui-btn:hover { + background: none; + color: var(--c-text); +} + +.portal-pipeline-header__identity { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; +} + +/* The shared Checkbox aligns its box to the top of the first text line, with a nudge tuned for its + own font size - that is for the label-plus-description case. This one is a single line, so centre + the box on it and leave the component's sizing alone (overriding the font size shifts the line + box and leaves the tick floating high). */ +.portal-pipeline-header__enabled.sui-check { + flex: none; + align-items: center; +} + +.portal-pipeline-header__enabled.sui-check .sui-check__box { + margin-top: 0; +} + +/* The name is the page's title, so it takes the room and reads at title size. */ +.portal-pipeline-header__name { + flex: 1 1 16rem; + min-width: 12rem; +} + +.portal-pipeline-header__name input { + font-size: 1rem; + font-weight: 500; +} + +/* Never let the labels squash: buttons hold their width and the row wraps instead of clipping. */ +.portal-pipeline-header__save { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex: none; +} + +.portal-pipeline-header__save .sui-btn { + flex: none; + white-space: nowrap; +} + +/* Operational actions: what you can do to this pipeline, kept off the identity row. */ +.portal-pipeline-header__actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding-top: 0.875rem; + border-top: 1px solid var(--c-border-subtle); +} + +/* Destructive, so it sits away from the rest rather than next in line. */ +.portal-pipeline-header__delete.sui-btn { + margin-left: auto; +} + +/* The last test run's outcome, beside the button that started it. Whole-pipeline, because the + backend reports one flat file list plus the step it stopped at - nothing per node to attach. */ +.portal-pipeline-header__result { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding-top: 0.875rem; + border-top: 1px solid var(--c-border-subtle); +} + +.portal-pipeline-header__result-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--c-text); +} + +.portal-pipeline-header__result-icon.is-ok { + color: var(--c-success); +} + +.portal-pipeline-header__result-icon.is-bad { + color: var(--c-danger); +} + +/* Why the run failed, shown inline in the strip. Neutral text (the icon already carries the tone); + it may wrap to keep a long backend message readable rather than clipping it. */ +.portal-pipeline-header__result-error { + font-size: 0.8125rem; + color: var(--c-text-muted); + min-width: 0; +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx new file mode 100644 index 0000000000..41c73a5ade --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.stories.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PipelineHeader, + type RunResultSummary, +} from "@portal/components/pipelines/PipelineHeader"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineHeader", + component: PipelineHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const noop = () => {}; + +/** The name and the enabled switch are live, so the section can be seen in both states. */ +function Playground({ + initialName, + isEdit, + initialEnabled = true, + runResult = null, + ...rest +}: { + initialName: string; + isEdit: boolean; + initialEnabled?: boolean; + runResult?: RunResultSummary | null; + saving?: boolean; + testing?: boolean; + running?: boolean; + canSave?: boolean; + stepCount?: number; +}) { + const [name, setName] = useState(initialName); + const [enabled, setEnabled] = useState(initialEnabled); + return ( + + ); +} + +/** An existing pipeline: everything is available. */ +export const Editing: Story = { + render: () => , +}; + +/** + * A pipeline that has never been saved. It can still be tested against a file, but there is + * nothing yet to run on a schedule, clear history for, or delete. + */ +export const New: Story = { + render: () => , +}; + +/** Paused: the pipeline exists but its trigger will not fire. */ +export const Paused: Story = { + render: () => ( + + ), +}; + +/** Mid test-run: the picker shows its own progress while the graph shows the steps. */ +export const Testing: Story = { + render: () => , +}; + +/** After a test run: the outcome and its files sit beside the button that started them. */ +export const WithRunResult: Story = { + render: () => ( + + ), +}; + +/** A failed run: the summary is here, the failing step's own message is on its node. */ +export const WithFailedRun: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx new file mode 100644 index 0000000000..2c5552be07 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.test.tsx @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineHeader, + type PipelineHeaderProps, +} from "@portal/components/pipelines/PipelineHeader"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderHeader(overrides: Partial = {}) { + const handlers = { + onNameChange: vi.fn(), + onEnabledChange: vi.fn(), + onSave: vi.fn(), + onCancel: vi.fn(), + onBack: vi.fn(), + onTest: vi.fn(), + onRun: vi.fn(), + onClearHistory: vi.fn(), + onDelete: vi.fn(), + onViewDefinition: vi.fn(), + onDownloadOutput: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineHeader", () => { + it("edits the pipeline's name and enabled state", () => { + const handlers = renderHeader(); + fireEvent.change( + screen.getByRole("textbox", { name: "portal.pipelines.composer.name" }), + { target: { value: "Renamed" } }, + ); + expect(handlers.onNameChange).toHaveBeenCalledWith("Renamed"); + + fireEvent.click(screen.getByRole("checkbox")); + expect(handlers.onEnabledChange).toHaveBeenCalledWith(false); + }); + + it("offers run, clear history and delete only once the pipeline exists", () => { + renderHeader({ isEdit: false }); + expect( + screen.queryByText("portal.pipelines.detail.run"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.detail.delete"), + ).not.toBeInTheDocument(); + // A test run needs no saved record, so it stays: it is how you check the steps as you build. + expect( + screen.getByText("portal.pipelines.builder.testRun"), + ).toBeInTheDocument(); + }); + + it("labels the save action for what it will do", () => { + renderHeader({ isEdit: false }); + expect( + screen.getByText("portal.pipelines.composer.create"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.composer.save"), + ).not.toBeInTheDocument(); + }); + + it("blocks saving until the pipeline is valid", () => { + renderHeader({ canSave: false }); + expect( + screen.getByText("portal.pipelines.composer.save").closest("button"), + ).toBeDisabled(); + }); + + it("hands the chosen file to the test run", () => { + const handlers = renderHeader(); + const file = new File(["x"], "claim.pdf", { type: "application/pdf" }); + const input = + document.querySelector('input[type="file"]'); + expect(input).not.toBeNull(); + fireEvent.change(input as HTMLInputElement, { target: { files: [file] } }); + expect(handlers.onTest).toHaveBeenCalledWith(file); + }); + + it("will not offer a test run on a chain with no steps", () => { + renderHeader({ stepCount: 0 }); + expect( + screen.getByText("portal.pipelines.builder.testRun").closest("button"), + ).toBeDisabled(); + }); + + it("shows why a test run failed, not only that it did", () => { + renderHeader({ + runResult: { + status: "failed", + completedSteps: 1, + stepCount: 3, + error: "OCR failed: unreadable page", + }, + }); + expect(screen.getByText("OCR failed: unreadable page")).toBeInTheDocument(); + }); + + it("runs and deletes from the row, clears history from the tray", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.detail.run")); + expect(handlers.onRun).toHaveBeenCalled(); + fireEvent.click(screen.getByText("portal.pipelines.detail.delete")); + expect(handlers.onDelete).toHaveBeenCalled(); + + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); + expect(handlers.onClearHistory).toHaveBeenCalled(); + }); + + it("leaves the page through cancel and back", () => { + const handlers = renderHeader(); + fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); + expect(handlers.onCancel).toHaveBeenCalled(); + fireEvent.click(screen.getByText("portal.pipelines.builder.back")); + expect(handlers.onBack).toHaveBeenCalled(); + }); + + it("keeps the occasional actions out of the row, behind a tray", () => { + renderHeader(); + // Running and testing earn a button each; reading the definition and wiping history do not. + expect( + screen.queryByText("portal.pipelines.builder.viewDefinition"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.detail.clearHistory"), + ).not.toBeInTheDocument(); + expect( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ).toBeInTheDocument(); + }); + + it("opens the definition from the tray", () => { + const handlers = renderHeader(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.builder.moreActions"), + ); + fireEvent.click( + screen.getByText("portal.pipelines.builder.viewDefinition"), + ); + expect(handlers.onViewDefinition).toHaveBeenCalled(); + }); + + it("shows no run strip until a test has been run", () => { + renderHeader(); + expect( + screen.queryByText(/portal.pipelines.inspector.status/), + ).not.toBeInTheDocument(); + }); + + it("reports a finished run and downloads the file clicked", () => { + const handlers = renderHeader({ + runResult: { + status: "completed", + completedSteps: 2, + stepCount: 2, + outputs: [ + { fileId: "f1", fileName: "claim.pdf" }, + { fileId: "f2", fileName: null }, + ], + }, + }); + fireEvent.click(screen.getByText("claim.pdf")); + expect(handlers.onDownloadOutput).toHaveBeenCalledWith({ + fileId: "f1", + fileName: "claim.pdf", + }); + // A file the backend did not name still has to be reachable. + expect(screen.getByText("f2")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx new file mode 100644 index 0000000000..25bfbd044f --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineHeader.tsx @@ -0,0 +1,301 @@ +import { useTranslation } from "react-i18next"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded"; +import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; +import ScienceOutlinedIcon from "@mui/icons-material/ScienceOutlined"; +import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; +import MoreHorizRoundedIcon from "@mui/icons-material/MoreHorizRounded"; +import CheckCircleOutlineRoundedIcon from "@mui/icons-material/CheckCircleOutlineRounded"; +import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; +import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; +import { + ActionIcon, + Button, + Checkbox, + Dropdown, + FilePicker, + Input, + Spinner, +} from "@app/ui"; +import "@portal/components/pipelines/PipelineHeader.css"; + +/** One file a test run produced, downloadable from the result strip. */ +export interface RunOutputFile { + fileId: string; + fileName: string | null; +} + +/** + * A test run's outcome. Whole-pipeline, not per-node: the backend reports one flat list of files + * plus the step it stopped at, so there is no per-node output to attach to a node. + */ +export interface RunResultSummary { + status: "running" | "completed" | "failed"; + completedSteps: number; + stepCount: number; + error?: string | null; + outputs?: RunOutputFile[]; +} + +export interface PipelineHeaderProps { + name: string; + onNameChange: (name: string) => void; + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + /** False for a pipeline that has never been saved: it cannot yet be run, cleared or deleted. */ + isEdit: boolean; + /** How many steps the chain has, so an empty pipeline cannot offer a test that does nothing. */ + stepCount: number; + + canSave: boolean; + saving: boolean; + onSave: () => void; + onCancel: () => void; + onBack: () => void; + + /** Run the steps as they stand against one uploaded file, without saving or delivering. */ + onTest: (file: File) => void; + testing: boolean; + /** Run the saved pipeline against its real input, delivering to its real destination. */ + onRun: () => void; + running: boolean; + onClearHistory: () => void; + clearingHistory: boolean; + onDelete: () => void; + + /** Opens the definition (JSON + cURL), which is pipeline-scoped like the rest of this row. */ + onViewDefinition: () => void; + /** The last test run in this session, or null if there has not been one. */ + runResult: RunResultSummary | null; + onDownloadOutput: (output: RunOutputFile) => void; +} + +/** + * The pipeline's identity and its whole-pipeline actions, at the top of the builder. + * + * Split in two so neither half gets lost in a single crowded row: what the pipeline *is* (name, + * whether it is live) sits with the actions that leave the page, and what you can *do to it* sits + * below the rule. A test run is part of building, so it lives here rather than off in a corner - + * its progress shows on the graph's nodes and its results in the inspector. + */ +export function PipelineHeader({ + name, + onNameChange, + enabled, + onEnabledChange, + isEdit, + stepCount, + canSave, + saving, + onSave, + onCancel, + onBack, + onTest, + testing, + onRun, + running, + onClearHistory, + clearingHistory, + onDelete, + onViewDefinition, + runResult, + onDownloadOutput, +}: PipelineHeaderProps) { + const { t } = useTranslation(); + + return ( +
    +
    + +
    + + +
    +
    + +
    + onNameChange(e.target.value)} + /> + {/* A checkbox, not a switch: this is a form value that takes effect on save, and a switch + would imply it applies the moment it is flipped. No description - a second line beside + the single-line name field leaves the row ragged. */} + onEnabledChange(e.target.checked)} + label={t("portal.pipelines.builder.enabled")} + /> +
    + +
    + file && onTest(file)} + leftSection={} + > + {t("portal.pipelines.builder.testRun")} + + + {isEdit && ( + + )} + + {/* Occasional things - reading the definition, wiping the processed history - kept behind a + tray so they do not compete with running and testing, which is what this row is for. */} + + + + + + + + } + > + {t("portal.pipelines.builder.viewDefinition")} + + {isEdit && ( + + } + > + {t("portal.pipelines.detail.clearHistory")} + + )} + + + + {isEdit && ( + + )} +
    + + {runResult && ( + + )} +
    + ); +} + +interface RunResultStripProps { + result: RunResultSummary; + onDownload: (output: RunOutputFile) => void; +} + +/** What the last test run did, beside the button that started it. */ +function RunResultStrip({ result, onDownload }: RunResultStripProps) { + const { t } = useTranslation(); + const outputs = result.outputs ?? []; + + return ( +
    +
    + {result.status === "running" && } + {result.status === "completed" && ( + + )} + {result.status === "failed" && ( + + )} + + {t(`portal.pipelines.inspector.status.${result.status}`, { + done: result.completedSteps, + count: result.stepCount, + })} + +
    + + {/* The reason it failed, where the failure is announced - not only on the node, which the user + has to know to click. */} + {result.status === "failed" && result.error && ( + + {result.error} + + )} + + {outputs.map((output) => ( + + ))} +
    + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInspector.css b/frontend/editor/src/portal/components/pipelines/PipelineInspector.css new file mode 100644 index 0000000000..0fc50597e8 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineInspector.css @@ -0,0 +1,34 @@ +/** + * The builder's right-hand panel: the selected node's settings. + */ + +.portal-inspector { + display: flex; + flex-direction: column; + gap: 0.875rem; + padding: 1.125rem; + background: var(--c-surface); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); + /* The builder caps its columns so the page itself does not scroll, which means a settings form + taller than the viewport has to scroll in here - otherwise its lower half is unreachable. */ + max-height: 100%; +} + +.portal-inspector__body { + display: flex; + flex-direction: column; + gap: 0.875rem; + min-height: 0; + overflow-y: auto; +} + +/* Names the node being edited, so the panel is not just a nameless form. */ +.portal-inspector__title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--c-text); +} diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx b/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx new file mode 100644 index 0000000000..ff4c8f3d21 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineInspector.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FormField, Input, Select } from "@app/ui"; +import { PipelineInspector } from "@portal/components/pipelines/PipelineInspector"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineInspector", + component: PipelineInspector, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( +
    + +
    + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** Stands in for a node's real editor, which the builder supplies. */ +function StubSettings() { + return ( + <> + + + + + } onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") onClose(); @@ -104,36 +104,48 @@ export function ToolPicker({
    {group.label}
    - {group.tools.map((tool) => ( - - ))} + + ); + })} )) )} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css new file mode 100644 index 0000000000..a821b9910e --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.css @@ -0,0 +1,180 @@ +/** + * One wire between two nodes, plus its insert affordance. The graph positions it; the wire is a + * 1px rule centred on the column with a filled arrowhead at the arriving end. + */ + +.portal-graph-edge { + position: absolute; + /* A wide drop target: the wire is a thin line, but a dragged step can be released anywhere across + the row, so the whole band between the nodes catches it. `left` is the column centre, so pull + back by half to keep the band centred on it. Line, insert and warning are placed absolutely + within. */ + width: 16rem; + transform: translateX(-50%); + --edge-color: var(--c-border-strong); +} + +.portal-graph-edge__line { + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + transform: translateX(-50%); + background: var(--edge-color); +} + +/** + * Arrowhead at the arriving end, so the chain reads as directed. + */ +.portal-graph-edge__line::after { + content: ""; + position: absolute; + bottom: 0; + left: 50%; + width: 0; + height: 0; + transform: translateX(-50%); + border-left: 0.1875rem solid transparent; + border-right: 0.1875rem solid transparent; + border-top: 0.3125rem solid var(--edge-color); +} + +/* Insert: the shared ActionIcon restyled to a small dot beside the wire (not on top of it, where it + hid the line). Hidden at rest - a solid plus on every wire reads as busy - and revealed only when + the pointer is over this wire's drop band (see the reveal rule below). When shown it is solid, not + faint: off to one side on the canvas it needs a real border and glyph to be seen at all. */ +.portal-graph-edge__insert.sui-ai { + position: absolute; + top: 50%; + /* Beside the wire: the column centre is 50%, nudge clear of the line and centre on the row. */ + left: 50%; + transform: translate(0.6rem, -50%); + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + border: 1px solid var(--c-border-strong); + background: var(--c-surface); + color: var(--c-text-muted); + opacity: 0; + transition: + opacity var(--motion-fast), + color var(--motion-fast), + border-color var(--motion-fast), + background var(--motion-fast); +} + +/* Optically centre the glyph in the circle: MUI's Add icon carries a hair of bottom bias. */ +.portal-graph-edge__insert.sui-ai svg { + display: block; +} + +/* On a warning wire the insert sits just past the pill, out of flow so the pill stays centred on the + wire whether or not the insert is showing (it reveals on hover like every other wire's). */ +.portal-graph-edge__note .portal-graph-edge__insert.sui-ai { + left: 100%; + margin-left: 0.375rem; + transform: translateY(-50%); +} + +/* Reveal the insert when the pointer is anywhere in this wire's drop band, or it has keyboard focus. + Revealing is not highlighting: it comes in at its resting weight and only goes primary once the + pointer is on the button itself (below) - not from anywhere in the wide band. */ +.portal-graph-edge:hover .portal-graph-edge__insert.sui-ai, +.portal-graph-edge__insert.sui-ai:focus-visible { + opacity: 1; +} + +.portal-graph-edge__insert.sui-ai:hover, +.portal-graph-edge__insert.sui-ai:focus-visible { + color: var(--c-accent-fg, var(--c-primary)); + border-color: var(--c-primary); +} + +/* A wire with no slot of its own (either side of the placeholder): line only. */ +.portal-graph-edge.is-plain { + --edge-color: var(--c-border-subtle); +} + +/** + * The pairing does not make much sense. Advisory: the wire still accepts drops and the chain still + * runs - the order stays the user's choice. + */ +.portal-graph-edge.has-warning { + --edge-color: var(--c-warning); +} + +/* The note and its insert ride together, centred on the wire, so a warned pairing keeps a way to + take a fixing step between its ends. Grows to its content and may overhang the band, which the + wider graph column absorbs. */ +.portal-graph-edge__note { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.portal-graph-edge__warning { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.0625rem 0.375rem; + border-radius: var(--radius-pill); + border: 1px solid var(--c-warning); + /* Matches the shared Banner's warning treatment: tinted ground, amber border and glyph, ordinary + text. Amber words would not clear 4.5:1 at this size, and the tint is what makes the neutral + text read as part of a warning rather than as stray body copy. */ + background: color-mix(in srgb, var(--c-warning) 12%, var(--c-surface)); + color: var(--c-text); + font-size: 0.6875rem; + line-height: 1.4; +} + +.portal-graph-edge__warning svg { + color: var(--c-warning); + flex: none; +} + +/* A blocking pairing: the chain cannot run in this order, so it must not read as the same gentle + advice as an odd-but-workable one. Same shape, danger tone - including the wire and its head, + which follow --edge-color. */ +.portal-graph-edge.is-blocking { + --edge-color: var(--c-danger); +} + +.portal-graph-edge.is-blocking .portal-graph-edge__warning { + border-color: var(--c-danger); + background: color-mix(in srgb, var(--c-danger) 12%, var(--c-surface)); +} + +.portal-graph-edge.is-blocking .portal-graph-edge__warning svg { + color: var(--c-danger); +} + +.portal-graph-edge__warning-label { + white-space: nowrap; +} + +/* While a step is being dragged, the insert is beside the point - the wire itself is the target - + and it would only clutter the row and collide with the drag hint. Hide it until the drag ends. + The wire itself stays at rest until the step is actually over it: lighting every wire the moment + a drag starts is noise, not a cue. */ +.portal-graph-edge.is-available .portal-graph-edge__insert.sui-ai { + display: none; +} + +/* The step is over this wire and would land here on release: only then does the wire go primary. */ +.portal-graph-edge.is-over { + --edge-color: var(--c-primary); +} + +.portal-graph-edge.is-over .portal-graph-edge__line { + width: 2px; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx new file mode 100644 index 0000000000..7f7dcca5d4 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphEdge.tsx @@ -0,0 +1,109 @@ +import { useTranslation } from "react-i18next"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded"; +import { ActionIcon } from "@app/ui"; +import type { LaidOutEdge } from "@portal/components/pipelines/graph/pipelineLayout"; +import { useEdgeDrop } from "@portal/components/pipelines/graph/useChainDragDrop"; +import "@portal/components/pipelines/graph/GraphEdge.css"; + +/** + * A note on the wire arriving at a node: why what flows in will not suit it. + * + * `blocking` separates "this cannot run in this order" from "this is probably not what you meant". + * Both are shown on the wire and neither refuses the edit - the order stays the user's to choose - + * but only a blocking one stops the pipeline being saved, so it must not read as mere advice. + */ +export interface ChainWarning { + text: string; + blocking?: boolean; +} + +export interface GraphEdgeProps { + edge: LaidOutEdge; + /** Add a new step in the slot this wire opens. */ + onInsert: (index: number) => void; + stepCount: number; + /** Given the chain's new order as original step indices, and which steps the drag carried. */ + onReorder: (order: number[], moved: readonly number[]) => void; + /** A step is in flight, so open wires advertise themselves as landing spots. */ + dragActive: boolean; + /** + * Why what flows along this wire will not be much use to the node it arrives at (encrypting + * before an OCR, say). Never refuses the edit - the order stays the user's to choose - but a + * blocking one means the chain cannot run at all, and is coloured apart from mere advice. + */ + warning?: ChainWarning; +} + +/** + * One wire between two nodes: a directed line carrying an insert affordance, and the drop target + * that catches a step dragged onto it. Where the pairing does not make sense the wire says so, + * rather than refusing it. + */ +export function GraphEdge({ + edge, + onInsert, + stepCount, + onReorder, + dragActive, + warning, +}: GraphEdgeProps) { + const { t } = useTranslation(); + const { ref, over } = useEdgeDrop({ + insertIndex: edge.insertIndex, + stepCount, + onReorder, + }); + const open = edge.insertIndex !== null; + + // The insert affordance is shown whenever the wire opens a slot - including on a warned wire, so a + // bad pairing can still take a fixing step between its ends rather than losing its only way in. + const insertButton = open ? ( + onInsert(edge.insertIndex as number)} + > + + + ) : null; + + return ( +
    + + {warning ? ( + + + + + {warning.text} + + + {insertButton} + + ) : ( + insertButton + )} +
    + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css new file mode 100644 index 0000000000..9bea07fd4b --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.css @@ -0,0 +1,140 @@ +/** + * Graph-only extras layered on the shared NodeCard tile (see @app/ui/NodeCard): the config warning + * line, drag dimming, the remove control and run-state glyphs. The surface, selection ring and + * icon/title/detail layout all live in NodeCard. + */ + +/* Amber carries the tone on the glyph; the words stay body-coloured. --c-warning is amber-600, + which is only 3.18:1 on a light surface at this size (axe) - readable as an icon, not as text. */ +.portal-graph-node__warning { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--c-text); +} + +.portal-graph-node__warning svg { + color: var(--c-warning); + flex: none; +} + +/* Lifted out of the chain: the origin dims so the drop target reads as the real position. */ +.portal-graph-node.is-dragging { + opacity: 0.4; +} + +/* Steps can be picked up and moved; the input and output are fixed ends. */ +.portal-graph-node--step .sui-node-card__select.sui-btn { + cursor: grab; +} + +.portal-graph-node--step.is-dragging .sui-node-card__select.sui-btn { + cursor: grabbing; +} + +/* Remove: quiet until the node is hovered or focused, so the chain stays calm. */ +.portal-graph-node__remove.sui-ai { + position: absolute; + top: -0.4375rem; + /* Logical, so in RTL the remove sits on the card's trailing (left) corner rather than on top of + the leading icon badge. */ + inset-inline-end: -0.4375rem; + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + border: 1px solid var(--c-border); + background: var(--c-surface); + color: var(--c-text-subtle); + opacity: 0; + transition: + opacity var(--motion-fast), + color var(--motion-fast), + border-color var(--motion-fast); +} + +.portal-graph-node:hover .portal-graph-node__remove.sui-ai, +.portal-graph-node.is-selected .portal-graph-node__remove.sui-ai, +.portal-graph-node__remove.sui-ai:focus-visible { + opacity: 1; +} + +.portal-graph-node__remove.sui-ai:hover { + color: var(--c-danger); + border-color: var(--c-danger); +} + +/* Run state: a status glyph on the card's trailing edge, inside the node. The state is carried by + the icon's shape as well as its colour, with the wording kept for assistive tech. */ +.portal-graph-node__run { + flex: none; + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + margin-inline-end: 0.625rem; + color: var(--c-text-subtle); +} + +/* A failed step's glyph is a button (it opens the error), so re-assert the plain glyph look over + the shared ActionIcon base. */ +.portal-graph-node__run--open.sui-ai { + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + border: none; + background: none; + color: var(--c-danger); +} + +.portal-graph-node__run-label { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.portal-graph-node.is-done .portal-graph-node__run { + color: var(--c-success); +} + +.portal-graph-node.is-failed .portal-graph-node__run { + color: var(--c-danger); +} + +/* Running is carried by the pulsing glyph alone: a primary border here would be the selected + treatment, and "the step I am editing" must stay distinguishable from "the step running now". */ +.portal-graph-node.is-running .portal-graph-node__run { + color: var(--c-primary); +} + +.portal-graph-node__pulse { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: currentColor; + animation: portal-graph-pulse 1.2s ease-in-out infinite; +} + +@keyframes portal-graph-pulse { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .portal-graph-node__pulse { + animation: none; + } +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx new file mode 100644 index 0000000000..c19a7c15f2 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphNode.tsx @@ -0,0 +1,183 @@ +import type { MouseEvent, ReactNode, Ref } from "react"; +import { useTranslation } from "react-i18next"; +import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; +import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; +import ErrorOutlineRoundedIcon from "@mui/icons-material/ErrorOutlineRounded"; +import MoveToInboxRoundedIcon from "@mui/icons-material/MoveToInboxRounded"; +import SendRoundedIcon from "@mui/icons-material/SendRounded"; +import TuneRoundedIcon from "@mui/icons-material/TuneRounded"; +import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded"; +import { ActionIcon, NodeCard } from "@app/ui"; +import { type IconBadgeAccent } from "@app/ui/IconBadge"; +import type { GraphNodeKind } from "@portal/components/pipelines/graph/pipelineLayout"; +import "@portal/components/pipelines/graph/GraphNode.css"; + +/** How a node is faring in the current or last test run. */ +export type NodeRunState = "running" | "done" | "failed"; + +/** The kinds this card renders. The placeholder is its own component, not a node variant. */ +type CardKind = Exclude; + +const KIND_ICON: Record = { + input: , + step: , + output: , +}; + +const KIND_ACCENT: Record = { + input: "green", + step: "blue", + output: "purple", +}; + +export interface GraphNodeProps { + kind: CardKind; + title: string; + /** One-line summary under the title (the source's path, a step's parameters). */ + detail?: string; + /** + * Problem with this node's configuration, shown in place of the detail. Distinct from a run + * failure: this is why the pipeline cannot be saved yet. + */ + warning?: string; + /** The step's own tool glyph; falls back to a per-kind default. */ + icon?: ReactNode; + selected: boolean; + runState?: NodeRunState; + onOpenRunState?: () => void; + onSelect: (event: MouseEvent) => void; + /** Takes the node off the chain. For an end, that returns its row to a placeholder. */ + onRemove?: () => void; + /** True while this node is being dragged to another place in the chain. */ + dragging?: boolean; + /** + * The step's place in the chain, so a multi-step drag preview can find the other selected cards + * in the DOM. Absent for the input and output, which are never dragged. + */ + stepIndex?: number; + /** The card element, for the drag adapter to register against. */ + ref?: Ref; +} + +/** + * One node in the pipeline graph: the shared {@link NodeCard} tile carrying its glyph, title and a + * one-line summary, plus the graph-only extras layered on top - run status, a remove control, drag + * dimming, and the "why this cannot be saved" warning line. Position is applied by the graph, so the + * node itself knows nothing about layout. + */ +export function GraphNode({ + kind, + title, + detail, + warning, + icon, + selected, + runState, + onOpenRunState, + onSelect, + onRemove, + dragging, + stepIndex, + ref, +}: GraphNodeProps) { + const { t } = useTranslation(); + + const runStatus = runState && ( + + ); + const remove = onRemove && ( + + + + ); + + return ( + + + {warning} + + ) : ( + detail + ) + } + trailing={ + <> + {runStatus} + {remove} + + } + /> + ); +} + +interface RunStatusProps { + runState: NodeRunState; + title: string; + onOpenRunState?: () => void; +} + +/** The run glyph on the card's trailing edge; a button when it opens a failure, else a status. */ +function RunStatus({ runState, title, onOpenRunState }: RunStatusProps) { + const { t } = useTranslation(); + if (runState === "failed" && onOpenRunState) { + return ( + + + + ); + } + return ( + + {runState === "running" && ( + + )} + {runState === "done" && ( + + )} + {runState === "failed" && ( + + )} + + {t(`portal.pipelines.graph.run.${runState}`)} + + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css new file mode 100644 index 0000000000..dba96072c4 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.css @@ -0,0 +1,44 @@ +/** + * The empty-pipeline stand-in: a dashed node in the row the first step will take. Dashed rather + * than solid so it reads as "not yet a step", and full width so it is an obvious target. + */ + +.portal-graph-placeholder.sui-btn { + width: 100%; + height: auto; + min-height: 0; + padding: 0.625rem 0.75rem; + background: none; + border: 1px dashed var(--c-border-strong); + border-radius: var(--radius-lg); + color: var(--c-text-muted); + font-weight: 400; + text-align: left; + transition: + border-color var(--motion-fast), + color var(--motion-fast), + background var(--motion-fast); +} + +.portal-graph-placeholder.sui-btn:hover { + border-color: var(--c-primary); + border-style: solid; + color: var(--c-accent-fg, var(--c-primary)); + background: var(--c-surface); +} + +/* Mantine lays a button's children out inside its label element, not on the root. */ +.portal-graph-placeholder.sui-btn .mantine-Button-label { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.625rem; + min-width: 0; + overflow: visible; +} + +.portal-graph-placeholder__title { + font-size: 0.875rem; + font-weight: 500; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx new file mode 100644 index 0000000000..0ecee1bfc7 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/GraphPlaceholderNode.tsx @@ -0,0 +1,30 @@ +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import { Button } from "@app/ui"; +import "@portal/components/pipelines/graph/GraphPlaceholderNode.css"; + +export interface GraphPlaceholderNodeProps { + label: string; + onAdd: () => void; +} + +/** + * The stand-in for a row the pipeline has not filled yet - the first step, or either end of the + * chain on a new pipeline. It sits in the row that thing will occupy, so the chain reads as + * input -> something -> output straight away, and it is the thing you click to fill it: a full-width + * target, rather than a caption pointing at a small plus on a wire. + */ +export function GraphPlaceholderNode({ + label, + onAdd, +}: GraphPlaceholderNodeProps) { + return ( + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css new file mode 100644 index 0000000000..c9aff67012 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.css @@ -0,0 +1,79 @@ +/** + * The graph surface. The canvas is sized by the derived layout and centred in the scroll area, so + * the chain stays put as steps are added or removed. + */ + +.portal-graph { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 1rem; + /* Grows with the chain up to whatever the page gives it, then scrolls rather than pushing the + inspector out of reach. A short chain still hugs its content, so there is no empty canvas. */ + max-height: 100%; + overflow: auto; + /* The canvas must sit below the node cards on the surface ladder so they read as raised off it in + both themes. --c-surface-sunken is the only rung darker than --c-surface in light *and* dark; + the legacy --color-bg-subtle collapsed into the page in dark, and --c-bg-raised is lighter than + the cards in light. */ + background: var(--c-surface-sunken); + border: 1px solid var(--c-border-subtle); + border-radius: var(--radius-lg); +} + +/* Sits beside the wire it is describing. Absolute, so appearing mid-drag moves nothing. */ +.portal-graph__drag-hint { + position: absolute; + margin: 0; + /* `left` is the column centre; clear the wire's hit area before the text starts. */ + transform: translate(1.75rem, -50%); + white-space: nowrap; + font-size: 0.75rem; + font-weight: 500; + color: var(--c-accent-fg, var(--c-primary)); + pointer-events: none; +} + +/* transform has no logical form, so mirror it by hand: in RTL the hint clears the wire on the other + side rather than reaching back across it onto the chain. */ +[dir="rtl"] .portal-graph__drag-hint { + transform: translate(-1.75rem, -50%); +} + +/* What follows the cursor when several steps are dragged at once: a copy of each card, stacked, so + the drag shows what is actually moving rather than only the card that was grabbed. Cloned nodes + keep their own styling; they are inert copies, hence no pointer events. */ +.portal-graph__drag-preview { + display: flex; + flex-direction: column; + gap: 0.375rem; + pointer-events: none; +} + +.portal-graph__drag-preview .portal-graph-node { + opacity: 0.9; +} + +.portal-graph__canvas { + position: relative; + flex: none; +} + +/* Nodes are placed by the layout; the slot carries the position, the card fills it. */ +.portal-graph__slot { + position: absolute; + display: flex; +} + +.portal-graph__slot > * { + flex: 1; + min-width: 0; +} + +.portal-graph__hint { + margin: 0; + font-size: 0.8125rem; + color: var(--c-text-subtle); + text-align: center; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx new file mode 100644 index 0000000000..c16291bd9c --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.stories.tsx @@ -0,0 +1,207 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + PipelineGraph, + selectedSteps, + type ChainEnd, + type GraphNodeContent, + type GraphSelection, + type GraphStepContent, +} from "@portal/components/pipelines/graph/PipelineGraph"; + +const meta: Meta = { + title: "Portal/Pipelines/PipelineGraph", + component: PipelineGraph, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +const INPUT = { + label: "Claims intake", + detail: "/srv/claims/in - every hour", +}; +const OUTPUT = { + label: "Archive bucket", + detail: "s3://claims-archive/done", +}; + +/** + * The builder owns the chain in the app, so the stories own it here - otherwise adding, removing + * and dragging would fire their handlers and visibly do nothing. Everything in these stories is + * live: click a wire's plus to insert, the node's X to remove, and drag a step onto a wire to move + * it there. + */ +function Playground({ + initialSteps, + output = OUTPUT, + /** Start with neither end on the chain, the way a brand new pipeline opens. */ + unplacedEnds = false, +}: { + initialSteps: GraphStepContent[]; + output?: { label: string; detail?: string; warning?: string }; + unplacedEnds?: boolean; +}) { + const [steps, setSteps] = useState(initialSteps); + const [selected, setSelected] = useState(null); + const [added, setAdded] = useState(0); + const [inputEnd, setInputEnd] = useState( + unplacedEnds ? null : INPUT, + ); + const [outputEnd, setOutputEnd] = useState( + unplacedEnds ? null : output, + ); + + // Placing an end leaves it owing a choice, which is the warning state the builder shows until the + // user picks a source or destination. + function addEnd(end: ChainEnd) { + if (end === "input") { + setInputEnd({ label: "Choose a source", warning: "No source chosen" }); + } else { + setOutputEnd({ + label: "Choose a destination", + warning: "No destination chosen", + }); + } + setSelected(end); + } + + function removeEnd(end: ChainEnd) { + if (end === "input") setInputEnd(null); + else setOutputEnd(null); + setSelected((current) => (current === end ? null : current)); + } + + function insert(at: number) { + const label = `New tool ${added + 1}`; + setAdded((n) => n + 1); + setSteps((current) => { + const next = [...current]; + next.splice(at, 0, { label }); + return next; + }); + setSelected({ steps: [at] }); + } + + function remove(indices: number[]) { + const gone = new Set(indices); + setSteps((current) => current.filter((_, i) => !gone.has(i))); + setSelected(null); + } + + function reorder(order: number[]) { + const moving = new Set(selectedSteps(selected)); + setSteps((current) => order.map((i) => current[i])); + const landed = order + .map((original, position) => ({ original, position })) + .filter(({ original }) => moving.has(original)) + .map(({ position }) => position); + setSelected(landed.length > 0 ? { steps: landed } : null); + } + + return ( + + ); +} + +/** A typical chain. Drag a step onto any wire to move it there. */ +export const Default: Story = { + render: () => ( + + ), +}; + +/** A pipeline with its ends settled but no steps yet: the placeholder holds the first step's place. */ +export const Empty: Story = { + render: () => , +}; + +/** + * A brand new pipeline, before anything has been chosen. Every row is an invitation rather than a + * complaint - nothing is wrong yet, because nothing has been asked of the user. Click an end to + * place it (it then owes a choice, and says so), and its X puts it back. + */ +export const NewPipeline: Story = { + render: () => , +}; + +/** + * An order that will not do what the user probably meant: OCR cannot read a file that the previous + * step encrypted. The wire says so and the chain still runs - nothing is refused, and the step can + * still be dragged anywhere. + */ +export const OddOrdering: Story = { + render: () => ( + + ), +}; + +/** Mid test-run: finished steps carry a tick, the current one pulses. */ +export const Running: Story = { + render: () => ( + + ), +}; + +/** A failed run, and a step that cannot be saved: the warning replaces the detail line. */ +export const Problems: Story = { + render: () => ( + + ), +}; + +/** + * Multi-selection: cmd/ctrl-click to add a step, shift-click for a run of them, then drag any one + * onto a line to move the whole set together. + */ +export const MultiSelect: Story = { + render: () => ( + + ), +}; diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx new file mode 100644 index 0000000000..c94bd512f3 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.test.tsx @@ -0,0 +1,455 @@ +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, +} from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { + PipelineGraph, + type GraphSelection, + type PipelineGraphProps, +} from "@portal/components/pipelines/graph/PipelineGraph"; + +// The nodes and wires are built from the shared Mantine-backed controls, so they need the provider. +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +// Deterministic i18n: keys returned verbatim, interpolation applied so aria-labels stay distinct. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, vars?: Record) => + vars?.name ? `${key}:${String(vars.name)}` : key, + }), +})); + +function renderGraph(overrides: Partial = {}) { + const handlers = { + onSelect: vi.fn(), + onAddEnd: vi.fn(), + onRemoveEnd: vi.fn(), + onInsertStep: vi.fn(), + onRemoveSteps: vi.fn(), + onReorderSteps: vi.fn(), + }; + render( + , + ); + return handlers; +} + +describe("PipelineGraph", () => { + it("renders the chain: input, each step in order, output", () => { + renderGraph(); + const titles = screen + .getAllByRole("button", { pressed: false }) + .map((node) => node.textContent); + expect(titles[0]).toContain("Claims intake"); + expect(titles[1]).toContain("OCR"); + expect(titles[2]).toContain("Redact"); + expect(titles[3]).toContain("Archive bucket"); + }); + + it("shows each node's one-line detail", () => { + renderGraph(); + expect(screen.getByText("/in - every hour")).toBeInTheDocument(); + expect(screen.getByText("s3://claims/done")).toBeInTheDocument(); + }); + + it("selects the ends by their kind and steps by index", () => { + const handlers = renderGraph(); + fireEvent.click(screen.getByText("Claims intake")); + expect(handlers.onSelect).toHaveBeenCalledWith("input"); + fireEvent.click(screen.getByText("Archive bucket")); + expect(handlers.onSelect).toHaveBeenCalledWith("output"); + fireEvent.click(screen.getByText("Redact")); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [1] }); + }); + + it("adds and removes steps from the selection with cmd/ctrl-click", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.click(screen.getByText("Redact"), { metaKey: true }); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0, 1] }); + }); + + it("cmd/ctrl-clicking the only selected step clears the selection", () => { + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.click(screen.getByText("Redact"), { ctrlKey: true }); + expect(handlers.onSelect).toHaveBeenCalledWith(null); + }); + + it("shift-click takes everything between the anchor and the clicked step", () => { + const handlers = renderGraph({ + selected: { steps: [0] }, + steps: [ + { label: "OCR" }, + { label: "Redact" }, + { label: "Compress" }, + { label: "Stamp" }, + ], + }); + fireEvent.click(screen.getByText("Stamp"), { shiftKey: true }); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0, 1, 2, 3] }); + }); + + it("marks every selected step as pressed, not just one", () => { + renderGraph({ selected: { steps: [0, 1] } }); + for (const label of ["OCR", "Redact"]) { + expect(screen.getByText(label).closest("button")).toHaveAttribute( + "aria-pressed", + "true", + ); + } + }); + + it("keeps the drag hint silent until a drag starts", () => { + // Only the silent half is testable here: starting a real drag needs native HTML5 drag events, + // which jsdom does not implement, so the visible half is checked in a browser. + renderGraph(); + expect( + screen.queryByText("portal.pipelines.graph.dragHint"), + ).not.toBeInTheDocument(); + }); + + it("clears the selection when the canvas itself is clicked", () => { + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.click(document.querySelector(".portal-graph") as HTMLElement); + expect(handlers.onSelect).toHaveBeenCalledWith(null); + }); + + it("does not clear the selection when a node is clicked", () => { + // The node's own handler runs; the background handler must not undo it. + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.click(screen.getByText("OCR")); + expect(handlers.onSelect).toHaveBeenCalledWith({ steps: [0] }); + expect(handlers.onSelect).not.toHaveBeenCalledWith(null); + }); + + it("marks the selected node as pressed", () => { + renderGraph({ selected: { steps: [0] } }); + expect(screen.getByText("OCR").closest("button")).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("puts an insert on every wire, reporting the slot it opens", () => { + const handlers = renderGraph(); + // input->OCR, OCR->Redact, Redact->output + const inserts = screen.getAllByLabelText( + "portal.pipelines.graph.insertHere", + ); + expect(inserts).toHaveLength(3); + fireEvent.click(inserts[1]); + expect(handlers.onInsertStep).toHaveBeenCalledWith(1); + }); + + it("holds the first step's place with a placeholder when the chain is empty", () => { + const handlers = renderGraph({ steps: [] }); + // The placeholder is the affordance, so the wires either side of it carry no plus of their + // own - two ways to fill the same slot would be a choice with no difference. + expect( + screen.queryByLabelText("portal.pipelines.graph.insertHere"), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("portal.pipelines.graph.addFirstTool")); + expect(handlers.onInsertStep).toHaveBeenCalledWith(0); + }); + + it("drops the placeholder once the chain has a step", () => { + renderGraph({ steps: [{ label: "OCR" }] }); + expect( + screen.queryByText("portal.pipelines.graph.addFirstTool"), + ).not.toBeInTheDocument(); + }); + + it("warns on the wire arriving at a step it makes little sense to feed", () => { + renderGraph({ + steps: [ + { label: "Add Password" }, + { + label: "OCR", + inputWarning: { text: "OCR cannot read an encrypted file" }, + }, + ], + }); + expect( + screen.getByText("OCR cannot read an encrypted file"), + ).toBeInTheDocument(); + }); + + it("still allows the odd pairing: warned wires keep taking inserts", () => { + // Advisory, not a block - the order stays the user's to choose. + const handlers = renderGraph({ + steps: [ + { label: "Add Password" }, + { + label: "OCR", + inputWarning: { text: "OCR cannot read an encrypted file" }, + }, + ], + }); + // The warned wire shows its note and keeps its plus, so a fixing step can still go between the + // ends that do not suit each other - every wire takes an insert. + const inserts = screen.getAllByLabelText( + "portal.pipelines.graph.insertHere", + ); + expect(inserts).toHaveLength(3); + // The middle wire is the warned one (Add Password -> OCR); inserting there lands between them. + fireEvent.click(inserts[1]); + expect(handlers.onInsertStep).toHaveBeenCalledWith(1); + }); + + it("marks a blocking pairing apart from a merely odd one", () => { + // Both sit on the wire and neither refuses the edit, but one means the chain cannot run at + // all - so it must not read as the same gentle advice. + renderGraph({ + steps: [ + { label: "Extract images" }, + { + label: "Compress", + inputWarning: { text: "Compress needs a PDF", blocking: true }, + }, + ], + }); + const wire = screen + .getByText("Compress needs a PDF") + .closest(".portal-graph-edge"); + expect(wire).toHaveClass("is-blocking"); + }); + + it("leaves an advisory pairing unblocked", () => { + renderGraph({ + steps: [ + { label: "Add Password" }, + { label: "OCR", inputWarning: { text: "OCR cannot read this" } }, + ], + }); + expect( + screen.getByText("OCR cannot read this").closest(".portal-graph-edge"), + ).not.toHaveClass("is-blocking"); + }); + + it("warns on the wire into the output too", () => { + renderGraph({ + output: { + label: "Archive", + inputWarning: { text: "Nothing writes a folder here" }, + }, + }); + expect( + screen.getByText("Nothing writes a folder here"), + ).toBeInTheDocument(); + }); + + it("removes a step from the node itself", () => { + const handlers = renderGraph(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.graph.removeNode:Redact"), + ); + expect(handlers.onRemoveSteps).toHaveBeenCalledWith([1]); + }); + + it("every node on the chain carries its own remove, ends included", () => { + renderGraph(); + // Two steps plus both ends: an end can be taken back off to its placeholder. + expect(screen.getAllByLabelText(/graph.removeNode/)).toHaveLength(4); + }); + + it("takes an end back off the chain", () => { + const handlers = renderGraph(); + fireEvent.click( + screen.getByLabelText("portal.pipelines.graph.removeNode:Claims intake"), + ); + expect(handlers.onRemoveEnd).toHaveBeenCalledWith("input"); + }); + + it("deletes every selected step with the Delete key", () => { + const handlers = renderGraph({ selected: { steps: [0, 1] } }); + fireEvent.keyDown(screen.getByText("Redact"), { key: "Delete" }); + expect(handlers.onRemoveSteps).toHaveBeenCalledWith([0, 1]); + }); + + it("takes a selected end off the chain with the Delete key, like its X does", () => { + const handlers = renderGraph({ selected: "input" }); + fireEvent.keyDown(screen.getByText("Claims intake"), { key: "Delete" }); + expect(handlers.onRemoveEnd).toHaveBeenCalledWith("input"); + // An end is not a step, so the step remover stays out of it. + expect(handlers.onRemoveSteps).not.toHaveBeenCalled(); + }); + + it("ignores Delete when nothing is selected", () => { + const handlers = renderGraph({ selected: null }); + fireEvent.keyDown(screen.getByText("OCR"), { key: "Delete" }); + expect(handlers.onRemoveSteps).not.toHaveBeenCalled(); + expect(handlers.onRemoveEnd).not.toHaveBeenCalled(); + }); + + it("moves the selected step down the chain with Alt+ArrowDown", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowDown", + altKey: true, + }); + // [OCR, Redact] with OCR moved down -> [Redact, OCR]; the dragged step is the reorder payload. + expect(handlers.onReorderSteps).toHaveBeenCalledWith([1, 0], [0]); + }); + + it("moves the selected step up the chain with Alt+ArrowUp", () => { + const handlers = renderGraph({ selected: { steps: [1] } }); + fireEvent.keyDown(screen.getByText("Redact"), { + key: "ArrowUp", + altKey: true, + }); + // [OCR, Redact] with Redact moved up -> [Redact, OCR]. + expect(handlers.onReorderSteps).toHaveBeenCalledWith([1, 0], [1]); + }); + + it("moves a multi-step selection together, keeping it as the payload", () => { + const handlers = renderGraph({ + selected: { steps: [0, 1] }, + steps: [{ label: "OCR" }, { label: "Redact" }, { label: "Compress" }], + }); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowDown", + altKey: true, + }); + // [OCR, Redact, Compress] with [OCR, Redact] moved down -> [Compress, OCR, Redact]. + expect(handlers.onReorderSteps).toHaveBeenCalledWith([2, 0, 1], [0, 1]); + }); + + it("does not reorder past the end of the chain", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowUp", + altKey: true, + }); + expect(handlers.onReorderSteps).not.toHaveBeenCalled(); + }); + + it("leaves a bare arrow alone, so only the modifier reorders", () => { + const handlers = renderGraph({ selected: { steps: [0] } }); + fireEvent.keyDown(screen.getByText("OCR"), { key: "ArrowDown" }); + expect(handlers.onReorderSteps).not.toHaveBeenCalled(); + }); + + it("carries focus to the moved step so it can be walked several slots", () => { + // A real reorder renumbers the nodes, so focus has to follow the step or a second key press + // would act on whatever now sits where it started. Drive it through a stateful host. + function Host() { + const [steps, setSteps] = useState([ + { label: "OCR" }, + { label: "Redact" }, + { label: "Compress" }, + ]); + const [selected, setSelected] = useState({ steps: [0] }); + return ( + { + setSteps((current) => order.map((i) => current[i])); + const landed = order + .map((original, position) => ({ original, position })) + .filter(({ original }) => moved.includes(original)) + .map(({ position }) => position); + setSelected({ steps: landed }); + }} + /> + ); + } + render(); + fireEvent.keyDown(screen.getByText("OCR"), { + key: "ArrowDown", + altKey: true, + }); + // OCR now sits at position 1, and its card's select button holds focus. + expect(document.activeElement?.textContent).toContain("OCR"); + expect( + document.activeElement?.closest("[data-step-index]"), + ).toHaveAttribute("data-step-index", "1"); + }); + + describe("an end the pipeline has not asked for yet", () => { + it("offers to add it instead of naming it", () => { + renderGraph({ input: null }); + expect( + screen.getByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect(screen.queryByText("Claims intake")).not.toBeInTheDocument(); + }); + + it("greets a brand new pipeline with no warnings at all", () => { + // The whole point: an end nobody has been offered yet is not a problem to report. + renderGraph({ + input: null, + output: null, + steps: [], + }); + expect(screen.queryByText(/warning|chosen/i)).not.toBeInTheDocument(); + expect(screen.getAllByText(/graph.add\./)).toHaveLength(2); + }); + + it("asks for the end when its placeholder is clicked", () => { + const handlers = renderGraph({ output: null }); + fireEvent.click(screen.getByText("portal.pipelines.graph.add.output")); + expect(handlers.onAddEnd).toHaveBeenCalledWith("output"); + }); + + it("has nothing to remove until it is placed", () => { + renderGraph({ input: null, output: null, steps: [] }); + expect( + screen.queryByLabelText(/graph.removeNode/), + ).not.toBeInTheDocument(); + }); + + it("carries no warning onto the wire that arrives at it", () => { + renderGraph({ output: null }); + expect( + screen.queryByText("Nothing writes a folder here"), + ).not.toBeInTheDocument(); + }); + }); + + it("shows a node's warning in place of its detail", () => { + renderGraph({ + steps: [ + { label: "Watermark", detail: "logo.png", warning: "Needs a file" }, + ], + }); + expect(screen.getByText("Needs a file")).toBeInTheDocument(); + expect(screen.queryByText("logo.png")).not.toBeInTheDocument(); + }); + + it("reports a run's progress on the steps it touched", () => { + renderGraph({ + steps: [ + { label: "OCR", runState: "done" }, + { label: "Redact", runState: "running" }, + ], + }); + expect( + screen.getByText("portal.pipelines.graph.run.done"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.graph.run.running"), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx new file mode 100644 index 0000000000..7afb3a9f41 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/PipelineGraph.tsx @@ -0,0 +1,379 @@ +import { + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + GraphNode, + type NodeRunState, +} from "@portal/components/pipelines/graph/GraphNode"; +import { + GraphEdge, + type ChainWarning, +} from "@portal/components/pipelines/graph/GraphEdge"; +import { GraphPlaceholderNode } from "@portal/components/pipelines/graph/GraphPlaceholderNode"; +import { + NODE_HEIGHT, + NODE_WIDTH, + layoutChain, + reorderMany, + stepIndexOf, +} from "@portal/components/pipelines/graph/pipelineLayout"; +import { useStepDraggable } from "@portal/components/pipelines/graph/useChainDragDrop"; +import "@portal/components/pipelines/graph/PipelineGraph.css"; + +// The wire renders it, but callers build it, so it is re-exported from the graph they talk to. +export type { ChainWarning }; + +/** + * What is selected: an end of the chain, one or more steps, or nothing. Only steps come in sets - + * the input and output are fixed ends, so there is nothing to gather or move. + */ +export type GraphSelection = "input" | "output" | { steps: number[] } | null; + +/** The selected step indices, in chain order. Empty unless steps are what is selected. */ +export function selectedSteps(selection: GraphSelection): number[] { + return selection !== null && typeof selection === "object" + ? selection.steps + : []; +} + +/** A node's display content. The graph never derives copy - the builder owns every label. */ +export interface GraphNodeContent { + label: string; + /** One-line summary: the source's path, a step's parameters, the destination. */ + detail?: string; + /** Why this node blocks saving, shown in place of the detail. */ + warning?: string; + /** Why the input will not be much use. */ + inputWarning?: ChainWarning; +} + +export interface GraphStepContent extends GraphNodeContent { + icon?: ReactNode; + runState?: NodeRunState; +} + +/** Which end of the chain: the two nodes every finished pipeline has, one of each. */ +export type ChainEnd = "input" | "output"; + +export interface PipelineGraphProps { + /** + * The chain's ends, or null while a new pipeline has yet to ask for one. Null renders the row as a + * placeholder to fill rather than a node owing a choice, which is what keeps a brand new pipeline + * from opening on a pair of warnings about decisions its author has not been offered yet. + */ + input: GraphNodeContent | null; + output: GraphNodeContent | null; + steps: GraphStepContent[]; + selected: GraphSelection; + onSelect: (selection: GraphSelection) => void; + /** Put an end on the chain, ready to be configured. */ + onAddEnd: (end: ChainEnd) => void; + /** Take an end back off, returning its row to a placeholder. */ + onRemoveEnd: (end: ChainEnd) => void; + /** Add a step in the slot the clicked wire opens. */ + onInsertStep: (index: number) => void; + /** Remove every step given, in one go. */ + onRemoveSteps: (indices: number[]) => void; + /** Reorder the chain to the given original step indices; `moved` is what the drag carried. */ + onReorderSteps: (order: number[], moved: readonly number[]) => void; + onOpenStepError?: (index: number) => void; +} + +/** + * The pipeline as a graph: one input, the steps in run order, one output. + * + * Layout is derived from the chain (see pipelineLayout), so there is nothing to lock, nothing to + * re-tidy and no stored positions - a node is always where its place in the order says it is. + * Dragging a step onto a wire moves it into that slot; clicking a node opens its settings in the + * inspector; the wires carry the insert affordance. + */ +export function PipelineGraph({ + input, + output, + steps, + selected, + onSelect, + onAddEnd, + onRemoveEnd, + onInsertStep, + onRemoveSteps, + onReorderSteps, + onOpenStepError, +}: PipelineGraphProps) { + const { t } = useTranslation(); + const [draggingIndex, setDraggingIndex] = useState(null); + const { nodes, edges, width, height } = layoutChain({ + stepCount: steps.length, + }); + + const graphRef = useRef(null); + // A keyboard reorder renumbers the nodes, so the focused card is no longer under the cursor's + // hand: without moving focus to where the step landed, a second Alt+Arrow would act on whatever + // now sits at the old position. Set by the handler, applied once the new order has rendered. + const focusStepAfterRender = useRef(null); + useLayoutEffect(() => { + const position = focusStepAfterRender.current; + if (position === null) return; + focusStepAfterRender.current = null; + graphRef.current + ?.querySelector( + `[data-step-index="${position}"] .sui-node-card__select`, + ) + ?.focus(); + }); + + // A wire carries the warning belonging to the node it arrives at. + const arrivalWarning = (nodeId: string): ChainWarning | undefined => { + if (nodeId === "output") return output?.inputWarning; + const index = stepIndexOf(nodeId); + return index === null ? undefined : steps[index]?.inputWarning; + }; + + /** + * Clicking the canvas itself clears the selection. Anything that is part of a node, a wire or the + * placeholder handles its own click, so only bare background gets here. + */ + function onBackgroundClick(event: ReactMouseEvent) { + const target = event.target as HTMLElement; + if ( + target.closest( + "[data-graph-node], .portal-graph-edge, .portal-graph-placeholder", + ) + ) { + return; + } + onSelect(null); + } + + const chosen = selectedSteps(selected); + + /** + * Plain click selects one step. Cmd/Ctrl toggles a step in or out of the selection; Shift takes + * everything between the first selected step and this one. The ends of the chain are single-only. + */ + function selectStep(index: number, event: ReactMouseEvent) { + if (event.metaKey || event.ctrlKey) { + const next = chosen.includes(index) + ? chosen.filter((i) => i !== index) + : [...chosen, index].sort((a, b) => a - b); + onSelect(next.length > 0 ? { steps: next } : null); + return; + } + if (event.shiftKey && chosen.length > 0) { + const anchor = chosen[0]; + const [from, to] = anchor <= index ? [anchor, index] : [index, anchor]; + const span = []; + for (let i = from; i <= to; i++) span.push(i); + onSelect({ steps: span }); + return; + } + onSelect({ steps: [index] }); + } + + /** + * Move the selected step(s) one slot along the chain - the keyboard alternative to dragging, which + * pointer-only users cannot reach. Alt with an arrow, so a plain arrow is still free for anything + * that later wants it. The moved block stays selected and takes focus with it, so it can be walked + * several slots in a row. + */ + function moveSelection(direction: "up" | "down"): boolean { + if (chosen.length === 0) return false; + const min = chosen[0]; + const max = chosen[chosen.length - 1]; + // reorderMany's slot is against the original chain: a step's own neighbouring slots are no-ops, + // so up aims one before the block and down one past it. + const slot = direction === "up" ? min - 1 : max + 2; + const order = reorderMany(steps.length, chosen, slot); + if (order === null) return false; // already at that end of the chain + focusStepAfterRender.current = order.indexOf(min); + onReorderSteps(order, chosen); + return true; + } + + /** + * Delete removes whatever is selected: every selected step, or an end of the chain (which returns + * its row to a placeholder, exactly as that node's X does). Alt + Up/Down reorders the selection. + * Scoped to the graph, so typing in the inspector's fields is never intercepted. + */ + function onKeyDown(event: KeyboardEvent) { + if ( + event.altKey && + (event.key === "ArrowUp" || event.key === "ArrowDown") + ) { + // Ends do not reorder (chosen is empty for them), so this only fires for a step selection. + if (moveSelection(event.key === "ArrowUp" ? "up" : "down")) { + event.preventDefault(); + } + return; + } + if (event.key !== "Delete" && event.key !== "Backspace") return; + if (selected === "input" || selected === "output") { + event.preventDefault(); + onRemoveEnd(selected); + return; + } + if (chosen.length === 0) return; + event.preventDefault(); + onRemoveSteps(chosen); + } + + return ( +
    +
    + {edges.map((edge) => ( + + ))} + + {draggingIndex !== null && edges.length > 0 && ( +

    + {t("portal.pipelines.graph.dragHint")} +

    + )} + + {nodes.map((node) => { + const style = { + left: `${node.x}px`, + top: `${node.y}px`, + width: `${NODE_WIDTH}px`, + minHeight: `${NODE_HEIGHT}px`, + }; + if (node.kind === "placeholder") { + return ( +
    + onInsertStep(0)} + /> +
    + ); + } + if (node.kind === "input" || node.kind === "output") { + const kind = node.kind; + const content = kind === "input" ? input : output; + return ( +
    + {content === null ? ( + onAddEnd(kind)} + /> + ) : ( + onSelect(kind)} + onRemove={() => onRemoveEnd(kind)} + /> + )} +
    + ); + } + const index = node.stepIndex ?? 0; + return ( +
    + selectStep(index, event)} + onRemove={() => onRemoveSteps([index])} + onDragChange={(dragging) => + setDraggingIndex(dragging ? index : null) + } + onOpenRunState={ + steps[index].runState === "failed" && onOpenStepError + ? () => onOpenStepError(index) + : undefined + } + /> +
    + ); + })} +
    +
    + ); +} + +interface ChainStepNodeProps { + index: number; + step: GraphStepContent; + selected: boolean; + dragging: boolean; + /** The steps this node's drag carries: the selection when it is part of it, else just itself. */ + moving: number[]; + onSelect: (event: ReactMouseEvent) => void; + onRemove: () => void; + onDragChange: (dragging: boolean) => void; + onOpenRunState?: () => void; +} + +/** A step node plus its drag wiring, which needs a hook per node and so a component per node. */ +function ChainStepNode({ + index, + step, + selected, + dragging, + moving, + onSelect, + onRemove, + onDragChange, + onOpenRunState, +}: ChainStepNodeProps) { + const { ref, guardClick } = useStepDraggable({ moving, onDragChange }); + return ( + + ); +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts new file mode 100644 index 0000000000..d03e6b3c42 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "vitest"; +import { + EDGE_LENGTH, + NODE_HEIGHT, + NODE_WIDTH, + layoutChain, + reorderMany, + stepIndexOf, + stepNodeId, +} from "@portal/components/pipelines/graph/pipelineLayout"; + +describe("layoutChain", () => { + test("an empty chain reserves the first step's row for the placeholder", () => { + const { nodes, edges } = layoutChain({ stepCount: 0 }); + expect(nodes.map((n) => n.kind)).toEqual([ + "input", + "placeholder", + "output", + ]); + // The placeholder is the affordance, so neither wire around it offers a plus as well. + expect(edges.map((e) => e.insertIndex)).toEqual([null, null]); + }); + + test("the empty chain is as tall as a one-step chain", () => { + expect(layoutChain({ stepCount: 0 }).height).toBe( + layoutChain({ stepCount: 1 }).height, + ); + }); + + test("steps sit between input and output, in order", () => { + const { nodes } = layoutChain({ stepCount: 3 }); + expect(nodes.map((n) => n.id)).toEqual([ + "input", + "step:0", + "step:1", + "step:2", + "output", + ]); + expect(nodes.map((n) => n.stepIndex)).toEqual([null, 0, 1, 2, null]); + }); + + test("rows are evenly pitched down one column", () => { + const { nodes, width } = layoutChain({ stepCount: 2 }); + expect(nodes.every((n) => n.x === 0)).toBe(true); + const ys = nodes.map((n) => n.y); + const pitch = NODE_HEIGHT + EDGE_LENGTH; + expect(ys).toEqual([0, pitch, pitch * 2, pitch * 3]); + expect(width).toBe(NODE_WIDTH); + }); + + test("the canvas is tall enough for the last node", () => { + const { nodes, height } = layoutChain({ stepCount: 4 }); + const last = nodes[nodes.length - 1]; + expect(height).toBe(last.y + NODE_HEIGHT); + }); + + test("wires span exactly from one node's bottom border to the next node's top", () => { + const { nodes, edges } = layoutChain({ stepCount: 1 }); + expect(edges).toHaveLength(2); + for (const edge of edges) { + expect(edge.x).toBe(NODE_WIDTH / 2); + expect(edge.y2 - edge.y1).toBe(EDGE_LENGTH); + } + expect(edges[0].y1).toBe(nodes[0].y + NODE_HEIGHT); + expect(edges[0].y2).toBe(nodes[1].y); + }); + + test("each wire opens the slot it sits above", () => { + const { edges } = layoutChain({ stepCount: 3 }); + // input->0, 0->1, 1->2, 2->output + expect(edges.map((e) => e.insertIndex)).toEqual([0, 1, 2, 3]); + }); + + test("every wire between real nodes stays open", () => { + // Ordering is the user's to choose: no pairing is refused, however odd it is. + const { edges } = layoutChain({ stepCount: 4 }); + expect(edges.every((e) => e.insertIndex !== null)).toBe(true); + }); +}); + +describe("node ids", () => { + test("step ids round-trip through their index", () => { + expect(stepIndexOf(stepNodeId(7))).toBe(7); + }); + + test("the input and output nodes have no step index", () => { + expect(stepIndexOf("input")).toBeNull(); + expect(stepIndexOf("output")).toBeNull(); + }); +}); + +describe("reorderMany", () => { + test("moving one step below its own place accounts for it lifting out first", () => { + // [a b c], drag a onto the wire above c (slot 2) -> [b a c]. + expect(reorderMany(3, [0], 2)).toEqual([1, 0, 2]); + }); + + test("moving one step above its own place lands on the slot as given", () => { + // [a b c], drag c onto the wire above b (slot 1) -> [a c b]. + expect(reorderMany(3, [2], 1)).toEqual([0, 2, 1]); + }); + + test("the wires either side of a lone step are no-ops", () => { + expect(reorderMany(3, [1], 1)).toBeNull(); + expect(reorderMany(3, [1], 2)).toBeNull(); + }); + + test("moves to either end", () => { + expect(reorderMany(3, [2], 0)).toEqual([2, 0, 1]); + expect(reorderMany(3, [0], 3)).toEqual([1, 2, 0]); + }); + + test("a set of steps lands together, keeping its own order", () => { + // [a b c d], move a+c to the end -> [b d a c]. + expect(reorderMany(4, [0, 2], 4)).toEqual([1, 3, 0, 2]); + }); + + test("a set gathers from apart into one run", () => { + // [a b c d e], move a+e above c (slot 2) -> [b a e c d]. + expect(reorderMany(5, [0, 4], 2)).toEqual([1, 0, 4, 2, 3]); + }); + + test("a contiguous set dropped back where it already is, is a no-op", () => { + expect(reorderMany(4, [1, 2], 1)).toBeNull(); + expect(reorderMany(4, [1, 2], 3)).toBeNull(); + }); + + test("order of the given indices does not matter", () => { + expect(reorderMany(4, [2, 0], 4)).toEqual(reorderMany(4, [0, 2], 4)); + }); + + test("moving every step is a no-op wherever it lands", () => { + expect(reorderMany(3, [0, 1, 2], 0)).toBeNull(); + expect(reorderMany(3, [2, 1, 0], 3)).toBeNull(); + }); + + test("nothing selected moves nothing", () => { + expect(reorderMany(3, [], 1)).toBeNull(); + }); + + test("ignores out-of-range indices rather than injecting undefined steps", () => { + // A stray index (negative or past the end) must be dropped, not carried into the new order. + expect(reorderMany(3, [0, 9], 2)).toEqual([1, 0, 2]); + expect(reorderMany(3, [-1], 2)).toBeNull(); + expect(reorderMany(3, [5], 0)).toBeNull(); + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts new file mode 100644 index 0000000000..10fadfb6f8 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/pipelineLayout.ts @@ -0,0 +1,184 @@ +/** + * Geometry for the pipeline graph. + * + * A pipeline is a strict sequence - one input, an ordered run of steps, one output - so a node's + * position carries no information that its place in the chain does not already carry. Layout is + * therefore *derived* here on every render rather than owned by the user and persisted: there are + * no stored coordinates to drift, nothing to lock, and nothing to re-tidy. Dragging a node is free + * to mean "move it in the chain" instead of "move it on screen" (see useChainDragDrop). + * + * Everything is a single centred column, which makes each wire a straight vertical line. When the + * model grows past one input/output and a pipeline can branch, x stops being constant and this is + * the module that changes - callers only ever read the result. + */ + +/** + * What a node represents. The chain always has exactly one input and one output. `placeholder` is + * the stand-in shown when a pipeline has no steps yet: it occupies the row the first step will take + * so the chain's shape is visible, and it is the affordance for adding that step. + */ +export type GraphNodeKind = "input" | "step" | "output" | "placeholder"; + +/** Node id: `"input"`, `"output"`, or `"step:"`. Stable for a given chain position. */ +export type GraphNodeId = string; + +export function stepNodeId(index: number): GraphNodeId { + return `step:${index}`; +} + +/** The step index a node id refers to, or null for the input/output nodes. */ +export function stepIndexOf(id: GraphNodeId): number | null { + const match = /^step:(\d+)$/.exec(id); + return match ? Number(match[1]) : null; +} + +export interface LaidOutNode { + id: GraphNodeId; + kind: GraphNodeKind; + /** Position in the chain's step list; null for input/output. */ + stepIndex: number | null; + /** Top-left corner, in canvas coordinates. */ + x: number; + y: number; +} + +export interface LaidOutEdge { + id: string; + from: GraphNodeId; + to: GraphNodeId; + /** + * Where a step dropped on this wire lands in the step list. Null only for the wires either side + * of the placeholder, which is itself the affordance for adding the first step. + */ + insertIndex: number | null; + /** Straight vertical wire, from the upper node's bottom port to the lower node's top port. */ + x: number; + y1: number; + y2: number; +} + +export interface LaidOutChain { + nodes: LaidOutNode[]; + edges: LaidOutEdge[]; + /** Canvas extent, so the scroll container can size itself without measuring. */ + width: number; + height: number; +} + +/** Node box, and the vertical room a wire plus its insert affordance needs between two of them. */ +export const NODE_WIDTH = 260; +export const NODE_HEIGHT = 64; +export const EDGE_LENGTH = 48; + +const ROW_PITCH = NODE_HEIGHT + EDGE_LENGTH; + +export interface LayoutChainOptions { + stepCount: number; +} + +/** + * Lay the chain out top to bottom: input, each step in order, output. Rows are evenly pitched and + * share one x, so wires are vertical and always aligned. + */ +export function layoutChain({ stepCount }: LayoutChainOptions): LaidOutChain { + const nodes: LaidOutNode[] = []; + const row = (index: number) => index * ROW_PITCH; + // An empty pipeline still shows a step row, filled by the placeholder, so the chain reads as + // input -> something -> output rather than as a bare wire. + const rows = Math.max(stepCount, 1); + + nodes.push({ id: "input", kind: "input", stepIndex: null, x: 0, y: row(0) }); + if (stepCount === 0) { + nodes.push({ + id: "placeholder", + kind: "placeholder", + stepIndex: null, + x: 0, + y: row(1), + }); + } + for (let i = 0; i < stepCount; i++) { + nodes.push({ + id: stepNodeId(i), + kind: "step", + stepIndex: i, + x: 0, + y: row(i + 1), + }); + } + nodes.push({ + id: "output", + kind: "output", + stepIndex: null, + x: 0, + y: row(rows + 1), + }); + + const centreX = NODE_WIDTH / 2; + const edges: LaidOutEdge[] = []; + for (let i = 0; i < nodes.length - 1; i++) { + const upper = nodes[i]; + const lower = nodes[i + 1]; + // A wire's insert index is the step slot it sits above: the wire below the input opens slot 0, + // the wire below step i opens slot i+1. A final-only step closes the wire beneath it. + const above = upper.stepIndex; + // The placeholder is itself the "add the first step" affordance, so the wires either side of it + // stay plain - two pluses for the same slot would be a choice with no difference. + const placeholderRow = + upper.kind === "placeholder" || lower.kind === "placeholder"; + const insertIndex = placeholderRow ? null : (above ?? -1) + 1; + edges.push({ + id: `${upper.id}->${lower.id}`, + from: upper.id, + to: lower.id, + insertIndex, + x: centreX, + // Exactly node-bottom to node-top: the wire meets both borders. The arrowhead is kept inside + // this box (see GraphEdge.css) so the node, drawn after it, cannot paint over the tip. + y1: upper.y + NODE_HEIGHT, + y2: lower.y, + }); + } + + return { + nodes, + edges, + width: NODE_WIDTH, + height: row(rows + 1) + NODE_HEIGHT, + }; +} + +/** + * The chain's new order after dropping `moving` on the wire that opens `insertIndex`. + * + * Returns the original step indices in their new positions, or null when the move changes nothing + * (dropping a step on either of its own wires, say). The moved steps land together in the target + * slot, keeping their order relative to each other; the slot is expressed against the *original* + * chain, so lifting the moved steps out first has to be accounted for - which is done by counting + * how many of the steps that stay put sit above the slot. + */ +export function reorderMany( + stepCount: number, + moving: readonly number[], + insertIndex: number, +): number[] | null { + // Range-check: a stray index would survive into `next` and then read as an undefined step, so + // keep only positions that exist in the chain before lifting anything out. + const lifted = [...new Set(moving)] + .filter((i) => i >= 0 && i < stepCount) + .sort((a, b) => a - b); + if (lifted.length === 0) return null; + + const staying: number[] = []; + for (let i = 0; i < stepCount; i++) { + if (!lifted.includes(i)) staying.push(i); + } + + const landing = staying.filter((i) => i < insertIndex).length; + const next = [ + ...staying.slice(0, landing), + ...lifted, + ...staying.slice(landing), + ]; + return next.every((value, i) => value === i) ? null : next; +} diff --git a/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts new file mode 100644 index 0000000000..1816f1a0ca --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + createDragClickGuard, + fillDragPreview, +} from "@portal/components/pipelines/graph/useChainDragDrop"; + +/** Stands in for the graph's rendered cards, which the preview clones out of the DOM. */ +function renderCards(labels: string[]) { + document.body.innerHTML = labels + .map( + (label, i) => + `
    ${label}
    `, + ) + .join(""); +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("fillDragPreview", () => { + it("stacks a copy of every dragged card, in the order given", () => { + renderCards(["OCR", "Redact", "Compress"]); + const container = document.createElement("div"); + fillDragPreview(container, [0, 2]); + expect([...container.children].map((c) => c.textContent)).toEqual([ + "OCR", + "Compress", + ]); + }); + + it("leaves the originals alone", () => { + renderCards(["OCR", "Redact"]); + fillDragPreview(document.createElement("div"), [0, 1]); + expect(document.querySelectorAll("[data-step-index]")).toHaveLength(2); + }); + + it("copies are solid, not dimmed like the cards they came from", () => { + renderCards(["OCR"]); + const container = document.createElement("div"); + fillDragPreview(container, [0]); + expect(document.querySelector("[data-step-index]")).toHaveClass( + "is-dragging", + ); + expect(container.firstElementChild).not.toHaveClass("is-dragging"); + }); + + it("skips an index with no card rather than throwing", () => { + renderCards(["OCR"]); + const container = document.createElement("div"); + fillDragPreview(container, [0, 7]); + expect(container.children).toHaveLength(1); + }); +}); + +// The drag itself needs native HTML5 drag events, which jsdom does not implement, so the guard's +// state machine is exercised here directly - it is the half that decides whether a click selects. +describe("createDragClickGuard", () => { + it("lets a plain press through", () => { + const guard = createDragClickGuard(); + guard.beginGesture(); + expect(guard.swallowsClick()).toBe(false); + }); + + it("swallows the click that trails a drag", () => { + const guard = createDragClickGuard(); + guard.beginGesture(); + guard.noteDrag(); + expect(guard.swallowsClick()).toBe(true); + }); + + it("still selects on the next press after a drag left no click behind", () => { + // The regression: native drag usually emits no trailing click, so a guard cleared only by + // consuming one stayed raised and ate the user's next real click on that node. + const guard = createDragClickGuard(); + guard.beginGesture(); + guard.noteDrag(); + // ...drop, and no click follows. + guard.beginGesture(); + expect(guard.swallowsClick()).toBe(false); + }); + + it("guards each drag, not just the first", () => { + const guard = createDragClickGuard(); + for (const _ of [1, 2]) { + guard.beginGesture(); + guard.noteDrag(); + expect(guard.swallowsClick()).toBe(true); + } + }); +}); diff --git a/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts new file mode 100644 index 0000000000..f80949d077 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/graph/useChainDragDrop.ts @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + draggable, + dropTargetForElements, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"; +import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source"; +import { + NODE_WIDTH, + reorderMany, +} from "@portal/components/pipelines/graph/pipelineLayout"; + +/** + * Drag-to-reorder for the pipeline chain. + * + * The chain is a sequence, so a step's meaningful move is "somewhere else in the order" - which + * makes the *wires* the drop targets, not the nodes. Each wire already knows the slot it opens + * (see layoutChain), so a drop is one call to reorderMany with that slot; there is no midpoint + * arithmetic or above/below bookkeeping, and no free coordinates to store. + */ + +const DRAG_TYPE = "pipeline-step"; + +interface StepDragData extends Record { + type: typeof DRAG_TYPE; + /** Every step this drag carries, in chain order - one, or the whole selection. */ + moving: number[]; +} + +function isStepDrag(data: Record): data is StepDragData { + return data.type === DRAG_TYPE && Array.isArray(data.moving); +} + +export interface UseStepDraggableOptions { + /** + * The steps this node's drag should carry: the current selection when this node is part of it, + * otherwise just itself. Resolved by the graph, which is what knows the selection. + */ + moving: number[]; + /** Told when this step's drag starts and ends, so the graph can light up the wires. */ + onDragChange: (dragging: boolean) => void; +} + +export interface UseStepDraggableResult { + ref: React.RefObject; + /** + * Wraps the node's click so the click that can trail a drag does not also select. Native drag + * usually swallows it, but the page editor carries the same guard - cheap insurance. + */ + guardClick: (action: (event: E) => void) => (event: E) => void; +} + +/** + * Tells a click that trails a drag apart from a genuine one. + * + * A gesture begins on pointerdown and may turn into a drag; only a click belonging to a gesture + * that dragged is swallowed. The clearing has to happen when the *next* gesture begins rather than + * when a click is swallowed - native HTML5 drag usually leaves no trailing click at all, so a flag + * cleared only by consuming one stays raised and eats the user's next real click on that node. + */ +export function createDragClickGuard() { + let dragged = false; + return { + /** A new press has started; nothing has dragged yet. */ + beginGesture: () => { + dragged = false; + }, + /** This gesture became a drag. */ + noteDrag: () => { + dragged = true; + }, + /** True if a click arriving now is the tail of a drag rather than a plain press. */ + swallowsClick: () => dragged, + }; +} + +export type DragClickGuard = ReturnType; + +/** + * Stack a copy of every dragged card into the preview container, so a multi-step drag shows what is + * actually moving rather than only the card that was grabbed. Exported for testing; the cards are + * found in the DOM by their chain position. + */ +export function fillDragPreview( + container: HTMLElement, + moving: readonly number[], +): void { + container.className = "portal-graph__drag-preview"; + container.style.width = `${NODE_WIDTH}px`; + for (const index of moving) { + const card = document.querySelector(`[data-step-index="${index}"]`); + if (!card) continue; + const copy = card.cloneNode(true) as HTMLElement; + // The originals dim once the drag starts; the copies are the drag, so they stay solid. + copy.classList.remove("is-dragging"); + container.appendChild(copy); + } +} + +/** Makes one step node draggable, tagged with the chain position it started from. */ +export function useStepDraggable({ + moving, + onDragChange, +}: UseStepDraggableOptions): UseStepDraggableResult { + const ref = useRef(null); + const guardRef = useRef(null); + guardRef.current ??= createDragClickGuard(); + const guard = guardRef.current; + + // Read through refs so a reorder (which renumbers every later step) never re-registers the + // adapter mid-gesture. + const movingRef = useRef(moving); + movingRef.current = moving; + const onDragChangeRef = useRef(onDragChange); + onDragChangeRef.current = onDragChange; + + useEffect(() => { + const element = ref.current; + if (!element) return; + // Any fresh input on the node starts a new gesture and clears the guard, so the only click it + // ever swallows is one trailing that same gesture's drag. Keyboard counts: activating the card + // with Enter or Space produces a click with no pointerdown before it. + const startGesture = () => guard.beginGesture(); + element.addEventListener("pointerdown", startGesture); + element.addEventListener("keydown", startGesture); + const stopDraggable = draggable({ + element, + getInitialData: (): StepDragData => ({ + type: DRAG_TYPE, + moving: movingRef.current, + }), + onGenerateDragPreview: ({ location, nativeSetDragImage }) => { + const moving = movingRef.current; + // One step drags as itself; the browser's own preview of the grabbed card is right. A set + // needs to show what is actually moving, so the preview stacks a copy of every card. + if (moving.length < 2) return; + setCustomNativeDragPreview({ + nativeSetDragImage, + getOffset: preserveOffsetOnSource({ + element, + input: location.current.input, + }), + render: ({ container }) => fillDragPreview(container, moving), + }); + }, + onDragStart: () => { + guard.noteDrag(); + onDragChangeRef.current(true); + }, + onDrop: () => onDragChangeRef.current(false), + }); + return () => { + element.removeEventListener("pointerdown", startGesture); + element.removeEventListener("keydown", startGesture); + stopDraggable(); + }; + }, [guard]); + + const guardClick = useCallback( + (action: (event: E) => void) => + (event: E) => { + if (guard.swallowsClick()) return; + action(event); + }, + [guard], + ); + + return { ref, guardClick }; +} + +export interface UseEdgeDropOptions { + /** The slot this wire opens; null for the wires either side of the empty-chain placeholder. */ + insertIndex: number | null; + stepCount: number; + /** + * Given the chain's new order as original step indices, and the original indices of the steps the + * drag actually carried - so the caller can keep the dragged steps selected rather than guessing + * from the prior selection. + */ + onReorder: (order: number[], moved: readonly number[]) => void; +} + +export interface UseEdgeDropResult { + ref: React.RefObject; + /** A step is hovering this wire and would land here. */ + over: boolean; +} + +/** Makes one wire a drop target that moves the dropped step into the slot the wire opens. */ +export function useEdgeDrop({ + insertIndex, + stepCount, + onReorder, +}: UseEdgeDropOptions): UseEdgeDropResult { + const ref = useRef(null); + const [over, setOver] = useState(false); + + const insertIndexRef = useRef(insertIndex); + insertIndexRef.current = insertIndex; + const stepCountRef = useRef(stepCount); + stepCountRef.current = stepCount; + const onReorderRef = useRef(onReorder); + onReorderRef.current = onReorder; + + useEffect(() => { + const element = ref.current; + if (!element) return; + return dropTargetForElements({ + element, + // A wire with no slot is not a target at all, so a step dragged over it shows no landing spot. + canDrop: ({ source }) => + insertIndexRef.current !== null && isStepDrag(source.data), + onDragEnter: () => setOver(true), + onDragLeave: () => setOver(false), + onDrop: ({ source }) => { + setOver(false); + const slot = insertIndexRef.current; + if (slot === null || !isStepDrag(source.data)) return; + const order = reorderMany( + stepCountRef.current, + source.data.moving, + slot, + ); + if (order !== null) onReorderRef.current(order, source.data.moving); + }, + }); + }, []); + + return { ref, over }; +} diff --git a/frontend/editor/src/portal/mocks/handlers/pipelines.ts b/frontend/editor/src/portal/mocks/handlers/pipelines.ts index f4c085ade7..0158d1e168 100644 --- a/frontend/editor/src/portal/mocks/handlers/pipelines.ts +++ b/frontend/editor/src/portal/mocks/handlers/pipelines.ts @@ -69,6 +69,29 @@ function seedPipelines(): StoredPolicy[] { output: { type: "inline", options: {} }, outputIds: ["src-contracts"], }, + { + // A chain long enough to overflow the builder's graph column, which is where the graph has to + // start scrolling instead of pushing the inspector off the page. + id: "plc-long", + name: "Full document pipeline", + owner: "ops@acme.com", + enabled: true, + inputs: [{ sourceId: "src-claims", trigger: null }], + steps: [ + { operation: "/api/v1/misc/repair", parameters: {} }, + { operation: "/api/v1/misc/ocr-pdf", parameters: {} }, + { operation: "/api/v1/general/rotate-pdf", parameters: {} }, + { operation: "/api/v1/general/crop", parameters: {} }, + { operation: "/api/v1/general/remove-pages", parameters: {} }, + { operation: "/api/v1/misc/add-page-numbers", parameters: {} }, + { operation: "/api/v1/security/add-watermark", parameters: {} }, + { operation: "/api/v1/security/sanitize-pdf", parameters: {} }, + { operation: "/api/v1/misc/flatten", parameters: {} }, + { operation: "/api/v1/misc/compress-pdf", parameters: {} }, + ], + output: { type: "inline", options: {} }, + outputIds: ["src-archive"], + }, { id: "plc-onboarding", name: "Onboarding OCR (paused)", diff --git a/frontend/editor/src/portal/views/PipelineBuilder.css b/frontend/editor/src/portal/views/PipelineBuilder.css index 128e8d4a1b..464d3fad55 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.css +++ b/frontend/editor/src/portal/views/PipelineBuilder.css @@ -1,3 +1,13 @@ +/** + * The builder claims the shell's view rather than lengthening it, so a long chain scrolls *inside* + * the graph while the header and the inspector stay put. A chain grows 112px per step, so it passes + * a typical viewport at around five steps - and if the page scrolled instead, clicking a node near + * the bottom would put the inspector (and Save) off-screen, which is the one interaction this whole + * layout exists to serve. + * + * The shell already makes .portal-shell__view the scroll container, so height here resolves against + * a definite box; capping the columns is what stops that view scrolling at all. + */ .portal-builder { display: flex; flex-direction: column; @@ -5,6 +15,14 @@ padding: 1.5rem; max-width: 84rem; margin: 0 auto; + height: 100%; + min-height: 0; +} + +/* The header and any banners keep their own size; only the grid absorbs (or gives up) space. Left + to the flex default they would all shrink together and squash on a short viewport. */ +.portal-builder > *:not(.portal-builder__grid) { + flex: none; } .portal-builder__loading { @@ -13,254 +31,38 @@ padding: 4rem 0; } -/* Header */ -.portal-builder__head { - display: flex; - align-items: center; - gap: 0.75rem; - flex-wrap: wrap; - padding-bottom: 1rem; - border-bottom: 1px solid var(--c-border-subtle); -} - -.portal-builder__back { - display: inline-flex; - align-items: center; - gap: 0.25rem; - border: none; - background: none; - padding: 0; - font-size: 0.8125rem; - color: var(--c-text-subtle); - cursor: pointer; - white-space: nowrap; -} - -.portal-builder__back:hover { - color: var(--c-text); -} - -.portal-builder__head-main { - flex: 1; - min-width: 12rem; -} - -.portal-builder__head-actions { - display: flex; - align-items: center; - gap: 0.75rem; -} - /* Two-pane layout */ .portal-builder__grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1.25rem; + /* Takes whatever the header leaves, and pins the row to exactly that. `minmax(0, 1fr)` rather + than the implicit `auto` row is what makes the columns' `max-height: 100%` mean anything: an + auto row is sized BY its tallest item, so a long settings form would size the row to itself and + then resolve its own 100% against it - capping nothing, and clipping the form with no scrollbar. + `align-items: start` still lets a short column hug its content inside the bounded row. */ + grid-template-rows: minmax(0, 1fr); + flex: 1 1 auto; + min-height: 0; align-items: start; } +/* Stacked, the inspector sits below the graph, so there is nothing to hold in view - and capping + here would nest a scroll region inside a scrolling page, which is worse than a long page. Let the + builder grow and hand scrolling back to the shell. */ @media (max-width: 60rem) { + .portal-builder { + height: auto; + } + .portal-builder__grid { grid-template-columns: 1fr; + grid-template-rows: auto; + flex: none; } } -.portal-builder__flow { - display: flex; - flex-direction: column; - gap: 0.625rem; -} - -.portal-builder__section-label { - font-size: 0.6875rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--c-text-subtle); - font-weight: 600; -} - -.portal-builder__empty { - font-size: 0.8125rem; - color: var(--c-text-subtle); - margin: 0; - padding: 0.5rem 0; -} - -/* Step cards */ -.portal-builder__steps { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.portal-builder__step { - display: flex; - align-items: center; - gap: 0.5rem; - background: var(--c-surface); - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-lg); - padding: 0.5rem 0.625rem; - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} - -.portal-builder__step--active { - border-color: var(--c-primary); - background: var(--c-primary-tint); -} - -.portal-builder__step-main { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - gap: 0.625rem; - border: none; - background: none; - padding: 0.25rem; - text-align: left; - cursor: pointer; - color: inherit; -} - -.portal-builder__step-index { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.375rem; - height: 1.375rem; - flex-shrink: 0; - border-radius: 50%; - font-size: 0.6875rem; - font-weight: 600; - background: var(--c-primary-tint); - color: var(--c-primary); -} - -.portal-builder__step--active .portal-builder__step-index { - background: var(--c-primary); - color: #fff; -} - -.portal-builder__step-text { - display: flex; - flex-direction: column; - min-width: 0; -} - -.portal-builder__step-name { - font-size: 0.875rem; - font-weight: 500; - color: var(--c-text); -} - -.portal-builder__step-note { - font-size: 0.6875rem; - color: var(--c-text-subtle); -} - -/* A step that cannot run on what the one before it produces. */ -.portal-builder__step-note--danger { - color: var(--c-danger); -} - -/* A picker entry that cannot run on what the chain currently produces. */ -.portal-pipelines__picker-note { - margin-left: auto; - padding-left: 0.5rem; - font-size: 0.6875rem; - color: var(--c-text-subtle); -} - -.portal-builder__step-actions { - display: flex; - gap: 0.25rem; -} - -.portal-builder__step-actions button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - padding: 0; - border-radius: var(--radius-md); - border: 1px solid var(--c-border); - background: var(--c-surface); - color: var(--c-text-subtle); - cursor: pointer; - transition: - background var(--motion-fast), - color var(--motion-fast); -} - -.portal-builder__step-actions button:hover:not(:disabled) { - background: var(--c-hover); - color: var(--c-text); -} - -.portal-builder__step-actions button:disabled { - opacity: 0.4; - cursor: default; -} - -.portal-builder__add-step { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.375rem; - width: 100%; - padding: 0.625rem; - border: 1px dashed var(--c-border); - border-radius: var(--radius-lg); - background: none; - color: var(--c-text-subtle); - font-size: 0.8125rem; - cursor: pointer; - transition: - border-color var(--motion-fast), - color var(--motion-fast); -} - -.portal-builder__add-step:hover { - border-color: var(--c-primary); - color: var(--c-primary); -} - -/* Pipeline settings (above the operation list) */ -.portal-builder__settings { - display: flex; - flex-direction: column; - gap: 0.75rem; - background: var(--color-bg-subtle); - border: 1px solid var(--c-border-subtle); - border-radius: var(--radius-lg); - padding: 1.125rem; -} - -.portal-builder__settings-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); - gap: 1.25rem; -} - -.portal-builder__settings-col { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -/* Input and destination each span the full settings width so their row has room. */ -.portal-builder__inputs-col { - grid-column: 1 / -1; -} - -/* The input row (source + trigger + optional schedule) and the destination row. */ +/* The input's source dropdown and its edit affordance, in the inspector. */ .portal-builder__input-row { display: flex; flex-wrap: wrap; @@ -273,58 +75,23 @@ min-width: 10rem; } -/* The connect-source button trails to the end of the row. */ -.portal-builder__input-row > button:last-child { - margin-left: auto; -} - -/* Inspector: heading sits outside the card so it aligns with the operations heading. */ -.portal-builder__inspector-col { - position: sticky; - top: 1rem; - display: flex; - flex-direction: column; - gap: 0.625rem; -} - -/* Once the grid stacks (60rem), a sticky inspector would ride over content */ -@media (max-width: 60rem) { - .portal-builder__inspector-col { - position: static; - } -} - -.portal-builder__inspector { - background: var(--c-surface); - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - padding: 1.125rem; -} - /* Tool picker */ -.portal-pipelines__picker { - border: 1px solid var(--c-border); - border-radius: var(--radius-lg); - background: var(--c-surface); - overflow: hidden; +/* The picker fills the modal hosting it rather than sitting in a card of its own: same surface, + same radius, so a bordered box in here would frame nothing. Its rows carry the structure, and + they run to the panel's edges - which is what lets the search divider span the full width. */ +.portal-pipelines__picker-modal .sui-modal__body { + padding: 0; } +/* Holds the shared Input, which brings its own border/focus ring; the row just insets it and rules + it off from the list below. */ .portal-pipelines__picker-search { - display: flex; - align-items: center; - padding: 0.5rem 0.75rem; + padding: 0.75rem 0.75rem 0.625rem; border-bottom: 1px solid var(--c-border-subtle); } -.portal-pipelines__picker-search input { - flex: 1; - border: none; - background: none; - padding: 0; - font-size: 0.875rem; - color: var(--c-text); - outline: none; +.portal-pipelines__picker-search .sui-input { + width: 100%; } .portal-pipelines__picker-list { @@ -334,7 +101,7 @@ } .portal-pipelines__picker-group-label { - padding: 0.5rem 0.75rem 0.25rem; + padding: 0.5rem 1.125rem 0.25rem; font-size: 0.6875rem; color: var(--c-text-subtle); } @@ -346,7 +113,7 @@ width: 100%; border: none; background: none; - padding: 0.4375rem 0.75rem; + padding: 0.4375rem 1.125rem; text-align: left; cursor: pointer; color: var(--c-text); @@ -368,63 +135,52 @@ display: block; } +/* Name over its optional note - a two-line item, so the note reads as a sub-line rather than + running straight on from the name. */ +.portal-pipelines__picker-text { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; +} + .portal-pipelines__picker-name { font-size: 0.8125rem; } +/* Why this tool cannot follow the step before it. Advisory: the item is still pickable, just dimmed + and captioned so the reason is clear without shouting. */ +.portal-pipelines__picker-note { + font-size: 0.6875rem; + color: var(--c-text-muted); + white-space: normal; + line-height: 1.3; +} + +/* Muted via a token, not opacity: opacity on text drops the contrast below the floor. The name + still reads as de-emphasised, and the icon (not text) can take the opacity. */ +.portal-pipelines__picker-item.is-incompatible .portal-pipelines__picker-name { + color: var(--c-text-muted); +} + +.portal-pipelines__picker-item.is-incompatible .portal-pipelines__picker-icon { + opacity: 0.55; +} + .portal-pipelines__picker-empty { - padding: 1rem 0.75rem; + padding: 1rem 1.125rem; font-size: 0.8125rem; color: var(--c-text-subtle); margin: 0; } -/* The back link, step row, add-step affordance, tool-picker item and step - actions are the shared Button/ActionIcon carrying bespoke styling. Re-assert - their original look over the design-system button base (which otherwise - imposes a fixed height, its own padding/border and accent text colour). */ -.portal-builder__back.sui-btn { - height: auto; - min-height: 0; - padding: 0; - font-weight: 400; - font-size: 0.8125rem; - color: var(--c-text-subtle); -} - -.portal-builder__back.sui-btn:hover { - color: var(--c-text); -} - -.portal-builder__step-main.sui-btn { - flex: 1; - height: auto; - min-height: 0; - padding: 0.25rem; - font-weight: 400; - color: inherit; -} - -.portal-builder__add-step.sui-btn { - height: auto; - min-height: 0; - padding: 0.625rem; - border: 1px dashed var(--c-border); - background: none; - font-weight: 400; - font-size: 0.8125rem; - color: var(--c-text-subtle); -} - -.portal-builder__add-step.sui-btn:hover { - border-color: var(--c-primary); - color: var(--c-primary); -} - +/* The tool-picker item is the shared Button carrying bespoke styling, so re-assert its look over + the design-system base (which otherwise imposes a fixed height, its own padding and an accent + text colour). */ .portal-pipelines__picker-item.sui-btn { height: auto; min-height: 0; - padding: 0.4375rem 0.75rem; + padding: 0.4375rem 1.125rem; font-weight: 400; color: var(--c-text); } @@ -433,9 +189,39 @@ background: var(--c-hover); } -.portal-builder__step-actions .sui-ai { - width: 1.5rem; - height: 1.5rem; - min-width: 1.5rem; - min-height: 1.5rem; +/* A quiet way into the chosen source's own settings, beside its dropdown. */ +.portal-builder__input-row .portal-builder__source-edit { + color: var(--c-text-subtle); +} + +.portal-builder__input-row .portal-builder__source-edit:hover:not(:disabled) { + color: var(--c-accent-fg, var(--c-primary)); +} + +/* The input's schedule row, its number field, and a builder-owned muted line. These lived in + Pipelines.css and only rendered because the router bundles both views together; a code-split (or + any Storybook story of the builder alone) left them unstyled. Kept here so the builder is + self-contained. */ +.portal-builder__schedule { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.portal-builder__schedule-count { + width: 5rem; +} + +.portal-builder__muted { + font-size: 0.8125rem; + color: var(--c-text-subtle); + margin: 0; +} + +/* The space-between button row shared by the builder's modal footers. */ +.portal-builder__composer-footer { + display: flex; + justify-content: space-between; + gap: 0.5rem; + width: 100%; } diff --git a/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx index 95d2987e52..c204721c38 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.stories.tsx @@ -22,10 +22,21 @@ function withRoute(path: string) { const meta: Meta = { title: "Portal/Views/PipelineBuilder", component: PipelineBuilder, - parameters: { layout: "padded" }, - // The builder reads the tool registry (for step labels + settings UIs), so - // it needs this provider to render at all. + parameters: { layout: "fullscreen" }, decorators: [ + // The builder sizes itself against the shell's view - a fixed-height, non-scrolling box - which + // is what lets it cap its columns instead of lengthening the page. Given an auto-height parent + // its `height: 100%` resolves to nothing and the cap silently stops applying, so the story has + // to honour that contract or it reviews a layout the app never renders. + // Matches .portal-shell__view: a definite height with `auto` overflow, so the capped desktop + // layout has something to size against and the stacked layout can still scroll. + (Story) => ( +
    + +
    + ), + // The builder reads the tool registry (for step labels + settings UIs), so + // it needs this provider to render at all. (Story) => ( @@ -45,3 +56,12 @@ export const Default: Story = { export const Edit: Story = { decorators: [withRoute("/processor/pipelines/plc-redaction")], }; + +/** + * A chain taller than the page. The graph column scrolls on its own so the header and the inspector + * stay where they are - if the page scrolled instead, selecting a step near the end of the chain + * would carry its settings off-screen. + */ +export const LongChain: Story = { + decorators: [withRoute("/processor/pipelines/plc-long")], +}; diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx index e06025889e..24d215a99f 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx @@ -7,6 +7,8 @@ import { } from "@testing-library/react"; import { PortalTestProviders } from "@portal/test/TestQueryProvider"; import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { qk } from "@portal/queries/keys"; import type { Policy, TriggerOutcome } from "@portal/api/pipelines"; import type { SourceView } from "@portal/api/sources"; import type { ToolRegistryCatalog } from "@app/contexts/ToolRegistryContext"; @@ -61,22 +63,63 @@ vi.mock("@portal/api/integrations", () => ({ createIntegration: (...args: unknown[]) => createIntegration(...args), })); -// The destination picker just selects saved sources; stub it to a button that -// picks a fixed source, keeping this suite focused on the builder. +// The destination picker just selects saved sources; stub its three affordances +// (pick, create, edit) to buttons, keeping this suite focused on the builder. vi.mock("@portal/components/pipelines/DestinationPicker", () => ({ DestinationPicker: ({ value, onChange, + onCreateNew, + onEdit, }: { value: string[]; onChange: (ids: string[]) => void; + onCreateNew: () => void; + onEdit: (sourceId: string) => void; }) => ( - + <> + + + + ), })); +// The source modal has its own suite; stub it to the two things the builder +// depends on - the record it was opened on, and the sources-cache invalidation +// that follows a save (which is how a new source reaches the pickers). +vi.mock("@portal/components/sources/SourceModal", () => ({ + SourceModal: ({ + open, + sourceId, + }: { + open: boolean; + sourceId?: string | null; + }) => { + const queryClient = useQueryClient(); + if (!open) return null; + return ( +
    + source-modal:{sourceId || "new"} + +
    + ); + }, +})); + // One editable tool, Compress, so the picker and step settings have something to render. vi.mock("@app/contexts/ToolRegistryContext", () => { const compress = { @@ -129,9 +172,41 @@ vi.mock("@app/contexts/ToolRegistryContext", () => { fromApiParams: (params: Record) => ({ ...params }), }, } as unknown as ToolRegistryEntry; + // A tool that will not run on its defaults: its validateParams is the same predicate its own Run + // button uses, so a step for it is "unconfigured" until a language is chosen. + const ocr = { + name: "OCR", + icon: null, + component: null, + description: "", + categoryId: "recommendedTools", + subcategoryId: "general", + automationSettings: (props: { + onParameterChange: (key: string, value: unknown) => void; + }) => ( + + ), + operationConfig: { + operationType: "ocr", + toolType: 0, + endpoint: "/api/v1/misc/ocr-pdf", + defaultParameters: { languages: [] }, + validateParams: (params: { languages?: string[] }) => + (params.languages ?? []).length > 0, + buildFormData: () => new FormData(), + toApiParams: (params: Record) => ({ ...params }), + fromApiParams: (params: Record) => ({ ...params }), + }, + } as unknown as ToolRegistryEntry; const allTools = { compress, extractImages, + ocr, } as unknown as ToolRegistryCatalog["allTools"]; const catalog: ToolRegistryCatalog = { regularTools: allTools, @@ -215,8 +290,44 @@ describe("PipelineBuilder", () => { createIntegration.mockReset(); }); - // Choose the given source in the (pre-seeded) input row's dropdown. + // The settings of a node are reached by selecting it in the graph, so every helper below opens + // its node first. Nodes are found by position rather than by label, because a node's title is + // its current value - it changes as the pipeline is filled in. + + /** The graph's selectable nodes, in chain order: input, each step, output. */ + function graphNodes(): HTMLElement[] { + return screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-pressed")); + } + + /** + * The control that opens an end of the chain, once the graph has rendered. A new pipeline has not + * placed its ends yet, so that control is the "add" placeholder and clicking it both puts the node + * on the chain and selects it; a loaded pipeline already has the node, so it is a plain select. + */ + function endOpener(end: "input" | "output"): Promise { + return waitFor(() => { + const placeholder = screen.queryByText( + `portal.pipelines.graph.add.${end}`, + ); + if (placeholder) return placeholder; + const nodes = graphNodes(); + if (nodes.length === 0) throw new Error("the graph has not rendered yet"); + return end === "input" ? nodes[0] : nodes[nodes.length - 1]; + }); + } + + async function openInput() { + fireEvent.click(await endOpener("input")); + } + + async function openOutput() { + fireEvent.click(await endOpener("output")); + } + async function pickInputSource(sourceName: string) { + await openInput(); fireEvent.click( await screen.findByRole("textbox", { name: "portal.pipelines.builder.inputSource", @@ -225,24 +336,147 @@ describe("PipelineBuilder", () => { fireEvent.click(await screen.findByText(sourceName)); } - it("always shows exactly one input row, with no add or remove controls", async () => { + /** + * Add a tool. An empty chain offers the placeholder; once it has steps, the wires carry the + * inserts instead. + */ + async function addTool(toolName: string) { + const placeholder = screen.queryByText( + "portal.pipelines.graph.addFirstTool", + ); + if (placeholder) { + fireEvent.click(placeholder); + } else { + // The LAST wire, so repeated calls append. Taking the first would insert each new tool ahead + // of the ones already there, silently reversing the order a caller asked for. + const inserts = screen.getAllByLabelText( + "portal.pipelines.graph.insertHere", + ); + fireEvent.click(inserts[inserts.length - 1]); + } + fireEvent.click(await screen.findByText(toolName)); + } + + async function pickDestination() { + await openOutput(); + fireEvent.click(await screen.findByText("pick output")); + } + + /** Open the header's overflow tray. */ + async function openTray() { + fireEvent.click( + await screen.findByLabelText("portal.pipelines.builder.moreActions"), + ); + } + + it("greets a new pipeline with places to fill, not problems to fix", async () => { renderBuilder("/processor/pipelines/new"); - // The input row is a fixed part of the form: its source dropdown is present from the - // start, and there is nothing to add or remove. + // Both ends offer to be added rather than complaining about being empty: the user has not been + // asked for a source or a destination yet, so there is nothing yet to warn them about. expect( - await screen.findAllByRole("textbox", { + await screen.findByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.graph.add.output"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsSource"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsDestination"), + ).not.toBeInTheDocument(); + // Nothing is on the chain, so there is nothing to remove either. + expect( + screen.queryByLabelText(/portal.pipelines.graph.removeNode/), + ).not.toBeInTheDocument(); + }); + + it("warns on a step whose tool cannot run on its defaults", async () => { + renderBuilder("/processor/pipelines/new"); + await addTool("OCR"); + + // The tool declares its own mandatory parameters, so the node says so without the builder + // knowing anything about OCR. + expect( + await screen.findByText("portal.pipelines.builder.needsConfiguring"), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.pipelines.composer.create").closest("button"), + ).toBeDisabled(); + }); + + it("clears the warning once the step is configured", async () => { + renderBuilder("/processor/pipelines/new"); + await addTool("OCR"); + await screen.findByText("portal.pipelines.builder.needsConfiguring"); + + // Adding a step selects it, so its settings are already open. + fireEvent.click(screen.getByText("pick language")); + + await waitFor(() => + expect( + screen.queryByText("portal.pipelines.builder.needsConfiguring"), + ).not.toBeInTheDocument(), + ); + }); + + it("leaves a tool that runs happily on its defaults unwarned", async () => { + renderBuilder("/processor/pipelines/new"); + await addTool("Compress"); + + expect( + await screen.findByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsConfiguring"), + ).not.toBeInTheDocument(); + }); + + it("only asks for a source once the user has asked for the node", async () => { + renderBuilder("/processor/pipelines/new"); + await openInput(); + + // Placing the node is what turns it into an outstanding choice. + expect( + await screen.findByText("portal.pipelines.builder.needsSource"), + ).toBeInTheDocument(); + // Still nothing chosen, so the pipeline cannot be saved. + expect( + screen.getByText("portal.pipelines.composer.create").closest("button"), + ).toBeDisabled(); + }); + + it("puts an end back to a placeholder when it is removed", async () => { + renderBuilder("/processor/pipelines/new"); + await openInput(); + await screen.findByText("portal.pipelines.builder.needsSource"); + + fireEvent.click(screen.getByLabelText("portal.pipelines.graph.removeNode")); + + expect( + await screen.findByText("portal.pipelines.graph.add.input"), + ).toBeInTheDocument(); + expect( + screen.queryByText("portal.pipelines.builder.needsSource"), + ).not.toBeInTheDocument(); + }); + + it("edits a node's settings only once it is selected", async () => { + renderBuilder("/processor/pipelines/new"); + await screen.findByText("portal.pipelines.graph.add.input"); + + // Nothing selected: the inspector says so rather than showing a form. + expect( + screen.getByText("portal.pipelines.inspector.noSelectionTitle"), + ).toBeInTheDocument(); + + await openInput(); + expect( + screen.getByRole("textbox", { name: "portal.pipelines.builder.inputSource", }), - ).toHaveLength(1); - expect( - screen.queryByText("portal.pipelines.builder.addInput"), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("button", { - name: "portal.pipelines.builder.removeInput", - }), - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); }); it("builds a new pipeline: name it, add a tool, an input, a destination, and save", async () => { @@ -257,12 +491,11 @@ describe("PipelineBuilder", () => { }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Compress"); // A pipeline must have at least one input source and one output destination. await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); fireEvent.click(screen.getByText("portal.pipelines.composer.create")); @@ -291,13 +524,11 @@ describe("PipelineBuilder", () => { { target: { value: "Broken chain" } }, ); await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); // Extract images emits images; compress only takes a PDF, so it can never run. - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Extract images")); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Extract images"); + await addTool("Compress"); expect( await screen.findByText("portal.pipelines.builder.stepsIncompatible"), @@ -307,6 +538,21 @@ describe("PipelineBuilder", () => { ).toBeDisabled(); }); + it("says why on the wire arriving at the step that cannot run", async () => { + renderBuilder("/processor/pipelines/new"); + await pickInputSource("Claims intake"); + await pickDestination(); + + await addTool("Extract images"); + await addTool("Compress"); + + // The banner names which steps are at fault; the wire explains what is wrong where it happens. + const note = await screen.findByText( + /portal\.pipelines\.builder\.diagnostic\./, + ); + expect(note.closest(".portal-graph-edge")).toHaveClass("is-blocking"); + }); + it("allows a chain whose steps line up", async () => { renderBuilder("/processor/pipelines/new"); @@ -317,10 +563,9 @@ describe("PipelineBuilder", () => { { target: { value: "Fine chain" } }, ); await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Compress"); expect( screen.queryByText("portal.pipelines.builder.stepsIncompatible"), @@ -352,7 +597,7 @@ describe("PipelineBuilder", () => { expect(saveButton()).toBeDisabled(); // Both chosen: allowed, and both are sent. - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); fireEvent.click(screen.getByText("portal.pipelines.composer.create")); await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1)); expect(savePipeline).toHaveBeenCalledWith( @@ -363,6 +608,130 @@ describe("PipelineBuilder", () => { ); }); + it("creates and edits sources in place through the modal", async () => { + renderBuilder("/processor/pipelines/new"); + await pickInputSource("Claims intake"); + + // Connect source opens the modal in create mode, without leaving the + // builder (and its unsaved edits) for the Sources page. + fireEvent.click(screen.getByText("portal.sources.actions.connectSource")); + expect(screen.getByText("source-modal:new")).toBeInTheDocument(); + expect(screen.queryByText("pipelines list")).not.toBeInTheDocument(); + + // The pencil beside the input opens the same modal on the chosen source. + fireEvent.click( + screen.getByLabelText("portal.pipelines.composer.editSource"), + ); + expect(screen.getByText("source-modal:src-in")).toBeInTheDocument(); + }); + + it("cannot edit an input source before one is chosen", async () => { + renderBuilder("/processor/pipelines/new"); + await openInput(); + + expect( + screen.getByLabelText("portal.pipelines.composer.editSource"), + ).toBeDisabled(); + await pickInputSource("Claims intake"); + expect( + screen.getByLabelText("portal.pipelines.composer.editSource"), + ).not.toBeDisabled(); + }); + + it("makes a source created from the input row the pipeline's input", async () => { + fetchSources + .mockResolvedValueOnce({ kpis: [], sources: [SOURCE] }) + .mockResolvedValue({ + kpis: [], + sources: [SOURCE, { ...SOURCE, id: "src-new", name: "Scanner drop" }], + }); + renderBuilder("/processor/pipelines/new"); + await openInput(); + + fireEvent.click(screen.getByText("portal.sources.actions.connectSource")); + fireEvent.click(screen.getByText("source saved")); + + // The new source is the one the pipeline was missing, so it becomes the input. + await waitFor(() => + expect( + screen.getByRole("textbox", { + name: "portal.pipelines.builder.inputSource", + }), + ).toHaveValue("Scanner drop"), + ); + }); + + it("makes a destination created from the picker the pipeline's output", async () => { + fetchSources + .mockResolvedValueOnce({ kpis: [], sources: [SOURCE] }) + .mockResolvedValue({ + kpis: [], + sources: [ + SOURCE, + { ...SOURCE, id: "src-new", name: "Archive bucket", type: "s3" }, + ], + }); + renderBuilder("/processor/pipelines/new"); + await openInput(); + openOutput(); + await screen.findByText("pick output"); + + fireEvent.click(screen.getByText("new destination")); + fireEvent.click(screen.getByText("source saved")); + + // Created from the destination picker, so it lands in the output rather + // than the input. + await waitFor(() => + expect(screen.getByText("output:src-new")).toBeInTheDocument(), + ); + // The input was left alone: its node still shows the prompt. + expect( + screen.getByText("portal.pipelines.builder.chooseSource"), + ).toBeInTheDocument(); + }); + + it("leaves a new source that cannot be written to out of the destination", async () => { + // A webhook can be read from but not written to, so it must not be picked + // as a destination the dropdown has no option for. + fetchSources + .mockResolvedValueOnce({ kpis: [], sources: [SOURCE] }) + .mockResolvedValue({ + kpis: [], + sources: [ + SOURCE, + { ...SOURCE, id: "src-hook", name: "Partner hook", type: "webhook" }, + ], + }); + renderBuilder("/processor/pipelines/new"); + await openInput(); + openOutput(); + await screen.findByText("pick output"); + + fireEvent.click(screen.getByText("new destination")); + fireEvent.click(screen.getByText("source saved")); + + // It was not made the destination... + await waitFor(() => expect(fetchSources).toHaveBeenCalledTimes(2)); + expect(screen.getByText("pick output")).toBeInTheDocument(); + + // ...but it did arrive, and is offered as an input, where a webhook makes sense. + await openInput(); + fireEvent.click( + screen.getByRole("textbox", { + name: "portal.pipelines.builder.inputSource", + }), + ); + expect(await screen.findByText("Partner hook")).toBeInTheDocument(); + }); + + it("edits the chosen destination through the same modal", async () => { + renderBuilder("/processor/pipelines/new"); + await pickDestination(); + + fireEvent.click(screen.getByText("edit destination")); + expect(screen.getByText("source-modal:src-1")).toBeInTheDocument(); + }); + it("runs an existing pipeline and reports success", async () => { renderBuilder("/processor/pipelines/plc-1"); @@ -401,9 +770,8 @@ describe("PipelineBuilder", () => { it("clears processed history from the header and confirms", async () => { renderBuilder("/processor/pipelines/plc-1"); - fireEvent.click( - await screen.findByText("portal.pipelines.detail.clearHistory"), - ); + await openTray(); + fireEvent.click(screen.getByText("portal.pipelines.detail.clearHistory")); await waitFor(() => expect(clearProcessedHistory).toHaveBeenCalledWith("plc-1"), @@ -424,8 +792,7 @@ describe("PipelineBuilder", () => { target: { value: "Watermarked" }, }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click(await screen.findByText("Compress")); + await addTool("Compress"); // The tool's settings upload a file, which a stored pipeline can't persist yet. fireEvent.click(await screen.findByText("upload logo")); @@ -450,10 +817,7 @@ describe("PipelineBuilder", () => { target: { value: "Notify only" }, }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click( - await screen.findByText("portal.policies.operations.discordNotify.label"), - ); + await addTool("portal.policies.operations.discordNotify.label"); // Operation chosen, account not: still not saveable. expect( @@ -516,10 +880,7 @@ describe("PipelineBuilder", () => { }, ); - fireEvent.click(screen.getByRole("button", { name: /addTool/ })); - fireEvent.click( - await screen.findByText("portal.policies.operations.discordNotify.label"), - ); + await addTool("portal.policies.operations.discordNotify.label"); fireEvent.click( await screen.findByPlaceholderText( @@ -530,7 +891,7 @@ describe("PipelineBuilder", () => { // Saving needs the input's source and a destination. await pickInputSource("Claims intake"); - fireEvent.click(screen.getByText("pick output")); + await pickDestination(); fireEvent.click(screen.getByText("portal.pipelines.composer.create")); diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx index d788fc4f35..ecc249d88e 100644 --- a/frontend/editor/src/portal/views/PipelineBuilder.tsx +++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx @@ -1,19 +1,15 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import KeyboardArrowUpRoundedIcon from "@mui/icons-material/KeyboardArrowUpRounded"; -import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded"; -import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; -import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; -import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded"; +import MoveToInboxRoundedIcon from "@mui/icons-material/MoveToInboxRounded"; +import SendRoundedIcon from "@mui/icons-material/SendRounded"; import { ActionIcon, Banner, Button, - Checkbox, - EmptyState, + FormField, Input, Modal, Select, @@ -47,11 +43,14 @@ import { deletePipeline, fetchPipeline, fetchRun, + fetchRunOutput, fetchTriggers, + runPipelineTest, savePipeline, triggerPipeline, type Policy, type PolicyRunView, + type RunOutputFile, type TriggerConfig, type TriggerInfo, type TriggerOutcome, @@ -61,14 +60,27 @@ import { DestinationPicker } from "@portal/components/pipelines/DestinationPicke import { availableOutputModes } from "@portal/components/pipelines/outputModes"; import { type SourceView } from "@portal/api/sources"; import { useSources } from "@portal/queries/sources"; +import { SourceModal } from "@portal/components/sources/SourceModal"; import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes"; import { useAsync } from "@portal/hooks/useAsync"; import { useQueryClient } from "@tanstack/react-query"; import { qk } from "@portal/queries/keys"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations"; +import { PipelineHeader } from "@portal/components/pipelines/PipelineHeader"; +import { PipelineInspector } from "@portal/components/pipelines/PipelineInspector"; +import { PipelineDefinitionModal } from "@portal/components/pipelines/PipelineDefinitionModal"; +import { + PipelineGraph, + selectedSteps, + type ChainEnd, + type ChainWarning, + type GraphSelection, + type GraphStepContent, +} from "@portal/components/pipelines/graph/PipelineGraph"; import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings"; import { ToolPicker } from "@portal/components/pipelines/ToolPicker"; +import { BrandMark } from "@portal/components/BrandMarks"; import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations"; import { integrationStepConfigured, @@ -158,6 +170,11 @@ function buildTriggerFor(input: WorkingInput): TriggerConfig | null { return { type: input.triggerType, options: {} }; } +/** Whether a source can be written to, i.e. offered as a pipeline destination. */ +function isWritableSource(source: SourceView): boolean { + return (availableOutputModes() as string[]).includes(source.type); +} + /** * Full-page pipeline builder (route: /pipelines/new and /pipelines/:id). Pipeline-level settings * (sources, trigger, output) sit above the operation list; the operation list and the selected @@ -206,10 +223,7 @@ export function PipelineBuilder() { // A destination is a source used as a write target: only writable types (folder/S3, filtered per // deployment) can be picked, and the virtual editor is already excluded from availableSources. const writableSources = useMemo( - () => - availableSources.filter((source) => - (availableOutputModes() as string[]).includes(source.type), - ), + () => availableSources.filter(isWritableSource), [availableSources], ); const triggers = useMemo( @@ -223,9 +237,23 @@ export function PipelineBuilder() { // wire shape stays a list (see save()). const [input, setInput] = useState(blankInput); const [steps, setSteps] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(null); - const [pickerOpen, setPickerOpen] = useState(false); + /** Which node the inspector is editing: an end of the chain, a step, or nothing. */ + const [selected, setSelected] = useState(null); + /** Slot the tool picker will insert into, or null when it is closed. */ + const [pickerAt, setPickerAt] = useState(null); + const [definitionOpen, setDefinitionOpen] = useState(false); + /** The last test run in this session: one file through the steps as they stand. */ + const [testRun, setTestRun] = useState(null); + const [testing, setTesting] = useState(false); const [outputIds, setOutputIds] = useState([]); + /** + * Whether the user has asked for each end of the chain yet, distinguishing "not offered" from + * "offered and still owed a choice" - the two states an empty sourceId cannot tell apart. Only a + * brand new pipeline starts with either false; anything loaded arrives with both ends set, and + * choosing one places it, so these are just the "clicked add, chosen nothing" window. + */ + const [inputAsked, setInputAsked] = useState(false); + const [outputAsked, setOutputAsked] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [seeded, setSeeded] = useState(false); @@ -236,6 +264,39 @@ export function PipelineBuilder() { const [deleting, setDeleting] = useState(false); const [pendingNav, setPendingNav] = useState(null); + // Create or edit a source in place, instead of leaving the builder (and its + // unsaved edits) for the Sources page. + const [sourceModal, setSourceModal] = useState<{ + open: boolean; + sourceId: string | null; + }>({ open: false, sourceId: null }); + // A source created from here is the one the pipeline was missing, so select it + // on arrival - as the input or the destination, whichever asked for it. + const autoSelectRef = useRef<"input" | "output" | null>(null); + const knownSourceIdsRef = useRef>(new Set()); + useEffect(() => { + const target = autoSelectRef.current; + const known = knownSourceIdsRef.current; + knownSourceIdsRef.current = new Set(availableSources.map((s) => s.id)); + if (!target) return; + const fresh = availableSources.find((s) => !known.has(s.id)); + if (!fresh) return; + // One arrival answers the request, whatever type it turned out to be. + autoSelectRef.current = null; + if (target === "input") { + changeInputSource(fresh.id); + } else if (isWritableSource(fresh)) { + // A source of an unwritable type is left alone rather than becoming a + // destination the picker has no option for. + setOutputIds([fresh.id]); + } + }, [availableSources]); + + function createSourceFor(target: "input" | "output") { + autoSelectRef.current = target; + setSourceModal({ open: true, sourceId: null }); + } + const mounted = useRef(true); useEffect(() => { mounted.current = true; @@ -273,14 +334,6 @@ export function PipelineBuilder() { setSeeded(true); }, [isEdit, policyState.data, allTools, seeded]); - // Keep one tool's settings open: auto-select the first step whenever a pipeline has steps but - // nothing is selected (initial load, or after the selected step is removed). - useEffect(() => { - if (seeded && selectedIndex === null && steps.length > 0) { - setSelectedIndex(0); - } - }, [seeded, selectedIndex, steps.length]); - const sourceType = (sourceId: string) => availableSources.find((s) => s.id === sourceId)?.type; @@ -339,38 +392,65 @@ export function PipelineBuilder() { }); } - function addOperationStep(op: (typeof STEP_OPERATIONS)[number]) { + /** Put an end on the chain and open it, so the click that asks for it also offers the choice. */ + function addEnd(end: ChainEnd) { + if (end === "input") setInputAsked(true); + else setOutputAsked(true); + setSelected(end); + } + + /** Take an end back off, discarding whatever it held so its row reads as unfilled again. */ + function removeEnd(end: ChainEnd) { + if (end === "input") { + setInputAsked(false); + setInput(blankInput()); + } else { + setOutputAsked(false); + setOutputIds([]); + } + setSelected((current) => (current === end ? null : current)); + } + + /** Drop a new step into the slot the picker was opened on, and select it to be configured. */ + function insertStep(step: WorkingToolStep) { + const at = pickerAt ?? steps.length; setSteps((current) => { - const next = [...current, newIntegrationStep(op)]; - setSelectedIndex(next.length - 1); + const next = [...current]; + next.splice(at, 0, step); return next; }); - setPickerOpen(false); + setSelected({ steps: [at] }); + setPickerAt(null); + } + + function addOperationStep(op: (typeof STEP_OPERATIONS)[number]) { + insertStep(newIntegrationStep(op)); } function addStep(tool: ExecutableTool) { - setSteps((current) => { - const next = [...current, newWorkingToolStep(tool, allTools)]; - setSelectedIndex(next.length - 1); - return next; - }); - setPickerOpen(false); + insertStep(newWorkingToolStep(tool, allTools)); } - function removeStep(index: number) { - setSelectedIndex(null); - setSteps((current) => current.filter((_, i) => i !== index)); + function removeSteps(indices: number[]) { + const gone = new Set(indices); + setSelected(null); + setSteps((current) => current.filter((_, i) => !gone.has(i))); } - function moveStep(index: number, delta: number) { - setSteps((current) => { - const target = index + delta; - if (target < 0 || target >= current.length) return current; - const next = [...current]; - [next[index], next[target]] = [next[target], next[index]]; - return next; - }); - setSelectedIndex((cur) => (cur === index ? index + delta : cur)); + /** + * Apply a reordered chain, given as the original step indices in their new positions. The steps + * the drag carried stay selected where they land, so a set can be dragged again without re-picking + * it - and dragging an unselected step selects it, rather than leaving the inspector on whatever + * was selected before. + */ + function reorderSteps(order: number[], moved: readonly number[]) { + const moving = new Set(moved); + setSteps((current) => order.map((i) => current[i])); + const landed = order + .map((original, position) => ({ original, position })) + .filter(({ original }) => moving.has(original)) + .map(({ position }) => position); + setSelected(landed.length > 0 ? { steps: landed } : null); } function updateStepParams(index: number, params: ErasedToolParams) { @@ -396,6 +476,21 @@ export function PipelineBuilder() { return entry?.name ?? humanizeOperation(step.operation); } + /** + * A step's glyph, matching how the tool picker draws it: an integration step carries its vendor's + * mark, a tool step its own icon. Without this every node falls back to the generic slider glyph, + * so a chain reads as a stack of identical cards. + */ + function stepIcon(step: WorkingToolStep): ReactNode { + const op = stepOperation(step); + if (op) + return ( + + ); + if (isIntegrationStep(step)) return ; + return step.toolId ? allTools[step.toolId]?.icon : undefined; + } + // Steps whose params carry an uploaded file can't be saved: the bytes aren't persisted with the // policy, so a later run would send null for that field (see stepRequiresUpload). const uploadStepLabels = steps.filter(stepRequiresUpload).map(stepLabel); @@ -431,16 +526,22 @@ export function PipelineBuilder() { .map((d) => stepLabel(steps[d.stepIndex])); const hasIncompatibleSteps = hasBlockingDiagnostics(chainDiagnostics); - // What a newly added step would be handed, so the picker can flag tools that cannot take it. - const chainOutput = useMemo( + /** + * What a step added at the open slot would be handed, so the picker can flag tools that cannot + * take it. Scoped to the steps *before* that slot rather than the whole chain: the graph inserts + * anywhere, so what precedes the new step is not necessarily the chain's final output. + */ + const precedingOutput = useMemo( () => - chainOutputFormat( - steps.map((step) => ({ - operation: step.operation, - parameters: step.params, - })), - ), - [steps], + pickerAt === null + ? undefined + : chainOutputFormat( + steps.slice(0, pickerAt).map((step) => ({ + operation: step.operation, + parameters: step.params, + })), + ), + [steps, pickerAt], ); function diagnosticNote(diagnostic: ToolDiagnostic): string { @@ -451,26 +552,21 @@ export function PipelineBuilder() { }); } - /** The most severe diagnostic for a step, rendered as its note. */ - function renderStepDiagnostic(index: number) { + /** + * The step's most severe diagnostic, for the wire arriving at it - which is where a note about + * what the step is being handed belongs, rather than on the step itself. + */ + function stepInputWarning(index: number): ChainWarning | undefined { const forStep = diagnosticsForStep(chainDiagnostics, index); const diagnostic = forStep.find((d) => d.severity === "ERROR") ?? forStep.find((d) => d.severity === "WARN") ?? forStep[0]; - if (!diagnostic) return null; - return ( - - {diagnosticNote(diagnostic)} - - ); + if (!diagnostic) return undefined; + return { + text: diagnosticNote(diagnostic), + blocking: diagnostic.severity === "ERROR", + }; } // Track unsaved edits: snapshot the form and compare against the state captured just after @@ -505,7 +601,6 @@ export function PipelineBuilder() { !submitting; const listPath = toPortalPath(VIEW_PATHS.pipelines); - const sourcesPath = `${toPortalPath(VIEW_PATHS.sources)}/new`; function close() { navigate(listPath); @@ -517,12 +612,6 @@ export function PipelineBuilder() { else navigate(destination); } - // Jump to the source builder, for when the source you want to read from or write to doesn't - // exist yet. Inputs and the output destination are both saved sources, so both create one here. - function goToSources() { - attemptLeave(sourcesPath); - } - async function save(destination: string) { if (!canSave) return; setSubmitting(true); @@ -550,16 +639,69 @@ export function PipelineBuilder() { } // Poll a run until it reaches a terminal state (or we give up), so a failure surfaces. - async function awaitRun(runId: string): Promise { + async function awaitRun( + runId: string, + onProgress?: (view: PolicyRunView) => void, + ): Promise { for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { if (!mounted.current) return null; const view = await fetchRun(runId); + onProgress?.(view); if (TERMINAL_STATUSES.has(view.status)) return view; await sleep(POLL_INTERVAL_MS); } return null; } + /** + * Run the steps as they stand against one uploaded file. Output is forced inline so nothing + * reaches the pipeline's real destination, and the pipeline need not be saved first - this is + * how the chain gets checked while it is still being built. + */ + async function handleTest(file: File) { + if (testing) return; + setTesting(true); + setTestRun(null); + setRunResult(null); + try { + const { runId } = await runPipelineTest( + { + name: name.trim() || t("portal.pipelines.builder.testRun"), + steps: steps.map((step) => serializeToolStep(step, allTools)), + output: { type: "inline", options: {} }, + }, + file, + ); + const final = await awaitRun(runId, (view) => { + if (mounted.current) setTestRun(view); + }); + if (mounted.current && final) setTestRun(final); + } catch (e) { + if (mounted.current) + setRunResult({ tone: "danger", text: errorMessage(e) }); + } finally { + if (mounted.current) setTesting(false); + } + } + + /** Save one of a test run's outputs to disk. */ + async function downloadOutput(output: RunOutputFile) { + try { + const blob = await fetchRunOutput(output.fileId); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = output.fileName ?? output.fileId; + link.click(); + // Revoke on the next tick: some browsers have not yet begun reading the + // blob when click() returns, and revoking now would cancel the download. + setTimeout(() => URL.revokeObjectURL(url), 0); + } catch (e) { + if (mounted.current) + setRunResult({ tone: "danger", text: errorMessage(e) }); + } + } + /** Explain an empty trigger: parked files outrank blander reasons. */ function emptySweepResult(outcome: TriggerOutcome): RunResult { if (outcome.parked > 0) { @@ -669,95 +811,257 @@ export function PipelineBuilder() { ); } + const chosenSteps = selectedSteps(selected); + // One step selected means its settings; several means there is no single thing to configure. const selectedStep = - selectedIndex !== null ? (steps[selectedIndex] ?? null) : null; + chosenSteps.length === 1 ? (steps[chosenSteps[0]] ?? null) : null; - return ( -
    -
    - -
    - setName(e.target.value)} - /> -
    -
    - setEnabled(e.target.checked)} - label={t("portal.pipelines.builder.enabled")} - /> - {isEdit && ( + const chosenSource = availableSources.find((s) => s.id === input.sourceId); + const chosenDestination = writableSources.find((s) => s.id === outputIds[0]); + + /** How this input fires, in a few words, for the input node's summary line. */ + function triggerSummary(): string { + if (input.triggerType === MANUAL) + return t("portal.pipelines.composer.triggerManual"); + if (input.triggerType === "schedule") + // One counted phrase per unit, so it reads "Runs every hour" / "Runs every 3 hours" rather + // than the ungrammatical, untranslatable "Run every 1 hours". + return t( + `portal.pipelines.composer.runsEvery.${input.scheduleUnit.toLowerCase()}`, + { count: Number(input.scheduleCount) || 1 }, + ); + return t(`portal.pipelines.trigger.${input.triggerType}`, { + defaultValue: input.triggerType, + }); + } + + /** Why a step cannot be saved yet, if anything. */ + function stepWarning(step: WorkingToolStep): string | undefined { + if (isIntegrationStep(step)) { + if (!stepOperation(step)) + return t("portal.pipelines.builder.chooseOperation"); + if (!integrationStepConfigured(step)) + return t("portal.pipelines.builder.chooseAccount"); + return undefined; + } + if (stepRequiresUpload(step)) + return t("portal.pipelines.builder.needsUpload"); + if (stepNeedsConfiguring(step, allTools)) + return t("portal.pipelines.builder.needsConfiguring"); + return undefined; + } + + /** A step's one-line summary: what it will do beyond its name. */ + function stepDetail(step: WorkingToolStep): string | undefined { + if (step.support === "unsupported") + return t("portal.pipelines.builder.usesDefaults"); + if (step.support === "unknown") + return t("portal.pipelines.builder.unknownStep"); + return undefined; + } + + // A run reports one step cursor, so progress reads off it: everything before the cursor is done, + // the cursor itself is whatever the run currently is. + function stepRunState(index: number): GraphStepContent["runState"] { + if (!testRun) return undefined; + if (index < testRun.currentStep) return "done"; + if (index > testRun.currentStep) return undefined; + if (testRun.status === "FAILED") return "failed"; + if (testRun.status === "COMPLETED") return "done"; + return "running"; + } + + const graphSteps: GraphStepContent[] = steps.map((step, i) => ({ + label: stepLabel(step), + detail: stepDetail(step), + icon: stepIcon(step), + warning: stepWarning(step), + inputWarning: stepInputWarning(i), + runState: stepRunState(i), + })); + + const definitionJson = JSON.stringify( + { + name: name.trim(), + enabled, + inputs: [{ sourceId: input.sourceId, trigger: buildTriggerFor(input) }], + steps: steps.map((step) => serializeToolStep(step, allTools)), + outputIds, + }, + null, + 2, + ); + + const testSummary = + testRun === null + ? null + : { + status: + testRun.status === "FAILED" + ? ("failed" as const) + : testRun.status === "COMPLETED" + ? ("completed" as const) + : ("running" as const), + completedSteps: testRun.currentStep, + stepCount: testRun.stepCount, + error: testRun.error, + outputs: testRun.outputs ?? [], + }; + + /** The editor for whatever node is selected. Undefined when nothing is. */ + function inspectorBody() { + if (selected === "input") { + // Nothing to pick from yet: a dropdown of nothing helps no one, so offer only the way to make + // the first source. The trigger has no meaning without a source either, so it waits too. + const hasSources = availableSources.length > 0; + return ( + <> + {hasSources && ( <> - - - + +
    +
    + + updateInput({ + triggerType: + value && value !== MANUAL_OPTION ? value : MANUAL, + }) + } + options={triggerOptionsFor(input.sourceId)} + /> + + + {input.triggerType === "schedule" && ( +
    + + {t("portal.pipelines.composer.scheduleEvery")} + + + updateInput({ scheduleCount: e.target.value }) + } + className="portal-builder__schedule-count" + /> + changeInputSource(value ?? "")} - options={sourceOptions} - /> -
    -
    - - updateInput({ scheduleCount: e.target.value }) - } - className="portal-pipelines__schedule-count" - /> -
    } is no longer a row to a screen reader. That row control is then the + * keyboard path to the same action, so nothing is lost by leaving the row itself inert. + */ + rowsContainControls?: boolean; /** Rendered in place of the body when there are no rows. */ empty?: ReactNode; className?: string; @@ -42,6 +55,7 @@ export function Table({ rowKey, onRowClick, isRowInteractive, + rowsContainControls = false, empty, className, }: TableProps) { @@ -60,7 +74,11 @@ export function Table({ className={`sui-table__th sui-table__th--${c.align ?? "left"}`} style={c.width ? { width: c.width } : undefined} > - {c.header} + {c.headerHidden ? ( + {c.header} + ) : ( + c.header + )} ))} @@ -76,6 +94,9 @@ export function Table({ rows.map((row) => { const rowInteractive = interactive && (isRowInteractive?.(row) ?? true); + // Only a row that owns the whole interaction takes the button role and the keyboard + // handling that goes with it; see rowsContainControls. + const rowIsControl = rowInteractive && !rowsContainControls; return ( ({ : "sui-table__row" } onClick={rowInteractive ? () => onRowClick?.(row) : undefined} - tabIndex={rowInteractive ? 0 : undefined} - role={rowInteractive ? "button" : undefined} + tabIndex={rowIsControl ? 0 : undefined} + role={rowIsControl ? "button" : undefined} onKeyDown={ - rowInteractive + rowIsControl ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); diff --git a/frontend/editor/src/portal/api/fileRunEvents.test.ts b/frontend/editor/src/portal/api/fileRunEvents.test.ts new file mode 100644 index 0000000000..0e1729a891 --- /dev/null +++ b/frontend/editor/src/portal/api/fileRunEvents.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Contract tests for the api module itself. The component tests mock it wholesale, + * so they cannot catch mistakes in how it talks to the http client — a + * double-encoded body slipped through that gap once. These pin the call shape. + */ + +const json = vi.fn(); + +vi.mock("@portal/api/http", () => ({ + apiClient: { local: { json: (...args: unknown[]) => json(...args) } }, +})); + +const { applyFileRunEventAction, fetchFileRunEvents } = + await import("@portal/api/fileRunEvents"); + +describe("fileRunEvents api", () => { + beforeEach(() => json.mockReset()); + + describe("fetchFileRunEvents", () => { + it("requests the collection with no query when unfiltered", async () => { + json.mockResolvedValue({ events: [] }); + + await fetchFileRunEvents(); + + expect(json).toHaveBeenCalledWith("/api/v1/file-run-events"); + }); + + it("serialises only the filters that were supplied", async () => { + json.mockResolvedValue({ events: [] }); + + await fetchFileRunEvents({ status: "NEW", limit: 10 }); + + const [path] = json.mock.calls[0] as [string]; + const query = new URL(path, "http://localhost").searchParams; + expect(query.get("status")).toBe("NEW"); + expect(query.get("limit")).toBe("10"); + expect(query.has("kindId")).toBe(false); + }); + + it("never sends a team parameter, because the server derives it", async () => { + // The server derives the team, so asserted here to keep a param from creeping in. + json.mockResolvedValue({ events: [] }); + + await fetchFileRunEvents({ status: "NEW", kindId: "UNKNOWN", limit: 5 }); + + const [path] = json.mock.calls[0] as [string]; + expect(path).not.toMatch(/team/i); + }); + + it("unwraps the response envelope", async () => { + json.mockResolvedValue({ events: [{ id: "a" }] }); + + await expect(fetchFileRunEvents()).resolves.toEqual([{ id: "a" }]); + }); + + it("tolerates a response with no events array", async () => { + json.mockResolvedValue(undefined); + + await expect(fetchFileRunEvents()).resolves.toEqual([]); + }); + }); + + describe("applyFileRunEventAction", () => { + it("passes body as an object, not a pre-serialised string", async () => { + // The regression this file exists for: apiClient stringifies `body` + // itself, so a string here would arrive double-encoded. + json.mockResolvedValue({ id: "fre-1" }); + + await applyFileRunEventAction("fre-1", "ACKNOWLEDGE"); + + const [, options] = json.mock.calls[0] as [ + string, + { method: string; body: unknown }, + ]; + expect(typeof options.body).toBe("object"); + expect(options.body).toEqual({ inputs: {} }); + expect(options.method).toBe("POST"); + }); + + it("forwards collected inputs", async () => { + json.mockResolvedValue({ id: "fre-1" }); + + await applyFileRunEventAction("fre-1", "ACKNOWLEDGE", { + password: "hunter2", + }); + + const [, options] = json.mock.calls[0] as [string, { body: unknown }]; + expect(options.body).toEqual({ inputs: { password: "hunter2" } }); + }); + + it("encodes ids into the path so an odd id cannot break the URL", async () => { + json.mockResolvedValue({ id: "x" }); + + await applyFileRunEventAction("a/b?c", "ACKNOWLEDGE"); + + const [path] = json.mock.calls[0] as [string]; + expect(path).toBe( + "/api/v1/file-run-events/a%2Fb%3Fc/actions/ACKNOWLEDGE", + ); + }); + }); +}); diff --git a/frontend/editor/src/portal/api/fileRunEvents.ts b/frontend/editor/src/portal/api/fileRunEvents.ts new file mode 100644 index 0000000000..8b52c7d3fc --- /dev/null +++ b/frontend/editor/src/portal/api/fileRunEvents.ts @@ -0,0 +1,120 @@ +import { apiClient } from "@portal/api/http"; + +/** + * Recorded policy and pipeline failures, mirroring the backend `FileRunEventView`. + * + * Two properties of the contract: the server sends i18n keys plus an English + * `defaultTitle` rather than rendered copy, so a client can display a kind it was + * not built with; and each row's `actions` arrive already resolved, so rendering + * buttons needs no knowledge of the rules. + */ + +/** Where the fault lay. Widened server-side ahead of the kinds that need it. */ +export type FailureStage = + | "INPUT" + | "INTERNAL" + | "OUTPUT" + | "BLOCKED" + | "NEVER_RAN"; + +export type FailureSeverity = "ERROR" | "WARNING" | "INFO"; + +export type FailureRemedy = + | "TRANSIENT" + | "NEEDS_USER_INPUT" + | "NEEDS_FILE_FIX" + | "NEEDS_CONFIG_FIX" + | "NEEDS_SERVER_FIX" + | "PERMANENT"; + +export type FailureScope = "FILE" | "RUN" | "POLICY" | "SOURCE" | "SERVER"; + +export type FailureOrigin = "TOOL" | "POLICY" | "PIPELINE"; + +export type FileRunEventStatus = + | "NEW" + | "ACKNOWLEDGED" + | "DISMISSED" + | "RESOLVED"; + +/** + * One button as offered for one row. `id` is a plain string rather than a union + * because the server may know actions this build does not; the renderer skips them. + */ +export interface FailureActionOffer { + id: string; + labelKey: string; + enabled: boolean; + disabledReasonKey: string | null; +} + +export interface FileRunEvent { + id: string; + kindId: string; + stage: FailureStage; + severity: FailureSeverity; + scope: FailureScope; + origin: FailureOrigin; + remedy: FailureRemedy; + titleKey: string; + descriptionKey: string; + /** English fallback, used when this build has no translation for `titleKey`. */ + defaultTitle: string; + /** Raw failure message. For an unclassified failure this is the only detail. */ + detail: string | null; + policyId: string | null; + runId: string | null; + /** + * Opaque reference, never a name. Only the owner's own client can resolve it to + * something readable, from its local file store. + */ + fileId: string | null; + actor: string | null; + /** How many times this same failure has been seen; repeats fold into one row. */ + occurrences: number; + status: FileRunEventStatus; + statusActor: string | null; + actions: FailureActionOffer[]; + createdAt: number; + lastSeenAt: number; +} + +export interface FileRunEventsResponse { + events: FileRunEvent[]; +} + +export interface ListFileRunEventsParams { + status?: FileRunEventStatus; + kindId?: string; + limit?: number; +} + +/** GET /api/v1/file-run-events — the caller's team only; there is no team param. */ +export async function fetchFileRunEvents( + params: ListFileRunEventsParams = {}, +): Promise { + const query = new URLSearchParams(); + if (params.status) query.set("status", params.status); + if (params.kindId) query.set("kindId", params.kindId); + if (params.limit != null) query.set("limit", String(params.limit)); + const suffix = query.toString() ? `?${query}` : ""; + + const response = await apiClient.local.json( + `/api/v1/file-run-events${suffix}`, + ); + return response?.events ?? []; +} + +/** POST /api/v1/file-run-events/{id}/actions/{actionId} — returns the updated row. */ +export async function applyFileRunEventAction( + eventId: string, + actionId: string, + inputs: Record = {}, +): Promise { + return apiClient.local.json( + `/api/v1/file-run-events/${encodeURIComponent(eventId)}/actions/${encodeURIComponent(actionId)}`, + // A plain object, not a JSON string: apiClient serialises `body` itself, so + // pre-stringifying would double-encode it. + { method: "POST", body: { inputs } }, + ); +} diff --git a/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx b/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx index bff13d0c4f..e63ea925c4 100644 --- a/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx +++ b/frontend/editor/src/portal/components/documents/ReviewQueueTable.tsx @@ -71,8 +71,11 @@ export function ReviewQueueTable({ )} {d.sensitive && ( + // role="img" so the label is allowed and the icon reads as one thing: aria-label + // is ignored on a bare span, leaving the padlock silent. @@ -136,7 +139,8 @@ export function ReviewQueueTable({ }, { key: "actions", - header: "", + header: t("portal.documents.table.columns.actions"), + headerHidden: true, width: "3rem", render: (d) => ( + ); + })} + + ); +} diff --git a/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx b/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx new file mode 100644 index 0000000000..1458a1d64d --- /dev/null +++ b/frontend/editor/src/portal/components/failures/FileRunEventList.test.tsx @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render as baseRender, screen, waitFor } from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import type { FileRunEvent } from "@portal/api/fileRunEvents"; + +/** + * Tests for the list: the states it survives (loading, empty, no registry, refused), + * plus replacing a row in place after acting and re-reading when the server refuses. + */ + +const fetchFileRunEvents = vi.fn(); +const applyFileRunEventAction = vi.fn(); + +vi.mock("@portal/api/fileRunEvents", () => ({ + fetchFileRunEvents: (...args: unknown[]) => fetchFileRunEvents(...args), + applyFileRunEventAction: (...args: unknown[]) => + applyFileRunEventAction(...args), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + // Faithful to i18next: a known key resolves, an unknown key falls back to + // defaultValue. That is what exercises the server-key-then-generic chain. + t: (key: string, options?: { defaultValue?: string } | string) => { + const known: Record = { + "portal.failures.kind.inputPasswordProtected.title": + "Password-protected document", + "portal.failures.action.acknowledge": "Acknowledge", + "portal.failures.empty.title": "No failures recorded", + "portal.failures.occurrences": "occurrences", + "portal.failures.runReference": "Run r1", + "portal.failures.stage.input": "Input", + }; + if (known[key]) return known[key]; + if (typeof options === "string") return options; + if (options?.defaultValue) return options.defaultValue; + return key; + }, + }), +})); + +// The list reads through the shared query hooks, and @app/ui needs Mantine. +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: PortalTestProviders }); + +const { FileRunEventList } = + await import("@portal/components/failures/FileRunEventList"); + +function event(overrides: Partial = {}): FileRunEvent { + return { + id: "fre-1", + kindId: "INPUT_PASSWORD_PROTECTED", + stage: "INPUT", + severity: "ERROR", + scope: "FILE", + origin: "POLICY", + remedy: "NEEDS_USER_INPUT", + titleKey: "portal.failures.kind.inputPasswordProtected.title", + descriptionKey: "portal.failures.kind.inputPasswordProtected.description", + defaultTitle: "Password-protected document", + detail: "The PDF Document is passworded", + policyId: "p1", + runId: "r1", + fileId: "f-1", + actor: "dana@example.com", + occurrences: 1, + status: "NEW", + statusActor: null, + actions: [ + { + id: "ACKNOWLEDGE", + labelKey: "portal.failures.action.acknowledge", + enabled: true, + disabledReasonKey: null, + }, + ], + createdAt: 0, + lastSeenAt: 0, + ...overrides, + }; +} + +describe("FileRunEventList", () => { + beforeEach(() => { + fetchFileRunEvents.mockReset(); + applyFileRunEventAction.mockReset(); + // The dev-panel test stubs import.meta.env.DEV, which would otherwise persist + // into every test after it. + vi.unstubAllEnvs(); + }); + + it("renders a row's title, run reference and raw detail, but no document name", async () => { + fetchFileRunEvents.mockResolvedValue([event()]); + + render(); + + expect(await screen.findByText("Password-protected document")).toBeTruthy(); + // A run reference, not a document name: the record holds no file identity. + expect(screen.getByText("Run r1")).toBeTruthy(); + // The raw message is shown, not swallowed: for an unclassified failure it is + // the only diagnostic available. + expect(screen.getByText("The PDF Document is passworded")).toBeTruthy(); + }); + + it("shows the occurrence count only once a failure has repeated", async () => { + fetchFileRunEvents.mockResolvedValue([event({ occurrences: 1 })]); + const { unmount } = render(); + await screen.findByText("Run r1"); + expect(screen.queryByText(/occurrences/)).toBeNull(); + unmount(); + + fetchFileRunEvents.mockResolvedValue([event({ occurrences: 14 })]); + render(); + expect(await screen.findByText(/occurrences/)).toBeTruthy(); + }); + + it("shows an empty state when there is nothing to triage", async () => { + fetchFileRunEvents.mockResolvedValue([]); + + render(); + + expect(await screen.findByText("No failures recorded")).toBeTruthy(); + }); + + it("renders nothing when the server has no failure registry", async () => { + // A core-only build has no such route, which is not worth showing a reviewer as + // an error, so the section stays silent. + fetchFileRunEvents.mockRejectedValue(new Error("404")); + + const { container } = render(); + + await waitFor(() => { + expect(container.querySelector(".portal-failures__list")).toBeNull(); + }); + expect(screen.queryByText("No failures recorded")).toBeNull(); + }); + + it("renders no heading at all for a caller the server refuses", async () => { + // Reviewing is leader-only, so a member's read returns 403. With no dev panel to + // frame, the whole section goes rather than leaving a bare heading. + vi.stubEnv("DEV", false); + fetchFileRunEvents.mockRejectedValue(new Error("403")); + + const { container } = render(); + + await waitFor(() => { + expect(container.querySelector(".portal-failures")).toBeNull(); + }); + // No section, and specifically no heading: Mantine puts its + + +
    +
    + +
    +
    Stirling PDF
    +
    Draw Signature
    +
    +
    + +
    +
    Connecting…
    + +
    +
    + +
    + +
    +
    + + +
    +
    + + + +
    +
    + + +
    +
    + + + + + +

    Draw your signature above, then send it. It appears in the Sign tool on your computer automatically.

    +
    + + + +
    Stirling PDF · signatures transfer directly to your desktop
    +
    + + + + diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java index bfacb4d503..dc9c9538cd 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/MobileScannerControllerTest.java @@ -49,6 +49,33 @@ class MobileScannerControllerTest { when(systemProps.isEnableMobileScanner()).thenReturn(false); } + // --- shared-endpoint gating: scanner and mobile signature share this API --- + + @Test + void createSession_whenOnlyMobileSignatureEnabled_returnsOk() { + // The signature feature must work with the scanner switched off. + when(applicationProperties.getSystem()).thenReturn(systemProps); + when(systemProps.isEnableMobileScanner()).thenReturn(false); + when(systemProps.isEnableMobileSignature()).thenReturn(true); + SessionInfo sessionInfo = new SessionInfo("test-session", 1000L, 601000L, 600000L); + when(mobileScannerService.createSession("test-session")).thenReturn(sessionInfo); + + ResponseEntity> response = controller.createSession("test-session"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + void createSession_whenBothFeaturesDisabled_returnsForbidden() { + when(applicationProperties.getSystem()).thenReturn(systemProps); + when(systemProps.isEnableMobileScanner()).thenReturn(false); + when(systemProps.isEnableMobileSignature()).thenReturn(false); + + ResponseEntity> response = controller.createSession("test-session"); + + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + } + // --- createSession tests --- @Test diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index ed1b61e098..8ce96c2552 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4993,6 +4993,34 @@ uploadSuccess = "Upload Successful!" uploadSuccessMessage = "Your images have been transferred." validating = "Validating session..." +[mobileSign] +clear = "Clear" +invalidSession = "Session expired" +invalidSessionMessage = "This QR code is no longer valid. Open the Sign tool on your computer and scan the new code." +penSizeLabel = "Pen size" +send = "Send to computer" +sendAnother = "Send another signature" +sendError = "Could not send the signature. Check the connection and try again." +sentMessage = "Signature sent to your computer. You can send another or close this page." +tabsLabel = "Signature source" +undo = "Undo" +validating = "Checking session…" + +[mobileSign.photo] +fromGallery = "From gallery" +hint = "Photograph a signature on white paper, or choose an existing image." +invalidType = "Please choose an image file." +takePhoto = "Take a photo" + +[mobileSign.tab] +draw = "Draw" +photo = "Photo" +type = "Type" + +[mobileSign.type] +placeholder = "Your name" +previewPlaceholder = "Signature preview" + [mobileUpload] description = "Scan to upload photos. Images auto-convert to PDF." descriptionNoConvert = "Scan to upload photos from your mobile device." @@ -10254,6 +10282,7 @@ backgroundRemovalFailedTitle = "Background removal failed" hint = "Upload a PNG or JPG image of your signature" label = "Upload signature image" placeholder = "Select image file" +previewAlt = "Current image signature" processing = "Processing image..." removeBackground = "Remove white background (make transparent)" @@ -10267,6 +10296,17 @@ saved = "Select a saved signature above, then click anywhere on the PDF to place text = "After entering your name above, click anywhere on the PDF to place your signature." title = "How to add signature" +[sign.mobile] +createFromPhone = "Mobile upload" +description = "Scan this QR code with your phone or tablet, draw your signature, and it will appear here automatically." +error = "Connection Error" +expiryWarning = "QR Code Expiring Soon" +expiryWarningMessage = "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically." +instructions = "Open the camera app on your phone and scan this code. Keep this window open while you draw." +pollingError = "Error checking for the signature" +sessionCreateError = "Failed to create session" +title = "Draw on your phone" + [sign.mode] move = "Move Signature" pause = "Pause placement" diff --git a/frontend/editor/src/core/App.tsx b/frontend/editor/src/core/App.tsx index 6fd23e5510..81db0564b8 100644 --- a/frontend/editor/src/core/App.tsx +++ b/frontend/editor/src/core/App.tsx @@ -9,6 +9,7 @@ import HomePage from "@app/pages/HomePage"; import Onboarding from "@app/components/onboarding/Onboarding"; const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); +const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); // Import global styles import "@app/styles/tailwind.css"; @@ -42,6 +43,16 @@ export default function App() { } /> + {/* Mobile signature drawing - reached from the Sign tool QR code */} + + + + } + /> + {/* All other routes need AppProviders for backend integration */} string | null; + undo: () => void; + clear: () => void; +} + +interface Stroke { + color: string; + size: number; + points: Array<{ x: number; y: number }>; +} + +interface MobileDrawCanvasProps { + penColor: string; + penSize: number; + /** Fired when the canvas goes between empty and inked (gates Send/Undo). */ + onHasInkChange: (hasInk: boolean) => void; +} + +/** Padding kept around the ink when cropping the export, in CSS pixels. */ +const EXPORT_PADDING = 12; + +function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) { + const { points } = stroke; + if (points.length === 0) return; + + ctx.strokeStyle = stroke.color; + ctx.fillStyle = stroke.color; + ctx.lineWidth = stroke.size; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + if (points.length === 1) { + // A tap: render a dot, which a zero-length stroke would not show. + ctx.beginPath(); + ctx.arc(points[0].x, points[0].y, stroke.size / 2, 0, Math.PI * 2); + ctx.fill(); + return; + } + + // Quadratic midpoint smoothing: each point becomes the control point of a + // curve to the midpoint of the next segment, turning jagged pointer samples + // into a pen-like line. + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length - 1; i++) { + const midX = (points[i].x + points[i + 1].x) / 2; + const midY = (points[i].y + points[i + 1].y) / 2; + ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY); + } + const last = points[points.length - 1]; + ctx.lineTo(last.x, last.y); + ctx.stroke(); +} + +export const MobileDrawCanvas = forwardRef< + MobileDrawCanvasHandle, + MobileDrawCanvasProps +>(function MobileDrawCanvas({ penColor, penSize, onHasInkChange }, ref) { + const canvasRef = useRef(null); + const strokesRef = useRef([]); + const activeStrokeRef = useRef(null); + // Live styling for the stroke currently being drawn, without re-rendering + const penRef = useRef({ color: penColor, size: penSize }); + penRef.current = { color: penColor, size: penSize }; + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (!canvas || !ctx) return; + const dpr = window.devicePixelRatio || 1; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); + for (const stroke of strokesRef.current) drawStroke(ctx, stroke); + if (activeStrokeRef.current) drawStroke(ctx, activeStrokeRef.current); + }, []); + + // Match the backing store to the element's CSS size × devicePixelRatio, and + // re-match on resize/rotation. Strokes are CSS-space, so a redraw restores + // them at the new size. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.max(1, Math.round(rect.width * dpr)); + canvas.height = Math.max(1, Math.round(rect.height * dpr)); + redraw(); + }; + + resize(); + const observer = new ResizeObserver(resize); + observer.observe(canvas); + return () => observer.disconnect(); + }, [redraw]); + + const pointFromEvent = (e: React.PointerEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + }; + + const handlePointerDown = (e: React.PointerEvent) => { + // One stroke at a time: a second touch while drawing would scribble. + if (activeStrokeRef.current) return; + e.currentTarget.setPointerCapture(e.pointerId); + activeStrokeRef.current = { + color: penRef.current.color, + size: penRef.current.size, + points: [pointFromEvent(e)], + }; + redraw(); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + const stroke = activeStrokeRef.current; + if (!stroke) return; + // Coalesced events give the full sample train on high-rate digitizers, + // where the per-frame synthetic event alone would drop curvature. + const events = + "getCoalescedEvents" in e.nativeEvent + ? (e.nativeEvent as PointerEvent).getCoalescedEvents() + : [e.nativeEvent as PointerEvent]; + const rect = (e.currentTarget as HTMLCanvasElement).getBoundingClientRect(); + for (const ev of events) { + stroke.points.push({ + x: ev.clientX - rect.left, + y: ev.clientY - rect.top, + }); + } + redraw(); + }; + + const endStroke = () => { + const stroke = activeStrokeRef.current; + if (!stroke) return; + activeStrokeRef.current = null; + strokesRef.current = [...strokesRef.current, stroke]; + redraw(); + onHasInkChange(true); + }; + + useImperativeHandle(ref, () => ({ + exportPng: () => { + const strokes = strokesRef.current; + const canvas = canvasRef.current; + if (strokes.length === 0 || !canvas) return null; + + // Crop to the inked region so the signature stamps tightly, clamped to + // what was actually visible. + const rect = canvas.getBoundingClientRect(); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const stroke of strokes) { + const reach = stroke.size / 2 + EXPORT_PADDING; + for (const p of stroke.points) { + minX = Math.min(minX, p.x - reach); + minY = Math.min(minY, p.y - reach); + maxX = Math.max(maxX, p.x + reach); + maxY = Math.max(maxY, p.y + reach); + } + } + minX = Math.max(0, minX); + minY = Math.max(0, minY); + maxX = Math.min(rect.width, maxX); + maxY = Math.min(rect.height, maxY); + const width = Math.max(1, maxX - minX); + const height = Math.max(1, maxY - minY); + + const dpr = window.devicePixelRatio || 1; + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = Math.round(width * dpr); + exportCanvas.height = Math.round(height * dpr); + const ctx = exportCanvas.getContext("2d"); + if (!ctx) return null; + // Translate args are device pixels; scale args map stroke space to them. + ctx.setTransform(dpr, 0, 0, dpr, -minX * dpr, -minY * dpr); + for (const stroke of strokes) drawStroke(ctx, stroke); + return exportCanvas.toDataURL("image/png"); + }, + undo: () => { + strokesRef.current = strokesRef.current.slice(0, -1); + redraw(); + onHasInkChange(strokesRef.current.length > 0); + }, + clear: () => { + strokesRef.current = []; + activeStrokeRef.current = null; + redraw(); + onHasInkChange(false); + }, + })); + + return ( + + ); +}); diff --git a/frontend/editor/src/core/components/shared/MobileTransferModal.tsx b/frontend/editor/src/core/components/shared/MobileTransferModal.tsx new file mode 100644 index 0000000000..7a153c59fc --- /dev/null +++ b/frontend/editor/src/core/components/shared/MobileTransferModal.tsx @@ -0,0 +1,161 @@ +import { ReactNode } from "react"; +import { Modal, Stack, Text, Box, Alert } from "@mantine/core"; +import { QRCodeSVG } from "qrcode.react"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; +import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; +import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import { useMobileTransferSession } from "@app/hooks/useMobileTransferSession"; + +/** + * The QR modal shell every phone-to-desktop transfer feature shares: session + * lifecycle, QR code, expiry/error alerts, and the fallback URL line. A + * feature supplies its copy, its public route, and what a received file + * means — the scanner converts to PDF, the sign tool routes it to a + * signature source. + */ +interface MobileTransferModalProps { + opened: boolean; + onClose: () => void; + /** SPA route the phone opens, without slashes: "mobile-scanner", "mobile-sign". */ + routePath: string; + /** Called once per uploaded file; arrivals are untrusted, validate inside. */ + onFileReceived: (file: File) => void | Promise; + title: string; + description: string; + instructions: string; + expiryWarningTitle: string; + /** Interpolates the seconds remaining into the feature's warning copy. */ + formatExpiryWarning: (seconds: number) => string; + errorTitle: string; + sessionCreateErrorMessage: string; + pollingErrorMessage: string; + /** Rendered under the QR once files have arrived (e.g. a received-count badge). */ + renderReceived?: (count: number) => ReactNode; + qrSize?: number; +} + +export default function MobileTransferModal({ + opened, + onClose, + routePath, + onFileReceived, + title, + description, + instructions, + expiryWarningTitle, + formatExpiryWarning, + errorTitle, + sessionCreateErrorMessage, + pollingErrorMessage, + renderReceived, + qrSize = 240, +}: MobileTransferModalProps) { + const { config } = useAppConfig(); + + const { mobileUrl, filesReceived, error, timeRemaining, showExpiryWarning } = + useMobileTransferSession({ + active: opened, + routePath, + onFileReceived, + sessionCreateErrorMessage, + pollingErrorMessage, + // In dev the backend-advertised frontendUrl is the backend origin, which + // serves no SPA — the phone must open the Vite origin this page runs on, + // so let the URL builder fall back to it. An explicit server_url still + // wins, as an escape hatch. + configuredUrl: + localStorage.getItem("server_url") || + (import.meta.env.DEV ? "" : config?.frontendUrl || ""), + }); + + return ( + + + } + color="blue" + variant="light" + > + {description} + + + {showExpiryWarning && timeRemaining !== null && ( + } + title={expiryWarningTitle} + color="orange" + > + + {formatExpiryWarning(Math.ceil(timeRemaining / 1000))} + + + )} + + {error && ( + } + title={errorTitle} + color="red" + > + {error} + + )} + + + + + + + {filesReceived > 0 && renderReceived?.(filesReceived)} + + + {instructions} + + + + {mobileUrl} + + + + + ); +} diff --git a/frontend/editor/src/core/components/shared/MobileUploadModal.tsx b/frontend/editor/src/core/components/shared/MobileUploadModal.tsx index 76557d2b77..64ff82cb9f 100644 --- a/frontend/editor/src/core/components/shared/MobileUploadModal.tsx +++ b/frontend/editor/src/core/components/shared/MobileUploadModal.tsx @@ -1,17 +1,10 @@ -import { useEffect, useCallback, useState, useRef } from "react"; -import { Modal, Stack, Text, Badge, Box, Alert } from "@mantine/core"; -import { QRCodeSVG } from "qrcode.react"; +import { useCallback } from "react"; +import { Badge } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useAppConfig } from "@app/contexts/AppConfigContext"; -import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; -import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; -import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; -import { BASE_PATH } from "@app/constants/app"; -import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl"; +import MobileTransferModal from "@app/components/shared/MobileTransferModal"; import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils"; -import apiClient from "@app/services/apiClient"; interface MobileUploadModalProps { opened: boolean; @@ -19,47 +12,6 @@ interface MobileUploadModalProps { onFilesReceived: (files: File[]) => void; } -// Generate a cryptographically secure UUID v4-like session ID -function generateSessionId(): string { - // Use Web Crypto API for cryptographically secure random values - const cryptoObj = - typeof crypto !== "undefined" ? crypto : (window as any).crypto; - - if (cryptoObj && typeof cryptoObj.getRandomValues === "function") { - const bytes = new Uint8Array(16); - cryptoObj.getRandomValues(bytes); - - // Set version (4) and variant bits per RFC 4122 - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 - - // Convert bytes to hex string in UUID format - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")); - return [ - hex.slice(0, 4).join(""), - hex.slice(4, 6).join(""), - hex.slice(6, 8).join(""), - hex.slice(8, 10).join(""), - hex.slice(10, 16).join(""), - ].join("-"); - } - - // If Web Crypto is not available, fail fast rather than using insecure randomness - console.error( - "Web Crypto API not available. Cannot generate secure session ID.", - ); - throw new Error( - "Web Crypto API not available. Cannot generate secure session ID.", - ); -} - -interface SessionInfo { - sessionId: string; - createdAt: number; - expiresAt: number; - timeoutMs: number; -} - /** * MobileUploadModal * @@ -73,371 +25,103 @@ export default function MobileUploadModal({ }: MobileUploadModalProps) { const { t } = useTranslation(); const { config } = useAppConfig(); + const convertToPdf = config?.mobileScannerConvertToPdf !== false; - const [sessionId, setSessionId] = useState(() => generateSessionId()); - const [sessionInfo, setSessionInfo] = useState(null); - const [filesReceived, setFilesReceived] = useState(0); - const [error, setError] = useState(null); - const [timeRemaining, setTimeRemaining] = useState(null); - const [showExpiryWarning, setShowExpiryWarning] = useState(false); - const pollIntervalRef = useRef(null); - const timerIntervalRef = useRef(null); - const processedFiles = useRef>(new Set()); + const handleFileReceived = useCallback( + async (received: File) => { + let file = received; - // Build the QR-code URL the phone opens. It must land on the public - // /mobile-scanner route under the app's base path, otherwise the phone hits - // the auth-gated catch-all route and is bounced to the login page. - const mobileUrl = buildMobileScannerUrl({ - configuredUrl: - localStorage.getItem("server_url") || config?.frontendUrl || "", - sessionId, - origin: window.location.origin, - basePath: BASE_PATH, - }); - - // Create session on backend - const createSession = useCallback( - async (newSessionId: string) => { - try { - const response = await apiClient.post( - `/api/v1/mobile-scanner/create-session/${newSessionId}`, - undefined, - { - responseType: "json", - }, - ); - - if (!response.status || response.status !== 200) { - throw new Error("Failed to create session"); - } - - const data = response.data; - setSessionInfo(data); - setError(null); - console.log("[MobileUploadModal] Session created:", data); - } catch (err) { - console.error("[MobileUploadModal] Failed to create session:", err); - setError( - t("mobileUpload.sessionCreateError", "Failed to create session"), - ); - } - }, - [t], - ); - - // Regenerate session (when expired or warned) - const regenerateSession = useCallback(() => { - const newSessionId = generateSessionId(); - setSessionId(newSessionId); - setShowExpiryWarning(false); - setFilesReceived(0); - processedFiles.current.clear(); - createSession(newSessionId); - }, [createSession]); - - const pollForFiles = useCallback(async () => { - if (!opened) return; - - try { - const response = await apiClient.get( - `/api/v1/mobile-scanner/files/${sessionId}`, - ); - if (!response.status || response.status !== 200) { - throw new Error("Failed to check for files"); - } - - const data = response.data; - const files = data.files || []; - - // Download only files we haven't processed yet - const newFiles = files.filter( - (f: any) => !processedFiles.current.has(f.filename), - ); - - if (newFiles.length > 0) { - for (const fileMetadata of newFiles) { - try { - const downloadResponse = await apiClient.get( - `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, - { - responseType: "blob", - }, - ); - - if (downloadResponse.status === 200) { - const blob = downloadResponse.data; - let file = new File([blob], fileMetadata.filename, { - type: fileMetadata.contentType || "image/jpeg", - }); - - // Convert images to PDF if enabled - if ( - isImageFile(file) && - config?.mobileScannerConvertToPdf !== false - ) { - try { - file = await convertImageToPdf(file, { - imageResolution: config?.mobileScannerImageResolution as - | "full" - | "reduced" - | undefined, - pageFormat: config?.mobileScannerPageFormat as - | "keep" - | "A4" - | "letter" - | undefined, - stretchToFit: config?.mobileScannerStretchToFit, - }); - console.log( - "[MobileUploadModal] Converted image to PDF:", - file.name, - ); - } catch (convertError) { - console.warn( - "[MobileUploadModal] Failed to convert image to PDF, using original file:", - convertError, - ); - // Continue with original image file if conversion fails - } - } - - processedFiles.current.add(fileMetadata.filename); - setFilesReceived((prev) => prev + 1); - onFilesReceived([file]); - } - } catch (err) { - console.error( - "[MobileUploadModal] Failed to download file:", - fileMetadata.filename, - err, - ); - } - } - - // Delete the entire session immediately after downloading all files - // This ensures files are only on server for ~1 second + // Convert images to PDF if enabled + if (isImageFile(file) && convertToPdf) { try { - await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`); - console.log( - "[MobileUploadModal] Session cleaned up after file download", - ); - } catch (cleanupErr) { + file = await convertImageToPdf(file, { + imageResolution: config?.mobileScannerImageResolution as + | "full" + | "reduced" + | undefined, + pageFormat: config?.mobileScannerPageFormat as + | "keep" + | "A4" + | "letter" + | undefined, + stretchToFit: config?.mobileScannerStretchToFit, + }); + } catch (convertError) { console.warn( - "[MobileUploadModal] Failed to cleanup session after download:", - cleanupErr, + "[MobileUploadModal] Failed to convert image to PDF, using original file:", + convertError, ); + // Continue with original image file if conversion fails } } - } catch (err) { - console.error("[MobileUploadModal] Error polling for files:", err); - setError(t("mobileUpload.pollingError", "Error checking for files")); - } - }, [opened, sessionId, onFilesReceived, t]); - // Create session when modal opens - useEffect(() => { - if (opened) { - createSession(sessionId); - setFilesReceived(0); - setError(null); - setShowExpiryWarning(false); - processedFiles.current.clear(); - } - }, [opened, sessionId]); // Only run when opened changes - - useEffect(() => { - if (!opened) return; - - createSession(sessionId); - setFilesReceived(0); - setError(null); - setShowExpiryWarning(false); - processedFiles.current.clear(); - - return () => { - console.log("Cleaning up session on unmount/close:", sessionId); - apiClient - .delete(`/api/v1/mobile-scanner/session/${sessionId}`) - .catch((err) => - console.warn("[MobileUploadModal] Cleanup failed:", err), - ); - }; - }, [opened, sessionId, createSession]); - - // Start polling for files when modal opens - useEffect(() => { - if (opened && sessionInfo) { - // Poll every 2 seconds - pollIntervalRef.current = window.setInterval(pollForFiles, 2000); - - // Initial poll - pollForFiles(); - } else { - // Stop polling when modal closes - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; - } - } - - return () => { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - } - }; - }, [opened, sessionInfo, pollForFiles]); - - // Session timeout timer - useEffect(() => { - if (!opened || !sessionInfo) return; - - const updateTimer = () => { - const now = Date.now(); - const remaining = sessionInfo.expiresAt - now; - - if (remaining <= 0) { - // Session expired - regenerate - setShowExpiryWarning(false); - regenerateSession(); - } else if (remaining <= 60000 && !showExpiryWarning) { - // Less than 1 minute remaining - show warning - setShowExpiryWarning(true); - } - - setTimeRemaining(Math.max(0, remaining)); - }; - - // Update immediately - updateTimer(); - - // Update every second - timerIntervalRef.current = window.setInterval(updateTimer, 1000); - - return () => { - if (timerIntervalRef.current) { - clearInterval(timerIntervalRef.current); - } - }; - }, [opened, sessionInfo, showExpiryWarning, regenerateSession]); + onFilesReceived([file]); + }, + [config, convertToPdf, onFilesReceived], + ); return ( - - - } - color="blue" - variant="light" + description={ + convertToPdf + ? t( + "mobileUpload.description", + "Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.", + ) + : t( + "mobileUpload.descriptionNoConvert", + "Scan this QR code with your mobile device to upload photos.", + ) + } + instructions={ + convertToPdf + ? t( + "mobileUpload.instructions", + "Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.", + ) + : t( + "mobileUpload.instructionsNoConvert", + "Open the camera app on your phone and scan this code. Files will be uploaded through the server.", + ) + } + expiryWarningTitle={t( + "mobileUpload.expiryWarning", + "Session Expiring Soon", + )} + formatExpiryWarning={(seconds) => + t( + "mobileUpload.expiryWarningMessage", + "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", + { seconds }, + ) + } + errorTitle={t("mobileUpload.error", "Connection Error")} + sessionCreateErrorMessage={t( + "mobileUpload.sessionCreateError", + "Failed to create session", + )} + pollingErrorMessage={t( + "mobileUpload.pollingError", + "Error checking for files", + )} + renderReceived={(count) => ( + } > - - {config?.mobileScannerConvertToPdf !== false - ? t( - "mobileUpload.description", - "Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.", - ) - : t( - "mobileUpload.descriptionNoConvert", - "Scan this QR code with your mobile device to upload photos.", - )} - - - - {showExpiryWarning && timeRemaining !== null && ( - } - title={t("mobileUpload.expiryWarning", "Session Expiring Soon")} - color="orange" - > - - {t( - "mobileUpload.expiryWarningMessage", - "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", - { seconds: Math.ceil(timeRemaining / 1000) }, - )} - - - )} - - {error && ( - } - title={t("mobileUpload.error", "Connection Error")} - color="red" - > - {error} - - )} - - - - - - - {filesReceived > 0 && ( - } - > - {t("mobileUpload.filesReceived", "{{count}} file(s) received", { - count: filesReceived, - })} - - )} - - - {config?.mobileScannerConvertToPdf !== false - ? t( - "mobileUpload.instructions", - "Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.", - ) - : t( - "mobileUpload.instructionsNoConvert", - "Open the camera app on your phone and scan this code. Files will be uploaded through the server.", - )} - - - - {mobileUrl} - - - - + {t("mobileUpload.filesReceived", "{{count}} file(s) received", { + count, + })} + + )} + /> ); } diff --git a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx new file mode 100644 index 0000000000..c8607d55c4 --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.test.tsx @@ -0,0 +1,176 @@ +/** + * Receive-flow contract for the phone-signature QR modal. + * + * The transfer session's upload endpoint accepts any file from anyone holding + * the QR URL, so the modal must treat arrivals as untrusted. Images become + * draw/photo payloads by filename prefix, a signature-text JSON payload is + * parsed and clamped field by field, and anything else is ignored. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import MobileSignatureModal, { + type MobileSignaturePayload, +} from "@app/components/tools/sign/MobileSignatureModal"; +import apiClient from "@app/services/apiClient"; +import { expectConsole } from "@app/tests/failOnConsole"; + +// Render the English fallbacks (the test i18n instance has no loaded locale). +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key, + }), +})); + +vi.mock("@app/services/apiClient", () => ({ + default: { + defaults: { baseURL: "http://localhost:8080" }, + post: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("@app/contexts/AppConfigContext", () => ({ + useAppConfig: () => ({ config: { enableMobileSignature: true } }), +})); + +const mockedApi = vi.mocked(apiClient, true); + +const SESSION_INFO = { + sessionId: "s", + createdAt: Date.now(), + expiresAt: Date.now() + 600_000, + timeoutMs: 600_000, +}; + +function primeSession( + files: Array<{ filename: string; contentType: string; body?: string }>, +) { + mockedApi.post.mockResolvedValue({ + status: 200, + data: SESSION_INFO, + } as never); + mockedApi.delete.mockResolvedValue({ status: 200 } as never); + mockedApi.get.mockImplementation(((url: string, config?: unknown) => { + if (url.includes("/files/")) { + return Promise.resolve({ status: 200, data: { files } } as never); + } + if (url.includes("/download/")) { + const filename = url.split("/").pop() ?? ""; + const meta = files.find((f) => f.filename === filename); + return Promise.resolve({ + status: 200, + data: new Blob([meta?.body ?? "fake-bytes"], { + type: meta?.contentType, + }), + config, + } as never); + } + return Promise.reject(new Error(`unexpected GET ${url}`)); + }) as never); +} + +function renderModal( + onSignatureReceived: (payload: MobileSignaturePayload) => void, + onClose: () => void, +) { + return render( + + + , + ); +} + +describe("MobileSignatureModal", () => { + // clearAllMocks (not restoreAllMocks): the hook's unmount cleanup still + // calls apiClient.delete during test teardown, so implementations must + // survive until React Testing Library's auto-cleanup has unmounted. + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("hands a drawn signature to the caller as a draw payload and closes", async () => { + primeSession([ + { filename: "signature-draw-1.png", contentType: "image/png" }, + ]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + const payload = onSignatureReceived.mock.calls[0][0]; + expect(payload.kind).toBe("draw"); + expect(payload.dataUrl).toMatch(/^data:image\/png/); + expect(onClose).toHaveBeenCalled(); + }); + + it("routes a photographed signature as a photo payload", async () => { + primeSession([ + { filename: "signature-photo-1.jpg", contentType: "image/jpeg" }, + ]); + const onSignatureReceived = vi.fn(); + + renderModal(onSignatureReceived, vi.fn()); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + expect(onSignatureReceived.mock.calls[0][0].kind).toBe("photo"); + }); + + it("parses a typed signature as text, clamping unknown font and colour", async () => { + primeSession([ + { + filename: "signature-text-1.json", + contentType: "application/json", + body: JSON.stringify({ + text: " Reece ", + fontFamily: "Wingdings", + color: "javascript:alert(1)", + }), + }, + ]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + await waitFor(() => expect(onSignatureReceived).toHaveBeenCalledTimes(1)); + expect(onSignatureReceived.mock.calls[0][0]).toEqual({ + kind: "text", + text: "Reece", + fontFamily: "Helvetica", + color: "#000000", + }); + expect(onClose).toHaveBeenCalled(); + }); + + it("ignores a non-image upload instead of setting it as the signature", async () => { + // Rejecting the upload logs a warning - that's the contract under test. + expectConsole.warn(/Ignoring non-image upload/); + primeSession([{ filename: "evil.html", contentType: "text/html" }]); + const onSignatureReceived = vi.fn(); + const onClose = vi.fn(); + + renderModal(onSignatureReceived, onClose); + + // The poll + download cycle must have run before we assert the negative. + await waitFor(() => + expect( + mockedApi.get.mock.calls.some(([url]) => + String(url).includes("/download/"), + ), + ).toBe(true), + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(onSignatureReceived).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx new file mode 100644 index 0000000000..82deae8b5f --- /dev/null +++ b/frontend/editor/src/core/components/tools/sign/MobileSignatureModal.tsx @@ -0,0 +1,155 @@ +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import MobileTransferModal from "@app/components/shared/MobileTransferModal"; + +/** + * What the phone sent, routed to the matching signature source: ink and + * photos as pixels, typed signatures as data so they stay editable text. + */ +export type MobileSignaturePayload = + | { kind: "draw"; dataUrl: string } + | { kind: "photo"; dataUrl: string } + | { kind: "text"; text: string; fontFamily: string; color: string }; + +/** Fonts the sign tool's text mode offers; anything else falls back. */ +const TEXT_FONTS = new Set([ + "Helvetica", + "Times-Roman", + "Courier", + "Arial", + "Georgia", +]); +const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; +const MAX_TEXT_LENGTH = 200; + +interface MobileSignatureModalProps { + opened: boolean; + onClose: () => void; + onSignatureReceived: (payload: MobileSignaturePayload) => void; +} + +/** FileReader-based (rather than File.text/arrayBuffer, absent in jsdom). */ +function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? "")); + reader.onerror = () => reject(reader.error); + reader.readAsText(file); + }); +} + +/** + * QR modal for creating a signature on a phone or tablet. The phone opens the + * public `/mobile-sign` page; the first valid arrival becomes the signature + * and the modal closes. + */ +export default function MobileSignatureModal({ + opened, + onClose, + onSignatureReceived, +}: MobileSignatureModalProps) { + const { t } = useTranslation(); + + // The session endpoints accept any upload from anyone holding the QR URL, + // so nothing here is trusted: images pass as pixels, a typed signature is + // parsed and clamped field by field, everything else is ignored. + const handleFileReceived = useCallback( + async (file: File) => { + if ( + file.type === "application/json" && + file.name.startsWith("signature-text") + ) { + try { + const parsed: unknown = JSON.parse(await readFileAsText(file)); + const record = parsed as Record; + const text = + typeof record?.text === "string" + ? record.text.trim().slice(0, MAX_TEXT_LENGTH) + : ""; + if (!text) return; + onSignatureReceived({ + kind: "text", + text, + fontFamily: TEXT_FONTS.has(record.fontFamily as string) + ? (record.fontFamily as string) + : "Helvetica", + color: HEX_COLOR.test(record.color as string) + ? (record.color as string) + : "#000000", + }); + onClose(); + } catch { + console.warn( + "[MobileSignatureModal] Ignoring malformed text payload", + ); + } + return; + } + + if (!file.type.startsWith("image/")) { + console.warn( + "[MobileSignatureModal] Ignoring non-image upload:", + file.type, + ); + return; + } + + await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result; + if (typeof dataUrl === "string") { + onSignatureReceived({ + kind: file.name.startsWith("signature-photo") ? "photo" : "draw", + dataUrl, + }); + onClose(); + } + resolve(); + }; + reader.onerror = () => resolve(); + reader.readAsDataURL(file); + }); + }, + [onSignatureReceived, onClose], + ); + + return ( + + t( + "sign.mobile.expiryWarningMessage", + "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", + { seconds }, + ) + } + errorTitle={t("sign.mobile.error", "Connection Error")} + sessionCreateErrorMessage={t( + "sign.mobile.sessionCreateError", + "Failed to create session", + )} + pollingErrorMessage={t( + "sign.mobile.pollingError", + "Error checking for the signature", + )} + /> + ); +} diff --git a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx index 648ada1c47..4aa9f8ffd3 100644 --- a/frontend/editor/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/core/components/tools/sign/SignSettings.tsx @@ -34,6 +34,11 @@ import { AddSignatureResult, } from "@app/hooks/tools/sign/useSavedSignatures"; import { SavedSignaturesSection } from "@app/components/tools/sign/SavedSignaturesSection"; +import MobileSignatureModal, { + type MobileSignaturePayload, +} from "@app/components/tools/sign/MobileSignatureModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { buildSignaturePreview } from "@app/utils/signaturePreview"; type SignatureDrafts = { @@ -116,6 +121,12 @@ const SignSettings = ({ const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false); + const [isMobileSignModalOpen, setIsMobileSignModalOpen] = useState(false); + const { config } = useAppConfig(); + const isMobileViewport = useIsMobile(); + // Drawing on a phone needs a second device, so the QR entry is desktop-only. + const canDrawOnPhone = + Boolean(config?.enableMobileSignature) && !isMobileViewport; // State for different signature types const [canvasSignatureData, setCanvasSignatureData] = useState< @@ -625,6 +636,71 @@ const SignSettings = ({ [onActivateSignaturePlacement], ); + // Route a signature made on a phone to the matching source, mirroring + // handleUseSavedSignature: ink is a canvas signature, a photo is an image + // signature, and typed text stays editable text rather than baked pixels. + const handleMobileSignatureReceived = useCallback( + (payload: MobileSignaturePayload) => { + // Receiving a signature is as clear an intent to place it as drawing + // one, so placement goes live even if it was paused beforehand. + setPlacementManuallyPaused(false); + lastAppliedPlacementKey.current = null; + if (payload.kind === "draw") { + if (parameters.signatureType !== "canvas") { + onParameterChange("signatureType", "canvas"); + } + handleCanvasSignatureChange(payload.dataUrl); + } else if (payload.kind === "photo") { + if (parameters.signatureType !== "image") { + onParameterChange("signatureType", "image"); + } + setImageSignatureData(payload.dataUrl); + } else { + if (parameters.signatureType !== "text") { + onParameterChange("signatureType", "text"); + } + onParameterChange("signerName", payload.text); + onParameterChange("fontFamily", payload.fontFamily); + onParameterChange("textColor", payload.color); + // Move the draft mirror in the same commit as the parameters. The + // record/restore effect pair otherwise sees them one commit apart and + // ping-pongs old draft against new params, wiping the received text + // and looping until React aborts the update depth. + const nextDraft = { + signerName: payload.text, + fontSize: parameters.fontSize ?? 16, + fontFamily: payload.fontFamily, + textColor: payload.color, + }; + lastSyncedTextDraft.current = nextDraft; + setSignatureDrafts((prev) => ({ ...prev, text: nextDraft })); + } + // Activate directly for every kind: the canvas-change handler only + // activates when the data actually changed, and the auto-activate + // effect only reacts to state transitions - neither fires for a + // repeat of the same signature. Fired twice because the first shot can + // land between the receive commit and the ready-state settling, where + // the placement effect immediately deactivates it; the second shot is + // after everything has settled, and re-activating is idempotent. + if (typeof window !== "undefined") { + window.setTimeout( + () => onActivateSignaturePlacement?.(), + PLACEMENT_ACTIVATION_DELAY, + ); + window.setTimeout(() => onActivateSignaturePlacement?.(), 500); + } else { + onActivateSignaturePlacement?.(); + } + }, + [ + parameters.signatureType, + parameters.fontSize, + onParameterChange, + handleCanvasSignatureChange, + onActivateSignaturePlacement, + ], + ); + const hasCanvasSignature = useMemo( () => Boolean(canvasSignatureData), [canvasSignatureData], @@ -980,6 +1056,29 @@ const SignSettings = ({ if (signatureSource === "image") { return ( + {imageSignatureData && ( + + {translate("image.previewAlt", + + )} + {canDrawOnPhone && ( + <> + + setIsMobileSignModalOpen(false)} + onSignatureReceived={handleMobileSignatureReceived} + /> + + )} {sourceOptions.length > 1 && ( b.toString(16).padStart(2, "0")); + return [ + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10, 16).join(""), + ].join("-"); + } + + // If Web Crypto is not available, fail fast rather than using insecure randomness + throw new Error( + "Web Crypto API not available. Cannot generate secure session ID.", + ); +} + +export interface MobileTransferSessionInfo { + sessionId: string; + createdAt: number; + expiresAt: number; + timeoutMs: number; +} + +interface UseMobileTransferSessionParams { + /** Session exists and polling runs only while true (modal open). */ + active: boolean; + /** SPA route the phone opens, without slashes: "mobile-scanner", "mobile-sign". */ + routePath: string; + /** Called once per newly uploaded file, in upload order. */ + onFileReceived: (file: File) => void | Promise; + /** Message shown when the backend refuses to create a session. */ + sessionCreateErrorMessage: string; + /** Message shown when polling for uploads fails. */ + pollingErrorMessage: string; + /** Host the phone should reach, when configured (server_url / frontendUrl). */ + configuredUrl?: string; +} + +export function useMobileTransferSession({ + active, + routePath, + onFileReceived, + sessionCreateErrorMessage, + pollingErrorMessage, + configuredUrl, +}: UseMobileTransferSessionParams) { + const [sessionId, setSessionId] = useState(() => generateSessionId()); + const [sessionInfo, setSessionInfo] = + useState(null); + const [filesReceived, setFilesReceived] = useState(0); + const [error, setError] = useState(null); + const [timeRemaining, setTimeRemaining] = useState(null); + const [showExpiryWarning, setShowExpiryWarning] = useState(false); + const pollIntervalRef = useRef(null); + const timerIntervalRef = useRef(null); + const processedFiles = useRef>(new Set()); + + // The QR-code URL the phone opens. It must land on the public route under + // the app's base path, otherwise the phone hits the auth-gated catch-all + // route and is bounced to the login page. + const mobileUrl = buildMobileRouteUrl({ + configuredUrl: configuredUrl ?? "", + sessionId, + origin: window.location.origin, + basePath: BASE_PATH, + routePath, + }); + + const createSession = useCallback( + async (newSessionId: string) => { + try { + const response = await apiClient.post( + `/api/v1/mobile-scanner/create-session/${newSessionId}`, + undefined, + { responseType: "json" }, + ); + + if (!response.status || response.status !== 200) { + throw new Error("Failed to create session"); + } + + setSessionInfo(response.data); + setError(null); + } catch (err) { + console.error("[useMobileTransferSession] create failed:", err); + setError(sessionCreateErrorMessage); + } + }, + [sessionCreateErrorMessage], + ); + + // Regenerate session (when expired or warned) + const regenerateSession = useCallback(() => { + const newSessionId = generateSessionId(); + setSessionId(newSessionId); + setShowExpiryWarning(false); + setFilesReceived(0); + processedFiles.current.clear(); + createSession(newSessionId); + }, [createSession]); + + const pollForFiles = useCallback(async () => { + if (!active) return; + + try { + const response = await apiClient.get( + `/api/v1/mobile-scanner/files/${sessionId}`, + ); + if (!response.status || response.status !== 200) { + throw new Error("Failed to check for files"); + } + + const files = response.data.files || []; + + // Download only files we haven't processed yet + const newFiles = files.filter( + (f: any) => !processedFiles.current.has(f.filename), + ); + if (newFiles.length === 0) return; + + for (const fileMetadata of newFiles) { + try { + const downloadResponse = await apiClient.get( + `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, + { responseType: "blob" }, + ); + + if (downloadResponse.status === 200) { + const file = new File( + [downloadResponse.data], + fileMetadata.filename, + { type: fileMetadata.contentType || "image/jpeg" }, + ); + processedFiles.current.add(fileMetadata.filename); + setFilesReceived((prev) => prev + 1); + await onFileReceived(file); + } + } catch (err) { + console.error( + "[useMobileTransferSession] download failed:", + fileMetadata.filename, + err, + ); + } + } + + // Delete the entire session immediately after downloading, so uploads + // sit on the server only for the seconds between polls. + try { + await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`); + } catch (cleanupErr) { + console.warn( + "[useMobileTransferSession] post-download cleanup failed:", + cleanupErr, + ); + } + } catch (err) { + console.error("[useMobileTransferSession] polling failed:", err); + setError(pollingErrorMessage); + } + }, [active, sessionId, onFileReceived, pollingErrorMessage]); + + // Create the session while active; delete it when deactivated/unmounted. + useEffect(() => { + if (!active) return; + + createSession(sessionId); + setFilesReceived(0); + setError(null); + setShowExpiryWarning(false); + processedFiles.current.clear(); + + return () => { + apiClient + .delete(`/api/v1/mobile-scanner/session/${sessionId}`) + .catch((err) => + console.warn("[useMobileTransferSession] cleanup failed:", err), + ); + }; + }, [active, sessionId, createSession]); + + // Poll for uploads while the session is live + useEffect(() => { + if (active && sessionInfo) { + pollIntervalRef.current = window.setInterval(pollForFiles, 2000); + pollForFiles(); + } else if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, [active, sessionInfo, pollForFiles]); + + // Session timeout timer: warn under a minute, regenerate on expiry + useEffect(() => { + if (!active || !sessionInfo) return; + + const updateTimer = () => { + const now = Date.now(); + const remaining = sessionInfo.expiresAt - now; + + if (remaining <= 0) { + setShowExpiryWarning(false); + regenerateSession(); + } else if (remaining <= 60000 && !showExpiryWarning) { + setShowExpiryWarning(true); + } + + setTimeRemaining(Math.max(0, remaining)); + }; + + updateTimer(); + timerIntervalRef.current = window.setInterval(updateTimer, 1000); + + return () => { + if (timerIntervalRef.current) { + clearInterval(timerIntervalRef.current); + } + }; + }, [active, sessionInfo, showExpiryWarning, regenerateSession]); + + return { + /** URL to encode in the QR code. */ + mobileUrl, + sessionInfo, + /** Count of files received this session (resets on regenerate). */ + filesReceived, + error, + /** Milliseconds until the session expires, once known. */ + timeRemaining, + /** True inside the final minute before expiry. */ + showExpiryWarning, + regenerateSession, + }; +} diff --git a/frontend/editor/src/core/pages/MobileSignPage.test.tsx b/frontend/editor/src/core/pages/MobileSignPage.test.tsx new file mode 100644 index 0000000000..84bcf1b736 --- /dev/null +++ b/frontend/editor/src/core/pages/MobileSignPage.test.tsx @@ -0,0 +1,100 @@ +/** + * Session-state contract for the phone-side signature page: a missing or + * expired session shows one clear error instead of a canvas whose Send would + * fail; a valid session shows the draw/type/photo tabs. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { MantineProvider } from "@mantine/core"; +import MobileSignPage from "@app/pages/MobileSignPage"; + +vi.mock("@app/services/apiClient", () => ({ + default: { defaults: { baseURL: "http://localhost:8080" } }, +})); + +// Render the English fallbacks the assertions read (the test i18n instance +// has no loaded locale, so bare t() would render raw keys). +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key, + }), +})); + +// Branding components pull theme preferences from providers this page doesn't +// need for its session-state contract. +vi.mock("@app/components/shared/LogoIcon", () => ({ + LogoIcon: () => , +})); +vi.mock("@app/components/shared/Wordmark", () => ({ + Wordmark: () => , +})); + +function renderAt(path: string) { + return render( + + + + + , + ); +} + +describe("MobileSignPage", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + // jsdom has no ResizeObserver; the draw canvas sizes itself with one. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom's canvas has no real 2d context (and logs an error when asked); + // the draw canvas tolerates a null context. + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the expired-session error when the URL has no session", async () => { + renderAt("/mobile-sign"); + + await waitFor(() => + expect(screen.getByText(/Session expired/i)).toBeInTheDocument(), + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("shows the expired-session error when the backend rejects the session", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + json: async () => ({ valid: false }), + } as Response); + + renderAt("/mobile-sign?session=stale-session"); + + await waitFor(() => + expect(screen.getByText(/Session expired/i)).toBeInTheDocument(), + ); + }); + + it("shows the signature tabs once the session validates", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ valid: true }), + } as Response); + + renderAt("/mobile-sign?session=good-session"); + + await waitFor(() => expect(screen.getByText("Draw")).toBeInTheDocument()); + expect(screen.getByText("Type")).toBeInTheDocument(); + expect(screen.getByText("Photo")).toBeInTheDocument(); + expect(screen.getByText(/Send to computer/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/core/pages/MobileSignPage.tsx b/frontend/editor/src/core/pages/MobileSignPage.tsx new file mode 100644 index 0000000000..fd4034e0ba --- /dev/null +++ b/frontend/editor/src/core/pages/MobileSignPage.tsx @@ -0,0 +1,554 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { + Alert, + Box, + Card, + Group, + Image, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; +import { Button as DSButton } from "@app/ui/Button"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; +import { useTranslation } from "react-i18next"; +import { LogoIcon } from "@app/components/shared/LogoIcon"; +import { Wordmark } from "@app/components/shared/Wordmark"; +import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded"; +import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; +import UndoRoundedIcon from "@mui/icons-material/UndoRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded"; +import PhotoCameraRoundedIcon from "@mui/icons-material/PhotoCameraRounded"; +import SendRoundedIcon from "@mui/icons-material/SendRounded"; +import { + MobileDrawCanvas, + type MobileDrawCanvasHandle, +} from "@app/components/mobileSign/MobileDrawCanvas"; +import apiClient from "@app/services/apiClient"; + +// Use the configured API base (e.g. api.stirling.com), not the page origin. +const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, ""); + +type SignatureTab = "draw" | "type" | "photo"; + +// Ink pigments, not UI theme colours: they are baked into the exported PNG +// and transferred to the desktop, so they must be fixed literals. +const INK_COLORS = [ + { value: "#101010", label: "black" }, // theme-allow-color ink pigment, serialized into the signature + { value: "#1d4ed8", label: "blue" }, // theme-allow-color ink pigment, serialized into the signature +]; + +const PEN_SIZES = [ + { value: 2, label: "S" }, + { value: 3.5, label: "M" }, + { value: 6, label: "L" }, +]; + +/** + * The sign tool's own text-mode fonts, so a typed signature transfers as + * data and stays editable there. `css` approximates each for the on-phone + * preview; `value` is what the desktop's font parameter understands. + */ +const TYPE_FONTS = [ + { + value: "Helvetica", + css: "Helvetica, Arial, sans-serif", + label: "Helvetica", + }, + { + value: "Times-Roman", + css: "'Times New Roman', Times, serif", + label: "Times", + }, + { + value: "Courier", + css: "'Courier New', Courier, monospace", + label: "Courier", + }, + { value: "Arial", css: "Arial, sans-serif", label: "Arial" }, + { value: "Georgia", css: "Georgia, serif", label: "Georgia" }, +]; + +async function dataUrlToBlob(dataUrl: string): Promise { + const response = await fetch(dataUrl); + return response.blob(); +} + +/** + * MobileSignPage + * + * Phone-side page for sending a signature to the desktop: draw one (the main + * path), type one, or photograph one. Reached by scanning the QR code shown in + * the editor's Sign tool; the session comes from the QR URL and rides the same + * transfer backend as the mobile scanner. + */ +export default function MobileSignPage() { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const sessionId = searchParams.get("session"); + // Landscape phones (not tablets — hence the height cap) get a compact + // layout: branding hidden, tighter padding, shorter pad, so the canvas and + // the Send button fit on screen together. + const compactLandscape = + useMediaQuery("(orientation: landscape) and (max-height: 32rem)") ?? false; + + const [sessionValid, setSessionValid] = useState(null); + const [tab, setTab] = useState("draw"); + const [hasInk, setHasInk] = useState(false); + const [typedText, setTypedText] = useState(""); + const [typeFont, setTypeFont] = useState(TYPE_FONTS[0].value); + const [inkColor, setInkColor] = useState(INK_COLORS[0].value); + const [penSize, setPenSize] = useState(PEN_SIZES[1].value); + const [photoDataUrl, setPhotoDataUrl] = useState(null); + const [photoError, setPhotoError] = useState(null); + const [isSending, setIsSending] = useState(false); + const [sendError, setSendError] = useState(null); + const [sentCount, setSentCount] = useState(0); + const [justSent, setJustSent] = useState(false); + + const canvasHandle = useRef(null); + const photoInputRef = useRef(null); + const cameraInputRef = useRef(null); + + // Validate the session up front, so a stale QR shows one clear error rather + // than a canvas whose Send fails. + useEffect(() => { + if (!sessionId) { + setSessionValid(false); + return; + } + (async () => { + try { + const response = await fetch( + `${API_BASE}/api/v1/mobile-scanner/validate-session/${sessionId}`, + ); + const data = response.ok ? await response.json() : null; + setSessionValid(Boolean(data?.valid)); + } catch { + setSessionValid(false); + } + })(); + }, [sessionId]); + + const handlePhotoSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith("image/")) { + setPhotoError( + t("mobileSign.photo.invalidType", "Please choose an image file."), + ); + return; + } + setPhotoError(null); + const reader = new FileReader(); + reader.onload = (event) => + setPhotoDataUrl((event.target?.result as string) ?? null); + reader.readAsDataURL(file); + }; + + /** + * What this tab sends: ink and photos as image files, typed signatures as a + * JSON payload (text + font + colour) so the desktop keeps them editable in + * the sign tool's text mode. The filename prefix tells the desktop which + * source the signature belongs to. + */ + const buildUpload = useCallback(async (): Promise<{ + blob: Blob; + filename: string; + } | null> => { + if (tab === "draw") { + const dataUrl = canvasHandle.current?.exportPng(); + if (!dataUrl) return null; + return { + blob: await dataUrlToBlob(dataUrl), + filename: `signature-draw-${Date.now()}.png`, + }; + } + if (tab === "type") { + const text = typedText.trim(); + if (!text) return null; + const payload = JSON.stringify({ + text, + fontFamily: typeFont, + color: inkColor, + }); + return { + blob: new Blob([payload], { type: "application/json" }), + filename: `signature-text-${Date.now()}.json`, + }; + } + if (!photoDataUrl) return null; + const blob = await dataUrlToBlob(photoDataUrl); + const extension = blob.type === "image/jpeg" ? "jpg" : "png"; + return { + blob, + filename: `signature-photo-${Date.now()}.${extension}`, + }; + }, [tab, typedText, typeFont, inkColor, photoDataUrl]); + + const canSend = + (tab === "draw" && hasInk) || + (tab === "type" && typedText.trim().length > 0) || + (tab === "photo" && photoDataUrl !== null); + + const handleSend = async () => { + if (!sessionId) return; + + setIsSending(true); + setSendError(null); + try { + const upload = await buildUpload(); + if (!upload) return; + const formData = new FormData(); + formData.append("files", upload.blob, upload.filename); + + const response = await fetch( + `${API_BASE}/api/v1/mobile-scanner/upload/${sessionId}`, + { method: "POST", body: formData }, + ); + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`); + } + + setSentCount((count) => count + 1); + setJustSent(true); + // Reset the inputs so "send another" starts clean + canvasHandle.current?.clear(); + setTypedText(""); + setPhotoDataUrl(null); + if (photoInputRef.current) photoInputRef.current.value = ""; + if (cameraInputRef.current) cameraInputRef.current.value = ""; + } catch (err) { + console.error("[MobileSignPage] upload failed:", err); + setSendError( + t( + "mobileSign.sendError", + "Could not send the signature. Check the connection and try again.", + ), + ); + } finally { + setIsSending(false); + } + }; + + const header = ( + + + + + ); + + if (sessionValid === null) { + return ( + + {header} + + {t("mobileSign.validating", "Checking session…")} + + + ); + } + + if (!sessionValid) { + return ( + + {header} + } + color="red" + title={t("mobileSign.invalidSession", "Session expired")} + > + {t( + "mobileSign.invalidSessionMessage", + "This QR code is no longer valid. Open the Sign tool on your computer and scan the new code.", + )} + + + ); + } + + return ( + + {!compactLandscape && header} + + {justSent && ( + } + color="green" + mb="sm" + withCloseButton + onClose={() => setJustSent(false)} + > + {t( + "mobileSign.sentMessage", + "Signature sent to your computer. You can send another or close this page.", + )} + + )} + {sendError && ( + } + color="red" + mb="sm" + > + {sendError} + + )} + + + + fullWidth + value={tab} + onChange={setTab} + ariaLabel={t("mobileSign.tabsLabel", "Signature source")} + options={[ + // Same order as the sign tool's sources: canvas, image, text + { value: "draw", label: t("mobileSign.tab.draw", "Draw") }, + { value: "photo", label: t("mobileSign.tab.photo", "Photo") }, + { value: "type", label: t("mobileSign.tab.type", "Type") }, + ]} + /> + + + {tab === "draw" && ( + + + + + + + + {INK_COLORS.map((color) => ( + setInkColor(color.value)} + aria-label={color.label} + style={{ + width: 32, + height: 32, + borderRadius: "50%", + background: color.value, + cursor: "pointer", + border: + inkColor === color.value + ? "3px solid var(--mantine-color-blue-4)" + : "3px solid transparent", + }} + /> + ))} + setPenSize(Number(value))} + ariaLabel={t("mobileSign.penSizeLabel", "Pen size")} + options={PEN_SIZES.map((size) => ({ + value: String(size.value), + label: size.label, + }))} + /> + + + canvasHandle.current?.undo()} + leftSection={} + > + {t("mobileSign.undo", "Undo")} + + canvasHandle.current?.clear()} + leftSection={ + + } + > + {t("mobileSign.clear", "Clear")} + + + + + )} + + {tab === "type" && ( + + setTypedText(e.target.value)} + placeholder={t("mobileSign.type.placeholder", "Your name")} + autoComplete="name" + /> + + + + )} + + + } + > + {sentCount > 0 + ? t("mobileSign.sendAnother", "Send another signature") + : t("mobileSign.send", "Send to computer")} + + + + ); +} diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index 72c7d7c5b6..2dafb3d07a 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -34,6 +34,7 @@ export interface AppConfig { serverCertificateEnabled?: boolean; hardwareSigningAvailable?: boolean; enableMobileScanner?: boolean; + enableMobileSignature?: boolean; mobileScannerConvertToPdf?: boolean; mobileScannerImageResolution?: string; mobileScannerPageFormat?: string; diff --git a/frontend/editor/src/core/utils/mobileScannerUrl.test.ts b/frontend/editor/src/core/utils/mobileScannerUrl.test.ts index 8890399b7b..024eeaf54a 100644 --- a/frontend/editor/src/core/utils/mobileScannerUrl.test.ts +++ b/frontend/editor/src/core/utils/mobileScannerUrl.test.ts @@ -9,10 +9,51 @@ */ import { describe, test, expect } from "vitest"; -import { buildMobileScannerUrl } from "@app/utils/mobileScannerUrl"; +import { + buildMobileRouteUrl, + buildMobileScannerUrl, +} from "@app/utils/mobileScannerUrl"; const sessionId = "abc-123"; +describe("buildMobileRouteUrl", () => { + test("routes other mobile pages (mobile-sign) with the base path", () => { + expect( + buildMobileRouteUrl({ + configuredUrl: "https://app.stirlingpdf.com", + sessionId, + origin: "https://app.stirlingpdf.com", + basePath: "/app", + routePath: "mobile-sign", + }), + ).toBe("https://app.stirlingpdf.com/app/mobile-sign?session=abc-123"); + }); + + test("configured URL with subpath keeps the route un-doubled", () => { + expect( + buildMobileRouteUrl({ + configuredUrl: "https://host.example/app/", + sessionId, + origin: "https://elsewhere.example", + basePath: "/app", + routePath: "mobile-sign", + }), + ).toBe("https://host.example/app/mobile-sign?session=abc-123"); + }); + + test("no configured URL falls back to origin + base path", () => { + expect( + buildMobileRouteUrl({ + configuredUrl: "", + sessionId, + origin: "http://192.168.1.20:8080", + basePath: "", + routePath: "mobile-sign", + }), + ).toBe("http://192.168.1.20:8080/mobile-sign?session=abc-123"); + }); +}); + describe("buildMobileScannerUrl", () => { test("origin-only frontendUrl keeps the app base path (SaaS web regression)", () => { expect( diff --git a/frontend/editor/src/core/utils/mobileScannerUrl.ts b/frontend/editor/src/core/utils/mobileScannerUrl.ts index c705069986..b44dcc358e 100644 --- a/frontend/editor/src/core/utils/mobileScannerUrl.ts +++ b/frontend/editor/src/core/utils/mobileScannerUrl.ts @@ -1,10 +1,10 @@ /** - * Build the URL a phone opens (via the QR code) to reach the SPA's - * `/mobile-scanner` route. + * Build the URL a phone opens (via a QR code) to reach one of the SPA's + * public mobile routes (`/mobile-scanner`, `/mobile-sign`). * - * That route is a public, top-level route. It lives under the app's base path, - * which is the router's `basename`. If the generated URL omits the base path, - * the phone loads a path the router can't match, falls through to the + * These routes are public, top-level routes. They live under the app's base + * path, which is the router's `basename`. If the generated URL omits the base + * path, the phone loads a path the router can't match, falls through to the * auth-gated catch-all route, and gets bounced to the login page. So the base * path must always be present. * @@ -18,15 +18,17 @@ * * With no usable configured URL, fall back to the current origin + base path. */ -export function buildMobileScannerUrl(params: { +export function buildMobileRouteUrl(params: { configuredUrl: string; sessionId: string; origin: string; basePath: string; + /** Route under the SPA base, without slashes: "mobile-scanner", "mobile-sign". */ + routePath: string; }): string { - const { configuredUrl, sessionId, origin, basePath } = params; + const { configuredUrl, sessionId, origin, basePath, routePath } = params; const query = `?session=${sessionId}`; - const route = `${basePath}/mobile-scanner`; + const route = `${basePath}/${routePath}`; const trimmed = configuredUrl.trim(); if (trimmed) { @@ -35,7 +37,7 @@ export function buildMobileScannerUrl(params: { if (parsed.protocol === "http:" || parsed.protocol === "https:") { const subpath = parsed.pathname.replace(/\/+$/, ""); return subpath - ? `${parsed.origin}${subpath}/mobile-scanner${query}` + ? `${parsed.origin}${subpath}/${routePath}${query}` : `${parsed.origin}${route}${query}`; } } catch { @@ -45,3 +47,13 @@ export function buildMobileScannerUrl(params: { return `${origin}${route}${query}`; } + +/** The `/mobile-scanner` QR URL. See {@link buildMobileRouteUrl}. */ +export function buildMobileScannerUrl(params: { + configuredUrl: string; + sessionId: string; + origin: string; + basePath: string; +}): string { + return buildMobileRouteUrl({ ...params, routePath: "mobile-scanner" }); +} diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index ed36815bfd..de828c9f2d 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -15,6 +15,7 @@ import Onboarding from "@app/components/onboarding/Onboarding"; import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration"; const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage")); +const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; @@ -59,6 +60,16 @@ export default function App() { } /> + {/* Mobile signature drawing - reached from the Sign tool QR code */} + + + + } + /> + {/* Participant signing — public, token-gated, no auth required */} import("@app/pages/MobileScannerPage")); +const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage")); // Import global styles import "@app/styles/tailwind.css"; @@ -83,6 +84,16 @@ export default function App() { } /> + {/* Mobile signature drawing - reached from the Sign tool QR code */} + + + + } + /> + {/* Admin-only route-set (the portal): its own top-level shell, mounted before the catch-all. */} {getAdminRouteExtensions()} diff --git a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx index b8691df30d..503087cbe4 100644 --- a/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx +++ b/frontend/editor/src/saas/components/tools/sign/SignSettings.tsx @@ -34,6 +34,11 @@ import { AddSignatureResult, } from "@app/hooks/tools/sign/useSavedSignatures"; import { SavedSignaturesSection } from "@app/components/tools/sign/SavedSignaturesSection"; +import MobileSignatureModal, { + type MobileSignaturePayload, +} from "@app/components/tools/sign/MobileSignatureModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useIsMobile } from "@app/hooks/useIsMobile"; import { buildSignaturePreview } from "@app/utils/signaturePreview"; type SignatureDrafts = { @@ -116,6 +121,12 @@ const SignSettings = ({ const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false); + const [isMobileSignModalOpen, setIsMobileSignModalOpen] = useState(false); + const { config } = useAppConfig(); + const isMobileViewport = useIsMobile(); + // Drawing on a phone needs a second device, so the QR entry is desktop-only. + const canDrawOnPhone = + Boolean(config?.enableMobileSignature) && !isMobileViewport; // State for different signature types const [canvasSignatureData, setCanvasSignatureData] = useState< @@ -665,6 +676,71 @@ const SignSettings = ({ [onActivateSignaturePlacement], ); + // Route a signature made on a phone to the matching source, mirroring + // handleUseSavedSignature: ink is a canvas signature, a photo is an image + // signature, and typed text stays editable text rather than baked pixels. + const handleMobileSignatureReceived = useCallback( + (payload: MobileSignaturePayload) => { + // Receiving a signature is as clear an intent to place it as drawing + // one, so placement goes live even if it was paused beforehand. + setPlacementManuallyPaused(false); + lastAppliedPlacementKey.current = null; + if (payload.kind === "draw") { + if (parameters.signatureType !== "canvas") { + onParameterChange("signatureType", "canvas"); + } + handleCanvasSignatureChange(payload.dataUrl); + } else if (payload.kind === "photo") { + if (parameters.signatureType !== "image") { + onParameterChange("signatureType", "image"); + } + setImageSignatureData(payload.dataUrl); + } else { + if (parameters.signatureType !== "text") { + onParameterChange("signatureType", "text"); + } + onParameterChange("signerName", payload.text); + onParameterChange("fontFamily", payload.fontFamily); + onParameterChange("textColor", payload.color); + // Move the draft mirror in the same commit as the parameters. The + // record/restore effect pair otherwise sees them one commit apart and + // ping-pongs old draft against new params, wiping the received text + // and looping until React aborts the update depth. + const nextDraft = { + signerName: payload.text, + fontSize: parameters.fontSize ?? 16, + fontFamily: payload.fontFamily, + textColor: payload.color, + }; + lastSyncedTextDraft.current = nextDraft; + setSignatureDrafts((prev) => ({ ...prev, text: nextDraft })); + } + // Activate directly for every kind: the canvas-change handler only + // activates when the data actually changed, and the auto-activate + // effect only reacts to state transitions - neither fires for a + // repeat of the same signature. Fired twice because the first shot can + // land between the receive commit and the ready-state settling, where + // the placement effect immediately deactivates it; the second shot is + // after everything has settled, and re-activating is idempotent. + if (typeof window !== "undefined") { + window.setTimeout( + () => onActivateSignaturePlacement?.(), + PLACEMENT_ACTIVATION_DELAY, + ); + window.setTimeout(() => onActivateSignaturePlacement?.(), 500); + } else { + onActivateSignaturePlacement?.(); + } + }, + [ + parameters.signatureType, + parameters.fontSize, + onParameterChange, + handleCanvasSignatureChange, + onActivateSignaturePlacement, + ], + ); + const hasCanvasSignature = useMemo( () => Boolean(canvasSignatureData), [canvasSignatureData], @@ -1019,6 +1095,29 @@ const SignSettings = ({ if (signatureSource === "image") { return ( + {imageSignatureData && ( + + {translate("image.previewAlt", + + )} + {canDrawOnPhone && ( + <> + + setIsMobileSignModalOpen(false)} + onSignatureReceived={handleMobileSignatureReceived} + /> + + )} {sourceOptions.length > 1 && ( Date: Wed, 12 Aug 2026 09:06:05 +0000 Subject: [PATCH 76/99] =?UTF-8?q?a11y:=20empty=20the=20grandfathered=20Sto?= =?UTF-8?q?rybook=20baseline=20(1,058=20=E2=86=92=200)=20(#7309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Empties the light Storybook accessibility baseline — **1,058 grandfathered violations across 846 stories → 0** — so a new violation fails the gate instead of being silently absorbed. Also burns the dark baseline **812 → 56**; every entry left is one `main` already grandfathers. ## The defect, repeated everywhere A colour picked as a **fill**, chosen to carry a white label at 3:1, reused as **text**, where the floor is 4.5:1. It recurred through status accents, filled buttons, form labels, Mantine's light and outline variants, CSS declarations, inline styles and the generated accent ramp. Three systemic causes account for most of it: - **Mantine's semantic slots were never bound.** `-text`, `-outline`, `-light-color`, `-filled` and `-dimmed` all default to the hue's solid fill. Both resolvers now pin them to the accessible ink for the active scheme. - **The tint ladder was compressed.** `--color--50/100/200` pointed at saturated 400-level primitives, so every "tint" background rendered as a fill. - **Text was faded with `opacity`**, pushing already-muted copy below the floor. Each site now recedes via ink or surface, which is what conveyed the state anyway. ## Dark mode The colour resolver's dark half was empty, so dark fell through to Mantine's stock palette — and fixing the naming violations unmasked the contrast sitting underneath them. Both schemes now share one slot map, since most slots are written in tokens that already flip. The dark-only fixes: `--c-text-subtle` (3.0:1, used in 478 places), the error and section-label inks, and the accent ramp's text step — which light reaches by mixing toward black and dark has to reach by mixing toward white. ## Also - New `--c-*-solid` tokens for fills that must carry a white label, distinct from the `--c-` values used for surfaces, borders and icons. - A `data-user-content-preview` opt-out for nodes rendering a facsimile of the user's own document — WCAG governs the interface, not content authored through it. ## Verification - `task frontend:check:all` — green. - Changed-set gate, both schemes, after the final rebase: **366 stories, 0 regressions**. - Full sweep at the prior base — light **1,447 stories / 0 violations**, dark **1,448 / 0 regressions**. The dark re-record was confirmed key-by-key to be a strict subset of `main`'s, so nothing new is grandfathered. Roughly 28% of what this clears is naming and structure (`button-name`, `label`, `aria-*`) and has no visual signature; the rest is contrast. --- frontend/.gitignore | 1 + frontend/.storybook/a11y-baseline.dark.json | 2483 +--------------- frontend/.storybook/a11y-baseline.json | 2641 +---------------- frontend/.storybook/a11y-changed.mjs | 25 +- frontend/.storybook/preview.tsx | 59 +- .../public/locales/en-GB/translation.toml | 19 + .../public/locales/en-US/translation.toml | 16 + .../shared/config/configSections/Payg.css | 12 +- .../shared/config/configSections/PaygFree.css | 2 +- .../config/configSections/SpendCapControl.css | 4 +- .../config/configSections/usageMeters.tsx | 3 + .../src/core/components/StorageStatsCard.tsx | 1 + .../annotation/shared/ColorPicker.tsx | 8 + .../components/fileEditor/AddFileCard.tsx | 2 +- .../fileManager/CompactFileDetails.tsx | 2 +- .../components/fileManager/DragOverlay.tsx | 14 +- .../fileManager/EmptyFilesState.tsx | 2 +- .../components/fileManager/FileInfoCard.tsx | 16 +- .../core/components/filesPage/FileGrid.tsx | 384 +-- .../components/filesPage/FileOriginBadge.tsx | 4 +- .../core/components/filesPage/FilesPage.css | 35 +- .../components/filesPage/FolderThumbnail.tsx | 5 +- .../InitialOnboardingModal.module.css | 2 +- .../onboarding/OnboardingSlideShell.tsx | 214 +- .../slides/AnalyticsChoiceSlide.tsx | 2 +- .../onboarding/slides/FirstLoginSlide.tsx | 2 +- .../onboarding/slides/SecurityCheckSlide.tsx | 2 +- .../BulkSelectionPanel.module.css | 5 +- .../shared/DropdownListWithFooter.tsx | 7 + .../components/shared/EditableSecretField.tsx | 14 +- .../shared/EncryptedPdfUnlockModal.tsx | 2 +- .../core/components/shared/ErrorBoundary.tsx | 2 +- .../components/shared/FileDropdownMenu.tsx | 5 + .../src/core/components/shared/FileGrid.tsx | 1 + .../components/shared/FilePickerModal.tsx | 9 +- .../shared/FileSelectorPicker.module.css | 2 +- .../components/shared/FileSelectorPicker.tsx | 1 + .../core/components/shared/FileSidebar.css | 6 +- .../components/shared/FileSidebarFileItem.css | 8 +- .../src/core/components/shared/InfoBanner.tsx | 2 +- .../components/shared/MobileTransferModal.tsx | 5 + .../ObscuredOverlay.module.css | 2 +- .../shared/PageEditorFileDropdown.tsx | 5 + .../core/components/shared/ThemeProvider.tsx | 6 +- .../src/core/components/shared/ToolChain.tsx | 4 +- .../components/shared/ToolPanelHeader.css | 8 +- .../core/components/shared/UpdateModal.tsx | 1282 ++++---- .../core/components/shared/WorkbenchBar.css | 4 +- .../components/shared/ZipWarningModal.tsx | 2 +- .../config/configSections/GeneralSection.tsx | 36 +- .../shared/config/configSections/Overview.tsx | 2 +- .../config/configSections/ProviderCard.tsx | 2 +- .../shared/filePreview/DocumentThumbnail.tsx | 2 +- .../shared/signing/CreateSessionFlow.tsx | 5 +- .../sliderWithInput/SliderWithInput.tsx | 8 +- .../wetSignature/DrawSignatureCanvas.tsx | 13 + .../shared/wetSignature/TypeSignatureText.tsx | 19 +- .../wetSignature/UploadSignatureImage.tsx | 2 +- .../PageNumberPreview.module.css | 2 +- .../tools/addStamp/StampPreview.module.css | 2 +- .../tools/addStamp/StampPreview.tsx | 1 + .../addWatermark/WatermarkStyleSettings.tsx | 17 +- .../tools/addWatermark/WatermarkTextStyle.tsx | 9 +- .../tools/automate/AutomationImportModal.tsx | 15 +- .../tools/automate/AutomationRun.tsx | 2 +- .../BookletImpositionSettings.tsx | 4 +- .../modals/CertificateConfigModal.tsx | 4 +- .../certSign/panels/SignControlsPanel.tsx | 4 +- .../certSign/panels/SignRequestPanel.tsx | 2 +- .../certSign/steps/AddSignaturesStep.tsx | 2 +- .../compare/ComparePixelWorkbenchView.tsx | 2 +- .../tools/compress/CompressSettings.tsx | 7 +- .../convert/ConvertFromEmailSettings.tsx | 5 +- .../tools/convert/ConvertFromWebSettings.tsx | 10 +- .../tools/convert/ConvertToPdfaSettings.tsx | 5 +- .../tools/ocr/LanguagePicker.module.css | 2 +- .../components/tools/ocr/LanguagePicker.tsx | 2 +- .../tools/pdfTextEditor/FontStatusPanel.tsx | 4 +- .../tools/pdfTextEditor/PdfTextEditorView.tsx | 2 +- .../removeBlanks/RemoveBlanksSettings.tsx | 13 +- .../replaceColor/ReplaceColorSettings.tsx | 17 +- .../tools/shared/NumberInputWithUnit.tsx | 5 +- .../core/components/tools/shared/ToolStep.tsx | 3 +- .../components/tools/showJS/ShowJSView.css | 2 +- .../tools/toolPicker/FavoriteStar.tsx | 4 + .../reportView/SignatureSection.tsx | 2 +- .../reportView/SignatureStatusBadge.tsx | 43 +- .../components/viewer/AttachmentSidebar.css | 2 +- .../components/viewer/AttachmentSidebar.tsx | 2 +- .../components/viewer/BookmarkSidebar.tsx | 6 +- .../components/viewer/CommentsSidebar.tsx | 7 +- .../core/components/viewer/EmbedPdfViewer.tsx | 2 +- .../core/components/viewer/LayerSidebar.tsx | 2 +- .../core/components/viewer/LocalEmbedPDF.tsx | 6 +- .../core/components/viewer/SidebarBase.css | 2 +- .../components/viewer/nonpdf/JsonViewer.tsx | 2 +- .../components/viewer/nonpdf/TextViewer.tsx | 6 +- .../src/core/pages/MobileScannerPage.tsx | 2 +- frontend/editor/src/core/styles/index.css | 2 +- frontend/editor/src/core/styles/theme.css | 48 +- frontend/editor/src/core/theme/colors.css | 51 +- .../editor/src/core/theme/mantineTheme.ts | 129 + frontend/editor/src/core/theme/primitives.css | 30 +- frontend/editor/src/core/tokens/base.css | 2 +- frontend/editor/src/core/tokens/tokens.css | 43 +- .../core/tools/formFill/FormFill.module.css | 2 +- frontend/editor/src/core/ui/Banner.css | 6 +- frontend/editor/src/core/ui/Button.tsx | 8 + frontend/editor/src/core/ui/ChatFABButton.tsx | 7 +- .../src/core/ui/ChatFABWindow.stories.tsx | 4 +- frontend/editor/src/core/ui/ChatFABWindow.tsx | 4 + frontend/editor/src/core/ui/Chip.tsx | 20 +- frontend/editor/src/core/ui/CodeBlock.tsx | 10 +- frontend/editor/src/core/ui/Drawer.tsx | 6 +- frontend/editor/src/core/ui/Dropdown.css | 2 +- frontend/editor/src/core/ui/EmptyState.css | 2 +- frontend/editor/src/core/ui/FormField.css | 6 +- frontend/editor/src/core/ui/Forms.stories.tsx | 3 + frontend/editor/src/core/ui/ListRow.css | 10 +- frontend/editor/src/core/ui/MantineForms.css | 2 +- .../src/core/ui/MantineForms.stories.tsx | 3 + frontend/editor/src/core/ui/MethodBadge.css | 2 +- frontend/editor/src/core/ui/MetricCard.css | 4 +- frontend/editor/src/core/ui/NavItem.css | 12 +- frontend/editor/src/core/ui/PanelHeader.css | 8 +- .../src/core/ui/ProgressBar.stories.tsx | 9 +- frontend/editor/src/core/ui/ProgressBar.tsx | 5 +- frontend/editor/src/core/ui/Select.tsx | 4 + frontend/editor/src/core/ui/SettingsRow.tsx | 20 +- frontend/editor/src/core/ui/StatTile.css | 2 +- frontend/editor/src/core/ui/StatusBadge.css | 2 +- frontend/editor/src/core/ui/StepIndicator.tsx | 6 + frontend/editor/src/core/ui/Tabs.css | 4 +- frontend/editor/src/core/ui/ToggleSwitch.tsx | 4 + frontend/editor/src/core/ui/accents.css | 30 +- .../src/portal/components/AssistantPanel.tsx | 6 +- .../components/ChatFABWidget.stories.tsx | 6 +- .../portal/components/DownloadEditorModal.css | 4 +- .../components/NotificationsDropdown.css | 4 +- .../src/portal/components/ProcessorFlow.css | 4 +- .../src/portal/components/ProcessorFlow.tsx | 6 +- .../account-link/LinkedInstancesTable.tsx | 6 +- .../billing/PrepaidCapacityCard.tsx | 1 + .../components/billing/SpendLimitCard.tsx | 1 + .../portal/components/billing/WalletMeter.tsx | 1 + .../src/portal/components/billing/billing.css | 32 +- .../components/docs/GettingStartedSection.tsx | 6 +- .../components/docs/PlaybooksSection.tsx | 2 +- .../portal/components/docs/SdksSection.tsx | 2 +- .../portal/components/docs/SkillsSection.tsx | 2 +- .../infrastructure/DeploymentsTab.tsx | 9 +- .../components/infrastructure/ModelsTab.tsx | 7 +- .../procurement/ProcurementAgreement.tsx | 5 + .../components/users/ResetPasswordModal.tsx | 8 +- .../editor/src/portal/data/Ops.stories.tsx | 4 +- frontend/editor/src/portal/theme/base.css | 2 +- .../editor/src/portal/theme/mantineTheme.ts | 46 + .../editor/src/portal/views/DeveloperDocs.css | 18 +- .../editor/src/portal/views/DeveloperDocs.tsx | 11 +- .../editor/src/portal/views/EditorAdmin.css | 12 +- frontend/editor/src/portal/views/Home.css | 2 +- .../src/portal/views/Infrastructure.css | 6 +- .../editor/src/portal/views/Integrations.css | 4 +- .../editor/src/portal/views/Pipelines.css | 4 +- frontend/editor/src/portal/views/Policies.css | 17 +- .../editor/src/portal/views/Procurement.css | 14 +- frontend/editor/src/portal/views/Sources.css | 6 +- frontend/editor/src/portal/views/Users.css | 8 +- .../src/proprietary/auth/ui/OAuthButtons.tsx | 10 +- .../src/proprietary/billing/MeterBar.tsx | 4 + .../proprietary/components/chat/ChatPanel.css | 12 +- .../shared/ChangeUserPasswordModal.tsx | 435 +-- .../config/configSections/AccountSection.tsx | 2 +- .../AdminConnectionsSection.tsx | 6 +- .../configSections/AdminMailSection.tsx | 4 +- .../AdminStorageSharingSection.tsx | 8 +- .../configSections/LoginAgreementEditor.tsx | 2 +- .../configSections/TeamDetailsSection.tsx | 14 +- .../config/configSections/TeamsSection.tsx | 8 +- .../configSections/apiKeys/RefreshModal.tsx | 2 +- .../audit/AuditSystemStatus.tsx | 2 - .../plan/AvailablePlansSection.tsx | 1 + .../plan/FeatureComparisonTable.tsx | 2 +- .../config/configSections/plan/PlanCard.tsx | 2 +- .../dividerWithText/DividerWithText.css | 9 +- .../DeleteFolderConfirmModal.tsx | 2 +- .../watchedFolders/WatchedFolderHomePage.tsx | 4 +- .../WatchedFolderManagementModal.tsx | 2 +- .../WatchedFolderWorkbenchView.tsx | 10 +- .../watchedFolders/WatchedFolders.css | 6 +- .../components/workflow/ParticipantView.tsx | 4 +- .../editor/src/proprietary/routes/Login.tsx | 2 +- .../routes/login/LoggedInState.tsx | 2 +- .../proprietary/routes/signup/SignupForm.tsx | 4 + .../onboarding/OnboardingChecklist.module.css | 4 +- frontend/editor/src/saas/routes/Login.tsx | 4 +- frontend/editor/src/saas/routes/Signup.tsx | 2 +- 197 files changed, 2368 insertions(+), 6641 deletions(-) diff --git a/frontend/.gitignore b/frontend/.gitignore index c0c467073d..9ab1c65091 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -49,3 +49,4 @@ test-results /scripts/dev-update-test/screenshots/ /editor/src-tauri/tauri.conf.dev-update.json .a11y-scan/ +.a11y-acc/ diff --git a/frontend/.storybook/a11y-baseline.dark.json b/frontend/.storybook/a11y-baseline.dark.json index fb461fe6d2..0967ef424b 100644 --- a/frontend/.storybook/a11y-baseline.dark.json +++ b/frontend/.storybook/a11y-baseline.dark.json @@ -1,2482 +1 @@ -{ - "editor/src/core/assets/Brand.stories.tsx :: Logos": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Nearing Quota": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: No Quota": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: Default": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: With Opacity": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Background Removal": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Label And Hint": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Empty": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Cloud Only": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Local And Cloud Choice": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: With Files": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: In Folder": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Local Only With Save To Server": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Multi Select": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: List Mode": [ - "aria-required-children" - ], - "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Cloud": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: Disabled": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx :: No Appearance Set": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Rename": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderThumbnail.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Create Folder": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Disabled Descendant": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: No File Selected": [ - "button-name" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Long Chain Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: No Header": [ - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Enabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice Error": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Desktop Install": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login Default Credentials": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Mfa Setup": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Security Check": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License Over Limit": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Stepped Flow Example": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Tour Overview": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Welcome": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Not Dismissible": [ - "aria-dialog-name", - "aria-progressbar-name" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Stepped With Back": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx :: With Expression": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx :: Empty Input": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Syntax Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Empty": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Multi Select With Footer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked Disabled": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: With Error": [ - "label-title-only" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Incorrect Password": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Multiple Files Remaining": [ - "button-name" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Processing": [ - "button-name" - ], - "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: No Remove": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Switching": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Search And Sort": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileUploadButton.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileUploadButton.stories.tsx :: With File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/Footer.stories.tsx :: All Links And Cookie Banner": [ - "color-contrast" - ], - "editor/src/core/components/shared/InfoBanner.stories.tsx :: Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/MobileUploadModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "svg-img-alt" - ], - "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: All Actions": [ - "color-contrast" - ], - "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Apply And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Export And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ObscuredOverlay.stories.tsx :: Unobscured": [ - "color-contrast" - ], - "editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx :: Compact Syntax Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx :: Syntax Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Default": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Empty": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/shared/PolicyBadges.stories.tsx :: Enforcing": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Blocked": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Ready To Restart": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Already Uploaded": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UserSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Saving": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Admin Banner": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: With Backend Version": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Admin": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: With Analytics Enabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Loaded": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: With Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Read Only": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Creating": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Pending Requests": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Empty": [ - "aria-input-field-name" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/toast/ToastRenderer.stories.tsx :: With Action Button": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Text": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Image Stamp": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Only": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Watermark": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Without Flatten Option": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx :: With Selected Image": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Default": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Edit Existing": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Embedded Hide Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: With Settings": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolSelector.stories.tsx :: Custom Placeholder": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Manual Duplex": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Auto Sign Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Jks": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Pkcs 12": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Pem Format": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: All Sources Available": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Server Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Minimal Details": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Upload Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Disabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Multiple Signatures": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: All Signed": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: No Signature Chosen": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Placement Mode": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Upload Ready": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: User Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Multiple Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Uploaded Certificate Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Default": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Disabled": [ - "aria-input-field-name" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Type Mode": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: With Signature": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Placed": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: With Custom Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: With Entries": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [ - "button-name" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: No Differences": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: With Warnings": [ - "color-contrast" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: File Size Method": [ - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Line Art Enabled": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Strict Mode": [ - "label" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Loading With Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Results": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Partial Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/ocr/OCRSettings.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Automatic With Words": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: With Words": [ - "color-contrast" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Include Blank Pages": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Custom Color": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Custom Title": [ - "button-name" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: With Min Max": [ - "label" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Single File": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: With Download": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Default": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Favorited": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Sizes": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Dropdown Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx :: Unstyled": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Default": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Multiple Signatures": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: No Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: With Cert File": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Invalid With Error": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Self Signed Minimal Data": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Invalid": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Untrusted Signer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Markdown": [ - "color-contrast" - ], - "editor/src/core/tokens/Tokens.stories.tsx :: Colours": ["color-contrast"], - "editor/src/core/tokens/Tokens.stories.tsx :: Typography": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Success": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Tone Matrix": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: With Action": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Disabled Dark": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Justify": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/Button.stories.tsx :: Padding": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Shape": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Variants": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: With Icons": ["color-contrast"], - "editor/src/core/ui/Card.stories.tsx :: Accent Matrix": ["color-contrast"], - "editor/src/core/ui/Card.stories.tsx :: In Context Metrics Inside Card": [ - "color-contrast" - ], - "editor/src/core/ui/Card.stories.tsx :: In Context Product Grid": [ - "color-contrast" - ], - "editor/src/core/ui/Card.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Default": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [ - "button-name" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"], - "editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [ - "color-contrast" - ], - "editor/src/core/ui/Chip.stories.tsx :: Playground": [ - "color-contrast", - "nested-interactive" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Quickstart": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Two Up Comparison": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/Collapsible.stories.tsx :: Default": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/DataRow.stories.tsx :: Single": ["color-contrast"], - "editor/src/core/ui/DataRow.stories.tsx :: Summary": ["color-contrast"], - "editor/src/core/ui/Drawer.stories.tsx :: Playground": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/Drawer.stories.tsx :: With Footer": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/Dropdown.stories.tsx :: Align Start": ["color-contrast"], - "editor/src/core/ui/Dropdown.stories.tsx :: Basic": ["color-contrast"], - "editor/src/core/ui/Dropdown.stories.tsx :: With Divider": ["color-contrast"], - "editor/src/core/ui/Dropdown.stories.tsx :: With Trailing Hints": [ - "color-contrast" - ], - "editor/src/core/ui/EmptyState.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/EmptyState.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/EmptyState.stories.tsx :: With CT As": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Accept Pdf": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Multiple": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Checkbox Grid Of Categories": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Checkbox Single": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Full Form": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Input Default": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input Error": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input With Icon": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Group": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Horizontal": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Confidence": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Retention": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Inline.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/Inline.stories.tsx :: Wrap": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: Interactive": ["color-contrast"], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Preselected": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Sm Size": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select With Values": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Decimal": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input With Unit": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Searchable": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Disabled": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider No Label": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider With Marks": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Watermark Form": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MethodBadge.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: In Row": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: Matrix": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Free Tier Strip": [ - "color-contrast" - ], - "editor/src/core/ui/MetricCard.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Pro Tier Strip": [ - "color-contrast" - ], - "editor/src/core/ui/MetricStrip.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/NavItem.stories.tsx :: In Context Sidebar Group": [ - "color-contrast" - ], - "editor/src/core/ui/NavItem.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/NavItem.stories.tsx :: With Trailing Badge": [ - "color-contrast" - ], - "editor/src/core/ui/PanelHeader.stories.tsx :: With Actions": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: In Context Usage Meter": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Playground": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Threshold Ladder": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/SectionDivider.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/ui/SectionHeader.stories.tsx :: Collapsible": [ - "color-contrast" - ], - "editor/src/core/ui/SectionHeader.stories.tsx :: Static": ["color-contrast"], - "editor/src/core/ui/SegmentedControl.stories.tsx :: Variants": [ - "color-contrast" - ], - "editor/src/core/ui/SegmentedControl.stories.tsx :: With Icons": [ - "color-contrast" - ], - "editor/src/core/ui/SettingsRow.stories.tsx :: List": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Select Control": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Toggle": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: With Description": [ - "color-contrast", - "label" - ], - "editor/src/core/ui/SettingsShell.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/Stack.stories.tsx :: Gap Sizes": ["color-contrast"], - "editor/src/core/ui/Stack.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/StatTile.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/StatTile.stories.tsx :: Tone Row": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: All Tones": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/StepIndicator.stories.tsx :: Small": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 1": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 2": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 3": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"], - "editor/src/core/ui/Table.stories.tsx :: Empty": ["color-contrast"], - "editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"], - "editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: Playground": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"], - "editor/src/core/ui/ToggleSwitch.stories.tsx :: In Context Settings Rows": [ - "color-contrast" - ], - "editor/src/core/ui/ToggleSwitch.stories.tsx :: With Description": [ - "color-contrast" - ], - "editor/src/portal/components/AppShell.stories.tsx :: Mobile": [ - "color-contrast" - ], - "editor/src/portal/components/AppShell.stories.tsx :: With Home View": [ - "color-contrast" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Reply Fails": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Slow Reply": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Suggestions Only": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Full Flow": [ - "aria-hidden-focus", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Loading While Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Unread Result": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/DownloadEditorModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: Deployment Unavailable": [ - "color-contrast" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/LinkAccountFooterItem.stories.tsx :: Unlinked": [ - "color-contrast" - ], - "editor/src/portal/components/PortalChrome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Idle Empty": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Playground": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Empty Catalogue": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Almost Done": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: In Progress": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Not Started": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Enterprise Tier": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/WelcomeBanner.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Load Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linking": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Unconfigured": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountModal.stories.tsx :: Reauth": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/billing/ActivationChoiceModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: First Purchase": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: Top Up": [ - "color-contrast" - ], - "editor/src/portal/components/billing/CardPlaceholder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/EnterpriseUpsell.stories.tsx :: Bare": [ - "color-contrast" - ], - "editor/src/portal/components/billing/EnterpriseUpsell.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/FreePdfEditorsCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Member": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/InvoicesList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PaymentMethodCard.stories.tsx :: Managed In Stripe": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PaymentMethodCard.stories.tsx :: With Card": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: Unsynced Only": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: With Breakdown": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx :: With Unsynced": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Healthy": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Low": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Offer Nudge": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepayModalHeader.stories.tsx :: Step Of Three": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepayModalHeader.stories.tsx :: Step Of Two": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Editing": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: No Cap": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Within Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: With Free Remaining": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Set Cap": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Uncapped": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: With Prepaid": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Approaching Limit": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Limit Reached": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Plenty Left": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/docs/AuthenticationSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ComponentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Badged Leaf Active": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/EndpointReferenceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ErrorsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/GettingStartedSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Single Language": [ - "color-contrast" - ], - "editor/src/portal/components/docs/PlaybooksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Ga Only": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SkillsSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/WebhooksSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Approved": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Default": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Sensitive": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Masked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Unlocked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentOverview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Needs Review": [ - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Recently Rotated": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Available": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Personal": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Revoked": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Export Fails": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Non Lead Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Team Lead Scoped": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/SectionHeader.stories.tsx :: Short Sub": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Multiple Selected": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Nothing Selected": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Incompatible Preceding Output": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: No Matches": [ - "color-contrast" - ], - "editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/policies/CatalogueSummary.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Coming Soon": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Not Set Up": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Active": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Custom No Activity": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Paused": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: With Flagged Items": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Custom Api Escape Hatch": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Notify Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Chips": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Select": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Text": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Connection Selected": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Classification": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Create": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Edit": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Pay": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Request Paid": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Sign": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ActionModal.stories.tsx :: Upload PO": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Agreement": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Quote": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Complete": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Download": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Paid Addon": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Sign Action": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/LockedState.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Agreeing": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Downloading": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Deal Underway": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Upsell": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License Trial": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Schedule Call": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage Maxed": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Setup": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Unlinked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementHome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Downloading": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Online Only": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment Pending": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/QuoteBuilder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: At Agreement": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Conditional Fields Revealed": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Edit": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Fixed Type": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Delegated Create": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Load Error": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionPicker.stories.tsx :: Preset Scoped": [ - "color-contrast" - ], - "editor/src/portal/components/sources/KpiStrip.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/sources/KpiStrip.stories.tsx :: Ready": [ - "color-contrast" - ], - "editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Choose Type": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Folder": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Webhook": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourcesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Danger": [ - "color-contrast" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Neutral": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Create Account Form": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: No Admin Role": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Scoped To Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted Direct Create": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted No Mail": [ - "color-contrast" - ], - "editor/src/portal/components/users/MoveToTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/NewTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Expires Today": [ - "color-contrast" - ], - "editor/src/portal/components/users/RenameTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: With Email": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Large Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Member States": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Org Only": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Saas Team Leader": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Single Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Team Wide Processor": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: With Guests": [ - "color-contrast" - ], - "editor/src/portal/data/Endpoints.stories.tsx :: By Vertical": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Agents": ["color-contrast"], - "editor/src/portal/data/Ops.stories.tsx :: Library By Category": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Pipeline Ops": ["color-contrast"], - "editor/src/portal/data/Ops.stories.tsx :: Sources And Destinations": [ - "color-contrast" - ], - "editor/src/portal/theme/MantineIntegration.stories.tsx :: Side By Side": [ - "color-contrast" - ], - "editor/src/portal/views/DeveloperDocs.stories.tsx :: Default": [ - "color-contrast", - "landmark-unique" - ], - "editor/src/portal/views/Documents.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Documents.stories.tsx :: Empty": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Free Tier": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Pro Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Subscribed In Procurement": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Integrations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Integrations.stories.tsx :: No Connections": [ - "color-contrast" - ], - "editor/src/portal/views/PipelineBuilder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"], - "editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"], - "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], - "editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"], - "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: First Time Setup": [ - "color-contrast" - ], - "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ - "aria-hidden-focus" - ], - "editor/src/proprietary/components/policies/ClassificationCategoryManager.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Default": [ - "button-name", - "label" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Mail Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Non Email Username": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Subcategory": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx :: Custom Return Url": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Admin Banner": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: With Backend Version": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Signed In": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Mfa Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Audit Logging Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Login Disabled": [ - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: All Fields Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Interactive": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: With Filters Applied": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Day": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Month": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: With Currency Selector": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Current Tier": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Hosted Checkout Success": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: With Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Network Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: With Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Polling": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Timeout": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Upgrade Complete": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/routes/login/OAuthButtons.stories.tsx :: Vertical": [ - "image-redundant-alt" - ] -} +{} diff --git a/frontend/.storybook/a11y-baseline.json b/frontend/.storybook/a11y-baseline.json index 0245e3101a..0967ef424b 100644 --- a/frontend/.storybook/a11y-baseline.json +++ b/frontend/.storybook/a11y-baseline.json @@ -1,2640 +1 @@ -{ - "editor/src/core/assets/Brand.stories.tsx :: Logos": [ - "scrollable-region-focusable" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: Nearing Quota": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/StorageStatsCard.stories.tsx :: No Quota": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: Default": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ColorPicker.stories.tsx :: With Opacity": [ - "aria-input-field-name", - "button-name", - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/DrawingControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Background Removal": [ - "color-contrast" - ], - "editor/src/core/components/annotation/shared/ImageUploader.stories.tsx :: With Label And Hint": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/DrawingTool.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/DrawingTool.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/annotation/tools/ImageTool.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/CompactFileDetails.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/CompactFileDetails.stories.tsx :: Multiple Files": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Compact": [ - "color-contrast" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/fileManager/FileDetails.stories.tsx :: Empty": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Cloud Only": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx :: Local And Cloud Choice": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx :: With Files": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: In Folder": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Local Only With Save To Server": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx :: Multi Select": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileGrid.stories.tsx :: List Mode": [ - "aria-required-children" - ], - "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Cloud": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FileOriginBadge.stories.tsx :: Shared With Me": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/filesPage/FolderNameDialog.stories.tsx :: Rename": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/FolderThumbnail.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Create Folder": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx :: With Disabled Descendant": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx :: No File Selected": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: Long Chain Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/filesPage/VersionTimeline.stories.tsx :: No Header": [ - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Admin Overview Login Enabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Analytics Choice Error": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Desktop Install": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: First Login Default Credentials": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Mfa Setup": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Security Check": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Server License Over Limit": [ - "aria-dialog-name" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Stepped Flow Example": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Tour Overview": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx :: Welcome": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Not Dismissible": [ - "aria-dialog-name", - "aria-progressbar-name" - ], - "editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx :: Stepped With Back": [ - "aria-dialog-name", - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkShareModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ButtonToggle.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/ButtonToggle.stories.tsx :: Small": [ - "color-contrast" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Empty": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/DropdownListWithFooter.stories.tsx :: Multi Select With Footer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: Masked Disabled": [ - "label" - ], - "editor/src/core/components/shared/EditableSecretField.stories.tsx :: With Error": [ - "color-contrast", - "label-title-only" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Incorrect Password": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Multiple Files Remaining": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx :: Processing": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/core/components/shared/ErrorBoundary.stories.tsx :: Custom Fallback": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileCard.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: No Remove": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileDropdownMenu.stories.tsx :: Switching": [ - "aria-allowed-attr" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileGrid.stories.tsx :: Search And Sort": [ - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/shared/FilePickerModal.stories.tsx :: Empty": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Custom Placeholder": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/FileSelectorPicker.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/Footer.stories.tsx :: All Links And Cookie Banner": [ - "color-contrast" - ], - "editor/src/core/components/shared/Footer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/InfoBanner.stories.tsx :: Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/MobileUploadModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "svg-img-alt" - ], - "editor/src/core/components/shared/MultiSelectControls.stories.tsx :: All Actions": [ - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Apply And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/NavigationWarningModal.stories.tsx :: With Export And Continue": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareFileModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ShareManagementModal.stories.tsx :: Links Enabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Blocked": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UpdateModal.stories.tsx :: Desktop Install Ready To Restart": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Already Uploaded": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UploadToServerModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/UserSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/ZipWarningModal.stories.tsx :: Single File": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/OverviewHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/PendingBadge.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/PendingBadge.stories.tsx :: Large Size": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx :: Saving": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Admin Banner": [ - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx :: With Backend Version": [ - "label" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Admin": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: Minimal Links": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx :: With Analytics Enabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: Loaded": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/Overview.stories.tsx :: With Warning": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx :: Read Only": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx :: Frontend": [ - "color-contrast" - ], - "editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx :: Encrypted": [ - "color-contrast" - ], - "editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Creating": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx :: Pending Requests": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx :: Invisible Signature": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: Multiple Files Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx :: Empty": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx :: With Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolLoadingFallback.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolLoadingFallback.stories.tsx :: With Tool Name": [ - "color-contrast" - ], - "editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/ToolRenderer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Quick Grid": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampPreview.stories.tsx :: With Text": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Image Stamp": [ - "color-contrast" - ], - "editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx :: Text Stamp With Preview": [ - "color-contrast" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Only": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx :: Text Watermark": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx :: Without Flatten Option": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Default": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Adjusted": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/automate/AutomationCreation.stories.tsx :: Edit Existing": [ - "color-contrast" - ], - "editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx :: With Settings": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx :: Manual Duplex": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Auto Sign Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx :: Auto Sign Mode": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx :: Pem Format": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: All Sources Available": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx :: Server Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Invisible": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx :: Minimal Details": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx :: Visible Signature": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx :: Upload Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Disabled": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx :: Multiple Signatures": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: All Signed": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx :: Finalized": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx :: No Signature Chosen": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx :: Placement Mode": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: Upload Ready": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx :: User Certificate": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Multiple Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx :: Uploaded Certificate Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Disabled": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: Type Mode": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx :: With Signature": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx :: Placed": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx :: With Custom Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx :: With Entries": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [ - "button-name" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: No Differences": [ - "color-contrast" - ], - "editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: With Warnings": [ - "color-contrast" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: File Size Method": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/compress/CompressSettings.stories.tsx :: Line Art Enabled": [ - "aria-input-field-name", - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Azw 3 Output": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx :: Strict Mode": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Custom Area": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx :: Automation Info": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: Loading With Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx :: No File Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx :: With Results": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Custom Render Dpi": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx :: Flatten Only Forms": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx :: Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx :: No Data": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx :: Partial Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: All Passed": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx :: Hidden Title": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx :: Custom Empty Message": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/ocr/OCRSettings.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx :: Default": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Automatic With Words": [ - "button-name" - ], - "editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Default": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx :: Include Blank Pages": [ - "aria-input-field-name", - "label" - ], - "editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx :: Invalid Input": [ - "color-contrast" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Custom Color": [ - "button-name", - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Default": [ - "label" - ], - "editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx :: Disabled": [ - "label" - ], - "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: All Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Custom Title": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/shared/ErrorNotification.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/components/tools/shared/FileMetadata.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/FileMetadata.stories.tsx :: Unknown Type": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NavigationControls.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NavigationControls.stories.tsx :: Last File": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NoToolsFound.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx :: With Min Max": [ - "label" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ResultsPreview.stories.tsx :: Single File": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: Collapsed": [ - "color-contrast" - ], - "editor/src/core/components/tools/shared/ToolStep.stories.tsx :: With Help Text And Number": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/showJS/ShowJSView.stories.tsx :: With Download": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Admin With Shared Delete": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: At Capacity": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: By Poster": [ - "color-contrast" - ], - "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: By Sections": [ - "color-contrast" - ], - "editor/src/core/components/tools/split/SplitSettings.stories.tsx :: No Method Selected": [ - "color-contrast" - ], - "editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Default": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Favorited": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx :: Sizes": [ - "aria-prohibited-attr" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Default": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: Multiple Signatures": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx :: No Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx :: With Cert File": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx :: Empty Value": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: Missing Metadata": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx :: No Signatures": [ - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Default": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Invalid With Error": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx :: Self Signed Minimal Data": [ - "aria-allowed-attr", - "color-contrast" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Default": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Invalid": [ - "aria-allowed-attr" - ], - "editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx :: Untrusted Signer": [ - "aria-allowed-attr" - ], - "editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx :: Unsupported File": [ - "color-contrast" - ], - "editor/src/core/components/viewer/NonPdfViewer.stories.tsx :: Csv": [ - "color-contrast" - ], - "editor/src/core/components/viewer/NonPdfViewer.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/core/components/viewer/SearchInterface.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/SearchInterface.stories.tsx :: Hidden": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx :: Tsv": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx :: Invalid Json": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx :: Markdown": [ - "color-contrast" - ], - "editor/src/core/tokens/Tokens.stories.tsx :: Colours": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Success": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: Tone Matrix": ["color-contrast"], - "editor/src/core/ui/Banner.stories.tsx :: With Action": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Disabled Dark": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/Button.stories.tsx :: Padding": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Shape": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: Variants": ["color-contrast"], - "editor/src/core/ui/Button.stories.tsx :: With Icons": ["color-contrast"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Default": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Loading": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick": ["button-name"], - "editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [ - "button-name" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"], - "editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"], - "editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [ - "color-contrast" - ], - "editor/src/core/ui/Chip.stories.tsx :: Playground": [ - "color-contrast", - "nested-interactive" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Quickstart": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: In Context Two Up Comparison": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Long Scrolling": [ - "color-contrast" - ], - "editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/Collapsible.stories.tsx :: Default": [ - "scrollable-region-focusable" - ], - "editor/src/core/ui/Drawer.stories.tsx :: Playground": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/Drawer.stories.tsx :: With Footer": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/core/ui/EmptyState.stories.tsx :: With CT As": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Accept Pdf": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/FilePicker.stories.tsx :: Multiple": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Checkbox Grid Of Categories": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Full Form": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Input Default": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input Error": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Input With Icon": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Group": ["color-contrast"], - "editor/src/core/ui/Forms.stories.tsx :: Radio Horizontal": [ - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Confidence": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Forms.stories.tsx :: Slider Retention": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/Inline.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/Inline.stories.tsx :: Space Between": ["color-contrast"], - "editor/src/core/ui/Inline.stories.tsx :: Wrap": ["color-contrast"], - "editor/src/core/ui/ListRow.stories.tsx :: In Card": ["color-contrast"], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Default": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Error": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Preselected": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Color Input Sm Size": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Multi Select With Values": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Decimal": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Number Input With Unit": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Default": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Error": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Searchable": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Select Sm Size": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Default": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider Disabled": [ - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider No Label": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Slider With Marks": [ - "aria-input-field-name", - "color-contrast" - ], - "editor/src/core/ui/MantineForms.stories.tsx :: Watermark Form": [ - "button-name", - "color-contrast" - ], - "editor/src/core/ui/MethodBadge.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: In Row": ["color-contrast"], - "editor/src/core/ui/MethodBadge.stories.tsx :: Matrix": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Playground": ["color-contrast"], - "editor/src/core/ui/MetricCard.stories.tsx :: Pro Tier Strip": [ - "color-contrast" - ], - "editor/src/core/ui/MetricStrip.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/NavItem.stories.tsx :: In Context Sidebar Group": [ - "color-contrast" - ], - "editor/src/core/ui/PanelHeader.stories.tsx :: With Actions": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: In Context Usage Meter": [ - "color-contrast" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Playground": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/ProgressBar.stories.tsx :: Threshold Ladder": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/SegmentedControl.stories.tsx :: Variants": [ - "color-contrast" - ], - "editor/src/core/ui/SegmentedControl.stories.tsx :: With Icons": [ - "color-contrast" - ], - "editor/src/core/ui/SettingsRow.stories.tsx :: List": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Select Control": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: Toggle": ["label"], - "editor/src/core/ui/SettingsRow.stories.tsx :: With Description": ["label"], - "editor/src/core/ui/SettingsShell.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/StatTile.stories.tsx :: Tone Row": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: All Tones": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Default": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Live": ["color-contrast"], - "editor/src/core/ui/StatusBadge.stories.tsx :: Sizes": ["color-contrast"], - "editor/src/core/ui/StepIndicator.stories.tsx :: Small": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 1": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 2": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/StepIndicator.stories.tsx :: Step 3": [ - "aria-progressbar-name" - ], - "editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"], - "editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"], - "editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: Playground": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"], - "editor/src/portal/components/AppShell.stories.tsx :: Mobile": [ - "color-contrast" - ], - "editor/src/portal/components/AppShell.stories.tsx :: With Home View": [ - "color-contrast" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Reply Fails": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Slow Reply": [ - "aria-allowed-role" - ], - "editor/src/portal/components/AssistantPanel.stories.tsx :: Suggestions Only": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Full Flow": [ - "aria-hidden-focus", - "color-contrast" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Loading While Closed": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/ChatFABWidget.stories.tsx :: Unread Result": [ - "aria-hidden-focus" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: Deployment Unavailable": [ - "color-contrast" - ], - "editor/src/portal/components/EditorStatusCard.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/ErrorBoundary.stories.tsx :: Caught Error": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/HomeHero.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/PortalChrome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Idle Empty": [ - "color-contrast" - ], - "editor/src/portal/components/ProcessorFlow.stories.tsx :: Playground": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/SearchModal.stories.tsx :: Empty Catalogue": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Almost Done": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: In Progress": [ - "color-contrast" - ], - "editor/src/portal/components/SetupChecklist.stories.tsx :: Not Started": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Enterprise Tier": [ - "color-contrast" - ], - "editor/src/portal/components/Sidebar.stories.tsx :: Free Tier": [ - "color-contrast" - ], - "editor/src/portal/components/WelcomeBanner.stories.tsx :: With Setup Checklist": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Load Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Error": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Linking": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Not Linked": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkAccountCard.stories.tsx :: Unconfigured": [ - "color-contrast" - ], - "editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/billing/ActivationChoiceModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: First Purchase": [ - "color-contrast" - ], - "editor/src/portal/components/billing/BundleCheckoutModal.stories.tsx :: Top Up": [ - "color-contrast" - ], - "editor/src/portal/components/billing/CardPlaceholder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/FreePlanView.stories.tsx :: Member": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/InvoicesList.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Healthy": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx :: Bundle Low": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Editing": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SpendLimitCard.stories.tsx :: Within Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx :: With Free Remaining": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Set Cap": [ - "color-contrast" - ], - "editor/src/portal/components/billing/StripeCheckoutModal.stories.tsx :: Uncapped": [ - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Approaching Cap": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: Leader": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/SubscribedPlanView.stories.tsx :: With Prepaid": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Approaching Limit": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Limit Reached": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/billing/WalletMeter.stories.tsx :: Free Plenty Left": [ - "aria-progressbar-name", - "color-contrast" - ], - "editor/src/portal/components/docs/AuthenticationSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ComponentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Badged Leaf Active": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsNav.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/DocsSection.stories.tsx :: Without Lead": [ - "color-contrast" - ], - "editor/src/portal/components/docs/EndpointReferenceSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/ErrorsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/GettingStartedSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/docs/LangSnippet.stories.tsx :: Single Language": [ - "color-contrast" - ], - "editor/src/portal/components/docs/PlaybooksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/docs/RateLimitsSection.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SdksSection.stories.tsx :: Ga Only": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/SkillsSection.stories.tsx :: Default": [ - "color-contrast", - "heading-order" - ], - "editor/src/portal/components/docs/WebhooksSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Approved": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentAudit.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Default": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentDrawer.stories.tsx :: Sensitive": [ - "aria-allowed-role", - "color-contrast" - ], - "editor/src/portal/components/documents/DocumentExtractions.stories.tsx :: Masked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Granted Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ElevationBanner.stories.tsx :: Locked Four Eyes": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueue.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Default": [ - "aria-prohibited-attr", - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Empty": [ - "empty-table-header" - ], - "editor/src/portal/components/documents/ReviewQueueTable.stories.tsx :: Needs Review": [ - "color-contrast", - "empty-table-header", - "nested-interactive" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/CredentialRotationCard.stories.tsx :: Recently Rotated": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentSummaryStrip.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/DeploymentTargets.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/InstanceHealthTable.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/OfflineActivationCard.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/editor-admin/PairingPanel.stories.tsx :: Pro": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Personal": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeyCard.stories.tsx :: Revoked": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ApiKeysTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Export Fails": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditExportModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Non Lead Forbidden": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/AuditTab.stories.tsx :: Team Lead Scoped": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineDefinitionModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Editing": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Paused": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: Testing": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Failed Run": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineHeader.stories.tsx :: With Run Result": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Failed Step": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineInspector.stories.tsx :: Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: Unsupported": [ - "color-contrast" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/pipelines/PipelinesTable.stories.tsx :: Empty": [ - "empty-table-header" - ], - "editor/src/portal/components/pipelines/ToolPicker.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Coming Soon": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyCategoryCard.stories.tsx :: Not Set Up": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Active": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Custom No Activity": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: Paused": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyDetailPanel.stories.tsx :: With Flagged Items": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Custom Api Escape Hatch": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx :: Notify Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Chips": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Select": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyFieldRow.stories.tsx :: Text": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Configured": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Connection Selected": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Classification": [ - "color-contrast" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Create": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/policies/PolicySetupWizard.stories.tsx :: Edit": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealJourney.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Agreement": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Live": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Quote": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DealStatusHero.stories.tsx :: Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Complete": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Download": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Paid Addon": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocRow.stories.tsx :: Sign Action": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: At Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/DocumentLedger.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/LockedState.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Agreeing": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx :: Downloading": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Deal Underway": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementBanner.stories.tsx :: Upsell": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: License Trial": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Manage": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementExtras.stories.tsx :: Trial Setup": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Default": [ - "color-contrast", - "scrollable-region-focusable" - ], - "editor/src/portal/components/procurement/ProcurementFlow.stories.tsx :: Unlinked": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementHome.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Downloading": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: License Online Only": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/ProcurementStages.stories.tsx :: Payment": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/QuoteBuilder.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/procurement/StageStepper.stories.tsx :: Locked": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Conditional Fields Revealed": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionForm.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Edit": [ - "color-contrast" - ], - "editor/src/portal/components/sources/ConnectionModal.stories.tsx :: Fixed Type": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Folder": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourceModal.stories.tsx :: Edit Webhook": [ - "color-contrast" - ], - "editor/src/portal/components/sources/SourcesTable.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Danger": [ - "color-contrast" - ], - "editor/src/portal/components/users/ConfirmModal.stories.tsx :: Neutral": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Create Account Form": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: No Admin Role": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Open": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Scoped To Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted Direct Create": [ - "color-contrast" - ], - "editor/src/portal/components/users/InviteMemberModal.stories.tsx :: Self Hosted No Mail": [ - "color-contrast" - ], - "editor/src/portal/components/users/MoveToTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/NewTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/portal/components/users/PendingInvitations.stories.tsx :: Expires Today": [ - "color-contrast" - ], - "editor/src/portal/components/users/RenameTeamModal.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: Default": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/ResetPasswordModal.stories.tsx :: With Email": [ - "color-contrast", - "label" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Large Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Member States": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Org Only": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Saas Team Leader": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Single Team": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: Team Wide Processor": [ - "color-contrast" - ], - "editor/src/portal/components/users/UsersDirectory.stories.tsx :: With Guests": [ - "color-contrast" - ], - "editor/src/portal/data/Endpoints.stories.tsx :: By Vertical": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Agents": ["color-contrast"], - "editor/src/portal/data/Ops.stories.tsx :: Library By Category": [ - "color-contrast" - ], - "editor/src/portal/data/Ops.stories.tsx :: Pipeline Ops": ["color-contrast"], - "editor/src/portal/theme/MantineIntegration.stories.tsx :: Side By Side": [ - "color-contrast" - ], - "editor/src/portal/views/DeveloperDocs.stories.tsx :: Default": [ - "color-contrast", - "landmark-unique" - ], - "editor/src/portal/views/Documents.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Documents.stories.tsx :: Empty": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Free Tier": ["color-contrast"], - "editor/src/portal/views/Home.stories.tsx :: Pro Tier": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Home.stories.tsx :: Subscribed In Procurement": [ - "color-contrast", - "landmark-no-duplicate-banner", - "landmark-unique" - ], - "editor/src/portal/views/Integrations.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/portal/views/Integrations.stories.tsx :: No Connections": [ - "color-contrast" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Pipelines.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"], - "editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"], - "editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [ - "aria-hidden-focus" - ], - "editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx :: With Errors": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx :: With Selection": [ - "color-contrast" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Default": [ - "button-name", - "color-contrast", - "label" - ], - "editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx :: Disabled": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Default": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Mail Disabled": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx :: Non Email Username": [ - "aria-dialog-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Admin Banner": [ - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: Default": [ - "label" - ], - "editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx :: With Backend Version": [ - "label" - ], - "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx :: Signed In": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Mfa Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx :: Sso User": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Audit Logging Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Load Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx :: Loading": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Default": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Empty": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx :: Login Disabled": [ - "color-contrast", - "empty-table-header" - ], - "editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx :: With File Metadata Columns": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: All Fields Enabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: Interactive": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx :: With Filters Applied": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Day": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx :: Month": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx :: Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: Login Disabled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx :: With Currency Selector": [ - "color-contrast", - "label" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Current Tier": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Enterprise Plan": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx :: Free Plan": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx :: Empty": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Default": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx :: Hosted Checkout Success": [ - "button-name", - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Enterprise With Total": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx :: Simple": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Current": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Popular": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx :: Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: Filled": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx :: With Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx :: Network Error": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx :: Redirecting": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: Enterprise": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx :: With Savings": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Polling": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Timeout": [ - "color-contrast" - ], - "editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx :: Upgrade Complete": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Completed": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Default": [ - "color-contrast" - ], - "editor/src/proprietary/components/workflow/ParticipantView.stories.tsx :: Expired": [ - "color-contrast" - ], - "editor/src/proprietary/routes/login/OAuthButtons.stories.tsx :: Vertical": [ - "image-redundant-alt" - ] -} +{} diff --git a/frontend/.storybook/a11y-changed.mjs b/frontend/.storybook/a11y-changed.mjs index 5ff7a9e633..f6daef5dbb 100644 --- a/frontend/.storybook/a11y-changed.mjs +++ b/frontend/.storybook/a11y-changed.mjs @@ -10,7 +10,8 @@ // embedded interpreter runs on Windows, but sed/grep/sort do not exist for // developers calling tasks from PowerShell. import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { readdirSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; const base = process.argv[2] || "origin/main"; @@ -40,9 +41,25 @@ for (const f of changed) { continue; } if (TEST.test(f) || !SOURCE.test(f)) continue; - const sibling = f.replace(SOURCE, ""); - for (const s of [`${sibling}.stories.tsx`, `${sibling}.stories.ts`]) - if (existsSync(s)) stories.add(s); + // A story file does not have to match its source's case — tokens.css sits + // beside Tokens.stories.tsx. Deriving the name from the source and trusting + // existsSync silently skips those on a case-sensitive filesystem, and on a + // case-insensitive one feeds the scan a path no result will ever match. Read + // the directory instead and compare case-insensitively, then use the name as + // it is actually spelled on disk. + const dir = dirname(f) || "."; + const stem = basename(f).replace(SOURCE, "").toLowerCase(); + let entries; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (!STORY.test(entry)) continue; + if (entry.replace(STORY, "").toLowerCase() !== stem) continue; + stories.add(join(dir, entry).split("\\").join("/")); + } } // One line, each path quoted: the output is interpolated into a task command, diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index e9d7e7f3d9..f568b34347 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -19,6 +19,11 @@ import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { UIProvider } from "@portal/contexts/UIContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; +import { MantineProvider } from "@mantine/core"; +import { + mantineTheme as editorMantineTheme, + editorCssVariablesResolver, +} from "@core/theme/mantineTheme"; import { handlers } from "@portal/mocks/handlers"; import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient"; import i18next from "i18next"; @@ -199,6 +204,37 @@ const withLocale: Decorator = (Story, context) => { return ; }; +/** + * Applies the Mantine theme the story's component actually runs under in the + * app: PortalApp wraps the Processor in SuiProvider, while the editor wraps + * everything else in its own ThemeProvider. Getting this wrong is not just + * cosmetic — the two themes carry different neutral ramps, so rendering an + * editor component under the Processor's theme drops it onto Mantine's stock + * greys and reports contrast failures the app doesn't have. + */ +function StoryTheme({ + isPortalStory, + colorScheme, + children, +}: { + isPortalStory: boolean; + colorScheme: "light" | "dark"; + children: React.ReactNode; +}) { + if (isPortalStory) { + return {children}; + } + return ( + + {children} + + ); +} + const withProviders: Decorator = (Story, context) => { const tier = (context.globals.tier as Tier) ?? "pro"; const linkState = @@ -214,16 +250,21 @@ const withProviders: Decorator = (Story, context) => { // the portal's base.css keys its reset/typography on. Give portal stories // the same wrapper (and only them — the scoping exists precisely so portal // styles never apply to editor components). - const isPortalStory = (context.parameters.fileName ?? "").includes( - "/portal/", - ); + // `fileName` is only injected by the dev/build pipeline — under the Vitest + // runner it is absent, so path alone would silently drop every portal story + // onto the editor theme (where portal-only palette entries like `amber` + // resolve to nothing and render unstyled). The title prefix is the fallback + // that survives both environments. + const isPortalStory = + (context.parameters.fileName ?? "").includes("/portal/") || + context.title.startsWith("Portal/"); return ( - + {/* LinkProvider must wrap TierProvider: TierContext derives its tier from useLink() (matches App.tsx's nesting). */} @@ -241,7 +282,7 @@ const withProviders: Decorator = (Story, context) => { - + @@ -274,6 +315,14 @@ const preview: Preview = { // any violation. Context is left at the addon default (the document root) // so it resolves under both the Storybook UI and the Vitest browser mount. test: "error", + context: { + // Nodes carrying this attribute render a facsimile of the user's own + // document — their stamp text, their watermark, in the colour and + // opacity they chose. WCAG contrast governs the interface, not the + // content authored through it, and the controls that set those values + // are checked normally. + exclude: ["[data-user-content-preview]"], + }, }, }, globalTypes: { diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index ae494e9582..a91d52cca0 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3001,12 +3001,15 @@ tooltip = "Runs in the cloud (included, no extra charge)" tooltip = "Pick colour from screen" [colorPicker] +hue = "Hue" +saturation = "Saturation and brightness" title = "Choose colour" [common] back = "Back" cancel = "Cancel" close = "Close" +codeSample = "Code sample" collapse = "Collapse" confirm = "Confirm" continue = "Continue" @@ -3023,6 +3026,7 @@ refresh = "Refresh" remaining = "Remaining" retry = "Retry" save = "Save" +stepOf = "Step {{current}} of {{total}}" [compare] clearSelected = "Clear selected" @@ -3185,6 +3189,7 @@ title = "Compression Method" [compress.settings] desiredSize = "Desired File Size" desiredSizePlaceholder = "Enter size" +desiredSizeUnit = "Size unit" [compress.tooltip.description] text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually." @@ -3824,11 +3829,13 @@ shareSelected = "Share Files" sharing = "Sharing" showAll = "Show All" showHistory = "Show History" +sortBy = "Sort files" sortByDate = "Sort by Date" sortByName = "Sort by Name" sortBySize = "Sort by Size" storage = "Storage" storageState = "Storage" +storageUsed = "Storage used" synced = "Synced" title = "Upload PDF Files" toolChain = "Tools Applied" @@ -4168,6 +4175,7 @@ noFilesInStorageOpen = "No files available in storage. Open some files first." open = "Open" openFile = "Open File" openFiles = "Open Files" +selectFile = "Select {{name}}" selectFromStorage = "Select from Storage" upload = "Upload" uploadFile = "Upload File" @@ -5127,6 +5135,7 @@ activeFiles = "The Active Files view shows all of the PDFs you 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." +dialogLabel = "Onboarding" 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." filesButton = "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on." @@ -5563,6 +5572,7 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man freeTitle = "Unlimited PDF editing" [payg.free.hero] +barAria = "Free PDFs used" capSuffix = "/ {{limit}} free PDFs" metaCategories = "Automation · AI · API requests" @@ -5642,6 +5652,7 @@ automation = "automations" default = "this feature" [payg.spendCapMeter] +barAria = "Spend against cap" capSuffix = "/ {{amount}} cap" metaCategories = "Automation · AI · API spend" resets = "Resets each billing period" @@ -6045,6 +6056,7 @@ small = "500 Credits" xsmall = "100 Credits" [plan.availablePlans] +currency = "Billing currency" subtitle = "Choose the plan that fits your needs" title = "Available Plans" @@ -6280,6 +6292,7 @@ revoked = "Revoked" unnamed = "Unnamed instance" [portal.accountLink.instances.columns] +actions = "Actions" instance = "Instance" lastSeen = "Last seen" linked = "Linked" @@ -6624,6 +6637,7 @@ reachedTitle = "Monthly spend limit reached" title = "Couldn't open Stripe portal" [portal.billing.walletMeter] +barAria = "Free PDFs used" capSuffix_one = "of {{allowance}} free PDFs used" capSuffix_other = "of {{allowance}} free PDFs used" eyebrow = "Processor trial" @@ -7184,6 +7198,7 @@ sensitiveTitle = "Sensitive — access required" [portal.documents.table.columns] action = "Pipeline / Action" +actions = "Actions" document = "Document" product = "Product" status = "Status" @@ -7481,6 +7496,7 @@ rolledBack = "Rolled back" rolling = "Rolling out" [portal.infrastructure.deployments] +loadAria = "Load for {{name}}" msValue = "{{value}} ms" throughputValue = "{{value}}/min" @@ -7526,6 +7542,7 @@ disabled = "Disabled" [portal.infrastructure.models] heading = "Models" +loadAria = "Load for {{name}}" msValue = "{{value}} ms" subheading = "The model catalogue and routing that powers document processing across your workspace." @@ -7892,6 +7909,7 @@ paused = "Paused" [portal.pipelines.table] name = "Pipeline" +open = "Open" sources = "Sources" status = "Status" steps = "Steps" @@ -8783,6 +8801,7 @@ unused = "Unused" [portal.sources.table] documents = "Documents" +open = "Open" source = "Source" status = "Status" usedBy = "Policies" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 8ce96c2552..69211b6360 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3005,12 +3005,15 @@ tooltip = "This operation will use your cloud credits" tooltip = "Pick color from screen" [colorPicker] +hue = "Hue" +saturation = "Saturation and brightness" title = "Choose color" [common] back = "Back" cancel = "Cancel" close = "Close" +codeSample = "Code sample" collapse = "Collapse" confirm = "Confirm" continue = "Continue" @@ -3028,6 +3031,7 @@ refresh = "Refresh" remaining = "Remaining" retry = "Retry" save = "Save" +stepOf = "Step {{current}} of {{total}}" [compare] clearSelected = "Clear selected" @@ -3190,6 +3194,7 @@ title = "Compression Method" [compress.settings] desiredSize = "Desired File Size" desiredSizePlaceholder = "Enter size" +desiredSizeUnit = "Size unit" [compress.tooltip.description] text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually." @@ -3833,11 +3838,13 @@ shareSelected = "Share Files" sharing = "Sharing" showAll = "Show All" showHistory = "Show History" +sortBy = "Sort files" sortByDate = "Sort by Date" sortByName = "Sort by Name" sortBySize = "Sort by Size" storage = "Storage" storageState = "Storage" +storageUsed = "Storage used" synced = "Synced" title = "Upload PDF Files" toolChain = "Tools Applied" @@ -4177,6 +4184,7 @@ noFilesInStorageOpen = "No files available in storage. Open some files first." open = "Open" openFile = "Open File" openFiles = "Open Files" +selectFile = "Select {{name}}" selectFromStorage = "Select from Storage" upload = "Upload" uploadFile = "Upload File" @@ -5169,6 +5177,7 @@ activeFiles = "The Active Files view shows all of the PDFs you 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." +dialogLabel = "Onboarding" fileCheckbox = "Files on the workbench are selected for processing. You can select multiple files for batch operations using the left files sidebar." fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools." filesButton = "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on." @@ -5605,6 +5614,7 @@ freeBody = "View, edit, merge, split, sign, watermark, compress, convert and man freeTitle = "Unlimited PDF editing" [payg.free.hero] +barAria = "Free PDFs used" capSuffix = "/ {{limit}} free PDFs" metaCategories = "Automation · AI · API requests" @@ -5684,6 +5694,7 @@ automation = "automations" default = "this feature" [payg.spendCapMeter] +barAria = "Spend against cap" capSuffix = "/ {{amount}} cap" metaCategories = "Automation · AI · API spend" resets = "Resets each billing period" @@ -6087,6 +6098,7 @@ small = "500 Credits" xsmall = "100 Credits" [plan.availablePlans] +currency = "Billing currency" subtitle = "Choose the plan that fits your needs" title = "Available Plans" @@ -6322,6 +6334,7 @@ revoked = "Revoked" unnamed = "Unnamed instance" [portal.accountLink.instances.columns] +actions = "Actions" instance = "Instance" lastSeen = "Last seen" linked = "Linked" @@ -6666,6 +6679,7 @@ reachedTitle = "Monthly spend limit reached" title = "Couldn't open Stripe portal" [portal.billing.walletMeter] +barAria = "Free PDFs used" capSuffix_one = "of {{allowance}} free PDFs used" capSuffix_other = "of {{allowance}} free PDFs used" eyebrow = "Processor trial" @@ -7559,6 +7573,7 @@ rolledBack = "Rolled back" rolling = "Rolling out" [portal.infrastructure.deployments] +loadAria = "Load for {{name}}" msValue = "{{value}} ms" throughputValue = "{{value}}/min" @@ -7604,6 +7619,7 @@ disabled = "Disabled" [portal.infrastructure.models] heading = "Models" +loadAria = "Load for {{name}}" msValue = "{{value}} ms" subheading = "The model catalogue and routing that powers document processing across your workspace." diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css b/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css index 581f3af671..9cbda9b6ca 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css +++ b/frontend/editor/src/cloud/components/shared/config/configSections/Payg.css @@ -95,7 +95,7 @@ margin-bottom: 8px; } .payg-planhead__lbl--free { - color: var(--c-success); + color: var(--color-green-dark); } .payg-planhead__lbl--meter { color: var(--payg-accent); @@ -242,7 +242,7 @@ background: color-mix(in srgb, var(--c-success) 14%, transparent); } [data-mantine-color-scheme="dark"] .payg-hero__credit { - color: var(--c-success); + color: var(--color-green-dark); background: color-mix(in srgb, var(--c-success) 18%, transparent); } @@ -459,11 +459,11 @@ } .payg-gate[data-enabled="true"] .payg-gate__chip { background: color-mix(in srgb, var(--c-success) 16%, transparent); - color: var(--c-success); + color: var(--color-green-dark); } .payg-gate[data-enabled="false"] .payg-gate__chip { background: color-mix(in srgb, var(--c-danger) 16%, transparent); - color: var(--c-danger); + color: var(--color-red-dark); } .payg-gate__label { font-size: 0.8125rem; @@ -486,11 +486,11 @@ background: var(--c-surface-sunken); } .payg-gate__tag[data-variant="pause"] { - color: var(--c-danger); + color: var(--color-red-dark); background: color-mix(in srgb, var(--c-danger) 12%, transparent); } [data-mantine-color-scheme="dark"] .payg-gate__tag[data-variant="pause"] { - color: var(--c-danger); + color: var(--color-red-dark); background: color-mix(in srgb, var(--c-danger) 18%, transparent); } 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 9e89fa38eb..af391f0aeb 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css +++ b/frontend/editor/src/cloud/components/shared/config/configSections/PaygFree.css @@ -298,7 +298,7 @@ font-size: 1rem !important; } .paygf-explainer__icon--free { - color: var(--c-success); + color: var(--color-green-dark); } .paygf-explainer__icon--paid { color: var(--payg-accent); 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 d0ed711434..b6b72f504a 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css +++ b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.css @@ -10,7 +10,7 @@ .scc { --scc-accent: var(--c-primary); - --scc-accent-text: var(--c-primary); + --scc-accent-text: var(--c-accent-text); --scc-accent-soft: color-mix(in srgb, var(--c-primary) 12%, transparent); --scc-accent-border: color-mix(in srgb, var(--c-primary) 25%, transparent); --scc-chip-bg: var(--c-surface-sunken); @@ -24,7 +24,7 @@ [data-mantine-color-scheme="dark"] .scc { /* Chip surface/border track the neutral --c-* tokens (base rule); only the brand-azure accent is tuned brighter for dark. */ - --scc-accent-text: var(--c-primary); + --scc-accent-text: var(--c-accent-text); --scc-accent-soft: color-mix(in srgb, var(--c-primary) 16%, transparent); } diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index abfb0110ca..8811e537b2 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -60,6 +60,7 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) { = ({ {storageStats.quota && ( 80 diff --git a/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx index ec854426e6..bfc53d5252 100644 --- a/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx +++ b/frontend/editor/src/core/components/annotation/shared/ColorPicker.tsx @@ -51,6 +51,13 @@ export const ColorPicker: React.FC = ({ format="hex" value={selectedColor} onChange={onColorChange} + // The saturation area and hue bar are role="slider" divs; these are + // their only accessible names. + saturationLabel={t( + "colorPicker.saturation", + "Saturation and brightness", + )} + hueLabel={t("colorPicker.hue", "Hue")} swatches={[ "#000000", "#0066cc", @@ -73,6 +80,7 @@ export const ColorPicker: React.FC = ({ max={100} value={opacity} onChange={onOpacityChange} + thumbLabel={resolvedOpacityLabel} marks={[ { value: 25, label: "25%" }, { value: 50, label: "50%" }, diff --git a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx index 94d2e35e47..5bce53410e 100644 --- a/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/editor/src/core/components/fileEditor/AddFileCard.tsx @@ -161,7 +161,7 @@ const AddFileCard = ({ icon={icons.uploadIconName} width="1.25rem" height="1.25rem" - style={{ color: "var(--c-primary)", flexShrink: 0 }} + style={{ color: "var(--c-accent-text)", flexShrink: 0 }} /> {isUploadHover && ( = ({ {currentFile && ` • v${currentFile.versionNumber || 1}`} {hasMultipleFiles && ( - + {currentFileIndex + 1} of {selectedFiles.length} )} diff --git a/frontend/editor/src/core/components/fileManager/DragOverlay.tsx b/frontend/editor/src/core/components/fileManager/DragOverlay.tsx index 023bb59d14..04af539346 100644 --- a/frontend/editor/src/core/components/fileManager/DragOverlay.tsx +++ b/frontend/editor/src/core/components/fileManager/DragOverlay.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Stack, Text, useMantineTheme, alpha } from "@mantine/core"; +import { Stack, Text } from "@mantine/core"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import { useTranslation } from "react-i18next"; @@ -9,7 +9,6 @@ interface DragOverlayProps { const DragOverlay: React.FC = ({ isVisible }) => { const { t } = useTranslation(); - const theme = useMantineTheme(); if (!isVisible) return null; @@ -21,8 +20,9 @@ const DragOverlay: React.FC = ({ isVisible }) => { left: 0, right: 0, bottom: 0, - backgroundColor: alpha(theme.colors.blue[6], 0.1), - border: `0.125rem dashed ${theme.colors.blue[6]}`, + // The prompt below is the drop affordance on its own. Tinting the whole + // region and ringing it in dashed accent reads as a second, competing + // surface, so the overlay stays transparent. borderRadius: "1.875rem", display: "flex", alignItems: "center", @@ -32,10 +32,12 @@ const DragOverlay: React.FC = ({ isVisible }) => { }} > + {/* Muted ink rather than the accent shade: it has to read on whatever + the overlay happens to sit on, in either scheme. */} - + {t("fileManager.dropFilesHere", "Drop files here to upload")} diff --git a/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx b/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx index 59b42ef954..890906abda 100644 --- a/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx +++ b/frontend/editor/src/core/components/fileManager/EmptyFilesState.tsx @@ -103,7 +103,7 @@ const EmptyFilesState: React.FC = () => { icon={icons.uploadIconName} width="1.25rem" height="1.25rem" - style={{ color: "var(--c-primary)" }} + style={{ color: "var(--c-accent-text)" }} /> {isUploadHover && ( diff --git a/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx b/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx index f43df3d86c..4f94155d4e 100644 --- a/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx +++ b/frontend/editor/src/core/components/fileManager/FileInfoCard.tsx @@ -86,19 +86,29 @@ const FileInfoCard: React.FC = ({ }} > - + {t("fileManager.details", "File Details")} - + {/* The viewport is focusable and named so keyboard users can scroll the + detail list once it overflows. */} + diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx index ce9be12ad2..7bed49bd0b 100644 --- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx +++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx @@ -906,35 +906,47 @@ function ListView({ return (
    + {/* Each direct child is a columnheader: a role="row" may only own cells, so + the sort controls and the select-all box have to sit inside one. */}
    {onSetSelection && visibleFileIds.length > 0 ? ( - { - onSetSelection(allSelected ? new Set() : new Set(visibleFileIds)); - }} - aria-label={ - allSelected - ? t("filesPage.deselectAll", "Clear selection") - : t("filesPage.selectAll", "Select all") - } - /> + + { + onSetSelection( + allSelected ? new Set() : new Set(visibleFileIds), + ); + }} + aria-label={ + allSelected + ? t("filesPage.deselectAll", "Clear selection") + : t("filesPage.selectAll", "Select all") + } + /> + ) : (
    @@ -1091,7 +1103,12 @@ function FolderRow({ className={`files-page-list-row${isDropTarget ? " is-drop-target" : ""}`} >
    ); } @@ -1254,35 +1275,40 @@ function FileRow({ isInWorkspace ? " is-in-workspace" : "" }`} > - {/* Checkbox only shows in multi-select mode (see FileCard). When the - checkbox is hidden the first grid column collapses, but the row's - CSS grid keeps the columns aligned via the named template, so no - empty cell shows. */} + {/* Each direct child is a gridcell: a role="row" may only own cells, so the + checkbox and the actions menu have to sit inside one. + + The checkbox only shows in multi-select mode (see FileCard). When it is + hidden the first grid column collapses, but the row's CSS grid keeps the + columns aligned via the named template, so no empty cell shows. */} {multiSelectActive ? ( - { - // Toggle this file in/out of the selection without modifier keys. - e.stopPropagation(); - onClick({ - ...e, - shiftKey: false, - ctrlKey: true, - metaKey: true, - } as unknown as React.MouseEvent); - }} - onChange={() => { - /* handled by onClick */ - }} - aria-label={t("filesPage.selectFile", "Select file {{name}}", { - name: file.name, - })} - /> + + { + // Toggle this file in/out of the selection without modifier keys. + e.stopPropagation(); + onClick({ + ...e, + shiftKey: false, + ctrlKey: true, + metaKey: true, + } as unknown as React.MouseEvent); + }} + onChange={() => { + /* handled by onClick */ + }} + aria-label={t("filesPage.selectFile", "Select file {{name}}", { + name: file.name, + })} + /> + ) : ( // Empty cell preserves grid column alignment. ); } diff --git a/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx index a4e9b6e3f9..3d94610e0b 100644 --- a/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx +++ b/frontend/editor/src/core/components/filesPage/FileOriginBadge.tsx @@ -32,12 +32,12 @@ const styles = { }, cloud: { background: "color-mix(in srgb, var(--c-primary) 16%, transparent)", - color: "var(--c-primary)", + color: "var(--c-accent-text)", }, shared: { background: "color-mix(in srgb, var(--mantine-color-orange-6) 16%, transparent)", - color: "var(--mantine-color-orange-6)", + color: "var(--color-amber-dark)", }, }; diff --git a/frontend/editor/src/core/components/filesPage/FilesPage.css b/frontend/editor/src/core/components/filesPage/FilesPage.css index 4815a1b31e..368bae2521 100644 --- a/frontend/editor/src/core/components/filesPage/FilesPage.css +++ b/frontend/editor/src/core/components/filesPage/FilesPage.css @@ -339,6 +339,9 @@ } .files-page-list-row.is-header [data-sortable="true"] { + /* Block so the hit area and hover tint fill the columnheader cell that wraps + it, rather than hugging the label text. */ + display: block; cursor: pointer; padding: 0.2rem 0.4rem; margin: -0.2rem -0.4rem; @@ -741,7 +744,7 @@ height: 5rem; border-radius: 50%; background: color-mix(in srgb, var(--c-primary) 12%, transparent); - color: var(--c-primary); + color: var(--c-accent-text); margin-bottom: 0.25rem; } @@ -980,7 +983,7 @@ .files-page-details-version-timeline-count { margin-left: auto; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); text-transform: none; letter-spacing: 0; } @@ -1112,7 +1115,29 @@ .files-page-details-version-timeline-expand-btn:hover span { color: var(--c-text); } - +.files-page-details-version-timeline-delta { + font-size: 0.82rem; + color: var(--c-text); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: inline-flex; + align-items: baseline; + gap: 0.25rem; +} +.files-page-details-version-timeline-delta.is-origin { + font-weight: 400; + color: var(--c-text-subtle); + font-style: italic; +} +.files-page-details-version-timeline-delta-plus { + color: var(--c-accent-text); + font-weight: 700; +} +.files-page-details-version-timeline-spacer { + flex: 1; +} .files-page-details-version-timeline-chevron { color: var(--c-text-subtle); transition: transform 0.15s ease; @@ -1120,7 +1145,7 @@ .files-page-details-version-timeline-chevron.is-expanded { transform: rotate(180deg); - color: var(--c-primary); + color: var(--c-accent-text); } .files-page-details-version-timeline-expanded { @@ -1185,7 +1210,7 @@ justify-content: center; gap: 1rem; pointer-events: none; - color: var(--c-primary); + color: var(--c-accent-text); font-weight: 600; font-size: 1.1rem; z-index: 10; diff --git a/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx index 39554a21ee..928a75eb3c 100644 --- a/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx +++ b/frontend/editor/src/core/components/filesPage/FolderThumbnail.tsx @@ -151,7 +151,10 @@ export function FolderThumbnail({ borderRadius: "999px", background: "var(--c-surface, #fff)", border: `1px solid ${accent}`, - color: accent, + // The ring carries the folder's accent; the numeral does not. + // Folder colours are user-chosen and many are too light to read + // as text on the white pill. + color: "var(--c-text)", fontSize: "0.7rem", fontWeight: 700, display: "inline-flex", diff --git a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css index f9439dcdd2..58108b55c1 100644 --- a/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css +++ b/frontend/editor/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css @@ -325,7 +325,7 @@ .v2Badge { background: var(--c-primary-tint); - color: var(--c-accent-fg); + color: var(--c-accent-text); padding: 3px 9px; border-radius: 6px; font-size: 12px; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx index 49d94662eb..3d077301eb 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx @@ -101,7 +101,10 @@ export default function OnboardingSlideShell({ ); return ( - , because only Modal.Content lands + // props on the role="dialog" element — the slide draws its own title, so the + // dialog needs an aria-label to have an accessible name. + -
    -
    -
    - - Stirling -
    -
    - {showProgress && ( - - {t("onboarding.stepOf", "Step {{current}} of {{total}}", { - current: stepIndex + 1, - total: stepCount, - })} - - )} - {allowDismiss && ( - - + + +
    +
    +
    + - - )} -
    -
    + Stirling +
    +
    + {showProgress && ( + + {t("onboarding.stepOf", "Step {{current}} of {{total}}", { + current: stepIndex + 1, + total: stepCount, + })} + + )} + {allowDismiss && ( + + + + )} +
    +
    - {showProgress && ( -
    - {Array.from({ length: stepCount }, (_, index) => ( - - ))} -
    - )} - -
    - -
    -
    -
    - {hero} -
    -
    - -
    - {title} -
    - -
    - {body} - -
    - -
    - {backButtons.length === 0 ? ( -
    {actions}
    - ) : ( -
    -
    - {backButtons.map((button) => ( - onAction(button.action)} - variant="tertiary" - accent="neutral" - disabled={button.disabled} - aria-label={t("onboarding.buttons.back", "Back")} - > - - - ))} -
    - {actions} + {showProgress && ( +
    + {Array.from({ length: stepCount }, (_, index) => ( + + ))}
    )} + +
    + +
    +
    +
    + {hero} +
    +
    + +
    + {title} +
    + +
    + {body} + +
    + +
    + {backButtons.length === 0 ? ( +
    {actions}
    + ) : ( +
    +
    + {backButtons.map((button) => ( + onAction(button.action)} + variant="tertiary" + accent="neutral" + disabled={button.disabled} + aria-label={t("onboarding.buttons.back", "Back")} + > + + + ))} +
    + {actions} +
    + )} +
    +
    -
    -
    - + + + ); } diff --git a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx index 23566a5527..aa04911832 100644 --- a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx @@ -50,7 +50,7 @@ export default function AnalyticsChoiceSlide({
    {analyticsError && ( -
    +
    {analyticsError}
    )} diff --git a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx index d684592a4a..0b1f5bb8f2 100644 --- a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx @@ -125,7 +125,7 @@ function FirstLoginForm({ icon="info-rounded" width={20} height={20} - style={{ color: "var(--c-primary)", flexShrink: 0 }} + style={{ color: "var(--c-accent-text)", flexShrink: 0 }} /> {t( diff --git a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx index 6101605772..7f3e9f12a5 100644 --- a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.tsx @@ -26,7 +26,7 @@ export default function SecurityCheckSlide({ icon="error" width={20} height={20} - style={{ color: "var(--c-danger)", flexShrink: 0 }} + style={{ color: "var(--color-red-dark)", flexShrink: 0 }} /> {i18n.t( diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css index 1ecd05808b..59729a79e0 100644 --- a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css @@ -222,7 +222,8 @@ /* Error helper text above the input */ .errorText { margin-top: 0.25rem; - color: var(--text-brand-accent); + /* The brand red is a fill; error copy takes the theme's error ink. */ + color: var(--color-red-dark); } /* Compact error container for inline tool settings */ @@ -237,7 +238,7 @@ /* Two-line clamp for compact error text */ .errorTextClamp { - color: var(--text-brand-accent); + color: var(--color-red-dark); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; diff --git a/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx b/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx index 0816ac9a14..4def62470d 100644 --- a/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx +++ b/frontend/editor/src/core/components/shared/DropdownListWithFooter.tsx @@ -135,7 +135,11 @@ const DropdownListWithFooter: React.FC = ({ zIndex={zIndex} > + {/* A real button: Popover.Target stamps aria-haspopup/aria-expanded on + its child, and those are only permitted on an actual control. */} = ({ padding: "8px 12px", backgroundColor: "light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))", + color: "inherit", + textAlign: "left", + width: "100%", opacity: disabled ? 0.6 : 1, cursor: disabled ? "not-allowed" : "pointer", minHeight: "36px", diff --git a/frontend/editor/src/core/components/shared/EditableSecretField.tsx b/frontend/editor/src/core/components/shared/EditableSecretField.tsx index a515a46cae..cce50f90a0 100644 --- a/frontend/editor/src/core/components/shared/EditableSecretField.tsx +++ b/frontend/editor/src/core/components/shared/EditableSecretField.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from "react"; +import { useId, useState, useRef, useEffect } from "react"; import { PasswordInput, Group, Tooltip, TextInput } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ActionIcon } from "@app/ui/ActionIcon"; @@ -33,6 +33,7 @@ export default function EditableSecretField({ }: EditableSecretFieldProps) { const { t } = useTranslation(); const resolvedPlaceholder = placeholder ?? t("common.enterValue"); + const fieldId = useId(); const [isEditing, setIsEditing] = useState(false); const [tempValue, setTempValue] = useState(""); const inputRef = useRef(null); @@ -67,6 +68,7 @@ export default function EditableSecretField({
    {label && (
    , document.body, diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css index 38b20ee1d8..8b84af29f1 100644 --- a/frontend/editor/src/core/ui/Dropdown.css +++ b/frontend/editor/src/core/ui/Dropdown.css @@ -54,7 +54,7 @@ .sui-dd__item.is-active { background: var(--c-primary-subtle); - color: var(--c-accent-fg); + color: var(--c-accent-text); font-weight: 500; } diff --git a/frontend/editor/src/core/ui/EmptyState.css b/frontend/editor/src/core/ui/EmptyState.css index bf8250bc82..bd3d7cf03e 100644 --- a/frontend/editor/src/core/ui/EmptyState.css +++ b/frontend/editor/src/core/ui/EmptyState.css @@ -22,7 +22,7 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--c-primary); + color: var(--c-accent-text); } .sui-empty__title { diff --git a/frontend/editor/src/core/ui/FormField.css b/frontend/editor/src/core/ui/FormField.css index f5cbe2d5fc..792845c255 100644 --- a/frontend/editor/src/core/ui/FormField.css +++ b/frontend/editor/src/core/ui/FormField.css @@ -14,7 +14,9 @@ } .sui-field__required { - color: var(--color-red); + /* The base red is a fill colour; as text on the form background it only + reaches 3.4:1. */ + color: var(--color-red-dark); } .sui-field__control { @@ -29,5 +31,5 @@ } .sui-field--error .sui-field__help { - color: var(--color-red); + color: var(--color-red-dark); } diff --git a/frontend/editor/src/core/ui/Forms.stories.tsx b/frontend/editor/src/core/ui/Forms.stories.tsx index 0d14cf3392..dff72b6da7 100644 --- a/frontend/editor/src/core/ui/Forms.stories.tsx +++ b/frontend/editor/src/core/ui/Forms.stories.tsx @@ -182,6 +182,7 @@ export const Slider_Confidence: Story = { step={0.01} onChange={setV} formatValue={(x) => x.toFixed(2)} + aria-label="Minimum confidence" /> ); @@ -203,6 +204,7 @@ export const Slider_Retention: Story = { step={1} onChange={setDays} formatValue={(d) => `${d} days`} + aria-label="Retain artifacts for" /> ); @@ -262,6 +264,7 @@ export const FullForm: Story = { step={0.01} onChange={setConf} formatValue={(v) => v.toFixed(2)} + aria-label="Confidence gate" /> diff --git a/frontend/editor/src/core/ui/ListRow.css b/frontend/editor/src/core/ui/ListRow.css index 836dba4d8f..019b679f5c 100644 --- a/frontend/editor/src/core/ui/ListRow.css +++ b/frontend/editor/src/core/ui/ListRow.css @@ -36,23 +36,23 @@ background: var(--c-surface-sunken); } .sui-listrow__leading[data-tone="success"] { - color: var(--color-green); + color: var(--color-green-dark); background: color-mix(in srgb, var(--color-green) 14%, transparent); } .sui-listrow__leading[data-tone="warning"] { - color: var(--color-amber); + color: var(--color-amber-dark); background: color-mix(in srgb, var(--color-amber) 14%, transparent); } .sui-listrow__leading[data-tone="danger"] { - color: var(--color-red); + color: var(--color-red-dark); background: color-mix(in srgb, var(--color-red) 14%, transparent); } .sui-listrow__leading[data-tone="info"] { - color: var(--c-primary); + color: var(--c-accent-text); background: color-mix(in srgb, var(--c-primary) 14%, transparent); } .sui-listrow__leading[data-tone="purple"] { - color: var(--color-purple); + color: var(--color-purple-dark); background: color-mix(in srgb, var(--color-purple) 14%, transparent); } diff --git a/frontend/editor/src/core/ui/MantineForms.css b/frontend/editor/src/core/ui/MantineForms.css index ebedf547f7..29cd64413d 100644 --- a/frontend/editor/src/core/ui/MantineForms.css +++ b/frontend/editor/src/core/ui/MantineForms.css @@ -55,7 +55,7 @@ /* Pills match SUI's Chip component: small rounded tags. */ .sui-mantine-pill { background: var(--c-primary-tint) !important; - color: var(--c-primary-hover) !important; + color: var(--c-accent-text) !important; border: 1px solid var(--c-primary-border) !important; border-radius: var(--radius-sm) !important; font-size: 0.75rem !important; diff --git a/frontend/editor/src/core/ui/MantineForms.stories.tsx b/frontend/editor/src/core/ui/MantineForms.stories.tsx index 08e2b2e179..e8860c1f30 100644 --- a/frontend/editor/src/core/ui/MantineForms.stories.tsx +++ b/frontend/editor/src/core/ui/MantineForms.stories.tsx @@ -446,6 +446,7 @@ export const Slider_Default: Story = { max={1} step={0.01} formatValue={(x) => x.toFixed(2)} + aria-label="Confidence threshold" /> ); @@ -467,6 +468,7 @@ export const Slider_WithMarks: Story = { max={365} step={1} formatValue={(d) => `${d}d`} + aria-label="Retain artifacts for" marks={[ { value: 30, label: "30d" }, { value: 90, label: "90d" }, @@ -494,6 +496,7 @@ export const Slider_NoLabel: Story = { max={100} step={1} showValue={false} + aria-label="Opacity" /> ); diff --git a/frontend/editor/src/core/ui/MethodBadge.css b/frontend/editor/src/core/ui/MethodBadge.css index 9b138ed389..1cc89c02a4 100644 --- a/frontend/editor/src/core/ui/MethodBadge.css +++ b/frontend/editor/src/core/ui/MethodBadge.css @@ -15,7 +15,7 @@ border-color: var(--color-green-border); } .sui-method--post { - color: var(--c-primary); + color: var(--c-accent-text); background: var(--c-primary-tint); border-color: var(--c-primary-border); } diff --git a/frontend/editor/src/core/ui/MetricCard.css b/frontend/editor/src/core/ui/MetricCard.css index 8c7adfeb00..520342c65a 100644 --- a/frontend/editor/src/core/ui/MetricCard.css +++ b/frontend/editor/src/core/ui/MetricCard.css @@ -83,10 +83,10 @@ font-size: 0.6875rem; } .sui-metric__delta--up { - color: var(--color-green); + color: var(--color-green-dark); } .sui-metric__delta--down { - color: var(--color-red); + color: var(--color-red-dark); } .sui-metric__delta--flat, .sui-metric__desc { diff --git a/frontend/editor/src/core/ui/NavItem.css b/frontend/editor/src/core/ui/NavItem.css index 8b9949eab5..9fd39f3eff 100644 --- a/frontend/editor/src/core/ui/NavItem.css +++ b/frontend/editor/src/core/ui/NavItem.css @@ -20,7 +20,7 @@ } .sui-navitem.is-active { background: var(--c-primary-subtle); - color: var(--c-accent-fg); + color: var(--c-accent-text); font-weight: 500; } .sui-navitem.is-active:hover { @@ -76,17 +76,17 @@ background: var(--color-red); } .sui-navitem[data-accent="blue"] .sui-navitem__icon { - color: var(--c-primary); + color: var(--c-accent-text); } .sui-navitem[data-accent="purple"] .sui-navitem__icon { - color: var(--color-purple); + color: var(--color-purple-dark); } .sui-navitem[data-accent="green"] .sui-navitem__icon { - color: var(--color-green); + color: var(--color-green-dark); } .sui-navitem[data-accent="amber"] .sui-navitem__icon { - color: var(--color-amber); + color: var(--color-amber-dark); } .sui-navitem[data-accent="red"] .sui-navitem__icon { - color: var(--color-red); + color: var(--color-red-dark); } diff --git a/frontend/editor/src/core/ui/PanelHeader.css b/frontend/editor/src/core/ui/PanelHeader.css index 02f32c3707..f8dafd2303 100644 --- a/frontend/editor/src/core/ui/PanelHeader.css +++ b/frontend/editor/src/core/ui/PanelHeader.css @@ -46,7 +46,7 @@ button.sui-panelhdr__bar:hover { height: 1.75rem; border-radius: 9999px; background: var(--mantine-color-blue-light); - color: var(--mantine-color-blue-filled); + color: var(--c-accent-text); flex-shrink: 0; } @@ -154,15 +154,15 @@ button.sui-panelhdr__bar:hover { var(--mantine-color-blue-filled) 18%, transparent ); - color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled)); + color: var(--c-accent-text); } /* Dark mode: the subtle gray close button is too dim against the dark rail — brighten it to a clearly-visible light grey (near-white on hover). */ [data-mantine-color-scheme="dark"] .sui-panelhdr__close { - color: var(--mantine-color-gray-4); + color: var(--c-text-subtle); } [data-mantine-color-scheme="dark"] .sui-panelhdr__close:hover { - color: var(--mantine-color-gray-2); + color: var(--c-text-subtle); } diff --git a/frontend/editor/src/core/ui/ProgressBar.stories.tsx b/frontend/editor/src/core/ui/ProgressBar.stories.tsx index 7c8b386dda..8c88bc039a 100644 --- a/frontend/editor/src/core/ui/ProgressBar.stories.tsx +++ b/frontend/editor/src/core/ui/ProgressBar.stories.tsx @@ -6,7 +6,12 @@ const meta: Meta = { component: ProgressBar, tags: ["autodocs"], parameters: { layout: "padded" }, - args: { value: 0.5, height: 6, thresholded: false }, + args: { + value: 0.5, + height: 6, + thresholded: false, + label: "Docs processed", + }, argTypes: { value: { control: { type: "range", min: 0, max: 1, step: 0.01 } }, height: { control: { type: "number" } }, @@ -42,7 +47,7 @@ export const ThresholdLadder: Story = { {Math.round(v * 100)}% - +
    ))}
    diff --git a/frontend/editor/src/core/ui/ProgressBar.tsx b/frontend/editor/src/core/ui/ProgressBar.tsx index e6ca30c0e1..cb11a2982a 100644 --- a/frontend/editor/src/core/ui/ProgressBar.tsx +++ b/frontend/editor/src/core/ui/ProgressBar.tsx @@ -10,8 +10,9 @@ export interface ProgressBarProps { /** Optional override colour (CSS gradient or solid). Disables threshold behaviour. */ color?: string; className?: string; - /** Accessible label for screen readers. */ - label?: string; + /** Accessible name — describe what is being measured ("Storage used"), since + * the bar carries no visible text of its own. */ + label: string; } function clamp01(n: number) { diff --git a/frontend/editor/src/core/ui/Select.tsx b/frontend/editor/src/core/ui/Select.tsx index 242ddf9740..c4861a7752 100644 --- a/frontend/editor/src/core/ui/Select.tsx +++ b/frontend/editor/src/core/ui/Select.tsx @@ -46,6 +46,7 @@ export interface SelectProps { id?: string; name?: string; "aria-label"?: string; + "aria-labelledby"?: string; "aria-invalid"?: boolean; "aria-describedby"?: string; required?: boolean; @@ -74,6 +75,7 @@ type PassthroughProps = Omit< | "id" | "name" | "aria-label" + | "aria-labelledby" | "aria-describedby" | "required" | "disabled" @@ -106,6 +108,7 @@ export function Select({ id, name, "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy, required, @@ -128,6 +131,7 @@ export function Select({ id, name, "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, required, disabled, diff --git a/frontend/editor/src/core/ui/SettingsRow.tsx b/frontend/editor/src/core/ui/SettingsRow.tsx index 65f48cddc8..8dbf9600ab 100644 --- a/frontend/editor/src/core/ui/SettingsRow.tsx +++ b/frontend/editor/src/core/ui/SettingsRow.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { cloneElement, isValidElement, useId, type ReactNode } from "react"; import "@app/ui/SettingsRow.css"; export interface SettingsRowProps { @@ -23,17 +23,31 @@ export function SettingsRow({ control, className, }: SettingsRowProps) { + const labelId = useId(); + // The row's label is plain text beside the control, not a
    {isLoading ? (
    diff --git a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx index daa066bd70..921de50853 100644 --- a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.tsx @@ -93,7 +93,11 @@ export function LinkedInstancesTable({ }, { key: "actions", - header: "", + header: ( + + {t("portal.accountLink.instances.columns.actions", "Actions")} + + ), align: "right", render: (i) => i.revoked ? null : ( diff --git a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx index b70fbb5a26..c7ab0e8704 100644 --- a/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx +++ b/frontend/editor/src/portal/components/billing/PrepaidCapacityCard.tsx @@ -72,6 +72,7 @@ export function PrepaidCapacityCard({ 1
    -

    {t("portal.docs.quickstart.step1.title")}

    +

    {t("portal.docs.quickstart.step1.title")}

    {t("portal.docs.quickstart.step1.body")}

    2
    -

    {t("portal.docs.quickstart.step2.title")}

    +

    {t("portal.docs.quickstart.step2.title")}

    {t("portal.docs.quickstart.step2.body")}

    3
    -

    {t("portal.docs.quickstart.step3.title")}

    +

    {t("portal.docs.quickstart.step3.title")}

    {t("portal.docs.quickstart.step3.body")}

    {playbooks.map((p) => ( -

    {p.title}

    +

    {p.title}

    {p.blurb}

    {p.steps.map((step, i) => ( diff --git a/frontend/editor/src/portal/components/docs/SdksSection.tsx b/frontend/editor/src/portal/components/docs/SdksSection.tsx index 1a0e72933e..331117af68 100644 --- a/frontend/editor/src/portal/components/docs/SdksSection.tsx +++ b/frontend/editor/src/portal/components/docs/SdksSection.tsx @@ -32,7 +32,7 @@ export function SdksSection({ sdks }: { sdks: Sdk[] }) { {sdk.icon} -

    {sdk.name}

    +

    {sdk.name}

    {badge && ( {t(badge.labelKey)} diff --git a/frontend/editor/src/portal/components/docs/SkillsSection.tsx b/frontend/editor/src/portal/components/docs/SkillsSection.tsx index ef34705c43..e373fc25c3 100644 --- a/frontend/editor/src/portal/components/docs/SkillsSection.tsx +++ b/frontend/editor/src/portal/components/docs/SkillsSection.tsx @@ -19,7 +19,7 @@ export function SkillsSection({ skills }: { skills: AgentSkill[] }) { -

    {s.name}

    +

    {s.name}

    {s.blurb}

    {s.ops} diff --git a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx index 8e96e4d997..41a9b5c561 100644 --- a/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/DeploymentsTab.tsx @@ -65,7 +65,14 @@ export function DeploymentsTab() { width: "9rem", render: (r) => (
    - + {pct(r.load)}
    ), diff --git a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx b/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx index 6d097b33af..505f41ac24 100644 --- a/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/ModelsTab.tsx @@ -77,7 +77,12 @@ export function ModelsTab() { width: "9rem", render: (m) => (
    - + {pct(m.load)}
    ), diff --git a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx index 318f23aca5..7aee96c822 100644 --- a/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx +++ b/frontend/editor/src/portal/components/procurement/ProcurementAgreement.tsx @@ -144,10 +144,15 @@ export function ProcurementAgreement({ {/* The tray scrolls, not the paper. The paper is its natural height inside it, so mid-document it runs flush to the footer with no grey beneath, and the tray's bottom padding only comes into view once the buyer reaches the end — the page ending is what shows they got there. */} + {/* Focusable and named: signing is gated on scrolling to the end, so the + tray has to be scrollable by keyboard as well as pointer. */}
    {loading &&

    {t("portal.procurement.agreement.loading")}

    } diff --git a/frontend/editor/src/portal/components/users/ResetPasswordModal.tsx b/frontend/editor/src/portal/components/users/ResetPasswordModal.tsx index d1cfe9e739..48b32d4398 100644 --- a/frontend/editor/src/portal/components/users/ResetPasswordModal.tsx +++ b/frontend/editor/src/portal/components/users/ResetPasswordModal.tsx @@ -140,7 +140,13 @@ export function ResetPasswordModal({ } >
    - + {/* FormField's label wires up to the row wrapper, not this + input, so name the input directly. */} + - +
    @@ -131,9 +134,9 @@ export function DeveloperDocs() {
    {hasToc && ( - +
    )}
    ); diff --git a/frontend/editor/src/portal/views/EditorAdmin.css b/frontend/editor/src/portal/views/EditorAdmin.css index 1ccd0d63b1..34aa81d69f 100644 --- a/frontend/editor/src/portal/views/EditorAdmin.css +++ b/frontend/editor/src/portal/views/EditorAdmin.css @@ -96,11 +96,11 @@ } .portal-editor__target-icon--blue { background: var(--c-primary-tint); - color: var(--c-primary); + color: var(--c-accent-text); } .portal-editor__target-icon--purple { background: var(--color-purple-light); - color: var(--color-purple); + color: var(--color-purple-dark); } .portal-editor__target-titles { @@ -192,15 +192,15 @@ .portal-editor__pairing-icon--blue { background: color-mix(in srgb, var(--c-primary) 12%, var(--c-surface)); - color: var(--c-primary); + color: var(--c-accent-text); } .portal-editor__pairing-icon--purple { background: var(--color-purple-light); - color: var(--color-purple); + color: var(--color-purple-dark); } .portal-editor__pairing-icon--green { background: var(--color-green-light); - color: var(--color-green); + color: var(--color-green-dark); } .portal-editor__pairing-titles { @@ -331,7 +331,7 @@ padding: 0.125rem 0.4375rem; border-radius: var(--radius-sm); background: var(--color-purple-light); - color: var(--color-purple); + color: var(--color-purple-dark); } .portal-editor__token-row { diff --git a/frontend/editor/src/portal/views/Home.css b/frontend/editor/src/portal/views/Home.css index 732bd617a5..236966c2fc 100644 --- a/frontend/editor/src/portal/views/Home.css +++ b/frontend/editor/src/portal/views/Home.css @@ -129,5 +129,5 @@ } .portal-home__quick-row:hover .portal-home__quick-arrow { - color: var(--c-primary); + color: var(--c-accent-text); } diff --git a/frontend/editor/src/portal/views/Infrastructure.css b/frontend/editor/src/portal/views/Infrastructure.css index cd532daeeb..660bd4a69f 100644 --- a/frontend/editor/src/portal/views/Infrastructure.css +++ b/frontend/editor/src/portal/views/Infrastructure.css @@ -113,7 +113,7 @@ .portal-infra__export-error { margin: 0; font-size: 0.8125rem; - color: var(--c-danger); + color: var(--color-red-dark); } .portal-infra__export-actions { @@ -373,7 +373,7 @@ .portal-infra__attestation-link { font-size: 0.75rem; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); text-decoration: none; } @@ -429,7 +429,7 @@ height: 1.75rem; border-radius: var(--radius-md); background: var(--c-primary-tint); - color: var(--c-primary); + color: var(--c-accent-text); font-size: 0.875rem; } diff --git a/frontend/editor/src/portal/views/Integrations.css b/frontend/editor/src/portal/views/Integrations.css index 9d90d70b13..d006fcbb84 100644 --- a/frontend/editor/src/portal/views/Integrations.css +++ b/frontend/editor/src/portal/views/Integrations.css @@ -66,7 +66,7 @@ .portal-integrations__filter.is-active { background: var(--c-primary-tint); - color: var(--c-primary); + color: var(--c-accent-text); } .portal-integrations__filter-count { @@ -243,7 +243,7 @@ button.portal-integrations__row[aria-expanded="true"] { gap: 0.375rem; font-size: 0.8125rem; font-weight: 500; - color: var(--c-success); + color: var(--color-green-dark); } .portal-integrations__status-dot { diff --git a/frontend/editor/src/portal/views/Pipelines.css b/frontend/editor/src/portal/views/Pipelines.css index 6fac70672e..f4bc729b4d 100644 --- a/frontend/editor/src/portal/views/Pipelines.css +++ b/frontend/editor/src/portal/views/Pipelines.css @@ -62,7 +62,7 @@ border-radius: var(--radius-md); font-size: 0.875rem; background: var(--c-primary-tint); - color: var(--c-primary); + color: var(--c-accent-text); } .portal-pipelines__muted { @@ -79,7 +79,7 @@ .portal-pipelines__caret.is-open { transform: rotate(90deg); - color: var(--c-primary); + color: var(--c-accent-text); } /* Table skeleton */ diff --git a/frontend/editor/src/portal/views/Policies.css b/frontend/editor/src/portal/views/Policies.css index e1a821ee27..fedcbbf835 100644 --- a/frontend/editor/src/portal/views/Policies.css +++ b/frontend/editor/src/portal/views/Policies.css @@ -56,7 +56,7 @@ .portal-policies__setup-link { font-size: 0.8125rem; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); text-decoration: underline; text-underline-offset: 2px; } @@ -70,7 +70,10 @@ } .portal-policies__card--locked { - opacity: 0.7; + /* Recede via surface and ink rather than opacity, which would fade the + card's own text below the contrast floor. */ + background: var(--c-surface-sunken); + color: var(--c-text-muted); } .portal-policies__card-identity { @@ -165,7 +168,7 @@ .portal-policies__card-cta { font-size: 0.75rem; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); } .portal-policies__chevron-right { @@ -297,7 +300,7 @@ padding: 0; font-size: 0.75rem; font-weight: 600; - color: var(--c-primary); + color: var(--c-accent-text); cursor: pointer; } @@ -378,13 +381,13 @@ } .portal-policies__activity-icon--success { - color: var(--color-green); + color: var(--color-green-dark); } .portal-policies__activity-icon--warning { - color: var(--color-amber); + color: var(--color-amber-dark); } .portal-policies__activity-icon--info { - color: var(--c-primary); + color: var(--c-accent-text); } @keyframes portal-policies-spin { diff --git a/frontend/editor/src/portal/views/Procurement.css b/frontend/editor/src/portal/views/Procurement.css index b721768952..556fff726a 100644 --- a/frontend/editor/src/portal/views/Procurement.css +++ b/frontend/editor/src/portal/views/Procurement.css @@ -89,7 +89,7 @@ color: var(--c-text); } .portal-qb__req { - color: var(--c-danger); + color: var(--color-red-dark); } .portal-qb__field[data-invalid] input, .portal-qb__field[data-invalid] select { @@ -98,7 +98,7 @@ .portal-qb__error { margin: 10px 0 0; font-size: 12px; - color: var(--c-danger); + color: var(--color-red-dark); } .portal-qb__row { display: flex; @@ -138,7 +138,7 @@ .portal-qb__discount { margin: 6px 0 0; font-size: 11.5px; - color: var(--c-success); + color: var(--color-green-dark); } .portal-qb__opts { display: flex; @@ -179,7 +179,7 @@ color: var(--c-text); } .portal-qb__opt[data-on] .portal-qb__opt-title { - color: var(--c-primary); + color: var(--c-accent-text); } .portal-qb__opt-sub { font-size: 11.5px; @@ -342,7 +342,7 @@ color: var(--c-text); } .portal-qb__lines li[data-kind="DISCOUNT"] { - color: var(--c-success); + color: var(--color-green-dark); } .portal-qb__lines li[data-kind="INCLUDED"] span:last-child { color: var(--c-text-subtle); @@ -516,7 +516,7 @@ align-items: center; justify-content: center; /* Holds a CheckIcon, which takes its stroke from `color` and its size from its own prop. */ - color: var(--c-success); + color: var(--color-green-dark); background: var(--c-success-subtle); } .portal-hero__live-text { @@ -897,7 +897,7 @@ margin-top: 0.75rem; } .portal-proc__error { - color: var(--c-danger); + color: var(--color-red-dark); font-size: 0.8125rem; margin: 0.5rem 0 0; } diff --git a/frontend/editor/src/portal/views/Sources.css b/frontend/editor/src/portal/views/Sources.css index 132c137c89..bb39eae86c 100644 --- a/frontend/editor/src/portal/views/Sources.css +++ b/frontend/editor/src/portal/views/Sources.css @@ -86,7 +86,7 @@ .portal-sources__caret.is-open { transform: rotate(90deg); - color: var(--c-primary); + color: var(--c-accent-text); } /* Expanded detail panel */ @@ -226,7 +226,7 @@ display: block; width: 100%; margin-top: 0.5rem; - color: var(--c-primary); + color: var(--c-accent-text); } .portal-sources__chips { @@ -414,7 +414,7 @@ } .portal-sources__type-card.is-selected .portal-sources__type-icon { - color: var(--c-primary); + color: var(--c-accent-text); } .portal-sources__type-icon .portal-sources__type-svg { diff --git a/frontend/editor/src/portal/views/Users.css b/frontend/editor/src/portal/views/Users.css index 75591cc8d8..ae11d3b88d 100644 --- a/frontend/editor/src/portal/views/Users.css +++ b/frontend/editor/src/portal/views/Users.css @@ -265,7 +265,7 @@ padding: 0.5rem 0.75rem; border-radius: 8px; background: color-mix(in srgb, var(--c-danger) 12%, transparent); - color: var(--c-danger); + color: var(--color-red-dark); font-size: 0.85rem; } @@ -279,7 +279,7 @@ } .portal-users__link { - color: var(--c-primary); + color: var(--c-accent-text); text-decoration: none; } .portal-users__link:hover { @@ -337,7 +337,7 @@ background: none; border: none; cursor: pointer; - color: var(--c-primary); + color: var(--c-accent-text); font-size: 0.8rem; font-weight: 500; white-space: nowrap; @@ -492,7 +492,7 @@ background: none; border: none; border-top: 1px solid var(--c-border-subtle); - color: var(--c-primary); + color: var(--c-accent-text); font-size: 0.8rem; font-weight: 500; cursor: pointer; diff --git a/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx b/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx index 3644ed91bc..6ffb202cd3 100644 --- a/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx +++ b/frontend/editor/src/proprietary/auth/ui/OAuthButtons.tsx @@ -24,6 +24,8 @@ export const oauthProviderConfig: Record< }; // Icon URLs + GENERIC_PROVIDER_ICON come from the shared oauthIcons resolver. +// Every provider icon is decorative (alt=""): the button it sits in already +// names the provider, so alt text would only repeat that name. interface OAuthButtonsProps { onProviderClick: (provider: OAuthProvider) => void; @@ -116,7 +118,7 @@ export default function OAuthButtons({ > {p.label} @@ -142,7 +144,7 @@ export default function OAuthButtons({ > {p.label} @@ -169,7 +171,7 @@ export default function OAuthButtons({ {p.label} @@ -210,7 +212,7 @@ export default function OAuthButtons({ {p.label} diff --git a/frontend/editor/src/proprietary/billing/MeterBar.tsx b/frontend/editor/src/proprietary/billing/MeterBar.tsx index ee03d62885..5049b91294 100644 --- a/frontend/editor/src/proprietary/billing/MeterBar.tsx +++ b/frontend/editor/src/proprietary/billing/MeterBar.tsx @@ -23,6 +23,8 @@ interface MeterBarProps { meta?: ReactNode; /** Hide the fill bar (e.g. uncapped). Shown by default. */ showBar?: boolean; + /** Accessible name for the fill bar — what the meter measures ("Spend limit"). */ + barLabel: string; } /** @@ -40,6 +42,7 @@ export function MeterBar({ statusLabel, meta, showBar = true, + barLabel, }: MeterBarProps) { return (
    @@ -61,6 +64,7 @@ export function MeterBar({ aria-valuenow={Math.round(pct)} aria-valuemin={0} aria-valuemax={100} + aria-label={barLabel} >
    , because only Modal.Content lands + // props on the role="dialog" element — the modal draws its own heading, so + // the dialog needs an aria-label to have an accessible name. + -
    - - - - - - - - {t("workspace.people.changePassword.title", "Change password")} - - - {t( - "workspace.people.changePassword.subtitle", - "Update the password for", - )}{" "} - {user?.username} - - + + + +
    + + + + + + + + {t( + "workspace.people.changePassword.title", + "Change password", + )} + + + {t( + "workspace.people.changePassword.subtitle", + "Update the password for", + )}{" "} + {user?.username} + + - - - setForm({ - ...form, - newPassword: event.currentTarget.value, - generateRandom: false, - }) - } - disabled={processing || disabled || form.generateRandom} - data-autofocus - /> - - setForm({ - ...form, - confirmPassword: event.currentTarget.value, - generateRandom: false, - }) - } - disabled={processing || disabled || form.generateRandom} - error={ - !form.generateRandom && - form.confirmPassword && - form.newPassword !== form.confirmPassword - ? t( - "workspace.people.changePassword.passwordMismatch", - "Passwords do not match", - ) - : undefined - } - /> - - { - const checked = event.currentTarget.checked; - setForm((prev) => ({ ...prev, generateRandom: checked })); - if (event.currentTarget.checked) { - handleGeneratePassword(); + + + setForm({ + ...form, + newPassword: event.currentTarget.value, + generateRandom: false, + }) } - }} - /> - {passwordPreview && ( - + disabled={processing || disabled || form.generateRandom} + data-autofocus + /> + + setForm({ + ...form, + confirmPassword: event.currentTarget.value, + generateRandom: false, + }) + } + disabled={processing || disabled || form.generateRandom} + error={ + !form.generateRandom && + form.confirmPassword && + form.newPassword !== form.confirmPassword + ? t( + "workspace.people.changePassword.passwordMismatch", + "Passwords do not match", + ) + : undefined + } + /> + + { + const checked = event.currentTarget.checked; + setForm((prev) => ({ ...prev, generateRandom: checked })); + if (event.currentTarget.checked) { + handleGeneratePassword(); + } + }} + /> + {passwordPreview && ( + + + {t( + "workspace.people.changePassword.generatedPreview", + "Generated password:", + )}{" "} + {passwordPreview} + + + + + + + + )} + + + + + + setForm({ ...form, sendEmail: event.currentTarget.checked }) + } + disabled={!canEmail || processing} + /> + + setForm({ + ...form, + includePassword: event.currentTarget.checked, + }) + } + disabled={!canEmail || !form.sendEmail || processing} + /> + + setForm({ + ...form, + forcePasswordChange: event.currentTarget.checked, + }) + } + disabled={processing || disabled} + /> + {!canEmail && ( + + {mailEnabled + ? t( + "workspace.people.changePassword.emailUnavailable", + "This user's email is not a valid email address. Notifications are disabled.", + ) + : t( + "workspace.people.changePassword.smtpDisabled", + "Email notifications require SMTP to be enabled in settings.", + )} + + )} + {canEmail && !form.includePassword && form.sendEmail && ( {t( - "workspace.people.changePassword.generatedPreview", - "Generated password:", - )}{" "} - {passwordPreview} + "workspace.people.changePassword.notifyOnly", + "An email will be sent without the password, letting the user know an admin changed it.", + )} - - - - - - - )} - - - - - - setForm({ ...form, sendEmail: event.currentTarget.checked }) - } - disabled={!canEmail || processing} - /> - - setForm({ - ...form, - includePassword: event.currentTarget.checked, - }) - } - disabled={!canEmail || !form.sendEmail || processing} - /> - - setForm({ - ...form, - forcePasswordChange: event.currentTarget.checked, - }) - } - disabled={processing || disabled} - /> - {!canEmail && ( - - {mailEnabled - ? t( - "workspace.people.changePassword.emailUnavailable", - "This user's email is not a valid email address. Notifications are disabled.", - ) - : t( - "workspace.people.changePassword.smtpDisabled", - "Email notifications require SMTP to be enabled in settings.", - )} - - )} - {canEmail && !form.includePassword && form.sendEmail && ( - - {t( - "workspace.people.changePassword.notifyOnly", - "An email will be sent without the password, letting the user know an admin changed it.", )} - - )} - + - - -
    - + +
    +
    + + +
    ); } 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 7212169e6e..4f132eb399 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.tsx @@ -604,7 +604,7 @@ const AccountSection: React.FC = () => { {t("account.mfa.manualKey", "Manual setup key")}:{" "} {mfaSetupData.secret} - + {t( "account.mfa.secretWarning", "Keep this key private. Anyone with access can generate valid authentication codes.", diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx index 2ad95525a1..f788551551 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminConnectionsSection.tsx @@ -658,7 +658,7 @@ export default function AdminConnectionsSection() { href="https://docs.stirlingpdf.com/Functionality/Mobile-Scanner" target="_blank" size="xs" - c="blue" + c="var(--c-accent-text)" > {t( "admin.settings.connections.documentation", @@ -687,7 +687,7 @@ export default function AdminConnectionsSection() { "Allow users to upload files from mobile devices by scanning a QR code", )} - + {t( "admin.settings.connections.mobileScanner.note", "Note: Requires Frontend URL to be configured. ", @@ -698,7 +698,7 @@ export default function AdminConnectionsSection() { e.preventDefault(); navigate("/settings/adminGeneral#frontendUrl"); }} - c="orange" + c="var(--color-amber-dark)" td="underline" > {t( diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx index c8df9eaea3..c62125be3f 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminMailSection.tsx @@ -168,7 +168,7 @@ export default function AdminMailSection() { "Allow admins to invite users via email with auto-generated passwords", )} - + {t( "admin.settings.mail.frontendUrlNote.note", "Note: Requires Frontend URL to be configured. ", @@ -179,7 +179,7 @@ export default function AdminMailSection() { e.preventDefault(); navigate("/settings/adminGeneral#frontendUrl"); }} - c="orange" + c="var(--color-amber-dark)" td="underline" > {t( diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx index 6ab2a0fef8..dfaecac1fb 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx @@ -254,7 +254,7 @@ export default function AdminStorageSharingSection() { )} {!frontendUrlConfigured && ( - + {t( "admin.settings.storage.sharing.links.frontendUrlNote", "Requires a Frontend URL. ", @@ -265,7 +265,7 @@ export default function AdminStorageSharingSection() { e.preventDefault(); navigate("/settings/adminGeneral#frontendUrl"); }} - c="orange" + c="var(--color-amber-dark)" td="underline" > {t( @@ -317,7 +317,7 @@ export default function AdminStorageSharingSection() { )} {!mailEnabled && ( - + {t( "admin.settings.storage.sharing.email.mailNote", "Requires mail configuration. ", @@ -328,7 +328,7 @@ export default function AdminStorageSharingSection() { e.preventDefault(); navigate("/settings/adminConnections"); }} - c="orange" + c="var(--color-amber-dark)" td="underline" > {t( 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 9af89dedad..23b879f974 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.tsx @@ -269,7 +269,7 @@ export default function LoginAgreementEditor({ {loading && } {loadFailed && !loading && ( - + {t( "admin.settings.legal.loginAgreement.loadError", "Failed to load the agreement for {{locale}}. Switch language and back to retry.", 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 d3702e7f80..76ee65abe0 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.tsx @@ -350,7 +350,7 @@ export default function TeamDetailsSection({ if (!team) { return ( - + {t("workspace.teams.teamNotFound", "Team not found")}
    {currency && onCurrencyChange && currencyOptions && (