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 34208a40172..278ede91c72 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 @@ -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): { - surface: Rgba - secondary: Rgba - text: Rgba - muted: Rgba - notice: Record -} { - 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): 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>([ - { '--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)') }) }) 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 192fc31d26e..0598b4b7604 100644 --- a/src/renderer/src/app-shell/appearance/notice-surface-tokens.ts +++ b/src/renderer/src/app-shell/appearance/notice-surface-tokens.ts @@ -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) } } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index d95efdff810..b6821e9624c 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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);