diff --git a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx
index e418d2468d..11465e937d 100644
--- a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx
+++ b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx
@@ -11,11 +11,11 @@ import {
Group,
Anchor,
Badge,
- Popover,
} from "@mantine/core";
import { Button } from "@app/ui/Button";
import { ActionIcon } from "@app/ui/ActionIcon";
import { ColorInput } from "@app/ui/ColorInput";
+import { ColorGridPicker } from "@app/ui/ColorGridPicker";
import { SegmentedControl } from "@app/ui/SegmentedControl";
import { clampAccentChoice } from "@app/utils/customPrimary";
import { useTranslation } from "react-i18next";
@@ -84,7 +84,7 @@ interface GeneralSectionProps {
desktopUpdateMode?: DesktopUpdateModeControl;
}
-/** Accent-colour picker: a swatch trigger opening a 3×5 grid — a "Default" icon cell + 14 accents — plus a "Custom" picker below. */
+/** Accent-colour picker: a swatch grid (a "Default" icon cell + 14 accents) with a "Custom" picker below, via the shared ColorGridPicker. */
function AccentSwatchDropdown({
value,
onChange,
@@ -95,8 +95,6 @@ function AccentSwatchDropdown({
ariaLabel: string;
}) {
const { t } = useTranslation();
- const [opened, setOpened] = useState(false);
- const isDefault = value === DEFAULT_ACCENT;
// Resolve the "default" sentinel to the blue it maps to so the ColorInput shows a real colour.
const asHex = (v: string) =>
v === DEFAULT_ACCENT ? DEFAULT_ACCENT_COLOR : v;
@@ -104,158 +102,25 @@ function AccentSwatchDropdown({
const [draft, setDraft] = useState(() => asHex(value));
useEffect(() => setDraft(asHex(value)), [value]);
return (
-
-
- {/* eslint-disable-next-line no-restricted-syntax -- swatch-trigger control, not a text button; the shared Button's variants/padding don't fit */}
-
-
-
-
- {/* Default — an icon chip for the neutral theme (surfaces untinted, blue buttons); stores the sentinel, a separate state from the colour swatches. */}
- {/* eslint-disable-next-line no-restricted-syntax -- an icon chip, not a text button; the shared Button's variants don't fit */}
-
- {THEME_ACCENT_PRESETS.map((color) => {
- const selected = value.toLowerCase() === color.toLowerCase();
- return (
- // eslint-disable-next-line no-restricted-syntax -- a colour swatch, not a text button; the shared Button's variants don't fit
-
- {/* Custom colour — below the presets; our ColorInput, clamped off white/grey/black. */}
-
+ defaultOption={{
+ // Default is not a colour — an icon chip signalling the neutral theme
+ // (surfaces untinted, blue buttons); stores the sentinel.
+ value: DEFAULT_ACCENT,
+ icon: "star-rounded",
+ label: t("settings.general.themeAccentDefault", "Default"),
+ hint: t(
+ "settings.general.themeAccentDefaultHint",
+ "Default theme (recommended)",
+ ),
+ }}
+ footer={
+ <>
{t(
"settings.general.themeAccentCustomHint",
@@ -276,9 +141,9 @@ function AccentSwatchDropdown({
)}
popoverProps={{ withinPortal: false }}
/>
-
-
-
+ >
+ }
+ />
);
}
diff --git a/frontend/editor/src/core/ui/ColorGridPicker.tsx b/frontend/editor/src/core/ui/ColorGridPicker.tsx
new file mode 100644
index 0000000000..1525a8269f
--- /dev/null
+++ b/frontend/editor/src/core/ui/ColorGridPicker.tsx
@@ -0,0 +1,204 @@
+import { useState, type ReactNode } from "react";
+import { Popover } from "@mantine/core";
+import LocalIcon from "@app/components/shared/LocalIcon";
+
+/**
+ * A compact swatch trigger that opens a grid of selectable colours (a
+ * radiogroup), with an optional leading "default/unset" cell shown as an icon
+ * chip rather than a hue, and an optional footer slot (e.g. a custom-colour
+ * input). Generic DS control — the caller supplies the colours and semantics.
+ */
+export interface ColorGridDefaultOption {
+ /** The value stored when this cell is chosen (a sentinel, not a colour). */
+ value: string;
+ /** LocalIcon name shown in the chip. */
+ icon: string;
+ label: string;
+ /** Native title tooltip. */
+ hint?: string;
+}
+
+export interface ColorGridPickerProps {
+ /** Current value: a colour from `colors`, or `defaultOption.value`. */
+ value: string;
+ onChange: (value: string) => void;
+ /** Preset colours (any CSS colour string). */
+ colors: string[];
+ /** Accessible name for the trigger and the radiogroup. */
+ ariaLabel: string;
+ /** Grid columns; defaults to 5. */
+ columns?: number;
+ /** Optional leading "default/unset" cell. */
+ defaultOption?: ColorGridDefaultOption;
+ /** Optional content below the grid (e.g. a custom-colour picker). */
+ footer?: ReactNode;
+ /** Popover z-index, for use inside modals. */
+ zIndex?: number;
+}
+
+const SWATCH = "1.75rem";
+
+export function ColorGridPicker({
+ value,
+ onChange,
+ colors,
+ ariaLabel,
+ columns = 5,
+ defaultOption,
+ footer,
+ zIndex,
+}: ColorGridPickerProps) {
+ const [opened, setOpened] = useState(false);
+ const isDefault = !!defaultOption && value === defaultOption.value;
+ const select = (v: string) => {
+ onChange(v);
+ setOpened(false);
+ };
+ return (
+
+
+
+
+
+