fix(notifications): keep muted panels on the notice surface's polarity for app themes

This commit is contained in:
Jinjing
2026-11-12 21:28:44 -08:00
parent 7330467f62
commit 18ebd5348a
3 changed files with 50 additions and 9 deletions
@@ -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<string, string>): 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<string, string> = { ...DARK_BUILT_INS, ...themeTokens }
const text = palette['--popover-foreground']
const notice: Record<string, string> = 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<Record<string, string>>([
{ '--popover': '#5a5a5a' },
{ '--popover': '#383838' },
@@ -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)
}
}
+2
View File
@@ -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);