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 && (
-