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 + + + +## Do + +### Interactive elements + + + +### Keyboard navigation + + + +

+ For composite components (e.g. menus, dialogs, tables), manage focus + intentionally: +

+ + + +### Icons and icon-only actions + + + +

+ 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 + + + +### Content and layout + + + +## Don’t + + + +## Storybook guidance + + + +## Quick sense check + + + +
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:

+ + + +

+ 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. +

+ + + +

+ 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.

+ + + +

+ 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:

+ + + +

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.

+ + + +

When using these colours:

+ + + +### 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 + + + +## Common pitfalls + + + +## When WCAG and APCA disagree + + + +## 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: +

+ + + +

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 + + + +### Reduce memory burden + + + +### Help users maintain focus + + + +### Help users avoid and recover from errors + + + +### Design for long-running workflows + + + +## Applying this in Storybook + +When reviewing components and patterns, ask: + + + +## 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 +}