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/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('returns white against a dark background', () => { + expect(pickAlarmColor([4, 57, 94])).toBe('#ffffff'); + expect(pickAlarmColor([37, 37, 38])).toBe('#ffffff'); + }); + + 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 2b226f4..a987c0f 100644 --- a/lib/src/lib/themes/dynamic-palette.ts +++ b/lib/src/lib/themes/dynamic-palette.ts @@ -2,6 +2,14 @@ import { chromaOklab, deltaEOklab, rgbOf, rgbToOklab } from '../color-contrast'; type Lab = [number, number, number]; +/** 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] = rgbToOklab(bgRgb); + return L < 0.5 ? '#ffffff' : '#000000'; +} + export interface FocusRingCandidate { varName: string; lab: Lab; @@ -57,14 +65,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 +102,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 3b0f140..e5fbf27 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

+ +
); 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); } 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 } ],