Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] ?? '';
Expand Down
2 changes: 1 addition & 1 deletion lib/src/components/Door.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function Door({
</span>
)}
{alertEnabled && (
<span className={alertRinging ? 'text-warning' : ''}>
<span className={alertRinging ? 'text-alarm-vs-door' : ''}>
<BellIcon size={11} weight="fill" className={bellIconClass(status)} />
</span>
)}
Expand Down
2 changes: 1 addition & 1 deletion lib/src/components/ThemeDebugger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
4 changes: 3 additions & 1 deletion lib/src/components/wall/TerminalPaneHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
<HeaderActionButton
className={[
'flex h-5 min-w-5 items-center justify-center rounded transition-colors shrink-0 hover:bg-current/10',
activity.status === 'ALERT_RINGING' ? 'text-warning' : '',
activity.status === 'ALERT_RINGING'
? (isActiveHeader ? 'text-alarm-vs-header-active' : 'text-alarm-vs-header-inactive')
: '',
].join(' ')}
onMouseDownCapture={(e) => {
if (e.button !== 0) return;
Expand Down
1 change: 0 additions & 1 deletion lib/src/lib/themes/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ const SEMANTIC_TOKEN_SOURCES: Array<Omit<SemanticTokenSnapshot, 'value' | 'sourc
{ token: '--color-header-inactive-fg', sourceVar: '--vscode-list-inactiveSelectionForeground', group: 'chrome' },
{ token: '--color-error', sourceVar: '--vscode-terminal-ansiRed', group: 'status' },
{ token: '--color-success', sourceVar: '--vscode-terminal-ansiGreen', group: 'status' },
{ token: '--color-warning', sourceVar: '--vscode-terminal-ansiYellow', group: 'status' },
{ token: '--color-input-bg', sourceVar: '--vscode-input-background', group: 'input' },
{ token: '--color-input-border', sourceVar: '--vscode-input-border', group: 'input' },
];
Expand Down
14 changes: 13 additions & 1 deletion lib/src/lib/themes/dynamic-palette.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { pickDynamicPalette, type Rgb } from './dynamic-palette';
import { pickAlarmColor, pickDynamicPalette, type Rgb } from './dynamic-palette';

function hexToRgb(color: string): Rgb | null {
const match = /^#([0-9a-f]{6})$/i.exec(color.trim());
Expand Down Expand Up @@ -46,3 +46,15 @@ describe('pickDynamicPalette', () => {
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');
});
});
31 changes: 30 additions & 1 deletion lib/src/lib/themes/dynamic-palette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<CSSStyleDeclaration, 'getPropertyValue'>,
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;
};

Expand All @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions lib/src/lib/themes/use-dynamic-palette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
};
}, []);
}
215 changes: 187 additions & 28 deletions lib/src/stories/Smoke.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>({});

useLayoutEffect(() => {
let frame = 0;

const readVars = () => {
const styles =
source === 'body-style' ? document.body.style : getComputedStyle(document.body);
const nextValues: Record<string, string> = {};
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 (
<div className="grid gap-px overflow-hidden rounded-lg border border-border bg-border font-mono text-xs">
{rows.map(([label, name]) => {
const value = values[name] || 'missing';
const isMissing = !values[name];

return (
<div
key={name}
className="grid grid-cols-1 gap-1 bg-app-bg px-3 py-2 sm:grid-cols-[minmax(9rem,0.8fr)_minmax(13rem,1.2fr)_minmax(7rem,0.7fr)] sm:items-center sm:gap-3"
>
<span className="text-app-fg">{label}</span>
<span className="truncate text-muted">{name}</span>
<span className={isMissing ? 'text-error' : 'text-app-fg'}>{value}</span>
</div>
);
})}
</div>
);
}

function Swatch({ label, token }: { label: string; token: string }) {
return (
<div className="flex min-w-0 items-center gap-2 rounded border border-border bg-app-bg p-2">
<div
className="h-8 w-8 shrink-0 rounded border border-border"
style={{ background: `var(${token})` }}
/>
<div className="min-w-0">
<div className="truncate font-medium text-app-fg">{label}</div>
<div className="truncate font-mono text-xs text-muted">{token}</div>
</div>
</div>
);
}

function ThemeCheck() {
return (
<div className="p-8 bg-app-bg text-app-fg min-h-screen">
<h1 className="text-lg font-bold mb-2">Storybook Smoke Test</h1>
<p className="text-muted mb-4">Theme tokens are working if you see colored squares below.</p>
<div className="flex gap-3">
<div className="flex flex-col items-center gap-1">
<div className="w-12 h-12 rounded bg-header-active-bg" />
<span className="text-sm text-muted">header-active-bg</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-12 h-12 rounded bg-header-inactive-bg" />
<span className="text-sm text-muted">header-inactive-bg</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-12 h-12 rounded bg-app-bg" />
<span className="text-sm text-muted">surface</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-12 h-12 rounded bg-surface-raised" />
<span className="text-sm text-muted">surface-raised</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-12 h-12 rounded bg-error" />
<span className="text-sm text-muted">error</span>
</div>
<div className="flex flex-col items-center gap-1">
<div className="w-12 h-12 rounded bg-terminal-bg border border-border" />
<span className="text-sm text-muted">terminal-bg</span>
</div>
<div className="min-h-screen bg-app-bg p-6 font-sans text-app-fg">
<div className="grid max-w-7xl gap-5">
<header className="grid gap-1">
<h1 className="text-lg font-semibold">Storybook Theme Smoke Test</h1>
<p className="max-w-3xl text-sm text-muted">
Verifies the resolved VSCode host variables, MouseTerm semantic tokens, and dynamic
palette picks that Storybook injects for isolated stories.
</p>
</header>

<section className="grid gap-3">
<h2 className="text-sm font-semibold">Chrome Preview</h2>
<div className="grid overflow-hidden rounded-lg border border-border bg-app-bg shadow-sm md:grid-cols-[1fr_9rem]">
<div className="grid grid-rows-[auto_1fr]">
<div className="grid grid-cols-2 text-sm font-medium">
<div className="bg-header-active-bg px-3 py-2 text-header-active-fg">
Active terminal
</div>
<div className="bg-header-inactive-bg px-3 py-2 text-header-inactive-fg">
Waiting terminal
</div>
</div>
<div className="grid min-h-44 gap-3 bg-terminal-bg p-4 font-mono text-sm text-terminal-fg ring-2 ring-focus-ring ring-inset">
<div>$ pnpm test</div>
<div className="text-success">resolver defaults materialized</div>
<div className="text-muted">dynamic palette published on body</div>
<div className="text-error">missing tokens render as failures below</div>
</div>
</div>
<aside className="grid content-start gap-2 bg-app-bg p-3">
<div className="rounded-t-lg bg-door-bg px-3 py-2 text-sm font-medium text-door-fg">
Door 1
</div>
<div className="rounded-t-lg bg-door-bg px-3 py-2 text-sm font-medium text-door-fg">
Door 2
</div>
</aside>
</div>
</section>

<section className="grid gap-3">
<h2 className="text-sm font-semibold">Semantic Palette</h2>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{SEMANTIC_VARS.map(([label, token]) => (
<Swatch key={token} label={label} token={token} />
))}
</div>
</section>

<section className="grid gap-3 xl:grid-cols-2">
<div className="grid content-start gap-3">
<h2 className="text-sm font-semibold">Resolved VSCode Variables</h2>
<VarTable rows={HOST_VARS} />
</div>
<div className="grid content-start gap-3">
<h2 className="text-sm font-semibold">MouseTerm Tokens</h2>
<VarTable rows={SEMANTIC_VARS} />
</div>
</section>

<section className="grid gap-3">
<h2 className="text-sm font-semibold">Storybook Dynamic Body Vars</h2>
<VarTable rows={DYNAMIC_BODY_VARS} source="body-style" />
</section>
</div>
</div>
);
Expand Down
12 changes: 10 additions & 2 deletions lib/src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
2 changes: 0 additions & 2 deletions standalone/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
"hiddenTitle": true,
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 600,
"resizable": true
}
],
Expand Down
Loading