From 18ebd5348ae8bc4d511bf20f7c24f587ea2e4529 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:37:49 -0800 Subject: [PATCH] fix(notifications): keep muted panels on the notice surface's polarity for app themes --- .../appearance/notice-surface-tokens.test.ts | 44 ++++++++++++++++--- .../appearance/notice-surface-tokens.ts | 13 +++++- src/renderer/src/assets/main.css | 2 + 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/app-shell/appearance/notice-surface-tokens.test.ts b/src/renderer/src/app-shell/appearance/notice-surface-tokens.test.ts index 278ede91c72..2d3f730d845 100644 --- a/src/renderer/src/app-shell/appearance/notice-surface-tokens.test.ts +++ b/src/renderer/src/app-shell/appearance/notice-surface-tokens.test.ts @@ -7,6 +7,7 @@ const DARK_BUILT_INS = { '--foreground': '#f0f0f0', '--popover': '#171717', '--popover-foreground': '#f0f0f0', + '--muted': '#262626', '--muted-foreground': '#acacac' } const probe = (value: string): Rgba | null => (value.startsWith('#') ? hexToRgb(value) : null) @@ -24,15 +25,42 @@ function wcagContrast(firstHex: string, secondHex: string): number { return (high + 0.05) / (low + 0.05) } +/** `over` with `top` painted at `alpha`, as the browser blends a bg-muted/N panel. */ +function composite(topHex: string, overHex: string, alpha: number): string { + return `#${[1, 3, 5] + .map((start) => { + const top = Number.parseInt(topHex.slice(start, start + 2), 16) + const under = Number.parseInt(overHex.slice(start, start + 2), 16) + return Math.round(top * alpha + under * (1 - alpha)) + .toString(16) + .padStart(2, '0') + }) + .join('')}` +} + /** The emitted tokens are the painted colors, so their contrast is what the user sees. */ function expectReadable(themeTokens: Record): void { - const text = { ...DARK_BUILT_INS, ...themeTokens }['--popover-foreground'] - const notice = noticeSurfaceTokens(themeTokens, 'dark', DARK_BUILT_INS, probe) - expect(wcagContrast(notice['--notice-surface'], text)).toBeGreaterThanOrEqual(4.5) + const palette: Record = { ...DARK_BUILT_INS, ...themeTokens } + const text = palette['--popover-foreground'] + const notice: Record = noticeSurfaceTokens( + themeTokens, + 'dark', + DARK_BUILT_INS, + probe + ) + const surface = notice['--notice-surface'] + const muted = notice['--notice-muted-foreground'] + expect(wcagContrast(surface, text)).toBeGreaterThanOrEqual(4.5) expect(wcagContrast(notice['--notice-secondary'], text)).toBeGreaterThanOrEqual(4.5) - expect( - wcagContrast(notice['--notice-surface'], notice['--notice-muted-foreground']) - ).toBeGreaterThanOrEqual(4.5) + expect(wcagContrast(surface, muted)).toBeGreaterThanOrEqual(4.5) + // UpdateErrorCardContent's bg-muted/30, /40 and /50 panels hold muted and body text. Without a + // --notice-muted token, main.css paints them from the palette's --muted. + const mutedPanel = notice['--notice-muted'] ?? palette['--muted'] + for (const alpha of [0.3, 0.4, 0.5]) { + const panel = composite(mutedPanel, surface, alpha) + expect(wcagContrast(panel, muted)).toBeGreaterThanOrEqual(4.5) + expect(wcagContrast(panel, text)).toBeGreaterThanOrEqual(4.5) + } } describe('noticeSurfaceTokens', () => { @@ -41,12 +69,14 @@ describe('noticeSurfaceTokens', () => { '--notice-surface': '#262626', '--notice-secondary': '#3a3a3a', '--notice-secondary-edge': 'transparent', + '--notice-muted': '#3a3a3a', '--notice-muted-foreground': '#acacac' }) }) // Popovers the theme validator accepts that once painted notice text under 4.5:1: #5a5a5a - // through Chromium's color-mix rounding, #383838 through hex rounding, the rest unlifted. + // through Chromium's color-mix rounding, #383838 through hex rounding, white-on-dark-base + // through dark muted panels, the rest unlifted. it.each>([ { '--popover': '#5a5a5a' }, { '--popover': '#383838' }, diff --git a/src/renderer/src/app-shell/appearance/notice-surface-tokens.ts b/src/renderer/src/app-shell/appearance/notice-surface-tokens.ts index 0598b4b7604..61077d821f1 100644 --- a/src/renderer/src/app-shell/appearance/notice-surface-tokens.ts +++ b/src/renderer/src/app-shell/appearance/notice-surface-tokens.ts @@ -12,6 +12,7 @@ export const NOTICE_SURFACE_TOKEN = '--notice-surface' export const NOTICE_SECONDARY_TOKEN = '--notice-secondary' export const NOTICE_MUTED_FOREGROUND_TOKEN = '--notice-muted-foreground' export const NOTICE_SECONDARY_EDGE_TOKEN = '--notice-secondary-edge' +export const NOTICE_MUTED_TOKEN = '--notice-muted' // Keep in sync with the built-in notice mixes in main.css. const NOTICE_LIFT = { light: 0, dark: 7 } as const const NOTICE_SECONDARY_MIX = { light: 6, dark: 10 } as const @@ -47,7 +48,8 @@ function deepestReadableMix(tint: Rgba, base: Rgba, text: Rgba, deepest: number) * Toasts and corner cards lift the popover toward the foreground and paint popover and muted * text on it, but a theme's popover pair is only checked unlifted. So the lift and secondary * fill shrink until popover text stays readable, and muted text moves toward popover text until - * it does too. Emitted as final hex colors: CSS paints exactly the colors measured here. + * it does too, on the surface and on muted panels. Emitted as final hex colors: CSS paints + * exactly the colors measured here. */ export function noticeSurfaceTokensFor( colors: { foreground: Rgba; popover: Rgba; popoverForeground: Rgba; mutedForeground: Rgba }, @@ -60,8 +62,12 @@ export function noticeSurfaceTokensFor( const text = painted(popoverForeground) const surface = deepestReadableMix(foreground, popover, text, NOTICE_LIFT[base]) const secondary = deepestReadableMix(foreground, surface, text, NOTICE_SECONDARY_MIX[base]) + // Muted panels (bg-muted/N) paint between the surface and the secondary fill, so muted text + // must read on both ends. + const mutedReads = (color: Rgba): boolean => + readable(surface, color) && readable(secondary, color) let muted = painted(mutedForeground) - for (let towardText = 1; towardText <= 100 && !readable(surface, muted); towardText++) { + for (let towardText = 1; towardText <= 100 && !mutedReads(muted); towardText++) { muted = painted(mix(popoverForeground, mutedForeground, towardText)) } const surfaceHex = rgbaToHex(surface) @@ -72,6 +78,9 @@ export function noticeSurfaceTokensFor( // Why: with no fill left, secondary buttons need an edge to still read as buttons. [NOTICE_SECONDARY_EDGE_TOKEN]: secondaryHex === surfaceHex ? 'var(--notice-border)' : 'transparent', + // Why: built-in palettes set --muted equal to --secondary; keeping that here keeps muted panels + // on the notice surface's polarity when a theme's popover and app polarity differ. + [NOTICE_MUTED_TOKEN]: secondaryHex, [NOTICE_MUTED_FOREGROUND_TOKEN]: rgbaToHex(muted) } } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index b6821e9624c..bed8e03bf62 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -554,6 +554,7 @@ /* One lifted surface for toasts and corner notification cards; the default card/popover blends into the app. App themes replace the surface, secondary and muted colors with readable hex (notice-surface-tokens.ts). */ :root { + --notice-muted: var(--muted); --notice-muted-foreground: var(--muted-foreground); --notice-secondary-edge: transparent; --notice-surface: var(--popover); @@ -578,6 +579,7 @@ --card: var(--notice-surface); --foreground: var(--popover-foreground); --card-foreground: var(--popover-foreground); + --muted: var(--notice-muted); --muted-foreground: var(--notice-muted-foreground); --secondary: var(--notice-secondary); --secondary-foreground: var(--popover-foreground);