From 1c3340b46bb291076e9728ce864776f80e201249 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 29 Apr 2026 13:08:55 -0700 Subject: [PATCH 1/5] fixup the smoke test. --- lib/src/stories/Smoke.stories.tsx | 215 ++++++++++++++++++++++++++---- 1 file changed, 187 insertions(+), 28 deletions(-) diff --git a/lib/src/stories/Smoke.stories.tsx b/lib/src/stories/Smoke.stories.tsx index 3b0f140..77fab04 100644 --- a/lib/src/stories/Smoke.stories.tsx +++ b/lib/src/stories/Smoke.stories.tsx @@ -1,35 +1,194 @@ import type { Meta, StoryObj } from '@storybook/react'; +import { useLayoutEffect, useState } from 'react'; + +const HOST_VARS = [ + ['sideBar.background', '--vscode-sideBar-background'], + ['sideBar.foreground', '--vscode-sideBar-foreground'], + ['terminal.background', '--vscode-terminal-background'], + ['terminal.foreground', '--vscode-terminal-foreground'], + ['list.activeSelectionBackground', '--vscode-list-activeSelectionBackground'], + ['list.activeSelectionForeground', '--vscode-list-activeSelectionForeground'], + ['list.inactiveSelectionBackground', '--vscode-list-inactiveSelectionBackground'], + ['list.inactiveSelectionForeground', '--vscode-list-inactiveSelectionForeground'], + ['focusBorder', '--vscode-focusBorder'], +] as const; + +const SEMANTIC_VARS = [ + ['app bg', '--color-app-bg'], + ['app fg', '--color-app-fg'], + ['terminal bg', '--color-terminal-bg'], + ['terminal fg', '--color-terminal-fg'], + ['active header bg', '--color-header-active-bg'], + ['active header fg', '--color-header-active-fg'], + ['inactive header bg', '--color-header-inactive-bg'], + ['inactive header fg', '--color-header-inactive-fg'], + ['door bg', '--color-door-bg'], + ['door fg', '--color-door-fg'], + ['focus ring', '--color-focus-ring'], +] as const; + +const DYNAMIC_BODY_VARS = [ + ['door bg', '--color-door-bg'], + ['door fg', '--color-door-fg'], + ['focus ring', '--color-focus-ring'], +] as const; + +type VarRow = readonly [label: string, name: string]; +type VarSource = 'computed' | 'body-style'; + +function useCssVars(rows: readonly VarRow[], source: VarSource) { + const [values, setValues] = useState>({}); + + useLayoutEffect(() => { + let frame = 0; + + const readVars = () => { + const styles = + source === 'body-style' ? document.body.style : getComputedStyle(document.body); + const nextValues: Record = {}; + for (const [, name] of rows) { + nextValues[name] = styles.getPropertyValue(name).trim(); + } + setValues(nextValues); + }; + + readVars(); + + const scheduleRead = () => { + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(readVars); + }; + + const observer = new MutationObserver(scheduleRead); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + + return () => { + window.cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [rows, source]); + + return values; +} + +function VarTable({ + rows, + source = 'computed', +}: { + rows: readonly VarRow[]; + source?: VarSource; +}) { + const values = useCssVars(rows, source); + + return ( +
+ {rows.map(([label, name]) => { + const value = values[name] || 'missing'; + const isMissing = !values[name]; + + return ( +
+ {label} + {name} + {value} +
+ ); + })} +
+ ); +} + +function Swatch({ label, token }: { label: string; token: string }) { + return ( +
+
+
+
{label}
+
{token}
+
+
+ ); +} function ThemeCheck() { return ( -
-

Storybook Smoke Test

-

Theme tokens are working if you see colored squares below.

-
-
-
- header-active-bg -
-
-
- header-inactive-bg -
-
-
- surface -
-
-
- surface-raised -
-
-
- error -
-
-
- terminal-bg -
+
+
+
+

Storybook Theme Smoke Test

+

+ Verifies the resolved VSCode host variables, MouseTerm semantic tokens, and dynamic + palette picks that Storybook injects for isolated stories. +

+
+ +
+

Chrome Preview

+
+
+
+
+ Active terminal +
+
+ Waiting terminal +
+
+
+
$ pnpm test
+
resolver defaults materialized
+
dynamic palette published on body
+
missing tokens render as failures below
+
+
+ +
+
+ +
+

Semantic Palette

+
+ {SEMANTIC_VARS.map(([label, token]) => ( + + ))} +
+
+ +
+
+

Resolved VSCode Variables

+ +
+
+

MouseTerm Tokens

+ +
+
+ +
+

Storybook Dynamic Body Vars

+ +
); From cc2e68fe0723c3da0980dcfb076d86475b4086b9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 29 Apr 2026 15:00:31 -0700 Subject: [PATCH 2/5] Remove minimum size from the app. --- standalone/src-tauri/tauri.conf.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/standalone/src-tauri/tauri.conf.json b/standalone/src-tauri/tauri.conf.json index 1011865..1101086 100644 --- a/standalone/src-tauri/tauri.conf.json +++ b/standalone/src-tauri/tauri.conf.json @@ -17,8 +17,6 @@ "hiddenTitle": true, "width": 1200, "height": 800, - "minWidth": 800, - "minHeight": 600, "resizable": true } ], From 9bbb4be2112927b9ab2d4d847971e6e87022be8f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 29 Apr 2026 15:02:40 -0700 Subject: [PATCH 3/5] Compute ringing-bell alarm color per-surface in OkLCH. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the static --color-warning family in favor of three runtime-computed tokens (--color-alarm-vs-{header-active,header-inactive,door}) derived from each surface's bg: rotate hue 180° (or hue=90 for greyscale bgs), push chroma high, let per-channel sRGB clipping handle gamut. Keeps the alarm theme-aware so the bell stays visible without hand-tuning per theme. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/.storybook/preview.ts | 3 ++ lib/src/components/Door.tsx | 2 +- lib/src/components/ThemeDebugger.tsx | 2 +- .../components/wall/TerminalPaneHeader.tsx | 4 +- lib/src/lib/color-contrast.ts | 31 +++++++++++++ lib/src/lib/themes/diagnostics.ts | 1 - lib/src/lib/themes/dynamic-palette.test.ts | 20 ++++++++- lib/src/lib/themes/dynamic-palette.ts | 43 ++++++++++++++++++- lib/src/lib/themes/use-dynamic-palette.ts | 3 ++ lib/src/stories/Smoke.stories.tsx | 2 +- lib/src/theme.css | 12 +++++- 11 files changed, 113 insertions(+), 10 deletions(-) diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index 47228e9..5aac2ca 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -33,6 +33,9 @@ const DYNAMIC_PALETTE_VARS = [ '--color-door-bg', '--color-door-fg', '--color-focus-ring', + '--color-alarm-vs-header-active', + '--color-alarm-vs-header-inactive', + '--color-alarm-vs-door', ] as const; const PREFERRED_STORYBOOK_THEME = 'Light (Visual Studio)'; const FIRST_STORYBOOK_THEME = Object.keys(VSCODE_THEMES)[0] ?? ''; diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx index 7ed3651..c8b88ad 100644 --- a/lib/src/components/Door.tsx +++ b/lib/src/components/Door.tsx @@ -49,7 +49,7 @@ export function Door({ )} {alertEnabled && ( - + )} diff --git a/lib/src/components/ThemeDebugger.tsx b/lib/src/components/ThemeDebugger.tsx index db29faa..9e9f535 100644 --- a/lib/src/components/ThemeDebugger.tsx +++ b/lib/src/components/ThemeDebugger.tsx @@ -44,7 +44,7 @@ function originClass(origin: VscodeThemeVarTraceOrigin | VisibleVarOrigin): stri return 'text-success'; case 'registry-default': case 'mouseterm-materialized': - return 'text-warning'; + return '[color:var(--vscode-terminal-ansiYellow)]'; case 'fallback': return 'text-muted'; case 'unresolved': diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 781e3b6..febe36e 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -152,7 +152,9 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { { if (e.button !== 0) return; diff --git a/lib/src/lib/color-contrast.ts b/lib/src/lib/color-contrast.ts index 8968267..6e16d72 100644 --- a/lib/src/lib/color-contrast.ts +++ b/lib/src/lib/color-contrast.ts @@ -48,3 +48,34 @@ export function deltaEOklab(a: [number, number, number], b: [number, number, num export function chromaOklab([, a, b]: [number, number, number]): number { return Math.sqrt(a * a + b * b); } + +/** OKLab → sRGB (0-255 bytes). Per-channel clip: when the requested OkLab + * point is outside sRGB's gamut, channels saturate at 0/255 and the resulting + * hue drifts toward an sRGB primary. Acceptable for attention-grabbing + * alarm colors where vivid-snapped-to-primary is the desired look. */ +export function oklabToRgb([L, a, b]: [number, number, number]): [number, number, number] { + const l_ = L + 0.3963377774 * a + 0.2158037573 * b; + const m_ = L - 0.1055613458 * a - 0.0638541728 * b; + const s_ = L - 0.0894841775 * a - 1.2914855480 * b; + const l = l_ * l_ * l_; + const m = m_ * m_ * m_; + const s = s_ * s_ * s_; + const lr = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + const lb = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; + const toSrgb = (v: number) => { + const c = v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; + return Math.max(0, Math.min(255, Math.round(c * 255))); + }; + return [toSrgb(lr), toSrgb(lg), toSrgb(lb)]; +} + +/** OkLCH → CSS hex. Hue in degrees. */ +export function oklchToCssHex({ L, C, H }: { L: number; C: number; H: number }): string { + const h = (H * Math.PI) / 180; + const a = C * Math.cos(h); + const b = C * Math.sin(h); + const [r, g, bl] = oklabToRgb([L, a, b]); + const hex = (n: number) => n.toString(16).padStart(2, '0'); + return `#${hex(r)}${hex(g)}${hex(bl)}`; +} diff --git a/lib/src/lib/themes/diagnostics.ts b/lib/src/lib/themes/diagnostics.ts index 3dba1c3..281b371 100644 --- a/lib/src/lib/themes/diagnostics.ts +++ b/lib/src/lib/themes/diagnostics.ts @@ -68,7 +68,6 @@ const SEMANTIC_TOKEN_SOURCES: Array { expect(picks.focusRing?.sourceVar).toBe('--color-header-active-bg'); }); }); + +describe('pickAlarmColor', () => { + it('rotates the hue away from a chromatic background', () => { + const navy: Rgb = [4, 57, 94]; + const out = pickAlarmColor(navy); + expect(out).toMatch(/^#[0-9a-f]{6}$/); + expect(out).not.toBe('#04395e'); + const rgb = hexToRgb(out)!; + // navy is blue-dominant; the complement should NOT be blue-dominant + expect(rgb[2]).toBeLessThan(Math.max(rgb[0], rgb[1])); + }); + + it('returns a valid hex for a near-greyscale background', () => { + const grey: Rgb = [37, 37, 38]; + const out = pickAlarmColor(grey); + expect(out).toMatch(/^#[0-9a-f]{6}$/); + }); +}); diff --git a/lib/src/lib/themes/dynamic-palette.ts b/lib/src/lib/themes/dynamic-palette.ts index 2b226f4..2b1c93e 100644 --- a/lib/src/lib/themes/dynamic-palette.ts +++ b/lib/src/lib/themes/dynamic-palette.ts @@ -1,7 +1,25 @@ -import { chromaOklab, deltaEOklab, rgbOf, rgbToOklab } from '../color-contrast'; +import { chromaOklab, deltaEOklab, oklchToCssHex, rgbOf, rgbToOklab } from '../color-contrast'; type Lab = [number, number, number]; +const ALARM_TARGET_CHROMA = 0.3; +const ALARM_GREYSCALE_HUE = 90; +const ALARM_GREY_CHROMA_THRESHOLD = 0.01; + +/** Compute an alarm color that visually pops against an arbitrary background. + * - Chromatic bg: rotate hue by 180° at the same L, push chroma high. + * - Greyscale bg: pick hue=90 (yellow-green) at the same L, push chroma high. + * Per-channel sRGB clipping in oklchToCssHex handles out-of-gamut targets. */ +export function pickAlarmColor(bgRgb: [number, number, number]): string { + const [L, a, b] = rgbToOklab(bgRgb); + const C = Math.sqrt(a * a + b * b); + const Hdeg = (Math.atan2(b, a) * 180) / Math.PI; + const H = C >= ALARM_GREY_CHROMA_THRESHOLD + ? (Hdeg + 180 + 360) % 360 + : ALARM_GREYSCALE_HUE; + return oklchToCssHex({ L, C: ALARM_TARGET_CHROMA, H }); +} + export interface FocusRingCandidate { varName: string; lab: Lab; @@ -57,14 +75,19 @@ export interface DynamicPaletteVars { '--color-door-bg'?: string; '--color-door-fg'?: string; '--color-focus-ring'?: string; + '--color-alarm-vs-header-active'?: string; + '--color-alarm-vs-header-inactive'?: string; + '--color-alarm-vs-door'?: string; } export function computeDynamicPalette( styles: Pick, ctx: CanvasRenderingContext2D, ): DynamicPaletteVars { + const rgbOfVar = (varName: string): [number, number, number] | null => + rgbOf(styles.getPropertyValue(varName).trim(), ctx); const labOf = (varName: string): Lab | null => { - const rgb = rgbOf(styles.getPropertyValue(varName).trim(), ctx); + const rgb = rgbOfVar(varName); return rgb ? rgbToOklab(rgb) : null; }; @@ -89,6 +112,22 @@ export function computeDynamicPalette( const pick = pickFocusRing(candidates, oApp); if (pick) result['--color-focus-ring'] = `var(${pick.varName})`; + const headerActiveRgb = rgbOfVar('--color-header-active-bg'); + if (headerActiveRgb) { + result['--color-alarm-vs-header-active'] = pickAlarmColor(headerActiveRgb); + } + const headerInactiveRgb = rgbOfVar('--color-header-inactive-bg'); + if (headerInactiveRgb) { + result['--color-alarm-vs-header-inactive'] = pickAlarmColor(headerInactiveRgb); + } + // Door bg is also computed by this same pass; on the first run after a theme + // change this reads the previous value, but the MutationObserver re-fires on + // our own body.style write and the next pass picks up the fresh door bg. + const doorRgb = rgbOfVar('--color-door-bg'); + if (doorRgb) { + result['--color-alarm-vs-door'] = pickAlarmColor(doorRgb); + } + return result; } diff --git a/lib/src/lib/themes/use-dynamic-palette.ts b/lib/src/lib/themes/use-dynamic-palette.ts index c90264d..503d2af 100644 --- a/lib/src/lib/themes/use-dynamic-palette.ts +++ b/lib/src/lib/themes/use-dynamic-palette.ts @@ -29,6 +29,9 @@ export function useDynamicPalette(): void { document.body.style.removeProperty('--color-door-bg'); document.body.style.removeProperty('--color-door-fg'); document.body.style.removeProperty('--color-focus-ring'); + document.body.style.removeProperty('--color-alarm-vs-header-active'); + document.body.style.removeProperty('--color-alarm-vs-header-inactive'); + document.body.style.removeProperty('--color-alarm-vs-door'); }; }, []); } diff --git a/lib/src/stories/Smoke.stories.tsx b/lib/src/stories/Smoke.stories.tsx index 77fab04..e5fbf27 100644 --- a/lib/src/stories/Smoke.stories.tsx +++ b/lib/src/stories/Smoke.stories.tsx @@ -150,7 +150,7 @@ function ThemeCheck() {
$ pnpm test
resolver defaults materialized
-
dynamic palette published on body
+
dynamic palette published on body
missing tokens render as failures below
diff --git a/lib/src/theme.css b/lib/src/theme.css index ebb57d4..c518918 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -76,7 +76,13 @@ /* Semantic status — use the active terminal palette. */ --color-error: var(--vscode-terminal-ansiRed); --color-success: var(--vscode-terminal-ansiGreen); - --color-warning: var(--vscode-terminal-ansiYellow); + + /* Alarm — per-surface, computed at runtime by use-dynamic-palette.ts from the + * bg the bell sits on (OkLCH hue rotation + max chroma). The fallback below + * is shown only before the dynamic pass runs. */ + --color-alarm-vs-header-active: var(--vscode-terminal-ansiYellow); + --color-alarm-vs-header-inactive: var(--vscode-terminal-ansiYellow); + --color-alarm-vs-door: var(--vscode-terminal-ansiYellow); /* Inputs — used by ThemePicker */ --color-input-bg: var(--vscode-input-background); @@ -111,7 +117,9 @@ body { --color-terminal-fg: var(--vscode-terminal-foreground); --color-error: var(--vscode-terminal-ansiRed); --color-success: var(--vscode-terminal-ansiGreen); - --color-warning: var(--vscode-terminal-ansiYellow); + --color-alarm-vs-header-active: var(--vscode-terminal-ansiYellow); + --color-alarm-vs-header-inactive: var(--vscode-terminal-ansiYellow); + --color-alarm-vs-door: var(--vscode-terminal-ansiYellow); --color-input-bg: var(--vscode-input-background); --color-input-border: var(--vscode-input-border); } From cd09f65ec00459b30607580d59c41ae0b9a7344b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 29 Apr 2026 15:09:10 -0700 Subject: [PATCH 4/5] Flip lightness for alarm color so dark themes get a bright bell. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous "preserve bg L" rule meant low-L bgs produced low-L alarms — fine hue/chroma but visually dim. Inverting L (1 - bgL) gives the bell strong luminance contrast against the bg, which is the dominant signal. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/themes/dynamic-palette.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/src/lib/themes/dynamic-palette.ts b/lib/src/lib/themes/dynamic-palette.ts index 2b1c93e..64db717 100644 --- a/lib/src/lib/themes/dynamic-palette.ts +++ b/lib/src/lib/themes/dynamic-palette.ts @@ -7,8 +7,9 @@ const ALARM_GREYSCALE_HUE = 90; const ALARM_GREY_CHROMA_THRESHOLD = 0.01; /** Compute an alarm color that visually pops against an arbitrary background. - * - Chromatic bg: rotate hue by 180° at the same L, push chroma high. - * - Greyscale bg: pick hue=90 (yellow-green) at the same L, push chroma high. + * - Chromatic bg: rotate hue by 180°, push chroma high. + * - Greyscale bg: pick hue=90 (yellow-green), push chroma high. + * - Lightness is flipped (1 - bgL) so the alarm always contrasts with the bg. * Per-channel sRGB clipping in oklchToCssHex handles out-of-gamut targets. */ export function pickAlarmColor(bgRgb: [number, number, number]): string { const [L, a, b] = rgbToOklab(bgRgb); @@ -17,7 +18,7 @@ export function pickAlarmColor(bgRgb: [number, number, number]): string { const H = C >= ALARM_GREY_CHROMA_THRESHOLD ? (Hdeg + 180 + 360) % 360 : ALARM_GREYSCALE_HUE; - return oklchToCssHex({ L, C: ALARM_TARGET_CHROMA, H }); + return oklchToCssHex({ L: 1 - L, C: ALARM_TARGET_CHROMA, H }); } export interface FocusRingCandidate { From 6ca65ab5c65572313773f8a7bcd3ab3e0d107a3d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 29 Apr 2026 15:10:42 -0700 Subject: [PATCH 5/5] Pick alarm color as black or white based on bg lightness. Hue-rotation schemes produced muddy colors at extreme lightnesses (light bgs went near-black, dark bgs went dim). Pure black/white based on bg L>=0.5 gives the strongest possible luminance contrast and reads as a clean alarm on every theme. Drops the unused oklabToRgb / oklchToCssHex helpers. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/color-contrast.ts | 31 ---------------------- lib/src/lib/themes/dynamic-palette.test.ts | 18 +++++-------- lib/src/lib/themes/dynamic-palette.ts | 23 +++++----------- 3 files changed, 12 insertions(+), 60 deletions(-) diff --git a/lib/src/lib/color-contrast.ts b/lib/src/lib/color-contrast.ts index 6e16d72..8968267 100644 --- a/lib/src/lib/color-contrast.ts +++ b/lib/src/lib/color-contrast.ts @@ -48,34 +48,3 @@ export function deltaEOklab(a: [number, number, number], b: [number, number, num export function chromaOklab([, a, b]: [number, number, number]): number { return Math.sqrt(a * a + b * b); } - -/** OKLab → sRGB (0-255 bytes). Per-channel clip: when the requested OkLab - * point is outside sRGB's gamut, channels saturate at 0/255 and the resulting - * hue drifts toward an sRGB primary. Acceptable for attention-grabbing - * alarm colors where vivid-snapped-to-primary is the desired look. */ -export function oklabToRgb([L, a, b]: [number, number, number]): [number, number, number] { - const l_ = L + 0.3963377774 * a + 0.2158037573 * b; - const m_ = L - 0.1055613458 * a - 0.0638541728 * b; - const s_ = L - 0.0894841775 * a - 1.2914855480 * b; - const l = l_ * l_ * l_; - const m = m_ * m_ * m_; - const s = s_ * s_ * s_; - const lr = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; - const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; - const lb = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; - const toSrgb = (v: number) => { - const c = v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; - return Math.max(0, Math.min(255, Math.round(c * 255))); - }; - return [toSrgb(lr), toSrgb(lg), toSrgb(lb)]; -} - -/** OkLCH → CSS hex. Hue in degrees. */ -export function oklchToCssHex({ L, C, H }: { L: number; C: number; H: number }): string { - const h = (H * Math.PI) / 180; - const a = C * Math.cos(h); - const b = C * Math.sin(h); - const [r, g, bl] = oklabToRgb([L, a, b]); - const hex = (n: number) => n.toString(16).padStart(2, '0'); - return `#${hex(r)}${hex(g)}${hex(bl)}`; -} diff --git a/lib/src/lib/themes/dynamic-palette.test.ts b/lib/src/lib/themes/dynamic-palette.test.ts index 97ef72e..0d52164 100644 --- a/lib/src/lib/themes/dynamic-palette.test.ts +++ b/lib/src/lib/themes/dynamic-palette.test.ts @@ -48,19 +48,13 @@ describe('pickDynamicPalette', () => { }); describe('pickAlarmColor', () => { - it('rotates the hue away from a chromatic background', () => { - const navy: Rgb = [4, 57, 94]; - const out = pickAlarmColor(navy); - expect(out).toMatch(/^#[0-9a-f]{6}$/); - expect(out).not.toBe('#04395e'); - const rgb = hexToRgb(out)!; - // navy is blue-dominant; the complement should NOT be blue-dominant - expect(rgb[2]).toBeLessThan(Math.max(rgb[0], rgb[1])); + it('returns white against a dark background', () => { + expect(pickAlarmColor([4, 57, 94])).toBe('#ffffff'); + expect(pickAlarmColor([37, 37, 38])).toBe('#ffffff'); }); - it('returns a valid hex for a near-greyscale background', () => { - const grey: Rgb = [37, 37, 38]; - const out = pickAlarmColor(grey); - expect(out).toMatch(/^#[0-9a-f]{6}$/); + it('returns black against a light background', () => { + expect(pickAlarmColor([228, 230, 241])).toBe('#000000'); + expect(pickAlarmColor([255, 255, 255])).toBe('#000000'); }); }); diff --git a/lib/src/lib/themes/dynamic-palette.ts b/lib/src/lib/themes/dynamic-palette.ts index 64db717..a987c0f 100644 --- a/lib/src/lib/themes/dynamic-palette.ts +++ b/lib/src/lib/themes/dynamic-palette.ts @@ -1,24 +1,13 @@ -import { chromaOklab, deltaEOklab, oklchToCssHex, rgbOf, rgbToOklab } from '../color-contrast'; +import { chromaOklab, deltaEOklab, rgbOf, rgbToOklab } from '../color-contrast'; type Lab = [number, number, number]; -const ALARM_TARGET_CHROMA = 0.3; -const ALARM_GREYSCALE_HUE = 90; -const ALARM_GREY_CHROMA_THRESHOLD = 0.01; - -/** Compute an alarm color that visually pops against an arbitrary background. - * - Chromatic bg: rotate hue by 180°, push chroma high. - * - Greyscale bg: pick hue=90 (yellow-green), push chroma high. - * - Lightness is flipped (1 - bgL) so the alarm always contrasts with the bg. - * Per-channel sRGB clipping in oklchToCssHex handles out-of-gamut targets. */ +/** Return pure black or pure white — whichever contrasts the given bg. + * Luminance contrast is the dominant signal for visibility, so a flat + * black/white pick beats any chroma-rotation scheme in practice. */ export function pickAlarmColor(bgRgb: [number, number, number]): string { - const [L, a, b] = rgbToOklab(bgRgb); - const C = Math.sqrt(a * a + b * b); - const Hdeg = (Math.atan2(b, a) * 180) / Math.PI; - const H = C >= ALARM_GREY_CHROMA_THRESHOLD - ? (Hdeg + 180 + 360) % 360 - : ALARM_GREYSCALE_HUE; - return oklchToCssHex({ L: 1 - L, C: ALARM_TARGET_CHROMA, H }); + const [L] = rgbToOklab(bgRgb); + return L < 0.5 ? '#ffffff' : '#000000'; } export interface FocusRingCandidate {