fix(notifications): remap accent/input/border onto the notice surface (#42207)

Outline and ghost buttons in toasts and corner cards painted dark:bg-input/30
and hover:bg-accent from the app palette under popover text, and panels and
buttons drew edges from the app's --border and --input. For app themes the
notice scope now remaps --accent, --accent-foreground and --input onto the
readable secondary fill, and --border onto a popover-text edge. Dark outline
buttons get an inset edge, and --notice-border now follows the popover text.
Built-in themes paint as before.
This commit is contained in:
Jinjing
2026-11-13 16:58:47 -08:00
committed by GitHub
parent 62b22aeab0
commit 4d3cfb7dcc
3 changed files with 59 additions and 7 deletions
@@ -7,7 +7,9 @@ const DARK_BUILT_INS = {
'--popover': '#171717',
'--popover-foreground': '#f0f0f0',
'--muted': '#262626',
'--muted-foreground': '#acacac'
'--muted-foreground': '#acacac',
'--accent': '#404040',
'--accent-foreground': '#f0f0f0'
}
const probe = (value: string): Rgba | null => (value.startsWith('#') ? hexToRgb(value) : null)
@@ -66,18 +68,37 @@ function expectReadable(themeTokens: Record<string, string>): void {
expect(wcagContrast(panel, muted)).toBeGreaterThanOrEqual(4.5)
expect(wcagContrast(panel, text)).toBeGreaterThanOrEqual(4.5)
}
// Hover fills (bg-accent, ghost dark:hover:bg-accent/50) paint accent-foreground or, for ghost
// buttons with hover:text-foreground, popover text. Without notice tokens, main.css paints the
// palette's --accent and --accent-foreground.
const accent = notice['--notice-accent'] ?? palette['--accent']
const accentText = notice['--notice-accent-foreground'] ?? palette['--accent-foreground']
for (const alpha of [0.5, 1]) {
const hover = composite(accent, surface, alpha)
expect(wcagContrast(hover, accentText)).toBeGreaterThanOrEqual(4.5)
expect(wcagContrast(hover, text)).toBeGreaterThanOrEqual(4.5)
}
// Dark outline buttons fill with dark:bg-input/30, /50 on hover, under popover text.
const input = notice['--notice-input'] ?? palette['--input']
if (input?.startsWith('#')) {
for (const alpha of [0.3, 0.5]) {
expect(wcagContrast(composite(input, surface, alpha), text)).toBeGreaterThanOrEqual(4.5)
}
}
}
describe('noticeSurfaceTokens', () => {
// Popovers the theme validator accepts that once painted notice text under 4.5:1: #5a5a5a
// through Chromium's color-mix rounding and an over-lifted 80% description, #383838 through hex
// rounding, white-on-dark-base through dark muted panels, the rest unlifted.
// rounding, white-on-dark-base through dark muted panels, hover and outline fills, the rest
// unlifted.
it.each<Record<string, string>>([
{ '--popover': '#5a5a5a' },
{ '--popover': '#383838' },
{ '--popover': '#444444' },
{ '--popover': '#303030', '--popover-foreground': '#a0a0a0' },
{ '--popover': '#ffffff', '--popover-foreground': '#111111' }
{ '--popover': '#ffffff', '--popover-foreground': '#111111' },
{ '--popover': '#ffffff', '--popover-foreground': '#747474', '--input': '#262626' }
])('keeps notice text 4.5:1 for an accepted %o', (themeTokens) => {
expectReadable(themeTokens)
})
@@ -13,6 +13,11 @@ 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'
export const NOTICE_ACCENT_TOKEN = '--notice-accent'
export const NOTICE_ACCENT_FOREGROUND_TOKEN = '--notice-accent-foreground'
export const NOTICE_INPUT_TOKEN = '--notice-input'
export const NOTICE_EDGE_TOKEN = '--notice-edge'
export const NOTICE_OUTLINE_EDGE_TOKEN = '--notice-outline-edge'
// 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
@@ -102,7 +107,17 @@ export function noticeSurfaceTokensFor(
// 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)
[NOTICE_MUTED_FOREGROUND_TOKEN]: rgbaToHex(muted),
// Why: hover (bg-accent, bg-accent/50) and outline fills (dark:bg-input/30, /50) paint under
// popover text in the notice scope; the secondary fill is the deepest one it stays readable on.
[NOTICE_ACCENT_TOKEN]: secondaryHex,
[NOTICE_ACCENT_FOREGROUND_TOKEN]: rgbaToHex(text),
[NOTICE_INPUT_TOKEN]: secondaryHex,
// Why: edges never sit under text, so they follow the popover text like the card border does;
// the theme's own --border/--input can be on the app's polarity, not the popover's.
[NOTICE_EDGE_TOKEN]: 'var(--notice-border)',
// Why: --input now only fills, so dark outline buttons (border-input) need the edge drawn.
[NOTICE_OUTLINE_EDGE_TOKEN]: 'var(--notice-border)'
}
}
+19 -3
View File
@@ -552,20 +552,25 @@
}
/* 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). */
App themes replace the surface, fill and edge colors with readable ones (notice-surface-tokens.ts). */
:root {
--notice-accent: var(--accent);
--notice-accent-foreground: var(--accent-foreground);
--notice-edge: var(--border);
--notice-input: var(--input);
--notice-muted: var(--muted);
--notice-muted-foreground: var(--muted-foreground);
--notice-outline-edge: transparent;
--notice-secondary-edge: transparent;
--notice-surface: var(--popover);
--notice-border: color-mix(in srgb, var(--foreground) 14%, transparent);
--notice-border: color-mix(in srgb, var(--popover-foreground) 14%, transparent);
--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-surface: color-mix(in srgb, var(--foreground) 7%, var(--popover));
--notice-border: color-mix(in srgb, var(--foreground) 15%, transparent);
--notice-border: color-mix(in srgb, var(--popover-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),
@@ -585,6 +590,10 @@
--muted-foreground: var(--notice-muted-foreground);
--secondary: var(--notice-secondary);
--secondary-foreground: var(--popover-foreground);
--accent: var(--notice-accent);
--accent-foreground: var(--notice-accent-foreground);
--input: var(--notice-input);
--border: var(--notice-edge);
}
/* Why inset outline: an edge for themes that leave secondary buttons unfilled, without moving layout. */
@@ -597,6 +606,13 @@
outline-offset: -1px;
}
.dark
:is([data-sonner-toaster], .notification-card-stack)
[data-slot='button'][data-variant='outline'] {
outline: 1px solid var(--notice-outline-edge);
outline-offset: -1px;
}
[data-sonner-toaster] {
font-family: var(--font-sans);
/* Why: above the floating workspace (z-45 panel, z-46 launcher), which parks in the toasts' corner. */