mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(notifications): paint app-theme notice colors as the exact hex measured, with a one-step margin
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user