diff --git a/.github/workflows/deploy-storybook.yaml b/.github/workflows/deploy-storybook.yaml
index 5ae937fc..cda7c81b 100644
--- a/.github/workflows/deploy-storybook.yaml
+++ b/.github/workflows/deploy-storybook.yaml
@@ -25,10 +25,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
- with:
- run_install: |
- args: [ --force ]
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
- name: Set Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
@@ -36,6 +34,9 @@ jobs:
node-version: ${{ matrix.node-version }}
cache: pnpm
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
- name: Build and publish
id: build-publish
uses: bitovi/github-actions-storybook-to-github-pages@v1.0.3
diff --git a/.github/workflows/npm-publish-pre-release.yaml b/.github/workflows/npm-publish-pre-release.yaml
index ca76aefb..e71cc402 100644
--- a/.github/workflows/npm-publish-pre-release.yaml
+++ b/.github/workflows/npm-publish-pre-release.yaml
@@ -19,9 +19,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
- with:
- run_install: |
- args: [ --force ]
- name: Setup node
uses: actions/setup-node@v4
diff --git a/.github/workflows/npm-publish.yaml b/.github/workflows/npm-publish.yaml
index 478cb2b3..e9cb93ba 100644
--- a/.github/workflows/npm-publish.yaml
+++ b/.github/workflows/npm-publish.yaml
@@ -20,9 +20,6 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
- with:
- run_install: |
- args: [ --force ]
- name: Setup node
uses: actions/setup-node@v4
diff --git a/.github/workflows/test-build.yaml b/.github/workflows/test-build.yaml
index afe814d2..48e9a519 100644
--- a/.github/workflows/test-build.yaml
+++ b/.github/workflows/test-build.yaml
@@ -20,17 +20,16 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Install dependencies
+ - name: Setup pnpm
uses: pnpm/action-setup@v4
- with:
- run_install: |
- args: [ --force ]
- name: Set Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
- name: Audit packages, run Typescript tests and lint client code
run: |
diff --git a/.npmrc b/.npmrc
index c14db4af..390b0c46 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,3 +1,2 @@
publish-branch=main
access=public
-ignore-scripts=true
\ No newline at end of file
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..f5c6190d 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,13 @@ const preview: Preview = {
storySort: {
order: [
"Introduction",
- "Components",
+ "Helpers",
"Theme",
"Theme/Logos",
"Theme/Colours",
- "Helpers",
+ "Accessibility",
+ "MUI",
+ "Components",
],
},
},
diff --git a/changelog.md b/changelog.md
index d26432ce..3797b903 100644
--- a/changelog.md
+++ b/changelog.md
@@ -2,6 +2,9 @@
## [Unreleased]
+### Changed
+- **Breaking** `keycloak-js` has been moved from a direct dependency to a peer and optional dependency, so must now be installed by the consuming application.
+
### Fixed
- Icon imports were causing issues downstream when components are unit tested.
diff --git a/package.json b/package.json
index 52c035e5..ccdfc14c 100644
--- a/package.json
+++ b/package.json
@@ -49,10 +49,9 @@
"storybook:publish": "gh-pages -b storybook/publish -d storybook-static"
},
"dependencies": {
- "keycloak-js": "^26.2.1",
+ "@mui/icons-material": "^7.0.0",
"react-icons": "^5.3.0",
- "utif": "^3.1.0",
- "@mui/icons-material": "^7.0.0"
+ "utif": "^3.1.0"
},
"peerDependencies": {
"@emotion/react": "^11.13.3",
@@ -61,7 +60,7 @@
"@jsonforms/material-renderers": "^3.7.0",
"@jsonforms/react": "^3.7.0",
"@mui/material": "^7.0.0",
- "@mui/icons-material": "^7.0.0",
+ "keycloak-js": "^26.2.1",
"react": "^18.3.1"
},
"devDependencies": {
@@ -71,6 +70,7 @@
"@babel/preset-typescript": "^7.26.10",
"@chromatic-com/storybook": "^3.2.2",
"@eslint/eslintrc": "^3.2.0",
+ "@eslint/js": "^10.0.1",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-image": "^3.0.3",
"@rollup/plugin-json": "^6.1.0",
@@ -118,8 +118,12 @@
"typescript-eslint": "^8.15.0",
"vitest": "^3.2.4"
},
+ "optionalDependencies": {
+ "keycloak-js": "^26.2.1"
+ },
"pnpm": {
"overrides": {
+ "fast-uri": "^3.1.2",
"lodash": "^4.18.1",
"qs@>=6.13.0 <6.14.0": "6.14.1",
"js-yaml@^4.1.0": "4.1.1",
@@ -127,8 +131,9 @@
"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"
+ "packageManager": "pnpm@10.26.0"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 75bba6b4..fd202d16 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5,6 +5,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
+ fast-uri: ^3.1.2
lodash: ^4.18.1
qs@>=6.13.0 <6.14.0: 6.14.1
js-yaml@^4.1.0: 4.1.1
@@ -13,6 +14,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:
@@ -29,7 +31,7 @@ importers:
version: 3.7.0
'@jsonforms/material-renderers':
specifier: ^3.7.0
- version: 3.7.0(tycpmb7mlqgjusrbuymfwpcqdy)
+ version: 3.7.0(408a1c23dbeeab334b849b456cede2fb)
'@jsonforms/react':
specifier: ^3.7.0
version: 3.7.0(@jsonforms/core@3.7.0)(react@18.3.1)
@@ -39,9 +41,6 @@ importers:
'@mui/material':
specifier: ^7.0.0
version: 7.3.10(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- keycloak-js:
- specifier: ^26.2.1
- version: 26.2.1
react:
specifier: ^18.3.1
version: 18.3.1
@@ -70,6 +69,9 @@ importers:
'@eslint/eslintrc':
specifier: ^3.2.0
version: 3.2.0
+ '@eslint/js':
+ specifier: ^10.0.1
+ version: 10.0.1(eslint@9.39.4)
'@rollup/plugin-commonjs':
specifier: ^28.0.1
version: 28.0.2(rollup@4.30.0)
@@ -208,6 +210,10 @@ importers:
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/node@20.19.21)(jsdom@20.0.3)(terser@5.37.0)(yaml@2.8.0)
+ optionalDependencies:
+ keycloak-js:
+ specifier: ^26.2.1
+ version: 26.2.1
packages:
@@ -1237,6 +1243,15 @@ packages:
resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/js@10.0.1':
+ resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ peerDependencies:
+ eslint: ^10.0.0
+ peerDependenciesMeta:
+ eslint:
+ optional: true
+
'@eslint/js@9.39.4':
resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1670,106 +1685,127 @@ packages:
resolution: {integrity: sha512-bsPGGzfiHXMhQGuFGpmo2PyTwcrh2otL6ycSZAFTESviUoBOuxF7iBbAL5IJXc/69peXl5rAtbewBFeASZ9O0g==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-gnueabihf@4.52.4':
resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.30.0':
resolution: {integrity: sha512-kvyIECEhs2DrrdfQf++maCWJIQ974EI4txlz1nNSBaCdtf7i5Xf1AQCEJWOC5rEBisdaMFFnOWNLYt7KpFqy5A==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm-musleabihf@4.52.4':
resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.30.0':
resolution: {integrity: sha512-CFE7zDNrokaotXu+shwIrmWrFxllg79vciH4E/zeK7NitVuWEaXRzS0mFfFvyhZfn8WfVOG/1E9u8/DFEgK7WQ==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-gnu@4.52.4':
resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.30.0':
resolution: {integrity: sha512-MctNTBlvMcIBP0t8lV/NXiUwFg9oK5F79CxLU+a3xgrdJjfBLVIEHSAjQ9+ipofN2GKaMLnFFXLltg1HEEPaGQ==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-musl@4.52.4':
resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.4':
resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-loongarch64-gnu@4.30.0':
resolution: {integrity: sha512-fBpoYwLEPivL3q368+gwn4qnYnr7GVwM6NnMo8rJ4wb0p/Y5lg88vQRRP077gf+tc25akuqd+1Sxbn9meODhwA==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-powerpc64le-gnu@4.30.0':
resolution: {integrity: sha512-1hiHPV6dUaqIMXrIjN+vgJqtfkLpqHS1Xsg0oUfUVD98xGp1wX89PIXgDF2DWra1nxAd8dfE0Dk59MyeKaBVAw==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.4':
resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.30.0':
resolution: {integrity: sha512-U0xcC80SMpEbvvLw92emHrNjlS3OXjAM0aVzlWfar6PR0ODWCTQtKeeB+tlAPGfZQXicv1SpWwRz9Hyzq3Jx3g==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.4':
resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.4':
resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.30.0':
resolution: {integrity: sha512-VU/P/IODrNPasgZDLIFJmMiLGez+BN11DQWfTVlViJVabyF3JaeaJkP6teI8760f18BMGCQOW9gOmuzFaI1pUw==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-s390x-gnu@4.52.4':
resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.30.0':
resolution: {integrity: sha512-laQVRvdbKmjXuFA3ZiZj7+U24FcmoPlXEi2OyLfbpY2MW1oxLt9Au8q9eHd0x6Pw/Kw4oe9gwVXWwIf2PVqblg==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.4':
resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.30.0':
resolution: {integrity: sha512-3wzKzduS7jzxqcOvy/ocU/gMR3/QrHEFLge5CD7Si9fyHuoXcidyYZ6jyx8OPYmCcGm3uKTUl+9jUSAY74Ln5A==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-x64-musl@4.52.4':
resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.4':
resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==}
@@ -2078,24 +2114,28 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@swc/core-linux-arm64-musl@1.10.4':
resolution: {integrity: sha512-cDDj2/uYsOH0pgAnDkovLZvKJpFmBMyXkxEG6Q4yw99HbzO6QzZ5HDGWGWVq/6dLgYKlnnmpjZCPPQIu01mXEg==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@swc/core-linux-x64-gnu@1.10.4':
resolution: {integrity: sha512-qJXh9D6Kf5xSdGWPINpLGixAbB5JX8JcbEJpRamhlDBoOcQC79dYfOMEIxWPhTS1DGLyFakAx2FX/b2VmQmj0g==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@swc/core-linux-x64-musl@1.10.4':
resolution: {integrity: sha512-A76lIAeyQnHCVt0RL/pG+0er8Qk9+acGJqSZOZm67Ve3B0oqMd871kPtaHBM0BW3OZAhoILgfHW3Op9Q3mx3Cw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@swc/core-win32-arm64-msvc@1.10.4':
resolution: {integrity: sha512-e6j5kBu4fIY7fFxFxnZI0MlEovRvp50Lg59Fw+DVbtqHk3C85dckcy5xKP+UoXeuEmFceauQDczUcGs19SRGSQ==}
@@ -3211,8 +3251,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==}
@@ -6312,6 +6352,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@eslint/js@10.0.1(eslint@9.39.4)':
+ optionalDependencies:
+ eslint: 9.39.4
+
'@eslint/js@9.39.4': {}
'@eslint/object-schema@2.1.7': {}
@@ -6386,7 +6430,7 @@ snapshots:
ajv-formats: 2.1.1(ajv@8.17.1)
lodash: 4.18.1
- '@jsonforms/material-renderers@3.7.0(tycpmb7mlqgjusrbuymfwpcqdy)':
+ '@jsonforms/material-renderers@3.7.0(408a1c23dbeeab334b849b456cede2fb)':
dependencies:
'@date-io/dayjs': 3.2.0(dayjs@1.10.7)
'@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1)
@@ -7629,7 +7673,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 +8579,7 @@ snapshots:
fast-levenshtein@2.0.6: {}
- fast-uri@3.0.5: {}
+ fast-uri@3.1.2: {}
fastq@1.18.0:
dependencies:
@@ -9093,7 +9137,8 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
- keycloak-js@26.2.1: {}
+ keycloak-js@26.2.1:
+ optional: true
keyv@4.5.4:
dependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 00000000..c7cad87a
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+ignoreScripts: true
+minimumReleaseAge: 10080 # 1 week
+trustPolicy: no-downgrade
\ No newline at end of file
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/storybook/accessibility/00-overview.mdx b/src/storybook/accessibility/00-overview.mdx
new file mode 100644
index 00000000..627f44f2
--- /dev/null
+++ b/src/storybook/accessibility/00-overview.mdx
@@ -0,0 +1,198 @@
+import { Meta } from "@storybook/blocks";
+
+
+
+
+
+
+
+# Accessibility
+
+
+ Accessibility (a11y) in the Diamond Design System is a baseline for quality,
+ clarity, and usability in complex scientific tools, not a checklist or a
+ separate mode.
+
+
+
+ Our goal is to make interfaces that are understandable, predictable, and
+ usable across a wide range of abilities, environments, and levels of fatigue.
+
+
+## Standards and approach
+
+
We follow WCAG 2.2 as the baseline for accessibility compliance.
+
+
+ We also use APCA (Advanced Perceptual Contrast Algorithm) to design for
+ perceived readability. Unlike WCAG contrast ratios, APCA accounts for font
+ size, weight, and light/dark conditions.
+
+
+
+ WCAG defines minimum thresholds. APCA is used to ensure interfaces remain
+ readable, comfortable, and usable in real-world conditions.
+
+
+
This reflects the direction of emerging standards such as WCAG 3.
+
+## Core principles
+
+
+ -
+ Clarity over cleverness: interfaces should explain
+ themselves.
+
+ -
+ Predictable behaviour: similar things behave in similar
+ ways.
+
+ -
+ Multiple ways to perceive information: never rely on one
+ signal alone.
+
+ -
+ Low cognitive effort by default: minimise mental overhead.
+
+
+
+## Do
+
+### Interactive elements
+
+
+ - Ensure every interactive control has an accessible name.
+ -
+ Provide this via a visible label,
aria-label, or{" "}
+ aria-labelledby.
+
+ - Make keyboard focus clearly visible at all times.
+ -
+ Use a consistent focus indicator (outline) rather than transient effects
+ such as ripples.
+
+ -
+ Ensure disabled states are clearly distinguishable from enabled and default.
+
+
+
+### Keyboard navigation
+
+
+ -
+ All interactive elements must be reachable and usable using a keyboard
+ alone.
+
+ - Follow a logical tab order that matches the visual and reading order.
+ -
+ Do not trap focus. Users must be able to move in and out of components using
+ standard keyboard interactions.
+
+ -
+ Ensure focus is always visible and moves predictably between elements.
+
+ -
+ Support standard keyboard interactions (e.g. Enter or Space to activate
+ controls).
+
+
+
+
+ For composite components (e.g. menus, dialogs, tables), manage focus
+ intentionally:
+
+
+
+ - Move focus into the component when it opens.
+ - Keep focus within the component while it is active.
+ - Return focus to the triggering element when it closes.
+
+
+### Icons and icon-only actions
+
+
+ -
+ Icon-only controls must include an accessible name that describes the action
+ (e.g. “Delete sample”), using
aria-label or{" "}
+ aria-labelledby.
+
+ -
+ Tooltips support discoverability, but do not replace accessible names.
+
+ -
+ Decorative icons should be hidden from assistive technologies using{" "}
+
aria-hidden="true".
+
+ - Do not rely on an icon alone to communicate critical meaning.
+ -
+ If an icon represents a toggle or state, expose the state using appropriate
+ ARIA attributes (e.g.
aria-pressed).
+
+
+
+
+ For interactive icons, ensure both an accessible name and a visible affordance
+ such as a tooltip or label.
+
+
+
+ In MUI, use aria-label on IconButton. Tooltips (e.g.
+ <Tooltip />) should be used in addition to, not instead of,
+ accessible labelling.
+
+
+### Forms and inputs
+
+
+ - Always associate inputs with labels (even if visually hidden).
+ -
+ Do not show validation errors while a user is actively typing. Validate on
+ blur or submission.
+
+ - Make error messages clear, specific, and actionable.
+ - Associate errors programmatically with the relevant field.
+
+
+### Content and layout
+
+
+ - Do not rely on colour alone to convey meaning.
+ - Do not rely on icons alone to convey critical meaning.
+ - Use a logical, predictable reading order.
+
+
+## Don’t
+
+
+ - Don’t use placeholder text as a replacement for labels.
+ - Don’t hide important information in faint or decorative text.
+ - Don’t introduce unexpected interaction patterns.
+ - Don’t create dense, unbroken blocks of content.
+
+
+## Storybook guidance
+
+
+ -
+ Simple component stories may be visually minimal, but should still expose an
+ accessible name, or be documented as intentionally incomplete.
+
+ -
+ Accessibility warnings in Storybook are signals, not noise. Either resolve
+ them or document why they exist.
+
+
+
+## Quick sense check
+
+
+ - Can this be used with a keyboard only?
+ - Is its purpose clear without colour or icons?
+ - Would it still make sense when someone is tired or distracted?
+
+
+
diff --git a/src/storybook/accessibility/01-colour-contrast.mdx b/src/storybook/accessibility/01-colour-contrast.mdx
new file mode 100644
index 00000000..b3fc37aa
--- /dev/null
+++ b/src/storybook/accessibility/01-colour-contrast.mdx
@@ -0,0 +1,561 @@
+import { Meta } from "@storybook/blocks";
+
+
+
+
+
+
+
+# Colour contrast
+
+
+ Colour contrast directly affects readability, speed, and error rate. In dense,
+ data-heavy scientific interfaces, poor contrast increases cognitive load and
+ slows decision-making.
+
+
+
+ We prioritise perceptual contrast using APCA when defining colour choices,
+ while maintaining WCAG 2.2 as the compliance baseline.
+
+
+## Why contrast needs a modern approach
+
+
+ Traditional WCAG 2.x contrast ratios are useful, but they are based on
+ luminance ratios rather than perceived readability. In practice, some
+ combinations can technically pass while still feel strained or unstable in
+ real UI.
+
+
+
+ This becomes more visible across different font sizes, weights, and light or
+ dark environments.
+
+
+
This leads to common issues:
+
+
+ - Combinations that pass but still feel hard to read.
+ - Overly strong contrast that causes glare or eye strain in dark mode.
+ -
+ Poor readability with saturated colours such as success, warning, and
+ danger.
+
+
+
+
+ APCA helps address these limitations by modelling perceived readability rather
+ than relying only on raw luminance difference.
+
+
+## APCA contrast (primary)
+
+
+ APCA reflects how contrast is actually experienced by users more closely than
+ older ratio-based methods.
+
+
+
+ - Accounts for font size, weight, and polarity.
+ - Handles light-on-dark and dark-on-light differently.
+ - Produces more reliable results for real UI text.
+ - Helps reduce strain in long-running workflows.
+
+
+
+ We use APCA to tune token values, component defaults, and interaction states.
+
+
+## WCAG 2.2 contrast (baseline)
+
+
WCAG 2.2 remains the current compliance baseline for text contrast.
+
+
+ - Defines minimum acceptable contrast thresholds.
+ - Used as a strict floor we do not go below.
+ - Required for accessibility reviews and audits.
+
+
+
+ WCAG 2.2 is necessary, but on its own it is not enough to judge readability
+ quality.
+
+
+## How we use them together
+
+
Contrast decisions follow a clear order:
+
+
+ -
+ 1. Design using APCA for perceptual readability.
+
+ -
+ 2. Validate against WCAG 2.2 for compliance.
+
+ -
+ 3. Adjust only if required, without degrading usability.
+
+
+
+
This helps keep interfaces both compliant and genuinely usable.
+
+## Colour-specific considerations
+
+
+ Some hues behave less predictably for perceived contrast, even when WCAG 2.x
+ ratios look acceptable.
+
+
+
+ In DiamondDS this matters most for status colours: --ds-success,
+ --ds-warning, and --ds-danger.
+
+
+
+ A common issue in real interfaces is that black text on a saturated colour may
+ look mathematically strong, but visually feel unstable, vibrating, or tiring,
+ especially for smaller or denser text.
+
+
+
APCA is better at exposing these perception problems.
+
+
+ -
+ Success: can feel muddy or uneven with dark text,
+ especially in small labels.
+
+ -
+ Warning: often looks shallower than expected, reducing
+ clarity on both light and dark surfaces.
+
+ -
+ Danger: more prone to vibration and reduced legibility,
+ particularly in error messaging.
+
+
+
+
When using these colours:
+
+
+ - Use APCA to judge readability, not just WCAG 2.2 ratios.
+ - Test across sizes, weights, and both themes.
+ - Always pair colour with text, icons, or labels.
+
+
+### WCAG 2.2 vs APCA: why passes can still fail users
+
+
+ WCAG 2.2 is a useful baseline, but it does not always predict comfort or
+ readability in real interfaces. APCA tracks perceived readability more closely.
+
+
+
+
+
+
Light
+
+
+
Success
+
+ --ds-success
+ #1B8834
+
+
+
+
WCAG 2.2 ✅
+
+ Experiment complete · 12 files processed
+
+
+
+
APCA ✅
+
+ Experiment complete · 12 files processed
+
+
+
+
+
+
+
Warning
+
+ --ds-warning
+ #e97b12
+
+
+
+
WCAG 2.2
+
+ Beamline temperature near limit
+
+
+
+
APCA ✅
+
+ Beamline temperature near limit
+
+
+
+
+
+
+
Danger
+
+ --ds-danger
+ #d63c41
+
+
+
+
WCAG 2.2 ✅
+
+ Connection failed · retry required
+
+
+
+
APCA ✅
+
+ Connection failed · retry required
+
+
+
+
+
+
+
+
Dark
+
+
+
Success
+
+ --ds-success
+ #23913C
+
+
+
+
WCAG 2.2
+
+ Experiment complete · 12 files processed
+
+
+
+
APCA ✅
+
+ Experiment complete · 12 files processed
+
+
+
+
+
+
+
Warning
+
+ --ds-warning
+ #f07a13
+
+
+
+
WCAG 2.2
+
+ Beamline temperature near limit
+
+
+
+
APCA ✅
+
+ Beamline temperature near limit
+
+
+
+
+
+
+
Danger
+
+ --ds-danger
+ #d63c41
+
+
+
+
WCAG 2.2 ✅
+
+ Connection failed · retry required
+
+
+
+
APCA ✅
+
+ Connection failed · retry required
+
+
+
+
+
+
+
+
+
How to use this
+
+ - Use WCAG 2.2 as the baseline check, especially for smaller text.
+ -
+ Use APCA to validate perceived readability and comfort, particularly for
+ saturated status colours.
+
+ - Always pair colour with icons or text. Never rely on colour alone.
+
+
+
+
+
+## Practical guidance
+
+
+ -
+ Body text: optimise for perceptual readability first, then
+ confirm WCAG 2.2 compliance.
+
+ -
+ UI chrome: maintain clear separation and predictable
+ hierarchy through surface and border roles.
+
+ -
+ Subtle text: only use for non-essential information.
+
+ -
+ Disabled states: must remain distinguishable without
+ becoming unreadable.
+
+
+
+## Common pitfalls
+
+
+ - Relying on WCAG ratios alone to judge readability.
+ -
+ Reducing contrast for visual subtlety where meaning is still required.
+
+ - Using saturated colours without perceptual validation.
+ - Ignoring polarity differences between light and dark mode.
+
+
+## When WCAG and APCA disagree
+
+
+ - If WCAG 2.2 fails, the colour must change.
+ -
+ If WCAG 2.2 passes but APCA still indicates poor readability, improve the
+ colour choice rather than treating compliance as enough.
+
+ -
+ In critical workflows, readability and accuracy take precedence over visual
+ subtlety.
+
+
+
+## Future: WCAG 3 and APCA
+
+
+ WCAG 3 is still evolving, but perceptual contrast models such as APCA are
+ influencing the direction of accessibility guidance.
+
+
+
+ Our approach reflects that direction while staying grounded in current
+ compliance requirements:
+
+
+
+ - Use perceptual contrast thinking to design and tune colour choices.
+ - Validate text contrast against WCAG 2.2 today.
+ -
+ Reduce future rework by avoiding colour decisions that are only
+ mathematically compliant.
+
+
+
+
Learn more:
+
+
+
+
diff --git a/src/storybook/accessibility/02-cognitive-a11y.mdx b/src/storybook/accessibility/02-cognitive-a11y.mdx
new file mode 100644
index 00000000..c75a0720
--- /dev/null
+++ b/src/storybook/accessibility/02-cognitive-a11y.mdx
@@ -0,0 +1,149 @@
+import { Meta } from "@storybook/blocks";
+
+
+
+
+
+
+
+# Cognitive accessibility and usability
+
+
+ Accessibility is not only about vision, hearing, or motor input. People also
+ differ in how they process information, maintain focus, recover from errors,
+ and work under pressure or fatigue.
+
+
+
+ In scientific environments, these differences matter. Users may work for long
+ periods, switch between multiple tools and data sources, or operate systems
+ during live experiments where mistakes can be costly.
+
+
+
+ This page focuses on cognitive accessibility and practical usability within
+ complex scientific software.
+
+
+## Designing for cognitive accessibility
+
+
+ Good accessibility and usability often come from the same decisions:
+ interfaces that are clear, predictable, consistent, and forgiving.
+
+
+
+ The goal is not to simplify scientific work itself, but to reduce unnecessary
+ cognitive load created by the interface.
+
+
+## Design objectives
+
+### Help users understand what things are
+
+
+ - Use familiar patterns, terminology, and behaviours.
+ - Avoid inventing new interaction models without strong justification.
+ - Make purpose and state visible at a glance.
+ - Ensure actions and outcomes feel predictable.
+
+
+### Reduce memory burden
+
+
+ - Do not rely on users remembering hidden rules or previous states.
+ - Keep important information visible where possible.
+ - Use clear labels, grouping, and hierarchy.
+ - Support recognition over recall.
+
+
+### Help users maintain focus
+
+
+ - Avoid unnecessary distractions or competing emphasis.
+ - Use layout and spacing to separate concerns clearly.
+ - Keep navigation and interaction patterns consistent.
+ - Help users re-orient when context is lost.
+
+
+### Help users avoid and recover from errors
+
+
+ - Prevent errors where possible rather than reacting to them later.
+ - Make system status and consequences visible before actions occur.
+ - Provide clear, actionable error messages.
+ - Make recovery paths obvious and forgiving.
+
+
+### Design for long-running workflows
+
+
+ - Assume users may be tired, interrupted, or multitasking.
+ - Reduce ambiguity in high-pressure or operational situations.
+ - Keep interfaces calm, stable, and predictable.
+ - Design interactions that still make sense at the end of a long day.
+
+
+## Applying this in Storybook
+
+When reviewing components and patterns, ask:
+
+
+ - Is the interface understandable without prior knowledge?
+ - Does it rely on memory or hidden behaviour?
+ - Is important state clearly visible?
+ - Is error recovery understandable and forgiving?
+ - Would this remain clear during a long experiment session?
+
+
+## Further reading
+
+
+
+
diff --git a/src/storybook/helpers/Auth.mdx b/src/storybook/helpers/Auth.mdx
index 7870e118..8e0db87e 100644
--- a/src/storybook/helpers/Auth.mdx
+++ b/src/storybook/helpers/Auth.mdx
@@ -12,6 +12,15 @@ import {useAuth} from "../../components/systems/auth";
This component is based on the official Keycloak.js adapter.
More info can be found here: www.keycloak.org/securing-apps/javascript-adapter
+ The auth component relies on keycloak-js. Although it is a peer and optional dependency of SciReactUI, you must install it separately with a compatible version (e.g. ^26.2.1).
+
+ ```sh
+ "One of:"
+ - pnpm add keycloak-js
+ - npm i keycloak-js
+ - yarn add keycloak-js
+ ```
+
## Basic setup
First place the provider around your app:
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
+}