From 0d75715af232483bf47d8e6f023522aa3e6ef071 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 25 Aug 2026 15:56:44 +0000 Subject: [PATCH 01/37] Fix flaky e2e tests (#7595) # Description of Changes e2e Playwright tests are currently failing intermittently on all platforms for different reasons, most notably WebKit, which seems to fail much more often than the others. This PR attempts to fix the issues. I've ran the e2e tests a few times now and they don't seem to be inconsistent any more, but it's difficult to tell if all the issues are genuinely fixed due to the inconsistent nature. As far as I can tell, I've not broken anything though. --- .../core/components/shared/AppConfigModal.tsx | 21 +---- .../shared/superSearch/SuperSearch.tsx | 17 +++- frontend/editor/src/core/pages/HomePage.tsx | 29 ++++-- .../src/core/tests/helpers/ui-helpers.ts | 89 +++++++++++++++---- 4 files changed, 108 insertions(+), 48 deletions(-) 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 }); } /** From 353df7a647cf0924a2aba87e0833fb5c4bb9f980 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 25 Aug 2026 15:59:16 +0000 Subject: [PATCH 02/37] Improve modals in Sources page in Processor (#7664) # Description of Changes Various changes throughout to try and convert the bulk of the dev UI sources modals to production quality. Changes include: - Fixing inconsistencies between different modals - Hide things users will rarely need to change behind advanced - Removed clutter in the UI - Renaming settings in terms that the user will understand and care about image image --- .../public/locales/en-US/translation.toml | 52 +-- frontend/editor/src/core/theme/colors.css | 12 +- frontend/editor/src/core/ui/Collapsible.css | 46 +-- .../src/core/ui/Collapsible.stories.tsx | 29 +- frontend/editor/src/core/ui/Collapsible.tsx | 52 +-- frontend/editor/src/core/ui/ColorInput.tsx | 2 +- frontend/editor/src/core/ui/FormField.css | 30 ++ frontend/editor/src/core/ui/FormField.tsx | 63 ++- frontend/editor/src/core/ui/Input.css | 2 +- frontend/editor/src/core/ui/MantineForms.css | 8 +- frontend/editor/src/core/ui/Modal.css | 18 +- frontend/editor/src/core/ui/Modal.tsx | 35 +- frontend/editor/src/core/ui/MultiSelect.tsx | 3 +- frontend/editor/src/core/ui/NumberInput.tsx | 2 +- frontend/editor/src/core/ui/Select.css | 2 +- frontend/editor/src/core/ui/Select.tsx | 7 +- .../sources/ConnectionModal.test.tsx | 6 +- .../components/sources/ConnectionModal.tsx | 20 +- .../components/sources/ConnectionPicker.tsx | 30 +- .../portal/components/sources/SourceModal.css | 32 +- .../components/sources/SourceModal.test.tsx | 12 +- .../portal/components/sources/SourceModal.tsx | 380 ++++++++++-------- .../portal/components/sources/connections.css | 7 +- .../portal/components/sources/sourceTypes.ts | 14 + .../editor/src/portal/theme/mantineTheme.ts | 2 +- 25 files changed, 512 insertions(+), 354 deletions(-) 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/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 && ( -