diff --git a/.storybook/ThemeSwapper.tsx b/.storybook/ThemeSwapper.tsx
index efd541cc..280ff099 100644
--- a/.storybook/ThemeSwapper.tsx
+++ b/.storybook/ThemeSwapper.tsx
@@ -18,18 +18,35 @@ export interface ThemeSwapperProps {
export const TextLight = "Mode: Light";
export const TextDark = "Mode: Dark";
+export const TextSystem = "Mode: System";
const ThemeSwapper = ({ context, children }: ThemeSwapperProps) => {
- const { mode, setMode } = useColorScheme();
- //if( !mode ) return
+ const { mode, systemMode, setMode } = useColorScheme();
useEffect(() => {
- const selectedThemeMode = context.globals.themeMode || TextLight;
- setMode(selectedThemeMode == TextLight ? "light" : "dark");
- }, [context.globals.themeMode]);
+ const selectedThemeMode = context.globals.themeMode ?? TextSystem;
+
+ if (selectedThemeMode === TextLight) {
+ setMode("light");
+ return;
+ }
+
+ if (selectedThemeMode === TextDark) {
+ setMode("dark");
+ return;
+ }
+
+ setMode("system");
+ }, [context.globals.themeMode, setMode]);
+
+ const resolvedMode = mode === "system" ? systemMode : mode;
return (
-
+
{children}
);
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 4de2bdc4..4902bf86 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -3,12 +3,32 @@ import { CssBaseline } from "@mui/material";
import type { Preview } from "@storybook/react";
import { ThemeProvider } from "../src";
-import { GenericTheme, DiamondTheme } from "../src";
-
-import { Context, ThemeSwapper, TextLight, TextDark } from "./ThemeSwapper";
+import { GenericTheme, DiamondTheme, DiamondDSTheme } from "../src";
+import { ThemeSwapper, TextLight, TextDark, TextSystem } from "./ThemeSwapper";
+import "../src/styles/diamondDS/diamond-ds-roles.css";
const TextThemeBase = "Theme: Generic";
const TextThemeDiamond = "Theme: Diamond";
+const TextThemeDiamondDS = "Theme: DiamondDS";
+
+function resolveTheme(selectedTheme: string) {
+ switch (selectedTheme) {
+ case TextThemeBase:
+ return GenericTheme;
+ case TextThemeDiamond:
+ return DiamondTheme;
+ case TextThemeDiamondDS:
+ default:
+ return DiamondDSTheme;
+ }
+}
+
+function resolveDefaultMode(selectedThemeMode: string) {
+ if (selectedThemeMode === TextLight) return "light";
+ if (selectedThemeMode === TextDark) return "dark";
+
+ return "system";
+}
export const decorators = [
(StoriesWithPadding: React.FC) => {
@@ -18,24 +38,21 @@ export const decorators = [
);
},
- (StoriesWithThemeSwapping: React.FC, context: Context) => {
- return (
-
-
-
- );
- },
- (StoriesWithThemeProvider: React.FC, context: Context) => {
- const selectedTheme = context.globals.theme || TextThemeBase;
- const selectedThemeMode = context.globals.themeMode || TextLight;
+
+ (Story, context) => {
+ const selectedTheme = context.globals.theme || TextThemeDiamondDS;
+ const selectedThemeMode = context.globals.themeMode || TextSystem;
return (
-
+
+
+
+
);
},
@@ -48,7 +65,7 @@ const preview: Preview = {
toolbar: {
title: "Theme",
icon: "cog",
- items: [TextThemeBase, TextThemeDiamond],
+ items: [TextThemeBase, TextThemeDiamond, TextThemeDiamondDS],
dynamicTitle: true,
},
},
@@ -57,14 +74,14 @@ const preview: Preview = {
toolbar: {
title: "Theme Mode",
icon: "mirror",
- items: [TextLight, TextDark],
+ items: [TextLight, TextDark, TextSystem],
dynamicTitle: true,
},
},
},
initialGlobals: {
- theme: "Theme: Diamond",
- themeMode: "Mode: Light",
+ theme: TextThemeDiamondDS,
+ themeMode: TextSystem,
},
parameters: {
controls: {
@@ -79,11 +96,12 @@ const preview: Preview = {
storySort: {
order: [
"Introduction",
- "Components",
+ "Helpers",
"Theme",
"Theme/Logos",
"Theme/Colours",
- "Helpers",
+ "MUI",
+ "Components",
],
},
},
diff --git a/package.json b/package.json
index 52c035e5..ea3ff23c 100644
--- a/package.json
+++ b/package.json
@@ -127,7 +127,8 @@
"brace-expansion@^2.0.0": "2.0.2",
"@babel/runtime@^7.26.0": "7.27.6",
"esbuild@>=0.24.0 <0.25.0": "0.25.0",
- "webpack@^5.0.0": "5.104.1"
+ "webpack@^5.0.0": "5.104.1",
+ "fast-uri@<3.1.2": "3.1.2"
}
},
"packageManager": "pnpm@9.12.3+sha256.24235772cc4ac82a62627cd47f834c72667a2ce87799a846ec4e8e555e2d4b8b"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 75bba6b4..e845ddf4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -13,6 +13,7 @@ overrides:
'@babel/runtime@^7.26.0': 7.27.6
esbuild@>=0.24.0 <0.25.0: 0.25.0
webpack@^5.0.0: 5.104.1
+ fast-uri@<3.1.2: 3.1.2
importers:
@@ -3211,8 +3212,8 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
- fast-uri@3.0.5:
- resolution: {integrity: sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==}
+ fast-uri@3.1.2:
+ resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fastq@1.18.0:
resolution: {integrity: sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==}
@@ -7629,7 +7630,7 @@ snapshots:
ajv@8.17.1:
dependencies:
fast-deep-equal: 3.1.3
- fast-uri: 3.0.5
+ fast-uri: 3.1.2
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -8535,7 +8536,7 @@ snapshots:
fast-levenshtein@2.0.6: {}
- fast-uri@3.0.5: {}
+ fast-uri@3.1.2: {}
fastq@1.18.0:
dependencies:
diff --git a/src/index.ts b/src/index.ts
index 9b8dda18..386c9447 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -25,7 +25,7 @@ export * from "./components/helpers/jsonforms";
// themes
export * from "./themes/BaseTheme";
export * from "./themes/DiamondTheme";
-export * from "./themes/DiamondOldTheme";
+export * from "./themes/DiamondDSTheme";
export * from "./themes/GenericTheme";
export * from "./themes/ThemeProvider";
export * from "./themes/ThemeManager";
diff --git a/src/styles/diamondDS/diamond-ds-roles.css b/src/styles/diamondDS/diamond-ds-roles.css
new file mode 100644
index 00000000..2ccf5815
--- /dev/null
+++ b/src/styles/diamondDS/diamond-ds-roles.css
@@ -0,0 +1,481 @@
+:root {
+ /* Neutral primitives */
+ --ds-grey-50: #f8f8fa;
+ --ds-grey-100: #eef1f5;
+ --ds-grey-200: #e6e9f0;
+ --ds-grey-300: #dde1e8;
+ --ds-grey-400: #bcc2cd;
+ --ds-grey-500: #a5acb8;
+ --ds-grey-600: #8a90a0;
+ --ds-grey-700: #505563;
+ --ds-grey-800: #2c3140;
+ --ds-grey-900: #1a1c23;
+
+ --ds-grey-dark-50: #e8eaf0;
+ --ds-grey-dark-100: #b6bcc9;
+ --ds-grey-dark-200: #7c8394;
+ --ds-grey-dark-300: #505664;
+ --ds-grey-dark-400: #3a3f4c;
+ --ds-grey-dark-500: #2c3140;
+ --ds-grey-dark-600: #222632;
+ --ds-grey-dark-700: #161820;
+ --ds-grey-dark-800: #0e1017;
+}
+
+/* Light mode semantic roles */
+:root,
+:root[data-mode="light"] {
+ color-scheme: light;
+
+ /* Neutral roles */
+ --ds-background: #f6f6f9;
+ --ds-background-channel: 246 246 249;
+
+ --ds-surface: #ffffff;
+ --ds-surface-channel: 255 255 255;
+
+ --ds-surface-container: var(--ds-grey-100);
+ --ds-surface-container-high: var(--ds-grey-200);
+ --ds-surface-disabled: rgba(0, 0, 0, 0.08);
+
+ --ds-on-surface: var(--ds-grey-900);
+ --ds-on-surface-variant: var(--ds-grey-700);
+ --ds-on-surface-disabled: rgba(0, 0, 0, 0.36);
+ --ds-action-disabled: rgba(0, 0, 0, 0.3);
+ --ds-on-solid: #ffffff;
+
+ --ds-on-surface-channel: 26 28 35;
+ --ds-on-surface-variant-channel: 80 85 99;
+
+ --ds-placeholder: var(--ds-grey-600);
+ --ds-placeholder-focus: var(--ds-grey-700);
+
+ --ds-border-subtle: var(--ds-grey-300);
+ --ds-border: var(--ds-grey-400);
+ --ds-border-emphasis: var(--ds-grey-500);
+ --ds-border-subtle-channel: 221 225 232;
+
+ /* Interaction overlays
+ *
+ * Overlays are layered on top of semantic surfaces rather than replacing them.
+ */
+ --ds-overlay-hover: rgba(0, 0, 0, 0.08);
+ --ds-overlay-hover-solid: rgba(0, 0, 0, 0.16);
+ --ds-overlay-selected: rgba(0, 0, 0, 0.25);
+ --ds-overlay-selected-channel: 0 0 0;
+ --ds-overlay-focus: rgba(0, 0, 0, 0.1);
+
+ /* Intent semantic roles
+ *
+ * Used for action hierarchy and status meaning.
+ *
+ * Scale logic:
+ * - accent = lighter/supporting emphasis
+ * - main = default semantic role
+ * - emphasis = stronger emphasis
+ * - container = subtle surface
+ * - solid = filled surface
+ */
+
+ /* Primary (Indigo-Blue) */
+ --ds-primary: #2a4db8;
+ --ds-on-primary: #ffffff;
+ --ds-primary-emphasis: #1f3d96;
+ --ds-primary-accent: #6a86e4;
+ --ds-primary-container: #e5ebff;
+ --ds-on-primary-container: #1a2f6b;
+ --ds-primary-solid: #3f63c9;
+ --ds-on-primary-solid: #ffffff;
+
+ --ds-on-primary-channel: 255 255 255;
+ --ds-primary-mainChannel: 42 77 184;
+ --ds-primary-lightChannel: 106 134 228;
+ --ds-primary-darkChannel: 31 61 150;
+
+ /* Secondary (Teal) */
+ --ds-secondary: #007b84;
+ --ds-on-secondary: #ffffff;
+ --ds-secondary-emphasis: #005f67;
+ --ds-secondary-accent: #27adb7;
+ --ds-secondary-container: #ddf3f5;
+ --ds-on-secondary-container: #00474d;
+ --ds-secondary-solid: #0a858e;
+ --ds-on-secondary-solid: #ffffff;
+
+ --ds-on-secondary-channel: 255 255 255;
+ --ds-secondary-mainChannel: 0 123 132;
+ --ds-secondary-lightChannel: 39 173 183;
+ --ds-secondary-darkChannel: 0 95 103;
+
+ /* Tertiary (Violet)
+ *
+ * Available as a token family but not currently exposed as a MUI intent colour.
+ */
+ --ds-tertiary: #8c0070;
+ --ds-on-tertiary: #ffffff;
+ --ds-tertiary-emphasis: #6c0057;
+ --ds-tertiary-accent: #c735a8;
+ --ds-tertiary-container: #f8e2f2;
+ --ds-on-tertiary-container: #4f003f;
+ --ds-tertiary-solid: #b8329b;
+ --ds-on-tertiary-solid: #ffffff;
+
+ --ds-on-tertiary-channel: 255 255 255;
+ --ds-tertiary-mainChannel: 140 0 112;
+ --ds-tertiary-lightChannel: 199 53 168;
+ --ds-tertiary-darkChannel: 108 0 87;
+
+ /* Brand (Diamond Blue) */
+ --ds-brand: #202945;
+ --ds-on-brand: #ffffff;
+ --ds-brand-emphasis: #171f35;
+ --ds-brand-accent: #6a86db;
+ --ds-brand-container: #e4e8f4;
+ --ds-on-brand-container: #202945;
+ --ds-brand-solid: #2f3b63;
+ --ds-on-brand-solid: #ffffff;
+
+ /* Fixed brand roles
+ *
+ * These remain stable across light and dark mode.
+ * Use sparingly for persistent Diamond identity surfaces or accents.
+ */
+ --ds-brand-fixed: #202945;
+ --ds-brand-fixed-dim: #586084;
+ --ds-on-brand-fixed: #ffffff;
+
+ --ds-on-brand-channel: 255 255 255;
+ --ds-brand-mainChannel: 32 41 69;
+ --ds-brand-lightChannel: 106 134 219;
+ --ds-brand-darkChannel: 23 31 53;
+
+ /* Danger (Red) */
+ --ds-danger: #b42318;
+ --ds-on-danger: #ffffff;
+ --ds-danger-emphasis: #912018;
+ --ds-danger-accent: #d94f45;
+ --ds-danger-container: #fde7e5;
+ --ds-on-danger-container: #6a1b15;
+ --ds-danger-solid: #d63c41;
+ --ds-on-danger-solid: #ffffff;
+
+ --ds-on-danger-channel: 255 255 255;
+ --ds-danger-mainChannel: 180 35 24;
+ --ds-danger-lightChannel: 217 79 69;
+ --ds-danger-darkChannel: 145 32 24;
+
+ /* Warning (Orange) */
+ --ds-warning: #c96a04;
+ --ds-on-warning: #ffffff;
+ --ds-warning-emphasis: #a95703;
+ --ds-warning-accent: #e98a15;
+ --ds-warning-container: #fef0df;
+ --ds-on-warning-container: #6f3200;
+ --ds-warning-solid: #e97b12;
+ --ds-on-warning-solid: #ffffff;
+
+ --ds-on-warning-channel: 255 255 255;
+ --ds-warning-mainChannel: 201 106 4;
+ --ds-warning-lightChannel: 233 138 21;
+ --ds-warning-darkChannel: 169 87 3;
+
+ /* Success (Green) */
+ --ds-success: #187a2f;
+ --ds-on-success: #ffffff;
+ --ds-success-emphasis: #146125;
+ --ds-success-accent: #2fb344;
+ --ds-success-container: #e3f4e7;
+ --ds-on-success-container: #124d22;
+ --ds-success-solid: #1b8834;
+ --ds-on-success-solid: #ffffff;
+
+ --ds-on-success-channel: 255 255 255;
+ --ds-success-mainChannel: 24 122 47;
+ --ds-success-lightChannel: 47 154 73;
+ --ds-success-darkChannel: 20 97 37;
+
+ /* Info (Blue) */
+ --ds-info: #355ec9;
+ --ds-on-info: #ffffff;
+ --ds-info-emphasis: #2a4ea7;
+ --ds-info-accent: #6f8fe8;
+ --ds-info-container: #e9efff;
+ --ds-on-info-container: #1f3b85;
+ --ds-info-solid: #4d72dd;
+ --ds-on-info-solid: #ffffff;
+
+ --ds-on-info-channel: 255 255 255;
+ --ds-info-mainChannel: 53 94 201;
+ --ds-info-lightChannel: 111 143 232;
+ --ds-info-darkChannel: 42 78 167;
+
+ /* Highlight
+ *
+ * Available as a token family but not currently exposed as a MUI intent colour.
+ */
+ --ds-highlight: #d4a900;
+ --ds-on-highlight: #1a1c23;
+ --ds-highlight-emphasis: #b89300;
+ --ds-highlight-accent: #ffd84d;
+ --ds-highlight-container: #fff4cc;
+ --ds-on-highlight-container: #6b5500;
+ --ds-highlight-solid: #b89300;
+ --ds-on-highlight-solid: #ffffff;
+
+ --ds-on-highlight-channel: 26 28 35;
+ --ds-highlight-mainChannel: 212 169 0;
+ --ds-highlight-lightChannel: 255 216 77;
+ --ds-highlight-darkChannel: 184 147 0;
+
+ /* Focus roles */
+ --ds-focus-ring: var(--ds-primary-accent);
+ --ds-focus-ring-width: 2px;
+ --ds-focus-ring-offset: 2px;
+}
+
+/**
+ * Dark mode semantic roles.
+ *
+ * Values are tuned for dark surfaces rather than mechanically inverted from light mode.
+ */
+:root[data-mode="dark"] {
+ color-scheme: dark;
+
+ /* Neutral roles */
+ --ds-background: var(--ds-grey-dark-800);
+ --ds-background-channel: 14 16 23;
+
+ --ds-surface: var(--ds-grey-dark-700);
+ --ds-surface-channel: 22 24 32;
+
+ --ds-surface-container: var(--ds-grey-dark-600);
+ --ds-surface-container-high: var(--ds-grey-dark-500);
+ --ds-surface-disabled: rgba(255, 255, 255, 0.14);
+
+ --ds-on-surface: var(--ds-grey-dark-50);
+ --ds-on-surface-variant: var(--ds-grey-dark-100);
+ --ds-on-surface-disabled: rgba(255, 255, 255, 0.36);
+ --ds-action-disabled: rgba(255, 255, 255, 0.3);
+ --ds-on-solid: #ffffff;
+
+ --ds-on-surface-channel: 232 234 240;
+ --ds-on-surface-variant-channel: 182 188 201;
+
+ --ds-placeholder: var(--ds-grey-dark-200);
+ --ds-placeholder-focus: var(--ds-grey-dark-100);
+
+ --ds-border-subtle: var(--ds-grey-dark-400);
+ --ds-border: var(--ds-grey-dark-300);
+ --ds-border-emphasis: var(--ds-grey-dark-200);
+ --ds-border-subtle-channel: 58 63 76;
+
+ /* Interaction overlays */
+ --ds-overlay-hover: rgba(255, 255, 255, 0.16);
+ --ds-overlay-hover-solid: rgba(255, 255, 255, 0.16);
+ --ds-overlay-selected: rgba(255, 255, 255, 0.12);
+ --ds-overlay-selected-channel: 255 255 255;
+ --ds-overlay-focus: rgba(255, 255, 255, 0.12);
+
+ /* Primary */
+ --ds-primary: #8aa7ff;
+ --ds-on-primary: #0b1638;
+ --ds-primary-emphasis: #c4d4ff;
+ --ds-primary-accent: #a5bcff;
+ --ds-primary-container: #1b2c5f;
+ --ds-on-primary-container: #e8eeff;
+ --ds-primary-solid: #3f63c9;
+ --ds-on-primary-solid: #ffffff;
+
+ --ds-on-primary-channel: 11 22 56;
+ --ds-primary-mainChannel: 138 167 255;
+ --ds-primary-lightChannel: 196 212 255;
+ --ds-primary-darkChannel: 165 188 255;
+
+ /* Secondary */
+ --ds-secondary: #58d6de;
+ --ds-on-secondary: #002529;
+ --ds-secondary-emphasis: #9af0f3;
+ --ds-secondary-accent: #7be4ea;
+ --ds-secondary-container: #0d3338;
+ --ds-on-secondary-container: #ccf7f9;
+ --ds-secondary-solid: #0a858e;
+ --ds-on-secondary-solid: #ffffff;
+
+ --ds-on-secondary-channel: 0 37 41;
+ --ds-secondary-mainChannel: 88 214 222;
+ --ds-secondary-lightChannel: 154 240 243;
+ --ds-secondary-darkChannel: 123 228 234;
+
+ /* Tertiary */
+ --ds-tertiary: #e587d1;
+ --ds-on-tertiary: #2a0022;
+ --ds-tertiary-emphasis: #f7bfeb;
+ --ds-tertiary-accent: #efa5e0;
+ --ds-tertiary-container: #381232;
+ --ds-on-tertiary-container: #f9d8f1;
+ --ds-tertiary-solid: #b8329b;
+ --ds-on-tertiary-solid: #ffffff;
+
+ --ds-on-tertiary-channel: 42 0 34;
+ --ds-tertiary-mainChannel: 229 135 209;
+ --ds-tertiary-lightChannel: 247 191 235;
+ --ds-tertiary-darkChannel: 239 165 224;
+
+ /* Brand */
+ --ds-brand: #aabdff;
+ --ds-on-brand: #0d1530;
+ --ds-brand-emphasis: #d7e1ff;
+ --ds-brand-accent: #c4d2ff;
+ --ds-brand-container: #202945;
+ --ds-on-brand-container: #e3e8f7;
+ --ds-brand-solid: #3a4a78;
+ --ds-on-brand-solid: #ffffff;
+
+ --ds-brand-fixed: #202945;
+ --ds-brand-fixed-dim: #586084;
+ --ds-on-brand-fixed: #ffffff;
+
+ --ds-on-brand-channel: 13 21 48;
+ --ds-brand-mainChannel: 170 189 255;
+ --ds-brand-lightChannel: 215 225 255;
+ --ds-brand-darkChannel: 196 210 255;
+
+ /* Danger */
+ --ds-danger: #ff9088;
+ --ds-on-danger: #2f0907;
+ --ds-danger-emphasis: #ffc7c2;
+ --ds-danger-accent: #ffb0aa;
+ --ds-danger-container: #3a1613;
+ --ds-on-danger-container: #ffd9d6;
+ --ds-danger-solid: #d63c41;
+ --ds-on-danger-solid: #ffffff;
+
+ --ds-on-danger-channel: 47 9 7;
+ --ds-danger-mainChannel: 255 144 136;
+ --ds-danger-lightChannel: 255 199 194;
+ --ds-danger-darkChannel: 255 176 170;
+
+ /* Warning */
+ --ds-warning: #ffb067;
+ --ds-on-warning: #311700;
+ --ds-warning-emphasis: #ffd9b0;
+ --ds-warning-accent: #ffc68a;
+ --ds-warning-container: #382006;
+ --ds-on-warning-container: #ffe4c8;
+ --ds-warning-solid: #f07a13;
+ --ds-on-warning-solid: #ffffff;
+
+ --ds-on-warning-channel: 49 23 0;
+ --ds-warning-mainChannel: 255 176 103;
+ --ds-warning-lightChannel: 255 217 176;
+ --ds-warning-darkChannel: 255 198 138;
+
+ /* Success */
+ --ds-success: #6fd88a;
+ --ds-on-success: #08210f;
+ --ds-success-emphasis: #b3f0c0;
+ --ds-success-accent: #8ae5a2;
+ --ds-success-container: #10341a;
+ --ds-on-success-container: #d2f7da;
+ --ds-success-solid: #23913c;
+ --ds-on-success-solid: #ffffff;
+
+ --ds-on-success-channel: 8 33 15;
+ --ds-success-mainChannel: 111 216 138;
+ --ds-success-lightChannel: 179 240 192;
+ --ds-success-darkChannel: 138 229 162;
+
+ /* Info */
+ --ds-info: #9fb7ff;
+ --ds-on-info: #101936;
+ --ds-info-emphasis: #d5e0ff;
+ --ds-info-accent: #bccdff;
+ --ds-info-container: #1b2b57;
+ --ds-on-info-container: #dce4ff;
+ --ds-info-solid: #4d72dd;
+ --ds-on-info-solid: #ffffff;
+
+ --ds-on-info-channel: 16 25 54;
+ --ds-info-mainChannel: 159 183 255;
+ --ds-info-lightChannel: 213 224 255;
+ --ds-info-darkChannel: 188 205 255;
+
+ /* Highlight */
+ --ds-highlight: #ffd84d;
+ --ds-on-highlight: #2a2100;
+ --ds-highlight-emphasis: #fff1b8;
+ --ds-highlight-accent: #ffeaa0;
+ --ds-highlight-container: #4b3a05;
+ --ds-on-highlight-container: #fff4c7;
+ --ds-highlight-solid: #d4a900;
+ --ds-on-highlight-solid: #1a1c23;
+
+ --ds-on-highlight-channel: 26 28 35;
+ --ds-highlight-mainChannel: 255 226 122;
+ --ds-highlight-lightChannel: 255 241 184;
+ --ds-highlight-darkChannel: 255 234 160;
+}
+
+/* Elavation colors
+
+0: base paper, dialogs on clean surface
+1–3: cards, panels, raised sections
+4–8: menus, popovers, floating UI
+9–16: more obviously separated overlays
+17–24: rare, maximum lift
+
+Figma references:
+LIGHT
+elevation-0 = #FFFFFF
+elevation-1 = #FDFDFE
+elevation-2 = #FAFBFC
+elevation-3 = #F8F9FB
+elevation-4 = #F7F9FB
+elevation-5 = #F6F8FA
+elevation-6 = #F5F7F9
+elevation-7 = #F4F6F8
+elevation-8 = #F3F5F7
+elevation-9 = #F3F5F7
+elevation-10 = #F2F4F7
+elevation-11 = #F2F4F7
+elevation-12 = #F1F3F6
+elevation-13 = #F1F3F6
+elevation-14 = #F1F3F6
+elevation-15 = #F0F2F5
+elevation-16 = #F0F2F5
+elevation-17 = #F0F2F5
+elevation-18 = #EFF1F4
+elevation-19 = #EFF1F4
+elevation-20 = #EEF1F5
+elevation-21 = #EEF1F5
+elevation-22 = #EEF1F5
+elevation-23 = #EEF1F5
+elevation-24 = #EEF1F5
+
+DARK
+elevation-0 = #161820
+elevation-1 = #181B23
+elevation-2 = #191C25
+elevation-3 = #1A1E27
+elevation-4 = #1B1F28
+elevation-5 = #1C202A
+elevation-6 = #1E222C
+elevation-7 = #1F232D
+elevation-8 = #20242F
+elevation-9 = #20242F
+elevation-10 = #212631
+elevation-11 = #212631
+elevation-12 = #222632
+elevation-13 = #222632
+elevation-14 = #222632
+elevation-15 = #242935
+elevation-16 = #242935
+elevation-17 = #252A37
+elevation-18 = #262C39
+elevation-19 = #262C39
+elevation-20 = #28303C
+elevation-21 = #28303C
+elevation-22 = #2A3140
+elevation-23 = #2A3140
+elevation-24 = #2C3140
+*/
diff --git a/src/themes/DiamondDSTheme.ts b/src/themes/DiamondDSTheme.ts
new file mode 100644
index 00000000..23e81f57
--- /dev/null
+++ b/src/themes/DiamondDSTheme.ts
@@ -0,0 +1,1418 @@
+/**
+ * DiamondDS MUI theme
+ *
+ * Maps DiamondDS semantic design tokens and interaction rules into MUI's
+ * theme system, component model and runtime styling APIs.
+ *
+ * CSS variables remain the source of truth.
+ * The MUI theme acts as the semantic adapter consumed by components.
+ *
+ * Components should consume semantic roles from the theme or semantic CSS
+ * variables rather than raw colour values.
+ */
+import "../styles/diamondDS/diamond-ds-roles.css";
+
+// Enables `theme.vars` typings for MUI CSS variable themes.
+import type {} from "@mui/material/themeCssVarsAugmentation";
+import { extendTheme } from "@mui/material/styles";
+import type { CSSObject, Theme } from "@mui/material/styles";
+
+/**
+ * Component prop types are used to type `ownerState` inside MUI style overrides.
+ */
+import type { AlertProps } from "@mui/material/Alert";
+import type { ButtonProps } from "@mui/material/Button";
+import type { CheckboxProps } from "@mui/material/Checkbox";
+import type { ChipProps } from "@mui/material/Chip";
+import type { CircularProgressProps } from "@mui/material/CircularProgress";
+import type { LinearProgressProps } from "@mui/material/LinearProgress";
+import type { OutlinedInputProps } from "@mui/material/OutlinedInput";
+import type { RadioProps } from "@mui/material/Radio";
+import type { TabProps } from "@mui/material/Tab";
+
+import logoImageLight from "../public/diamond/logo-light.svg";
+import logoImageDark from "../public/diamond/logo-dark.svg";
+import logoShort from "../public/diamond/logo-short.svg";
+import type { ImageColourSchemeSwitchType } from "components/controls/ImageColourSchemeSwitch";
+
+/**
+ * Standard argument shape for MUI style override callbacks.
+ *
+ * `ownerState` is MUI's current component prop/state snapshot.
+ */
+type OverrideArgs = {
+ ownerState: OwnerState;
+ theme: Theme;
+};
+
+/**
+ * Theme-only argument shape for MUI style overrides.
+ */
+type ThemeOnlyArgs = {
+ theme: Theme;
+};
+
+/**
+ * Canonical list of supported DiamondDS intent colours.
+ *
+ * DiamondDS supports:
+ * - action intents: primary, secondary
+ * - status intents: success, warning, error, info
+ *
+ * Intent colours communicate hierarchy, meaning and state through component
+ * APIs such as `color="primary"` or `color="error"`.
+ *
+ * Brand is intentionally excluded. Brand communicates Diamond identity rather
+ * than behaviour or status.
+ */
+const intentColours = [
+ "primary",
+ "secondary",
+ "error",
+ "warning",
+ "info",
+ "success",
+] as const;
+
+type IntentColour = (typeof intentColours)[number];
+
+/**
+ * Internal DiamondDS palette contract.
+ *
+ * Every supported intent colour must provide the roles needed for text,
+ * container, solid and interaction states. MUI's public palette option types
+ * remain partial, but DiamondDS helpers use this stricter resolved contract.
+ */
+type ExtendedPaletteColor = {
+ light: string;
+ main: string;
+ dark: string;
+ contrastText: string;
+ mainChannel: string;
+ lightChannel: string;
+ darkChannel: string;
+ contrastTextChannel: string;
+ container: string;
+ onContainer: string;
+ solid: string;
+ onSolid: string;
+};
+
+type BrandPaletteColor = ExtendedPaletteColor & {
+ /**
+ * Fixed brand roles stay stable across light and dark mode.
+ *
+ * Use for persistent Diamond identity surfaces or accents only.
+ */
+ fixed: string;
+ fixedDim: string;
+ onFixed: string;
+};
+
+type BrandPaletteOptions = Partial;
+
+/**
+ * Strict DiamondDS intent palette map.
+ *
+ * Every supported intent colour must provide the full semantic role set.
+ */
+type IntentPaletteRecord = Record;
+
+/**
+ * Theme shape used by DiamondDS intent helpers.
+ *
+ * `theme.palette` is treated as the resolved strict contract.
+ * `theme.vars.palette` remains partial because MUI controls CSS variable
+ * resolution.
+ */
+type ThemeWithIntentPalette = Theme & {
+ vars?: {
+ palette?: Partial>>;
+ };
+ palette: Theme["palette"] & IntentPaletteRecord;
+};
+
+/**
+ * MUI theme augmentation for DiamondDS semantic roles.
+ *
+ * CSS variables remain the source of truth. These typings expose DiamondDS
+ * text, surface, border and palette roles through the MUI theme API.
+ */
+declare module "@mui/material/styles" {
+ interface CssVarsTheme {
+ logos?: {
+ normal: ImageColourSchemeSwitchType;
+ short?: ImageColourSchemeSwitchType;
+ };
+ }
+
+ interface CssVarsThemeOptions {
+ logos?: {
+ normal: ImageColourSchemeSwitchType;
+ short?: ImageColourSchemeSwitchType;
+ };
+ }
+
+ interface TypeBackground {
+ default: string;
+ paper: string;
+ }
+
+ interface TypeText {
+ placeholder?: string;
+ placeholderFocus?: string;
+ onSolid?: string;
+ primaryChannel?: string;
+ secondaryChannel?: string;
+ }
+
+ interface TypeTextOptions {
+ primary?: string;
+ secondary?: string;
+ disabled?: string;
+ placeholder?: string;
+ placeholderFocus?: string;
+ primaryChannel?: string;
+ secondaryChannel?: string;
+ }
+
+ interface Palette {
+ /**
+ * Brand is an identity/accent colour, not an intent colour.
+ *
+ * Use it for Diamond recognition, product identity and selected visual
+ * accents. Avoid using it as a general status or behaviour signal.
+ */
+ brand?: BrandPaletteColor;
+
+ /** Neutral border roles used for structure, not meaning. */
+ borders: {
+ subtle: string;
+ base: string;
+ emphasis: string;
+ };
+
+ /** Neutral surface roles used to create hierarchy without semantic state. */
+ surface: {
+ subtle: string;
+ strong: string;
+ };
+ }
+
+ /**
+ * Theme authoring interface.
+ *
+ * Unlike the resolved runtime palette, theme options remain intentionally
+ * partial so themes can provide only the values they need to override.
+ *
+ * DiamondDS extends MUI's palette options with:
+ * - brand identity roles
+ * - semantic border roles
+ * - semantic surface roles
+ *
+ * The stricter runtime intent contract is enforced separately through
+ * IntentPaletteRecord and ExtendedPaletteColor.
+ */
+ interface PaletteOptions {
+ brand?: BrandPaletteOptions;
+
+ borders?: {
+ subtle?: string;
+ base?: string;
+ emphasis?: string;
+ };
+ surface?: {
+ subtle?: string;
+ strong?: string;
+ };
+ }
+
+ interface PaletteColor {
+ mainChannel?: string;
+ lightChannel?: string;
+ darkChannel?: string;
+ contrastTextChannel?: string;
+ container?: string;
+ onContainer?: string;
+ solid?: string;
+ onSolid?: string;
+ }
+
+ interface SimplePaletteColorOptions {
+ mainChannel?: string;
+ lightChannel?: string;
+ darkChannel?: string;
+ contrastTextChannel?: string;
+ container?: string;
+ onContainer?: string;
+ solid?: string;
+ onSolid?: string;
+ }
+}
+
+export type DSMode = "light" | "dark";
+
+// --- Semantic palette and interaction helpers ---
+
+const isIntentColour = (colour: unknown): colour is IntentColour =>
+ typeof colour === "string" && intentColours.includes(colour as IntentColour);
+
+/**
+ * Creates a DiamondDS semantic palette entry from a token namespace.
+ *
+ * CSS variables remain the source of truth. The MUI palette is an adapter layer
+ * that lets component overrides use stable semantic names instead of repeating
+ * raw `var(--ds-*)` references everywhere.
+ *
+ * MUI mapping follows the DiamondDS/Radix-style role logic:
+ * - light -> accent / focus-adjacent role
+ * - main -> default semantic colour
+ * - dark -> stronger emphasis role (not simply a darker colour)
+ * - container -> subtle semantic surface
+ * - onContainer -> foreground on subtle semantic surface
+ * - solid -> filled interactive surface
+ * - onSolid -> foreground on filled interactive surface
+ */
+const createPaletteColour = (tokenName: string): ExtendedPaletteColor => ({
+ light: `var(--ds-${tokenName}-accent)`,
+ main: `var(--ds-${tokenName})`,
+ dark: `var(--ds-${tokenName}-emphasis)`,
+ contrastText: `var(--ds-on-${tokenName})`,
+ container: `var(--ds-${tokenName}-container)`,
+ onContainer: `var(--ds-on-${tokenName}-container)`,
+ solid: `var(--ds-${tokenName}-solid)`,
+ onSolid: `var(--ds-on-${tokenName}-solid)`,
+
+ contrastTextChannel: `var(--ds-on-${tokenName}-channel)`,
+ mainChannel: `var(--ds-${tokenName}-mainChannel)`,
+ lightChannel: `var(--ds-${tokenName}-lightChannel)`,
+ darkChannel: `var(--ds-${tokenName}-darkChannel)`,
+});
+
+/**
+ * Creates the DiamondDS brand palette.
+ *
+ * Brand includes the regular semantic palette roles plus fixed brand roles.
+ * Fixed roles remain stable across light and dark mode and should only be used
+ * for persistent Diamond identity surfaces or accents.
+ */
+const createBrandPaletteColour = (): BrandPaletteColor => ({
+ ...createPaletteColour("brand"),
+
+ fixed: "var(--ds-brand-fixed)",
+ fixedDim: "var(--ds-brand-fixed-dim)",
+ onFixed: "var(--ds-on-brand-fixed)",
+});
+
+/**
+ * MUI uses `error`; DiamondDS tokens use `danger`.
+ *
+ * Keep the translation here so component code can continue to speak MUI while
+ * the CSS token layer can use DiamondDS language.
+ */
+const intentTokenName: Record = {
+ primary: "primary",
+ secondary: "secondary",
+ error: "danger",
+ warning: "warning",
+ success: "success",
+ info: "info",
+};
+
+/**
+ * Builds the complete DiamondDS intent palette from token namespaces.
+ *
+ * Keeping this generated from `intentTokenName` avoids repeating the same MUI
+ * palette mapping for every supported intent.
+ */
+const createIntentPalette = (): IntentPaletteRecord => ({
+ primary: createPaletteColour(intentTokenName.primary),
+ secondary: createPaletteColour(intentTokenName.secondary),
+ error: createPaletteColour(intentTokenName.error),
+ warning: createPaletteColour(intentTokenName.warning),
+ success: createPaletteColour(intentTokenName.success),
+ info: createPaletteColour(intentTokenName.info),
+});
+
+/**
+ * Returns a supported intent palette.
+ *
+ * `theme.vars.palette` can be present when MUI CSS variables are enabled. When
+ * it exists, it may contain the resolved variable-aware values. We merge it over
+ * `theme.palette` while preserving the DiamondDS contract.
+ *
+ * Fallback policy:
+ * - unsupported colour values fall back to primary before this function is used
+ * - missing palette entries fall back to primary in development with a warning
+ *
+ * That fallback has a deliberate meaning: primary is the safest non-destructive
+ * action intent. We do not silently fall back from error/warning to decorative
+ * or brand values.
+ */
+const getIntentPalette = (
+ theme: Theme,
+ colour: IntentColour,
+): ExtendedPaletteColor => {
+ const { vars, palette } = theme as ThemeWithIntentPalette;
+
+ const paletteColour = palette[colour];
+ const varsColour = vars?.palette?.[colour];
+
+ if (paletteColour) {
+ return {
+ ...paletteColour,
+ ...varsColour,
+ };
+ }
+
+ if (process.env.NODE_ENV !== "production") {
+ console.warn(
+ `[DiamondDS] getIntentPalette: colour "${colour}" not found. Falling back to primary.`,
+ );
+ }
+
+ return {
+ ...palette.primary,
+ ...vars?.palette?.primary,
+ };
+};
+
+/**
+ * Normalises external MUI colour props into DiamondDS-supported intents.
+ *
+ * Component `ownerState` values come from MUI props and internal state. They can
+ * include values such as `inherit`, `default`, or custom app colours. DiamondDS
+ * only treats the declared `IntentColour` set as semantic intents.
+ */
+const getIntentFromColourProp = (
+ colour: unknown,
+ fallback: IntentColour = "primary",
+): IntentColour => (isIntentColour(colour) ? colour : fallback);
+
+/**
+ * Focus rings use one shared DiamondDS focus token.
+ *
+ * Focus shows keyboard/navigation state. It should not change by intent,
+ * status or validation colour.
+ */
+const getFocusOutline = (): CSSObject => ({
+ "&.Mui-focusVisible": {
+ outline: "var(--ds-focus-ring-width) solid var(--ds-focus-ring)",
+ outlineOffset: "var(--ds-focus-ring-offset)",
+ },
+});
+
+/**
+ * Interaction overlays are layered on top of the base surface.
+ *
+ * This keeps hover/active/focus feedback separate from semantic colour roles,
+ * which is especially useful across light and dark modes.
+ */
+const getOverlayInset = (token = "var(--ds-overlay-hover)") =>
+ `inset 0 0 0 9999px ${token}`;
+
+/**
+ * Shared interaction treatment for semantic interactive surfaces.
+ *
+ * Keeps hover and active overlays visually consistent across components.
+ */
+const getInteractiveSurfaceStateStyles = (
+ backgroundColor: string,
+ overlay = "var(--ds-overlay-hover)",
+): CSSObject => ({
+ "&:hover": {
+ backgroundColor,
+ boxShadow: getOverlayInset(overlay),
+ },
+
+ "&:active": {
+ backgroundColor,
+ boxShadow: getOverlayInset("var(--ds-overlay-selected)"),
+ },
+});
+
+/**
+ * Disabled state intentionally removes interactive affordances.
+ *
+ * Disabled styles should visually override hover, focus and active states.
+ */
+const getDisabledControlStyles = (backgroundColor = "transparent"): CSSObject =>
+ ({
+ opacity: 1,
+ backgroundColor,
+ color: "var(--ds-on-surface-disabled)",
+ boxShadow: "none",
+ }) satisfies CSSObject;
+
+/**
+ * Creates the resolved DiamondDS MUI theme.
+ *
+ * This factory:
+ * - maps DiamondDS semantic tokens into MUI
+ * - configures component defaults and overrides
+ * - applies light/dark semantic role resolution
+ * - keeps CSS variables as the source of truth
+ *
+ * The resulting theme should expose semantic roles rather than raw colours.
+ */
+
+/**
+ * Creates the shared DiamondDS semantic palette for a colour scheme.
+ *
+ * Light and dark schemes intentionally reference the same semantic CSS
+ * variables. The actual values are resolved by the `data-mode` attribute on
+ * ``, keeping CSS variables as the source of truth while still giving
+ * MUI a proper colour-scheme-aware theme.
+ */
+const createDiamondPalette = (mode: DSMode) => {
+ const intentPalette = createIntentPalette();
+
+ return {
+ mode,
+
+ /**
+ * MUI action tokens are mapped to DiamondDS overlay and disabled roles.
+ *
+ * Components should prefer semantic CSS variables directly where they need
+ * precise behaviour, but these values keep MUI defaults aligned with the
+ * design system.
+ */
+ action: {
+ hover: "var(--ds-overlay-hover)",
+ selected: "var(--ds-overlay-selected)",
+ selectedChannel: "var(--ds-overlay-selected-channel)",
+ focus: "var(--ds-overlay-focus)",
+ disabled: "var(--ds-on-surface-disabled)",
+ disabledBackground: "var(--ds-surface-disabled)",
+
+ hoverOpacity: 0.04,
+ selectedOpacity: 0.08,
+ disabledOpacity: 0.38,
+ focusOpacity: 0.16,
+ },
+
+ /**
+ * Text roles describe hierarchy and surface relationship.
+ *
+ * Prefer these semantic roles over raw greys so dark mode and future
+ * accessibility refinements can be made centrally.
+ */
+ text: {
+ primary: "var(--ds-on-surface)",
+ secondary: "var(--ds-on-surface-variant)",
+ onSolid: "var(--ds-on-solid)",
+ disabled: "var(--ds-on-surface-disabled)",
+ placeholder: "var(--ds-placeholder)",
+ placeholderFocus: "var(--ds-placeholder-focus)",
+
+ primaryChannel: "var(--ds-on-surface-channel)",
+ secondaryChannel: "var(--ds-on-surface-variant-channel)",
+ },
+
+ background: {
+ default: "rgb(var(--ds-background-channel))",
+ paper: "rgb(var(--ds-surface-channel))",
+ },
+
+ divider: "var(--ds-border-subtle)",
+ dividerChannel: "var(--ds-border-subtle-channel)",
+
+ borders: {
+ subtle: "var(--ds-border-subtle)",
+ base: "var(--ds-border)",
+ emphasis: "var(--ds-border-emphasis)",
+ },
+
+ surface: {
+ subtle: "var(--ds-surface-container)",
+ strong: "var(--ds-surface-container-high)",
+ },
+
+ ...intentPalette,
+
+ /**
+ * Brand is provided as a palette entry for places that need Diamond visual
+ * identity, but it is not part of the intent-colour helper path.
+ */
+ brand: createBrandPaletteColour(),
+
+ grey: {
+ 50: "#f8f8fa",
+ 100: "#eef1f5",
+ 200: "#e6e9f0",
+ 300: "#dde1e8",
+ 400: "#bcc2cd",
+ 500: "#a5acb8",
+ 600: "#8a90a0",
+ 700: "#505563",
+ 800: "#2c3140",
+ 900: "#1a1c23",
+ },
+ };
+};
+
+/**
+ * Resolved DiamondDS MUI theme.
+ *
+ * MUI handles the colour-scheme state. DiamondDS handles the actual role values
+ * through `html[data-mode="light"]` and `html[data-mode="dark"]` CSS variables.
+ */
+const DiamondDSTheme = extendTheme({
+ /**
+ * Match the DiamondDS runtime selector:
+ *
+ * or
+ */
+ colorSchemeSelector: '[data-mode="%s"]',
+
+ colorSchemes: {
+ light: {
+ palette: createDiamondPalette("light"),
+ },
+ dark: {
+ palette: createDiamondPalette("dark"),
+ },
+ },
+
+ typography: {
+ fontFamily: [
+ '"Inter Variable"',
+ "Inter",
+ "system-ui",
+ "-apple-system",
+ '"Segoe UI"',
+ "Roboto",
+ "Helvetica",
+ "Arial",
+ "sans-serif",
+ ].join(","),
+ },
+
+ logos: {
+ normal: {
+ src: logoImageLight,
+ srcDark: logoImageDark ?? logoImageLight,
+ alt: "Diamond Light Source Logo",
+ width: "100",
+ },
+ short: {
+ src: logoShort,
+ alt: "Diamond Light Source Logo",
+ width: "35",
+ },
+ },
+
+ components: {
+ /**
+ * Component overrides translate DiamondDS semantic roles into MUI behaviour.
+ *
+ * Keep overrides token-led:
+ * - use semantic tokens or palette roles
+ * - avoid raw colours
+ * - keep disabled and error states visually dominant
+ * - prefer scoped/additive changes over breaking MUI defaults
+ *
+ * Component override summary
+ *
+ * Base interaction:
+ * MuiButtonBase → ripple and focus behaviour
+ *
+ * Actions and selection:
+ * MuiButton → contained, outlined and text variants
+ * MuiIconButton → intent-aware icon actions
+ * MuiToggleButton → selection, border and hover states
+ *
+ * Inputs and forms:
+ * MuiInputBase → placeholder behaviour
+ * MuiOutlinedInput → border priority and validation states
+ * MuiInputLabel → label response to focus and validation
+ *
+ * Navigation and display:
+ * MuiTab → navigation hierarchy and selected state
+ * MuiAlert → semantic feedback variants
+ * MuiChip → metadata, status and interactive chips
+ *
+ * Progress and loading:
+ * MuiLinearProgress → semantic activity indicators
+ * MuiCircularProgress → semantic activity indicators
+ * MuiSkeleton → loading placeholders and shimmer
+ *
+ * Selection controls:
+ * MuiCheckbox → checked and disabled states
+ * MuiRadio → checked and disabled states
+ *
+ * Feedback surfaces:
+ * MuiSnackbar → layout constraints
+ * MuiSnackbarContent → surface styling and actions
+ */
+
+ MuiButtonBase: {
+ /**
+ * Keeps MUI ripple behaviour available while using DiamondDS focus outlines.
+ */
+ defaultProps: {
+ disableRipple: false,
+ disableTouchRipple: false,
+ focusRipple: false,
+ },
+ },
+
+ MuiButton: {
+ /**
+ * Button uses the DiamondDS intent model:
+ *
+ * - contained = solid action surface
+ * - outlined = subtle intent container with border
+ * - text = low-emphasis action
+ *
+ * Disabled styles are declared inside each variant so they override
+ * hover, active and focus treatments for that variant.
+ */
+ defaultProps: {
+ disableFocusRipple: true,
+ },
+
+ styleOverrides: {
+ root: ({ ownerState, theme }: OverrideArgs): CSSObject => {
+ const base: CSSObject = {
+ textTransform: "none",
+ boxShadow: "none",
+
+ "&:hover": {
+ boxShadow: "none",
+ },
+ };
+
+ const variant = ownerState.variant ?? "text";
+ const rawColour = ownerState.color ?? "primary";
+
+ if (rawColour === "inherit") {
+ return {
+ ...base,
+ ...getFocusOutline(),
+ };
+ }
+
+ const colour = getIntentFromColourProp(rawColour);
+ const p = getIntentPalette(theme, colour);
+
+ if (variant === "contained") {
+ return {
+ ...base,
+
+ backgroundColor: p.solid,
+ color: p.onSolid,
+
+ ...getInteractiveSurfaceStateStyles(
+ p.solid,
+ "var(--ds-overlay-hover-solid)",
+ ),
+
+ "&.Mui-focusVisible": {
+ outline:
+ "var(--ds-focus-ring-width) solid var(--ds-focus-ring)",
+ outlineOffset: "var(--ds-focus-ring-offset)",
+ boxShadow: getOverlayInset("var(--ds-overlay-focus)"),
+ },
+
+ "&.Mui-disabled": getDisabledControlStyles(
+ "var(--ds-surface-disabled)",
+ ),
+ };
+ }
+
+ if (variant === "outlined") {
+ return {
+ ...base,
+ ...getFocusOutline(),
+
+ color: p.onContainer,
+ backgroundColor: p.container,
+ border: `1px solid ${p.light}`,
+
+ ...getInteractiveSurfaceStateStyles(p.container),
+
+ "&:hover": {
+ backgroundColor: p.container,
+ borderColor: p.main,
+ boxShadow: getOverlayInset(),
+ },
+
+ "&:active": {
+ backgroundColor: p.container,
+ borderColor: p.dark,
+ boxShadow: getOverlayInset("var(--ds-overlay-selected)"),
+ },
+
+ "&.Mui-disabled": {
+ ...getDisabledControlStyles(),
+ borderColor: "var(--ds-border-subtle)",
+ },
+ };
+ }
+
+ if (variant === "text") {
+ return {
+ ...base,
+ ...getFocusOutline(),
+
+ color: p.main,
+
+ "&:hover": {
+ backgroundColor: p.container,
+ boxShadow: getOverlayInset(),
+ },
+
+ "&.Mui-disabled": {
+ color: "var(--ds-on-surface-disabled)",
+ },
+ };
+ }
+
+ return {
+ ...base,
+ ...getFocusOutline(),
+ };
+ },
+ },
+ },
+
+ MuiIconButton: {
+ /**
+ * IconButton follows the same intent model as Button, but default/inherit
+ * colours stay neutral unless an explicit intent is provided.
+ */
+ defaultProps: {
+ disableRipple: false,
+ disableFocusRipple: true,
+ },
+ styleOverrides: {
+ root: ({
+ ownerState,
+ theme,
+ }: OverrideArgs<{
+ color?: "inherit" | "default" | IntentColour;
+ }>): CSSObject => {
+ const rawColour = ownerState.color ?? "default";
+
+ if (rawColour === "inherit" || rawColour === "default") {
+ return {
+ "&:hover": {
+ boxShadow: getOverlayInset(),
+ },
+ "&.Mui-disabled": {
+ color: "var(--ds-on-surface-disabled)",
+ backgroundColor: "transparent",
+ boxShadow: "none",
+ },
+ ...getFocusOutline(),
+ };
+ }
+
+ const colour = getIntentFromColourProp(rawColour);
+ const p = getIntentPalette(theme, colour);
+
+ return {
+ color: p.main,
+
+ "&:hover": {
+ backgroundColor: p.container,
+ boxShadow: getOverlayInset(),
+ },
+
+ "&.Mui-disabled": {
+ color: "var(--ds-on-surface-disabled)",
+ backgroundColor: "transparent",
+ boxShadow: "none",
+ },
+ ...getFocusOutline(),
+ };
+ },
+ },
+ },
+
+ MuiToggleButton: {
+ styleOverrides: {
+ root: ({ theme }: ThemeOnlyArgs): CSSObject => ({
+ textTransform: "none",
+ border: `1px solid ${theme.palette.borders.base}`,
+
+ "&:hover": {
+ borderColor: theme.palette.borders.emphasis,
+ },
+
+ "&.Mui-selected": {
+ backgroundColor: "var(--ds-primary-container)",
+ color: "var(--ds-on-primary-container)",
+ borderColor: "var(--ds-primary-accent)",
+ },
+
+ "&.Mui-selected:hover": {
+ backgroundColor: "var(--ds-primary-container)",
+ borderColor: "var(--ds-primary)",
+ boxShadow: getOverlayInset(),
+ },
+
+ "&.Mui-disabled": {
+ color: "var(--ds-on-surface-disabled)",
+ borderColor: "var(--ds-border-subtle)",
+ },
+ }),
+ },
+ },
+
+ MuiChip: {
+ /**
+ * Chip supports both neutral metadata and semantic status/action usage.
+ *
+ * Interactive chips receive focus and overlay states; static chips remain calm.
+ */
+ styleOverrides: {
+ root: ({ ownerState, theme }: OverrideArgs): CSSObject => {
+ const base: CSSObject = {
+ "& .MuiChip-icon": {
+ color: "currentColor",
+ },
+ };
+
+ const rawColour = ownerState.color ?? "default";
+ const isDefault = rawColour === "default";
+ const isOutlined = ownerState.variant === "outlined";
+ const isInteractive = !!(ownerState.clickable || ownerState.onDelete);
+
+ if (isDefault) {
+ const backgroundColor = "var(--ds-surface-container-high)";
+
+ return {
+ ...base,
+ ...(isInteractive ? getFocusOutline() : {}),
+
+ color: "var(--ds-on-surface)",
+ borderColor: "var(--ds-border)",
+ backgroundColor,
+
+ ...(isInteractive && {
+ ...getInteractiveSurfaceStateStyles(backgroundColor),
+
+ "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible":
+ {
+ backgroundColor,
+ boxShadow: getOverlayInset("var(--ds-overlay-focus)"),
+ },
+
+ "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover":
+ {
+ backgroundColor,
+ boxShadow: getOverlayInset("var(--ds-overlay-focus)"),
+ },
+ }),
+ };
+ }
+
+ const colour = getIntentFromColourProp(rawColour);
+ const p = getIntentPalette(theme, colour);
+
+ if (isOutlined) {
+ return {
+ ...base,
+ ...(isInteractive ? getFocusOutline() : {}),
+
+ color: p.onContainer,
+ borderColor: p.light,
+ backgroundColor: p.container,
+
+ ...(isInteractive && {
+ ...getInteractiveSurfaceStateStyles(p.container),
+
+ "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible":
+ {
+ backgroundColor: p.container,
+ borderColor: p.light,
+ boxShadow: getOverlayInset("var(--ds-overlay-focus)"),
+ },
+
+ "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover":
+ {
+ backgroundColor: p.container,
+ borderColor: p.light,
+ boxShadow: getOverlayInset("var(--ds-overlay-focus)"),
+ },
+ }),
+ };
+ }
+
+ return {
+ ...base,
+ ...(isInteractive ? getFocusOutline() : {}),
+
+ color: p.onSolid,
+ backgroundColor: p.solid,
+
+ ...(isInteractive && {
+ ...getInteractiveSurfaceStateStyles(
+ p.solid,
+ "var(--ds-overlay-hover-solid)",
+ ),
+
+ "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible":
+ {
+ backgroundColor: p.solid,
+ boxShadow: getOverlayInset("var(--ds-overlay-focus)"),
+ },
+
+ "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover":
+ {
+ backgroundColor: p.solid,
+ boxShadow: getOverlayInset("var(--ds-overlay-focus)"),
+ },
+ }),
+ };
+ },
+ },
+ },
+
+ MuiInputBase: {
+ styleOverrides: {
+ input: ({ theme }: ThemeOnlyArgs): CSSObject => ({
+ "&::placeholder": {
+ color: theme.palette.text.placeholder,
+ opacity: 1,
+ },
+
+ "&::-webkit-input-placeholder": {
+ color: theme.palette.text.placeholder,
+ opacity: 1,
+ },
+
+ "&::-moz-placeholder": {
+ color: theme.palette.text.placeholder,
+ opacity: 1,
+ },
+
+ "&:focus::placeholder": {
+ color: theme.palette.text.placeholderFocus,
+ },
+
+ "&:focus::-webkit-input-placeholder": {
+ color: theme.palette.text.placeholderFocus,
+ opacity: 1,
+ },
+
+ "&:focus::-moz-placeholder": {
+ color: theme.palette.text.placeholderFocus,
+ opacity: 1,
+ },
+ }),
+
+ root: ({ theme }: ThemeOnlyArgs): CSSObject => ({
+ /** Error and disabled placeholder states win over normal focus. */
+ "&.Mui-error input::placeholder, &.Mui-error input::-webkit-input-placeholder, &.Mui-error input::-moz-placeholder":
+ {
+ color: theme.palette.error.light,
+ opacity: 1,
+ },
+
+ "&.Mui-disabled input::placeholder, &.Mui-disabled input::-webkit-input-placeholder, &.Mui-disabled input::-moz-placeholder":
+ {
+ color: theme.palette.text.disabled,
+ opacity: 1,
+ },
+ }),
+ },
+ },
+
+ MuiOutlinedInput: {
+ styleOverrides: {
+ /**
+ * Outlined inputs prioritise state clarity:
+ *
+ * disabled > error > focused > hover > default
+ *
+ * This order avoids a focused or hover style masking validation state.
+ */
+ root: ({
+ ownerState,
+ theme,
+ }: OverrideArgs): CSSObject => {
+ const colour = getIntentFromColourProp(ownerState.color);
+ const p = getIntentPalette(theme, colour);
+
+ return {
+ "& .MuiOutlinedInput-notchedOutline": {
+ borderColor: theme.palette.borders.base,
+ },
+
+ "&:hover:not(.Mui-disabled):not(.Mui-error):not(.Mui-focused) .MuiOutlinedInput-notchedOutline":
+ {
+ borderColor: theme.palette.borders.emphasis,
+ },
+
+ "&.Mui-focused:not(.Mui-disabled):not(.Mui-error) .MuiOutlinedInput-notchedOutline":
+ {
+ borderColor: p.light,
+ borderWidth: 2,
+ },
+
+ "&.Mui-focused:hover:not(.Mui-disabled):not(.Mui-error) .MuiOutlinedInput-notchedOutline":
+ {
+ borderColor: p.light,
+ borderWidth: 2,
+ },
+
+ "&.Mui-error .MuiOutlinedInput-notchedOutline": {
+ borderColor: theme.palette.error.light,
+ },
+
+ "&.Mui-error:hover:not(.Mui-disabled):not(.Mui-focused) .MuiOutlinedInput-notchedOutline":
+ {
+ borderColor: theme.palette.error.light,
+ },
+
+ "&.Mui-error.Mui-focused .MuiOutlinedInput-notchedOutline": {
+ borderColor: theme.palette.error.light,
+ borderWidth: 2,
+ },
+
+ "&.Mui-focusVisible": {
+ outline: "var(--ds-focus-ring-width) solid var(--ds-focus-ring)",
+ outlineOffset: "var(--ds-focus-ring-offset)",
+ },
+
+ "&.Mui-disabled .MuiOutlinedInput-notchedOutline": {
+ borderColor: "var(--ds-border-subtle)",
+ },
+ };
+ },
+ },
+ },
+
+ MuiInputLabel: {
+ styleOverrides: {
+ root: ({ theme }: ThemeOnlyArgs): CSSObject => ({
+ "&:not(.MuiInputLabel-shrink)": {
+ color: theme.palette.text.secondary,
+ },
+
+ "&.Mui-disabled:not(.MuiInputLabel-shrink)": {
+ color: theme.palette.text.disabled,
+ },
+
+ "&.Mui-focused": {
+ color: theme.palette.primary.main,
+ },
+
+ "&.Mui-focused.MuiFormLabel-colorSecondary": {
+ color: theme.palette.secondary.main,
+ },
+
+ "&.Mui-focused.MuiFormLabel-colorSuccess": {
+ color: theme.palette.success.main,
+ },
+
+ "&.Mui-focused.MuiFormLabel-colorWarning": {
+ color: theme.palette.warning.main,
+ },
+
+ "&.Mui-focused.MuiFormLabel-colorError": {
+ color: theme.palette.error.main,
+ },
+
+ "&.Mui-focused.MuiFormLabel-colorInfo": {
+ color: theme.palette.info.main,
+ },
+
+ "&.Mui-focused.Mui-error": {
+ color: theme.palette.error.main,
+ },
+
+ "&.Mui-disabled": {
+ color: theme.palette.text.disabled,
+ },
+ }),
+ },
+ },
+
+ MuiTab: {
+ styleOverrides: {
+ root: ({ theme }: OverrideArgs): CSSObject => ({
+ textTransform: "none",
+ color: theme.palette.text.secondary,
+ fontWeight: 500,
+ minHeight: 44,
+
+ "&:hover": {
+ color: theme.palette.text.primary,
+ boxShadow: getOverlayInset(),
+ },
+
+ "&.Mui-selected": {
+ color: theme.palette.primary.main,
+ fontWeight: 600,
+ },
+
+ "&.Mui-disabled": {
+ color: theme.palette.text.disabled,
+ },
+
+ "&.Mui-focusVisible, &:focus-visible": {
+ outline: "var(--ds-focus-ring-width) solid var(--ds-focus-ring)",
+ outlineOffset: "-2px",
+ },
+ }),
+ },
+ },
+
+ MuiAlert: {
+ /**
+ * Alerts use status intents only. Filled alerts use solid/onSolid; standard and
+ * outlined alerts use container/onContainer.
+ */
+ styleOverrides: {
+ root: ({ ownerState, theme }: OverrideArgs): CSSObject => {
+ const severity = getIntentFromColourProp(
+ ownerState.severity,
+ "success",
+ );
+ const p = getIntentPalette(theme, severity);
+
+ const common: CSSObject = {
+ borderRadius: 8,
+ alignItems: "flex-start",
+
+ "& .MuiAlert-icon": {
+ color: "currentColor",
+ opacity: 1,
+ },
+
+ "& .MuiAlert-action": {
+ color: "inherit",
+
+ "& .MuiIconButton-root:hover": {
+ boxShadow: getOverlayInset(),
+ },
+ },
+ };
+
+ if (ownerState.variant === "filled") {
+ return {
+ ...common,
+ backgroundColor: p.solid,
+ color: p.onSolid,
+ };
+ }
+
+ if (ownerState.variant === "outlined") {
+ return {
+ ...common,
+ backgroundColor: p.container,
+ color: p.onContainer,
+ border: `1px solid ${p.light}`,
+ };
+ }
+
+ return {
+ ...common,
+ backgroundColor: p.container,
+ color: p.onContainer,
+ border: "1px solid var(--ds-border-subtle)",
+ };
+ },
+ },
+ },
+
+ /**
+ * Progress indicators use intent `main` as an activity signal, not a filled
+ * surface. This keeps them visually lighter than buttons or alerts.
+ */
+ MuiLinearProgress: {
+ styleOverrides: {
+ root: {
+ height: 6,
+ borderRadius: 999,
+ overflow: "hidden",
+ backgroundColor: "var(--ds-surface-container-high)",
+ },
+
+ bar: ({
+ ownerState,
+ theme,
+ }: OverrideArgs): CSSObject => {
+ const colour = getIntentFromColourProp(ownerState.color);
+ const p = getIntentPalette(theme, colour);
+
+ return {
+ backgroundColor: p.main,
+ };
+ },
+ },
+ },
+
+ MuiCircularProgress: {
+ styleOverrides: {
+ root: ({
+ ownerState,
+ theme,
+ }: OverrideArgs): CSSObject => {
+ const colour = getIntentFromColourProp(ownerState.color);
+ const p = getIntentPalette(theme, colour);
+
+ return {
+ color: p.main,
+ };
+ },
+ },
+ },
+
+ MuiSkeleton: {
+ styleOverrides: {
+ root: {
+ backgroundColor: "var(--ds-surface-container-high)",
+ },
+
+ wave: {
+ backgroundColor: "var(--ds-surface-container-high)",
+ position: "relative",
+ overflow: "hidden",
+
+ "&::after": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ transform: "translateX(-100%)",
+ backgroundImage:
+ "linear-gradient(90deg, transparent, var(--ds-overlay-hover), transparent)",
+ },
+ },
+ },
+ },
+
+ MuiSnackbar: {
+ styleOverrides: {
+ root: {
+ "& .MuiSnackbarContent-root, & .MuiAlert-root": {
+ minWidth: 320,
+ maxWidth: 560,
+ },
+ },
+ },
+ },
+
+ MuiSnackbarContent: {
+ styleOverrides: {
+ root: {
+ backgroundColor: "var(--ds-surface-container)",
+ color: "var(--ds-on-surface)",
+ border: "1px solid var(--ds-border-subtle)",
+ borderRadius: 8,
+ },
+
+ message: {
+ padding: "8px 0",
+ },
+
+ action: {
+ color: "inherit",
+
+ "& .MuiIconButton-root:hover": {
+ boxShadow: getOverlayInset(),
+ },
+ },
+ },
+ },
+
+ MuiCheckbox: {
+ defaultProps: {
+ disableRipple: true,
+ },
+ styleOverrides: {
+ root: ({
+ ownerState,
+ theme,
+ }: OverrideArgs): CSSObject => {
+ const rawColour = ownerState.color ?? "primary";
+ const isDefault = rawColour === "default";
+ const colour = getIntentFromColourProp(rawColour);
+
+ const p = !isDefault ? getIntentPalette(theme, colour) : null;
+
+ return {
+ color: "var(--ds-on-surface-variant)",
+ borderRadius: 8,
+
+ "&:hover": {
+ backgroundColor: "var(--ds-overlay-hover)",
+ },
+
+ ...getFocusOutline(),
+
+ "&.Mui-checked": {
+ color: isDefault ? "var(--ds-on-surface)" : p?.main,
+ },
+
+ "&.MuiCheckbox-indeterminate": {
+ color: isDefault ? "var(--ds-on-surface)" : p?.main,
+ },
+
+ "&.Mui-disabled": {
+ color: "var(--ds-action-disabled)",
+ },
+ };
+ },
+ },
+ },
+
+ MuiRadio: {
+ defaultProps: {
+ disableRipple: true,
+ },
+ styleOverrides: {
+ root: ({ ownerState, theme }: OverrideArgs): CSSObject => {
+ const rawColour = ownerState.color ?? "primary";
+ const isDefault = rawColour === "default";
+ const colour = getIntentFromColourProp(rawColour);
+
+ const p = !isDefault ? getIntentPalette(theme, colour) : null;
+
+ return {
+ color: "var(--ds-on-surface-variant)",
+ borderRadius: "50%",
+
+ "&:hover": {
+ backgroundColor: "var(--ds-overlay-hover)",
+ },
+
+ ...getFocusOutline(),
+
+ "&.Mui-checked": {
+ color: isDefault ? "var(--ds-on-surface)" : p?.main,
+ },
+
+ "&.Mui-disabled": {
+ color: "var(--ds-action-disabled)",
+ },
+ };
+ },
+ },
+ },
+ },
+});
+
+/**
+ * Backwards-compatible factory for older call sites.
+ *
+ * Mode is now controlled through MUI colour schemes and `html[data-mode]`, so
+ * the same theme object is returned for both modes.
+ */
+export const createDiamondTheme = (_mode?: DSMode): Theme =>
+ DiamondDSTheme as Theme;
+
+/**
+ * Pre-built theme for convenience.
+ */
+export { DiamondDSTheme };
+
+/**
+ * Backwards compatibility aliases. Prefer `DiamondDSTheme` for new code.
+ */
+export const DiamondDSThemeDark = DiamondDSTheme;
+export const createMuiTheme = createDiamondTheme;
diff --git a/src/themes/Theme.test.tsx b/src/themes/Theme.test.tsx
new file mode 100644
index 00000000..1bd1bc6c
--- /dev/null
+++ b/src/themes/Theme.test.tsx
@@ -0,0 +1,42 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { it, expect } from "vitest";
+import { ThemeProvider, useColorScheme } from "@mui/material/styles";
+import { useEffect } from "react";
+
+import { DiamondDSTheme } from "./DiamondDSTheme";
+
+export function TestComponent({ set }: { set: "dark" | "light" }) {
+ const { mode, setMode } = useColorScheme();
+
+ useEffect(() => {
+ setMode(set);
+ }, [set, setMode]);
+
+ return {mode}
;
+}
+
+it("switches to dark mode", async () => {
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mode").textContent).toBe("dark");
+ expect(document.documentElement.getAttribute("data-mode")).toBe("dark");
+ });
+});
+
+it("switches to light mode", async () => {
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("mode").textContent).toBe("light");
+ expect(document.documentElement.getAttribute("data-mode")).toBe("light");
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index beb704da..bdf6a6a5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "esnext",
- "lib": [
- "dom",
- "dom.iterable",
- "esnext"
- ],
+ "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@@ -22,14 +18,8 @@
"emitDeclarationOnly": true,
"jsx": "react-jsx",
"baseUrl": "src",
- "types": [
- "vitest/globals",
- "@testing-library/jest-dom"
- ]
+ "types": ["vitest/globals", "@testing-library/jest-dom"]
},
- "include": [
- "src",
- "src/types"
- ],
+ "include": ["src", "src/types"],
"rootDir": "src"
-}
\ No newline at end of file
+}