From 97df697fa24623cb004213e7a5cd4e20b73adcc5 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Fri, 8 May 2026 14:20:05 +0100 Subject: [PATCH 1/9] Add accessibility documentation structure and initial guidance pages --- src/storybook/accessibility/00-overview.mdx | 198 +++++++ .../accessibility/01-colour-contrast.mdx | 561 ++++++++++++++++++ .../accessibility/02-cognitive-a11y.mdx | 149 +++++ 3 files changed, 908 insertions(+) create mode 100644 src/storybook/accessibility/00-overview.mdx create mode 100644 src/storybook/accessibility/01-colour-contrast.mdx create mode 100644 src/storybook/accessibility/02-cognitive-a11y.mdx diff --git a/src/storybook/accessibility/00-overview.mdx b/src/storybook/accessibility/00-overview.mdx new file mode 100644 index 00000000..627f44f2 --- /dev/null +++ b/src/storybook/accessibility/00-overview.mdx @@ -0,0 +1,198 @@ +import { Meta } from "@storybook/blocks"; + + + + + +
+ +# Accessibility + +

+ Accessibility (a11y) in the Diamond Design System is a baseline for quality, + clarity, and usability in complex scientific tools, not a checklist or a + separate mode. +

+ +

+ Our goal is to make interfaces that are understandable, predictable, and + usable across a wide range of abilities, environments, and levels of fatigue. +

+ +## Standards and approach + +

We follow WCAG 2.2 as the baseline for accessibility compliance.

+ +

+ We also use APCA (Advanced Perceptual Contrast Algorithm) to design for + perceived readability. Unlike WCAG contrast ratios, APCA accounts for font + size, weight, and light/dark conditions. +

+ +

+ WCAG defines minimum thresholds. APCA is used to ensure interfaces remain + readable, comfortable, and usable in real-world conditions. +

+ +

This reflects the direction of emerging standards such as WCAG 3.

+ +## Core principles + + + +## Do + +### Interactive elements + + + +### Keyboard navigation + + + +

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

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

+ For interactive icons, ensure both an accessible name and a visible affordance + such as a tooltip or label. +

+ +

+ In MUI, use aria-label on IconButton. Tooltips (e.g. + <Tooltip />) should be used in addition to, not instead of, + accessible labelling. +

+ +### Forms and inputs + + + +### Content and layout + + + +## Don’t + + + +## Storybook guidance + + + +## Quick sense check + + + +
diff --git a/src/storybook/accessibility/01-colour-contrast.mdx b/src/storybook/accessibility/01-colour-contrast.mdx new file mode 100644 index 00000000..b3fc37aa --- /dev/null +++ b/src/storybook/accessibility/01-colour-contrast.mdx @@ -0,0 +1,561 @@ +import { Meta } from "@storybook/blocks"; + + + + + +
+ +# Colour contrast + +

+ Colour contrast directly affects readability, speed, and error rate. In dense, + data-heavy scientific interfaces, poor contrast increases cognitive load and + slows decision-making. +

+ +

+ We prioritise perceptual contrast using APCA when defining colour choices, + while maintaining WCAG 2.2 as the compliance baseline. +

+ +## Why contrast needs a modern approach + +

+ Traditional WCAG 2.x contrast ratios are useful, but they are based on + luminance ratios rather than perceived readability. In practice, some + combinations can technically pass while still feel strained or unstable in + real UI. +

+ +

+ This becomes more visible across different font sizes, weights, and light or + dark environments. +

+ +

This leads to common issues:

+ + + +

+ APCA helps address these limitations by modelling perceived readability rather + than relying only on raw luminance difference. +

+ +## APCA contrast (primary) + +

+ APCA reflects how contrast is actually experienced by users more closely than + older ratio-based methods. +

+ + + +

+ We use APCA to tune token values, component defaults, and interaction states. +

+ +## WCAG 2.2 contrast (baseline) + +

WCAG 2.2 remains the current compliance baseline for text contrast.

+ + + +

+ WCAG 2.2 is necessary, but on its own it is not enough to judge readability + quality. +

+ +## How we use them together + +

Contrast decisions follow a clear order:

+ + + +

This helps keep interfaces both compliant and genuinely usable.

+ +## Colour-specific considerations + +

+ Some hues behave less predictably for perceived contrast, even when WCAG 2.x + ratios look acceptable. +

+ +

+ In DiamondDS this matters most for status colours: --ds-success, + --ds-warning, and --ds-danger. +

+ +

+ A common issue in real interfaces is that black text on a saturated colour may + look mathematically strong, but visually feel unstable, vibrating, or tiring, + especially for smaller or denser text. +

+ +

APCA is better at exposing these perception problems.

+ + + +

When using these colours:

+ + + +### WCAG 2.2 vs APCA: why passes can still fail users + +

+ WCAG 2.2 is a useful baseline, but it does not always predict comfort or + readability in real interfaces. APCA tracks perceived readability more closely. +

+ +
+
+
+

Light

+ +
+

Success

+
+ --ds-success + #1B8834 +
+
+
+

WCAG 2.2

+
+ Experiment complete · 12 files processed +
+
+
+

APCA

+
+ Experiment complete · 12 files processed +
+
+
+
+ +
+

Warning

+
+ --ds-warning + #e97b12 +
+
+
+

WCAG 2.2

+
+ Beamline temperature near limit +
+
+
+

APCA

+
+ Beamline temperature near limit +
+
+
+
+ +
+

Danger

+
+ --ds-danger + #d63c41 +
+
+
+

WCAG 2.2

+
+ Connection failed · retry required +
+
+
+

APCA

+
+ Connection failed · retry required +
+
+
+
+
+ +
+

Dark

+ +
+

Success

+
+ --ds-success + #23913C +
+
+
+

WCAG 2.2

+
+ Experiment complete · 12 files processed +
+
+
+

APCA

+
+ Experiment complete · 12 files processed +
+
+
+
+ +
+

Warning

+
+ --ds-warning + #f07a13 +
+
+
+

WCAG 2.2

+
+ Beamline temperature near limit +
+
+
+

APCA

+
+ Beamline temperature near limit +
+
+
+
+ +
+

Danger

+
+ --ds-danger + #d63c41 +
+
+
+

WCAG 2.2

+
+ Connection failed · retry required +
+
+
+

APCA

+
+ Connection failed · retry required +
+
+
+
+
+
+ +
+

How to use this

+
    +
  • Use WCAG 2.2 as the baseline check, especially for smaller text.
  • +
  • + Use APCA to validate perceived readability and comfort, particularly for + saturated status colours. +
  • +
  • Always pair colour with icons or text. Never rely on colour alone.
  • +
+
+ +
+ +## Practical guidance + + + +## Common pitfalls + + + +## When WCAG and APCA disagree + + + +## Future: WCAG 3 and APCA + +

+ WCAG 3 is still evolving, but perceptual contrast models such as APCA are + influencing the direction of accessibility guidance. +

+ +

+ Our approach reflects that direction while staying grounded in current + compliance requirements: +

+ + + +

Learn more:

+ + + +
diff --git a/src/storybook/accessibility/02-cognitive-a11y.mdx b/src/storybook/accessibility/02-cognitive-a11y.mdx new file mode 100644 index 00000000..c75a0720 --- /dev/null +++ b/src/storybook/accessibility/02-cognitive-a11y.mdx @@ -0,0 +1,149 @@ +import { Meta } from "@storybook/blocks"; + + + + + +
+ +# Cognitive accessibility and usability + +

+ Accessibility is not only about vision, hearing, or motor input. People also + differ in how they process information, maintain focus, recover from errors, + and work under pressure or fatigue. +

+ +

+ In scientific environments, these differences matter. Users may work for long + periods, switch between multiple tools and data sources, or operate systems + during live experiments where mistakes can be costly. +

+ +

+ This page focuses on cognitive accessibility and practical usability within + complex scientific software. +

+ +## Designing for cognitive accessibility + +

+ Good accessibility and usability often come from the same decisions: + interfaces that are clear, predictable, consistent, and forgiving. +

+ +

+ The goal is not to simplify scientific work itself, but to reduce unnecessary + cognitive load created by the interface. +

+ +## Design objectives + +### Help users understand what things are + + + +### Reduce memory burden + + + +### Help users maintain focus + + + +### Help users avoid and recover from errors + + + +### Design for long-running workflows + + + +## Applying this in Storybook + +When reviewing components and patterns, ask: + + + +## Further reading + + + +
From b353295ceb8fd5478466a2d276979157c61c506e Mon Sep 17 00:00:00 2001 From: Zohar Date: Fri, 22 May 2026 09:31:43 +0100 Subject: [PATCH 2/9] Introduce DiamondDS theme foundation and component updates (#177) * 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. Add design system intro and foundation documentation pages Introduced System settings option and a way to switch to it Updated dependency of `fast-uri` --- .storybook/ThemeSwapper.tsx | 29 +- .storybook/preview.tsx | 62 +- package.json | 3 +- pnpm-lock.yaml | 9 +- src/index.ts | 2 +- src/styles/diamondDS/diamond-ds-roles.css | 481 +++++++ src/themes/DiamondDSTheme.ts | 1418 +++++++++++++++++++++ src/themes/Theme.test.tsx | 42 + tsconfig.json | 18 +- 9 files changed, 2016 insertions(+), 48 deletions(-) create mode 100644 src/styles/diamondDS/diamond-ds-roles.css create mode 100644 src/themes/DiamondDSTheme.ts create mode 100644 src/themes/Theme.test.tsx diff --git a/.storybook/ThemeSwapper.tsx b/.storybook/ThemeSwapper.tsx index efd541cc..280ff099 100644 --- a/.storybook/ThemeSwapper.tsx +++ b/.storybook/ThemeSwapper.tsx @@ -18,18 +18,35 @@ export interface ThemeSwapperProps { export const TextLight = "Mode: Light"; export const TextDark = "Mode: Dark"; +export const TextSystem = "Mode: System"; const ThemeSwapper = ({ context, children }: ThemeSwapperProps) => { - const { mode, setMode } = useColorScheme(); - //if( !mode ) return + const { mode, systemMode, setMode } = useColorScheme(); useEffect(() => { - const selectedThemeMode = context.globals.themeMode || TextLight; - setMode(selectedThemeMode == TextLight ? "light" : "dark"); - }, [context.globals.themeMode]); + const selectedThemeMode = context.globals.themeMode ?? TextSystem; + + if (selectedThemeMode === TextLight) { + setMode("light"); + return; + } + + if (selectedThemeMode === TextDark) { + setMode("dark"); + return; + } + + setMode("system"); + }, [context.globals.themeMode, setMode]); + + const resolvedMode = mode === "system" ? systemMode : mode; return ( -
+
{children}
); diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 4de2bdc4..4902bf86 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,12 +3,32 @@ import { CssBaseline } from "@mui/material"; import type { Preview } from "@storybook/react"; import { ThemeProvider } from "../src"; -import { GenericTheme, DiamondTheme } from "../src"; - -import { Context, ThemeSwapper, TextLight, TextDark } from "./ThemeSwapper"; +import { GenericTheme, DiamondTheme, DiamondDSTheme } from "../src"; +import { ThemeSwapper, TextLight, TextDark, TextSystem } from "./ThemeSwapper"; +import "../src/styles/diamondDS/diamond-ds-roles.css"; const TextThemeBase = "Theme: Generic"; const TextThemeDiamond = "Theme: Diamond"; +const TextThemeDiamondDS = "Theme: DiamondDS"; + +function resolveTheme(selectedTheme: string) { + switch (selectedTheme) { + case TextThemeBase: + return GenericTheme; + case TextThemeDiamond: + return DiamondTheme; + case TextThemeDiamondDS: + default: + return DiamondDSTheme; + } +} + +function resolveDefaultMode(selectedThemeMode: string) { + if (selectedThemeMode === TextLight) return "light"; + if (selectedThemeMode === TextDark) return "dark"; + + return "system"; +} export const decorators = [ (StoriesWithPadding: React.FC) => { @@ -18,24 +38,21 @@ export const decorators = [
); }, - (StoriesWithThemeSwapping: React.FC, context: Context) => { - return ( - - - - ); - }, - (StoriesWithThemeProvider: React.FC, context: Context) => { - const selectedTheme = context.globals.theme || TextThemeBase; - const selectedThemeMode = context.globals.themeMode || TextLight; + + (Story, context) => { + const selectedTheme = context.globals.theme || TextThemeDiamondDS; + const selectedThemeMode = context.globals.themeMode || TextSystem; return ( - + + + + ); }, @@ -48,7 +65,7 @@ const preview: Preview = { toolbar: { title: "Theme", icon: "cog", - items: [TextThemeBase, TextThemeDiamond], + items: [TextThemeBase, TextThemeDiamond, TextThemeDiamondDS], dynamicTitle: true, }, }, @@ -57,14 +74,14 @@ const preview: Preview = { toolbar: { title: "Theme Mode", icon: "mirror", - items: [TextLight, TextDark], + items: [TextLight, TextDark, TextSystem], dynamicTitle: true, }, }, }, initialGlobals: { - theme: "Theme: Diamond", - themeMode: "Mode: Light", + theme: TextThemeDiamondDS, + themeMode: TextSystem, }, parameters: { controls: { @@ -79,11 +96,12 @@ const preview: Preview = { storySort: { order: [ "Introduction", - "Components", + "Helpers", "Theme", "Theme/Logos", "Theme/Colours", - "Helpers", + "MUI", + "Components", ], }, }, diff --git a/package.json b/package.json index 52c035e5..ea3ff23c 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,8 @@ "brace-expansion@^2.0.0": "2.0.2", "@babel/runtime@^7.26.0": "7.27.6", "esbuild@>=0.24.0 <0.25.0": "0.25.0", - "webpack@^5.0.0": "5.104.1" + "webpack@^5.0.0": "5.104.1", + "fast-uri@<3.1.2": "3.1.2" } }, "packageManager": "pnpm@9.12.3+sha256.24235772cc4ac82a62627cd47f834c72667a2ce87799a846ec4e8e555e2d4b8b" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75bba6b4..e845ddf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,7 @@ overrides: '@babel/runtime@^7.26.0': 7.27.6 esbuild@>=0.24.0 <0.25.0: 0.25.0 webpack@^5.0.0: 5.104.1 + fast-uri@<3.1.2: 3.1.2 importers: @@ -3211,8 +3212,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.0.5: - resolution: {integrity: sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fastq@1.18.0: resolution: {integrity: sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==} @@ -7629,7 +7630,7 @@ snapshots: ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.0.5 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -8535,7 +8536,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.0.5: {} + fast-uri@3.1.2: {} fastq@1.18.0: dependencies: diff --git a/src/index.ts b/src/index.ts index 9b8dda18..386c9447 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,7 @@ export * from "./components/helpers/jsonforms"; // themes export * from "./themes/BaseTheme"; export * from "./themes/DiamondTheme"; -export * from "./themes/DiamondOldTheme"; +export * from "./themes/DiamondDSTheme"; export * from "./themes/GenericTheme"; export * from "./themes/ThemeProvider"; export * from "./themes/ThemeManager"; diff --git a/src/styles/diamondDS/diamond-ds-roles.css b/src/styles/diamondDS/diamond-ds-roles.css new file mode 100644 index 00000000..2ccf5815 --- /dev/null +++ b/src/styles/diamondDS/diamond-ds-roles.css @@ -0,0 +1,481 @@ +:root { + /* Neutral primitives */ + --ds-grey-50: #f8f8fa; + --ds-grey-100: #eef1f5; + --ds-grey-200: #e6e9f0; + --ds-grey-300: #dde1e8; + --ds-grey-400: #bcc2cd; + --ds-grey-500: #a5acb8; + --ds-grey-600: #8a90a0; + --ds-grey-700: #505563; + --ds-grey-800: #2c3140; + --ds-grey-900: #1a1c23; + + --ds-grey-dark-50: #e8eaf0; + --ds-grey-dark-100: #b6bcc9; + --ds-grey-dark-200: #7c8394; + --ds-grey-dark-300: #505664; + --ds-grey-dark-400: #3a3f4c; + --ds-grey-dark-500: #2c3140; + --ds-grey-dark-600: #222632; + --ds-grey-dark-700: #161820; + --ds-grey-dark-800: #0e1017; +} + +/* Light mode semantic roles */ +:root, +:root[data-mode="light"] { + color-scheme: light; + + /* Neutral roles */ + --ds-background: #f6f6f9; + --ds-background-channel: 246 246 249; + + --ds-surface: #ffffff; + --ds-surface-channel: 255 255 255; + + --ds-surface-container: var(--ds-grey-100); + --ds-surface-container-high: var(--ds-grey-200); + --ds-surface-disabled: rgba(0, 0, 0, 0.08); + + --ds-on-surface: var(--ds-grey-900); + --ds-on-surface-variant: var(--ds-grey-700); + --ds-on-surface-disabled: rgba(0, 0, 0, 0.36); + --ds-action-disabled: rgba(0, 0, 0, 0.3); + --ds-on-solid: #ffffff; + + --ds-on-surface-channel: 26 28 35; + --ds-on-surface-variant-channel: 80 85 99; + + --ds-placeholder: var(--ds-grey-600); + --ds-placeholder-focus: var(--ds-grey-700); + + --ds-border-subtle: var(--ds-grey-300); + --ds-border: var(--ds-grey-400); + --ds-border-emphasis: var(--ds-grey-500); + --ds-border-subtle-channel: 221 225 232; + + /* Interaction overlays + * + * Overlays are layered on top of semantic surfaces rather than replacing them. + */ + --ds-overlay-hover: rgba(0, 0, 0, 0.08); + --ds-overlay-hover-solid: rgba(0, 0, 0, 0.16); + --ds-overlay-selected: rgba(0, 0, 0, 0.25); + --ds-overlay-selected-channel: 0 0 0; + --ds-overlay-focus: rgba(0, 0, 0, 0.1); + + /* Intent semantic roles + * + * Used for action hierarchy and status meaning. + * + * Scale logic: + * - accent = lighter/supporting emphasis + * - main = default semantic role + * - emphasis = stronger emphasis + * - container = subtle surface + * - solid = filled surface + */ + + /* Primary (Indigo-Blue) */ + --ds-primary: #2a4db8; + --ds-on-primary: #ffffff; + --ds-primary-emphasis: #1f3d96; + --ds-primary-accent: #6a86e4; + --ds-primary-container: #e5ebff; + --ds-on-primary-container: #1a2f6b; + --ds-primary-solid: #3f63c9; + --ds-on-primary-solid: #ffffff; + + --ds-on-primary-channel: 255 255 255; + --ds-primary-mainChannel: 42 77 184; + --ds-primary-lightChannel: 106 134 228; + --ds-primary-darkChannel: 31 61 150; + + /* Secondary (Teal) */ + --ds-secondary: #007b84; + --ds-on-secondary: #ffffff; + --ds-secondary-emphasis: #005f67; + --ds-secondary-accent: #27adb7; + --ds-secondary-container: #ddf3f5; + --ds-on-secondary-container: #00474d; + --ds-secondary-solid: #0a858e; + --ds-on-secondary-solid: #ffffff; + + --ds-on-secondary-channel: 255 255 255; + --ds-secondary-mainChannel: 0 123 132; + --ds-secondary-lightChannel: 39 173 183; + --ds-secondary-darkChannel: 0 95 103; + + /* Tertiary (Violet) + * + * Available as a token family but not currently exposed as a MUI intent colour. + */ + --ds-tertiary: #8c0070; + --ds-on-tertiary: #ffffff; + --ds-tertiary-emphasis: #6c0057; + --ds-tertiary-accent: #c735a8; + --ds-tertiary-container: #f8e2f2; + --ds-on-tertiary-container: #4f003f; + --ds-tertiary-solid: #b8329b; + --ds-on-tertiary-solid: #ffffff; + + --ds-on-tertiary-channel: 255 255 255; + --ds-tertiary-mainChannel: 140 0 112; + --ds-tertiary-lightChannel: 199 53 168; + --ds-tertiary-darkChannel: 108 0 87; + + /* Brand (Diamond Blue) */ + --ds-brand: #202945; + --ds-on-brand: #ffffff; + --ds-brand-emphasis: #171f35; + --ds-brand-accent: #6a86db; + --ds-brand-container: #e4e8f4; + --ds-on-brand-container: #202945; + --ds-brand-solid: #2f3b63; + --ds-on-brand-solid: #ffffff; + + /* Fixed brand roles + * + * These remain stable across light and dark mode. + * Use sparingly for persistent Diamond identity surfaces or accents. + */ + --ds-brand-fixed: #202945; + --ds-brand-fixed-dim: #586084; + --ds-on-brand-fixed: #ffffff; + + --ds-on-brand-channel: 255 255 255; + --ds-brand-mainChannel: 32 41 69; + --ds-brand-lightChannel: 106 134 219; + --ds-brand-darkChannel: 23 31 53; + + /* Danger (Red) */ + --ds-danger: #b42318; + --ds-on-danger: #ffffff; + --ds-danger-emphasis: #912018; + --ds-danger-accent: #d94f45; + --ds-danger-container: #fde7e5; + --ds-on-danger-container: #6a1b15; + --ds-danger-solid: #d63c41; + --ds-on-danger-solid: #ffffff; + + --ds-on-danger-channel: 255 255 255; + --ds-danger-mainChannel: 180 35 24; + --ds-danger-lightChannel: 217 79 69; + --ds-danger-darkChannel: 145 32 24; + + /* Warning (Orange) */ + --ds-warning: #c96a04; + --ds-on-warning: #ffffff; + --ds-warning-emphasis: #a95703; + --ds-warning-accent: #e98a15; + --ds-warning-container: #fef0df; + --ds-on-warning-container: #6f3200; + --ds-warning-solid: #e97b12; + --ds-on-warning-solid: #ffffff; + + --ds-on-warning-channel: 255 255 255; + --ds-warning-mainChannel: 201 106 4; + --ds-warning-lightChannel: 233 138 21; + --ds-warning-darkChannel: 169 87 3; + + /* Success (Green) */ + --ds-success: #187a2f; + --ds-on-success: #ffffff; + --ds-success-emphasis: #146125; + --ds-success-accent: #2fb344; + --ds-success-container: #e3f4e7; + --ds-on-success-container: #124d22; + --ds-success-solid: #1b8834; + --ds-on-success-solid: #ffffff; + + --ds-on-success-channel: 255 255 255; + --ds-success-mainChannel: 24 122 47; + --ds-success-lightChannel: 47 154 73; + --ds-success-darkChannel: 20 97 37; + + /* Info (Blue) */ + --ds-info: #355ec9; + --ds-on-info: #ffffff; + --ds-info-emphasis: #2a4ea7; + --ds-info-accent: #6f8fe8; + --ds-info-container: #e9efff; + --ds-on-info-container: #1f3b85; + --ds-info-solid: #4d72dd; + --ds-on-info-solid: #ffffff; + + --ds-on-info-channel: 255 255 255; + --ds-info-mainChannel: 53 94 201; + --ds-info-lightChannel: 111 143 232; + --ds-info-darkChannel: 42 78 167; + + /* Highlight + * + * Available as a token family but not currently exposed as a MUI intent colour. + */ + --ds-highlight: #d4a900; + --ds-on-highlight: #1a1c23; + --ds-highlight-emphasis: #b89300; + --ds-highlight-accent: #ffd84d; + --ds-highlight-container: #fff4cc; + --ds-on-highlight-container: #6b5500; + --ds-highlight-solid: #b89300; + --ds-on-highlight-solid: #ffffff; + + --ds-on-highlight-channel: 26 28 35; + --ds-highlight-mainChannel: 212 169 0; + --ds-highlight-lightChannel: 255 216 77; + --ds-highlight-darkChannel: 184 147 0; + + /* Focus roles */ + --ds-focus-ring: var(--ds-primary-accent); + --ds-focus-ring-width: 2px; + --ds-focus-ring-offset: 2px; +} + +/** + * Dark mode semantic roles. + * + * Values are tuned for dark surfaces rather than mechanically inverted from light mode. + */ +:root[data-mode="dark"] { + color-scheme: dark; + + /* Neutral roles */ + --ds-background: var(--ds-grey-dark-800); + --ds-background-channel: 14 16 23; + + --ds-surface: var(--ds-grey-dark-700); + --ds-surface-channel: 22 24 32; + + --ds-surface-container: var(--ds-grey-dark-600); + --ds-surface-container-high: var(--ds-grey-dark-500); + --ds-surface-disabled: rgba(255, 255, 255, 0.14); + + --ds-on-surface: var(--ds-grey-dark-50); + --ds-on-surface-variant: var(--ds-grey-dark-100); + --ds-on-surface-disabled: rgba(255, 255, 255, 0.36); + --ds-action-disabled: rgba(255, 255, 255, 0.3); + --ds-on-solid: #ffffff; + + --ds-on-surface-channel: 232 234 240; + --ds-on-surface-variant-channel: 182 188 201; + + --ds-placeholder: var(--ds-grey-dark-200); + --ds-placeholder-focus: var(--ds-grey-dark-100); + + --ds-border-subtle: var(--ds-grey-dark-400); + --ds-border: var(--ds-grey-dark-300); + --ds-border-emphasis: var(--ds-grey-dark-200); + --ds-border-subtle-channel: 58 63 76; + + /* Interaction overlays */ + --ds-overlay-hover: rgba(255, 255, 255, 0.16); + --ds-overlay-hover-solid: rgba(255, 255, 255, 0.16); + --ds-overlay-selected: rgba(255, 255, 255, 0.12); + --ds-overlay-selected-channel: 255 255 255; + --ds-overlay-focus: rgba(255, 255, 255, 0.12); + + /* Primary */ + --ds-primary: #8aa7ff; + --ds-on-primary: #0b1638; + --ds-primary-emphasis: #c4d4ff; + --ds-primary-accent: #a5bcff; + --ds-primary-container: #1b2c5f; + --ds-on-primary-container: #e8eeff; + --ds-primary-solid: #3f63c9; + --ds-on-primary-solid: #ffffff; + + --ds-on-primary-channel: 11 22 56; + --ds-primary-mainChannel: 138 167 255; + --ds-primary-lightChannel: 196 212 255; + --ds-primary-darkChannel: 165 188 255; + + /* Secondary */ + --ds-secondary: #58d6de; + --ds-on-secondary: #002529; + --ds-secondary-emphasis: #9af0f3; + --ds-secondary-accent: #7be4ea; + --ds-secondary-container: #0d3338; + --ds-on-secondary-container: #ccf7f9; + --ds-secondary-solid: #0a858e; + --ds-on-secondary-solid: #ffffff; + + --ds-on-secondary-channel: 0 37 41; + --ds-secondary-mainChannel: 88 214 222; + --ds-secondary-lightChannel: 154 240 243; + --ds-secondary-darkChannel: 123 228 234; + + /* Tertiary */ + --ds-tertiary: #e587d1; + --ds-on-tertiary: #2a0022; + --ds-tertiary-emphasis: #f7bfeb; + --ds-tertiary-accent: #efa5e0; + --ds-tertiary-container: #381232; + --ds-on-tertiary-container: #f9d8f1; + --ds-tertiary-solid: #b8329b; + --ds-on-tertiary-solid: #ffffff; + + --ds-on-tertiary-channel: 42 0 34; + --ds-tertiary-mainChannel: 229 135 209; + --ds-tertiary-lightChannel: 247 191 235; + --ds-tertiary-darkChannel: 239 165 224; + + /* Brand */ + --ds-brand: #aabdff; + --ds-on-brand: #0d1530; + --ds-brand-emphasis: #d7e1ff; + --ds-brand-accent: #c4d2ff; + --ds-brand-container: #202945; + --ds-on-brand-container: #e3e8f7; + --ds-brand-solid: #3a4a78; + --ds-on-brand-solid: #ffffff; + + --ds-brand-fixed: #202945; + --ds-brand-fixed-dim: #586084; + --ds-on-brand-fixed: #ffffff; + + --ds-on-brand-channel: 13 21 48; + --ds-brand-mainChannel: 170 189 255; + --ds-brand-lightChannel: 215 225 255; + --ds-brand-darkChannel: 196 210 255; + + /* Danger */ + --ds-danger: #ff9088; + --ds-on-danger: #2f0907; + --ds-danger-emphasis: #ffc7c2; + --ds-danger-accent: #ffb0aa; + --ds-danger-container: #3a1613; + --ds-on-danger-container: #ffd9d6; + --ds-danger-solid: #d63c41; + --ds-on-danger-solid: #ffffff; + + --ds-on-danger-channel: 47 9 7; + --ds-danger-mainChannel: 255 144 136; + --ds-danger-lightChannel: 255 199 194; + --ds-danger-darkChannel: 255 176 170; + + /* Warning */ + --ds-warning: #ffb067; + --ds-on-warning: #311700; + --ds-warning-emphasis: #ffd9b0; + --ds-warning-accent: #ffc68a; + --ds-warning-container: #382006; + --ds-on-warning-container: #ffe4c8; + --ds-warning-solid: #f07a13; + --ds-on-warning-solid: #ffffff; + + --ds-on-warning-channel: 49 23 0; + --ds-warning-mainChannel: 255 176 103; + --ds-warning-lightChannel: 255 217 176; + --ds-warning-darkChannel: 255 198 138; + + /* Success */ + --ds-success: #6fd88a; + --ds-on-success: #08210f; + --ds-success-emphasis: #b3f0c0; + --ds-success-accent: #8ae5a2; + --ds-success-container: #10341a; + --ds-on-success-container: #d2f7da; + --ds-success-solid: #23913c; + --ds-on-success-solid: #ffffff; + + --ds-on-success-channel: 8 33 15; + --ds-success-mainChannel: 111 216 138; + --ds-success-lightChannel: 179 240 192; + --ds-success-darkChannel: 138 229 162; + + /* Info */ + --ds-info: #9fb7ff; + --ds-on-info: #101936; + --ds-info-emphasis: #d5e0ff; + --ds-info-accent: #bccdff; + --ds-info-container: #1b2b57; + --ds-on-info-container: #dce4ff; + --ds-info-solid: #4d72dd; + --ds-on-info-solid: #ffffff; + + --ds-on-info-channel: 16 25 54; + --ds-info-mainChannel: 159 183 255; + --ds-info-lightChannel: 213 224 255; + --ds-info-darkChannel: 188 205 255; + + /* Highlight */ + --ds-highlight: #ffd84d; + --ds-on-highlight: #2a2100; + --ds-highlight-emphasis: #fff1b8; + --ds-highlight-accent: #ffeaa0; + --ds-highlight-container: #4b3a05; + --ds-on-highlight-container: #fff4c7; + --ds-highlight-solid: #d4a900; + --ds-on-highlight-solid: #1a1c23; + + --ds-on-highlight-channel: 26 28 35; + --ds-highlight-mainChannel: 255 226 122; + --ds-highlight-lightChannel: 255 241 184; + --ds-highlight-darkChannel: 255 234 160; +} + +/* Elavation colors + +0: base paper, dialogs on clean surface +1–3: cards, panels, raised sections +4–8: menus, popovers, floating UI +9–16: more obviously separated overlays +17–24: rare, maximum lift + +Figma references: +LIGHT +elevation-0 = #FFFFFF +elevation-1 = #FDFDFE +elevation-2 = #FAFBFC +elevation-3 = #F8F9FB +elevation-4 = #F7F9FB +elevation-5 = #F6F8FA +elevation-6 = #F5F7F9 +elevation-7 = #F4F6F8 +elevation-8 = #F3F5F7 +elevation-9 = #F3F5F7 +elevation-10 = #F2F4F7 +elevation-11 = #F2F4F7 +elevation-12 = #F1F3F6 +elevation-13 = #F1F3F6 +elevation-14 = #F1F3F6 +elevation-15 = #F0F2F5 +elevation-16 = #F0F2F5 +elevation-17 = #F0F2F5 +elevation-18 = #EFF1F4 +elevation-19 = #EFF1F4 +elevation-20 = #EEF1F5 +elevation-21 = #EEF1F5 +elevation-22 = #EEF1F5 +elevation-23 = #EEF1F5 +elevation-24 = #EEF1F5 + +DARK +elevation-0 = #161820 +elevation-1 = #181B23 +elevation-2 = #191C25 +elevation-3 = #1A1E27 +elevation-4 = #1B1F28 +elevation-5 = #1C202A +elevation-6 = #1E222C +elevation-7 = #1F232D +elevation-8 = #20242F +elevation-9 = #20242F +elevation-10 = #212631 +elevation-11 = #212631 +elevation-12 = #222632 +elevation-13 = #222632 +elevation-14 = #222632 +elevation-15 = #242935 +elevation-16 = #242935 +elevation-17 = #252A37 +elevation-18 = #262C39 +elevation-19 = #262C39 +elevation-20 = #28303C +elevation-21 = #28303C +elevation-22 = #2A3140 +elevation-23 = #2A3140 +elevation-24 = #2C3140 +*/ diff --git a/src/themes/DiamondDSTheme.ts b/src/themes/DiamondDSTheme.ts new file mode 100644 index 00000000..23e81f57 --- /dev/null +++ b/src/themes/DiamondDSTheme.ts @@ -0,0 +1,1418 @@ +/** + * DiamondDS MUI theme + * + * Maps DiamondDS semantic design tokens and interaction rules into MUI's + * theme system, component model and runtime styling APIs. + * + * CSS variables remain the source of truth. + * The MUI theme acts as the semantic adapter consumed by components. + * + * Components should consume semantic roles from the theme or semantic CSS + * variables rather than raw colour values. + */ +import "../styles/diamondDS/diamond-ds-roles.css"; + +// Enables `theme.vars` typings for MUI CSS variable themes. +import type {} from "@mui/material/themeCssVarsAugmentation"; +import { extendTheme } from "@mui/material/styles"; +import type { CSSObject, Theme } from "@mui/material/styles"; + +/** + * Component prop types are used to type `ownerState` inside MUI style overrides. + */ +import type { AlertProps } from "@mui/material/Alert"; +import type { ButtonProps } from "@mui/material/Button"; +import type { CheckboxProps } from "@mui/material/Checkbox"; +import type { ChipProps } from "@mui/material/Chip"; +import type { CircularProgressProps } from "@mui/material/CircularProgress"; +import type { LinearProgressProps } from "@mui/material/LinearProgress"; +import type { OutlinedInputProps } from "@mui/material/OutlinedInput"; +import type { RadioProps } from "@mui/material/Radio"; +import type { TabProps } from "@mui/material/Tab"; + +import logoImageLight from "../public/diamond/logo-light.svg"; +import logoImageDark from "../public/diamond/logo-dark.svg"; +import logoShort from "../public/diamond/logo-short.svg"; +import type { ImageColourSchemeSwitchType } from "components/controls/ImageColourSchemeSwitch"; + +/** + * Standard argument shape for MUI style override callbacks. + * + * `ownerState` is MUI's current component prop/state snapshot. + */ +type OverrideArgs = { + ownerState: OwnerState; + theme: Theme; +}; + +/** + * Theme-only argument shape for MUI style overrides. + */ +type ThemeOnlyArgs = { + theme: Theme; +}; + +/** + * Canonical list of supported DiamondDS intent colours. + * + * DiamondDS supports: + * - action intents: primary, secondary + * - status intents: success, warning, error, info + * + * Intent colours communicate hierarchy, meaning and state through component + * APIs such as `color="primary"` or `color="error"`. + * + * Brand is intentionally excluded. Brand communicates Diamond identity rather + * than behaviour or status. + */ +const intentColours = [ + "primary", + "secondary", + "error", + "warning", + "info", + "success", +] as const; + +type IntentColour = (typeof intentColours)[number]; + +/** + * Internal DiamondDS palette contract. + * + * Every supported intent colour must provide the roles needed for text, + * container, solid and interaction states. MUI's public palette option types + * remain partial, but DiamondDS helpers use this stricter resolved contract. + */ +type ExtendedPaletteColor = { + light: string; + main: string; + dark: string; + contrastText: string; + mainChannel: string; + lightChannel: string; + darkChannel: string; + contrastTextChannel: string; + container: string; + onContainer: string; + solid: string; + onSolid: string; +}; + +type BrandPaletteColor = ExtendedPaletteColor & { + /** + * Fixed brand roles stay stable across light and dark mode. + * + * Use for persistent Diamond identity surfaces or accents only. + */ + fixed: string; + fixedDim: string; + onFixed: string; +}; + +type BrandPaletteOptions = Partial; + +/** + * Strict DiamondDS intent palette map. + * + * Every supported intent colour must provide the full semantic role set. + */ +type IntentPaletteRecord = Record; + +/** + * Theme shape used by DiamondDS intent helpers. + * + * `theme.palette` is treated as the resolved strict contract. + * `theme.vars.palette` remains partial because MUI controls CSS variable + * resolution. + */ +type ThemeWithIntentPalette = Theme & { + vars?: { + palette?: Partial>>; + }; + palette: Theme["palette"] & IntentPaletteRecord; +}; + +/** + * MUI theme augmentation for DiamondDS semantic roles. + * + * CSS variables remain the source of truth. These typings expose DiamondDS + * text, surface, border and palette roles through the MUI theme API. + */ +declare module "@mui/material/styles" { + interface CssVarsTheme { + logos?: { + normal: ImageColourSchemeSwitchType; + short?: ImageColourSchemeSwitchType; + }; + } + + interface CssVarsThemeOptions { + logos?: { + normal: ImageColourSchemeSwitchType; + short?: ImageColourSchemeSwitchType; + }; + } + + interface TypeBackground { + default: string; + paper: string; + } + + interface TypeText { + placeholder?: string; + placeholderFocus?: string; + onSolid?: string; + primaryChannel?: string; + secondaryChannel?: string; + } + + interface TypeTextOptions { + primary?: string; + secondary?: string; + disabled?: string; + placeholder?: string; + placeholderFocus?: string; + primaryChannel?: string; + secondaryChannel?: string; + } + + interface Palette { + /** + * Brand is an identity/accent colour, not an intent colour. + * + * Use it for Diamond recognition, product identity and selected visual + * accents. Avoid using it as a general status or behaviour signal. + */ + brand?: BrandPaletteColor; + + /** Neutral border roles used for structure, not meaning. */ + borders: { + subtle: string; + base: string; + emphasis: string; + }; + + /** Neutral surface roles used to create hierarchy without semantic state. */ + surface: { + subtle: string; + strong: string; + }; + } + + /** + * Theme authoring interface. + * + * Unlike the resolved runtime palette, theme options remain intentionally + * partial so themes can provide only the values they need to override. + * + * DiamondDS extends MUI's palette options with: + * - brand identity roles + * - semantic border roles + * - semantic surface roles + * + * The stricter runtime intent contract is enforced separately through + * IntentPaletteRecord and ExtendedPaletteColor. + */ + interface PaletteOptions { + brand?: BrandPaletteOptions; + + borders?: { + subtle?: string; + base?: string; + emphasis?: string; + }; + surface?: { + subtle?: string; + strong?: string; + }; + } + + interface PaletteColor { + mainChannel?: string; + lightChannel?: string; + darkChannel?: string; + contrastTextChannel?: string; + container?: string; + onContainer?: string; + solid?: string; + onSolid?: string; + } + + interface SimplePaletteColorOptions { + mainChannel?: string; + lightChannel?: string; + darkChannel?: string; + contrastTextChannel?: string; + container?: string; + onContainer?: string; + solid?: string; + onSolid?: string; + } +} + +export type DSMode = "light" | "dark"; + +// --- Semantic palette and interaction helpers --- + +const isIntentColour = (colour: unknown): colour is IntentColour => + typeof colour === "string" && intentColours.includes(colour as IntentColour); + +/** + * Creates a DiamondDS semantic palette entry from a token namespace. + * + * CSS variables remain the source of truth. The MUI palette is an adapter layer + * that lets component overrides use stable semantic names instead of repeating + * raw `var(--ds-*)` references everywhere. + * + * MUI mapping follows the DiamondDS/Radix-style role logic: + * - light -> accent / focus-adjacent role + * - main -> default semantic colour + * - dark -> stronger emphasis role (not simply a darker colour) + * - container -> subtle semantic surface + * - onContainer -> foreground on subtle semantic surface + * - solid -> filled interactive surface + * - onSolid -> foreground on filled interactive surface + */ +const createPaletteColour = (tokenName: string): ExtendedPaletteColor => ({ + light: `var(--ds-${tokenName}-accent)`, + main: `var(--ds-${tokenName})`, + dark: `var(--ds-${tokenName}-emphasis)`, + contrastText: `var(--ds-on-${tokenName})`, + container: `var(--ds-${tokenName}-container)`, + onContainer: `var(--ds-on-${tokenName}-container)`, + solid: `var(--ds-${tokenName}-solid)`, + onSolid: `var(--ds-on-${tokenName}-solid)`, + + contrastTextChannel: `var(--ds-on-${tokenName}-channel)`, + mainChannel: `var(--ds-${tokenName}-mainChannel)`, + lightChannel: `var(--ds-${tokenName}-lightChannel)`, + darkChannel: `var(--ds-${tokenName}-darkChannel)`, +}); + +/** + * Creates the DiamondDS brand palette. + * + * Brand includes the regular semantic palette roles plus fixed brand roles. + * Fixed roles remain stable across light and dark mode and should only be used + * for persistent Diamond identity surfaces or accents. + */ +const createBrandPaletteColour = (): BrandPaletteColor => ({ + ...createPaletteColour("brand"), + + fixed: "var(--ds-brand-fixed)", + fixedDim: "var(--ds-brand-fixed-dim)", + onFixed: "var(--ds-on-brand-fixed)", +}); + +/** + * MUI uses `error`; DiamondDS tokens use `danger`. + * + * Keep the translation here so component code can continue to speak MUI while + * the CSS token layer can use DiamondDS language. + */ +const intentTokenName: Record = { + primary: "primary", + secondary: "secondary", + error: "danger", + warning: "warning", + success: "success", + info: "info", +}; + +/** + * Builds the complete DiamondDS intent palette from token namespaces. + * + * Keeping this generated from `intentTokenName` avoids repeating the same MUI + * palette mapping for every supported intent. + */ +const createIntentPalette = (): IntentPaletteRecord => ({ + primary: createPaletteColour(intentTokenName.primary), + secondary: createPaletteColour(intentTokenName.secondary), + error: createPaletteColour(intentTokenName.error), + warning: createPaletteColour(intentTokenName.warning), + success: createPaletteColour(intentTokenName.success), + info: createPaletteColour(intentTokenName.info), +}); + +/** + * Returns a supported intent palette. + * + * `theme.vars.palette` can be present when MUI CSS variables are enabled. When + * it exists, it may contain the resolved variable-aware values. We merge it over + * `theme.palette` while preserving the DiamondDS contract. + * + * Fallback policy: + * - unsupported colour values fall back to primary before this function is used + * - missing palette entries fall back to primary in development with a warning + * + * That fallback has a deliberate meaning: primary is the safest non-destructive + * action intent. We do not silently fall back from error/warning to decorative + * or brand values. + */ +const getIntentPalette = ( + theme: Theme, + colour: IntentColour, +): ExtendedPaletteColor => { + const { vars, palette } = theme as ThemeWithIntentPalette; + + const paletteColour = palette[colour]; + const varsColour = vars?.palette?.[colour]; + + if (paletteColour) { + return { + ...paletteColour, + ...varsColour, + }; + } + + if (process.env.NODE_ENV !== "production") { + console.warn( + `[DiamondDS] getIntentPalette: colour "${colour}" not found. Falling back to primary.`, + ); + } + + return { + ...palette.primary, + ...vars?.palette?.primary, + }; +}; + +/** + * Normalises external MUI colour props into DiamondDS-supported intents. + * + * Component `ownerState` values come from MUI props and internal state. They can + * include values such as `inherit`, `default`, or custom app colours. DiamondDS + * only treats the declared `IntentColour` set as semantic intents. + */ +const getIntentFromColourProp = ( + colour: unknown, + fallback: IntentColour = "primary", +): IntentColour => (isIntentColour(colour) ? colour : fallback); + +/** + * Focus rings use one shared DiamondDS focus token. + * + * Focus shows keyboard/navigation state. It should not change by intent, + * status or validation colour. + */ +const getFocusOutline = (): CSSObject => ({ + "&.Mui-focusVisible": { + outline: "var(--ds-focus-ring-width) solid var(--ds-focus-ring)", + outlineOffset: "var(--ds-focus-ring-offset)", + }, +}); + +/** + * Interaction overlays are layered on top of the base surface. + * + * This keeps hover/active/focus feedback separate from semantic colour roles, + * which is especially useful across light and dark modes. + */ +const getOverlayInset = (token = "var(--ds-overlay-hover)") => + `inset 0 0 0 9999px ${token}`; + +/** + * Shared interaction treatment for semantic interactive surfaces. + * + * Keeps hover and active overlays visually consistent across components. + */ +const getInteractiveSurfaceStateStyles = ( + backgroundColor: string, + overlay = "var(--ds-overlay-hover)", +): CSSObject => ({ + "&:hover": { + backgroundColor, + boxShadow: getOverlayInset(overlay), + }, + + "&:active": { + backgroundColor, + boxShadow: getOverlayInset("var(--ds-overlay-selected)"), + }, +}); + +/** + * Disabled state intentionally removes interactive affordances. + * + * Disabled styles should visually override hover, focus and active states. + */ +const getDisabledControlStyles = (backgroundColor = "transparent"): CSSObject => + ({ + opacity: 1, + backgroundColor, + color: "var(--ds-on-surface-disabled)", + boxShadow: "none", + }) satisfies CSSObject; + +/** + * Creates the resolved DiamondDS MUI theme. + * + * This factory: + * - maps DiamondDS semantic tokens into MUI + * - configures component defaults and overrides + * - applies light/dark semantic role resolution + * - keeps CSS variables as the source of truth + * + * The resulting theme should expose semantic roles rather than raw colours. + */ + +/** + * Creates the shared DiamondDS semantic palette for a colour scheme. + * + * Light and dark schemes intentionally reference the same semantic CSS + * variables. The actual values are resolved by the `data-mode` attribute on + * ``, keeping CSS variables as the source of truth while still giving + * MUI a proper colour-scheme-aware theme. + */ +const createDiamondPalette = (mode: DSMode) => { + const intentPalette = createIntentPalette(); + + return { + mode, + + /** + * MUI action tokens are mapped to DiamondDS overlay and disabled roles. + * + * Components should prefer semantic CSS variables directly where they need + * precise behaviour, but these values keep MUI defaults aligned with the + * design system. + */ + action: { + hover: "var(--ds-overlay-hover)", + selected: "var(--ds-overlay-selected)", + selectedChannel: "var(--ds-overlay-selected-channel)", + focus: "var(--ds-overlay-focus)", + disabled: "var(--ds-on-surface-disabled)", + disabledBackground: "var(--ds-surface-disabled)", + + hoverOpacity: 0.04, + selectedOpacity: 0.08, + disabledOpacity: 0.38, + focusOpacity: 0.16, + }, + + /** + * Text roles describe hierarchy and surface relationship. + * + * Prefer these semantic roles over raw greys so dark mode and future + * accessibility refinements can be made centrally. + */ + text: { + primary: "var(--ds-on-surface)", + secondary: "var(--ds-on-surface-variant)", + onSolid: "var(--ds-on-solid)", + disabled: "var(--ds-on-surface-disabled)", + placeholder: "var(--ds-placeholder)", + placeholderFocus: "var(--ds-placeholder-focus)", + + primaryChannel: "var(--ds-on-surface-channel)", + secondaryChannel: "var(--ds-on-surface-variant-channel)", + }, + + background: { + default: "rgb(var(--ds-background-channel))", + paper: "rgb(var(--ds-surface-channel))", + }, + + divider: "var(--ds-border-subtle)", + dividerChannel: "var(--ds-border-subtle-channel)", + + borders: { + subtle: "var(--ds-border-subtle)", + base: "var(--ds-border)", + emphasis: "var(--ds-border-emphasis)", + }, + + surface: { + subtle: "var(--ds-surface-container)", + strong: "var(--ds-surface-container-high)", + }, + + ...intentPalette, + + /** + * Brand is provided as a palette entry for places that need Diamond visual + * identity, but it is not part of the intent-colour helper path. + */ + brand: createBrandPaletteColour(), + + grey: { + 50: "#f8f8fa", + 100: "#eef1f5", + 200: "#e6e9f0", + 300: "#dde1e8", + 400: "#bcc2cd", + 500: "#a5acb8", + 600: "#8a90a0", + 700: "#505563", + 800: "#2c3140", + 900: "#1a1c23", + }, + }; +}; + +/** + * Resolved DiamondDS MUI theme. + * + * MUI handles the colour-scheme state. DiamondDS handles the actual role values + * through `html[data-mode="light"]` and `html[data-mode="dark"]` CSS variables. + */ +const DiamondDSTheme = extendTheme({ + /** + * Match the DiamondDS runtime selector: + * + * or + */ + colorSchemeSelector: '[data-mode="%s"]', + + colorSchemes: { + light: { + palette: createDiamondPalette("light"), + }, + dark: { + palette: createDiamondPalette("dark"), + }, + }, + + typography: { + fontFamily: [ + '"Inter Variable"', + "Inter", + "system-ui", + "-apple-system", + '"Segoe UI"', + "Roboto", + "Helvetica", + "Arial", + "sans-serif", + ].join(","), + }, + + logos: { + normal: { + src: logoImageLight, + srcDark: logoImageDark ?? logoImageLight, + alt: "Diamond Light Source Logo", + width: "100", + }, + short: { + src: logoShort, + alt: "Diamond Light Source Logo", + width: "35", + }, + }, + + components: { + /** + * Component overrides translate DiamondDS semantic roles into MUI behaviour. + * + * Keep overrides token-led: + * - use semantic tokens or palette roles + * - avoid raw colours + * - keep disabled and error states visually dominant + * - prefer scoped/additive changes over breaking MUI defaults + * + * Component override summary + * + * Base interaction: + * MuiButtonBase → ripple and focus behaviour + * + * Actions and selection: + * MuiButton → contained, outlined and text variants + * MuiIconButton → intent-aware icon actions + * MuiToggleButton → selection, border and hover states + * + * Inputs and forms: + * MuiInputBase → placeholder behaviour + * MuiOutlinedInput → border priority and validation states + * MuiInputLabel → label response to focus and validation + * + * Navigation and display: + * MuiTab → navigation hierarchy and selected state + * MuiAlert → semantic feedback variants + * MuiChip → metadata, status and interactive chips + * + * Progress and loading: + * MuiLinearProgress → semantic activity indicators + * MuiCircularProgress → semantic activity indicators + * MuiSkeleton → loading placeholders and shimmer + * + * Selection controls: + * MuiCheckbox → checked and disabled states + * MuiRadio → checked and disabled states + * + * Feedback surfaces: + * MuiSnackbar → layout constraints + * MuiSnackbarContent → surface styling and actions + */ + + MuiButtonBase: { + /** + * Keeps MUI ripple behaviour available while using DiamondDS focus outlines. + */ + defaultProps: { + disableRipple: false, + disableTouchRipple: false, + focusRipple: false, + }, + }, + + MuiButton: { + /** + * Button uses the DiamondDS intent model: + * + * - contained = solid action surface + * - outlined = subtle intent container with border + * - text = low-emphasis action + * + * Disabled styles are declared inside each variant so they override + * hover, active and focus treatments for that variant. + */ + defaultProps: { + disableFocusRipple: true, + }, + + styleOverrides: { + root: ({ ownerState, theme }: OverrideArgs): CSSObject => { + const base: CSSObject = { + textTransform: "none", + boxShadow: "none", + + "&:hover": { + boxShadow: "none", + }, + }; + + const variant = ownerState.variant ?? "text"; + const rawColour = ownerState.color ?? "primary"; + + if (rawColour === "inherit") { + return { + ...base, + ...getFocusOutline(), + }; + } + + const colour = getIntentFromColourProp(rawColour); + const p = getIntentPalette(theme, colour); + + if (variant === "contained") { + return { + ...base, + + backgroundColor: p.solid, + color: p.onSolid, + + ...getInteractiveSurfaceStateStyles( + p.solid, + "var(--ds-overlay-hover-solid)", + ), + + "&.Mui-focusVisible": { + outline: + "var(--ds-focus-ring-width) solid var(--ds-focus-ring)", + outlineOffset: "var(--ds-focus-ring-offset)", + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + + "&.Mui-disabled": getDisabledControlStyles( + "var(--ds-surface-disabled)", + ), + }; + } + + if (variant === "outlined") { + return { + ...base, + ...getFocusOutline(), + + color: p.onContainer, + backgroundColor: p.container, + border: `1px solid ${p.light}`, + + ...getInteractiveSurfaceStateStyles(p.container), + + "&:hover": { + backgroundColor: p.container, + borderColor: p.main, + boxShadow: getOverlayInset(), + }, + + "&:active": { + backgroundColor: p.container, + borderColor: p.dark, + boxShadow: getOverlayInset("var(--ds-overlay-selected)"), + }, + + "&.Mui-disabled": { + ...getDisabledControlStyles(), + borderColor: "var(--ds-border-subtle)", + }, + }; + } + + if (variant === "text") { + return { + ...base, + ...getFocusOutline(), + + color: p.main, + + "&:hover": { + backgroundColor: p.container, + boxShadow: getOverlayInset(), + }, + + "&.Mui-disabled": { + color: "var(--ds-on-surface-disabled)", + }, + }; + } + + return { + ...base, + ...getFocusOutline(), + }; + }, + }, + }, + + MuiIconButton: { + /** + * IconButton follows the same intent model as Button, but default/inherit + * colours stay neutral unless an explicit intent is provided. + */ + defaultProps: { + disableRipple: false, + disableFocusRipple: true, + }, + styleOverrides: { + root: ({ + ownerState, + theme, + }: OverrideArgs<{ + color?: "inherit" | "default" | IntentColour; + }>): CSSObject => { + const rawColour = ownerState.color ?? "default"; + + if (rawColour === "inherit" || rawColour === "default") { + return { + "&:hover": { + boxShadow: getOverlayInset(), + }, + "&.Mui-disabled": { + color: "var(--ds-on-surface-disabled)", + backgroundColor: "transparent", + boxShadow: "none", + }, + ...getFocusOutline(), + }; + } + + const colour = getIntentFromColourProp(rawColour); + const p = getIntentPalette(theme, colour); + + return { + color: p.main, + + "&:hover": { + backgroundColor: p.container, + boxShadow: getOverlayInset(), + }, + + "&.Mui-disabled": { + color: "var(--ds-on-surface-disabled)", + backgroundColor: "transparent", + boxShadow: "none", + }, + ...getFocusOutline(), + }; + }, + }, + }, + + MuiToggleButton: { + styleOverrides: { + root: ({ theme }: ThemeOnlyArgs): CSSObject => ({ + textTransform: "none", + border: `1px solid ${theme.palette.borders.base}`, + + "&:hover": { + borderColor: theme.palette.borders.emphasis, + }, + + "&.Mui-selected": { + backgroundColor: "var(--ds-primary-container)", + color: "var(--ds-on-primary-container)", + borderColor: "var(--ds-primary-accent)", + }, + + "&.Mui-selected:hover": { + backgroundColor: "var(--ds-primary-container)", + borderColor: "var(--ds-primary)", + boxShadow: getOverlayInset(), + }, + + "&.Mui-disabled": { + color: "var(--ds-on-surface-disabled)", + borderColor: "var(--ds-border-subtle)", + }, + }), + }, + }, + + MuiChip: { + /** + * Chip supports both neutral metadata and semantic status/action usage. + * + * Interactive chips receive focus and overlay states; static chips remain calm. + */ + styleOverrides: { + root: ({ ownerState, theme }: OverrideArgs): CSSObject => { + const base: CSSObject = { + "& .MuiChip-icon": { + color: "currentColor", + }, + }; + + const rawColour = ownerState.color ?? "default"; + const isDefault = rawColour === "default"; + const isOutlined = ownerState.variant === "outlined"; + const isInteractive = !!(ownerState.clickable || ownerState.onDelete); + + if (isDefault) { + const backgroundColor = "var(--ds-surface-container-high)"; + + return { + ...base, + ...(isInteractive ? getFocusOutline() : {}), + + color: "var(--ds-on-surface)", + borderColor: "var(--ds-border)", + backgroundColor, + + ...(isInteractive && { + ...getInteractiveSurfaceStateStyles(backgroundColor), + + "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible": + { + backgroundColor, + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + + "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover": + { + backgroundColor, + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + }), + }; + } + + const colour = getIntentFromColourProp(rawColour); + const p = getIntentPalette(theme, colour); + + if (isOutlined) { + return { + ...base, + ...(isInteractive ? getFocusOutline() : {}), + + color: p.onContainer, + borderColor: p.light, + backgroundColor: p.container, + + ...(isInteractive && { + ...getInteractiveSurfaceStateStyles(p.container), + + "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible": + { + backgroundColor: p.container, + borderColor: p.light, + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + + "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover": + { + backgroundColor: p.container, + borderColor: p.light, + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + }), + }; + } + + return { + ...base, + ...(isInteractive ? getFocusOutline() : {}), + + color: p.onSolid, + backgroundColor: p.solid, + + ...(isInteractive && { + ...getInteractiveSurfaceStateStyles( + p.solid, + "var(--ds-overlay-hover-solid)", + ), + + "&&.MuiChip-clickable.Mui-focusVisible, &&.MuiChip-deletable.Mui-focusVisible": + { + backgroundColor: p.solid, + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + + "&&.MuiChip-clickable.Mui-focusVisible:hover, &&.MuiChip-deletable.Mui-focusVisible:hover": + { + backgroundColor: p.solid, + boxShadow: getOverlayInset("var(--ds-overlay-focus)"), + }, + }), + }; + }, + }, + }, + + MuiInputBase: { + styleOverrides: { + input: ({ theme }: ThemeOnlyArgs): CSSObject => ({ + "&::placeholder": { + color: theme.palette.text.placeholder, + opacity: 1, + }, + + "&::-webkit-input-placeholder": { + color: theme.palette.text.placeholder, + opacity: 1, + }, + + "&::-moz-placeholder": { + color: theme.palette.text.placeholder, + opacity: 1, + }, + + "&:focus::placeholder": { + color: theme.palette.text.placeholderFocus, + }, + + "&:focus::-webkit-input-placeholder": { + color: theme.palette.text.placeholderFocus, + opacity: 1, + }, + + "&:focus::-moz-placeholder": { + color: theme.palette.text.placeholderFocus, + opacity: 1, + }, + }), + + root: ({ theme }: ThemeOnlyArgs): CSSObject => ({ + /** Error and disabled placeholder states win over normal focus. */ + "&.Mui-error input::placeholder, &.Mui-error input::-webkit-input-placeholder, &.Mui-error input::-moz-placeholder": + { + color: theme.palette.error.light, + opacity: 1, + }, + + "&.Mui-disabled input::placeholder, &.Mui-disabled input::-webkit-input-placeholder, &.Mui-disabled input::-moz-placeholder": + { + color: theme.palette.text.disabled, + opacity: 1, + }, + }), + }, + }, + + MuiOutlinedInput: { + styleOverrides: { + /** + * Outlined inputs prioritise state clarity: + * + * disabled > error > focused > hover > default + * + * This order avoids a focused or hover style masking validation state. + */ + root: ({ + ownerState, + theme, + }: OverrideArgs): CSSObject => { + const colour = getIntentFromColourProp(ownerState.color); + const p = getIntentPalette(theme, colour); + + return { + "& .MuiOutlinedInput-notchedOutline": { + borderColor: theme.palette.borders.base, + }, + + "&:hover:not(.Mui-disabled):not(.Mui-error):not(.Mui-focused) .MuiOutlinedInput-notchedOutline": + { + borderColor: theme.palette.borders.emphasis, + }, + + "&.Mui-focused:not(.Mui-disabled):not(.Mui-error) .MuiOutlinedInput-notchedOutline": + { + borderColor: p.light, + borderWidth: 2, + }, + + "&.Mui-focused:hover:not(.Mui-disabled):not(.Mui-error) .MuiOutlinedInput-notchedOutline": + { + borderColor: p.light, + borderWidth: 2, + }, + + "&.Mui-error .MuiOutlinedInput-notchedOutline": { + borderColor: theme.palette.error.light, + }, + + "&.Mui-error:hover:not(.Mui-disabled):not(.Mui-focused) .MuiOutlinedInput-notchedOutline": + { + borderColor: theme.palette.error.light, + }, + + "&.Mui-error.Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: theme.palette.error.light, + borderWidth: 2, + }, + + "&.Mui-focusVisible": { + outline: "var(--ds-focus-ring-width) solid var(--ds-focus-ring)", + outlineOffset: "var(--ds-focus-ring-offset)", + }, + + "&.Mui-disabled .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--ds-border-subtle)", + }, + }; + }, + }, + }, + + MuiInputLabel: { + styleOverrides: { + root: ({ theme }: ThemeOnlyArgs): CSSObject => ({ + "&:not(.MuiInputLabel-shrink)": { + color: theme.palette.text.secondary, + }, + + "&.Mui-disabled:not(.MuiInputLabel-shrink)": { + color: theme.palette.text.disabled, + }, + + "&.Mui-focused": { + color: theme.palette.primary.main, + }, + + "&.Mui-focused.MuiFormLabel-colorSecondary": { + color: theme.palette.secondary.main, + }, + + "&.Mui-focused.MuiFormLabel-colorSuccess": { + color: theme.palette.success.main, + }, + + "&.Mui-focused.MuiFormLabel-colorWarning": { + color: theme.palette.warning.main, + }, + + "&.Mui-focused.MuiFormLabel-colorError": { + color: theme.palette.error.main, + }, + + "&.Mui-focused.MuiFormLabel-colorInfo": { + color: theme.palette.info.main, + }, + + "&.Mui-focused.Mui-error": { + color: theme.palette.error.main, + }, + + "&.Mui-disabled": { + color: theme.palette.text.disabled, + }, + }), + }, + }, + + MuiTab: { + styleOverrides: { + root: ({ theme }: OverrideArgs): CSSObject => ({ + textTransform: "none", + color: theme.palette.text.secondary, + fontWeight: 500, + minHeight: 44, + + "&:hover": { + color: theme.palette.text.primary, + boxShadow: getOverlayInset(), + }, + + "&.Mui-selected": { + color: theme.palette.primary.main, + fontWeight: 600, + }, + + "&.Mui-disabled": { + color: theme.palette.text.disabled, + }, + + "&.Mui-focusVisible, &:focus-visible": { + outline: "var(--ds-focus-ring-width) solid var(--ds-focus-ring)", + outlineOffset: "-2px", + }, + }), + }, + }, + + MuiAlert: { + /** + * Alerts use status intents only. Filled alerts use solid/onSolid; standard and + * outlined alerts use container/onContainer. + */ + styleOverrides: { + root: ({ ownerState, theme }: OverrideArgs): CSSObject => { + const severity = getIntentFromColourProp( + ownerState.severity, + "success", + ); + const p = getIntentPalette(theme, severity); + + const common: CSSObject = { + borderRadius: 8, + alignItems: "flex-start", + + "& .MuiAlert-icon": { + color: "currentColor", + opacity: 1, + }, + + "& .MuiAlert-action": { + color: "inherit", + + "& .MuiIconButton-root:hover": { + boxShadow: getOverlayInset(), + }, + }, + }; + + if (ownerState.variant === "filled") { + return { + ...common, + backgroundColor: p.solid, + color: p.onSolid, + }; + } + + if (ownerState.variant === "outlined") { + return { + ...common, + backgroundColor: p.container, + color: p.onContainer, + border: `1px solid ${p.light}`, + }; + } + + return { + ...common, + backgroundColor: p.container, + color: p.onContainer, + border: "1px solid var(--ds-border-subtle)", + }; + }, + }, + }, + + /** + * Progress indicators use intent `main` as an activity signal, not a filled + * surface. This keeps them visually lighter than buttons or alerts. + */ + MuiLinearProgress: { + styleOverrides: { + root: { + height: 6, + borderRadius: 999, + overflow: "hidden", + backgroundColor: "var(--ds-surface-container-high)", + }, + + bar: ({ + ownerState, + theme, + }: OverrideArgs): CSSObject => { + const colour = getIntentFromColourProp(ownerState.color); + const p = getIntentPalette(theme, colour); + + return { + backgroundColor: p.main, + }; + }, + }, + }, + + MuiCircularProgress: { + styleOverrides: { + root: ({ + ownerState, + theme, + }: OverrideArgs): CSSObject => { + const colour = getIntentFromColourProp(ownerState.color); + const p = getIntentPalette(theme, colour); + + return { + color: p.main, + }; + }, + }, + }, + + MuiSkeleton: { + styleOverrides: { + root: { + backgroundColor: "var(--ds-surface-container-high)", + }, + + wave: { + backgroundColor: "var(--ds-surface-container-high)", + position: "relative", + overflow: "hidden", + + "&::after": { + content: '""', + position: "absolute", + inset: 0, + transform: "translateX(-100%)", + backgroundImage: + "linear-gradient(90deg, transparent, var(--ds-overlay-hover), transparent)", + }, + }, + }, + }, + + MuiSnackbar: { + styleOverrides: { + root: { + "& .MuiSnackbarContent-root, & .MuiAlert-root": { + minWidth: 320, + maxWidth: 560, + }, + }, + }, + }, + + MuiSnackbarContent: { + styleOverrides: { + root: { + backgroundColor: "var(--ds-surface-container)", + color: "var(--ds-on-surface)", + border: "1px solid var(--ds-border-subtle)", + borderRadius: 8, + }, + + message: { + padding: "8px 0", + }, + + action: { + color: "inherit", + + "& .MuiIconButton-root:hover": { + boxShadow: getOverlayInset(), + }, + }, + }, + }, + + MuiCheckbox: { + defaultProps: { + disableRipple: true, + }, + styleOverrides: { + root: ({ + ownerState, + theme, + }: OverrideArgs): CSSObject => { + const rawColour = ownerState.color ?? "primary"; + const isDefault = rawColour === "default"; + const colour = getIntentFromColourProp(rawColour); + + const p = !isDefault ? getIntentPalette(theme, colour) : null; + + return { + color: "var(--ds-on-surface-variant)", + borderRadius: 8, + + "&:hover": { + backgroundColor: "var(--ds-overlay-hover)", + }, + + ...getFocusOutline(), + + "&.Mui-checked": { + color: isDefault ? "var(--ds-on-surface)" : p?.main, + }, + + "&.MuiCheckbox-indeterminate": { + color: isDefault ? "var(--ds-on-surface)" : p?.main, + }, + + "&.Mui-disabled": { + color: "var(--ds-action-disabled)", + }, + }; + }, + }, + }, + + MuiRadio: { + defaultProps: { + disableRipple: true, + }, + styleOverrides: { + root: ({ ownerState, theme }: OverrideArgs): CSSObject => { + const rawColour = ownerState.color ?? "primary"; + const isDefault = rawColour === "default"; + const colour = getIntentFromColourProp(rawColour); + + const p = !isDefault ? getIntentPalette(theme, colour) : null; + + return { + color: "var(--ds-on-surface-variant)", + borderRadius: "50%", + + "&:hover": { + backgroundColor: "var(--ds-overlay-hover)", + }, + + ...getFocusOutline(), + + "&.Mui-checked": { + color: isDefault ? "var(--ds-on-surface)" : p?.main, + }, + + "&.Mui-disabled": { + color: "var(--ds-action-disabled)", + }, + }; + }, + }, + }, + }, +}); + +/** + * Backwards-compatible factory for older call sites. + * + * Mode is now controlled through MUI colour schemes and `html[data-mode]`, so + * the same theme object is returned for both modes. + */ +export const createDiamondTheme = (_mode?: DSMode): Theme => + DiamondDSTheme as Theme; + +/** + * Pre-built theme for convenience. + */ +export { DiamondDSTheme }; + +/** + * Backwards compatibility aliases. Prefer `DiamondDSTheme` for new code. + */ +export const DiamondDSThemeDark = DiamondDSTheme; +export const createMuiTheme = createDiamondTheme; diff --git a/src/themes/Theme.test.tsx b/src/themes/Theme.test.tsx new file mode 100644 index 00000000..1bd1bc6c --- /dev/null +++ b/src/themes/Theme.test.tsx @@ -0,0 +1,42 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { it, expect } from "vitest"; +import { ThemeProvider, useColorScheme } from "@mui/material/styles"; +import { useEffect } from "react"; + +import { DiamondDSTheme } from "./DiamondDSTheme"; + +export function TestComponent({ set }: { set: "dark" | "light" }) { + const { mode, setMode } = useColorScheme(); + + useEffect(() => { + setMode(set); + }, [set, setMode]); + + return
{mode}
; +} + +it("switches to dark mode", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("mode").textContent).toBe("dark"); + expect(document.documentElement.getAttribute("data-mode")).toBe("dark"); + }); +}); + +it("switches to light mode", async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("mode").textContent).toBe("light"); + expect(document.documentElement.getAttribute("data-mode")).toBe("light"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index beb704da..bdf6a6a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "esnext", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, @@ -22,14 +18,8 @@ "emitDeclarationOnly": true, "jsx": "react-jsx", "baseUrl": "src", - "types": [ - "vitest/globals", - "@testing-library/jest-dom" - ] + "types": ["vitest/globals", "@testing-library/jest-dom"] }, - "include": [ - "src", - "src/types" - ], + "include": ["src", "src/types"], "rootDir": "src" -} \ No newline at end of file +} From 7b9cc4b422aa27dca2e7864ae8faab9d758de5dd Mon Sep 17 00:00:00 2001 From: VictoriaBeilstenEdmands Date: Wed, 13 May 2026 08:49:29 +0100 Subject: [PATCH 3/9] Upgrade to pnpm v10.20 & add minimumReleaseAge --- .npmrc | 1 - package.json | 10 ++++++---- pnpm-lock.yaml | 46 +++++++++++++++++++++++++++++++++++++++++++-- pnpm-workspace.yaml | 2 ++ 4 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 pnpm-workspace.yaml diff --git a/.npmrc b/.npmrc index c14db4af..390b0c46 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,2 @@ publish-branch=main access=public -ignore-scripts=true \ No newline at end of file diff --git a/package.json b/package.json index ea3ff23c..e035ccdd 100644 --- a/package.json +++ b/package.json @@ -49,10 +49,10 @@ "storybook:publish": "gh-pages -b storybook/publish -d storybook-static" }, "dependencies": { + "@mui/icons-material": "^7.0.0", "keycloak-js": "^26.2.1", "react-icons": "^5.3.0", - "utif": "^3.1.0", - "@mui/icons-material": "^7.0.0" + "utif": "^3.1.0" }, "peerDependencies": { "@emotion/react": "^11.13.3", @@ -60,8 +60,8 @@ "@jsonforms/core": "^3.7.0", "@jsonforms/material-renderers": "^3.7.0", "@jsonforms/react": "^3.7.0", - "@mui/material": "^7.0.0", "@mui/icons-material": "^7.0.0", + "@mui/material": "^7.0.0", "react": "^18.3.1" }, "devDependencies": { @@ -71,6 +71,7 @@ "@babel/preset-typescript": "^7.26.10", "@chromatic-com/storybook": "^3.2.2", "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^10.0.1", "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-image": "^3.0.3", "@rollup/plugin-json": "^6.1.0", @@ -120,6 +121,7 @@ }, "pnpm": { "overrides": { + "fast-uri": "^3.1.2", "lodash": "^4.18.1", "qs@>=6.13.0 <6.14.0": "6.14.1", "js-yaml@^4.1.0": "4.1.1", @@ -131,5 +133,5 @@ "fast-uri@<3.1.2": "3.1.2" } }, - "packageManager": "pnpm@9.12.3+sha256.24235772cc4ac82a62627cd47f834c72667a2ce87799a846ec4e8e555e2d4b8b" + "packageManager": "pnpm@10.20.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e845ddf4..8f2a321c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + fast-uri: ^3.1.2 lodash: ^4.18.1 qs@>=6.13.0 <6.14.0: 6.14.1 js-yaml@^4.1.0: 4.1.1 @@ -30,7 +31,7 @@ importers: version: 3.7.0 '@jsonforms/material-renderers': specifier: ^3.7.0 - version: 3.7.0(tycpmb7mlqgjusrbuymfwpcqdy) + version: 3.7.0(408a1c23dbeeab334b849b456cede2fb) '@jsonforms/react': specifier: ^3.7.0 version: 3.7.0(@jsonforms/core@3.7.0)(react@18.3.1) @@ -71,6 +72,9 @@ importers: '@eslint/eslintrc': specifier: ^3.2.0 version: 3.2.0 + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@9.39.4) '@rollup/plugin-commonjs': specifier: ^28.0.1 version: 28.0.2(rollup@4.30.0) @@ -1238,6 +1242,15 @@ packages: resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + '@eslint/js@9.39.4': resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1671,106 +1684,127 @@ packages: resolution: {integrity: sha512-bsPGGzfiHXMhQGuFGpmo2PyTwcrh2otL6ycSZAFTESviUoBOuxF7iBbAL5IJXc/69peXl5rAtbewBFeASZ9O0g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.52.4': resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.30.0': resolution: {integrity: sha512-kvyIECEhs2DrrdfQf++maCWJIQ974EI4txlz1nNSBaCdtf7i5Xf1AQCEJWOC5rEBisdaMFFnOWNLYt7KpFqy5A==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm-musleabihf@4.52.4': resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.30.0': resolution: {integrity: sha512-CFE7zDNrokaotXu+shwIrmWrFxllg79vciH4E/zeK7NitVuWEaXRzS0mFfFvyhZfn8WfVOG/1E9u8/DFEgK7WQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.52.4': resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.30.0': resolution: {integrity: sha512-MctNTBlvMcIBP0t8lV/NXiUwFg9oK5F79CxLU+a3xgrdJjfBLVIEHSAjQ9+ipofN2GKaMLnFFXLltg1HEEPaGQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-musl@4.52.4': resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.52.4': resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loongarch64-gnu@4.30.0': resolution: {integrity: sha512-fBpoYwLEPivL3q368+gwn4qnYnr7GVwM6NnMo8rJ4wb0p/Y5lg88vQRRP077gf+tc25akuqd+1Sxbn9meODhwA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.30.0': resolution: {integrity: sha512-1hiHPV6dUaqIMXrIjN+vgJqtfkLpqHS1Xsg0oUfUVD98xGp1wX89PIXgDF2DWra1nxAd8dfE0Dk59MyeKaBVAw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.52.4': resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.30.0': resolution: {integrity: sha512-U0xcC80SMpEbvvLw92emHrNjlS3OXjAM0aVzlWfar6PR0ODWCTQtKeeB+tlAPGfZQXicv1SpWwRz9Hyzq3Jx3g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.52.4': resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.52.4': resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.30.0': resolution: {integrity: sha512-VU/P/IODrNPasgZDLIFJmMiLGez+BN11DQWfTVlViJVabyF3JaeaJkP6teI8760f18BMGCQOW9gOmuzFaI1pUw==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.52.4': resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.30.0': resolution: {integrity: sha512-laQVRvdbKmjXuFA3ZiZj7+U24FcmoPlXEi2OyLfbpY2MW1oxLt9Au8q9eHd0x6Pw/Kw4oe9gwVXWwIf2PVqblg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.52.4': resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.30.0': resolution: {integrity: sha512-3wzKzduS7jzxqcOvy/ocU/gMR3/QrHEFLge5CD7Si9fyHuoXcidyYZ6jyx8OPYmCcGm3uKTUl+9jUSAY74Ln5A==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-linux-x64-musl@4.52.4': resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.52.4': resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} @@ -2079,24 +2113,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.10.4': resolution: {integrity: sha512-cDDj2/uYsOH0pgAnDkovLZvKJpFmBMyXkxEG6Q4yw99HbzO6QzZ5HDGWGWVq/6dLgYKlnnmpjZCPPQIu01mXEg==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.10.4': resolution: {integrity: sha512-qJXh9D6Kf5xSdGWPINpLGixAbB5JX8JcbEJpRamhlDBoOcQC79dYfOMEIxWPhTS1DGLyFakAx2FX/b2VmQmj0g==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.10.4': resolution: {integrity: sha512-A76lIAeyQnHCVt0RL/pG+0er8Qk9+acGJqSZOZm67Ve3B0oqMd871kPtaHBM0BW3OZAhoILgfHW3Op9Q3mx3Cw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.10.4': resolution: {integrity: sha512-e6j5kBu4fIY7fFxFxnZI0MlEovRvp50Lg59Fw+DVbtqHk3C85dckcy5xKP+UoXeuEmFceauQDczUcGs19SRGSQ==} @@ -6313,6 +6351,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/js@10.0.1(eslint@9.39.4)': + optionalDependencies: + eslint: 9.39.4 + '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} @@ -6387,7 +6429,7 @@ snapshots: ajv-formats: 2.1.1(ajv@8.17.1) lodash: 4.18.1 - '@jsonforms/material-renderers@3.7.0(tycpmb7mlqgjusrbuymfwpcqdy)': + '@jsonforms/material-renderers@3.7.0(408a1c23dbeeab334b849b456cede2fb)': dependencies: '@date-io/dayjs': 3.2.0(dayjs@1.10.7) '@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..eed1ce82 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +ignoreScripts: true +minimumReleaseAge: 10080 From b0e241e0b6da24871dde8049b9f221f0844639d1 Mon Sep 17 00:00:00 2001 From: VictoriaBeilstenEdmands Date: Thu, 14 May 2026 13:18:00 +0100 Subject: [PATCH 4/9] Reorder pnpm install step in GH actions --- .github/workflows/deploy-storybook.yaml | 13 ++++++++----- .github/workflows/npm-publish.yaml | 11 +++++------ .github/workflows/test-build.yaml | 14 ++++++++------ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/workflows/deploy-storybook.yaml b/.github/workflows/deploy-storybook.yaml index 5ae937fc..f6179876 100644 --- a/.github/workflows/deploy-storybook.yaml +++ b/.github/workflows/deploy-storybook.yaml @@ -25,17 +25,20 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - run_install: | - args: [ --force ] - - name: Set Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: pnpm + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build and publish id: build-publish uses: bitovi/github-actions-storybook-to-github-pages@v1.0.3 diff --git a/.github/workflows/npm-publish.yaml b/.github/workflows/npm-publish.yaml index 478cb2b3..0a30284f 100644 --- a/.github/workflows/npm-publish.yaml +++ b/.github/workflows/npm-publish.yaml @@ -18,12 +18,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - run_install: | - args: [ --force ] - - name: Setup node uses: actions/setup-node@v4 with: @@ -32,6 +26,11 @@ jobs: scope: '@diamondlightsource' cache: pnpm + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/test-build.yaml b/.github/workflows/test-build.yaml index afe814d2..1fcd477d 100644 --- a/.github/workflows/test-build.yaml +++ b/.github/workflows/test-build.yaml @@ -20,18 +20,20 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install dependencies - uses: pnpm/action-setup@v4 - with: - run_install: | - args: [ --force ] - - name: Set Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: pnpm + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Audit packages, run Typescript tests and lint client code run: | pnpm audit --prod --audit-level high From 852df823563d75dc0fe178efc25d1a2292f1ef8a Mon Sep 17 00:00:00 2001 From: VictoriaBeilstenEdmands Date: Fri, 15 May 2026 08:29:54 +0100 Subject: [PATCH 5/9] Remove duplicate icons-material import & bump pnpm v10.26 --- package.json | 3 +-- pnpm-workspace.yaml | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e035ccdd..15a844a7 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "@jsonforms/core": "^3.7.0", "@jsonforms/material-renderers": "^3.7.0", "@jsonforms/react": "^3.7.0", - "@mui/icons-material": "^7.0.0", "@mui/material": "^7.0.0", "react": "^18.3.1" }, @@ -133,5 +132,5 @@ "fast-uri@<3.1.2": "3.1.2" } }, - "packageManager": "pnpm@10.20.0" + "packageManager": "pnpm@10.26.0" } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eed1ce82..f6077230 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ ignoreScripts: true -minimumReleaseAge: 10080 +minimumReleaseAge: 10080 # 1 week +blockExoticSubdeps: true +trustPolicy: no-downgrade \ No newline at end of file From 939562cd84dd2cb15f8985d8e4815b498905f839 Mon Sep 17 00:00:00 2001 From: VictoriaBeilstenEdmands Date: Fri, 15 May 2026 14:25:36 +0100 Subject: [PATCH 6/9] Add pnpm install before node setup in actions --- .github/workflows/deploy-storybook.yaml | 8 +++----- .github/workflows/npm-publish-pre-release.yaml | 3 --- .github/workflows/npm-publish.yaml | 8 +++----- .github/workflows/test-build.yaml | 9 +++------ 4 files changed, 9 insertions(+), 19 deletions(-) diff --git a/.github/workflows/deploy-storybook.yaml b/.github/workflows/deploy-storybook.yaml index f6179876..cda7c81b 100644 --- a/.github/workflows/deploy-storybook.yaml +++ b/.github/workflows/deploy-storybook.yaml @@ -25,17 +25,15 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Set Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: pnpm - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10 - - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/npm-publish-pre-release.yaml b/.github/workflows/npm-publish-pre-release.yaml index ca76aefb..e71cc402 100644 --- a/.github/workflows/npm-publish-pre-release.yaml +++ b/.github/workflows/npm-publish-pre-release.yaml @@ -19,9 +19,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - run_install: | - args: [ --force ] - name: Setup node uses: actions/setup-node@v4 diff --git a/.github/workflows/npm-publish.yaml b/.github/workflows/npm-publish.yaml index 0a30284f..e9cb93ba 100644 --- a/.github/workflows/npm-publish.yaml +++ b/.github/workflows/npm-publish.yaml @@ -18,6 +18,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup node uses: actions/setup-node@v4 with: @@ -26,11 +29,6 @@ jobs: scope: '@diamondlightsource' cache: pnpm - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10 - - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/test-build.yaml b/.github/workflows/test-build.yaml index 1fcd477d..48e9a519 100644 --- a/.github/workflows/test-build.yaml +++ b/.github/workflows/test-build.yaml @@ -20,16 +20,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Set Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - cache: pnpm - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10 - name: Install dependencies run: pnpm install --frozen-lockfile From 3f085b1eed820aa29f058b21becdedff87b365e3 Mon Sep 17 00:00:00 2001 From: VictoriaBeilstenEdmands Date: Thu, 21 May 2026 10:15:59 +0100 Subject: [PATCH 7/9] Move keycloak-js to peer and optional deps --- changelog.md | 3 +++ package.json | 5 ++++- pnpm-lock.yaml | 10 ++++++---- pnpm-workspace.yaml | 1 - src/storybook/helpers/Auth.mdx | 9 +++++++++ 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/changelog.md b/changelog.md index d26432ce..3797b903 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Changed +- **Breaking** `keycloak-js` has been moved from a direct dependency to a peer and optional dependency, so must now be installed by the consuming application. + ### Fixed - Icon imports were causing issues downstream when components are unit tested. diff --git a/package.json b/package.json index 15a844a7..ccdfc14c 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ }, "dependencies": { "@mui/icons-material": "^7.0.0", - "keycloak-js": "^26.2.1", "react-icons": "^5.3.0", "utif": "^3.1.0" }, @@ -61,6 +60,7 @@ "@jsonforms/material-renderers": "^3.7.0", "@jsonforms/react": "^3.7.0", "@mui/material": "^7.0.0", + "keycloak-js": "^26.2.1", "react": "^18.3.1" }, "devDependencies": { @@ -118,6 +118,9 @@ "typescript-eslint": "^8.15.0", "vitest": "^3.2.4" }, + "optionalDependencies": { + "keycloak-js": "^26.2.1" + }, "pnpm": { "overrides": { "fast-uri": "^3.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f2a321c..fd202d16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,9 +41,6 @@ importers: '@mui/material': specifier: ^7.0.0 version: 7.3.10(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - keycloak-js: - specifier: ^26.2.1 - version: 26.2.1 react: specifier: ^18.3.1 version: 18.3.1 @@ -213,6 +210,10 @@ importers: vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@20.19.21)(jsdom@20.0.3)(terser@5.37.0)(yaml@2.8.0) + optionalDependencies: + keycloak-js: + specifier: ^26.2.1 + version: 26.2.1 packages: @@ -9136,7 +9137,8 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 - keycloak-js@26.2.1: {} + keycloak-js@26.2.1: + optional: true keyv@4.5.4: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f6077230..c7cad87a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,3 @@ ignoreScripts: true minimumReleaseAge: 10080 # 1 week -blockExoticSubdeps: true trustPolicy: no-downgrade \ No newline at end of file diff --git a/src/storybook/helpers/Auth.mdx b/src/storybook/helpers/Auth.mdx index 7870e118..8e0db87e 100644 --- a/src/storybook/helpers/Auth.mdx +++ b/src/storybook/helpers/Auth.mdx @@ -12,6 +12,15 @@ import {useAuth} from "../../components/systems/auth"; This component is based on the official Keycloak.js adapter. More info can be found here: www.keycloak.org/securing-apps/javascript-adapter + The auth component relies on keycloak-js. Although it is a peer and optional dependency of SciReactUI, you must install it separately with a compatible version (e.g. ^26.2.1). + + ```sh + "One of:" + - pnpm add keycloak-js + - npm i keycloak-js + - yarn add keycloak-js + ``` + ## Basic setup First place the provider around your app: From 0a17925610fedb996c4bfec4b763973bec1ab96f Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Fri, 8 May 2026 14:20:05 +0100 Subject: [PATCH 8/9] Add accessibility documentation structure and initial guidance pages --- src/storybook/accessibility/00-overview.mdx | 198 +++++++ .../accessibility/01-colour-contrast.mdx | 561 ++++++++++++++++++ .../accessibility/02-cognitive-a11y.mdx | 149 +++++ 3 files changed, 908 insertions(+) create mode 100644 src/storybook/accessibility/00-overview.mdx create mode 100644 src/storybook/accessibility/01-colour-contrast.mdx create mode 100644 src/storybook/accessibility/02-cognitive-a11y.mdx diff --git a/src/storybook/accessibility/00-overview.mdx b/src/storybook/accessibility/00-overview.mdx new file mode 100644 index 00000000..627f44f2 --- /dev/null +++ b/src/storybook/accessibility/00-overview.mdx @@ -0,0 +1,198 @@ +import { Meta } from "@storybook/blocks"; + + + + + +
+ +# Accessibility + +

+ Accessibility (a11y) in the Diamond Design System is a baseline for quality, + clarity, and usability in complex scientific tools, not a checklist or a + separate mode. +

+ +

+ Our goal is to make interfaces that are understandable, predictable, and + usable across a wide range of abilities, environments, and levels of fatigue. +

+ +## Standards and approach + +

We follow WCAG 2.2 as the baseline for accessibility compliance.

+ +

+ We also use APCA (Advanced Perceptual Contrast Algorithm) to design for + perceived readability. Unlike WCAG contrast ratios, APCA accounts for font + size, weight, and light/dark conditions. +

+ +

+ WCAG defines minimum thresholds. APCA is used to ensure interfaces remain + readable, comfortable, and usable in real-world conditions. +

+ +

This reflects the direction of emerging standards such as WCAG 3.

+ +## Core principles + +
    +
  • + Clarity over cleverness: interfaces should explain + themselves. +
  • +
  • + Predictable behaviour: similar things behave in similar + ways. +
  • +
  • + Multiple ways to perceive information: never rely on one + signal alone. +
  • +
  • + Low cognitive effort by default: minimise mental overhead. +
  • +
+ +## Do + +### Interactive elements + +
    +
  • Ensure every interactive control has an accessible name.
  • +
  • + Provide this via a visible label, aria-label, or{" "} + aria-labelledby. +
  • +
  • Make keyboard focus clearly visible at all times.
  • +
  • + Use a consistent focus indicator (outline) rather than transient effects + such as ripples. +
  • +
  • + Ensure disabled states are clearly distinguishable from enabled and default. +
  • +
+ +### Keyboard navigation + +
    +
  • + All interactive elements must be reachable and usable using a keyboard + alone. +
  • +
  • Follow a logical tab order that matches the visual and reading order.
  • +
  • + Do not trap focus. Users must be able to move in and out of components using + standard keyboard interactions. +
  • +
  • + Ensure focus is always visible and moves predictably between elements. +
  • +
  • + Support standard keyboard interactions (e.g. Enter or Space to activate + controls). +
  • +
+ +

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

+ +
    +
  • Move focus into the component when it opens.
  • +
  • Keep focus within the component while it is active.
  • +
  • Return focus to the triggering element when it closes.
  • +
+ +### Icons and icon-only actions + +
    +
  • + Icon-only controls must include an accessible name that describes the action + (e.g. “Delete sample”), using aria-label or{" "} + aria-labelledby. +
  • +
  • + Tooltips support discoverability, but do not replace accessible names. +
  • +
  • + Decorative icons should be hidden from assistive technologies using{" "} + aria-hidden="true". +
  • +
  • Do not rely on an icon alone to communicate critical meaning.
  • +
  • + If an icon represents a toggle or state, expose the state using appropriate + ARIA attributes (e.g. aria-pressed). +
  • +
+ +

+ For interactive icons, ensure both an accessible name and a visible affordance + such as a tooltip or label. +

+ +

+ In MUI, use aria-label on IconButton. Tooltips (e.g. + <Tooltip />) should be used in addition to, not instead of, + accessible labelling. +

+ +### Forms and inputs + +
    +
  • Always associate inputs with labels (even if visually hidden).
  • +
  • + Do not show validation errors while a user is actively typing. Validate on + blur or submission. +
  • +
  • Make error messages clear, specific, and actionable.
  • +
  • Associate errors programmatically with the relevant field.
  • +
+ +### Content and layout + +
    +
  • Do not rely on colour alone to convey meaning.
  • +
  • Do not rely on icons alone to convey critical meaning.
  • +
  • Use a logical, predictable reading order.
  • +
+ +## Don’t + +
    +
  • Don’t use placeholder text as a replacement for labels.
  • +
  • Don’t hide important information in faint or decorative text.
  • +
  • Don’t introduce unexpected interaction patterns.
  • +
  • Don’t create dense, unbroken blocks of content.
  • +
+ +## Storybook guidance + +
    +
  • + Simple component stories may be visually minimal, but should still expose an + accessible name, or be documented as intentionally incomplete. +
  • +
  • + Accessibility warnings in Storybook are signals, not noise. Either resolve + them or document why they exist. +
  • +
+ +## Quick sense check + +
    +
  • Can this be used with a keyboard only?
  • +
  • Is its purpose clear without colour or icons?
  • +
  • Would it still make sense when someone is tired or distracted?
  • +
+ +
diff --git a/src/storybook/accessibility/01-colour-contrast.mdx b/src/storybook/accessibility/01-colour-contrast.mdx new file mode 100644 index 00000000..b3fc37aa --- /dev/null +++ b/src/storybook/accessibility/01-colour-contrast.mdx @@ -0,0 +1,561 @@ +import { Meta } from "@storybook/blocks"; + + + + + +
+ +# Colour contrast + +

+ Colour contrast directly affects readability, speed, and error rate. In dense, + data-heavy scientific interfaces, poor contrast increases cognitive load and + slows decision-making. +

+ +

+ We prioritise perceptual contrast using APCA when defining colour choices, + while maintaining WCAG 2.2 as the compliance baseline. +

+ +## Why contrast needs a modern approach + +

+ Traditional WCAG 2.x contrast ratios are useful, but they are based on + luminance ratios rather than perceived readability. In practice, some + combinations can technically pass while still feel strained or unstable in + real UI. +

+ +

+ This becomes more visible across different font sizes, weights, and light or + dark environments. +

+ +

This leads to common issues:

+ +
    +
  • Combinations that pass but still feel hard to read.
  • +
  • Overly strong contrast that causes glare or eye strain in dark mode.
  • +
  • + Poor readability with saturated colours such as success, warning, and + danger. +
  • +
+ +

+ APCA helps address these limitations by modelling perceived readability rather + than relying only on raw luminance difference. +

+ +## APCA contrast (primary) + +

+ APCA reflects how contrast is actually experienced by users more closely than + older ratio-based methods. +

+ +
    +
  • Accounts for font size, weight, and polarity.
  • +
  • Handles light-on-dark and dark-on-light differently.
  • +
  • Produces more reliable results for real UI text.
  • +
  • Helps reduce strain in long-running workflows.
  • +
+ +

+ We use APCA to tune token values, component defaults, and interaction states. +

+ +## WCAG 2.2 contrast (baseline) + +

WCAG 2.2 remains the current compliance baseline for text contrast.

+ +
    +
  • Defines minimum acceptable contrast thresholds.
  • +
  • Used as a strict floor we do not go below.
  • +
  • Required for accessibility reviews and audits.
  • +
+ +

+ WCAG 2.2 is necessary, but on its own it is not enough to judge readability + quality. +

+ +## How we use them together + +

Contrast decisions follow a clear order:

+ +
    +
  • + 1. Design using APCA for perceptual readability. +
  • +
  • + 2. Validate against WCAG 2.2 for compliance. +
  • +
  • + 3. Adjust only if required, without degrading usability. +
  • +
+ +

This helps keep interfaces both compliant and genuinely usable.

+ +## Colour-specific considerations + +

+ Some hues behave less predictably for perceived contrast, even when WCAG 2.x + ratios look acceptable. +

+ +

+ In DiamondDS this matters most for status colours: --ds-success, + --ds-warning, and --ds-danger. +

+ +

+ A common issue in real interfaces is that black text on a saturated colour may + look mathematically strong, but visually feel unstable, vibrating, or tiring, + especially for smaller or denser text. +

+ +

APCA is better at exposing these perception problems.

+ +
    +
  • + Success: can feel muddy or uneven with dark text, + especially in small labels. +
  • +
  • + Warning: often looks shallower than expected, reducing + clarity on both light and dark surfaces. +
  • +
  • + Danger: more prone to vibration and reduced legibility, + particularly in error messaging. +
  • +
+ +

When using these colours:

+ +
    +
  • Use APCA to judge readability, not just WCAG 2.2 ratios.
  • +
  • Test across sizes, weights, and both themes.
  • +
  • Always pair colour with text, icons, or labels.
  • +
+ +### WCAG 2.2 vs APCA: why passes can still fail users + +

+ WCAG 2.2 is a useful baseline, but it does not always predict comfort or + readability in real interfaces. APCA tracks perceived readability more closely. +

+ +
+
+
+

Light

+ +
+

Success

+
+ --ds-success + #1B8834 +
+
+
+

WCAG 2.2

+
+ Experiment complete · 12 files processed +
+
+
+

APCA

+
+ Experiment complete · 12 files processed +
+
+
+
+ +
+

Warning

+
+ --ds-warning + #e97b12 +
+
+
+

WCAG 2.2

+
+ Beamline temperature near limit +
+
+
+

APCA

+
+ Beamline temperature near limit +
+
+
+
+ +
+

Danger

+
+ --ds-danger + #d63c41 +
+
+
+

WCAG 2.2

+
+ Connection failed · retry required +
+
+
+

APCA

+
+ Connection failed · retry required +
+
+
+
+
+ +
+

Dark

+ +
+

Success

+
+ --ds-success + #23913C +
+
+
+

WCAG 2.2

+
+ Experiment complete · 12 files processed +
+
+
+

APCA

+
+ Experiment complete · 12 files processed +
+
+
+
+ +
+

Warning

+
+ --ds-warning + #f07a13 +
+
+
+

WCAG 2.2

+
+ Beamline temperature near limit +
+
+
+

APCA

+
+ Beamline temperature near limit +
+
+
+
+ +
+

Danger

+
+ --ds-danger + #d63c41 +
+
+
+

WCAG 2.2

+
+ Connection failed · retry required +
+
+
+

APCA

+
+ Connection failed · retry required +
+
+
+
+
+
+ +
+

How to use this

+
    +
  • Use WCAG 2.2 as the baseline check, especially for smaller text.
  • +
  • + Use APCA to validate perceived readability and comfort, particularly for + saturated status colours. +
  • +
  • Always pair colour with icons or text. Never rely on colour alone.
  • +
+
+ +
+ +## Practical guidance + +
    +
  • + Body text: optimise for perceptual readability first, then + confirm WCAG 2.2 compliance. +
  • +
  • + UI chrome: maintain clear separation and predictable + hierarchy through surface and border roles. +
  • +
  • + Subtle text: only use for non-essential information. +
  • +
  • + Disabled states: must remain distinguishable without + becoming unreadable. +
  • +
+ +## Common pitfalls + +
    +
  • Relying on WCAG ratios alone to judge readability.
  • +
  • + Reducing contrast for visual subtlety where meaning is still required. +
  • +
  • Using saturated colours without perceptual validation.
  • +
  • Ignoring polarity differences between light and dark mode.
  • +
+ +## When WCAG and APCA disagree + +
    +
  • If WCAG 2.2 fails, the colour must change.
  • +
  • + If WCAG 2.2 passes but APCA still indicates poor readability, improve the + colour choice rather than treating compliance as enough. +
  • +
  • + In critical workflows, readability and accuracy take precedence over visual + subtlety. +
  • +
+ +## Future: WCAG 3 and APCA + +

+ WCAG 3 is still evolving, but perceptual contrast models such as APCA are + influencing the direction of accessibility guidance. +

+ +

+ Our approach reflects that direction while staying grounded in current + compliance requirements: +

+ +
    +
  • Use perceptual contrast thinking to design and tune colour choices.
  • +
  • Validate text contrast against WCAG 2.2 today.
  • +
  • + Reduce future rework by avoiding colour decisions that are only + mathematically compliant. +
  • +
+ +

Learn more:

+ + + +
diff --git a/src/storybook/accessibility/02-cognitive-a11y.mdx b/src/storybook/accessibility/02-cognitive-a11y.mdx new file mode 100644 index 00000000..c75a0720 --- /dev/null +++ b/src/storybook/accessibility/02-cognitive-a11y.mdx @@ -0,0 +1,149 @@ +import { Meta } from "@storybook/blocks"; + + + + + +
+ +# Cognitive accessibility and usability + +

+ Accessibility is not only about vision, hearing, or motor input. People also + differ in how they process information, maintain focus, recover from errors, + and work under pressure or fatigue. +

+ +

+ In scientific environments, these differences matter. Users may work for long + periods, switch between multiple tools and data sources, or operate systems + during live experiments where mistakes can be costly. +

+ +

+ This page focuses on cognitive accessibility and practical usability within + complex scientific software. +

+ +## Designing for cognitive accessibility + +

+ Good accessibility and usability often come from the same decisions: + interfaces that are clear, predictable, consistent, and forgiving. +

+ +

+ The goal is not to simplify scientific work itself, but to reduce unnecessary + cognitive load created by the interface. +

+ +## Design objectives + +### Help users understand what things are + +
    +
  • Use familiar patterns, terminology, and behaviours.
  • +
  • Avoid inventing new interaction models without strong justification.
  • +
  • Make purpose and state visible at a glance.
  • +
  • Ensure actions and outcomes feel predictable.
  • +
+ +### Reduce memory burden + +
    +
  • Do not rely on users remembering hidden rules or previous states.
  • +
  • Keep important information visible where possible.
  • +
  • Use clear labels, grouping, and hierarchy.
  • +
  • Support recognition over recall.
  • +
+ +### Help users maintain focus + +
    +
  • Avoid unnecessary distractions or competing emphasis.
  • +
  • Use layout and spacing to separate concerns clearly.
  • +
  • Keep navigation and interaction patterns consistent.
  • +
  • Help users re-orient when context is lost.
  • +
+ +### Help users avoid and recover from errors + +
    +
  • Prevent errors where possible rather than reacting to them later.
  • +
  • Make system status and consequences visible before actions occur.
  • +
  • Provide clear, actionable error messages.
  • +
  • Make recovery paths obvious and forgiving.
  • +
+ +### Design for long-running workflows + +
    +
  • Assume users may be tired, interrupted, or multitasking.
  • +
  • Reduce ambiguity in high-pressure or operational situations.
  • +
  • Keep interfaces calm, stable, and predictable.
  • +
  • Design interactions that still make sense at the end of a long day.
  • +
+ +## Applying this in Storybook + +When reviewing components and patterns, ask: + +
    +
  • Is the interface understandable without prior knowledge?
  • +
  • Does it rely on memory or hidden behaviour?
  • +
  • Is important state clearly visible?
  • +
  • Is error recovery understandable and forgiving?
  • +
  • Would this remain clear during a long experiment session?
  • +
+ +## Further reading + + + +
From e89f180298e0982ac783de741e7aa22416edae11 Mon Sep 17 00:00:00 2001 From: Zohar Manor-Abel Date: Tue, 26 May 2026 18:32:33 +0100 Subject: [PATCH 9/9] Update preview.tsx --- .storybook/preview.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 4902bf86..f5c6190d 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -100,6 +100,7 @@ const preview: Preview = { "Theme", "Theme/Logos", "Theme/Colours", + "Accessibility", "MUI", "Components", ],