diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 0d5a310c45..39bb60bdef 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -4,7 +4,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3 ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application (backend only, no frontend) -FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build +FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build # JDK 25+: --add-exports is no longer accepted via JAVA_TOOL_OPTIONS; use JDK_JAVA_OPTIONS instead ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 7289d67bb5..cac30d88b1 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -5,7 +5,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3 ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application and frontend -FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build +FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build ARG TASK_VERSION=3.52.0 RUN apt-get update \ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index cda78127af..42c9a16af2 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -8,7 +8,7 @@ ARG BASE_VERSION=1.0.2@sha256:c7698687f486707ddef9e0298587ca8b44c4e96185e1bdb0c3 ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION} # Stage 1: Build the Java application and frontend -FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS app-build +FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS app-build ARG TASK_VERSION=3.52.0 RUN apt-get update \ diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index c21ddf2f8d..f1389c1600 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -4,7 +4,7 @@ # Single JAR contains both frontend and backend with minimal dependencies # Stage 1: Build application with embedded frontend -FROM gradle:9.7.1-jdk25@sha256:a80276ab804c348989df46016e2b5d58cad07c5b29e06f2112434d28ca5b2844 AS build +FROM gradle:9.7.1-jdk25@sha256:d868117760a7c92214705f47ed173116a5d13e58d68702f974ff30acd062737e AS build # Install Node.js and npm for frontend build ARG TASK_VERSION=3.52.0 diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 7760f002dd..1b81a551f1 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -8833,7 +8833,9 @@ title = "Sources" connectSource = "Connect source" [portal.sources.builder] +advanced = "Advanced" back = "Back to sources" +backToSource = "Back to source setup" backToTypes = "All source types" cancel = "Cancel" chooseHint = "Choose where documents come from. Greyed-out connectors are on the way." @@ -8843,7 +8845,6 @@ create = "Create source" createTitle = "Connect a source" delete = "Delete" editTitle = "Edit source" -enabled = "Enabled" save = "Save changes" [portal.sources.builder.folderAccess] @@ -8863,23 +8864,24 @@ total = "Connections" unused = "Unused" [portal.sources.networkFields.connection] -helperText = "The stored connection with the server address and credentials. Reused by every source that references it." +helperText = "The saved connection with the server address and credentials. Shared by every source that uses it." label = "Connection" [portal.sources.networkFields.directory] -helperText = "Folder on the server to poll, relative to the login home or share root. Leave blank for the root." +helperText = "The folder on the server to watch, relative to the login home or share root." label = "Folder" -placeholder = "incoming/" +placeholder = "Login home or share root" [portal.sources.networkFields.mode] -helperText = "Consume removes each file from the server once every policy has processed it." -label = "Read mode" +helperText = "Whether to leave the original file in place after it has been processed. If the original file is left in place, it will be re-processed next time the pipeline scans the source." +label = "After processing" [portal.sources.networkFields.mode.options] -consume = "Consume: process each file once" -snapshot = "Snapshot: re-read the folder every run" +consume = "Delete the file" +snapshot = "Leave it in place" [portal.sources.networkFields.recursive] +helperText = "Include subfolders if your files are organised into nested folders, or watch only the top level." label = "Folder depth" [portal.sources.networkFields.recursive.options] @@ -8920,26 +8922,28 @@ description = "Watch a directory on the server for new documents." label = "Folder" [portal.sources.types.folder.fields.directory] -helperText = "Absolute path Stirling watches for files to process." -label = "Directory path" +helperText = "The absolute path to the folder to watch for new files." +label = "Folder" placeholder = "/data/incoming" [portal.sources.types.folder.fields.identity] -helperText = "Content check reads each changed file, so renames and touches that don't alter content are not reprocessed." +helperText = "Using the file contents for change detection is slower but more accurate." label = "Change detection" [portal.sources.types.folder.fields.identity.options] -hash = "Size, date and content check" -stat = "Size and date modified" +hash = "File Metadata & Content" +stat = "File Metadata" [portal.sources.types.folder.fields.mode] -label = "Read mode" +helperText = "Whether to leave the original file in place after it has been processed. If the original file is left in place, it will be re-processed next time the pipeline scans the source." +label = "After processing" [portal.sources.types.folder.fields.mode.options] -consume = "Consume: process each file once" -snapshot = "Snapshot: re-read the folder every run" +consume = "Delete the file" +snapshot = "Leave it in place" [portal.sources.types.folder.fields.recursive] +helperText = "Include subfolders if your files are organised into nested folders, or watch only the top level." label = "Folder depth" [portal.sources.types.folder.fields.recursive.options] @@ -8967,21 +8971,21 @@ description = "Pull documents from an Amazon S3 or S3-compatible bucket." label = "Amazon S3" [portal.sources.types.s3.fields.connection] -helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it." +helperText = "The saved connection with the bucket and credentials. Shared by every source and pipeline output that uses it." label = "Connection" [portal.sources.types.s3.fields.mode] -helperText = "Consume removes each object from the bucket once every policy has processed it." -label = "Read mode" +helperText = "Whether to leave the original object in place after it has been processed. If the original object is left in place, it will be re-processed next time the pipeline scans the source." +label = "After processing" [portal.sources.types.s3.fields.mode.options] -consume = "Consume: process each object once" -snapshot = "Snapshot: re-read the bucket every run" +consume = "Delete the object" +snapshot = "Leave it in place" [portal.sources.types.s3.fields.prefix] -helperText = "Only objects whose keys start with this prefix are processed." -label = "Key prefix" -placeholder = "incoming/" +helperText = "The folder (key prefix) within the bucket to watch." +label = "Folder" +placeholder = "Whole bucket" [portal.sources.types.sftp] description = "Poll an SFTP drop folder for new documents." diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 8c6bf423ed..c5adef038d 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -33,7 +33,6 @@ import { useUnsavedChanges, } from "@app/contexts/UnsavedChangesContext"; import { stripBasePath, withBasePath } from "@app/constants/app"; -import { EDITOR_BASENAME } from "@app/routes/editorBasename"; interface AppConfigModalProps { opened: boolean; @@ -232,27 +231,9 @@ const AppConfigModalInner: React.FC = ({ const handleClose = useCallback(async () => { const canProceed = await confirmIfDirty(); if (!canProceed) return false; - - // Only unwind history if settings was opened via the URL; opened via state - // there's no /settings entry to pop and navigate(-1) would jump to /files. - if (urlSync && location.pathname.startsWith("/settings")) { - // "default" key = first entry (deep link/refresh); nothing to pop to. - if (location.key === "default") { - navigate(EDITOR_BASENAME, { replace: true }); - } else { - navigate(-1); - } - } onClose(); return true; - }, [ - confirmIfDirty, - location.key, - location.pathname, - navigate, - onClose, - urlSync, - ]); + }, [confirmIfDirty, onClose]); // Synchronous wrapper for contexts (e.g. tour buttons) that need () => void const handleCloseSync = useCallback(() => { diff --git a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx index 9b5cf22190..054e99cfde 100644 --- a/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx +++ b/frontend/editor/src/core/components/shared/superSearch/SuperSearch.tsx @@ -289,16 +289,25 @@ export default function SuperSearch({ inputRef.current?.select(); }; // Focus handover from a closing dialog. Only the on-screen instance - // responds (offsetParent is null while display:none / unmounted hosts), - // and focus waits two frames so the dialog's own return-focus runs first. + // responds (offsetParent is null while a host is display:none / unmounted). const onFocusRequest = () => { const input = inputRef.current; if (!input || input.offsetParent === null) return; setOpen(true); + const grab = () => { + input.focus(); + input.select(); + }; requestAnimationFrame(() => requestAnimationFrame(() => { - input.focus(); - input.select(); + grab(); + // The dialog's return-focus fires shortly after it closes and steals + // focus back once; re-grab it if that happens. + input.addEventListener("focusout", grab, { once: true }); + window.setTimeout( + () => input.removeEventListener("focusout", grab), + 250, + ); }), ); }; diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index d695de8ac3..e0c5303447 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -29,6 +29,7 @@ import LocalIcon from "@app/components/shared/LocalIcon"; import AppConfigModal from "@app/components/shared/AppConfigModalLazy"; import { getStartupNavigationAction } from "@app/utils/homePageNavigation"; import { EDITOR_BASENAME } from "@app/routes/editorBasename"; +import { stripBasePath } from "@app/constants/app"; import { HomePageExtensions } from "@app/components/home/HomePageExtensions"; import { FilesPageProvider, @@ -123,12 +124,30 @@ export default function HomePage() { return () => window.removeEventListener("appConfig:open", handler); }, []); - const handleCloseConfig = useCallback(() => { - setConfigModalOpen(false); - if (location.pathname.startsWith("/settings")) { - navigate(EDITOR_BASENAME, { replace: true }); + // Where the user was before settings opened, so close can restore it. Null + // when opened directly on a /settings URL (deep link) - close falls back to + // the editor root. + const settingsOriginRef = useRef(null); + const wasConfigOpenRef = useRef(false); + useEffect(() => { + if (configModalOpen && !wasConfigOpenRef.current) { + settingsOriginRef.current = location.pathname.startsWith("/settings") + ? null + : location.pathname; } - }, [location.pathname, navigate]); + wasConfigOpenRef.current = configModalOpen; + }, [configModalOpen, location.pathname]); + + const handleCloseConfig = useCallback(() => { + // Restore the URL before clearing the flag, or a late /settings commit + // re-opens the modal. Read window.location, not useLocation: a tab switch + // updates the URL synchronously while the router's commit lags. Replace to + // the origin rather than navigate(-1), which webkit can drop. + if (stripBasePath(window.location.pathname).startsWith("/settings")) { + navigate(settingsOriginRef.current ?? EDITOR_BASENAME, { replace: true }); + } + setConfigModalOpen(false); + }, [navigate]); const { activeFiles } = useFileContext(); const navigationState = useNavigationState(); diff --git a/frontend/editor/src/core/tests/helpers/ui-helpers.ts b/frontend/editor/src/core/tests/helpers/ui-helpers.ts index 353037c106..1ce62bd828 100644 --- a/frontend/editor/src/core/tests/helpers/ui-helpers.ts +++ b/frontend/editor/src/core/tests/helpers/ui-helpers.ts @@ -69,31 +69,73 @@ export async function waitForModalClose( /** * Upload one or more files through the FileSidebar's "Open from computer" - * action. The button is always rendered (collapsed or expanded sidebar) and - * fires the hidden `data-testid="file-input"`. Its native OS picker is mocked - * globally by `suppressNativeFilePicker` (installed by the test fixtures), so - * the click is safe on every browser; we then set the files directly on the - * input via `setInputFiles`. - * - * `setInputFiles` doesn't await the input's async onChange (which writes to - * IndexedDB via `addFiles`), so without a sync point a caller that follows - * with `page.goto()` can race the IDB flush. Wait for the workbench to - * pick up the upload (the FileSidebar renders the added file in its scroll - * list once `addFiles` resolves and IDB has been written). + * action. The native picker is mocked globally by `suppressNativeFilePicker`, + * so the click is safe on every browser; files are set on the hidden input via + * `setInputFiles`. Returns only once the files are durably in IndexedDB, so + * callers may navigate or reload without racing the write. */ export async function uploadFiles( page: Page, filePaths: string | string[], ): Promise { const paths = Array.isArray(filePaths) ? filePaths : [filePaths]; + const names = paths.map((p) => p.split(/[\\/]/).pop() ?? p); await page.getByTestId("files-button").click(); await page.locator('[data-testid="file-input"]').setInputFiles(paths); - // Sync point: wait until at least one file lands in the sidebar's file - // list. The list only renders once `addFiles` has resolved (which awaits - // the IDB write). Use first() so multi-file uploads pass too. + // The sidebar renders from in-memory state, before the IDB write commits. + // first() so multi-file uploads pass too. await expect(page.locator(".file-sidebar-file-item").first()).toBeVisible({ timeout: 10_000, }); + // A navigation before the write commits aborts the transaction and drops the + // file, so wait for it to land rather than assume the sidebar means it did. + await waitForStoredFiles(page, names); +} + +/** + * Resolve once every uploaded name is in the files store. Read-only: aborts + * rather than create or upgrade the DB, so it can't race the app's own + * versioned open and leave it without object stores. Reads only a DB the app + * already made; until then each poll returns false and retries. + */ +async function waitForStoredFiles(page: Page, names: string[]): Promise { + await page.waitForFunction( + (expected) => + new Promise((resolve) => { + const open = indexedDB.open("stirling-pdf-files"); + open.onupgradeneeded = () => { + open.transaction?.abort(); + resolve(false); + }; + open.onsuccess = () => { + const db = open.result; + if (!db.objectStoreNames.contains("files")) { + db.close(); + resolve(false); + return; + } + const request = db + .transaction("files", "readonly") + .objectStore("files") + .getAll(); + request.onsuccess = () => { + const stored = new Set( + (request.result as Array<{ name?: string }>).map((r) => r.name), + ); + db.close(); + resolve(expected.every((name) => stored.has(name))); + }; + request.onerror = () => { + db.close(); + resolve(false); + }; + }; + open.onerror = () => resolve(false); + open.onblocked = () => resolve(false); + }), + names, + { timeout: 10_000, polling: 100 }, + ); } /** @@ -152,11 +194,20 @@ export async function openSettings(page: Page): Promise { * dialog is fully dismissed before returning. */ export async function closeSettings(page: Page): Promise { - const closeBtn = page.locator('[aria-label="Close"]').first(); - await closeBtn.click(); - await expect(page.locator(".mantine-Modal-content").first()).not.toBeVisible({ - timeout: 5_000, - }); + const modal = page.locator(".mantine-Modal-content").first(); + // A click on the X can be swallowed by a re-render, leaving the modal open; + // retry until it's gone. A genuinely broken close still fails - every retry + // misses and the modal stays past the cap. + await expect(async () => { + if (await modal.isVisible().catch(() => false)) { + await page + .locator('[aria-label="Close"]') + .first() + .click({ timeout: 2_000 }) + .catch(() => {}); + } + await expect(modal).not.toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 12_000 }); } /** diff --git a/frontend/editor/src/core/theme/colors.css b/frontend/editor/src/core/theme/colors.css index 8e416af124..31f9666271 100644 --- a/frontend/editor/src/core/theme/colors.css +++ b/frontend/editor/src/core/theme/colors.css @@ -1,5 +1,5 @@ /* COLORS — canonical semantic core (~21 --c-* tokens) mapped from primitives.css per theme; compat.css aliases legacy names onto these. Editor: always data-app-theme="custom" + data-mantine-color-scheme=light|dark. Portal/Storybook: data-theme=light|dark → LIGHT/MIDNIGHT. */ -/* Surface elevation: --c-bg (canvas) < --c-bg-raised (sidebars) < --c-surface (cards) < --c-surface-raised < --c-surface-sunken; plus --c-input-bg, --c-hover, --c-active, --c-overlay. */ +/* Surface elevation: --c-bg (canvas) < --c-bg-raised (sidebars) < --c-surface (cards) < --c-surface-raised < --c-surface-sunken; plus --c-modal-surface (modal panels), --c-input-bg, --c-hover, --c-active, --c-overlay. */ /* ── LIGHT ───────────────────────────────────────────────────────────────── */ :root, @@ -11,6 +11,7 @@ html[data-app-theme="light"] { --c-surface-raised: var(--p-white); --c-surface-sunken: var(--p-gray-100); --c-input-bg: var(--p-white); + --c-modal-surface: var(--p-white); --c-hover: var(--p-gray-50); --c-active: var(--p-gray-100); --c-overlay: rgba(0, 0, 0, 0.5); @@ -22,7 +23,7 @@ html[data-app-theme="light"] { --c-btn-solid: var(--c-text); --c-btn-inverse: var(--p-snow); - --c-btn-secondary: var(--c-btn-inverse); + --c-btn-secondary: var(--c-surface-raised); --c-btn-secondary-border: var(--c-border); --c-border: var(--p-c-f0f0f0); @@ -144,7 +145,8 @@ html[data-app-theme="midnight"] { --c-surface: var(--p-c-1a1a1d); --c-surface-raised: var(--p-zinc-650); --c-surface-sunken: var(--p-zinc-850); - --c-input-bg: var(--p-zinc-650); + --c-input-bg: var(--c-surface); + --c-modal-surface: var(--c-surface); --c-hover: var(--p-gray-800); --c-active: var(--p-gray-800); --c-overlay: rgba(0, 0, 0, 0.6); @@ -360,7 +362,7 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] { var(--c-primary) 8%, var(--p-zinc-900) ); - --c-input-bg: color-mix(in srgb, var(--c-primary) 7%, var(--p-zinc-900)); + --c-input-bg: var(--c-surface); --c-hover: color-mix(in srgb, var(--c-primary) 12%, var(--p-zinc-750)); --c-active: color-mix(in srgb, var(--c-primary) 15%, var(--p-zinc-700)); --c-border: color-mix(in srgb, var(--c-primary) 14%, var(--p-c-28282d)); @@ -424,7 +426,7 @@ html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme=" --c-surface: var(--p-c-1a1a1d); --c-surface-raised: var(--p-zinc-775); --c-surface-sunken: var(--p-zinc-900); - --c-input-bg: var(--p-zinc-900); + --c-input-bg: var(--c-surface); --c-hover: var(--p-zinc-750); --c-active: var(--p-zinc-700); --c-border: var(--p-c-28282d); diff --git a/frontend/editor/src/core/ui/Collapsible.css b/frontend/editor/src/core/ui/Collapsible.css index 6f7164dea5..1565464810 100644 --- a/frontend/editor/src/core/ui/Collapsible.css +++ b/frontend/editor/src/core/ui/Collapsible.css @@ -1,51 +1,41 @@ .sui-collapsible { + display: flex; + flex-direction: column; overflow: hidden; } +/* A bare chevron-led toggle: no box, sized to its label, left-aligned. */ .sui-collapsible__head { - display: flex; + align-self: flex-start; + display: inline-flex; align-items: center; - gap: 0.5rem; - width: 100%; - padding: 0.75rem 0.875rem; - background: var(--c-surface); + gap: 0.375rem; + padding: 0; border: none; + background: none; cursor: pointer; - text-align: left; - transition: background-color 0.15s ease; + font: inherit; + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text-muted); } .sui-collapsible__head:hover { - background: var(--c-hover); -} - -.sui-collapsible__head-main { - display: flex; - align-items: center; - gap: 0.5rem; - flex: 1; - min-width: 0; - flex-wrap: wrap; -} - -.sui-collapsible__head-end { - display: inline-flex; - align-items: center; - gap: 0.625rem; - margin-left: auto; - flex-shrink: 0; + color: var(--c-text); } +/* Points right when closed, rotates down when open. */ .sui-collapsible__chevron { color: var(--c-text-subtle); + transform: rotate(-90deg); transition: transform 0.2s ease; } .sui-collapsible__chevron[data-open="true"] { - transform: rotate(180deg); + transform: rotate(0deg); } -/* Grid-rows disclosure: 0fr → 1fr, no magic max-height */ +/* Grid-rows disclosure: 0fr → 1fr, no magic max-height. */ .sui-collapsible__body { display: grid; grid-template-rows: 0fr; @@ -56,6 +46,8 @@ grid-template-rows: 1fr; } +/* Gap between the toggle and the revealed content, no side indent. */ .sui-collapsible__body-inner { overflow: hidden; + padding-top: 0.875rem; } diff --git a/frontend/editor/src/core/ui/Collapsible.stories.tsx b/frontend/editor/src/core/ui/Collapsible.stories.tsx index 36dffb527b..b2c1c6c5a4 100644 --- a/frontend/editor/src/core/ui/Collapsible.stories.tsx +++ b/frontend/editor/src/core/ui/Collapsible.stories.tsx @@ -19,25 +19,17 @@ export const Default: Story = { setOpen((o) => !o)} - header={Section title} - aside={3 items} + header="Advanced" > -
- Body content revealed when the section is open. -
+ Body content revealed when the section is open.
); }, }; -// Stacked instances form an accordion; each section toggles independently. -export const Accordion: Story = { +// Several independent disclosures stacked in a form-like column. +export const Stacked: Story = { render: () => { const [open, setOpen] = useState(0); const sections = ["Trial", "Quote", "Agreement"]; @@ -47,7 +39,7 @@ export const Accordion: Story = { maxWidth: "40rem", display: "flex", flexDirection: "column", - gap: "0.75rem", + gap: "0.875rem", }} > {sections.map((label, i) => ( @@ -55,16 +47,9 @@ export const Accordion: Story = { key={label} open={open === i} onToggle={() => setOpen(open === i ? null : i)} - header={{label}} + header={label} > -
- {label} details. -
+ {label} details. ))} diff --git a/frontend/editor/src/core/ui/Collapsible.tsx b/frontend/editor/src/core/ui/Collapsible.tsx index 229b2d2ef1..12992c2668 100644 --- a/frontend/editor/src/core/ui/Collapsible.tsx +++ b/frontend/editor/src/core/ui/Collapsible.tsx @@ -1,38 +1,31 @@ import type { ReactNode } from "react"; -import "@app/ui/Surface.css"; import "@app/ui/Collapsible.css"; export interface CollapsibleProps { /** Whether the section is expanded. Controlled — pair with `onToggle`. */ open: boolean; onToggle: () => void; - /** Header content (left/main side); the chevron is appended automatically. */ + /** The toggle's label, shown after the chevron. */ header: ReactNode; - /** Right-aligned header content shown before the chevron (a count, a label). */ - aside?: ReactNode; children: ReactNode; className?: string; } /** - * A single bordered disclosure section: an always-visible header that toggles, - * and a body that animates open/closed via a grid-rows transition (no magic - * max-height). Stack several to build an accordion. Header content is fully - * caller-supplied, so it suits both terse and rich (icon + chips + count) rows. + * A lightweight inline disclosure: a chevron-led label that toggles, and a body + * that animates open/closed via a grid-rows transition (no magic max-height). + * No surface or box - it sits inline within a form or panel. */ export function Collapsible({ open, onToggle, header, - aside, children, className, }: CollapsibleProps) { return (
{children}
diff --git a/frontend/editor/src/core/ui/ColorInput.tsx b/frontend/editor/src/core/ui/ColorInput.tsx index cf33331d7d..40e61470fa 100644 --- a/frontend/editor/src/core/ui/ColorInput.tsx +++ b/frontend/editor/src/core/ui/ColorInput.tsx @@ -12,7 +12,7 @@ import { useInputAria } from "@app/ui/ariaForwarding"; import "@app/ui/MantineForms.css"; const SUI_INPUT_VARS = { - "--input-bg": "var(--c-surface)", + "--input-bg": "var(--c-input-bg)", "--input-bd": "var(--c-border)", "--input-bd-focus": "var(--c-primary)", "--input-radius": "var(--radius-md)", diff --git a/frontend/editor/src/core/ui/FormField.css b/frontend/editor/src/core/ui/FormField.css index 792845c255..f7aaf61131 100644 --- a/frontend/editor/src/core/ui/FormField.css +++ b/frontend/editor/src/core/ui/FormField.css @@ -5,6 +5,12 @@ min-width: 0; } +.sui-field__label-row { + display: flex; + align-items: center; + gap: 0.25rem; +} + .sui-field__label { font-size: 0.75rem; font-weight: 600; @@ -13,6 +19,30 @@ color: var(--color-section-label); } +/* An (i) affordance beside the label: the explanation lives in its tooltip + rather than as permanent subtext under the control. */ +.sui-field__info { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: none; + color: var(--c-text-subtle); + cursor: pointer; + line-height: 0; +} + +.sui-field__info:hover { + color: var(--c-text); +} + +.sui-field__info:focus-visible { + outline: 2px solid var(--c-primary); + outline-offset: 2px; + border-radius: 999px; +} + .sui-field__required { /* The base red is a fill colour; as text on the form background it only reaches 3.4:1. */ diff --git a/frontend/editor/src/core/ui/FormField.tsx b/frontend/editor/src/core/ui/FormField.tsx index 65eec64eb8..473ca82472 100644 --- a/frontend/editor/src/core/ui/FormField.tsx +++ b/frontend/editor/src/core/ui/FormField.tsx @@ -5,12 +5,18 @@ import { type ReactElement, type ReactNode, } from "react"; +import { Tooltip } from "@mantine/core"; import "@app/ui/FormField.css"; export interface FormFieldProps { label?: ReactNode; /** Helper text shown under the control. Replaced by `error` when present. */ helperText?: ReactNode; + /** + * Supplementary explanation shown behind an (i) affordance on the label, + * instead of taking up permanent space under the control like `helperText`. + */ + info?: ReactNode; /** Error string. Causes the control + helper region to swap to the error tone. */ error?: ReactNode; required?: boolean; @@ -32,6 +38,7 @@ export interface FormFieldProps { export function FormField({ label, helperText, + info, error, required, children, @@ -57,16 +64,54 @@ export function FormField({ .filter(Boolean) .join(" ")} > - {label && ( -