From b6f8c78fae2fe94b1e7970d160bdb4aa30e87851 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Fri, 8 May 2026 08:52:12 +0000 Subject: [PATCH 01/12] Introduce DiamondDS theme foundation and component updates Introduced the initial Diamond Design System theme foundation, including semantic colour roles, light/dark mode support, CSS variable mappings, and integration with the MUI theming architecture. Updated component styling, states, and overrides across inputs, buttons, checkboxes, radio buttons, overlays, borders, and theme icons. Fixed issues Prettify --- .storybook/ThemeSwapper.tsx | 8 +- .storybook/preview.tsx | 72 +- src/index.ts | 2 +- src/styles/diamondDS/diamond-ds-roles.css | 430 ++++++++ src/themes/DiamondDSTheme.ts | 1122 +++++++++++++++++++++ src/themes/ThemeProvider.tsx | 29 +- tsconfig.json | 18 +- 7 files changed, 1633 insertions(+), 48 deletions(-) create mode 100644 src/styles/diamondDS/diamond-ds-roles.css create mode 100644 src/themes/DiamondDSTheme.ts diff --git a/.storybook/ThemeSwapper.tsx b/.storybook/ThemeSwapper.tsx index efd541cc..99634d0f 100644 --- a/.storybook/ThemeSwapper.tsx +++ b/.storybook/ThemeSwapper.tsx @@ -21,12 +21,14 @@ export const TextDark = "Mode: Dark"; const ThemeSwapper = ({ context, children }: ThemeSwapperProps) => { const { mode, setMode } = useColorScheme(); - //if( !mode ) return useEffect(() => { const selectedThemeMode = context.globals.themeMode || TextLight; - setMode(selectedThemeMode == TextLight ? "light" : "dark"); - }, [context.globals.themeMode]); + const nextMode = selectedThemeMode === TextLight ? "light" : "dark"; + + setMode(nextMode); + document.documentElement.setAttribute("data-mode", nextMode); + }, [context.globals.themeMode, setMode]); return (
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 4de2bdc4..821b5212 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,15 +1,53 @@ -import React from "react"; +import React, { useLayoutEffect } from "react"; import { CssBaseline } from "@mui/material"; import type { Preview } from "@storybook/react"; - import { ThemeProvider } from "../src"; -import { GenericTheme, DiamondTheme } from "../src"; - +import { + GenericTheme, + DiamondTheme, + DiamondDSTheme, + DiamondDSThemeDark, +} from "../src"; import { Context, ThemeSwapper, TextLight, TextDark } from "./ThemeSwapper"; const TextThemeBase = "Theme: Generic"; const TextThemeDiamond = "Theme: Diamond"; +const TextThemeDiamondDS = "Theme: DiamondDS"; + +function resolveTheme(selectedTheme: string, mode: "light" | "dark") { + switch (selectedTheme) { + case TextThemeBase: + return GenericTheme; + case TextThemeDiamondDS: + return mode === "dark" ? DiamondDSThemeDark : DiamondDSTheme; + case TextThemeDiamond: + default: + return DiamondTheme; + } +} + +function ApplyModeToPreviewDoc({ + mode, + doc, +}: { + mode: "light" | "dark"; + doc: Document; +}) { + useLayoutEffect(() => { + const root = doc.documentElement; // + root.setAttribute("data-mode", mode); + + // Optional: keep class too if your CSS supports it + root.classList.toggle("dark", mode === "dark"); + root.classList.toggle("light", mode === "light"); + + root.style.colorScheme = mode; + }, [mode, doc]); + + return null; +} + export const decorators = [ (StoriesWithPadding: React.FC) => { return ( @@ -28,12 +66,16 @@ export const decorators = [ (StoriesWithThemeProvider: React.FC, context: Context) => { const selectedTheme = context.globals.theme || TextThemeBase; const selectedThemeMode = context.globals.themeMode || TextLight; + const mode = selectedThemeMode === TextLight ? "light" : "dark"; + // ensure we target the preview iframe document + const doc: Document = context?.canvasElement?.ownerDocument ?? document; return ( + @@ -48,7 +90,7 @@ const preview: Preview = { toolbar: { title: "Theme", icon: "cog", - items: [TextThemeBase, TextThemeDiamond], + items: [TextThemeBase, TextThemeDiamond, TextThemeDiamondDS], dynamicTitle: true, }, }, @@ -63,8 +105,8 @@ const preview: Preview = { }, }, initialGlobals: { - theme: "Theme: Diamond", - themeMode: "Mode: Light", + theme: TextThemeDiamondDS, + themeMode: TextLight, }, parameters: { controls: { @@ -75,18 +117,6 @@ const preview: Preview = { }, backgrounds: { disable: true }, layout: "fullscreen", - options: { - storySort: { - order: [ - "Introduction", - "Components", - "Theme", - "Theme/Logos", - "Theme/Colours", - "Helpers", - ], - }, - }, }, argTypes: { linkComponent: { diff --git a/src/index.ts b/src/index.ts index 9b8dda18..386c9447 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,7 @@ export * from "./components/helpers/jsonforms"; // themes export * from "./themes/BaseTheme"; export * from "./themes/DiamondTheme"; -export * from "./themes/DiamondOldTheme"; +export * from "./themes/DiamondDSTheme"; export * from "./themes/GenericTheme"; export * from "./themes/ThemeProvider"; export * from "./themes/ThemeManager"; diff --git a/src/styles/diamondDS/diamond-ds-roles.css b/src/styles/diamondDS/diamond-ds-roles.css new file mode 100644 index 00000000..ed198f98 --- /dev/null +++ b/src/styles/diamondDS/diamond-ds-roles.css @@ -0,0 +1,430 @@ +:root, .light, +html[data-mode="light"] { + /* Neutral */ + + --ds-background: #F6F6F9; + --ds-background-channel: 246 246 249; + + --ds-surface: #ffffff; + --ds-surface-channel: 255 255 255; + + --ds-surface-container: #eef1f5; + --ds-surface-container-high: #e6e9f0; + --ds-surface-disabled: rgba(0, 0, 0, 0.08); + + --ds-on-surface: #1a1c23; + --ds-on-surface-variant: #5e6473; + --ds-on-surface-disabled: rgba(0, 0, 0, 0.36); + --ds-action-disabled: rgba(0, 0, 0, 0.30); + --ds-on-solid: #ffffff; + + --ds-on-surface-channel: 26 28 35; + --ds-on-surface-variant-channel: 80 85 99; + + --ds-placeholder: #8a90a0; + --ds-placeholder-focus: #505563; + + --ds-border-subtle: #dde1e8; + --ds-border: #bcc2cd; + --ds-border-emphasis: #a5acb8; + + /* Overlay */ + --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-focus: rgba(0, 0, 0, 0.10); + + /* 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) */ + --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; + + --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 */ + --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 */ + --ds-focus-ring: var(--ds-primary-accent); + --ds-focus-ring-width: 2px; + --ds-focus-ring-offset: 2px; + + --ds-focus-ring-primary: var(--ds-primary-accent); + --ds-focus-ring-secondary: var(--ds-secondary-accent); + --ds-focus-ring-danger: var(--ds-danger-accent); + --ds-focus-ring-warning: var(--ds-warning-accent); + --ds-focus-ring-success: var(--ds-success-accent); + --ds-focus-ring-info: var(--ds-info-accent); + --ds-focus-ring-brand: var(--ds-brand-accent); + --ds-focus-ring-highlight: var(--ds-highlight-accent); +} + +.dark, +html[data-mode="dark"] { + /* Neutral */ + + --ds-background: #0e1017; + --ds-background-channel: 14 16 23; + + --ds-surface: #161820; + --ds-surface-channel: 22 24 32; + + --ds-surface-container: #222632; + --ds-surface-container-high: #2c3140; + --ds-surface-disabled: rgba(255, 255, 255, 0.14); + + --ds-on-surface: #e8eaf0; + --ds-on-surface-variant: #b6bcc9; + --ds-on-surface-disabled: rgba(255, 255, 255, 0.36); + --ds-action-disabled: rgba(255, 255, 255, 0.30); + --ds-on-solid: #ffffff; + + --ds-on-surface-channel: 232 234 240; + --ds-on-surface-variant-channel: 182 188 201; + + --ds-placeholder: #7a8191; + --ds-placeholder-focus: #b6bcc9; + + --ds-border-subtle: #3a3f4c; + --ds-border: #505664; + --ds-border-emphasis: #7c8394; + + /* Overlay */ + --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-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 +*/ \ No newline at end of file diff --git a/src/themes/DiamondDSTheme.ts b/src/themes/DiamondDSTheme.ts new file mode 100644 index 00000000..b2bf9516 --- /dev/null +++ b/src/themes/DiamondDSTheme.ts @@ -0,0 +1,1122 @@ +import "../styles/diamondDS/diamond-ds-roles.css"; + +import type {} from "@mui/material/themeCssVarsAugmentation"; +import { createTheme } from "@mui/material/styles"; +import type { CSSObject, Theme } from "@mui/material/styles"; + +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 { mergeThemeOptions } from "./ThemeManager"; + +import logoImageLight from "../public/diamond/logo-light.svg"; +import logoImageDark from "../public/diamond/logo-dark.svg"; +import logoShort from "../public/diamond/logo-short.svg"; + +type OverrideArgs = { + ownerState: OwnerState; + theme: Theme; +}; + +type ThemeOnlyArgs = { + theme: Theme; +}; + +type IntentColour = + | "primary" + | "secondary" + | "error" + | "warning" + | "info" + | "success"; + +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 IntentPaletteRecord = Partial>; + +type ThemeWithIntentPalette = Theme & { + vars?: { + palette?: IntentPaletteRecord; + }; + palette: Theme["palette"] & IntentPaletteRecord; +}; + +declare module "@mui/material/styles" { + 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?: PaletteColor; + borders: { + subtle: string; + base: string; + emphasis: string; + }; + surface: { + subtle: string; + strong: string; + }; + } + + interface PaletteOptions { + brand?: SimplePaletteColorOptions; + 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"; + +// --- Helpers --- + +const getIntentPalette = ( + theme: Theme, + colour: IntentColour, +): ExtendedPaletteColor => { + const { vars, palette } = theme as ThemeWithIntentPalette; + + const fallbackPalette = palette[colour]; + const varsPalette = vars?.palette?.[colour]; + + if (process.env.NODE_ENV !== "production" && !fallbackPalette) { + console.warn( + `[DiamondDS] getIntentPalette: colour "${colour}" not found in palette`, + ); + } + + return { + ...fallbackPalette, + ...varsPalette, + }; +}; + +const getFocusToken = (colour?: IntentColour) => { + if (!colour) return "var(--ds-focus-ring)"; + + return `var(--ds-focus-ring-${colour === "error" ? "danger" : colour})`; +}; + +const getFocusOutline = (token?: string): CSSObject => ({ + "&.Mui-focusVisible": { + outline: "var(--ds-focus-ring-width) solid", + outlineColor: token ?? "var(--ds-focus-ring)", + outlineOffset: "var(--ds-focus-ring-offset)", + }, +}); + +const getOverlayInset = (token = "var(--ds-overlay-hover)") => + `inset 0 0 0 9999px ${token}`; + +// --- Theme factory --- + +export const createDiamondTheme = (mode: DSMode): Theme => { + const DiamondDSThemeOptions = mergeThemeOptions({ + typography: { + fontFamily: [ + "Inter Variable", + "Inter", + "system-ui", + "-apple-system", + '"Segoe UI"', + "Roboto", + "Helvetica", + "Arial", + "sans-serif", + ].join(","), + }, + + logos: { + normal: { + src: + mode === "dark" ? (logoImageDark ?? logoImageLight) : logoImageLight, + srcDark: logoImageDark ?? logoImageLight, + alt: "Diamond Light Source Logo", + width: "100", + }, + short: { + src: logoShort, + alt: "Diamond Light Source Logo", + width: "35", + }, + }, + + palette: { + mode, + + action: { + hover: "var(--ds-overlay-hover)", + selected: "var(--ds-overlay-selected)", + focus: "var(--ds-overlay-focus)", + disabled: "var(--ds-on-surface-disabled)", + disabledBackground: "var(--ds-surface-disabled)", + + hoverOpacity: 0.04, + selectedOpacity: 0.08, + disabledOpacity: 0.1, + focusOpacity: 0.1, + }, + + 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)", + + 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)", + }, + + primary: { + light: "var(--ds-primary-accent)", + main: "var(--ds-primary)", + dark: "var(--ds-primary-emphasis)", + contrastText: "var(--ds-on-primary)", + container: "var(--ds-primary-container)", + onContainer: "var(--ds-on-primary-container)", + solid: "var(--ds-primary-solid)", + onSolid: "var(--ds-on-primary-solid)", + + contrastTextChannel: "var(--ds-on-primary-channel)", + mainChannel: "var(--ds-primary-mainChannel)", + lightChannel: "var(--ds-primary-lightChannel)", + darkChannel: "var(--ds-primary-darkChannel)", + }, + + secondary: { + light: "var(--ds-secondary-accent)", + main: "var(--ds-secondary)", + dark: "var(--ds-secondary-emphasis)", + contrastText: "var(--ds-on-secondary)", + container: "var(--ds-secondary-container)", + onContainer: "var(--ds-on-secondary-container)", + solid: "var(--ds-secondary-solid)", + onSolid: "var(--ds-on-secondary-solid)", + + contrastTextChannel: "var(--ds-on-secondary-channel)", + mainChannel: "var(--ds-secondary-mainChannel)", + lightChannel: "var(--ds-secondary-lightChannel)", + darkChannel: "var(--ds-secondary-darkChannel)", + }, + + brand: { + light: "var(--ds-brand-accent)", + main: "var(--ds-brand)", + dark: "var(--ds-brand-emphasis)", + contrastText: "var(--ds-on-brand)", + container: "var(--ds-brand-container)", + onContainer: "var(--ds-on-brand-container)", + solid: "var(--ds-brand-solid)", + onSolid: "var(--ds-on-brand-solid)", + + contrastTextChannel: "var(--ds-on-brand-channel)", + mainChannel: "var(--ds-brand-mainChannel)", + lightChannel: "var(--ds-brand-lightChannel)", + darkChannel: "var(--ds-brand-darkChannel)", + }, + + error: { + light: "var(--ds-danger-accent)", + main: "var(--ds-danger)", + dark: "var(--ds-danger-emphasis)", + contrastText: "var(--ds-on-danger)", + container: "var(--ds-danger-container)", + onContainer: "var(--ds-on-danger-container)", + solid: "var(--ds-danger-solid)", + onSolid: "var(--ds-on-danger-solid)", + + contrastTextChannel: "var(--ds-on-danger-channel)", + mainChannel: "var(--ds-danger-mainChannel)", + lightChannel: "var(--ds-danger-lightChannel)", + darkChannel: "var(--ds-danger-darkChannel)", + }, + + warning: { + light: "var(--ds-warning-accent)", + main: "var(--ds-warning)", + dark: "var(--ds-warning-emphasis)", + contrastText: "var(--ds-on-warning)", + container: "var(--ds-warning-container)", + onContainer: "var(--ds-on-warning-container)", + solid: "var(--ds-warning-solid)", + onSolid: "var(--ds-on-warning-solid)", + + contrastTextChannel: "var(--ds-on-warning-channel)", + mainChannel: "var(--ds-warning-mainChannel)", + lightChannel: "var(--ds-warning-lightChannel)", + darkChannel: "var(--ds-warning-darkChannel)", + }, + + success: { + light: "var(--ds-success-accent)", + main: "var(--ds-success)", + dark: "var(--ds-success-emphasis)", + contrastText: "var(--ds-on-success)", + container: "var(--ds-success-container)", + onContainer: "var(--ds-on-success-container)", + solid: "var(--ds-success-solid)", + onSolid: "var(--ds-on-success-solid)", + + contrastTextChannel: "var(--ds-on-success-channel)", + mainChannel: "var(--ds-success-mainChannel)", + lightChannel: "var(--ds-success-lightChannel)", + darkChannel: "var(--ds-success-darkChannel)", + }, + + info: { + light: "var(--ds-info-accent)", + main: "var(--ds-info)", + dark: "var(--ds-info-emphasis)", + contrastText: "var(--ds-on-info)", + container: "var(--ds-info-container)", + onContainer: "var(--ds-on-info-container)", + solid: "var(--ds-info-solid)", + onSolid: "var(--ds-on-info-solid)", + + contrastTextChannel: "var(--ds-on-info-channel)", + mainChannel: "var(--ds-info-mainChannel)", + lightChannel: "var(--ds-info-lightChannel)", + darkChannel: "var(--ds-info-darkChannel)", + }, + + grey: { + 50: "#F8F8FA", + 100: "#EEF1F5", + 200: "#E6E9F0", + 300: "#DDE1E8", + 400: "#BCC2CD", + 500: "#A5ACB8", + 600: "#8A90A0", + 700: "#505563", + 800: "#2C3140", + 900: "#1A1C23", + }, + }, + + components: { + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: "none", + }, + }, + }, + + MuiButtonBase: { + defaultProps: { + disableRipple: true, + disableTouchRipple: true, + focusRipple: false, + }, + }, + + MuiButton: { + defaultProps: { + disableFocusRipple: true, + }, + styleOverrides: { + root: ({ + ownerState, + theme, + }: OverrideArgs): CSSObject => { + const base: CSSObject = { + textTransform: "none", + boxShadow: "none", + }; + + const variant = ownerState.variant ?? "text"; + const rawColour = ownerState.color ?? "primary"; + + if (rawColour === "inherit") { + return { + ...base, + ...getFocusOutline(), + }; + } + + const colour = rawColour as IntentColour; + const p = getIntentPalette(theme, colour); + const focusToken = getFocusToken(colour); + const subtle = p.container; + const onSubtle = p.onContainer; + + if (variant === "contained") { + return { + ...base, + ...getFocusOutline(focusToken), + backgroundColor: p.solid ?? p.main, + color: p.onSolid ?? "var(--ds-on-solid)", + + "&:hover": { + backgroundColor: p.solid ?? p.main, + boxShadow: getOverlayInset("var(--ds-overlay-hover-solid)"), + }, + + "&:active": { + backgroundColor: p.solid ?? p.main, + boxShadow: getOverlayInset("var(--ds-overlay-selected)"), + }, + + "&.Mui-focusVisible": { + outline: "var(--ds-focus-ring-width) solid", + outlineColor: focusToken, + outlineOffset: "var(--ds-focus-ring-offset)", + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + + "&.Mui-disabled": { + opacity: 1, + backgroundColor: "var(--ds-surface-disabled)", + color: "var(--ds-on-surface-disabled)", + boxShadow: "none", + }, + }; + } + + if (variant === "outlined") { + return { + ...base, + ...getFocusOutline(focusToken), + + color: onSubtle, + backgroundColor: subtle, + + "&:hover": { + backgroundColor: subtle, + boxShadow: getOverlayInset(), + }, + + "&:active": { + backgroundColor: subtle, + boxShadow: getOverlayInset("var(--ds-overlay-selected)"), + }, + + "&.Mui-disabled": { + opacity: 1, + backgroundColor: "transparent", + color: "var(--ds-on-surface-disabled)", + boxShadow: "none", + }, + }; + } + + if (variant === "text") { + return { + ...base, + ...getFocusOutline(focusToken), + color: p.main, + + "&:hover": { + backgroundColor: subtle, + boxShadow: getOverlayInset(), + }, + }; + } + + return { + ...base, + ...getFocusOutline(focusToken), + }; + }, + }, + }, + + MuiIconButton: { + defaultProps: { + disableRipple: true, + 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(), + }, + ...getFocusOutline(), + }; + } + + const colour = rawColour as IntentColour; + const p = getIntentPalette(theme, colour); + const focusToken = getFocusToken(colour); + + return { + color: p.main, + + "&:hover": { + backgroundColor: p.container, + boxShadow: getOverlayInset(), + }, + + ...getFocusOutline(focusToken), + }; + }, + }, + }, + + MuiToggleButton: { + styleOverrides: { + root: ({ theme }: ThemeOnlyArgs): CSSObject => ({ + textTransform: "none", + border: `1px solid ${theme.palette.borders.base}`, + + "&:hover": { + borderColor: theme.palette.borders.emphasis, + }, + }), + }, + }, + + MuiChip: { + 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) { + return { + ...base, + ...(isInteractive ? getFocusOutline() : {}), + + color: "var(--ds-on-surface)", + borderColor: "var(--ds-border)", + backgroundColor: "var(--ds-surface-container-high)", + + ...(isInteractive && { + "&:hover": { + backgroundColor: "var(--ds-surface-container-high)", + boxShadow: getOverlayInset(), + }, + + "&:active": { + backgroundColor: "var(--ds-surface-container-high)", + boxShadow: getOverlayInset("var(--ds-overlay-selected)"), + }, + + "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible": + { + backgroundColor: "var(--ds-surface-container-high)", + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + + "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover": + { + backgroundColor: "var(--ds-surface-container-high)", + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + }), + }; + } + + const colour = rawColour as IntentColour; + const p = getIntentPalette(theme, colour); + const focusToken = getFocusToken(colour); + + if (isOutlined) { + return { + ...base, + ...(isInteractive ? getFocusOutline(focusToken) : {}), + + color: p.onContainer, + borderColor: p.light, + backgroundColor: p.container, + + ...(isInteractive && { + "&:hover": { + backgroundColor: p.container, + borderColor: p.light, + boxShadow: getOverlayInset(), + }, + + "&:active": { + backgroundColor: p.container, + borderColor: p.light, + boxShadow: getOverlayInset("var(--ds-overlay-selected)"), + }, + + "&&.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)"), + }, + }), + }; + } + + const solid = p.solid ?? p.main; + + return { + ...base, + ...(isInteractive ? getFocusOutline(focusToken) : {}), + + color: p.onSolid ?? "var(--ds-on-solid)", + backgroundColor: solid, + + ...(isInteractive && { + "&:hover": { + backgroundColor: solid, + boxShadow: getOverlayInset("var(--ds-overlay-hover-solid)"), + }, + + "&:active": { + backgroundColor: solid, + boxShadow: getOverlayInset("var(--ds-overlay-selected)"), + }, + + "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible": + { + backgroundColor: solid, + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + + "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover": + { + backgroundColor: 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 => ({ + "&.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: { + root: ({ + ownerState, + theme, + }: OverrideArgs): CSSObject => { + const colour = (ownerState.color ?? "primary") as IntentColour; + const p = getIntentPalette(theme, colour); + const focusToken = getFocusToken(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", + outlineColor: focusToken, + 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: { + styleOverrides: { + root: ({ + ownerState, + theme, + }: OverrideArgs): CSSObject => { + const severity = (ownerState.severity ?? "success") as IntentColour; + 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 ?? p.main, + color: p.onSolid ?? "var(--ds-on-solid)", + }; + } + + 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)", + }; + }, + }, + }, + + MuiLinearProgress: { + styleOverrides: { + root: { + height: 6, + borderRadius: 999, + overflow: "hidden", + backgroundColor: "var(--ds-surface-container-high)", + }, + + bar: ({ + ownerState, + theme, + }: OverrideArgs): CSSObject => { + const colour = (ownerState.color ?? "primary") as IntentColour; + const p = getIntentPalette(theme, colour); + + return { + backgroundColor: p.main, + }; + }, + }, + }, + + MuiCircularProgress: { + styleOverrides: { + root: ({ + ownerState, + theme, + }: OverrideArgs): CSSObject => { + const colour = (ownerState.color ?? "primary") as IntentColour; + 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 = rawColour as IntentColour; + + const p = !isDefault ? getIntentPalette(theme, colour) : null; + const focusToken = !isDefault ? getFocusToken(colour) : undefined; + + return { + color: "var(--ds-on-surface-variant)", + borderRadius: 8, + + "&:hover": { + backgroundColor: "var(--ds-overlay-hover)", + }, + + ...getFocusOutline(focusToken), + + "&.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 = rawColour as IntentColour; + + const p = !isDefault ? getIntentPalette(theme, colour) : null; + const focusToken = !isDefault ? getFocusToken(colour) : undefined; + + return { + color: "var(--ds-on-surface-variant)", + borderRadius: "50%", + + "&:hover": { + backgroundColor: "var(--ds-overlay-hover)", + }, + + ...getFocusOutline(focusToken), + + "&.Mui-checked": { + color: isDefault ? "var(--ds-on-surface)" : p?.main, + }, + + "&.Mui-disabled": { + color: "var(--ds-action-disabled)", + }, + }; + }, + }, + }, + }, + }); + + return createTheme(DiamondDSThemeOptions); +}; + +// Convenience exports — derive from the factory so they stay in sync. +export const DiamondDSTheme = createDiamondTheme("light"); +export const DiamondDSThemeDark = createDiamondTheme("dark"); + +// Keep the old export name as an alias for backwards compatibility. +export const createMuiTheme = createDiamondTheme; diff --git a/src/themes/ThemeProvider.tsx b/src/themes/ThemeProvider.tsx index a4badc84..56d306b1 100644 --- a/src/themes/ThemeProvider.tsx +++ b/src/themes/ThemeProvider.tsx @@ -1,26 +1,37 @@ -import { ThemeProvider as MuiThemeProvider } from "@mui/material/styles"; +import React, { useLayoutEffect, useMemo } from "react"; import { CssBaseline } from "@mui/material"; -import { GenericTheme } from "./GenericTheme"; -import { ThemeProviderProps as MuiThemeProviderProps } from "@mui/material/styles"; +import { ThemeProvider as MuiThemeProvider } from "@mui/material/styles"; +import type { ThemeProviderProps as MuiThemeProviderProps } from "@mui/material/styles"; +import { createMuiTheme } from "./DiamondDSTheme"; +import type { DSMode } from "./DiamondDSTheme"; interface ThemeProviderProps extends Partial { baseline?: boolean; + mode?: DSMode; // 'light' | 'dark' (adding 'system' for future use) } -const ThemeProvider = function ({ +export function ThemeProvider({ children, - theme = GenericTheme, baseline = true, defaultMode = "system", + mode = "light", // default to light mode (for now) ...props }: ThemeProviderProps) { + useLayoutEffect(() => { + const root = document.documentElement; + + root.setAttribute("data-mode", mode); + root.classList.toggle("dark", mode === "dark"); + root.classList.toggle("light", mode === "light"); + root.style.colorScheme = mode; + }, [mode]); + + const theme = useMemo(() => createMuiTheme(mode), [mode]); + return ( {baseline && } {children} ); -}; - -export { ThemeProvider }; -export type { ThemeProviderProps }; +} 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 +} From 261a108b5b37eb6468c962ffbd5620cb3c632905 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Fri, 8 May 2026 14:05:02 +0100 Subject: [PATCH 02/12] Add design system intro and foundation documentation pages Mini intro and foundations pages for principles, colour, and typography --- src/storybook/design-system.mdx | 40 + src/storybook/foundation/01-principles.mdx | 149 +++ src/storybook/foundation/02-colours.mdx | 1184 ++++++++++++++++++++ src/storybook/foundation/03-typography.mdx | 96 ++ 4 files changed, 1469 insertions(+) create mode 100644 src/storybook/design-system.mdx create mode 100644 src/storybook/foundation/01-principles.mdx create mode 100644 src/storybook/foundation/02-colours.mdx create mode 100644 src/storybook/foundation/03-typography.mdx diff --git a/src/storybook/design-system.mdx b/src/storybook/design-system.mdx new file mode 100644 index 00000000..3378075f --- /dev/null +++ b/src/storybook/design-system.mdx @@ -0,0 +1,40 @@ +import { Meta } from "@storybook/blocks"; + + + + + +
+ +# Diamond Design System ❖ + +

+ The design system for Diamond Light Source, providing a shared foundation for + building clear, consistent, and reliable scientific interfaces. +

+ +

+ It evolves SciReactUI into a consistent, semantic, and scalable system for + data acquisition, analysis, and operational tools across Diamond. +

+ +

+ Built on structure and precision, the system supports clear, coherent, and + predictable interfaces across scientific workflows and applications. +

+ +## What it helps us do + +
    +
  • Create clearer, more predictable scientific interfaces.
  • +
  • Bring consistency across tools and beamlines.
  • +
  • Reduce cognitive load in complex workflows.
  • +
  • Speed up development with reusable components.
  • +
  • Ensure new tools start coherent and stay aligned.
  • +
+ +
diff --git a/src/storybook/foundation/01-principles.mdx b/src/storybook/foundation/01-principles.mdx new file mode 100644 index 00000000..4e81e88e --- /dev/null +++ b/src/storybook/foundation/01-principles.mdx @@ -0,0 +1,149 @@ +import { Meta } from "@storybook/blocks"; + + + + + +
+ +# Core principles + +

+ These principles guide how we design and build interfaces across the Diamond + Design System. +

+ +
+ +
+ +
+

Clarity

+

+ Interfaces should be easy to understand at a glance. Users should be able to + see system state and next actions without interpreting multiple signals. +

+
+ +
+

Consistency

+

+ Similar elements should look and behave the same across the system, reducing + the need to relearn interactions. +

+
+ +
+

Accessibility

+

+ Accessibility is a baseline, not an enhancement. Interfaces must remain + usable across a wide range of abilities and conditions. +

+
+ +
+

Predictable behaviour

+

+ Interactions should behave as users expect. Reliability is critical in + stateful and high-consequence workflows. +

+
+ +
+

Token-driven

+

+ All visual decisions are defined through design tokens, ensuring consistency + across themes and components. +

+
+ +
+

Semantic first

+

+ Use roles and meaning, not raw values. For example, use + --ds-bg-surface instead of hardcoded colours. +

+
+ +
+

Separation of concerns

+

+ Tokens, components, and implementation are distinct layers, allowing the + system to scale safely. +

+
+ +
+

+
+ +## Do and don’t + +### Do + +
    +
  • Use semantic design tokens.
  • +
  • Start with existing components.
  • +
  • Keep behaviour and interaction patterns consistent.
  • +
+ +### Don’t + +
    +
  • Hardcode colours or spacing values.
  • +
  • Create new components without a clear need.
  • +
  • Override system behaviour without strong justification.
  • +
+ +## Light and dark mode + +

+ Light and dark mode are supported by default and are handled at the system + level, not per component. +

+ +
    +
  • + Theme switching is controlled via data-mode on the{" "} + html element. +
  • +
  • Components should use semantic tokens so colours adapt automatically.
  • +
  • No component should implement separate light/dark logic.
  • +
+ +
{``}
+ +
{``}
+ +
diff --git a/src/storybook/foundation/02-colours.mdx b/src/storybook/foundation/02-colours.mdx new file mode 100644 index 00000000..d03be370 --- /dev/null +++ b/src/storybook/foundation/02-colours.mdx @@ -0,0 +1,1184 @@ +import { Meta } from "@storybook/blocks"; + + + + + +# Colour + +
+ +

+ Diamond Design System uses a role-based colour system designed for scientific + software. Colours are defined through stable UI roles and then mapped into the + MUI theme, so components remain consistent across light and dark mode. +

+ +
+ +
+ +
+ Architecture +
+ Role tokens → MUI theme mapping → components +
+

+ Theme switching happens via <html data-mode="light|dark"> + . Role tokens define all colours and change between themes. The MUI theme + maps those roles into component behaviour, so components update + automatically. +

+
+ +
+ +## How the layers work + +
+ +
+
+

Role tokens

+

+ These are the source of truth for colour in the Diamond Design System. They + define UI roles directly, such as background, surface, border, text, + overlay, focus, and intent. +

+
    +
  • Components should depend on roles, not raw colour values.
  • +
  • Theme switching swaps role values, not component code.
  • +
+
+ +
+

Theme mapping

+

+ The MUI theme maps those role tokens into palette slots, action states, and + component defaults. +

+

+ This lets MUI components use familiar props like{" "} + color="primary" + while still following Diamond Design System rules. +

+
+ +
+

Components

+

+ Components consume those mapped roles. This keeps buttons, fields, tabs, + chips, and custom UI consistent across themes. +

+
    +
  • Structure comes from neutral roles.
  • +
  • Meaning comes from intent roles.
  • +
+
+
+ +
+

Rule of thumb

+

+ Structure uses background and border roles. + Meaning uses intent roles. + Interaction uses overlays and focus tokens. +

+
+ +
+ +## Role tokens + +
+ +

+ Role tokens are the colour source of truth in the Diamond Design System. Each + token represents a specific role in the interface and maps directly to a + concrete value in light and dark mode. +

+ +
+ +
+ +
+ Token groups +
    +
  • + Background: app and page canvas +
  • +
  • + Surfaces: containers and grouped regions +
  • +
  • + Borders: structure and affordance +
  • +
  • + Foreground: text and icon contrast +
  • +
  • + Intent: action, status, and meaning +
  • +
  • + Overlays: hover, selected, focus, disabled +
  • +
+
+ +
+ +## Neutral roles + +
+ +

+ Most of the interface should be built from the neutral layer. This keeps dense + screens calm, predictable, and readable before any meaning is added through + intent colours. +

+ +
+ +
+ +
+
+
+
+

Background and surface roles

+
    +
  • background: --ds-background
  • +
  • surface: --ds-surface
  • +
  • surface container: --ds-surface-container
  • +
  • surface container high: --ds-surface-container-high
  • +
+
+ background + surface + container + container high +
+
+
+ +
+
+
+

Border roles

+
    +
  • + subtle: --ds-border-subtle +
  • +
  • + base: --ds-border +
  • +
  • + emphasis: --ds-border-emphasis +
  • +
+

+ Use subtle for quiet separation, base for standard structure, and emphasis + where affordance needs to be clearer. +

+
+
+ +
+
+
+

Foreground roles

+
    +
  • primary text: --ds-on-surface
  • +
  • secondary text: --ds-on-surface-variant
  • +
  • disabled text: --ds-on-surface-disabled
  • +
  • placeholder: --ds-placeholder
  • +
  • placeholder focus: --ds-placeholder-focus
  • +
+
+
+
+ +
+ +## Intent roles + +
+ +

+ Intent roles add meaning. They are mapped into the MUI palette and also + provide container, solid, on-container, and on-solid values for calmer and + stronger usage patterns. +

+ +
+ +
+ +
+ How intent works +
    +
  • + main is the default semantic colour for text, icons, + borders, and standard intent usage. +
  • +
  • + emphasis is the stronger interactive or more prominent + semantic step. +
  • +
  • + accent supports lighter emphasis and supporting semantic + detail. +
  • +
  • + container is a subtle intent surface. +
  • +
  • + on container is the readable foreground for that subtle + surface. +
  • +
  • + solid is the stronger filled semantic surface. +
  • +
  • + on solid is the readable foreground for that stronger + fill. +
  • +
+
+ +
+
+
+
+

Primary

+
    +
  • main: --ds-primary
  • +
  • emphasis: --ds-primary-emphasis
  • +
  • accent: --ds-primary-accent
  • +
  • container: --ds-primary-container
  • +
  • on container: --ds-on-primary-container
  • +
  • solid: --ds-primary-solid
  • +
  • on solid: --ds-on-primary-solid
  • +
+
+ main + emphasis + solid + container +
+
+
+ +
+
+
+

Secondary

+
    +
  • + main: --ds-secondary +
  • +
  • + emphasis: --ds-secondary-emphasis +
  • +
  • + accent: --ds-secondary-accent +
  • +
  • + container: --ds-secondary-container +
  • +
  • + on container: --ds-on-secondary-container +
  • +
  • + solid: --ds-secondary-solid +
  • +
  • + on solid: --ds-on-secondary-solid +
  • +
+
+ + main + + + emphasis + + + solid + + + container + +
+
+
+ +
+
+
+

Tertiary

+
    +
  • + main: --ds-tertiary +
  • +
  • + emphasis: --ds-tertiary-emphasis +
  • +
  • + accent: --ds-tertiary-accent +
  • +
  • + container: --ds-tertiary-container +
  • +
  • + on container: --ds-on-tertiary-container +
  • +
  • + solid: --ds-tertiary-solid +
  • +
  • + on solid: --ds-on-tertiary-solid +
  • +
+
+ + main + + + emphasis + + + solid + + + container + +
+
+
+ +
+
+
+

Brand

+
    +
  • main: --ds-brand
  • +
  • emphasis: --ds-brand-emphasis
  • +
  • accent: --ds-brand-accent
  • +
  • container: --ds-brand-container
  • +
  • on container: --ds-on-brand-container
  • +
  • solid: --ds-brand-solid
  • +
  • on solid: --ds-on-brand-solid
  • +
+
+ main + solid + container +
+
+
+
+ +
+ +## Status colours + +
+ +

+ Status colours communicate feedback and system meaning. Use them deliberately + and consistently so they remain useful in dense, high-attention workflows. +

+ +
+ +
+ +
+
+
+
+

Success

+
    +
  • main: --ds-success
  • +
  • emphasis: --ds-success-emphasis
  • +
  • accent: --ds-success-accent
  • +
  • container: --ds-success-container
  • +
  • on container: --ds-on-success-container
  • +
  • solid: --ds-success-solid
  • +
  • on solid: --ds-on-success-solid
  • +
+
+ main + solid + container +
+
+
+ +
+
+
+

Warning

+
    +
  • + main: --ds-warning +
  • +
  • + emphasis: --ds-warning-emphasis +
  • +
  • + accent: --ds-warning-accent +
  • +
  • + container: --ds-warning-container +
  • +
  • + on container: --ds-on-warning-container +
  • +
  • + solid: --ds-warning-solid +
  • +
  • + on solid: --ds-on-warning-solid +
  • +
+
+ + main + + + solid + + + container + +
+
+
+ +
+
+
+

Danger / error

+
    +
  • + main: --ds-danger +
  • +
  • + emphasis: --ds-danger-emphasis +
  • +
  • + accent: --ds-danger-accent +
  • +
  • + container: --ds-danger-container +
  • +
  • + on container: --ds-on-danger-container +
  • +
  • + solid: --ds-danger-solid +
  • +
  • + on solid: --ds-on-danger-solid +
  • +
+
+ + main + + + solid + + + container + +
+
+
+ +
+
+
+

Info

+
    +
  • main: --ds-info
  • +
  • emphasis: --ds-info-emphasis
  • +
  • accent: --ds-info-accent
  • +
  • container: --ds-info-container
  • +
  • on container: --ds-on-info-container
  • +
  • solid: --ds-info-solid
  • +
  • on solid: --ds-on-info-solid
  • +
+
+ main + solid + container +
+
+
+
+ +
+ +## How roles map to the MUI palette + +
+ +

+ The Diamond Design System defines colour through semantic role tokens. The MUI + palette is a mapping layer that exposes those roles through the fields MUI + expects, such as light, main, dark, and + contrastText. +

+ +

+ This allows standard MUI components to work with familiar props like + color="primary", while Diamond-specific roles such as + container, on-container, solid, and + on-solid support richer component behaviour and custom UI. +

+ +
+ +
+ +
+ Important +

+ MUI palette fields are not the source of truth. They are derived from + Diamond Design System role tokens so MUI components behave correctly. +

+
+ +
+ +## Surfaces and elevation + +
+ +

+ Diamond Design System uses elevation to create hierarchy, but most elevation + should come from surface colour, borders, and spacing rather than drop + shadows. This keeps dense scientific interfaces calm, readable, and + predictable. +

+ +
+ +
+ +
+ Surface hierarchy +
    +
  • + background → app and page background ( + --ds-background) +
  • +
  • + surface → primary container surface ( + --ds-surface) +
  • +
  • + surface container → grouped sections ( + --ds-surface-container) +
  • +
  • + surface container high → more prominent nested areas ( + --ds-surface-container-high) +
  • +
+

+ Use drop shadows sparingly, mainly for floating overlays such as menus, + popovers, dialogs, and tooltips. +

+
+ +
+

Practical: Paper with surfaces inside

+
{`
+  
+    Section title
+  
+  
+    Interactive area (inputs, controls)
+  
+`}
+
+ +
+ +## Overlays and interaction states + +
+ +

+ Interaction states are defined as explicit overlays so hover, selection, + focus, and disabled behaviour stay consistent across themes. MUI’s + palette.action is mapped directly to these tokens. +

+ +
+ +
+ +
+
+
+
+
+
+
    +
  • emphasis: --ds-overlay-emphasis
  • +
+
+
+ +
+
+
+
+
+
    +
  • + selected: --ds-overlay-selected +
  • +
+
+
+ +
+
+
+
+
+
    +
  • + focus: --ds-overlay-focus +
  • +
+
+
+ +
+
+
+
+
+
    +
  • + disabled: --ds-overlay-disabled +
  • +
+
+
+ +
+
+
+
+
+
    +
  • disabled background: --ds-overlay-disabled-bg
  • +
+
+
+
+ +
+ +## Focus + +
+ +

+ Focus is handled through explicit focus-ring tokens rather than ripple or + shadow. This keeps keyboard interaction visible and steady across components. +

+ +
+ +
+ +
+ Focus tokens +
    +
  • + default: --ds-focus-ring +
  • +
  • + primary: --ds-focus-ring-primary +
  • +
  • + secondary: --ds-focus-ring-secondary +
  • +
  • + danger: --ds-focus-ring-danger +
  • +
  • + warning: --ds-focus-ring-warning +
  • +
  • + success: --ds-focus-ring-success +
  • +
  • + info: --ds-focus-ring-info +
  • +
  • + brand: --ds-focus-ring-brand +
  • +
+
+ +
+ +## Using MUI palette vs Diamond roles + +
+ +

+ Use MUI palette props for intent on MUI components. Use Diamond Design System + role tokens for structure, surfaces, borders, overlays, and custom UI. +

+ +
+ +
+ +
+
+ Use MUI palette when +
    +
  • You’re using MUI components directly.
  • +
  • You want consistent intent behaviour through props.
  • +
  • You’re relying on theme defaults for hover, selected, and focus behaviour.
  • +
+
+

Example

+
{``}
+
+
+ +
+ Use Diamond roles when +
    +
  • You’re styling layout or containers.
  • +
  • You’re building a custom component or anatomy.
  • +
  • You need a specific surface or border role.
  • +
  • You’re styling non-MUI DOM elements.
  • +
+
+

Example

+
{`
+ Panel content +
`}
+
+
+
+ +
+ +## Do and don’t + +
+ +
+
+ Do +
    +
  • Use neutral roles for structure (--ds-background, --ds-surface, --ds-border-*).
  • +
  • Use intent roles for meaning (--ds-primary, --ds-success, --ds-danger, etc.).
  • +
  • Use surfaces to separate regions rather than heavy shadows.
  • +
  • Let the same token names work across light and dark themes.
  • +
  • Ensure disabled and error states override other styling.
  • +
+
+ +
+ Don’t +
    +
  • Don’t hardcode hex values in component code.
  • +
  • Don’t use strong intent colours as general layout backgrounds.
  • +
  • Don’t use colour alone to communicate state.
  • +
  • Don’t mix shadow-based elevation and surface layering unnecessarily.
  • +
  • Don’t invent one-off colour behaviour inside individual components.
  • +
+
+
+ +
+ +## Quick visual check + +
+ +

+ A simple sanity check for surfaces, text, and dividers in the current theme. +

+ +
+ +
+ +
+
+
+

+ Text primary--ds-on-surface +

+

+ Text secondary → --ds-on-surface-variant +

+

+ Text disabled → --ds-on-surface-disabled +

+
+
+

+ Background uses --ds-background, main containers use + --ds-surface, grouped areas use + --ds-surface-container /{" "} + --ds-surface-container-high, and dividers use{" "} + --ds-border-subtle. +

+
+
+ +
+ Diamond Design System principle +

+ Use colour to support careful work, not to decorate it. Build hierarchy with + neutral roles, apply meaning through intent roles, and keep interaction + states explicit and predictable. +

+
+ +
diff --git a/src/storybook/foundation/03-typography.mdx b/src/storybook/foundation/03-typography.mdx new file mode 100644 index 00000000..644f7cdb --- /dev/null +++ b/src/storybook/foundation/03-typography.mdx @@ -0,0 +1,96 @@ +import { Meta } from "@storybook/blocks"; + + + + + +
+ +# Typography + +

+ Typography is a functional tool, not decoration. In scientific software, type + choices affect speed, accuracy, and confidence. +

+ +## Why it matters in Diamond tools + +
    +
  • + Readability and legibility: clear at small sizes and on + imperfect displays. +
  • +
  • + Hierarchy: predictable structure for scanning data-heavy + screens. +
  • +
  • + Accessibility: legible sizing and contrast across contexts. +
  • +
  • + Reduced cognitive load: less visual noise, faster + decisions. +
  • +
  • + Works for dense data: tables, logs, dashboards, forms, and + documentation. +
  • +
+ +## Font roles + +
    +
  • + Inter: default UI font for functional work, including body + text, labels, forms, tables, and component text. +
  • +
  • + IBM Plex Mono: code, identifiers, logs, aligned technical + values, and monospace data. +
  • +
  • + Outfit: optional brand-forward headings and hero text, not + dense operational content. +
  • +
+ +## Practical guidance + +### Do + +
    +
  • Keep hierarchy consistent across products and screens.
  • +
  • Prefer short, clear labels in controls and tables.
  • +
  • Make numbers, units, and technical values easy to scan.
  • +
  • Use mono type where alignment or identifier readability matters.
  • +
+ +### Don’t + +
    +
  • Don’t use brand typography for dense operational content.
  • +
  • Don’t “style your way” out of hierarchy problems.
  • +
  • Don’t hide important information in faint secondary text.
  • +
  • Don’t introduce one-off font sizes without a reusable need.
  • +
+ +## Tokens + +

The current MUI theme uses Inter as the default UI font.

+ +

+ Typography tokens will define display, body, label, and mono usage, for + example `typography.display.*`, `typography.body.*`, `typography.label.*`, and + `typography.mono.*`. +

+ +

+ These tokens should be referenced consistently across design and code rather + than using one-off font sizes, weights, or families. +

+ +
From 4f6b799115d5caa400183eb7e01aace41aca7ff6 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Mon, 11 May 2026 11:51:57 +0100 Subject: [PATCH 03/12] Updated opacity as was too low --- src/themes/DiamondDSTheme.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/themes/DiamondDSTheme.ts b/src/themes/DiamondDSTheme.ts index b2bf9516..50a4d4e8 100644 --- a/src/themes/DiamondDSTheme.ts +++ b/src/themes/DiamondDSTheme.ts @@ -221,8 +221,8 @@ export const createDiamondTheme = (mode: DSMode): Theme => { hoverOpacity: 0.04, selectedOpacity: 0.08, - disabledOpacity: 0.1, - focusOpacity: 0.1, + disabledOpacity: 0.38, + focusOpacity: 0.16, }, text: { From d69d096c0006bf98e2e8371ce94a8d0d5613017e Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Tue, 12 May 2026 10:34:11 +0100 Subject: [PATCH 04/12] Restored StoryOrder, added CSS grey to root and remove reference to class --- .storybook/preview.tsx | 14 +++++++ src/styles/diamondDS/diamond-ds-roles.css | 47 ++++++++++++++--------- src/themes/DiamondDSTheme.ts | 20 +++++----- 3 files changed, 53 insertions(+), 28 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 821b5212..a46c2aa7 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -117,6 +117,20 @@ const preview: Preview = { }, backgrounds: { disable: true }, layout: "fullscreen", + options: { + storySort: { + order: [ + "Introduction", + "Foundation", + "Helpers", + "MUI", + "Components", + "Theme", + "Theme/Logos", + "Theme/Colours", + ], + }, + }, }, argTypes: { linkComponent: { diff --git a/src/styles/diamondDS/diamond-ds-roles.css b/src/styles/diamondDS/diamond-ds-roles.css index ed198f98..4c826945 100644 --- a/src/styles/diamondDS/diamond-ds-roles.css +++ b/src/styles/diamondDS/diamond-ds-roles.css @@ -1,8 +1,21 @@ -:root, .light, -html[data-mode="light"] { +:root { + --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; +} + +:root, +:root[data-mode="light"] { /* Neutral */ - --ds-background: #F6F6F9; + --ds-background: #f6f6f9; --ds-background-channel: 246 246 249; --ds-surface: #ffffff; @@ -15,7 +28,7 @@ html[data-mode="light"] { --ds-on-surface: #1a1c23; --ds-on-surface-variant: #5e6473; --ds-on-surface-disabled: rgba(0, 0, 0, 0.36); - --ds-action-disabled: rgba(0, 0, 0, 0.30); + --ds-action-disabled: rgba(0, 0, 0, 0.3); --ds-on-solid: #ffffff; --ds-on-surface-channel: 26 28 35; @@ -32,7 +45,7 @@ html[data-mode="light"] { --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-focus: rgba(0, 0, 0, 0.10); + --ds-overlay-focus: rgba(0, 0, 0, 0.1); /* Primary (Indigo-Blue) */ --ds-primary: #2a4db8; @@ -132,10 +145,10 @@ html[data-mode="light"] { --ds-success: #187a2f; --ds-on-success: #ffffff; --ds-success-emphasis: #146125; - --ds-success-accent: #2FB344; + --ds-success-accent: #2fb344; --ds-success-container: #e3f4e7; --ds-on-success-container: #124d22; - --ds-success-solid: #1B8834; + --ds-success-solid: #1b8834; --ds-on-success-solid: #ffffff; --ds-on-success-channel: 255 255 255; @@ -168,7 +181,7 @@ html[data-mode="light"] { --ds-highlight-solid: #b89300; --ds-on-highlight-solid: #ffffff; - --ds-on-highlight-channel: 26 28 35; + --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; @@ -188,8 +201,7 @@ html[data-mode="light"] { --ds-focus-ring-highlight: var(--ds-highlight-accent); } -.dark, -html[data-mode="dark"] { +:root[data-mode="dark"] { /* Neutral */ --ds-background: #0e1017; @@ -205,7 +217,7 @@ html[data-mode="dark"] { --ds-on-surface: #e8eaf0; --ds-on-surface-variant: #b6bcc9; --ds-on-surface-disabled: rgba(255, 255, 255, 0.36); - --ds-action-disabled: rgba(255, 255, 255, 0.30); + --ds-action-disabled: rgba(255, 255, 255, 0.3); --ds-on-solid: #ffffff; --ds-on-surface-channel: 232 234 240; @@ -280,7 +292,7 @@ html[data-mode="dark"] { --ds-on-brand-solid: #ffffff; --ds-brand-fixed: #202945; - --ds-brand-fixed-dim : #586084; + --ds-brand-fixed-dim: #586084; --ds-on-brand-fixed: #ffffff; --ds-on-brand-channel: 13 21 48; @@ -325,7 +337,7 @@ html[data-mode="dark"] { --ds-success-accent: #8ae5a2; --ds-success-container: #10341a; --ds-on-success-container: #d2f7da; - --ds-success-solid: #23913C; + --ds-success-solid: #23913c; --ds-on-success-solid: #ffffff; --ds-on-success-channel: 8 33 15; @@ -355,16 +367,15 @@ html[data-mode="dark"] { --ds-highlight-accent: #ffeaa0; --ds-highlight-container: #4b3a05; --ds-on-highlight-container: #fff4c7; - --ds-highlight-solid: #D4A900; - --ds-on-highlight-solid: #1A1C23; - + --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 @@ -427,4 +438,4 @@ elevation-21 = #28303C elevation-22 = #2A3140 elevation-23 = #2A3140 elevation-24 = #2C3140 -*/ \ No newline at end of file +*/ diff --git a/src/themes/DiamondDSTheme.ts b/src/themes/DiamondDSTheme.ts index 50a4d4e8..9d6eb70b 100644 --- a/src/themes/DiamondDSTheme.ts +++ b/src/themes/DiamondDSTheme.ts @@ -368,16 +368,16 @@ export const createDiamondTheme = (mode: DSMode): Theme => { }, grey: { - 50: "#F8F8FA", - 100: "#EEF1F5", - 200: "#E6E9F0", - 300: "#DDE1E8", - 400: "#BCC2CD", - 500: "#A5ACB8", - 600: "#8A90A0", - 700: "#505563", - 800: "#2C3140", - 900: "#1A1C23", + 50: "var(--ds-grey-50)", + 100: "var(--ds-grey-100)", + 200: "var(--ds-grey-200)", + 300: "var(--ds-grey-300)", + 400: "var(--ds-grey-400)", + 500: "var(--ds-grey-500)", + 600: "var(--ds-grey-600)", + 700: "var(--ds-grey-700)", + 800: "var(--ds-grey-800)", + 900: "var(--ds-grey-900)", }, }, From fc5e2554b3f6c33e0815b690432247f7ec41568b Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Tue, 12 May 2026 10:50:12 +0100 Subject: [PATCH 05/12] Refactor Storybook theme handling and introduce Diamond DS theme configuration support Following suggestion of simplification by @akademy. - Updated theme mode handling to use MUI colour scheme classes - Removed previous data-mode logic - Introduced back System settings option and a way to switch to it Revert "Refactor Storybook theme handling and introduce Diamond DS theme configuration support" This reverts commit 378432399d5fb243bd5d171193302b012f5d9937. Reapply "Refactor Storybook theme handling and introduce Diamond DS theme configuration support" This reverts commit a84523885a2d01f80a665418d27d07b2686656d9. Revert "Refactor Storybook theme handling and introduce Diamond DS theme configuration support" This reverts commit 8b304fd18d7ace6b296890a058c770f6eb86fed0. From 5e7b804f08e21ecfcfd63973601f45a850cf25c2 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Tue, 12 May 2026 14:36:00 +0100 Subject: [PATCH 06/12] Introduced back System settings option and a way to switch to it --- .storybook/ThemeSwapper.tsx | 32 ++++++--- .storybook/preview.tsx | 84 +++++++++++++++-------- src/styles/diamondDS/diamond-ds-roles.css | 6 +- 3 files changed, 81 insertions(+), 41 deletions(-) diff --git a/.storybook/ThemeSwapper.tsx b/.storybook/ThemeSwapper.tsx index 99634d0f..e7a3f8b8 100644 --- a/.storybook/ThemeSwapper.tsx +++ b/.storybook/ThemeSwapper.tsx @@ -1,6 +1,5 @@ -import { useColorScheme } from "@mui/material"; +import { useColorScheme } from "@mui/material/styles"; import * as React from "react"; -import { useEffect } from "react"; interface Globals { theme: string; @@ -18,20 +17,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(); + const { mode, systemMode, setMode } = useColorScheme(); - useEffect(() => { - const selectedThemeMode = context.globals.themeMode || TextLight; - const nextMode = selectedThemeMode === TextLight ? "light" : "dark"; + React.useEffect(() => { + const selectedThemeMode = context.globals.themeMode || TextSystem; - setMode(nextMode); - document.documentElement.setAttribute("data-mode", nextMode); + 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 a46c2aa7..5eab3b85 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,6 +1,6 @@ import React, { useLayoutEffect } from "react"; -import { CssBaseline } from "@mui/material"; import type { Preview } from "@storybook/react"; + import { ThemeProvider } from "../src"; import { GenericTheme, @@ -8,22 +8,45 @@ import { DiamondDSTheme, DiamondDSThemeDark, } from "../src"; -import { Context, ThemeSwapper, TextLight, TextDark } from "./ThemeSwapper"; + +import { + Context, + ThemeSwapper, + TextDark, + TextLight, + TextSystem, +} from "./ThemeSwapper"; const TextThemeBase = "Theme: Generic"; const TextThemeDiamond = "Theme: Diamond"; +const TextThemeDiamondDS = "Theme: Diamond DS"; + +function resolveThemeMode( + selectedThemeMode: string, +): "light" | "dark" | "system" { + if (selectedThemeMode === TextLight) return "light"; + if (selectedThemeMode === TextDark) return "dark"; -const TextThemeDiamondDS = "Theme: DiamondDS"; + return "system"; +} + +function getSystemMode(): "light" | "dark" { + return window.matchMedia?.("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} -function resolveTheme(selectedTheme: string, mode: "light" | "dark") { +function resolveTheme(selectedTheme: string, resolvedMode: "light" | "dark") { switch (selectedTheme) { case TextThemeBase: return GenericTheme; - case TextThemeDiamondDS: - return mode === "dark" ? DiamondDSThemeDark : DiamondDSTheme; + case TextThemeDiamond: - default: return DiamondTheme; + + case TextThemeDiamondDS: + default: + return resolvedMode === "dark" ? DiamondDSThemeDark : DiamondDSTheme; } } @@ -35,13 +58,11 @@ function ApplyModeToPreviewDoc({ doc: Document; }) { useLayoutEffect(() => { - const root = doc.documentElement; // - root.setAttribute("data-mode", mode); + const root = doc.documentElement; - // Optional: keep class too if your CSS supports it + root.setAttribute("data-mode", mode); root.classList.toggle("dark", mode === "dark"); root.classList.toggle("light", mode === "light"); - root.style.colorScheme = mode; }, [mode, doc]); @@ -56,28 +77,27 @@ 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; - const mode = selectedThemeMode === TextLight ? "light" : "dark"; + const selectedTheme = context.globals.theme || TextThemeDiamondDS; + const selectedThemeMode = context.globals.themeMode || TextSystem; + + const defaultMode = resolveThemeMode(selectedThemeMode); + const resolvedMode = + defaultMode === "system" ? getSystemMode() : defaultMode; - // ensure we target the preview iframe document const doc: Document = context?.canvasElement?.ownerDocument ?? document; + return ( - - - + + + + + ); }, @@ -94,20 +114,23 @@ const preview: Preview = { dynamicTitle: true, }, }, + themeMode: { description: "Global theme mode for components", toolbar: { - title: "Theme Mode", + title: "Theme mode", icon: "mirror", - items: [TextLight, TextDark], + items: [TextLight, TextDark, TextSystem], dynamicTitle: true, }, }, }, + initialGlobals: { theme: TextThemeDiamondDS, - themeMode: TextLight, + themeMode: TextSystem, }, + parameters: { controls: { matchers: { @@ -132,6 +155,7 @@ const preview: Preview = { }, }, }, + argTypes: { linkComponent: { control: false, diff --git a/src/styles/diamondDS/diamond-ds-roles.css b/src/styles/diamondDS/diamond-ds-roles.css index 4c826945..fb0fe46e 100644 --- a/src/styles/diamondDS/diamond-ds-roles.css +++ b/src/styles/diamondDS/diamond-ds-roles.css @@ -12,7 +12,8 @@ } :root, -:root[data-mode="light"] { +:root[data-mode="light"], +:root.light { /* Neutral */ --ds-background: #f6f6f9; @@ -201,7 +202,8 @@ --ds-focus-ring-highlight: var(--ds-highlight-accent); } -:root[data-mode="dark"] { +:root[data-mode="dark"], +:root.dark { /* Neutral */ --ds-background: #0e1017; From d7707e18d3010d2297285bd6df99c8b5c4b414a8 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Tue, 12 May 2026 14:36:11 +0100 Subject: [PATCH 07/12] Update DiamondDS theme documentation and brand roles - document theme architecture and semantic token model - simplified repeated conventions across intents - clarify action intent, status intent, and brand usage - add dedicated BrandPaletteColor typing with fixed brand roles - improve helper documentation and component override comments - keep CSS variables as the source of truth for theme roles - remove global Paper override - update theme colours to use grey primitives Improved focus handling and comments slightly --- src/styles/diamondDS/diamond-ds-roles.css | 108 +-- src/themes/DiamondDSTheme.ts | 775 ++++++++++++++-------- 2 files changed, 586 insertions(+), 297 deletions(-) diff --git a/src/styles/diamondDS/diamond-ds-roles.css b/src/styles/diamondDS/diamond-ds-roles.css index fb0fe46e..79f583f2 100644 --- a/src/styles/diamondDS/diamond-ds-roles.css +++ b/src/styles/diamondDS/diamond-ds-roles.css @@ -1,4 +1,5 @@ :root { + /* Neutral primitives */ --ds-grey-50: #f8f8fa; --ds-grey-100: #eef1f5; --ds-grey-200: #e6e9f0; @@ -9,25 +10,35 @@ --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"], :root.light { - /* Neutral */ - + /* Neutral roles */ --ds-background: #f6f6f9; --ds-background-channel: 246 246 249; --ds-surface: #ffffff; --ds-surface-channel: 255 255 255; - --ds-surface-container: #eef1f5; - --ds-surface-container-high: #e6e9f0; + --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: #1a1c23; - --ds-on-surface-variant: #5e6473; + --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; @@ -35,19 +46,34 @@ --ds-on-surface-channel: 26 28 35; --ds-on-surface-variant-channel: 80 85 99; - --ds-placeholder: #8a90a0; - --ds-placeholder-focus: #505563; + --ds-placeholder: var(--ds-grey-600); + --ds-placeholder-focus: var(--ds-grey-700); - --ds-border-subtle: #dde1e8; - --ds-border: #bcc2cd; - --ds-border-emphasis: #a5acb8; + --ds-border-subtle: var(--ds-grey-300); + --ds-border: var(--ds-grey-400); + --ds-border-emphasis: var(--ds-grey-500); - /* Overlay */ + /* 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-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; @@ -78,7 +104,10 @@ --ds-secondary-lightChannel: 39 173 183; --ds-secondary-darkChannel: 0 95 103; - /* Tertiary (Violet) */ + /* 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; @@ -103,6 +132,11 @@ --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; @@ -172,7 +206,10 @@ --ds-info-lightChannel: 111 143 232; --ds-info-darkChannel: 42 78 167; - /* Highlight */ + /* 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; @@ -187,37 +224,32 @@ --ds-highlight-lightChannel: 255 216 77; --ds-highlight-darkChannel: 184 147 0; - /* Focus */ + /* Focus roles */ --ds-focus-ring: var(--ds-primary-accent); --ds-focus-ring-width: 2px; --ds-focus-ring-offset: 2px; - - --ds-focus-ring-primary: var(--ds-primary-accent); - --ds-focus-ring-secondary: var(--ds-secondary-accent); - --ds-focus-ring-danger: var(--ds-danger-accent); - --ds-focus-ring-warning: var(--ds-warning-accent); - --ds-focus-ring-success: var(--ds-success-accent); - --ds-focus-ring-info: var(--ds-info-accent); - --ds-focus-ring-brand: var(--ds-brand-accent); - --ds-focus-ring-highlight: var(--ds-highlight-accent); } +/** + * Dark mode semantic roles. + * + * Values are tuned for dark surfaces rather than mechanically inverted from light mode. + */ :root[data-mode="dark"], :root.dark { - /* Neutral */ - - --ds-background: #0e1017; + /* Neutral roles */ + --ds-background: var(--ds-grey-dark-800); --ds-background-channel: 14 16 23; - --ds-surface: #161820; + --ds-surface: var(--ds-grey-dark-700); --ds-surface-channel: 22 24 32; - --ds-surface-container: #222632; - --ds-surface-container-high: #2c3140; + --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: #e8eaf0; - --ds-on-surface-variant: #b6bcc9; + --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; @@ -225,14 +257,14 @@ --ds-on-surface-channel: 232 234 240; --ds-on-surface-variant-channel: 182 188 201; - --ds-placeholder: #7a8191; - --ds-placeholder-focus: #b6bcc9; + --ds-placeholder: var(--ds-grey-dark-200); + --ds-placeholder-focus: var(--ds-grey-dark-100); - --ds-border-subtle: #3a3f4c; - --ds-border: #505664; - --ds-border-emphasis: #7c8394; + --ds-border-subtle: var(--ds-grey-dark-400); + --ds-border: var(--ds-grey-dark-300); + --ds-border-emphasis: var(--ds-grey-dark-200); - /* Overlay */ + /* 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); diff --git a/src/themes/DiamondDSTheme.ts b/src/themes/DiamondDSTheme.ts index 9d6eb70b..39a72123 100644 --- a/src/themes/DiamondDSTheme.ts +++ b/src/themes/DiamondDSTheme.ts @@ -1,9 +1,25 @@ +/** + * 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 { createTheme } 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"; @@ -20,47 +36,109 @@ import logoImageLight from "../public/diamond/logo-light.svg"; import logoImageDark from "../public/diamond/logo-dark.svg"; import logoShort from "../public/diamond/logo-short.svg"; +/** + * 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; }; -type IntentColour = - | "primary" - | "secondary" - | "error" - | "warning" - | "info" - | "success"; - +/** + * 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; + 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 IntentPaletteRecord = Partial>; +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?: IntentPaletteRecord; + 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 TypeBackground { default: string; @@ -86,20 +164,45 @@ declare module "@mui/material/styles" { } interface Palette { - brand?: PaletteColor; + /** + * 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?: SimplePaletteColorOptions; + brand?: BrandPaletteOptions; + borders?: { subtle?: string; base?: string; @@ -136,53 +239,216 @@ declare module "@mui/material/styles" { export type DSMode = "light" | "dark"; -// --- Helpers --- +// --- 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 fallbackPalette = palette[colour]; - const varsPalette = vars?.palette?.[colour]; + const paletteColour = palette[colour]; + const varsColour = vars?.palette?.[colour]; - if (process.env.NODE_ENV !== "production" && !fallbackPalette) { + if (paletteColour) { + return { + ...paletteColour, + ...varsColour, + }; + } + + if (process.env.NODE_ENV !== "production") { console.warn( - `[DiamondDS] getIntentPalette: colour "${colour}" not found in palette`, + `[DiamondDS] getIntentPalette: colour "${colour}" not found. Falling back to primary.`, ); } return { - ...fallbackPalette, - ...varsPalette, + ...palette.primary, + ...vars?.palette?.primary, }; }; -const getFocusToken = (colour?: IntentColour) => { - if (!colour) return "var(--ds-focus-ring)"; - - return `var(--ds-focus-ring-${colour === "error" ? "danger" : colour})`; -}; - -const getFocusOutline = (token?: string): CSSObject => ({ +/** + * 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", - outlineColor: token ?? "var(--ds-focus-ring)", + 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}`; -// --- Theme factory --- +/** + * 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. + */ export const createDiamondTheme = (mode: DSMode): Theme => { + const intentPalette = createIntentPalette(); + const DiamondDSThemeOptions = mergeThemeOptions({ typography: { fontFamily: [ - "Inter Variable", + '"Inter Variable"', "Inter", "system-ui", "-apple-system", @@ -212,6 +478,13 @@ export const createDiamondTheme = (mode: DSMode): Theme => { palette: { 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)", @@ -225,6 +498,12 @@ export const createDiamondTheme = (mode: DSMode): Theme => { 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)", @@ -255,117 +534,13 @@ export const createDiamondTheme = (mode: DSMode): Theme => { strong: "var(--ds-surface-container-high)", }, - primary: { - light: "var(--ds-primary-accent)", - main: "var(--ds-primary)", - dark: "var(--ds-primary-emphasis)", - contrastText: "var(--ds-on-primary)", - container: "var(--ds-primary-container)", - onContainer: "var(--ds-on-primary-container)", - solid: "var(--ds-primary-solid)", - onSolid: "var(--ds-on-primary-solid)", - - contrastTextChannel: "var(--ds-on-primary-channel)", - mainChannel: "var(--ds-primary-mainChannel)", - lightChannel: "var(--ds-primary-lightChannel)", - darkChannel: "var(--ds-primary-darkChannel)", - }, - - secondary: { - light: "var(--ds-secondary-accent)", - main: "var(--ds-secondary)", - dark: "var(--ds-secondary-emphasis)", - contrastText: "var(--ds-on-secondary)", - container: "var(--ds-secondary-container)", - onContainer: "var(--ds-on-secondary-container)", - solid: "var(--ds-secondary-solid)", - onSolid: "var(--ds-on-secondary-solid)", - - contrastTextChannel: "var(--ds-on-secondary-channel)", - mainChannel: "var(--ds-secondary-mainChannel)", - lightChannel: "var(--ds-secondary-lightChannel)", - darkChannel: "var(--ds-secondary-darkChannel)", - }, - - brand: { - light: "var(--ds-brand-accent)", - main: "var(--ds-brand)", - dark: "var(--ds-brand-emphasis)", - contrastText: "var(--ds-on-brand)", - container: "var(--ds-brand-container)", - onContainer: "var(--ds-on-brand-container)", - solid: "var(--ds-brand-solid)", - onSolid: "var(--ds-on-brand-solid)", - - contrastTextChannel: "var(--ds-on-brand-channel)", - mainChannel: "var(--ds-brand-mainChannel)", - lightChannel: "var(--ds-brand-lightChannel)", - darkChannel: "var(--ds-brand-darkChannel)", - }, - - error: { - light: "var(--ds-danger-accent)", - main: "var(--ds-danger)", - dark: "var(--ds-danger-emphasis)", - contrastText: "var(--ds-on-danger)", - container: "var(--ds-danger-container)", - onContainer: "var(--ds-on-danger-container)", - solid: "var(--ds-danger-solid)", - onSolid: "var(--ds-on-danger-solid)", - - contrastTextChannel: "var(--ds-on-danger-channel)", - mainChannel: "var(--ds-danger-mainChannel)", - lightChannel: "var(--ds-danger-lightChannel)", - darkChannel: "var(--ds-danger-darkChannel)", - }, + ...intentPalette, - warning: { - light: "var(--ds-warning-accent)", - main: "var(--ds-warning)", - dark: "var(--ds-warning-emphasis)", - contrastText: "var(--ds-on-warning)", - container: "var(--ds-warning-container)", - onContainer: "var(--ds-on-warning-container)", - solid: "var(--ds-warning-solid)", - onSolid: "var(--ds-on-warning-solid)", - - contrastTextChannel: "var(--ds-on-warning-channel)", - mainChannel: "var(--ds-warning-mainChannel)", - lightChannel: "var(--ds-warning-lightChannel)", - darkChannel: "var(--ds-warning-darkChannel)", - }, - - success: { - light: "var(--ds-success-accent)", - main: "var(--ds-success)", - dark: "var(--ds-success-emphasis)", - contrastText: "var(--ds-on-success)", - container: "var(--ds-success-container)", - onContainer: "var(--ds-on-success-container)", - solid: "var(--ds-success-solid)", - onSolid: "var(--ds-on-success-solid)", - - contrastTextChannel: "var(--ds-on-success-channel)", - mainChannel: "var(--ds-success-mainChannel)", - lightChannel: "var(--ds-success-lightChannel)", - darkChannel: "var(--ds-success-darkChannel)", - }, - - info: { - light: "var(--ds-info-accent)", - main: "var(--ds-info)", - dark: "var(--ds-info-emphasis)", - contrastText: "var(--ds-on-info)", - container: "var(--ds-info-container)", - onContainer: "var(--ds-on-info-container)", - solid: "var(--ds-info-solid)", - onSolid: "var(--ds-on-info-solid)", - - contrastTextChannel: "var(--ds-on-info-channel)", - mainChannel: "var(--ds-info-mainChannel)", - lightChannel: "var(--ds-info-lightChannel)", - darkChannel: "var(--ds-info-darkChannel)", - }, + /** + * 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: "var(--ds-grey-50)", @@ -382,26 +557,75 @@ export const createDiamondTheme = (mode: DSMode): Theme => { }, components: { - MuiPaper: { - styleOverrides: { - root: { - backgroundImage: "none", - }, - }, - }, + /** + * 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: true, - disableTouchRipple: true, + 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, @@ -410,6 +634,10 @@ export const createDiamondTheme = (mode: DSMode): Theme => { const base: CSSObject = { textTransform: "none", boxShadow: "none", + + "&:hover": { + boxShadow: "none", + }, }; const variant = ownerState.variant ?? "text"; @@ -422,68 +650,60 @@ export const createDiamondTheme = (mode: DSMode): Theme => { }; } - const colour = rawColour as IntentColour; + const colour = getIntentFromColourProp(rawColour); const p = getIntentPalette(theme, colour); - const focusToken = getFocusToken(colour); - const subtle = p.container; - const onSubtle = p.onContainer; if (variant === "contained") { return { ...base, - ...getFocusOutline(focusToken), - backgroundColor: p.solid ?? p.main, - color: p.onSolid ?? "var(--ds-on-solid)", - "&:hover": { - backgroundColor: p.solid ?? p.main, - boxShadow: getOverlayInset("var(--ds-overlay-hover-solid)"), - }, + backgroundColor: p.solid, + color: p.onSolid, - "&:active": { - backgroundColor: p.solid ?? p.main, - boxShadow: getOverlayInset("var(--ds-overlay-selected)"), - }, + ...getInteractiveSurfaceStateStyles( + p.solid, + "var(--ds-overlay-hover-solid)", + ), "&.Mui-focusVisible": { - outline: "var(--ds-focus-ring-width) solid", - outlineColor: focusToken, + 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": { - opacity: 1, - backgroundColor: "var(--ds-surface-disabled)", - color: "var(--ds-on-surface-disabled)", - boxShadow: "none", - }, + "&.Mui-disabled": getDisabledControlStyles( + "var(--ds-surface-disabled)", + ), }; } if (variant === "outlined") { return { ...base, - ...getFocusOutline(focusToken), + ...getFocusOutline(), - color: onSubtle, - backgroundColor: subtle, + color: p.onContainer, + backgroundColor: p.container, + border: `1px solid ${p.light}`, + + ...getInteractiveSurfaceStateStyles(p.container), "&:hover": { - backgroundColor: subtle, + backgroundColor: p.container, + borderColor: p.main, boxShadow: getOverlayInset(), }, "&:active": { - backgroundColor: subtle, + backgroundColor: p.container, + borderColor: p.dark, boxShadow: getOverlayInset("var(--ds-overlay-selected)"), }, "&.Mui-disabled": { - opacity: 1, - backgroundColor: "transparent", - color: "var(--ds-on-surface-disabled)", - boxShadow: "none", + ...getDisabledControlStyles(), + borderColor: "var(--ds-border-subtle)", }, }; } @@ -491,27 +711,36 @@ export const createDiamondTheme = (mode: DSMode): Theme => { if (variant === "text") { return { ...base, - ...getFocusOutline(focusToken), + ...getFocusOutline(), + color: p.main, "&:hover": { - backgroundColor: subtle, + backgroundColor: p.container, boxShadow: getOverlayInset(), }, + + "&.Mui-disabled": { + color: "var(--ds-on-surface-disabled)", + }, }; } return { ...base, - ...getFocusOutline(focusToken), + ...getFocusOutline(), }; }, }, }, MuiIconButton: { + /** + * IconButton follows the same intent model as Button, but default/inherit + * colours stay neutral unless an explicit intent is provided. + */ defaultProps: { - disableRipple: true, + disableRipple: false, disableFocusRipple: true, }, styleOverrides: { @@ -528,13 +757,17 @@ export const createDiamondTheme = (mode: DSMode): Theme => { "&:hover": { boxShadow: getOverlayInset(), }, + "&.Mui-disabled": { + color: "var(--ds-on-surface-disabled)", + backgroundColor: "transparent", + boxShadow: "none", + }, ...getFocusOutline(), }; } - const colour = rawColour as IntentColour; + const colour = getIntentFromColourProp(rawColour); const p = getIntentPalette(theme, colour); - const focusToken = getFocusToken(colour); return { color: p.main, @@ -544,7 +777,12 @@ export const createDiamondTheme = (mode: DSMode): Theme => { boxShadow: getOverlayInset(), }, - ...getFocusOutline(focusToken), + "&.Mui-disabled": { + color: "var(--ds-on-surface-disabled)", + backgroundColor: "transparent", + boxShadow: "none", + }, + ...getFocusOutline(), }; }, }, @@ -559,11 +797,33 @@ export const createDiamondTheme = (mode: DSMode): Theme => { "&: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 = { @@ -580,65 +840,48 @@ export const createDiamondTheme = (mode: DSMode): Theme => { ); if (isDefault) { + const backgroundColor = "var(--ds-surface-container-high)"; + return { ...base, ...(isInteractive ? getFocusOutline() : {}), color: "var(--ds-on-surface)", borderColor: "var(--ds-border)", - backgroundColor: "var(--ds-surface-container-high)", + backgroundColor, ...(isInteractive && { - "&:hover": { - backgroundColor: "var(--ds-surface-container-high)", - boxShadow: getOverlayInset(), - }, - - "&:active": { - backgroundColor: "var(--ds-surface-container-high)", - boxShadow: getOverlayInset("var(--ds-overlay-selected)"), - }, + ...getInteractiveSurfaceStateStyles(backgroundColor), "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible": { - backgroundColor: "var(--ds-surface-container-high)", + backgroundColor, boxShadow: getOverlayInset("var(--ds-overlay-focus)"), }, "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover": { - backgroundColor: "var(--ds-surface-container-high)", + backgroundColor, boxShadow: getOverlayInset("var(--ds-overlay-focus)"), }, }), }; } - const colour = rawColour as IntentColour; + const colour = getIntentFromColourProp(rawColour); const p = getIntentPalette(theme, colour); - const focusToken = getFocusToken(colour); if (isOutlined) { return { ...base, - ...(isInteractive ? getFocusOutline(focusToken) : {}), + ...(isInteractive ? getFocusOutline() : {}), color: p.onContainer, borderColor: p.light, backgroundColor: p.container, ...(isInteractive && { - "&:hover": { - backgroundColor: p.container, - borderColor: p.light, - boxShadow: getOverlayInset(), - }, - - "&:active": { - backgroundColor: p.container, - borderColor: p.light, - boxShadow: getOverlayInset("var(--ds-overlay-selected)"), - }, + ...getInteractiveSurfaceStateStyles(p.container), "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible": { @@ -657,35 +900,28 @@ export const createDiamondTheme = (mode: DSMode): Theme => { }; } - const solid = p.solid ?? p.main; - return { ...base, - ...(isInteractive ? getFocusOutline(focusToken) : {}), + ...(isInteractive ? getFocusOutline() : {}), - color: p.onSolid ?? "var(--ds-on-solid)", - backgroundColor: solid, + color: p.onSolid, + backgroundColor: p.solid, ...(isInteractive && { - "&:hover": { - backgroundColor: solid, - boxShadow: getOverlayInset("var(--ds-overlay-hover-solid)"), - }, - - "&:active": { - backgroundColor: solid, - boxShadow: getOverlayInset("var(--ds-overlay-selected)"), - }, + ...getInteractiveSurfaceStateStyles( + p.solid, + "var(--ds-overlay-hover-solid)", + ), "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible": { - backgroundColor: solid, + backgroundColor: p.solid, boxShadow: getOverlayInset("var(--ds-overlay-focus)"), }, "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover": { - backgroundColor: solid, + backgroundColor: p.solid, boxShadow: getOverlayInset("var(--ds-overlay-focus)"), }, }), @@ -728,6 +964,7 @@ export const createDiamondTheme = (mode: DSMode): Theme => { }), 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, @@ -745,13 +982,19 @@ export const createDiamondTheme = (mode: DSMode): Theme => { 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 = (ownerState.color ?? "primary") as IntentColour; + const colour = getIntentFromColourProp(ownerState.color); const p = getIntentPalette(theme, colour); - const focusToken = getFocusToken(colour); return { "& .MuiOutlinedInput-notchedOutline": { @@ -790,8 +1033,8 @@ export const createDiamondTheme = (mode: DSMode): Theme => { }, "&.Mui-focusVisible": { - outline: "var(--ds-focus-ring-width) solid", - outlineColor: focusToken, + outline: + "var(--ds-focus-ring-width) solid var(--ds-focus-ring)", outlineOffset: "var(--ds-focus-ring-offset)", }, @@ -880,12 +1123,19 @@ export const createDiamondTheme = (mode: DSMode): Theme => { }, 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 = (ownerState.severity ?? "success") as IntentColour; + const severity = getIntentFromColourProp( + ownerState.severity, + "success", + ); const p = getIntentPalette(theme, severity); const common: CSSObject = { @@ -909,8 +1159,8 @@ export const createDiamondTheme = (mode: DSMode): Theme => { if (ownerState.variant === "filled") { return { ...common, - backgroundColor: p.solid ?? p.main, - color: p.onSolid ?? "var(--ds-on-solid)", + backgroundColor: p.solid, + color: p.onSolid, }; } @@ -933,6 +1183,10 @@ export const createDiamondTheme = (mode: DSMode): Theme => { }, }, + /** + * 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: { @@ -946,7 +1200,7 @@ export const createDiamondTheme = (mode: DSMode): Theme => { ownerState, theme, }: OverrideArgs): CSSObject => { - const colour = (ownerState.color ?? "primary") as IntentColour; + const colour = getIntentFromColourProp(ownerState.color); const p = getIntentPalette(theme, colour); return { @@ -962,7 +1216,7 @@ export const createDiamondTheme = (mode: DSMode): Theme => { ownerState, theme, }: OverrideArgs): CSSObject => { - const colour = (ownerState.color ?? "primary") as IntentColour; + const colour = getIntentFromColourProp(ownerState.color); const p = getIntentPalette(theme, colour); return { @@ -1040,10 +1294,9 @@ export const createDiamondTheme = (mode: DSMode): Theme => { }: OverrideArgs): CSSObject => { const rawColour = ownerState.color ?? "primary"; const isDefault = rawColour === "default"; - const colour = rawColour as IntentColour; + const colour = getIntentFromColourProp(rawColour); const p = !isDefault ? getIntentPalette(theme, colour) : null; - const focusToken = !isDefault ? getFocusToken(colour) : undefined; return { color: "var(--ds-on-surface-variant)", @@ -1053,7 +1306,7 @@ export const createDiamondTheme = (mode: DSMode): Theme => { backgroundColor: "var(--ds-overlay-hover)", }, - ...getFocusOutline(focusToken), + ...getFocusOutline(), "&.Mui-checked": { color: isDefault ? "var(--ds-on-surface)" : p?.main, @@ -1082,10 +1335,9 @@ export const createDiamondTheme = (mode: DSMode): Theme => { }: OverrideArgs): CSSObject => { const rawColour = ownerState.color ?? "primary"; const isDefault = rawColour === "default"; - const colour = rawColour as IntentColour; + const colour = getIntentFromColourProp(rawColour); const p = !isDefault ? getIntentPalette(theme, colour) : null; - const focusToken = !isDefault ? getFocusToken(colour) : undefined; return { color: "var(--ds-on-surface-variant)", @@ -1095,7 +1347,7 @@ export const createDiamondTheme = (mode: DSMode): Theme => { backgroundColor: "var(--ds-overlay-hover)", }, - ...getFocusOutline(focusToken), + ...getFocusOutline(), "&.Mui-checked": { color: isDefault ? "var(--ds-on-surface)" : p?.main, @@ -1114,9 +1366,14 @@ export const createDiamondTheme = (mode: DSMode): Theme => { return createTheme(DiamondDSThemeOptions); }; -// Convenience exports — derive from the factory so they stay in sync. +/** + * Pre-built themes for convenience. + * Most apps can use these directly; use createDiamondTheme() for custom modes. + */ export const DiamondDSTheme = createDiamondTheme("light"); export const DiamondDSThemeDark = createDiamondTheme("dark"); -// Keep the old export name as an alias for backwards compatibility. +/** + * Backwards compatibility alias. Use createDiamondTheme() for new code. + */ export const createMuiTheme = createDiamondTheme; From 6a99d5246eaf76a9c2b36f925281599155935b98 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Wed, 13 May 2026 16:33:00 +0000 Subject: [PATCH 08/12] Added practical guidance with key consideration --- src/storybook/practical-guidance.mdx | 429 +++++++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 src/storybook/practical-guidance.mdx diff --git a/src/storybook/practical-guidance.mdx b/src/storybook/practical-guidance.mdx new file mode 100644 index 00000000..8d9dba2a --- /dev/null +++ b/src/storybook/practical-guidance.mdx @@ -0,0 +1,429 @@ +import { Meta } from "@storybook/blocks"; + + + + + +
+ +# Practical guidance + +Diamond Design System uses MUI with Diamond semantic tokens layered on top. + +CSS variables are the source of truth. The MUI theme adapts those tokens for components and implementation. + +## Using the theme + +Prefer semantic tokens, existing component variants, and reusable patterns. + +Avoid hardcoded values, one-off styling, and inconsistent interaction patterns. + +## Using components + +Build with existing components and patterns before creating new ones. + +Components should keep interfaces predictable, readable, and consistent. + +
+
+

Do

+
    +
  • Reuse established layouts and behaviours.
  • +
  • Use standard component variants consistently.
  • +
  • Keep disabled and error states visually clear.
  • +
  • Use spacing and typography for hierarchy.
  • +
+
+ +
+

Don’t

+
    +
  • Don’t restyle components screen by screen.
  • +
  • Don’t introduce custom colours unnecessarily.
  • +
  • Don’t create multiple patterns for the same interaction.
  • +
  • Don’t override the theme without a reusable reason.
  • +
+
+
+ +## Colour usage + +Use colour semantically. Separate structure, meaning, and brand. + +DiamondDS supports: + +- action intents: primary, secondary +- status intents: success, warning, error, info + +Intent colours communicate hierarchy, operational meaning, and state through component APIs such as `color="primary"` or `color="error"`. +Their meaning depends on context and workflow. + +Brand colour is intentionally separate. Brand communicates Diamond identity rather than behaviour or status. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RoleTypePurposeTypical usage
+ + Neutral + StructureLayout and hierarchyBackgrounds, surfaces, borders, tables
+ + Primary + Action intentMain actions and emphasisPrimary actions, selected states
+ + Secondary + Action intentSupporting actions and alternate emphasisSecondary actions, filters, supporting controls
+ + Success + Status intentConfirmed, available, or operationally valid statesCompletion, confirmation, active or valid states
+ + Warning + Status intentAttention, caution, or elevated operational awarenessRisk states, hazardous conditions, attention-required states
+ + Error (Danger) + Status intentCritical, blocking, hazardous, or destructive statesErrors, failed actions, emergency-related or destructive actions
+ + Info + Status intentInformational, contextual, or progress-related feedbackProgress, guidance, scanning, movement, or system messages
+ + Brand + IdentityFacility identityBranding moments and facility identity
+ +
+
+

Do

+
    +
  • Use neutral roles for structure.
  • +
  • Use intent roles for meaning.
  • +
  • Let tokens work across light and dark themes.
  • +
  • Ensure disabled and error states override other styling.
  • +
+
+ +
+

Don’t

+
    +
  • Don’t hardcode hex values.
  • +
  • Don’t use strong intent colours as layout backgrounds.
  • +
  • Don’t rely on colour alone to communicate state.
  • +
  • Don’t invent one-off colour behaviour.
  • +
+
+
+ +## Surfaces and elevation + +Prefer layered surfaces and borders over heavy shadows. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenPurposeMUI mapping
+ --ds-bg-page + Root application background + background.default +
+ --ds-surface + Base content surface + background.paper +
+ --ds-surface-container + Standard container surface + surface.subtle +
+ --ds-surface-container-high + Highlighted container + surface.strong +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementRecommended treatment
Top nav bar + Elevation 0 + border-bottom. Elevate to 1–2 on scroll when sticky and no + secondary nav is present +
SidebarElevation 0 + border-right
Secondary header/nav barElevation 1. Elevate to 1–2 on scroll when sticky
Cards + Elevation 0-2 + subtle border, or no border when the surface is already + clear +
Interactive cardsElevation 3–4, such as drag/drop cards
Menus, popovers, drawersElevation 8–16
DialogsElevation 16–24
+ +## Typography usage + +Typography should support readability, scanning, and technical clarity. + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypefacePurposeTypical usage
InterDefault interface typefaceUI text, controls, tables, navigation
OutfitBrand and high-level headingsProduct headings, landing areas
IBM Plex MonoTechnical and aligned contentIDs, timestamps, code, numeric values
+ +
+
+

Do

+
    +
  • Keep hierarchy consistent across products.
  • +
  • Prefer short, clear labels.
  • +
  • Make numbers and technical values easy to scan.
  • +
  • Use mono type where alignment matters.
  • +
+
+ +
+

Don’t

+
    +
  • Don’t use brand typography for dense operational content.
  • +
  • Don’t rely on decorative styling for hierarchy.
  • +
  • Don’t hide important information in faint text.
  • +
  • Don’t introduce one-off font sizes.
  • +
+
+
+ +## General principles + +
    +
  • Prefer semantic tokens over raw values.
  • +
  • Reuse patterns before creating new ones.
  • +
  • Keep interactions predictable.
  • +
  • Design for clarity and operational confidence.
  • +
  • Similar things should look and behave similarly.
  • +
+ +
From 39bf3a7529bc7fdd84891d6ea32cc0ee0cdf4f70 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Tue, 19 May 2026 15:11:04 +0100 Subject: [PATCH 09/12] Update theme to be using new method to switch theme Merging changes by @akademy @VictoriaBeilsten-Edmands branches for /new-dstheme Using "data-mode" Updated Channel spcific requirements for MUI --- .storybook/ThemeSwapper.tsx | 9 +- .storybook/preview.tsx | 89 +- src/styles/diamondDS/diamond-ds-roles.css | 14 +- src/themes/DiamondDSTheme.ts | 1503 +++++++++++---------- src/themes/Theme.test.tsx | 42 + src/themes/ThemeProvider.tsx | 29 +- 6 files changed, 856 insertions(+), 830 deletions(-) create mode 100644 src/themes/Theme.test.tsx diff --git a/.storybook/ThemeSwapper.tsx b/.storybook/ThemeSwapper.tsx index e7a3f8b8..280ff099 100644 --- a/.storybook/ThemeSwapper.tsx +++ b/.storybook/ThemeSwapper.tsx @@ -1,5 +1,6 @@ -import { useColorScheme } from "@mui/material/styles"; +import { useColorScheme } from "@mui/material"; import * as React from "react"; +import { useEffect } from "react"; interface Globals { theme: string; @@ -22,8 +23,8 @@ export const TextSystem = "Mode: System"; const ThemeSwapper = ({ context, children }: ThemeSwapperProps) => { const { mode, systemMode, setMode } = useColorScheme(); - React.useEffect(() => { - const selectedThemeMode = context.globals.themeMode || TextSystem; + useEffect(() => { + const selectedThemeMode = context.globals.themeMode ?? TextSystem; if (selectedThemeMode === TextLight) { setMode("light"); @@ -43,7 +44,7 @@ const ThemeSwapper = ({ context, children }: ThemeSwapperProps) => { return (
{children} diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 5eab3b85..2577bfc5 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,72 +1,33 @@ -import React, { useLayoutEffect } from "react"; +import React from "react"; +import { CssBaseline } from "@mui/material"; import type { Preview } from "@storybook/react"; import { ThemeProvider } from "../src"; -import { - GenericTheme, - DiamondTheme, - DiamondDSTheme, - DiamondDSThemeDark, -} from "../src"; - -import { - Context, - ThemeSwapper, - TextDark, - TextLight, - TextSystem, -} 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: Diamond DS"; - -function resolveThemeMode( - selectedThemeMode: string, -): "light" | "dark" | "system" { - if (selectedThemeMode === TextLight) return "light"; - if (selectedThemeMode === TextDark) return "dark"; - - return "system"; -} - -function getSystemMode(): "light" | "dark" { - return window.matchMedia?.("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; -} +const TextThemeDiamondDS = "Theme: DiamondDS"; -function resolveTheme(selectedTheme: string, resolvedMode: "light" | "dark") { +function resolveTheme(selectedTheme: string) { switch (selectedTheme) { case TextThemeBase: return GenericTheme; - case TextThemeDiamond: return DiamondTheme; - case TextThemeDiamondDS: default: - return resolvedMode === "dark" ? DiamondDSThemeDark : DiamondDSTheme; + return DiamondDSTheme; } } -function ApplyModeToPreviewDoc({ - mode, - doc, -}: { - mode: "light" | "dark"; - doc: Document; -}) { - useLayoutEffect(() => { - const root = doc.documentElement; - - root.setAttribute("data-mode", mode); - root.classList.toggle("dark", mode === "dark"); - root.classList.toggle("light", mode === "light"); - root.style.colorScheme = mode; - }, [mode, doc]); +function resolveDefaultMode(selectedThemeMode: string) { + if (selectedThemeMode === TextLight) return "light"; + if (selectedThemeMode === TextDark) return "dark"; - return null; + return "system"; } export const decorators = [ @@ -78,25 +39,19 @@ export const decorators = [ ); }, - (StoriesWithThemeProvider: React.FC, context: Context) => { + (Story, context) => { const selectedTheme = context.globals.theme || TextThemeDiamondDS; const selectedThemeMode = context.globals.themeMode || TextSystem; - const defaultMode = resolveThemeMode(selectedThemeMode); - const resolvedMode = - defaultMode === "system" ? getSystemMode() : defaultMode; - - const doc: Document = context?.canvasElement?.ownerDocument ?? document; - return ( - + - + ); @@ -114,23 +69,20 @@ const preview: Preview = { dynamicTitle: true, }, }, - themeMode: { description: "Global theme mode for components", toolbar: { - title: "Theme mode", + title: "Theme Mode", icon: "mirror", items: [TextLight, TextDark, TextSystem], dynamicTitle: true, }, }, }, - initialGlobals: { theme: TextThemeDiamondDS, themeMode: TextSystem, }, - parameters: { controls: { matchers: { @@ -144,18 +96,15 @@ const preview: Preview = { storySort: { order: [ "Introduction", - "Foundation", - "Helpers", - "MUI", "Components", "Theme", "Theme/Logos", "Theme/Colours", + "Helpers", ], }, }, }, - argTypes: { linkComponent: { control: false, diff --git a/src/styles/diamondDS/diamond-ds-roles.css b/src/styles/diamondDS/diamond-ds-roles.css index 79f583f2..2ccf5815 100644 --- a/src/styles/diamondDS/diamond-ds-roles.css +++ b/src/styles/diamondDS/diamond-ds-roles.css @@ -24,8 +24,9 @@ /* Light mode semantic roles */ :root, -:root[data-mode="light"], -:root.light { +:root[data-mode="light"] { + color-scheme: light; + /* Neutral roles */ --ds-background: #f6f6f9; --ds-background-channel: 246 246 249; @@ -52,6 +53,7 @@ --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 * @@ -60,6 +62,7 @@ --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 @@ -235,8 +238,9 @@ * * Values are tuned for dark surfaces rather than mechanically inverted from light mode. */ -:root[data-mode="dark"], -:root.dark { +:root[data-mode="dark"] { + color-scheme: dark; + /* Neutral roles */ --ds-background: var(--ds-grey-dark-800); --ds-background-channel: 14 16 23; @@ -263,11 +267,13 @@ --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 */ diff --git a/src/themes/DiamondDSTheme.ts b/src/themes/DiamondDSTheme.ts index 39a72123..4eaea085 100644 --- a/src/themes/DiamondDSTheme.ts +++ b/src/themes/DiamondDSTheme.ts @@ -14,7 +14,7 @@ import "../styles/diamondDS/diamond-ds-roles.css"; // Enables `theme.vars` typings for MUI CSS variable themes. import type {} from "@mui/material/themeCssVarsAugmentation"; -import { createTheme } from "@mui/material/styles"; +import { extendTheme } from "@mui/material/styles"; import type { CSSObject, Theme } from "@mui/material/styles"; /** @@ -30,11 +30,10 @@ import type { OutlinedInputProps } from "@mui/material/OutlinedInput"; import type { RadioProps } from "@mui/material/Radio"; import type { TabProps } from "@mui/material/Tab"; -import { mergeThemeOptions } from "./ThemeManager"; - 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. @@ -140,6 +139,20 @@ type ThemeWithIntentPalette = Theme & { * 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; @@ -442,334 +455,307 @@ const getDisabledControlStyles = (backgroundColor = "transparent"): CSSObject => * * The resulting theme should expose semantic roles rather than raw colours. */ -export const createDiamondTheme = (mode: DSMode): Theme => { + +/** + * 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(); - const DiamondDSThemeOptions = mergeThemeOptions({ - typography: { - fontFamily: [ - '"Inter Variable"', - "Inter", - "system-ui", - "-apple-system", - '"Segoe UI"', - "Roboto", - "Helvetica", - "Arial", - "sans-serif", - ].join(","), + 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, }, - logos: { - normal: { - src: - mode === "dark" ? (logoImageDark ?? logoImageLight) : logoImageLight, - srcDark: logoImageDark ?? logoImageLight, - alt: "Diamond Light Source Logo", - width: "100", - }, - short: { - src: logoShort, - alt: "Diamond Light Source Logo", - width: "35", - }, + /** + * 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)", }, - palette: { - mode, + background: { + default: "rgb(var(--ds-background-channel))", + paper: "rgb(var(--ds-surface-channel))", + }, - /** - * 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)", - 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, - }, + divider: "var(--ds-border-subtle)", + dividerChannel: "var(--ds-border-subtle-channel)", - /** - * 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)", - }, + borders: { + subtle: "var(--ds-border-subtle)", + base: "var(--ds-border)", + emphasis: "var(--ds-border-emphasis)", + }, - background: { - default: "rgb(var(--ds-background-channel))", - paper: "rgb(var(--ds-surface-channel))", - }, + surface: { + subtle: "var(--ds-surface-container)", + strong: "var(--ds-surface-container-high)", + }, - divider: "var(--ds-border-subtle)", + ...intentPalette, - borders: { - subtle: "var(--ds-border-subtle)", - base: "var(--ds-border)", - emphasis: "var(--ds-border-emphasis)", - }, + /** + * 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: "var(--ds-grey-50)", + 100: "var(--ds-grey-100)", + 200: "var(--ds-grey-200)", + 300: "var(--ds-grey-300)", + 400: "var(--ds-grey-400)", + 500: "var(--ds-grey-500)", + 600: "var(--ds-grey-600)", + 700: "var(--ds-grey-700)", + 800: "var(--ds-grey-800)", + 900: "var(--ds-grey-900)", + }, + }; +}; - surface: { - subtle: "var(--ds-surface-container)", - strong: "var(--ds-surface-container-high)", - }, +/** + * 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(","), + }, - ...intentPalette, + 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: { /** - * 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. + * Keeps MUI ripple behaviour available while using DiamondDS focus outlines. */ - brand: createBrandPaletteColour(), - - grey: { - 50: "var(--ds-grey-50)", - 100: "var(--ds-grey-100)", - 200: "var(--ds-grey-200)", - 300: "var(--ds-grey-300)", - 400: "var(--ds-grey-400)", - 500: "var(--ds-grey-500)", - 600: "var(--ds-grey-600)", - 700: "var(--ds-grey-700)", - 800: "var(--ds-grey-800)", - 900: "var(--ds-grey-900)", + defaultProps: { + disableRipple: false, + disableTouchRipple: false, + focusRipple: false, }, }, - components: { + MuiButton: { /** - * 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 + * Button uses the DiamondDS intent model: * - * Progress and loading: - * MuiLinearProgress → semantic activity indicators - * MuiCircularProgress → semantic activity indicators - * MuiSkeleton → loading placeholders and shimmer + * - contained = solid action surface + * - outlined = subtle intent container with border + * - text = low-emphasis action * - * Selection controls: - * MuiCheckbox → checked and disabled states - * MuiRadio → checked and disabled states - * - * Feedback surfaces: - * MuiSnackbar → layout constraints - * MuiSnackbarContent → surface styling and actions + * Disabled styles are declared inside each variant so they override + * hover, active and focus treatments for that variant. */ - - MuiButtonBase: { - /** - * Keeps MUI ripple behaviour available while using DiamondDS focus outlines. - */ - defaultProps: { - disableRipple: false, - disableTouchRipple: false, - focusRipple: false, - }, + defaultProps: { + disableFocusRipple: true, }, - 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", - styleOverrides: { - root: ({ - ownerState, - theme, - }: OverrideArgs): CSSObject => { - const base: CSSObject = { - textTransform: "none", + "&:hover": { boxShadow: "none", + }, + }; - "&:hover": { - boxShadow: "none", - }, + const variant = ownerState.variant ?? "text"; + const rawColour = ownerState.color ?? "primary"; + + if (rawColour === "inherit") { + return { + ...base, + ...getFocusOutline(), }; + } - const variant = ownerState.variant ?? "text"; - const rawColour = ownerState.color ?? "primary"; + const colour = getIntentFromColourProp(rawColour); + const p = getIntentPalette(theme, colour); - if (rawColour === "inherit") { - return { - ...base, - ...getFocusOutline(), - }; - } + if (variant === "contained") { + return { + ...base, - const colour = getIntentFromColourProp(rawColour); - const p = getIntentPalette(theme, colour); + backgroundColor: p.solid, + color: p.onSolid, - if (variant === "contained") { - return { - ...base, + ...getInteractiveSurfaceStateStyles( + p.solid, + "var(--ds-overlay-hover-solid)", + ), - backgroundColor: p.solid, - color: p.onSolid, + "&.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)"), + }, - ...getInteractiveSurfaceStateStyles( - p.solid, - "var(--ds-overlay-hover-solid)", - ), + "&.Mui-disabled": getDisabledControlStyles( + "var(--ds-surface-disabled)", + ), + }; + } - "&.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)"), - }, + if (variant === "outlined") { + return { + ...base, + ...getFocusOutline(), - "&.Mui-disabled": getDisabledControlStyles( - "var(--ds-surface-disabled)", - ), - }; - } + color: p.onContainer, + backgroundColor: p.container, + border: `1px solid ${p.light}`, - if (variant === "outlined") { - return { - ...base, - ...getFocusOutline(), + ...getInteractiveSurfaceStateStyles(p.container), - color: p.onContainer, + "&:hover": { 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, + borderColor: p.main, + boxShadow: getOverlayInset(), + }, - "&:hover": { - backgroundColor: p.container, - boxShadow: getOverlayInset(), - }, + "&:active": { + backgroundColor: p.container, + borderColor: p.dark, + boxShadow: getOverlayInset("var(--ds-overlay-selected)"), + }, - "&.Mui-disabled": { - color: "var(--ds-on-surface-disabled)", - }, - }; - } + "&.Mui-disabled": { + ...getDisabledControlStyles(), + borderColor: "var(--ds-border-subtle)", + }, + }; + } + if (variant === "text") { 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": { @@ -779,601 +765,654 @@ export const createDiamondTheme = (mode: DSMode): Theme => { "&.Mui-disabled": { color: "var(--ds-on-surface-disabled)", - backgroundColor: "transparent", - boxShadow: "none", }, - ...getFocusOutline(), }; - }, + } + + return { + ...base, + ...getFocusOutline(), + }; }, }, + }, - MuiToggleButton: { - styleOverrides: { - root: ({ theme }: ThemeOnlyArgs): CSSObject => ({ - textTransform: "none", - border: `1px solid ${theme.palette.borders.base}`, + 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(), + }; + } - "&:hover": { - borderColor: theme.palette.borders.emphasis, - }, + const colour = getIntentFromColourProp(rawColour); + const p = getIntentPalette(theme, colour); - "&.Mui-selected": { - backgroundColor: "var(--ds-primary-container)", - color: "var(--ds-on-primary-container)", - borderColor: "var(--ds-primary-accent)", - }, + return { + color: p.main, - "&.Mui-selected:hover": { - backgroundColor: "var(--ds-primary-container)", - borderColor: "var(--ds-primary)", + "&:hover": { + backgroundColor: p.container, boxShadow: getOverlayInset(), }, "&.Mui-disabled": { color: "var(--ds-on-surface-disabled)", - borderColor: "var(--ds-border-subtle)", + backgroundColor: "transparent", + boxShadow: "none", }, - }), + ...getFocusOutline(), + }; }, }, + }, - 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", - }, - }; + MuiToggleButton: { + styleOverrides: { + root: ({ theme }: ThemeOnlyArgs): CSSObject => ({ + textTransform: "none", + border: `1px solid ${theme.palette.borders.base}`, - 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, + "&: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); - ...(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)"), - }, - }), - }; - } + if (isDefault) { + const backgroundColor = "var(--ds-surface-container-high)"; return { ...base, ...(isInteractive ? getFocusOutline() : {}), - color: p.onSolid, - backgroundColor: p.solid, + color: "var(--ds-on-surface)", + borderColor: "var(--ds-border)", + backgroundColor, ...(isInteractive && { - ...getInteractiveSurfaceStateStyles( - p.solid, - "var(--ds-overlay-hover-solid)", - ), + ...getInteractiveSurfaceStateStyles(backgroundColor), "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible": { - backgroundColor: p.solid, + backgroundColor, boxShadow: getOverlayInset("var(--ds-overlay-focus)"), }, "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover": { - backgroundColor: p.solid, + backgroundColor, boxShadow: getOverlayInset("var(--ds-overlay-focus)"), }, }), }; - }, - }, - }, + } - MuiInputBase: { - styleOverrides: { - input: ({ theme }: ThemeOnlyArgs): CSSObject => ({ - "&::placeholder": { - color: theme.palette.text.placeholder, - opacity: 1, - }, + const colour = getIntentFromColourProp(rawColour); + const p = getIntentPalette(theme, colour); - "&::-webkit-input-placeholder": { - color: theme.palette.text.placeholder, - opacity: 1, - }, - - "&::-moz-placeholder": { - color: theme.palette.text.placeholder, - opacity: 1, - }, + if (isOutlined) { + return { + ...base, + ...(isInteractive ? getFocusOutline() : {}), - "&:focus::placeholder": { - color: theme.palette.text.placeholderFocus, - }, + color: p.onContainer, + borderColor: p.light, + backgroundColor: p.container, - "&:focus::-webkit-input-placeholder": { - color: theme.palette.text.placeholderFocus, - opacity: 1, - }, + ...(isInteractive && { + ...getInteractiveSurfaceStateStyles(p.container), - "&:focus::-moz-placeholder": { - color: theme.palette.text.placeholderFocus, - opacity: 1, - }, - }), + "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible": + { + backgroundColor: p.container, + borderColor: p.light, + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, - 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, - }, + "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover": + { + backgroundColor: p.container, + borderColor: p.light, + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + }), + }; + } - "&.Mui-disabled input::placeholder, &.Mui-disabled input::-webkit-input-placeholder, &.Mui-disabled input::-moz-placeholder": - { - color: theme.palette.text.disabled, - opacity: 1, - }, - }), - }, - }, + return { + ...base, + ...(isInteractive ? getFocusOutline() : {}), - 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); + color: p.onSolid, + backgroundColor: p.solid, - return { - "& .MuiOutlinedInput-notchedOutline": { - borderColor: theme.palette.borders.base, - }, + ...(isInteractive && { + ...getInteractiveSurfaceStateStyles( + p.solid, + "var(--ds-overlay-hover-solid)", + ), - "&:hover:not(.Mui-disabled):not(.Mui-error):not(.Mui-focused) .MuiOutlinedInput-notchedOutline": + "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible": { - borderColor: theme.palette.borders.emphasis, + backgroundColor: p.solid, + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), }, - "&.Mui-focused:not(.Mui-disabled):not(.Mui-error) .MuiOutlinedInput-notchedOutline": + "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover": { - borderColor: p.light, - borderWidth: 2, + backgroundColor: p.solid, + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), }, + }), + }; + }, + }, + }, - "&.Mui-focused:hover:not(.Mui-disabled):not(.Mui-error) .MuiOutlinedInput-notchedOutline": - { - borderColor: p.light, - borderWidth: 2, - }, + MuiInputBase: { + styleOverrides: { + input: ({ theme }: ThemeOnlyArgs): CSSObject => ({ + "&::placeholder": { + color: theme.palette.text.placeholder, + opacity: 1, + }, - "&.Mui-error .MuiOutlinedInput-notchedOutline": { - borderColor: theme.palette.error.light, - }, + "&::-webkit-input-placeholder": { + color: theme.palette.text.placeholder, + opacity: 1, + }, - "&.Mui-error:hover:not(.Mui-disabled):not(.Mui-focused) .MuiOutlinedInput-notchedOutline": - { - borderColor: theme.palette.error.light, - }, + "&::-moz-placeholder": { + color: theme.palette.text.placeholder, + opacity: 1, + }, - "&.Mui-error.Mui-focused .MuiOutlinedInput-notchedOutline": { - borderColor: theme.palette.error.light, - borderWidth: 2, - }, + "&:focus::placeholder": { + color: theme.palette.text.placeholderFocus, + }, - "&.Mui-focusVisible": { - outline: - "var(--ds-focus-ring-width) solid var(--ds-focus-ring)", - outlineOffset: "var(--ds-focus-ring-offset)", - }, + "&:focus::-webkit-input-placeholder": { + color: theme.palette.text.placeholderFocus, + opacity: 1, + }, - "&.Mui-disabled .MuiOutlinedInput-notchedOutline": { - borderColor: "var(--ds-border-subtle)", - }, - }; + "&:focus::-moz-placeholder": { + color: theme.palette.text.placeholderFocus, + opacity: 1, }, - }, - }, + }), - MuiInputLabel: { - styleOverrides: { - root: ({ theme }: ThemeOnlyArgs): CSSObject => ({ - "&:not(.MuiInputLabel-shrink)": { - color: theme.palette.text.secondary, + 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:not(.MuiInputLabel-shrink)": { + "&.Mui-disabled input::placeholder, &.Mui-disabled input::-webkit-input-placeholder, &.Mui-disabled input::-moz-placeholder": + { color: theme.palette.text.disabled, + opacity: 1, }, + }), + }, + }, - "&.Mui-focused": { - color: theme.palette.primary.main, + 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, }, - "&.Mui-focused.MuiFormLabel-colorSecondary": { - color: theme.palette.secondary.main, - }, + "&:hover:not(.Mui-disabled):not(.Mui-error):not(.Mui-focused) .MuiOutlinedInput-notchedOutline": + { + borderColor: theme.palette.borders.emphasis, + }, - "&.Mui-focused.MuiFormLabel-colorSuccess": { - color: theme.palette.success.main, - }, + "&.Mui-focused:not(.Mui-disabled):not(.Mui-error) .MuiOutlinedInput-notchedOutline": + { + borderColor: p.light, + borderWidth: 2, + }, - "&.Mui-focused.MuiFormLabel-colorWarning": { - color: theme.palette.warning.main, - }, + "&.Mui-focused:hover:not(.Mui-disabled):not(.Mui-error) .MuiOutlinedInput-notchedOutline": + { + borderColor: p.light, + borderWidth: 2, + }, - "&.Mui-focused.MuiFormLabel-colorError": { - color: theme.palette.error.main, + "&.Mui-error .MuiOutlinedInput-notchedOutline": { + borderColor: theme.palette.error.light, }, - "&.Mui-focused.MuiFormLabel-colorInfo": { - color: theme.palette.info.main, + "&.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-focused.Mui-error": { - color: theme.palette.error.main, + "&.Mui-focusVisible": { + outline: "var(--ds-focus-ring-width) solid var(--ds-focus-ring)", + outlineOffset: "var(--ds-focus-ring-offset)", }, - "&.Mui-disabled": { - color: theme.palette.text.disabled, + "&.Mui-disabled .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--ds-border-subtle)", }, - }), + }; }, }, + }, - MuiTab: { - styleOverrides: { - root: ({ theme }: OverrideArgs): CSSObject => ({ - textTransform: "none", + MuiInputLabel: { + styleOverrides: { + root: ({ theme }: ThemeOnlyArgs): CSSObject => ({ + "&:not(.MuiInputLabel-shrink)": { color: theme.palette.text.secondary, - fontWeight: 500, - minHeight: 44, + }, - "&:hover": { - color: theme.palette.text.primary, - boxShadow: getOverlayInset(), - }, + "&.Mui-disabled:not(.MuiInputLabel-shrink)": { + color: theme.palette.text.disabled, + }, - "&.Mui-selected": { - color: theme.palette.primary.main, - fontWeight: 600, - }, + "&.Mui-focused": { + color: theme.palette.primary.main, + }, - "&.Mui-disabled": { - color: theme.palette.text.disabled, - }, + "&.Mui-focused.MuiFormLabel-colorSecondary": { + color: theme.palette.secondary.main, + }, - "&.Mui-focusVisible, &:focus-visible": { - outline: "var(--ds-focus-ring-width) solid var(--ds-focus-ring)", - outlineOffset: "-2px", - }, - }), - }, + "&.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: { + /** + * 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-action": { - color: "inherit", + "& .MuiAlert-icon": { + color: "currentColor", + opacity: 1, + }, - "& .MuiIconButton-root:hover": { - boxShadow: getOverlayInset(), - }, + "& .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}`, - }; - } + 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 var(--ds-border-subtle)", + 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)", - }, + /** + * 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); + bar: ({ + ownerState, + theme, + }: OverrideArgs): CSSObject => { + const colour = getIntentFromColourProp(ownerState.color); + const p = getIntentPalette(theme, colour); - return { - backgroundColor: p.main, - }; - }, + 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, - }; - }, + 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)", - }, + 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)", - }, + 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, - }, + 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, - }, + 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", - }, + message: { + padding: "8px 0", + }, - action: { - color: "inherit", + action: { + color: "inherit", - "& .MuiIconButton-root:hover": { - boxShadow: getOverlayInset(), - }, + "& .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, + 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)", - }, + "&:hover": { + backgroundColor: "var(--ds-overlay-hover)", + }, - ...getFocusOutline(), + ...getFocusOutline(), - "&.Mui-checked": { - color: isDefault ? "var(--ds-on-surface)" : p?.main, - }, + "&.Mui-checked": { + color: isDefault ? "var(--ds-on-surface)" : p?.main, + }, - "&.MuiCheckbox-indeterminate": { - 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)", - }, - }; - }, + "&.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); + 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; + const p = !isDefault ? getIntentPalette(theme, colour) : null; - return { - color: "var(--ds-on-surface-variant)", - borderRadius: "50%", + return { + color: "var(--ds-on-surface-variant)", + borderRadius: "50%", - "&:hover": { - backgroundColor: "var(--ds-overlay-hover)", - }, + "&:hover": { + backgroundColor: "var(--ds-overlay-hover)", + }, - ...getFocusOutline(), + ...getFocusOutline(), - "&.Mui-checked": { - color: isDefault ? "var(--ds-on-surface)" : p?.main, - }, + "&.Mui-checked": { + color: isDefault ? "var(--ds-on-surface)" : p?.main, + }, - "&.Mui-disabled": { - color: "var(--ds-action-disabled)", - }, - }; - }, + "&.Mui-disabled": { + color: "var(--ds-action-disabled)", + }, + }; }, }, }, - }); + }, +}); - return createTheme(DiamondDSThemeOptions); -}; +/** + * 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 themes for convenience. - * Most apps can use these directly; use createDiamondTheme() for custom modes. + * Pre-built theme for convenience. */ -export const DiamondDSTheme = createDiamondTheme("light"); -export const DiamondDSThemeDark = createDiamondTheme("dark"); +export { DiamondDSTheme }; /** - * Backwards compatibility alias. Use createDiamondTheme() for new code. + * 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/src/themes/ThemeProvider.tsx b/src/themes/ThemeProvider.tsx index 56d306b1..a4badc84 100644 --- a/src/themes/ThemeProvider.tsx +++ b/src/themes/ThemeProvider.tsx @@ -1,37 +1,26 @@ -import React, { useLayoutEffect, useMemo } from "react"; -import { CssBaseline } from "@mui/material"; import { ThemeProvider as MuiThemeProvider } from "@mui/material/styles"; -import type { ThemeProviderProps as MuiThemeProviderProps } from "@mui/material/styles"; -import { createMuiTheme } from "./DiamondDSTheme"; -import type { DSMode } from "./DiamondDSTheme"; +import { CssBaseline } from "@mui/material"; +import { GenericTheme } from "./GenericTheme"; +import { ThemeProviderProps as MuiThemeProviderProps } from "@mui/material/styles"; interface ThemeProviderProps extends Partial { baseline?: boolean; - mode?: DSMode; // 'light' | 'dark' (adding 'system' for future use) } -export function ThemeProvider({ +const ThemeProvider = function ({ children, + theme = GenericTheme, baseline = true, defaultMode = "system", - mode = "light", // default to light mode (for now) ...props }: ThemeProviderProps) { - useLayoutEffect(() => { - const root = document.documentElement; - - root.setAttribute("data-mode", mode); - root.classList.toggle("dark", mode === "dark"); - root.classList.toggle("light", mode === "light"); - root.style.colorScheme = mode; - }, [mode]); - - const theme = useMemo(() => createMuiTheme(mode), [mode]); - return ( {baseline && } {children} ); -} +}; + +export { ThemeProvider }; +export type { ThemeProviderProps }; From 896b36bf7289f054e3abce109d27811fd69d48a3 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Wed, 20 May 2026 13:12:54 +0100 Subject: [PATCH 10/12] Updated dependency of `fast-uri` --- package.json | 3 ++- pnpm-lock.yaml | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 52c035e5..ea3ff23c 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,8 @@ "brace-expansion@^2.0.0": "2.0.2", "@babel/runtime@^7.26.0": "7.27.6", "esbuild@>=0.24.0 <0.25.0": "0.25.0", - "webpack@^5.0.0": "5.104.1" + "webpack@^5.0.0": "5.104.1", + "fast-uri@<3.1.2": "3.1.2" } }, "packageManager": "pnpm@9.12.3+sha256.24235772cc4ac82a62627cd47f834c72667a2ce87799a846ec4e8e555e2d4b8b" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75bba6b4..e845ddf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,7 @@ overrides: '@babel/runtime@^7.26.0': 7.27.6 esbuild@>=0.24.0 <0.25.0: 0.25.0 webpack@^5.0.0: 5.104.1 + fast-uri@<3.1.2: 3.1.2 importers: @@ -3211,8 +3212,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.0.5: - resolution: {integrity: sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fastq@1.18.0: resolution: {integrity: sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==} @@ -7629,7 +7630,7 @@ snapshots: ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.0.5 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -8535,7 +8536,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.0.5: {} + fast-uri@3.1.2: {} fastq@1.18.0: dependencies: From c6eb490230796915a543d70cb64492d578b28ef4 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Thu, 21 May 2026 13:22:09 +0100 Subject: [PATCH 11/12] Removed docs Update preview to organise nav order --- .storybook/preview.tsx | 5 +- src/storybook/design-system.mdx | 40 - src/storybook/foundation/01-principles.mdx | 149 --- src/storybook/foundation/02-colours.mdx | 1184 -------------------- src/storybook/foundation/03-typography.mdx | 96 -- src/storybook/practical-guidance.mdx | 429 ------- 6 files changed, 3 insertions(+), 1900 deletions(-) delete mode 100644 src/storybook/design-system.mdx delete mode 100644 src/storybook/foundation/01-principles.mdx delete mode 100644 src/storybook/foundation/02-colours.mdx delete mode 100644 src/storybook/foundation/03-typography.mdx delete mode 100644 src/storybook/practical-guidance.mdx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 2577bfc5..4902bf86 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -96,11 +96,12 @@ const preview: Preview = { storySort: { order: [ "Introduction", - "Components", + "Helpers", "Theme", "Theme/Logos", "Theme/Colours", - "Helpers", + "MUI", + "Components", ], }, }, diff --git a/src/storybook/design-system.mdx b/src/storybook/design-system.mdx deleted file mode 100644 index 3378075f..00000000 --- a/src/storybook/design-system.mdx +++ /dev/null @@ -1,40 +0,0 @@ -import { Meta } from "@storybook/blocks"; - - - - - -
- -# Diamond Design System ❖ - -

- The design system for Diamond Light Source, providing a shared foundation for - building clear, consistent, and reliable scientific interfaces. -

- -

- It evolves SciReactUI into a consistent, semantic, and scalable system for - data acquisition, analysis, and operational tools across Diamond. -

- -

- Built on structure and precision, the system supports clear, coherent, and - predictable interfaces across scientific workflows and applications. -

- -## What it helps us do - -
    -
  • Create clearer, more predictable scientific interfaces.
  • -
  • Bring consistency across tools and beamlines.
  • -
  • Reduce cognitive load in complex workflows.
  • -
  • Speed up development with reusable components.
  • -
  • Ensure new tools start coherent and stay aligned.
  • -
- -
diff --git a/src/storybook/foundation/01-principles.mdx b/src/storybook/foundation/01-principles.mdx deleted file mode 100644 index 4e81e88e..00000000 --- a/src/storybook/foundation/01-principles.mdx +++ /dev/null @@ -1,149 +0,0 @@ -import { Meta } from "@storybook/blocks"; - - - - - -
- -# Core principles - -

- These principles guide how we design and build interfaces across the Diamond - Design System. -

- -
- -
- -
-

Clarity

-

- Interfaces should be easy to understand at a glance. Users should be able to - see system state and next actions without interpreting multiple signals. -

-
- -
-

Consistency

-

- Similar elements should look and behave the same across the system, reducing - the need to relearn interactions. -

-
- -
-

Accessibility

-

- Accessibility is a baseline, not an enhancement. Interfaces must remain - usable across a wide range of abilities and conditions. -

-
- -
-

Predictable behaviour

-

- Interactions should behave as users expect. Reliability is critical in - stateful and high-consequence workflows. -

-
- -
-

Token-driven

-

- All visual decisions are defined through design tokens, ensuring consistency - across themes and components. -

-
- -
-

Semantic first

-

- Use roles and meaning, not raw values. For example, use - --ds-bg-surface instead of hardcoded colours. -

-
- -
-

Separation of concerns

-

- Tokens, components, and implementation are distinct layers, allowing the - system to scale safely. -

-
- -
-

-
- -## Do and don’t - -### Do - -
    -
  • Use semantic design tokens.
  • -
  • Start with existing components.
  • -
  • Keep behaviour and interaction patterns consistent.
  • -
- -### Don’t - -
    -
  • Hardcode colours or spacing values.
  • -
  • Create new components without a clear need.
  • -
  • Override system behaviour without strong justification.
  • -
- -## Light and dark mode - -

- Light and dark mode are supported by default and are handled at the system - level, not per component. -

- -
    -
  • - Theme switching is controlled via data-mode on the{" "} - html element. -
  • -
  • Components should use semantic tokens so colours adapt automatically.
  • -
  • No component should implement separate light/dark logic.
  • -
- -
{``}
- -
{``}
- -
diff --git a/src/storybook/foundation/02-colours.mdx b/src/storybook/foundation/02-colours.mdx deleted file mode 100644 index d03be370..00000000 --- a/src/storybook/foundation/02-colours.mdx +++ /dev/null @@ -1,1184 +0,0 @@ -import { Meta } from "@storybook/blocks"; - - - - - -# Colour - -
- -

- Diamond Design System uses a role-based colour system designed for scientific - software. Colours are defined through stable UI roles and then mapped into the - MUI theme, so components remain consistent across light and dark mode. -

- -
- -
- -
- Architecture -
- Role tokens → MUI theme mapping → components -
-

- Theme switching happens via <html data-mode="light|dark"> - . Role tokens define all colours and change between themes. The MUI theme - maps those roles into component behaviour, so components update - automatically. -

-
- -
- -## How the layers work - -
- -
-
-

Role tokens

-

- These are the source of truth for colour in the Diamond Design System. They - define UI roles directly, such as background, surface, border, text, - overlay, focus, and intent. -

-
    -
  • Components should depend on roles, not raw colour values.
  • -
  • Theme switching swaps role values, not component code.
  • -
-
- -
-

Theme mapping

-

- The MUI theme maps those role tokens into palette slots, action states, and - component defaults. -

-

- This lets MUI components use familiar props like{" "} - color="primary" - while still following Diamond Design System rules. -

-
- -
-

Components

-

- Components consume those mapped roles. This keeps buttons, fields, tabs, - chips, and custom UI consistent across themes. -

-
    -
  • Structure comes from neutral roles.
  • -
  • Meaning comes from intent roles.
  • -
-
-
- -
-

Rule of thumb

-

- Structure uses background and border roles. - Meaning uses intent roles. - Interaction uses overlays and focus tokens. -

-
- -
- -## Role tokens - -
- -

- Role tokens are the colour source of truth in the Diamond Design System. Each - token represents a specific role in the interface and maps directly to a - concrete value in light and dark mode. -

- -
- -
- -
- Token groups -
    -
  • - Background: app and page canvas -
  • -
  • - Surfaces: containers and grouped regions -
  • -
  • - Borders: structure and affordance -
  • -
  • - Foreground: text and icon contrast -
  • -
  • - Intent: action, status, and meaning -
  • -
  • - Overlays: hover, selected, focus, disabled -
  • -
-
- -
- -## Neutral roles - -
- -

- Most of the interface should be built from the neutral layer. This keeps dense - screens calm, predictable, and readable before any meaning is added through - intent colours. -

- -
- -
- -
-
-
-
-

Background and surface roles

-
    -
  • background: --ds-background
  • -
  • surface: --ds-surface
  • -
  • surface container: --ds-surface-container
  • -
  • surface container high: --ds-surface-container-high
  • -
-
- background - surface - container - container high -
-
-
- -
-
-
-

Border roles

-
    -
  • - subtle: --ds-border-subtle -
  • -
  • - base: --ds-border -
  • -
  • - emphasis: --ds-border-emphasis -
  • -
-

- Use subtle for quiet separation, base for standard structure, and emphasis - where affordance needs to be clearer. -

-
-
- -
-
-
-

Foreground roles

-
    -
  • primary text: --ds-on-surface
  • -
  • secondary text: --ds-on-surface-variant
  • -
  • disabled text: --ds-on-surface-disabled
  • -
  • placeholder: --ds-placeholder
  • -
  • placeholder focus: --ds-placeholder-focus
  • -
-
-
-
- -
- -## Intent roles - -
- -

- Intent roles add meaning. They are mapped into the MUI palette and also - provide container, solid, on-container, and on-solid values for calmer and - stronger usage patterns. -

- -
- -
- -
- How intent works -
    -
  • - main is the default semantic colour for text, icons, - borders, and standard intent usage. -
  • -
  • - emphasis is the stronger interactive or more prominent - semantic step. -
  • -
  • - accent supports lighter emphasis and supporting semantic - detail. -
  • -
  • - container is a subtle intent surface. -
  • -
  • - on container is the readable foreground for that subtle - surface. -
  • -
  • - solid is the stronger filled semantic surface. -
  • -
  • - on solid is the readable foreground for that stronger - fill. -
  • -
-
- -
-
-
-
-

Primary

-
    -
  • main: --ds-primary
  • -
  • emphasis: --ds-primary-emphasis
  • -
  • accent: --ds-primary-accent
  • -
  • container: --ds-primary-container
  • -
  • on container: --ds-on-primary-container
  • -
  • solid: --ds-primary-solid
  • -
  • on solid: --ds-on-primary-solid
  • -
-
- main - emphasis - solid - container -
-
-
- -
-
-
-

Secondary

-
    -
  • - main: --ds-secondary -
  • -
  • - emphasis: --ds-secondary-emphasis -
  • -
  • - accent: --ds-secondary-accent -
  • -
  • - container: --ds-secondary-container -
  • -
  • - on container: --ds-on-secondary-container -
  • -
  • - solid: --ds-secondary-solid -
  • -
  • - on solid: --ds-on-secondary-solid -
  • -
-
- - main - - - emphasis - - - solid - - - container - -
-
-
- -
-
-
-

Tertiary

-
    -
  • - main: --ds-tertiary -
  • -
  • - emphasis: --ds-tertiary-emphasis -
  • -
  • - accent: --ds-tertiary-accent -
  • -
  • - container: --ds-tertiary-container -
  • -
  • - on container: --ds-on-tertiary-container -
  • -
  • - solid: --ds-tertiary-solid -
  • -
  • - on solid: --ds-on-tertiary-solid -
  • -
-
- - main - - - emphasis - - - solid - - - container - -
-
-
- -
-
-
-

Brand

-
    -
  • main: --ds-brand
  • -
  • emphasis: --ds-brand-emphasis
  • -
  • accent: --ds-brand-accent
  • -
  • container: --ds-brand-container
  • -
  • on container: --ds-on-brand-container
  • -
  • solid: --ds-brand-solid
  • -
  • on solid: --ds-on-brand-solid
  • -
-
- main - solid - container -
-
-
-
- -
- -## Status colours - -
- -

- Status colours communicate feedback and system meaning. Use them deliberately - and consistently so they remain useful in dense, high-attention workflows. -

- -
- -
- -
-
-
-
-

Success

-
    -
  • main: --ds-success
  • -
  • emphasis: --ds-success-emphasis
  • -
  • accent: --ds-success-accent
  • -
  • container: --ds-success-container
  • -
  • on container: --ds-on-success-container
  • -
  • solid: --ds-success-solid
  • -
  • on solid: --ds-on-success-solid
  • -
-
- main - solid - container -
-
-
- -
-
-
-

Warning

-
    -
  • - main: --ds-warning -
  • -
  • - emphasis: --ds-warning-emphasis -
  • -
  • - accent: --ds-warning-accent -
  • -
  • - container: --ds-warning-container -
  • -
  • - on container: --ds-on-warning-container -
  • -
  • - solid: --ds-warning-solid -
  • -
  • - on solid: --ds-on-warning-solid -
  • -
-
- - main - - - solid - - - container - -
-
-
- -
-
-
-

Danger / error

-
    -
  • - main: --ds-danger -
  • -
  • - emphasis: --ds-danger-emphasis -
  • -
  • - accent: --ds-danger-accent -
  • -
  • - container: --ds-danger-container -
  • -
  • - on container: --ds-on-danger-container -
  • -
  • - solid: --ds-danger-solid -
  • -
  • - on solid: --ds-on-danger-solid -
  • -
-
- - main - - - solid - - - container - -
-
-
- -
-
-
-

Info

-
    -
  • main: --ds-info
  • -
  • emphasis: --ds-info-emphasis
  • -
  • accent: --ds-info-accent
  • -
  • container: --ds-info-container
  • -
  • on container: --ds-on-info-container
  • -
  • solid: --ds-info-solid
  • -
  • on solid: --ds-on-info-solid
  • -
-
- main - solid - container -
-
-
-
- -
- -## How roles map to the MUI palette - -
- -

- The Diamond Design System defines colour through semantic role tokens. The MUI - palette is a mapping layer that exposes those roles through the fields MUI - expects, such as light, main, dark, and - contrastText. -

- -

- This allows standard MUI components to work with familiar props like - color="primary", while Diamond-specific roles such as - container, on-container, solid, and - on-solid support richer component behaviour and custom UI. -

- -
- -
- -
- Important -

- MUI palette fields are not the source of truth. They are derived from - Diamond Design System role tokens so MUI components behave correctly. -

-
- -
- -## Surfaces and elevation - -
- -

- Diamond Design System uses elevation to create hierarchy, but most elevation - should come from surface colour, borders, and spacing rather than drop - shadows. This keeps dense scientific interfaces calm, readable, and - predictable. -

- -
- -
- -
- Surface hierarchy -
    -
  • - background → app and page background ( - --ds-background) -
  • -
  • - surface → primary container surface ( - --ds-surface) -
  • -
  • - surface container → grouped sections ( - --ds-surface-container) -
  • -
  • - surface container high → more prominent nested areas ( - --ds-surface-container-high) -
  • -
-

- Use drop shadows sparingly, mainly for floating overlays such as menus, - popovers, dialogs, and tooltips. -

-
- -
-

Practical: Paper with surfaces inside

-
{`
-  
-    Section title
-  
-  
-    Interactive area (inputs, controls)
-  
-`}
-
- -
- -## Overlays and interaction states - -
- -

- Interaction states are defined as explicit overlays so hover, selection, - focus, and disabled behaviour stay consistent across themes. MUI’s - palette.action is mapped directly to these tokens. -

- -
- -
- -
-
-
-
-
-
-
    -
  • emphasis: --ds-overlay-emphasis
  • -
-
-
- -
-
-
-
-
-
    -
  • - selected: --ds-overlay-selected -
  • -
-
-
- -
-
-
-
-
-
    -
  • - focus: --ds-overlay-focus -
  • -
-
-
- -
-
-
-
-
-
    -
  • - disabled: --ds-overlay-disabled -
  • -
-
-
- -
-
-
-
-
-
    -
  • disabled background: --ds-overlay-disabled-bg
  • -
-
-
-
- -
- -## Focus - -
- -

- Focus is handled through explicit focus-ring tokens rather than ripple or - shadow. This keeps keyboard interaction visible and steady across components. -

- -
- -
- -
- Focus tokens -
    -
  • - default: --ds-focus-ring -
  • -
  • - primary: --ds-focus-ring-primary -
  • -
  • - secondary: --ds-focus-ring-secondary -
  • -
  • - danger: --ds-focus-ring-danger -
  • -
  • - warning: --ds-focus-ring-warning -
  • -
  • - success: --ds-focus-ring-success -
  • -
  • - info: --ds-focus-ring-info -
  • -
  • - brand: --ds-focus-ring-brand -
  • -
-
- -
- -## Using MUI palette vs Diamond roles - -
- -

- Use MUI palette props for intent on MUI components. Use Diamond Design System - role tokens for structure, surfaces, borders, overlays, and custom UI. -

- -
- -
- -
-
- Use MUI palette when -
    -
  • You’re using MUI components directly.
  • -
  • You want consistent intent behaviour through props.
  • -
  • You’re relying on theme defaults for hover, selected, and focus behaviour.
  • -
-
-

Example

-
{``}
-
-
- -
- Use Diamond roles when -
    -
  • You’re styling layout or containers.
  • -
  • You’re building a custom component or anatomy.
  • -
  • You need a specific surface or border role.
  • -
  • You’re styling non-MUI DOM elements.
  • -
-
-

Example

-
{`
- Panel content -
`}
-
-
-
- -
- -## Do and don’t - -
- -
-
- Do -
    -
  • Use neutral roles for structure (--ds-background, --ds-surface, --ds-border-*).
  • -
  • Use intent roles for meaning (--ds-primary, --ds-success, --ds-danger, etc.).
  • -
  • Use surfaces to separate regions rather than heavy shadows.
  • -
  • Let the same token names work across light and dark themes.
  • -
  • Ensure disabled and error states override other styling.
  • -
-
- -
- Don’t -
    -
  • Don’t hardcode hex values in component code.
  • -
  • Don’t use strong intent colours as general layout backgrounds.
  • -
  • Don’t use colour alone to communicate state.
  • -
  • Don’t mix shadow-based elevation and surface layering unnecessarily.
  • -
  • Don’t invent one-off colour behaviour inside individual components.
  • -
-
-
- -
- -## Quick visual check - -
- -

- A simple sanity check for surfaces, text, and dividers in the current theme. -

- -
- -
- -
-
-
-

- Text primary--ds-on-surface -

-

- Text secondary → --ds-on-surface-variant -

-

- Text disabled → --ds-on-surface-disabled -

-
-
-

- Background uses --ds-background, main containers use - --ds-surface, grouped areas use - --ds-surface-container /{" "} - --ds-surface-container-high, and dividers use{" "} - --ds-border-subtle. -

-
-
- -
- Diamond Design System principle -

- Use colour to support careful work, not to decorate it. Build hierarchy with - neutral roles, apply meaning through intent roles, and keep interaction - states explicit and predictable. -

-
- -
diff --git a/src/storybook/foundation/03-typography.mdx b/src/storybook/foundation/03-typography.mdx deleted file mode 100644 index 644f7cdb..00000000 --- a/src/storybook/foundation/03-typography.mdx +++ /dev/null @@ -1,96 +0,0 @@ -import { Meta } from "@storybook/blocks"; - - - - - -
- -# Typography - -

- Typography is a functional tool, not decoration. In scientific software, type - choices affect speed, accuracy, and confidence. -

- -## Why it matters in Diamond tools - -
    -
  • - Readability and legibility: clear at small sizes and on - imperfect displays. -
  • -
  • - Hierarchy: predictable structure for scanning data-heavy - screens. -
  • -
  • - Accessibility: legible sizing and contrast across contexts. -
  • -
  • - Reduced cognitive load: less visual noise, faster - decisions. -
  • -
  • - Works for dense data: tables, logs, dashboards, forms, and - documentation. -
  • -
- -## Font roles - -
    -
  • - Inter: default UI font for functional work, including body - text, labels, forms, tables, and component text. -
  • -
  • - IBM Plex Mono: code, identifiers, logs, aligned technical - values, and monospace data. -
  • -
  • - Outfit: optional brand-forward headings and hero text, not - dense operational content. -
  • -
- -## Practical guidance - -### Do - -
    -
  • Keep hierarchy consistent across products and screens.
  • -
  • Prefer short, clear labels in controls and tables.
  • -
  • Make numbers, units, and technical values easy to scan.
  • -
  • Use mono type where alignment or identifier readability matters.
  • -
- -### Don’t - -
    -
  • Don’t use brand typography for dense operational content.
  • -
  • Don’t “style your way” out of hierarchy problems.
  • -
  • Don’t hide important information in faint secondary text.
  • -
  • Don’t introduce one-off font sizes without a reusable need.
  • -
- -## Tokens - -

The current MUI theme uses Inter as the default UI font.

- -

- Typography tokens will define display, body, label, and mono usage, for - example `typography.display.*`, `typography.body.*`, `typography.label.*`, and - `typography.mono.*`. -

- -

- These tokens should be referenced consistently across design and code rather - than using one-off font sizes, weights, or families. -

- -
diff --git a/src/storybook/practical-guidance.mdx b/src/storybook/practical-guidance.mdx deleted file mode 100644 index 8d9dba2a..00000000 --- a/src/storybook/practical-guidance.mdx +++ /dev/null @@ -1,429 +0,0 @@ -import { Meta } from "@storybook/blocks"; - - - - - -
- -# Practical guidance - -Diamond Design System uses MUI with Diamond semantic tokens layered on top. - -CSS variables are the source of truth. The MUI theme adapts those tokens for components and implementation. - -## Using the theme - -Prefer semantic tokens, existing component variants, and reusable patterns. - -Avoid hardcoded values, one-off styling, and inconsistent interaction patterns. - -## Using components - -Build with existing components and patterns before creating new ones. - -Components should keep interfaces predictable, readable, and consistent. - -
-
-

Do

-
    -
  • Reuse established layouts and behaviours.
  • -
  • Use standard component variants consistently.
  • -
  • Keep disabled and error states visually clear.
  • -
  • Use spacing and typography for hierarchy.
  • -
-
- -
-

Don’t

-
    -
  • Don’t restyle components screen by screen.
  • -
  • Don’t introduce custom colours unnecessarily.
  • -
  • Don’t create multiple patterns for the same interaction.
  • -
  • Don’t override the theme without a reusable reason.
  • -
-
-
- -## Colour usage - -Use colour semantically. Separate structure, meaning, and brand. - -DiamondDS supports: - -- action intents: primary, secondary -- status intents: success, warning, error, info - -Intent colours communicate hierarchy, operational meaning, and state through component APIs such as `color="primary"` or `color="error"`. -Their meaning depends on context and workflow. - -Brand colour is intentionally separate. Brand communicates Diamond identity rather than behaviour or status. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RoleTypePurposeTypical usage
- - Neutral - StructureLayout and hierarchyBackgrounds, surfaces, borders, tables
- - Primary - Action intentMain actions and emphasisPrimary actions, selected states
- - Secondary - Action intentSupporting actions and alternate emphasisSecondary actions, filters, supporting controls
- - Success - Status intentConfirmed, available, or operationally valid statesCompletion, confirmation, active or valid states
- - Warning - Status intentAttention, caution, or elevated operational awarenessRisk states, hazardous conditions, attention-required states
- - Error (Danger) - Status intentCritical, blocking, hazardous, or destructive statesErrors, failed actions, emergency-related or destructive actions
- - Info - Status intentInformational, contextual, or progress-related feedbackProgress, guidance, scanning, movement, or system messages
- - Brand - IdentityFacility identityBranding moments and facility identity
- -
-
-

Do

-
    -
  • Use neutral roles for structure.
  • -
  • Use intent roles for meaning.
  • -
  • Let tokens work across light and dark themes.
  • -
  • Ensure disabled and error states override other styling.
  • -
-
- -
-

Don’t

-
    -
  • Don’t hardcode hex values.
  • -
  • Don’t use strong intent colours as layout backgrounds.
  • -
  • Don’t rely on colour alone to communicate state.
  • -
  • Don’t invent one-off colour behaviour.
  • -
-
-
- -## Surfaces and elevation - -Prefer layered surfaces and borders over heavy shadows. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TokenPurposeMUI mapping
- --ds-bg-page - Root application background - background.default -
- --ds-surface - Base content surface - background.paper -
- --ds-surface-container - Standard container surface - surface.subtle -
- --ds-surface-container-high - Highlighted container - surface.strong -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ElementRecommended treatment
Top nav bar - Elevation 0 + border-bottom. Elevate to 1–2 on scroll when sticky and no - secondary nav is present -
SidebarElevation 0 + border-right
Secondary header/nav barElevation 1. Elevate to 1–2 on scroll when sticky
Cards - Elevation 0-2 + subtle border, or no border when the surface is already - clear -
Interactive cardsElevation 3–4, such as drag/drop cards
Menus, popovers, drawersElevation 8–16
DialogsElevation 16–24
- -## Typography usage - -Typography should support readability, scanning, and technical clarity. - - - - - - - - - - - - - - - - - - - - - - - - - - -
TypefacePurposeTypical usage
InterDefault interface typefaceUI text, controls, tables, navigation
OutfitBrand and high-level headingsProduct headings, landing areas
IBM Plex MonoTechnical and aligned contentIDs, timestamps, code, numeric values
- -
-
-

Do

-
    -
  • Keep hierarchy consistent across products.
  • -
  • Prefer short, clear labels.
  • -
  • Make numbers and technical values easy to scan.
  • -
  • Use mono type where alignment matters.
  • -
-
- -
-

Don’t

-
    -
  • Don’t use brand typography for dense operational content.
  • -
  • Don’t rely on decorative styling for hierarchy.
  • -
  • Don’t hide important information in faint text.
  • -
  • Don’t introduce one-off font sizes.
  • -
-
-
- -## General principles - -
    -
  • Prefer semantic tokens over raw values.
  • -
  • Reuse patterns before creating new ones.
  • -
  • Keep interactions predictable.
  • -
  • Design for clarity and operational confidence.
  • -
  • Similar things should look and behave similarly.
  • -
- -
From 110274c25db23f2a9403993d640a262abcb38cd3 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Thu, 21 May 2026 17:52:16 +0100 Subject: [PATCH 12/12] Reverting to direct hex for grey, as seems to be required by MUI ` --- src/themes/DiamondDSTheme.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/themes/DiamondDSTheme.ts b/src/themes/DiamondDSTheme.ts index 4eaea085..23e81f57 100644 --- a/src/themes/DiamondDSTheme.ts +++ b/src/themes/DiamondDSTheme.ts @@ -537,16 +537,16 @@ const createDiamondPalette = (mode: DSMode) => { brand: createBrandPaletteColour(), grey: { - 50: "var(--ds-grey-50)", - 100: "var(--ds-grey-100)", - 200: "var(--ds-grey-200)", - 300: "var(--ds-grey-300)", - 400: "var(--ds-grey-400)", - 500: "var(--ds-grey-500)", - 600: "var(--ds-grey-600)", - 700: "var(--ds-grey-700)", - 800: "var(--ds-grey-800)", - 900: "var(--ds-grey-900)", + 50: "#f8f8fa", + 100: "#eef1f5", + 200: "#e6e9f0", + 300: "#dde1e8", + 400: "#bcc2cd", + 500: "#a5acb8", + 600: "#8a90a0", + 700: "#505563", + 800: "#2c3140", + 900: "#1a1c23", }, }; };