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 e9c4e7936e4..4f6fc8267f8 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 @@ -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): 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>([ { '--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) }) 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 7afca6bb419..5b87a6966fe 100644 --- a/src/renderer/src/app-shell/appearance/notice-surface-tokens.ts +++ b/src/renderer/src/app-shell/appearance/notice-surface-tokens.ts @@ -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)' } } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 88a4063759d..560b4dd83a7 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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. */