fix(notifications): paint app-theme notice colors as the exact hex measured, with a one-step margin

This commit is contained in:
Jinjing
2026-11-12 21:28:44 -08:00
parent 67918a4c52
commit 7330467f62
3 changed files with 79 additions and 85 deletions
@@ -1,11 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
contrastRatio,
hexToRgb,
mix,
rgbaToHex,
type Rgba
} from '../../../../shared/color/rgba-contrast'
import { hexToRgb, rgbaToHex, type Rgba } from '../../../../shared/color/rgba-contrast'
import { noticeSurfaceTokens } from './notice-surface-tokens'
import { safePluginThemeTokens } from './plugin-theme-tokens'
@@ -16,60 +10,54 @@ const DARK_BUILT_INS = {
'--muted-foreground': '#acacac'
}
const probe = (value: string): Rgba | null => (value.startsWith('#') ? hexToRgb(value) : null)
const percent = (value: string): number => Number.parseFloat(value)
/** What the screen shows: color-mix results land on 8-bit channels. */
const painted = (color: Rgba): Rgba => hexToRgb(rgbaToHex(color))
/** What the cards paint: main.css's dark notice surface and secondary, with these tokens. */
function paintedNotice(themeTokens: Record<string, string>): {
surface: Rgba
secondary: Rgba
text: Rgba
muted: Rgba
notice: Record<string, string>
} {
const colors = { ...DARK_BUILT_INS, ...themeTokens }
const notice = noticeSurfaceTokens(themeTokens, 'dark', DARK_BUILT_INS, probe)
const foreground = hexToRgb(colors['--foreground'])
const surface = mix(foreground, hexToRgb(colors['--popover']), percent(notice['--notice-lift']))
return {
surface: painted(surface),
secondary: painted(mix(foreground, surface, percent(notice['--notice-secondary-mix']))),
text: hexToRgb(colors['--popover-foreground']),
muted: hexToRgb(notice['--notice-muted-foreground']),
notice
/** WCAG 2 contrast written out here, so the check shares no math with the code under test. */
function wcagContrast(firstHex: string, secondHex: string): number {
const luminance = (hex: string): number => {
const [r, g, b] = [1, 3, 5].map((start) => {
const c = Number.parseInt(hex.slice(start, start + 2), 16) / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const [high, low] = [luminance(firstHex), luminance(secondHex)].sort((a, b) => b - a)
return (high + 0.05) / (low + 0.05)
}
/** The emitted tokens are the painted colors, so their contrast is what the user sees. */
function expectReadable(themeTokens: Record<string, string>): void {
const { surface, secondary, text, muted } = paintedNotice(themeTokens)
expect(contrastRatio(surface, text)).toBeGreaterThanOrEqual(4.5)
expect(contrastRatio(secondary, text)).toBeGreaterThanOrEqual(4.5)
expect(contrastRatio(surface, muted)).toBeGreaterThanOrEqual(4.5)
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)
expect(wcagContrast(notice['--notice-secondary'], text)).toBeGreaterThanOrEqual(4.5)
expect(
wcagContrast(notice['--notice-surface'], notice['--notice-muted-foreground'])
).toBeGreaterThanOrEqual(4.5)
}
describe('noticeSurfaceTokens', () => {
it("keeps Orca's dark lift, secondary mix and muted text for the built-in palette", () => {
it("paints Orca's dark notice colors for the built-in palette", () => {
expect(noticeSurfaceTokens({}, 'dark', DARK_BUILT_INS, probe)).toEqual({
'--notice-lift': '7%',
'--notice-secondary-mix': '10%',
'--notice-surface': '#262626',
'--notice-secondary': '#3a3a3a',
'--notice-secondary-edge': 'transparent',
'--notice-muted-foreground': '#acacac'
})
})
// Popover pairs the theme validator accepts; lifted as-is they dropped card text under 4.5:1.
// #383838 passed only before rounding the muted color to the painted hex.
// 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.
it.each<Record<string, string>>([
{ '--popover': '#444444' },
{ '--popover': '#5a5a5a' },
{ '--popover': '#383838' },
{ '--popover': '#444444' },
{ '--popover': '#303030', '--popover-foreground': '#a0a0a0' },
{ '--popover': '#ffffff', '--popover-foreground': '#111111' }
])('keeps painted notice text 4.5:1 for an accepted %o', (themeTokens) => {
])('keeps notice text 4.5:1 for an accepted %o', (themeTokens) => {
expectReadable(themeTokens)
})
it('keeps painted notice text 4.5:1 for every gray popover the validator accepts', () => {
it('keeps notice text 4.5:1 for every gray popover the validator accepts', () => {
const accepted = Array.from({ length: 256 }, (_, gray) => ({
'--popover': rgbaToHex([gray, gray, gray, 1])
})).filter(
@@ -82,8 +70,13 @@ describe('noticeSurfaceTokens', () => {
})
it('outlines secondary buttons only when their fill is gone', () => {
const { notice } = paintedNotice({ '--popover': '#303030', '--popover-foreground': '#a0a0a0' })
expect(notice['--notice-secondary-mix']).toBe('0%')
const notice = noticeSurfaceTokens(
{ '--popover': '#303030', '--popover-foreground': '#a0a0a0' },
'dark',
DARK_BUILT_INS,
probe
)
expect(notice['--notice-secondary']).toBe(notice['--notice-surface'])
expect(notice['--notice-secondary-edge']).toBe('var(--notice-border)')
})
})
@@ -8,34 +8,46 @@ import {
type Rgba
} from '../../../../shared/color/rgba-contrast'
// Keep in sync with the notice tokens in main.css.
export const NOTICE_LIFT_TOKEN = '--notice-lift'
export const NOTICE_SECONDARY_MIX_TOKEN = '--notice-secondary-mix'
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_LIFT = { light: 0, dark: 7 } as const
export const NOTICE_SECONDARY_MIX = { light: 6, dark: 10 } as const
// 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
/** The 8-bit color that is actually painted, so a pass can't round below the floor. */
const BLACK: Rgba = [0, 0, 0, 1]
const painted = (color: Rgba): Rgba => hexToRgb(rgbaToHex(color))
const step = (color: Rgba, by: number): Rgba => [
Math.min(255, Math.max(0, color[0] + by)),
Math.min(255, Math.max(0, color[1] + by)),
Math.min(255, Math.max(0, color[2] + by)),
1
]
const readable = (surface: Rgba, text: Rgba): boolean =>
contrastRatio(painted(surface), painted(text)) >= MIN_TEXT_CONTRAST
/** 4.5:1 even with each color one 8-bit step toward the other, so a theme color that paints a
* channel off (fractional CSS colors round their own way) can't sink below the floor. */
function readable(a: Rgba, b: Rgba): boolean {
const [lighter, darker] = contrastRatio(a, BLACK) >= contrastRatio(b, BLACK) ? [a, b] : [b, a]
return contrastRatio(step(lighter, -1), step(darker, 1)) >= MIN_TEXT_CONTRAST
}
/** The deepest mix of `tint` into `surface`, from `deepest` down to 0, that keeps `text` readable. */
function deepestReadableMix(tint: Rgba, surface: Rgba, text: Rgba, deepest: number): number {
/** `tint` mixed into `base` at the deepest percent, from `deepest` down, that keeps `text` readable. */
function deepestReadableMix(tint: Rgba, base: Rgba, text: Rgba, deepest: number): Rgba {
for (let percent = deepest; percent > 0; percent--) {
if (readable(mix(tint, surface, percent), text)) {
return percent
const candidate = painted(mix(tint, base, percent))
if (readable(candidate, text)) {
return candidate
}
}
return 0
return painted(base)
}
/**
* Toasts and corner cards lift the popover toward the foreground and paint popover and muted
* text on it. A theme's popover pair is only checked unlifted, so lift and secondary mix shrink
* until popover text stays 4.5:1, and muted text moves toward popover text until it does too.
* 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.
*/
export function noticeSurfaceTokensFor(
colors: { foreground: Rgba; popover: Rgba; popoverForeground: Rgba; mutedForeground: Rgba },
@@ -45,27 +57,22 @@ export function noticeSurfaceTokensFor(
if ([foreground, popover, popoverForeground, mutedForeground].some((color) => color[3] < 1)) {
return {}
}
const lift = deepestReadableMix(foreground, popover, popoverForeground, NOTICE_LIFT[base])
const surface = mix(foreground, popover, lift)
const secondaryMix = deepestReadableMix(
foreground,
surface,
popoverForeground,
NOTICE_SECONDARY_MIX[base]
)
let towardText = 0
while (
towardText < 100 &&
!readable(surface, mix(popoverForeground, mutedForeground, towardText))
) {
towardText++
const text = painted(popoverForeground)
const surface = deepestReadableMix(foreground, popover, text, NOTICE_LIFT[base])
const secondary = deepestReadableMix(foreground, surface, text, NOTICE_SECONDARY_MIX[base])
let muted = painted(mutedForeground)
for (let towardText = 1; towardText <= 100 && !readable(surface, muted); towardText++) {
muted = painted(mix(popoverForeground, mutedForeground, towardText))
}
const surfaceHex = rgbaToHex(surface)
const secondaryHex = rgbaToHex(secondary)
return {
[NOTICE_LIFT_TOKEN]: `${lift}%`,
[NOTICE_SECONDARY_MIX_TOKEN]: `${secondaryMix}%`,
[NOTICE_SURFACE_TOKEN]: surfaceHex,
[NOTICE_SECONDARY_TOKEN]: secondaryHex,
// Why: with no fill left, secondary buttons need an edge to still read as buttons.
[NOTICE_SECONDARY_EDGE_TOKEN]: secondaryMix === 0 ? 'var(--notice-border)' : 'transparent',
[NOTICE_MUTED_FOREGROUND_TOKEN]: rgbaToHex(mix(popoverForeground, mutedForeground, towardText))
[NOTICE_SECONDARY_EDGE_TOKEN]:
secondaryHex === surfaceHex ? 'var(--notice-border)' : 'transparent',
[NOTICE_MUTED_FOREGROUND_TOKEN]: rgbaToHex(muted)
}
}
+4 -10
View File
@@ -552,26 +552,20 @@
}
/* One lifted surface for toasts and corner notification cards; the default card/popover blends into the app.
Plugin themes override the lift, secondary mix and muted text (notice-surface-tokens.ts) to keep text readable. */
App themes replace the surface, secondary and muted colors with readable hex (notice-surface-tokens.ts). */
:root {
--notice-secondary-mix: 6%;
--notice-muted-foreground: var(--muted-foreground);
--notice-secondary-edge: transparent;
--notice-surface: var(--popover);
--notice-border: color-mix(in srgb, var(--foreground) 14%, transparent);
--notice-secondary: color-mix(
in srgb,
var(--foreground) var(--notice-secondary-mix),
var(--notice-surface)
);
--notice-secondary: color-mix(in srgb, var(--foreground) 6%, var(--notice-surface));
--notice-shadow: 0 11px 32px -6px rgb(0 0 0 / 0.2), 0 2px 6px rgb(0 0 0 / 0.08);
}
.dark {
--notice-lift: 7%;
--notice-secondary-mix: 10%;
--notice-surface: color-mix(in srgb, var(--foreground) var(--notice-lift), var(--popover));
--notice-surface: color-mix(in srgb, var(--foreground) 7%, var(--popover));
--notice-border: color-mix(in srgb, var(--foreground) 15%, transparent);
--notice-secondary: color-mix(in srgb, var(--foreground) 10%, var(--notice-surface));
--notice-shadow:
0 11px 32px -6px rgb(0 0 0 / 0.55), 0 2px 6px rgb(0 0 0 / 0.22),
inset 0 1px 0 rgb(255 255 255 / 0.06);