diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
index dd8e379a8a..cd601cf525 100644
--- a/frontend/.storybook/preview.tsx
+++ b/frontend/.storybook/preview.tsx
@@ -7,7 +7,6 @@ import type { Decorator, Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";
import { MemoryRouter } from "react-router-dom";
import { withThemeByDataAttribute } from "@storybook/addon-themes";
-import { MantineProvider } from "@mantine/core";
// Reference React so the import isn't dropped as unused by the bundler — the
// classic runtime needs it present even though it's not named in the JSX.
@@ -17,7 +16,7 @@ import { TierProvider, type Tier } from "@portal/contexts/TierContext";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { ThemeProvider } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
-import { mantineTheme } from "@portal/theme/mantineTheme";
+import { SuiProvider } from "@portal/theme/SuiProvider";
import { handlers } from "@portal/mocks/handlers";
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
@@ -102,7 +101,7 @@ const withProviders: Decorator = (Story, context) => {
return (
-
+
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
from useLink() (matches App.tsx's nesting). */}
@@ -115,7 +114,7 @@ const withProviders: Decorator = (Story, context) => {
-
+
);
diff --git a/frontend/editor/src/portal/PortalApp.tsx b/frontend/editor/src/portal/PortalApp.tsx
index 1494e23cdf..629a8ac57a 100644
--- a/frontend/editor/src/portal/PortalApp.tsx
+++ b/frontend/editor/src/portal/PortalApp.tsx
@@ -1,6 +1,5 @@
import { useEffect, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
-import { MantineProvider } from "@mantine/core";
import { AuthProvider } from "@app/auth";
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
@@ -8,7 +7,7 @@ import { TierProvider } from "@portal/contexts/TierContext";
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin";
import { UIProvider, useUI } from "@portal/contexts/UIContext";
-import { mantineTheme } from "@portal/theme/mantineTheme";
+import { SuiProvider } from "@portal/theme/SuiProvider";
import { AppShell } from "@portal/components/AppShell";
import { AuthGate } from "@portal/components/AuthGate";
import { AssistantButton } from "@portal/components/AssistantButton";
@@ -25,17 +24,13 @@ import { ViewRouter } from "@portal/ViewRouter";
import "@portal/theme/base.css";
/**
- * Binds Mantine's colour scheme to the portal's own ThemeProvider so Mantine
- * components follow the same light/dark switch as the SUI primitives. Must sit
+ * Binds the SUI design system to the portal's own ThemeProvider so the SUI
+ * components follow the same light/dark switch as the CSS tokens. Must sit
* inside to read useTheme().
*/
-function PortalMantineProvider({ children }: { children: ReactNode }) {
+function ThemedSuiProvider({ children }: { children: ReactNode }) {
const { theme } = useTheme();
- return (
-
- {children}
-
- );
+ return {children} ;
}
/**
@@ -129,7 +124,7 @@ function RoutedContent() {
export function PortalApp() {
return (
-
+
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
@@ -156,7 +151,7 @@ export function PortalApp() {
-
+
);
}
diff --git a/frontend/editor/src/portal/UI_CONVENTIONS.md b/frontend/editor/src/portal/UI_CONVENTIONS.md
index 40879e2199..5487672b7b 100644
--- a/frontend/editor/src/portal/UI_CONVENTIONS.md
+++ b/frontend/editor/src/portal/UI_CONVENTIONS.md
@@ -1,19 +1,23 @@
# Portal UI conventions — SUI vs Mantine
-The portal has two component sources. The rule:
+The portal has one component source from the caller's point of view: **SUI
+(`@app/ui`)**. Under the hood there are two kinds of SUI component. The rule:
-> **Simple, presentational, brand-defining UI → our SUI design system
-> (`@app/ui`). Complex, stateful, or accessibility-hard widgets →
-> Mantine.** Don't reinvent what Mantine already does well; do own the look of
-> the simple, high-frequency pieces.
+> **Simple, presentational, brand-defining UI → hand-rolled SUI components.
+> Complex, stateful, or accessibility-hard widgets → Mantine, wrapped behind a
+> locked SUI interface.** Either way, callers import from `@app/ui`. Mantine is
+> an implementation detail of the design system — feature code never imports
+> `@mantine/core` directly.
-Both are theme-bound: `MantineProvider` in `App.tsx` is wired to the portal's
-`ThemeProvider` (`mantineTheme.ts` maps the brand palette), so Mantine widgets
-follow the same light/dark switch and brand colours as SUI. **The provider is
-intentional** — it exists precisely so we can drop Mantine widgets in where they
-earn their keep.
+Theme wiring lives in one place: `SuiProvider` (`@portal/theme/SuiProvider`)
+applies the SUI-token Mantine theme, remaps Mantine's neutral palette
+(dropdown/popover surfaces, borders, text) onto SUI tokens via
+`suiCssVariablesResolver`, and takes the resolved light/dark scheme so Mantine
+chrome and the SUI CSS variables switch together. The app and Storybook both
+render through it.
+
+## Hand-rolled SUI — our own style
-## Use SUI (`@app/ui`) — our own style
Layout and presentational primitives we want full brand control over and that
are cheap to own:
@@ -23,40 +27,64 @@ are cheap to own:
`Stack` / `Inline` · `Table` (static/presentational) · `CodeBlock` ·
`FormField` (label/help/error layout) · simple `Tabs`.
-## Use Mantine — don't reinvent
-Anything that needs portals, focus traps, ARIA keyboard patterns, or is just a
-solved hard problem:
+## Mantine-backed SUI — don't reinvent, but do own the interface
-- **Overlays**: `Modal`, `Drawer`, `Popover` (focus trap, scroll lock, escape, focus restore)
-- **Menus**: `Menu` (roving arrow-key navigation)
-- **Selects**: `Select` / `MultiSelect` / `Combobox` / `Autocomplete` (keyboard + filtering)
-- **Dates**: `@mantine/dates` `DatePicker` / `DatePickerInput` (e.g. billing period range)
-- **Files**: `@mantine/dropzone` `Dropzone` (connect-source upload, op-runner sample drop)
-- **Progress UX**: `Stepper` (multi-step wizards), `Notifications`, `Tooltip`
-- Hooks: prefer `@mantine/hooks` (`useDisclosure`, `useClickOutside`, `useHotkeys`, …) over hand-rolling.
+Anything that needs portals, focus traps, ARIA keyboard patterns, or is just a
+solved hard problem gets a Mantine implementation behind a SUI wrapper.
+Shipped today: `Select` · `MultiSelect` · `NumberInput` · `ColorInput` ·
+`Slider`.
+
+Every wrapper follows the same contract (use the existing ones as the
+template):
+
+- **Explicit prop allowlist.** Only behavioural props are exposed; appearance
+ props (`color`, `variant`, `radius`, `classNames`, `styles`) are locked
+ internally to SUI tokens.
+- **No labels or error text.** Callers use `` for both. Wrappers
+ take an `invalid` flag that applies error styling only; Mantine never
+ renders its own message element.
+- **Accessibility props forwarded.** `id`, `aria-label`, `aria-invalid`, and
+ `aria-describedby` pass through so `FormField`'s injected wiring reaches the
+ underlying input.
+- **Typed escape hatches** (`comboboxProps`, `popoverProps`, `rightSection`)
+ for the z-index-in-modal case, documented on the component.
+
+When a feature needs a Mantine widget that has no wrapper yet (`Modal`,
+`Drawer`, `Menu`, `Stepper`, `Tooltip`, `@mantine/dates`,
+`@mantine/dropzone`, …), add the wrapper to `@app/ui` following this contract
+rather than importing Mantine in feature code. Hooks are the exception:
+prefer `@mantine/hooks` (`useDisclosure`, `useClickOutside`, `useHotkeys`, …)
+over hand-rolling, imported directly.
## Why
+
Mantine is mature and battle-tested for accessibility. A review of the
hand-rolled SUI overlays found real gaps — `Dropdown` has no arrow-key
navigation, `Modal`/`Drawer` mishandle focus when there are no focusable
children, `Toast` uses `role="alert"` for every tone — exactly the things
Mantine gets right. Owning those is wasted effort and an a11y liability.
-## Known migrations (hand-rolled today → should be Mantine)
-These shipped as SUI primitives during the initial build and should move to
-Mantine equivalents (fixes the a11y findings above):
+The wrapper (rather than direct Mantine use) is what keeps the door open to
+swapping the implementation later: callers depend on the SUI contract, not on
+Mantine's API surface.
-| Today (SUI) | → Mantine |
-|---|---|
-| `Dropdown` (menus: tier switcher, app switcher, notifications) | `Menu` |
-| `Modal` (composer, wizards, settings, create-key) | `Modal` |
-| `Drawer` (pipeline detail) | `Drawer` |
-| `Toast` | `notifications` |
-| _new need:_ billing date range | `@mantine/dates` |
-| _new need:_ file upload | `@mantine/dropzone` |
+## Known migrations (hand-rolled today → Mantine-backed SUI)
-Keep `Tabs` SUI for the simple in-page switchers; only reach for more if a true
-tabpanel/roving-focus contract is needed.
+These shipped as hand-rolled primitives during the initial build and should
+move to Mantine-backed wrappers (fixes the a11y findings above). `Select` and
+`Slider` have already made this move.
+
+| Today (hand-rolled) | → Mantine-backed SUI wrapper |
+| -------------------------------------------------------------- | ---------------------------- |
+| `Dropdown` (menus: tier switcher, app switcher, notifications) | wraps `Menu` |
+| `Modal` (composer, wizards, settings, create-key) | wraps `Modal` |
+| `Drawer` (pipeline detail) | wraps `Drawer` |
+| `Toast` | wraps `notifications` |
+| _new need:_ billing date range | wraps `@mantine/dates` |
+| _new need:_ file upload | wraps `@mantine/dropzone` |
+
+Keep `Tabs` hand-rolled for the simple in-page switchers; only reach for more
+if a true tabpanel/roving-focus contract is needed.
> Migrating overlays touches visible chrome and behaviour, so do it deliberately
> (with eyes on the result), not as a blind sweep.
diff --git a/frontend/editor/src/portal/components/SettingsModal.tsx b/frontend/editor/src/portal/components/SettingsModal.tsx
index aa01c2ba0e..c6a40f3389 100644
--- a/frontend/editor/src/portal/components/SettingsModal.tsx
+++ b/frontend/editor/src/portal/components/SettingsModal.tsx
@@ -612,7 +612,7 @@ function WorkspacePanel({
>
onRegion(e.target.value)}
+ onChange={(value) => onRegion(value ?? "")}
options={regionOptions}
/>
@@ -750,8 +750,8 @@ function AuthenticationPanel({
>
- onSecurity({ sessionTimeoutMins: Number(e.target.value) })
+ onChange={(value) =>
+ onSecurity({ sessionTimeoutMins: Number(value ?? "0") })
}
options={SESSION_TIMEOUT_VALUES.map((value) => ({
value,
diff --git a/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx b/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx
index 5f944465d8..7b2ecaa441 100644
--- a/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx
@@ -186,7 +186,9 @@ export function StorageTab() {
setRetention(e.target.value as RetentionWindow)}
+ onChange={(value) =>
+ setRetention((value ?? "") as RetentionWindow)
+ }
/>
diff --git a/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx b/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx
index 1d35e8d0f7..5040a97ac0 100644
--- a/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx
+++ b/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx
@@ -362,8 +362,8 @@ export function PipelineComposer({
- setScheduleUnit(e.target.value as ScheduleUnit)
+ onChange={(value) =>
+ setScheduleUnit((value ?? "") as ScheduleUnit)
}
options={SCHEDULE_UNITS.map((unit) => ({
value: unit,
diff --git a/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx
index cb1893cc66..4024cce5e3 100644
--- a/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx
+++ b/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx
@@ -64,7 +64,7 @@ export function PolicyFieldRow({
inputSize="sm"
value={typeof value === "string" ? value : ""}
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
- onChange={(e) => onChange(e.target.value)}
+ onChange={(value) => onChange(value ?? "")}
/>
);
diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx
index 30b1d05403..96a9d377f2 100644
--- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx
+++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx
@@ -453,8 +453,8 @@ function PolicySetupWizardBody({
- setRunOn(e.target.value as "upload" | "export")
+ onChange={(value) =>
+ setRunOn((value ?? "upload") as "upload" | "export")
}
options={[
{
@@ -474,8 +474,10 @@ function PolicySetupWizardBody({
{
- const mode = e.target.value as "new_file" | "new_version";
+ onChange={(value) => {
+ const mode = (value ?? "new_file") as
+ | "new_file"
+ | "new_version";
setOutputMode(mode);
// Auto-number only applies to separate new files.
if (
@@ -508,9 +510,12 @@ function PolicySetupWizardBody({
+ onChange={(value) =>
setOutputNamePosition(
- e.target.value as "prefix" | "suffix" | "auto-number",
+ (value ?? "suffix") as
+ | "prefix"
+ | "suffix"
+ | "auto-number",
)
}
options={[
diff --git a/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx b/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx
index c6874effcd..d354da1d40 100644
--- a/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx
+++ b/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx
@@ -1,8 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
import { HttpError } from "@portal/api/http";
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
+function renderWithMantine(ui: React.ReactElement) {
+ return render({ui} );
+}
+
// Deterministic i18n: keys come back verbatim so the test never waits on the
// async TOML backend.
vi.mock("react-i18next", () => ({
@@ -41,7 +46,9 @@ describe("ConnectWizard", () => {
const onCreated = vi.fn();
const onClose = vi.fn();
- render( );
+ renderWithMantine(
+ ,
+ );
stepToReview();
@@ -66,7 +73,7 @@ describe("ConnectWizard", () => {
createSource.mockResolvedValue({ id: "s1" });
const onCreated = vi.fn();
- render(
+ renderWithMantine(
{
}),
);
- render( );
+ renderWithMantine(
+ ,
+ );
stepToReview();
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
diff --git a/frontend/editor/src/portal/components/sources/ConnectWizard.tsx b/frontend/editor/src/portal/components/sources/ConnectWizard.tsx
index 8683873316..2c6e9263b7 100644
--- a/frontend/editor/src/portal/components/sources/ConnectWizard.tsx
+++ b/frontend/editor/src/portal/components/sources/ConnectWizard.tsx
@@ -245,8 +245,8 @@ export function ConnectWizard({
value: o.value,
label: t(o.labelKey),
}))}
- onChange={(e) =>
- setOptions((o) => ({ ...o, [field.key]: e.target.value }))
+ onChange={(value) =>
+ setOptions((o) => ({ ...o, [field.key]: value ?? "" }))
}
/>
) : (
diff --git a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx
index 5031b6427c..ad98077ba4 100644
--- a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx
+++ b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx
@@ -91,7 +91,7 @@ export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) {
setRole(e.target.value as RoleId)}
+ onChange={(value) => setRole((value ?? "") as RoleId)}
/>
diff --git a/frontend/editor/src/portal/theme/SuiProvider.tsx b/frontend/editor/src/portal/theme/SuiProvider.tsx
new file mode 100644
index 0000000000..3af449d0e1
--- /dev/null
+++ b/frontend/editor/src/portal/theme/SuiProvider.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from "react";
+import { MantineProvider } from "@mantine/core";
+import {
+ mantineTheme,
+ suiCssVariablesResolver,
+} from "@portal/theme/mantineTheme";
+
+export interface SuiProviderProps {
+ /**
+ * Resolved colour scheme. Must match whatever drives [data-theme] so the
+ * Mantine chrome and the SUI CSS tokens switch together.
+ */
+ colorScheme: "light" | "dark";
+ children: ReactNode;
+}
+
+/**
+ * Sets up the SUI design system for a subtree. Mantine is an implementation
+ * detail of the SUI components (@app/ui); this provider applies the SUI-token
+ * theme and remaps Mantine's neutral palette (dropdown/popover surfaces,
+ * borders, text) onto SUI tokens so floating elements follow the SUI palette
+ * in both colour schemes.
+ */
+export function SuiProvider({ colorScheme, children }: SuiProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/editor/src/portal/theme/mantineTheme.ts b/frontend/editor/src/portal/theme/mantineTheme.ts
index fa8e6d5297..01953245f9 100644
--- a/frontend/editor/src/portal/theme/mantineTheme.ts
+++ b/frontend/editor/src/portal/theme/mantineTheme.ts
@@ -1,4 +1,8 @@
-import { createTheme, type MantineColorsTuple } from "@mantine/core";
+import {
+ createTheme,
+ type CSSVariablesResolver,
+ type MantineColorsTuple,
+} from "@mantine/core";
/**
* Mantine theme for the portal, bound to the SUI design tokens in
@@ -69,6 +73,39 @@ const purple = tuple(
"--color-purple-dark",
);
+/**
+ * Maps Mantine's neutral CSS variables to SUI tokens so dropdowns, popovers,
+ * and other floating elements follow the SUI surface/border/text palette rather
+ * than Mantine's default white/gray-* / dark-* scale.
+ *
+ * SuiProvider syncs Mantine's color scheme to SUI's light/dark toggle
+ * (forceColorScheme), so the light/dark buckets here align with
+ * [data-theme="light/dark"] and the SUI token values are correct.
+ */
+export const suiCssVariablesResolver: CSSVariablesResolver = () => ({
+ variables: {
+ "--mantine-color-text": "var(--color-text-1)",
+ "--mantine-color-placeholder": "var(--color-text-placeholder)",
+ "--mantine-color-body": "var(--color-bg)",
+ },
+ light: {
+ // Popover/dropdown background + combobox search input
+ "--mantine-color-white": "var(--color-surface)",
+ // Option hover background
+ "--mantine-color-gray-0": "var(--color-bg-hover)",
+ // Dropdown border
+ "--mantine-color-gray-2": "var(--color-border)",
+ },
+ dark: {
+ // Popover/dropdown background (dark-6 is the floating surface in dark mode)
+ "--mantine-color-dark-6": "var(--color-surface)",
+ // Deeper background used for option hover + combobox search input
+ "--mantine-color-dark-7": "var(--color-bg)",
+ // Border in dark mode
+ "--mantine-color-dark-4": "var(--color-border)",
+ },
+});
+
export const mantineTheme = createTheme({
primaryColor: "blue",
// Mantine uses index 6 of the tuple for filled components by default, which
diff --git a/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx
index 2559a0e522..4049533290 100644
--- a/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx
+++ b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx
@@ -81,7 +81,7 @@ export function PolicyFieldRow({
label: t(`policies.fieldOption.${field.key}.${o}`, o),
}))}
value={typeof value === "string" ? value : ""}
- onChange={(e) => onChange(e.target.value)}
+ onChange={(value) => onChange(value ?? "")}
aria-label={fieldLabel}
/>
) : (
diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx
index e63ee4e61f..abd1f6d8df 100644
--- a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx
+++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx
@@ -409,8 +409,8 @@ export function PolicySetupWizard({
- setRunOn(e.target.value as "upload" | "export")
+ onChange={(value) =>
+ setRunOn((value ?? "upload") as "upload" | "export")
}
aria-label={t("policies.wizard.runOnLabel", "Run on")}
options={[
@@ -437,8 +437,8 @@ export function PolicySetupWizard({
{
- const mode = e.target.value as
+ onChange={(value) => {
+ const mode = (value ?? "new_file") as
| "new_file"
| "new_version";
setOutputMode(mode);
@@ -481,9 +481,12 @@ export function PolicySetupWizard({
+ onChange={(value) =>
setOutputNamePosition(
- e.target.value as "prefix" | "suffix" | "auto-number",
+ (value ?? "suffix") as
+ | "prefix"
+ | "suffix"
+ | "auto-number",
)
}
aria-label={t(
diff --git a/frontend/editor/src/proprietary/ui/ColorInput.tsx b/frontend/editor/src/proprietary/ui/ColorInput.tsx
new file mode 100644
index 0000000000..6063ecf688
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/ColorInput.tsx
@@ -0,0 +1,146 @@
+import type React from "react";
+import {
+ ColorInput as MantineColorInput,
+ type ColorInputProps as MantineColorInputProps,
+} from "@mantine/core";
+import { useInputAria } from "@app/ui/ariaForwarding";
+import "@app/ui/MantineForms.css";
+
+const SUI_INPUT_VARS = {
+ "--input-bg": "var(--color-surface)",
+ "--input-bd": "var(--color-border-input)",
+ "--input-bd-focus": "var(--color-blue)",
+ "--input-radius": "var(--radius-md)",
+ "--input-color": "var(--color-text-1)",
+ "--input-placeholder-color": "var(--color-text-placeholder)",
+ "--input-height-sm": "1.75rem",
+ "--input-height-md": "2.25rem",
+} as React.CSSProperties;
+
+export type ColorInputSize = "sm" | "md";
+
+export interface ColorInputProps {
+ // Value
+ value?: string;
+ onChange?: (value: string) => void;
+ defaultValue?: string;
+
+ // Behaviour
+ format?: "hex" | "hexa" | "rgb" | "rgba" | "hsl" | "hsla";
+ swatches?: string[];
+ swatchesPerRow?: number;
+ withPicker?: boolean;
+
+ // Popover escape hatch — only for zIndex / offset overrides in modals
+ popoverProps?: MantineColorInputProps["popoverProps"];
+
+ // Form
+ placeholder?: string;
+ id?: string;
+ name?: string;
+ "aria-label"?: string;
+ "aria-invalid"?: boolean;
+ "aria-describedby"?: string;
+ required?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+
+ // SUI — invalid applies error styling; FormField renders the message itself.
+ inputSize?: ColorInputSize;
+ invalid?: boolean;
+}
+
+type PassthroughProps = Omit<
+ Pick<
+ MantineColorInputProps,
+ | "value"
+ | "onChange"
+ | "defaultValue"
+ | "format"
+ | "swatches"
+ | "swatchesPerRow"
+ | "withPicker"
+ | "popoverProps"
+ | "placeholder"
+ | "id"
+ | "name"
+ | "aria-label"
+ | "aria-describedby"
+ | "required"
+ | "disabled"
+ | "readOnly"
+ | "onFocus"
+ | "onBlur"
+ >,
+ never
+>;
+
+/**
+ * SUI colour picker input with swatch preview and popover picker. Use with
+ * for labels and error display. Appearance is locked to SUI tokens.
+ *
+ * Defaults to hex format. Pass `popoverProps={{ withinPortal: true, zIndex: Z }}` when
+ * rendering inside a modal.
+ */
+export function ColorInput({
+ inputSize = "md",
+ invalid,
+ format = "hex",
+ value,
+ onChange,
+ defaultValue,
+ swatches,
+ swatchesPerRow,
+ withPicker,
+ popoverProps,
+ placeholder,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-invalid": ariaInvalid,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+}: ColorInputProps) {
+ const inputRef = useInputAria({ describedBy: ariaDescribedBy });
+ const passthroughProps: PassthroughProps = {
+ value,
+ onChange,
+ defaultValue,
+ format,
+ swatches,
+ swatchesPerRow,
+ withPicker,
+ popoverProps,
+ placeholder,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/ui/Forms.stories.tsx b/frontend/editor/src/proprietary/ui/Forms.stories.tsx
index 10f4e0b1c3..0d14cf3392 100644
--- a/frontend/editor/src/proprietary/ui/Forms.stories.tsx
+++ b/frontend/editor/src/proprietary/ui/Forms.stories.tsx
@@ -73,23 +73,6 @@ export const Input_Error: Story = {
),
};
-export const Select_Default: Story = {
- render: () => (
-
-
-
- ),
-};
-
export const Checkbox_Single: Story = {
render: () => (
@@ -251,7 +234,7 @@ export const FullForm: Story = {
setRetention(e.target.value)}
+ onChange={(value) => setRetention(value ?? "90")}
options={[
{ value: "30", label: "30 days" },
{ value: "90", label: "90 days" },
diff --git a/frontend/editor/src/proprietary/ui/IconBadge.css b/frontend/editor/src/proprietary/ui/IconBadge.css
index 10e2d28915..43d4eb0af9 100644
--- a/frontend/editor/src/proprietary/ui/IconBadge.css
+++ b/frontend/editor/src/proprietary/ui/IconBadge.css
@@ -34,3 +34,7 @@
.sui-iconbadge--red {
--ib-base: var(--color-red);
}
+.sui-iconbadge--neutral {
+ --ib-base: var(--color-text-1);
+ background: none;
+}
diff --git a/frontend/editor/src/proprietary/ui/IconBadge.tsx b/frontend/editor/src/proprietary/ui/IconBadge.tsx
index 9b421d47c5..ae6c5536d1 100644
--- a/frontend/editor/src/proprietary/ui/IconBadge.tsx
+++ b/frontend/editor/src/proprietary/ui/IconBadge.tsx
@@ -1,7 +1,13 @@
import type { ReactNode } from "react";
import "@app/ui/IconBadge.css";
-export type IconBadgeAccent = "blue" | "purple" | "green" | "amber" | "red";
+export type IconBadgeAccent =
+ | "blue"
+ | "purple"
+ | "green"
+ | "amber"
+ | "red"
+ | "neutral";
export interface IconBadgeProps {
children: ReactNode;
diff --git a/frontend/editor/src/proprietary/ui/MantineForms.css b/frontend/editor/src/proprietary/ui/MantineForms.css
new file mode 100644
index 0000000000..66551aedf5
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/MantineForms.css
@@ -0,0 +1,114 @@
+/* ===================================================================
+ * MantineForms.css — SUI design tokens injected into Mantine inputs
+ *
+ * Applied via classNames.wrapper="sui-mantine-wrapper" on each
+ * Mantine-backed SUI component (MultiSelect, NumberInput, ColorInput).
+ * The inline `styles.wrapper` in each component sets the base CSS
+ * variables; this file adds focus ring + pill styling that require
+ * CSS pseudo-selectors or attribute selectors.
+ * =================================================================== */
+
+/* CSS custom properties for Mantine's input system, mapped to SUI tokens.
+ * These are also set as inline styles (higher specificity); the declarations
+ * here act as a typed reference and as a fallback for any slot Mantine reads
+ * before the inline vars are applied. */
+.sui-mantine-wrapper {
+ --input-bg: var(--color-surface);
+ --input-bd: var(--color-border-input);
+ --input-bd-focus: var(--color-blue);
+ --input-radius: var(--radius-md);
+ --input-color: var(--color-text-1);
+ --input-placeholder-color: var(--color-text-placeholder);
+ --input-height-sm: 1.75rem;
+ --input-height-md: 2.25rem;
+ font-size: 0.875rem;
+}
+
+/* SUI focus ring — matches .sui-input:focus-within */
+.sui-mantine-wrapper[data-focused],
+.sui-mantine-wrapper:focus-within {
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
+}
+
+/* Error state */
+.sui-mantine-wrapper[data-invalid] {
+ --input-bd: var(--color-red);
+ --input-bd-focus: var(--color-red);
+}
+
+.sui-mantine-wrapper[data-invalid][data-focused],
+.sui-mantine-wrapper[data-invalid]:focus-within {
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-red) 16%, transparent);
+}
+
+/* Disabled state */
+.sui-mantine-wrapper[data-disabled] {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ---- MultiSelect pills ---- */
+/* Pills match SUI's Chip component: small rounded tags. */
+.sui-mantine-pill {
+ background: var(--color-blue-light) !important;
+ color: var(--color-blue-dark) !important;
+ border: 1px solid var(--color-blue-border) !important;
+ border-radius: var(--radius-sm) !important;
+ font-size: 0.75rem !important;
+ font-weight: 500 !important;
+}
+
+/* Pills list needs a min-height to not collapse when empty */
+.sui-mantine-pills-list {
+ min-height: var(--input-height-sm, 1.75rem);
+}
+
+/* ---- NumberInput controls (increment/decrement buttons) ---- */
+.sui-mantine-control {
+ border-color: var(--color-border-input) !important;
+ color: var(--color-text-3) !important;
+}
+
+.sui-mantine-control:hover {
+ background: var(--color-bg-hover) !important;
+ color: var(--color-text-1) !important;
+}
+
+/* ---- Select: hide Mantine's right-section clear button border ---- */
+.sui-mantine-wrapper .mantine-Select-section {
+ color: var(--color-text-3);
+}
+
+/* ---- Slider ---- */
+.sui-mantine-slider {
+ --slider-color: var(--color-blue);
+ --slider-track-bg: var(--color-border);
+ --slider-thumb-color: var(--color-surface);
+ --slider-thumb-bd: var(--color-blue);
+}
+
+.sui-mantine-slider:focus-within {
+ outline: none;
+}
+
+/* Thumb focus ring matches SUI */
+.sui-mantine-slider .mantine-Slider-thumb:focus-visible {
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
+ outline: none;
+}
+
+/* Mark labels use SUI text tokens */
+.sui-mantine-slider .mantine-Slider-markLabel {
+ color: var(--color-text-3);
+ font-size: 0.75rem;
+}
+
+/* ---- Dark mode: Mantine's own dark-mode vars beat ours when Mantine's
+ * color-scheme is "dark". Reassert SUI tokens so the two systems stay in sync.
+ * SuiProvider syncs forceColorScheme to SUI theme, so
+ * [data-mantine-color-scheme="dark"] === [data-theme="dark"] in practice. ---- */
+[data-mantine-color-scheme="dark"] .sui-mantine-wrapper {
+ --input-bg: var(--color-surface);
+ --input-bd: var(--color-border-input);
+ --input-color: var(--color-text-1);
+}
diff --git a/frontend/editor/src/proprietary/ui/MantineForms.stories.tsx b/frontend/editor/src/proprietary/ui/MantineForms.stories.tsx
new file mode 100644
index 0000000000..dfa40644dd
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/MantineForms.stories.tsx
@@ -0,0 +1,568 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { FormField } from "@app/ui/FormField";
+import { Stack } from "@app/ui/Stack";
+import { MultiSelect } from "@app/ui/MultiSelect";
+import { NumberInput } from "@app/ui/NumberInput";
+import { ColorInput } from "@app/ui/ColorInput";
+import { Select } from "@app/ui/Select";
+import { Slider } from "@app/ui/Slider";
+
+const PII_OPTIONS = [
+ { value: "ssn", label: "Social Security Number" },
+ { value: "dob", label: "Date of Birth" },
+ { value: "account", label: "Account Number" },
+ { value: "email", label: "Email Address" },
+ { value: "phone", label: "Phone Number" },
+ { value: "address", label: "Postal Address" },
+ { value: "passport", label: "Passport Number" },
+ { value: "license", label: "Driver's License" },
+];
+
+const meta: Meta = {
+ title: "Primitives/Forms",
+ parameters: { layout: "padded" },
+ decorators: [
+ (S) => (
+
+
+
+ ),
+ ],
+};
+export default meta;
+type Story = StoryObj;
+
+// ─── MultiSelect ─────────────────────────────────────────────────────────────
+
+export const MultiSelect_Default: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState([]);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const MultiSelect_WithValues: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState(["ssn", "dob", "email"]);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const MultiSelect_SmSize: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState(["ssn", "email"]);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const MultiSelect_Error: Story = {
+ render: () => (
+
+ {}}
+ placeholder="Choose types…"
+ invalid
+ />
+
+ ),
+};
+
+export const MultiSelect_Disabled: Story = {
+ render: () => (
+
+ {}}
+ disabled
+ />
+
+ ),
+};
+
+// ─── NumberInput ─────────────────────────────────────────────────────────────
+
+export const NumberInput_Default: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState(100);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const NumberInput_Decimal: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState(0.85);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const NumberInput_WithUnit: Story = {
+ render: () => {
+ function Bound() {
+ const [opacity, setOpacity] = useState(80);
+ const [fontSize, setFontSize] = useState(24);
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const NumberInput_SmSize: Story = {
+ render: () => {
+ function Bound() {
+ const [v, setV] = useState(12);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const NumberInput_Error: Story = {
+ render: () => (
+
+ {}} invalid />
+
+ ),
+};
+
+export const NumberInput_Disabled: Story = {
+ render: () => (
+
+ {}} disabled />
+
+ ),
+};
+
+// ─── ColorInput ──────────────────────────────────────────────────────────────
+
+export const ColorInput_Default: Story = {
+ render: () => {
+ function Bound() {
+ const [color, setColor] = useState("");
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const ColorInput_Preselected: Story = {
+ render: () => {
+ function Bound() {
+ const [color, setColor] = useState("#3B82F6");
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const ColorInput_SmSize: Story = {
+ render: () => {
+ function Bound() {
+ const [color, setColor] = useState("#EF4444");
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const ColorInput_Error: Story = {
+ render: () => (
+
+ {}} invalid />
+
+ ),
+};
+
+export const ColorInput_Disabled: Story = {
+ render: () => (
+
+ {}} disabled />
+
+ ),
+};
+
+// ─── Select ──────────────────────────────────────────────────────────────────
+
+const RETENTION_OPTIONS = [
+ { value: "30", label: "30 days" },
+ { value: "60", label: "60 days" },
+ { value: "90", label: "90 days (default)" },
+ { value: "180", label: "180 days" },
+ { value: "never", label: "Never expire" },
+];
+
+export const Select_Default: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState("90");
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Select_Searchable: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState(null);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Select_SmSize: Story = {
+ render: () => {
+ function Bound() {
+ const [value, setValue] = useState("upload");
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Select_Error: Story = {
+ render: () => (
+
+ {}}
+ placeholder="Choose…"
+ invalid
+ />
+
+ ),
+};
+
+export const Select_Disabled: Story = {
+ render: () => (
+
+ {}}
+ disabled
+ />
+
+ ),
+};
+
+// ─── Slider ───────────────────────────────────────────────────────────────────
+
+export const Slider_Default: Story = {
+ render: () => {
+ function Bound() {
+ const [v, setV] = useState(0.85);
+ return (
+
+ x.toFixed(2)}
+ />
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Slider_WithMarks: Story = {
+ render: () => {
+ function Bound() {
+ const [days, setDays] = useState(90);
+ return (
+
+ `${d}d`}
+ marks={[
+ { value: 30, label: "30d" },
+ { value: 90, label: "90d" },
+ { value: 180, label: "180d" },
+ { value: 365, label: "1y" },
+ ]}
+ />
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Slider_NoLabel: Story = {
+ render: () => {
+ function Bound() {
+ const [v, setV] = useState(50);
+ return (
+
+
+
+ );
+ }
+ return ;
+ },
+};
+
+export const Slider_Disabled: Story = {
+ render: () => (
+
+ x.toFixed(2)}
+ disabled
+ />
+
+ ),
+};
+
+// ─── Combined ────────────────────────────────────────────────────────────────
+
+export const WatermarkForm: Story = {
+ render: () => {
+ function Form() {
+ const [color, setColor] = useState("#000000");
+ const [opacity, setOpacity] = useState(50);
+ const [fontSize, setFontSize] = useState(24);
+ const [piiFields, setPiiFields] = useState([]);
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ return ;
+ },
+};
diff --git a/frontend/editor/src/proprietary/ui/MultiSelect.tsx b/frontend/editor/src/proprietary/ui/MultiSelect.tsx
new file mode 100644
index 0000000000..31ac9544f8
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/MultiSelect.tsx
@@ -0,0 +1,184 @@
+import type React from "react";
+import {
+ MultiSelect as MantineMultiSelect,
+ type MultiSelectProps as MantineMultiSelectProps,
+ type ComboboxData,
+} from "@mantine/core";
+import { useInputAria } from "@app/ui/ariaForwarding";
+import "@app/ui/MantineForms.css";
+
+const SUI_INPUT_VARS = {
+ "--input-bg": "var(--color-surface)",
+ "--input-bd": "var(--color-border-input)",
+ "--input-bd-focus": "var(--color-blue)",
+ "--input-radius": "var(--radius-md)",
+ "--input-color": "var(--color-text-1)",
+ "--input-placeholder-color": "var(--color-text-placeholder)",
+ "--input-height-sm": "1.75rem",
+ "--input-height-md": "2.25rem",
+} as React.CSSProperties;
+
+export type MultiSelectSize = "sm" | "md";
+
+export interface MultiSelectProps {
+ // Data
+ data: ComboboxData;
+ value?: string[];
+ onChange?: (value: string[]) => void;
+ defaultValue?: string[];
+
+ // Behaviour
+ searchable?: boolean;
+ clearable?: boolean;
+ limit?: number;
+ maxValues?: number;
+ searchValue?: string;
+ onSearchChange?: (value: string) => void;
+ nothingFoundMessage?: React.ReactNode;
+ maxDropdownHeight?: number | string;
+ filter?: MantineMultiSelectProps["filter"];
+
+ // Dropdown escape hatch — only for zIndex / offset overrides in modals
+ comboboxProps?: MantineMultiSelectProps["comboboxProps"];
+
+ // Form
+ placeholder?: string;
+ id?: string;
+ name?: string;
+ "aria-label"?: string;
+ "aria-invalid"?: boolean;
+ "aria-describedby"?: string;
+ required?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+ onDropdownOpen?: () => void;
+ onDropdownClose?: () => void;
+
+ // SUI — invalid applies error styling; FormField renders the message itself.
+ inputSize?: MultiSelectSize;
+ invalid?: boolean;
+}
+
+type PassthroughProps = Omit<
+ Pick<
+ MantineMultiSelectProps,
+ | "data"
+ | "value"
+ | "onChange"
+ | "defaultValue"
+ | "searchable"
+ | "clearable"
+ | "limit"
+ | "maxValues"
+ | "searchValue"
+ | "onSearchChange"
+ | "nothingFoundMessage"
+ | "maxDropdownHeight"
+ | "filter"
+ | "comboboxProps"
+ | "placeholder"
+ | "id"
+ | "name"
+ | "aria-label"
+ | "aria-describedby"
+ | "required"
+ | "disabled"
+ | "readOnly"
+ | "onFocus"
+ | "onBlur"
+ | "onDropdownOpen"
+ | "onDropdownClose"
+ >,
+ never
+>;
+
+/**
+ * SUI multi-select with pill display and optional search. Use with
+ * for labels and error display. Appearance is locked to SUI tokens.
+ *
+ * Pass `comboboxProps={{ zIndex: Z_INDEX_MODAL }}` when rendering inside a modal.
+ */
+export function MultiSelect({
+ inputSize = "md",
+ invalid,
+ data,
+ value,
+ onChange,
+ defaultValue,
+ searchable,
+ clearable,
+ limit,
+ maxValues,
+ searchValue,
+ onSearchChange,
+ nothingFoundMessage,
+ maxDropdownHeight,
+ filter,
+ comboboxProps,
+ placeholder,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-invalid": ariaInvalid,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+ onDropdownOpen,
+ onDropdownClose,
+}: MultiSelectProps) {
+ const inputRef = useInputAria({ describedBy: ariaDescribedBy, required });
+ const passthroughProps: PassthroughProps = {
+ data,
+ value,
+ onChange,
+ defaultValue,
+ searchable,
+ clearable,
+ limit,
+ maxValues,
+ searchValue,
+ onSearchChange,
+ nothingFoundMessage,
+ maxDropdownHeight,
+ filter,
+ comboboxProps,
+ placeholder,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+ onDropdownOpen,
+ onDropdownClose,
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/ui/NumberInput.tsx b/frontend/editor/src/proprietary/ui/NumberInput.tsx
new file mode 100644
index 0000000000..751c902636
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/NumberInput.tsx
@@ -0,0 +1,190 @@
+import type React from "react";
+import {
+ NumberInput as MantineNumberInput,
+ type NumberInputProps as MantineNumberInputProps,
+} from "@mantine/core";
+import { useInputAria } from "@app/ui/ariaForwarding";
+import "@app/ui/MantineForms.css";
+
+const SUI_INPUT_VARS = {
+ "--input-bg": "var(--color-surface)",
+ "--input-bd": "var(--color-border-input)",
+ "--input-bd-focus": "var(--color-blue)",
+ "--input-radius": "var(--radius-md)",
+ "--input-color": "var(--color-text-1)",
+ "--input-placeholder-color": "var(--color-text-placeholder)",
+ "--input-height-sm": "1.75rem",
+ "--input-height-md": "2.25rem",
+} as React.CSSProperties;
+
+export type NumberInputSize = "sm" | "md";
+
+export interface NumberInputProps {
+ // Value
+ value?: number | string;
+ onChange?: (value: number | string) => void;
+ defaultValue?: number | string;
+
+ // Constraints
+ min?: number;
+ max?: number;
+ step?: number;
+ decimalScale?: number;
+ fixedDecimalScale?: boolean;
+ allowNegative?: boolean;
+ allowDecimal?: boolean;
+ clampBehavior?: "strict" | "blur" | "none";
+
+ // Display
+ placeholder?: string;
+ suffix?: string;
+ prefix?: string;
+ hideControls?: boolean;
+
+ // Right section — escape hatch for inline unit labels
+ rightSection?: React.ReactNode;
+ rightSectionWidth?: React.CSSProperties["width"];
+
+ // Form
+ id?: string;
+ name?: string;
+ "aria-label"?: string;
+ "aria-invalid"?: boolean;
+ "aria-describedby"?: string;
+ required?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ autoFocus?: boolean;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+ onKeyDown?: React.KeyboardEventHandler;
+
+ // SUI — invalid applies error styling; FormField renders the message itself.
+ inputSize?: NumberInputSize;
+ invalid?: boolean;
+}
+
+// Narrows MantineNumberInputProps to only what our interface exposes so the
+// spread below stays type-safe without manually listing every prop.
+type PassthroughProps = Omit<
+ Pick<
+ MantineNumberInputProps,
+ | "value"
+ | "onChange"
+ | "defaultValue"
+ | "min"
+ | "max"
+ | "step"
+ | "decimalScale"
+ | "fixedDecimalScale"
+ | "allowNegative"
+ | "allowDecimal"
+ | "clampBehavior"
+ | "placeholder"
+ | "suffix"
+ | "prefix"
+ | "hideControls"
+ | "rightSection"
+ | "rightSectionWidth"
+ | "id"
+ | "name"
+ | "aria-label"
+ | "aria-describedby"
+ | "required"
+ | "disabled"
+ | "readOnly"
+ | "autoFocus"
+ | "onFocus"
+ | "onBlur"
+ | "onKeyDown"
+ >,
+ never
+>;
+
+/**
+ * SUI number input with increment/decrement controls. Use with
+ * for labels and error display. Appearance is locked to SUI tokens.
+ */
+export function NumberInput({
+ inputSize = "md",
+ invalid,
+ value,
+ onChange,
+ defaultValue,
+ min,
+ max,
+ step,
+ decimalScale,
+ fixedDecimalScale,
+ allowNegative,
+ allowDecimal,
+ clampBehavior,
+ placeholder,
+ suffix,
+ prefix,
+ hideControls,
+ rightSection,
+ rightSectionWidth,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-invalid": ariaInvalid,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ autoFocus,
+ onFocus,
+ onBlur,
+ onKeyDown,
+}: NumberInputProps) {
+ const inputRef = useInputAria({ describedBy: ariaDescribedBy });
+ const passthroughProps: PassthroughProps = {
+ value,
+ onChange,
+ defaultValue,
+ min,
+ max,
+ step,
+ decimalScale,
+ fixedDecimalScale,
+ allowNegative,
+ allowDecimal,
+ clampBehavior,
+ placeholder,
+ suffix,
+ prefix,
+ hideControls,
+ rightSection,
+ rightSectionWidth,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ autoFocus,
+ onFocus,
+ onBlur,
+ onKeyDown,
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/ui/Select.tsx b/frontend/editor/src/proprietary/ui/Select.tsx
index b970cc1815..fa2d5346b7 100644
--- a/frontend/editor/src/proprietary/ui/Select.tsx
+++ b/frontend/editor/src/proprietary/ui/Select.tsx
@@ -1,5 +1,21 @@
-import { forwardRef, type SelectHTMLAttributes } from "react";
-import "@app/ui/Select.css";
+import type React from "react";
+import {
+ Select as MantineSelect,
+ type SelectProps as MantineSelectProps,
+} from "@mantine/core";
+import { useInputAria } from "@app/ui/ariaForwarding";
+import "@app/ui/MantineForms.css";
+
+const SUI_INPUT_VARS = {
+ "--input-bg": "var(--color-surface)",
+ "--input-bd": "var(--color-border-input)",
+ "--input-bd-focus": "var(--color-blue)",
+ "--input-radius": "var(--radius-md)",
+ "--input-color": "var(--color-text-1)",
+ "--input-placeholder-color": "var(--color-text-placeholder)",
+ "--input-height-sm": "1.75rem",
+ "--input-height-md": "2.25rem",
+} as React.CSSProperties;
export interface SelectOption {
value: string;
@@ -9,61 +25,130 @@ export interface SelectOption {
export type SelectSize = "sm" | "md";
-export interface SelectProps extends Omit<
- SelectHTMLAttributes,
- "size"
-> {
- inputSize?: SelectSize;
+export interface SelectProps {
+ // Data
options: SelectOption[];
- /** Optional placeholder rendered as a disabled first option. */
+ value?: string | null;
+ onChange?: (value: string | null) => void;
+ defaultValue?: string;
+
+ // Behaviour
+ searchable?: boolean;
+ clearable?: boolean;
placeholder?: string;
+ nothingFoundMessage?: React.ReactNode;
+ maxDropdownHeight?: number | string;
+
+ // Dropdown escape hatch — for zIndex / offset overrides in modals
+ comboboxProps?: MantineSelectProps["comboboxProps"];
+
+ // Form
+ id?: string;
+ name?: string;
+ "aria-label"?: string;
+ "aria-invalid"?: boolean;
+ "aria-describedby"?: string;
+ required?: boolean;
+ disabled?: boolean;
+ readOnly?: boolean;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+
+ // SUI — invalid applies error styling; FormField renders the message itself.
+ inputSize?: SelectSize;
invalid?: boolean;
}
-export const Select = forwardRef(
- function Select(
- { inputSize = "md", options, placeholder, invalid, className, ...rest },
- ref,
- ) {
- return (
-
-
- {placeholder && (
-
- {placeholder}
-
- )}
- {options.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
-
-
-
-
-
- );
- },
-);
+type PassthroughProps = Omit<
+ Pick<
+ MantineSelectProps,
+ | "value"
+ | "onChange"
+ | "defaultValue"
+ | "searchable"
+ | "clearable"
+ | "placeholder"
+ | "nothingFoundMessage"
+ | "maxDropdownHeight"
+ | "comboboxProps"
+ | "id"
+ | "name"
+ | "aria-label"
+ | "aria-describedby"
+ | "required"
+ | "disabled"
+ | "readOnly"
+ | "onFocus"
+ | "onBlur"
+ >,
+ never
+>;
+
+/**
+ * SUI select / combobox backed by Mantine. Supports optional search and clear.
+ * Use with for labels and error display. Appearance is locked to SUI tokens.
+ *
+ * onChange receives the selected string value (or null when cleared), not a DOM event.
+ */
+export function Select({
+ inputSize = "md",
+ invalid,
+ options,
+ value,
+ onChange,
+ defaultValue,
+ searchable,
+ clearable,
+ placeholder,
+ nothingFoundMessage,
+ maxDropdownHeight,
+ comboboxProps,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-invalid": ariaInvalid,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+}: SelectProps) {
+ const inputRef = useInputAria({ describedBy: ariaDescribedBy });
+ const passthroughProps: PassthroughProps = {
+ value,
+ onChange,
+ defaultValue,
+ searchable,
+ clearable,
+ placeholder,
+ nothingFoundMessage,
+ maxDropdownHeight,
+ comboboxProps,
+ id,
+ name,
+ "aria-label": ariaLabel,
+ "aria-describedby": ariaDescribedBy,
+ required,
+ disabled,
+ readOnly,
+ onFocus,
+ onBlur,
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/editor/src/proprietary/ui/Slider.tsx b/frontend/editor/src/proprietary/ui/Slider.tsx
index 3b540def06..ebeb782f9e 100644
--- a/frontend/editor/src/proprietary/ui/Slider.tsx
+++ b/frontend/editor/src/proprietary/ui/Slider.tsx
@@ -1,63 +1,113 @@
-import { forwardRef, type InputHTMLAttributes } from "react";
-import "@app/ui/Slider.css";
+import {
+ Slider as MantineSlider,
+ type SliderProps as MantineSliderProps,
+} from "@mantine/core";
+import { useThumbAria } from "@app/ui/ariaForwarding";
+import "@app/ui/MantineForms.css";
-export interface SliderProps extends Omit<
- InputHTMLAttributes,
- "type" | "value" | "onChange"
-> {
+export interface SliderMark {
value: number;
+ label?: React.ReactNode;
+}
+
+export interface SliderProps {
+ value: number;
+ onChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
- onChange: (value: number) => void;
- /** Optional formatter for the value pill (e.g. "0.85", "30 days"). */
+
+ /** Tick marks along the track. */
+ marks?: SliderMark[];
+
+ /** Format the tooltip shown while dragging. Defaults to the raw number. */
formatValue?: (value: number) => string;
- /** Show the right-aligned value badge. Defaults to true. */
+
+ /**
+ * Show the value tooltip on hover/drag. Defaults to true.
+ * Pass false to hide the label entirely (useful when the value is shown elsewhere).
+ */
showValue?: boolean;
+
+ // Form
+ id?: string;
+ /** Accessible name for the slider thumb — the visible FormField label can't
+ * associate with Mantine's non-input thumb element, so set this too. */
+ "aria-label"?: string;
+ "aria-invalid"?: boolean;
+ "aria-describedby"?: string;
+ disabled?: boolean;
+
+ // SUI
+ inputSize?: "sm" | "md";
}
-export const Slider = forwardRef(function Slider(
- {
+type PassthroughProps = Omit<
+ Pick<
+ MantineSliderProps,
+ | "value"
+ | "onChange"
+ | "min"
+ | "max"
+ | "step"
+ | "marks"
+ | "label"
+ | "thumbLabel"
+ | "id"
+ | "disabled"
+ | "size"
+ >,
+ never
+>;
+
+/**
+ * SUI range slider backed by Mantine. Provides accessible keyboard navigation,
+ * optional tick marks, and a drag tooltip. Use with for labels.
+ * Appearance is locked to SUI tokens.
+ */
+export function Slider({
+ value,
+ onChange,
+ min = 0,
+ max = 1,
+ step = 0.01,
+ marks,
+ formatValue,
+ showValue = true,
+ id,
+ "aria-label": ariaLabel,
+ "aria-invalid": ariaInvalid,
+ "aria-describedby": ariaDescribedBy,
+ disabled,
+ inputSize = "md",
+}: SliderProps) {
+ const label = showValue
+ ? (v: number) => (formatValue ? formatValue(v) : String(v))
+ : null;
+
+ // The role="slider" element is the thumb, not an input, so FormField's
+ // injected aria wiring has to land there for AT to announce it.
+ const rootRef = useThumbAria(ariaDescribedBy, ariaInvalid);
+
+ const passthroughProps: PassthroughProps = {
value,
- min = 0,
- max = 1,
- step = 0.01,
onChange,
- formatValue,
- showValue = true,
- className,
- ...rest
- },
- ref,
-) {
- const pct = ((value - min) / (max - min)) * 100;
+ min,
+ max,
+ step,
+ marks,
+ label,
+ thumbLabel: ariaLabel,
+ id,
+ disabled,
+ size: inputSize,
+ };
+
return (
-
- onChange(Number(e.target.value))}
- className="sui-slider__input"
- {...rest}
- />
- {showValue && (
-
- {formatValue ? formatValue(value) : value.toString()}
-
- )}
-
+
);
-});
+}
diff --git a/frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx b/frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx
new file mode 100644
index 0000000000..608baff21d
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx
@@ -0,0 +1,106 @@
+import { describe, expect, it } from "vitest";
+import { render } from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
+import { Select } from "@app/ui/Select";
+import { MultiSelect } from "@app/ui/MultiSelect";
+import { NumberInput } from "@app/ui/NumberInput";
+import { ColorInput } from "@app/ui/ColorInput";
+import { Slider } from "@app/ui/Slider";
+
+// Guards the FormField contract on the Mantine-backed components: the
+// injected required / aria-describedby / aria-invalid wiring must reach the
+// focusable element. Mantine internals clobber some of these (see
+// ariaForwarding.ts), so this exercises the real DOM output.
+
+function renderInProvider(ui: React.ReactElement) {
+ return render({ui} );
+}
+
+const OPTIONS = [{ value: "a", label: "A" }];
+
+describe("Mantine-backed SUI aria forwarding", () => {
+ it("Select forwards required and aria-describedby to the input", () => {
+ const { container } = renderInProvider(
+ {}}
+ required
+ aria-describedby="help-1"
+ />,
+ );
+ const input = container.querySelector("input");
+ expect(input?.hasAttribute("required")).toBe(true);
+ expect(input?.getAttribute("aria-describedby")).toBe("help-1");
+ });
+
+ it("Select sets aria-invalid from the invalid flag", () => {
+ const { container } = renderInProvider(
+ {}} invalid />,
+ );
+ expect(container.querySelector("input")?.getAttribute("aria-invalid")).toBe(
+ "true",
+ );
+ });
+
+ it("MultiSelect forwards aria-required and aria-describedby to the field", () => {
+ const { container } = renderInProvider(
+ {}}
+ required
+ aria-describedby="help-2"
+ />,
+ );
+ // The focusable pills field; a native `required` would misfire form
+ // validation there, so the requirement is announced via aria-required.
+ const input = container.querySelector("input");
+ expect(input?.getAttribute("aria-required")).toBe("true");
+ expect(input?.getAttribute("aria-describedby")).toBe("help-2");
+ });
+
+ it("NumberInput forwards required and aria-describedby to the input", () => {
+ const { container } = renderInProvider(
+ {}}
+ required
+ aria-describedby="help-3"
+ />,
+ );
+ const input = container.querySelector("input");
+ expect(input?.hasAttribute("required")).toBe(true);
+ expect(input?.getAttribute("aria-describedby")).toBe("help-3");
+ });
+
+ it("ColorInput forwards required and aria-describedby to the input", () => {
+ const { container } = renderInProvider(
+ {}}
+ required
+ aria-describedby="help-4"
+ />,
+ );
+ const input = container.querySelector("input");
+ expect(input?.hasAttribute("required")).toBe(true);
+ expect(input?.getAttribute("aria-describedby")).toBe("help-4");
+ });
+
+ it("Slider forwards aria wiring to the role=slider thumb", () => {
+ const { container } = renderInProvider(
+ {}}
+ aria-label="Confidence"
+ aria-invalid
+ aria-describedby="help-5"
+ />,
+ );
+ const thumb = container.querySelector('[role="slider"]');
+ expect(thumb?.getAttribute("aria-label")).toBe("Confidence");
+ expect(thumb?.getAttribute("aria-invalid")).toBe("true");
+ expect(thumb?.getAttribute("aria-describedby")).toBe("help-5");
+ });
+});
diff --git a/frontend/editor/src/proprietary/ui/ariaForwarding.ts b/frontend/editor/src/proprietary/ui/ariaForwarding.ts
new file mode 100644
index 0000000000..344016a50c
--- /dev/null
+++ b/frontend/editor/src/proprietary/ui/ariaForwarding.ts
@@ -0,0 +1,57 @@
+import { useEffect, useRef } from "react";
+
+/**
+ * Mantine drops caller-supplied accessibility wiring in a few places:
+ * Input-based components overwrite `aria-describedby` with their own
+ * Input.Wrapper context (unset here — FormField owns the help text),
+ * MultiSelect consumes `required` for its label asterisk without marking the
+ * focusable field, and Slider's thumb ignores unknown `thumbProps` keys
+ * entirely. These hooks re-apply the attributes to the rendered DOM node
+ * after every render so FormField's injected wiring survives.
+ */
+
+/**
+ * Ref for a Mantine input component; keeps `aria-describedby` applied.
+ * Pass `required` only when Mantine doesn't put the attribute on the field
+ * itself (MultiSelect) — it is announced as `aria-required`, since a native
+ * `required` on a combobox search field would misfire form validation.
+ */
+export function useInputAria(options: {
+ describedBy?: string;
+ required?: boolean;
+}) {
+ const { describedBy, required } = options;
+ const ref = useRef(null);
+ useEffect(() => {
+ applyAria(ref.current, "aria-describedby", describedBy);
+ applyAria(ref.current, "aria-required", required ? "true" : undefined);
+ });
+ return ref;
+}
+
+/** Ref for Mantine Slider's root; keeps aria wiring applied to the thumb. */
+export function useThumbAria(
+ describedBy: string | undefined,
+ invalid: boolean | undefined,
+) {
+ const rootRef = useRef(null);
+ useEffect(() => {
+ const thumb = rootRef.current?.querySelector('[role="slider"]');
+ applyAria(thumb, "aria-describedby", describedBy);
+ applyAria(thumb, "aria-invalid", invalid ? "true" : undefined);
+ });
+ return rootRef;
+}
+
+function applyAria(
+ el: Element | null | undefined,
+ attribute: string,
+ value: string | undefined,
+) {
+ if (!el) return;
+ if (value !== undefined) {
+ el.setAttribute(attribute, value);
+ } else {
+ el.removeAttribute(attribute);
+ }
+}
diff --git a/frontend/editor/src/proprietary/ui/index.ts b/frontend/editor/src/proprietary/ui/index.ts
index 6c2132f894..e6c4864c52 100644
--- a/frontend/editor/src/proprietary/ui/index.ts
+++ b/frontend/editor/src/proprietary/ui/index.ts
@@ -41,3 +41,8 @@ export * from "@app/ui/Select";
export * from "@app/ui/Checkbox";
export * from "@app/ui/Radio";
export * from "@app/ui/Slider";
+
+// Mantine-backed form elements (SUI-styled)
+export * from "@app/ui/MultiSelect";
+export * from "@app/ui/NumberInput";
+export * from "@app/ui/ColorInput";