mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(notifications): keep muted panels on the notice surface's polarity for app themes
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user