diff --git a/frontend/src/v2/__tests__/V2FeaturePageColors.test.ts b/frontend/src/v2/__tests__/V2FeaturePageColors.test.ts index 90d8676125..d93279c6fa 100644 --- a/frontend/src/v2/__tests__/V2FeaturePageColors.test.ts +++ b/frontend/src/v2/__tests__/V2FeaturePageColors.test.ts @@ -1,11 +1,12 @@ import fs from 'fs'; import path from 'path'; -import { V2_COLORS } from '../components/V2FeaturePage'; +import { V2_COLORS, V2_DARK_COLORS } from '../components/V2FeaturePage'; +import { resolvedV2Dark } from '../utils/theme'; -// TASK-681: V2FeaturePage's MUI theme cannot read var(), so its colours stay -// literal. This pins each literal to the light --v2-* token of the same role, so -// a token change fails here instead of drifting silently. Dark mode needs its own -// decision (UX Lead, TASK-681 ruling). +// TASK-681/685: V2FeaturePage's MUI theme cannot read var(), so its colours stay +// literal, in a light set and a dark set. This pins each literal to the --v2-* +// token of the same role in its mode, so a token change fails here instead of +// drifting silently. const css = fs.readFileSync(path.join(__dirname, '../v2.css'), 'utf8'); const TOKEN_FOR: Record = { @@ -21,6 +22,7 @@ const TOKEN_FOR: Record = { accent: 'accent', accentStrong: 'accent-strong', accentSoft: 'accent-soft', + onAccent: 'on-accent', success: 'success', warning: 'warning', danger: 'danger', @@ -30,18 +32,58 @@ const TOKEN_FOR: Record = { shadowLg: '', }; -// First declaration wins: the light block sits above any future dark scope. -const lightTokenValue = (name: string): string | null => { - const m = new RegExp(`--v2-${name}:\\s*(#[0-9a-fA-F]{6})\\s*;`).exec(css); +const valueIn = (block: string, name: string): string | null => { + const m = new RegExp(`--v2-${name}:\\s*(#[0-9a-fA-F]{6})\\s*;`).exec(block); return m ? m[1].toLowerCase() : null; }; -describe('V2FeaturePage MUI palette matches the light --v2-* tokens', () => { - const colourKeys = (Object.keys(TOKEN_FOR) as Array) - .filter((k) => TOKEN_FOR[k] !== ''); +// A block runs from its selector to the first closing brace; neither block nests one. +const blockOf = (selector: string): string => { + const start = css.indexOf(selector); + if (start < 0) throw new Error(`no block for ${selector}`); + return css.slice(start, css.indexOf('}', start)); +}; + +const darkBlock = blockOf(':root[data-v2-theme="dark"] .v2-root {'); +const darkTwinBlock = blockOf(':root:not([data-v2-theme="light"]) .v2-root {'); + +// First declaration wins: the light block sits above the dark scopes. +const lightTokenValue = (name: string): string | null => valueIn(css, name); + +// A name the dark block leaves out inherits its light value, so the light value is +// the dark one for it. That is how the status solids reach dark mode. +const darkTokenValue = (name: string): string | null => valueIn(darkBlock, name) ?? lightTokenValue(name); - test.each(colourKeys)('%s equals its light token', (key) => { +const colourKeys = (Object.keys(TOKEN_FOR) as Array) + .filter((k) => TOKEN_FOR[k] !== ''); + +describe('V2FeaturePage MUI palette matches the --v2-* tokens', () => { + test.each(colourKeys)('light %s equals its light token', (key) => { expect(lightTokenValue(TOKEN_FOR[key])).not.toBeNull(); expect(V2_COLORS[key].toLowerCase()).toBe(lightTokenValue(TOKEN_FOR[key])); }); + + test.each(colourKeys)('dark %s equals its dark token, or the light one where dark inherits it', (key) => { + expect(V2_DARK_COLORS[key].toLowerCase()).toBe(darkTokenValue(TOKEN_FOR[key])); + }); + + test('the dark block and its prefers-color-scheme twin agree', () => { + // The twin paints when no attribute is set, so a drift between the two would + // show dark on one path and not the other. + colourKeys.forEach((key) => { + expect(valueIn(darkTwinBlock, TOKEN_FOR[key])).toBe(valueIn(darkBlock, TOKEN_FOR[key])); + }); + }); +}); + +describe('resolvedV2Dark follows an explicit attribute, then the OS', () => { + test('an explicit light or dark wins over the OS', () => { + expect(resolvedV2Dark('dark', false)).toBe(true); + expect(resolvedV2Dark('light', true)).toBe(false); + }); + + test('auto and an unset attribute follow the OS', () => { + expect(resolvedV2Dark('auto', true)).toBe(true); + expect(resolvedV2Dark(null, false)).toBe(false); + }); }); diff --git a/frontend/src/v2/components/V2FeaturePage.tsx b/frontend/src/v2/components/V2FeaturePage.tsx index 31f904fd62..6808cdd43c 100644 --- a/frontend/src/v2/components/V2FeaturePage.tsx +++ b/frontend/src/v2/components/V2FeaturePage.tsx @@ -6,6 +6,7 @@ import V2NavRail from './V2NavRail'; import V2PodsSidebar from './V2PodsSidebar'; import { V2EmbeddedProvider } from '../hooks/useV2Embedded'; import { useV2PodAttention } from '../hooks/useV2PodAttention'; +import { useV2ResolvedDark } from '../hooks/useV2ResolvedDark'; import V2MobileTabs from './V2MobileTabs'; import V2PageHead from './V2PageHead'; @@ -26,7 +27,8 @@ const withV2Prefix = (path: string): string => { }; // MUI's createTheme cannot read var(), so these stay literal. Each value is the -// light --v2-* token of the same role; V2FeaturePageColors.test pins them to v2.css. +// --v2-* token of the same role in its mode; V2FeaturePageColors.test pins both +// sets to v2.css. The status solids have no dark override, so both sets share them. export const V2_COLORS = { bg: '#f9fafb', surface: '#ffffff', @@ -40,6 +42,7 @@ export const V2_COLORS = { accent: '#1d3fd1', accentStrong: '#1633a8', accentSoft: '#e9edfb', + onAccent: '#ffffff', success: '#10b981', warning: '#f4a23a', danger: '#ef4444', @@ -49,32 +52,57 @@ export const V2_COLORS = { shadowLg: 'none', }; -const v2FeatureTheme = createTheme({ +export const V2_DARK_COLORS: typeof V2_COLORS = { + bg: '#181e28', + surface: '#141922', + surfaceHover: '#1e2531', + border: '#364051', + borderSoft: '#262d3a', + borderStrong: '#5f6979', + textPrimary: '#e8ebf1', + textSecondary: '#aeb6c4', + textMuted: '#8b94a5', + accent: '#6680ff', + accentStrong: '#3d5ef2', + accentSoft: '#1b2546', + onAccent: '#ffffff', + success: '#10b981', + warning: '#f4a23a', + danger: '#ef4444', + info: '#0891b2', + shadowSm: 'none', + shadow: 'none', + shadowLg: 'none', +}; + +type V2Palette = typeof V2_COLORS; + +const buildV2FeatureTheme = (c: V2Palette, mode: 'light' | 'dark') => createTheme({ palette: { - mode: 'light', + mode, primary: { - main: V2_COLORS.accent, - light: '#e9edfb', - dark: V2_COLORS.accentStrong, - contrastText: V2_COLORS.surface, + main: c.accent, + light: c.accentSoft, + dark: c.accentStrong, + contrastText: c.onAccent, }, secondary: { main: '#0f2378', - contrastText: V2_COLORS.surface, + contrastText: c.surface, }, - success: { main: V2_COLORS.success }, - warning: { main: V2_COLORS.warning }, - error: { main: V2_COLORS.danger }, - info: { main: V2_COLORS.info }, + success: { main: c.success }, + warning: { main: c.warning }, + error: { main: c.danger }, + info: { main: c.info }, text: { - primary: V2_COLORS.textPrimary, - secondary: V2_COLORS.textSecondary, + primary: c.textPrimary, + secondary: c.textSecondary, }, background: { - default: V2_COLORS.bg, - paper: V2_COLORS.surface, + default: c.bg, + paper: c.surface, }, - divider: V2_COLORS.border, + divider: c.border, }, typography: { fontFamily: [ @@ -104,8 +132,8 @@ const v2FeatureTheme = createTheme({ styleOverrides: { root: { backgroundImage: 'none', - borderColor: V2_COLORS.border, - color: V2_COLORS.textPrimary, + borderColor: c.border, + color: c.textPrimary, }, rounded: { borderRadius: 12, @@ -115,9 +143,9 @@ const v2FeatureTheme = createTheme({ MuiCard: { styleOverrides: { root: { - border: `1px solid ${V2_COLORS.border}`, + border: `1px solid ${c.border}`, borderRadius: 12, - boxShadow: V2_COLORS.shadowSm, + boxShadow: c.shadowSm, }, }, }, @@ -131,24 +159,24 @@ const v2FeatureTheme = createTheme({ minHeight: 34, }, containedPrimary: { - background: V2_COLORS.accent, + background: c.accent, '&:hover': { - background: V2_COLORS.accentStrong, + background: c.accentStrong, boxShadow: 'none', }, }, outlined: { - borderColor: V2_COLORS.border, - color: V2_COLORS.accent, + borderColor: c.border, + color: c.accent, '&:hover': { - borderColor: V2_COLORS.borderStrong, - backgroundColor: V2_COLORS.surfaceHover, + borderColor: c.borderStrong, + backgroundColor: c.surfaceHover, }, }, text: { - color: V2_COLORS.accent, + color: c.accent, '&:hover': { - backgroundColor: V2_COLORS.surfaceHover, + backgroundColor: c.surfaceHover, }, }, }, @@ -158,8 +186,8 @@ const v2FeatureTheme = createTheme({ root: { borderRadius: 999, fontWeight: 700, - backgroundColor: V2_COLORS.accentSoft, - color: V2_COLORS.accent, + backgroundColor: c.accentSoft, + color: c.accent, }, }, }, @@ -172,22 +200,22 @@ const v2FeatureTheme = createTheme({ styleOverrides: { root: { borderRadius: 10, - backgroundColor: V2_COLORS.surface, + backgroundColor: c.surface, '&.Mui-focused .MuiOutlinedInput-notchedOutline': { - borderColor: V2_COLORS.accent, + borderColor: c.accent, }, }, notchedOutline: { - borderColor: V2_COLORS.border, + borderColor: c.border, }, }, }, MuiInputLabel: { styleOverrides: { root: { - color: V2_COLORS.textSecondary, + color: c.textSecondary, '&.Mui-focused': { - color: V2_COLORS.accent, + color: c.accent, }, }, }, @@ -198,7 +226,7 @@ const v2FeatureTheme = createTheme({ minHeight: 42, }, indicator: { - backgroundColor: V2_COLORS.accent, + backgroundColor: c.accent, height: 2, }, }, @@ -208,10 +236,10 @@ const v2FeatureTheme = createTheme({ root: { textTransform: 'none', fontWeight: 700, - color: V2_COLORS.textSecondary, + color: c.textSecondary, minHeight: 42, '&.Mui-selected': { - color: V2_COLORS.accent, + color: c.accent, }, }, }, @@ -219,11 +247,11 @@ const v2FeatureTheme = createTheme({ MuiIconButton: { styleOverrides: { root: { - color: V2_COLORS.textMuted, + color: c.textMuted, borderRadius: 8, '&:hover': { - color: V2_COLORS.textPrimary, - backgroundColor: V2_COLORS.surfaceHover, + color: c.textPrimary, + backgroundColor: c.surfaceHover, }, }, }, @@ -232,28 +260,28 @@ const v2FeatureTheme = createTheme({ styleOverrides: { paper: { borderRadius: 16, - border: `1px solid ${V2_COLORS.border}`, - boxShadow: V2_COLORS.shadowLg, + border: `1px solid ${c.border}`, + boxShadow: c.shadowLg, }, }, }, MuiMenu: { styleOverrides: { paper: { - border: `1px solid ${V2_COLORS.border}`, - boxShadow: V2_COLORS.shadow, + border: `1px solid ${c.border}`, + boxShadow: c.shadow, }, }, }, MuiMenuItem: { styleOverrides: { root: { - color: V2_COLORS.textPrimary, + color: c.textPrimary, '&:hover': { - backgroundColor: V2_COLORS.surfaceHover, + backgroundColor: c.surfaceHover, }, '&.Mui-selected': { - backgroundColor: V2_COLORS.accentSoft, + backgroundColor: c.accentSoft, }, }, }, @@ -261,13 +289,13 @@ const v2FeatureTheme = createTheme({ MuiTableCell: { styleOverrides: { root: { - borderBottomColor: V2_COLORS.borderSoft, - color: V2_COLORS.textPrimary, + borderBottomColor: c.borderSoft, + color: c.textPrimary, }, head: { - color: V2_COLORS.textSecondary, + color: c.textSecondary, fontWeight: 800, - backgroundColor: V2_COLORS.bg, + backgroundColor: c.bg, }, }, }, @@ -281,17 +309,20 @@ const v2FeatureTheme = createTheme({ MuiListItemText: { styleOverrides: { primary: { - color: V2_COLORS.textPrimary, + color: c.textPrimary, fontWeight: 700, }, secondary: { - color: V2_COLORS.textSecondary, + color: c.textSecondary, }, }, }, }, }); +const v2FeatureTheme = buildV2FeatureTheme(V2_COLORS, 'light'); +const v2DarkFeatureTheme = buildV2FeatureTheme(V2_DARK_COLORS, 'dark'); + const V2FeaturePage: React.FC = ({ title, hint, @@ -303,6 +334,7 @@ const V2FeaturePage: React.FC = ({ const navigate = useNavigate(); const { t } = useTranslation(); const attention = useV2PodAttention(); + const dark = useV2ResolvedDark(); useEffect(() => { try { @@ -335,7 +367,7 @@ const V2FeaturePage: React.FC = ({
{showHeader && }
- +
{children} diff --git a/frontend/src/v2/hooks/useV2ResolvedDark.test.tsx b/frontend/src/v2/hooks/useV2ResolvedDark.test.tsx new file mode 100644 index 0000000000..7c49d185cb --- /dev/null +++ b/frontend/src/v2/hooks/useV2ResolvedDark.test.tsx @@ -0,0 +1,24 @@ +import { act, renderHook } from '@testing-library/react'; +import { useV2ResolvedDark } from './useV2ResolvedDark'; + +describe('useV2ResolvedDark', () => { + afterEach(() => { + document.documentElement.removeAttribute('data-v2-theme'); + }); + + test('follows data-v2-theme as it changes, without a reload', async () => { + // jsdom has no matchMedia, so the OS reads as light and only the attribute moves the result. + const { result } = renderHook(() => useV2ResolvedDark()); + expect(result.current).toBe(false); + + await act(async () => { + document.documentElement.setAttribute('data-v2-theme', 'dark'); + }); + expect(result.current).toBe(true); + + await act(async () => { + document.documentElement.setAttribute('data-v2-theme', 'light'); + }); + expect(result.current).toBe(false); + }); +}); diff --git a/frontend/src/v2/hooks/useV2ResolvedDark.ts b/frontend/src/v2/hooks/useV2ResolvedDark.ts new file mode 100644 index 0000000000..6b35c6c311 --- /dev/null +++ b/frontend/src/v2/hooks/useV2ResolvedDark.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; +import { resolvedV2Dark } from '../utils/theme'; + +const DARK_QUERY = '(prefers-color-scheme: dark)'; + +const readDark = (): boolean => resolvedV2Dark( + document.documentElement.getAttribute('data-v2-theme'), + typeof window.matchMedia === 'function' && window.matchMedia(DARK_QUERY).matches, +); + +// Whether v2 paints dark right now. Follows the data-v2-theme attribute that +// setV2Theme writes and the OS scheme, so a change in Settings reaches the +// MUI theme without a reload. +export const useV2ResolvedDark = (): boolean => { + const [dark, setDark] = useState(readDark); + + useEffect(() => { + const sync = () => setDark(readDark()); + const observer = new MutationObserver(sync); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-v2-theme'] }); + const media = typeof window.matchMedia === 'function' ? window.matchMedia(DARK_QUERY) : null; + media?.addEventListener?.('change', sync); + sync(); + return () => { + observer.disconnect(); + media?.removeEventListener?.('change', sync); + }; + }, []); + + return dark; +}; diff --git a/frontend/src/v2/utils/theme.ts b/frontend/src/v2/utils/theme.ts index 667dfd9c4b..4f52e986c7 100644 --- a/frontend/src/v2/utils/theme.ts +++ b/frontend/src/v2/utils/theme.ts @@ -15,6 +15,14 @@ export function getV2Theme(): V2ThemePref { } } +// Whether the CSS paints dark for this attribute value. An explicit 'light' or +// 'dark' wins; 'auto' or no attribute follows the OS, as v2.css does. +export function resolvedV2Dark(attr: string | null, systemPrefersDark: boolean): boolean { + if (attr === 'dark') return true; + if (attr === 'light') return false; + return systemPrefersDark; +} + export function applyV2Theme(pref: V2ThemePref): void { document.documentElement.setAttribute('data-v2-theme', pref); }