diff --git a/src/renderer/components/terminal/terminalColors.test.ts b/src/renderer/components/terminal/terminalColors.test.ts index 70471ce4..02391860 100644 --- a/src/renderer/components/terminal/terminalColors.test.ts +++ b/src/renderer/components/terminal/terminalColors.test.ts @@ -1,43 +1,68 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { resolveTerminalColor } from "./terminalColors"; -// Emulate a browser's CanvasRenderingContext2D.fillStyle: a parseable color is -// stored in a normalized form, while an unparseable value is silently ignored -// (the previous value is kept) — the exact behavior resolveTerminalColor relies -// on. Modern color syntaxes the real engine converts to sRGB hex are mapped to a -// fixed hex; a wide-gamut `color(srgb ...)` parses but stays in that format. -function emulateBrowserParse(input: string): string | null { - const v = input.trim().toLowerCase(); - if (/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(v)) { - if (v.length === 4) { - return `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`; - } - return v; - } - if (/^rgb\(/.test(v)) return v.replace(/\s+/g, "").replace("rgb(", "rgb("); - if (/^rgba\(/.test(v)) return v.replace(/\s+/g, ""); - // oklch()/oklab()/color-mix(...) are in-gamut here → engine yields sRGB hex. - if (/^(oklch|oklab|color-mix)\(/.test(v)) return "#3a6ea5"; - // Wide-gamut color() parses but cannot be downgraded to hex/rgb. - if (/^color\(/.test(v)) return v; - return null; // unparseable: "notacolor", "var(--x)", etc. -} +// These fixtures are EMPIRICALLY MEASURED from a real renderer (Electron 41 / +// Chromium 140), because the resolver's correctness hinges on quirks a hand- +// written mock would get wrong — the bug this guards against was a mock that +// *assumed* `oklch()` serializes to hex. It does not: +// - `fillStyle` echoes modern color spaces back unchanged: `oklch(...)` stays +// `oklch(...)`, `color-mix(in oklab, ...)` becomes `oklab(...)`, wide-gamut +// `color(...)` stays `color(...)`. None are xterm-safe. +// - Opaque `rgb()`/`hsl()`/named colors serialize to `#hex`; `rgba()` stays a +// legacy `rgba()`. Those are xterm-safe and returned verbatim. +// - Only the pixel-readback path (getImageData) collapses a modern color space +// to concrete sRGB bytes. +// Each fixture pairs an input with `serialize` (what `fillStyle` echoes — absent +// ⇒ unparseable, so the setter ignores it) and `raster` (the sRGB pixel a 1×1 +// fill produces). One entry per color keeps the two readings from drifting apart. +const FIXTURES: Record = { + "#000000": { serialize: "#000000", raster: [0, 0, 0, 255] }, + "#ffffff": { serialize: "#ffffff", raster: [255, 255, 255, 255] }, + "#abc": { serialize: "#aabbcc", raster: [170, 187, 204, 255] }, + "#1d4c89": { serialize: "#1d4c89", raster: [29, 76, 137, 255] }, + "rgba(148, 191, 255, 0.24)": { + serialize: "rgba(148, 191, 255, 0.24)", + raster: [146, 192, 255, 61], + }, + "oklch(0.9911 0 0)": { serialize: "oklch(0.9911 0 0)", raster: [252, 252, 252, 255] }, + "oklch(0.2 0.004 286)": { serialize: "oklch(0.2 0.004 286)", raster: [22, 22, 24, 255] }, + "oklch(0.975 0.003 286)": { serialize: "oklch(0.975 0.003 286)", raster: [246, 246, 249, 255] }, + "oklch(0.77 0.08 244)": { serialize: "oklch(0.77 0.08 244)", raster: [136, 186, 228, 255] }, + "color-mix(in oklab, #ffffff 50%, #000000)": { + serialize: "oklab(0.499997 0.0000227839 0.0000100434)", + raster: [99, 99, 99, 255], + }, + "color(srgb 1 0.5 0)": { serialize: "color(srgb 1 0.5 0)", raster: [255, 128, 0, 255] }, +}; let originalGetContext: typeof HTMLCanvasElement.prototype.getContext; beforeAll(() => { originalGetContext = HTMLCanvasElement.prototype.getContext; - let stored = "#000000"; + let serialized = "#000000"; + let rawForRaster = "#000000"; + let pixel: [number, number, number, number] = [0, 0, 0, 255]; const ctx = { get fillStyle() { - return stored; + return serialized; }, set fillStyle(value: string) { - const parsed = emulateBrowserParse(value); - if (parsed !== null) stored = parsed; + const key = String(value).trim().toLowerCase(); + const fixture = FIXTURES[key]; + // Unknown ⇒ unparseable; the real setter keeps the previous value. + if (fixture === undefined) return; + serialized = fixture.serialize; + rawForRaster = key; + }, + clearRect() {}, + fillRect() { + pixel = FIXTURES[rawForRaster]?.raster ?? [0, 0, 0, 255]; + }, + getImageData() { + return { data: Uint8ClampedArray.from(pixel) }; }, }; - // @ts-expect-error -- partial mock; only fillStyle is exercised. + // @ts-expect-error -- partial mock; only the members the resolver touches. HTMLCanvasElement.prototype.getContext = () => ctx; }); @@ -46,13 +71,22 @@ afterAll(() => { }); describe("resolveTerminalColor", () => { - it("converts oklch() to an xterm-safe hex string", () => { - // The crash that motivated this: xterm cannot parse "oklch(0.9911 0 0)". - expect(resolveTerminalColor("oklch(0.9911 0 0)")).toBe("#3a6ea5"); + it("rasterizes oklch() down to an xterm-safe sRGB hex", () => { + // Regression: xterm cannot parse "oklch(...)", and Chromium's fillStyle + // echoes it back unchanged — so it MUST be rasterized, not passed through. + expect(resolveTerminalColor("oklch(0.9911 0 0)")).toBe("#fcfcfc"); + expect(resolveTerminalColor("oklch(0.2 0.004 286)")).toBe("#161618"); + expect(resolveTerminalColor("oklch(0.975 0.003 286)")).toBe("#f6f6f9"); + expect(resolveTerminalColor("oklch(0.77 0.08 244)")).toBe("#88bae4"); }); - it("converts color-mix(in oklab, ...) to an xterm-safe hex string", () => { - expect(resolveTerminalColor("color-mix(in oklab, #ffffff 50%, #000000)")).toBe("#3a6ea5"); + it("rasterizes color-mix(in oklab, ...) down to an xterm-safe sRGB hex", () => { + // Serializes to oklab(...), which xterm also cannot parse. + expect(resolveTerminalColor("color-mix(in oklab, #ffffff 50%, #000000)")).toBe("#636363"); + }); + + it("rasterizes a wide-gamut color() down to clamped sRGB", () => { + expect(resolveTerminalColor("color(srgb 1 0.5 0)")).toBe("#ff8000"); }); it("passes through and normalizes plain hex colors", () => { @@ -60,8 +94,10 @@ describe("resolveTerminalColor", () => { expect(resolveTerminalColor("#1d4c89")).toBe("#1d4c89"); }); - it("passes through rgb()/rgba() colors", () => { - expect(resolveTerminalColor("rgba(148, 191, 255, 0.24)")).toBe("rgba(148,191,255,0.24)"); + it("passes through rgba() verbatim, preserving exact alpha", () => { + // Fast path: the serialization is already xterm-safe, so we keep it rather + // than rasterizing (round-tripping alpha through a pixel is lossy). + expect(resolveTerminalColor("rgba(148, 191, 255, 0.24)")).toBe("rgba(148, 191, 255, 0.24)"); }); it("returns null for an empty value so callers fall back", () => { @@ -73,16 +109,11 @@ describe("resolveTerminalColor", () => { expect(resolveTerminalColor("notacolor")).toBeNull(); }); - it("returns null for a parseable-but-xterm-unsafe wide-gamut color()", () => { - // Guards against handing xterm a format its parser still cannot read. - expect(resolveTerminalColor("color(srgb 1 0.5 0)")).toBeNull(); - }); - it("never returns a value containing modern color syntax", () => { for (const input of [ "oklch(0.77 0.08 244)", - "oklch(0.19 0.004 286)", - "color-mix(in oklab, oklch(0.2 0.004 286) 50%, #000000)", + "oklch(0.2 0.004 286)", + "color-mix(in oklab, #ffffff 50%, #000000)", ]) { const resolved = resolveTerminalColor(input); expect(resolved).not.toBeNull(); @@ -90,6 +121,7 @@ describe("resolveTerminalColor", () => { expect(resolved).not.toContain("oklch"); expect(resolved).not.toContain("oklab"); expect(resolved).not.toContain("color-mix"); + expect(resolved).not.toContain("color("); } }); }); diff --git a/src/renderer/components/terminal/terminalColors.ts b/src/renderer/components/terminal/terminalColors.ts index 3043365b..fa8cbe56 100644 --- a/src/renderer/components/terminal/terminalColors.ts +++ b/src/renderer/components/terminal/terminalColors.ts @@ -4,8 +4,19 @@ // base themes and `color-mix(in oklab, ...)` for presets — which xterm cannot // parse and which crashes the glyph rasterizer with // "Unexpected fillStyle color format". These helpers let the browser resolve any -// CSS color string down to an xterm-safe `#hex`/`rgba()` value via a 2D canvas -// context. +// CSS color string down to an xterm-safe `#hex`/`rgba()` value. +// +// Resolution can't lean on the canvas `fillStyle` round-trip alone: modern +// Chromium (verified on Electron 41 / Chromium 140) serializes `fillStyle` back +// in the *authored* color space, so `oklch(...)` echoes `oklch(...)` and +// `color-mix(in oklab, ...)` echoes `oklab(...)` — neither xterm-safe. (Opaque +// `rgb()`/`hsl()`/named colors *do* serialize to `#hex`, and `rgba()` to a legacy +// `rgba()`.) So we take the serialization when it's already safe, and otherwise +// rasterize a single pixel and read back its concrete sRGB bytes via +// `getImageData` — the one path that always collapses any color space to plain +// sRGB. Callers fall back to fixed light/dark values when this returns `null`. + +import { toHex } from "@/renderer/theme/colorMath"; let colorResolverCtx: CanvasRenderingContext2D | null = null; @@ -14,17 +25,42 @@ function getColorResolverCtx(): CanvasRenderingContext2D | null { // transient failure (or a test that installs a canvas mock after first use) // doesn't permanently disable resolution. if (!colorResolverCtx && typeof document !== "undefined") { - colorResolverCtx = document.createElement("canvas").getContext("2d"); + // `willReadFrequently` keeps the canvas CPU-backed so the rasterize path's + // `getImageData` readback doesn't pay a GPU round-trip. + colorResolverCtx = document + .createElement("canvas") + .getContext("2d", { willReadFrequently: true }); } return colorResolverCtx; } +/** + * Rasterize a known-parseable color to a 1×1 pixel and read it back as a plain + * sRGB `#hex` (opaque) or `rgba()` (translucent) string. `clearRect` first so a + * translucent fill composites over transparent, not over the previous pixel. + * Returns `null` if the readback fails (e.g. a tainted/unavailable context). + */ +function rasterizeColor(ctx: CanvasRenderingContext2D, value: string): string | null { + try { + ctx.fillStyle = value; + ctx.clearRect(0, 0, 1, 1); + ctx.fillRect(0, 0, 1, 1); + const [r = 0, g = 0, b = 0, a = 255] = ctx.getImageData(0, 0, 1, 1).data; + if (a === 255) { + return toHex([r, g, b]); + } + // Round alpha to 3 decimals so the 0–255 → 0–1 division doesn't leak float noise. + return `rgba(${r}, ${g}, ${b}, ${Number((a / 255).toFixed(3))})`; + } catch { + return null; + } +} + /** * Resolve an arbitrary CSS color string (`oklch(...)`, `color-mix(...)`, hex, * `rgb()`, named, ...) to a hex or `rgb(a)` string that xterm's color parser can - * understand. Returns `null` when the value is empty, unparseable, or resolves - * to a format xterm still wouldn't understand (e.g. a wide-gamut - * `color(srgb ...)`), so callers can fall back to a known-safe color. + * understand. Returns `null` when the value is empty or unparseable, so callers + * can fall back to a known-safe color. */ export function resolveTerminalColor(value: string): string | null { if (!value) return null; @@ -41,5 +77,10 @@ export function resolveTerminalColor(value: string): string | null { ctx.fillStyle = value; const second = ctx.fillStyle; if (first !== second) return null; - return /^#[0-9a-f]{3,8}$/i.test(first) || /^rgba?\(/i.test(first) ? first : null; + // Fast path: the serialization is already xterm-safe (hex / rgb / rgba). Use + // it verbatim so translucent `rgba()` keeps its exact channels (rasterizing + // round-trips alpha lossily). Otherwise it's a modern color space xterm can't + // read — rasterize the verified-parseable value down to sRGB. + if (/^#[0-9a-f]{3,8}$/i.test(first) || /^rgba?\(/i.test(first)) return first; + return rasterizeColor(ctx, value); } diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 56a122a2..2b10c1ee 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -791,7 +791,6 @@ --content-background: var(--background); --window-header-background: var(--sidebar-background); --window-overlay-background: rgba(0, 0, 0, 0); - --terminal-surface: var(--surface); --composer-surface: var(--surface); --interactive-cursor: default; @@ -843,7 +842,6 @@ --sidebar-background: oklch(0.945 0.004 286); --content-background: oklch(0.975 0.003 286); --window-header-background: var(--sidebar-background); - --terminal-surface: oklch(0.967 0.003 286); --composer-surface: oklch(0.982 0.002 286); } @@ -874,7 +872,6 @@ --sidebar-background: oklch(0.22 0.004 286); --content-background: oklch(0.2 0.004 286); --window-header-background: var(--sidebar-background); - --terminal-surface: oklch(0.155 0.004 286); --composer-surface: oklch(0.25 0.004 286); } diff --git a/src/renderer/theme/themePresets.ts b/src/renderer/theme/themePresets.ts index 1a7b1274..1a2d3446 100644 --- a/src/renderer/theme/themePresets.ts +++ b/src/renderer/theme/themePresets.ts @@ -50,7 +50,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ border: "#cacace", sidebar: "#ececef", content: "#f6f6f9", - terminal: "#f4f4f6", }, dark: { bg: "#141416", @@ -61,7 +60,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ border: "#303033", sidebar: "#1a1a1c", content: "#161618", - terminal: "#0c0c0e", }, }, @@ -78,7 +76,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#ffffff", border: "#bcc0cc", sidebar: "#e6e9ef", - terminal: "#dce0e8", }, dark: { bg: "#1e1e2e", @@ -89,7 +86,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#1e1e2e", border: "#313244", sidebar: "#181825", - terminal: "#181825", }, }, @@ -106,7 +102,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ border: "#d0d7de", sidebar: "#f6f8fa", content: "#ffffff", - terminal: "#f6f8fa", }, dark: { bg: "#0d1117", @@ -116,7 +111,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#ffffff", border: "#30363d", sidebar: "#0d1117", - terminal: "#010409", }, }, @@ -132,7 +126,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#ffffff", border: "#e5e5e6", sidebar: "#eaeaeb", - terminal: "#f0f0f0", }, dark: { bg: "#282c34", @@ -143,7 +136,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#282c34", border: "#3b4048", sidebar: "#21252b", - terminal: "#21252b", }, }, @@ -159,7 +151,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#ffffff", border: "#d4cfc0", sidebar: "#f3eedd", - terminal: "#f3eedd", }, dark: { bg: "#282a36", @@ -169,7 +160,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#282a36", border: "#44475a", sidebar: "#21222c", - terminal: "#21222c", }, }, @@ -185,7 +175,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#ffffff", border: "#d8dee9", sidebar: "#e5e9f0", - terminal: "#e5e9f0", }, dark: { bg: "#2e3440", @@ -196,7 +185,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#2e3440", border: "#434c5e", sidebar: "#2b303b", - terminal: "#272c36", }, }, @@ -213,7 +201,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#ffffff", border: "#c4c8da", sidebar: "#d6d8df", - terminal: "#d6d8df", }, dark: { bg: "#1a1b26", @@ -224,7 +211,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#1a1b26", border: "#292e42", sidebar: "#16161e", - terminal: "#16161e", }, }, @@ -240,7 +226,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#fbf1c7", border: "#d5c4a1", sidebar: "#ebdbb2", - terminal: "#ebdbb2", }, dark: { bg: "#282828", @@ -251,7 +236,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#282828", border: "#504945", sidebar: "#1d2021", - terminal: "#1d2021", }, }, @@ -269,7 +253,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#fdf6e3", border: "#ddd6c1", sidebar: "#eee8d5", - terminal: "#eee8d5", }, dark: { bg: "#002b36", @@ -279,7 +262,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#002b36", border: "#0a4a5a", sidebar: "#002028", - terminal: "#002028", }, }, @@ -296,7 +278,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#ffffff", border: "#dfdad9", sidebar: "#f2e9e1", - terminal: "#f2e9e1", }, dark: { bg: "#232136", @@ -306,7 +287,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#232136", border: "#44415a", sidebar: "#1f1d2e", - terminal: "#1f1d2e", }, }, @@ -326,7 +306,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#ffffff", border: "#e0dcc7", sidebar: "#efebd4", - terminal: "#efebd4", }, dark: { bg: "#2d353b", @@ -337,7 +316,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#2d353b", border: "#475258", sidebar: "#272e33", - terminal: "#272e33", }, }, @@ -353,7 +331,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#ffffff", border: "#e4e3da", sidebar: "#f1f1ea", - terminal: "#f1f1ea", }, dark: { bg: "#272822", @@ -363,7 +340,6 @@ export const THEME_SPECS: AppThemeSpec[] = [ accentFg: "#ffffff", border: "#3e3d32", sidebar: "#1d1e19", - terminal: "#1d1e19", }, }, ]; diff --git a/src/renderer/theme/themeTokens.ts b/src/renderer/theme/themeTokens.ts index d4d5a926..284dd8de 100644 --- a/src/renderer/theme/themeTokens.ts +++ b/src/renderer/theme/themeTokens.ts @@ -35,8 +35,6 @@ export interface ThemeSpec { sidebar?: string; /** Optional explicit content-area background. Defaults to `bg`. */ content?: string; - /** Optional explicit agent-terminal background. Defaults to `bg`. */ - terminal?: string; } /** @@ -86,7 +84,6 @@ export const MANAGED_THEME_VARS = [ "--separator", "--sidebar-background", "--content-background", - "--terminal-surface", "--composer-surface", ] as const; @@ -101,7 +98,6 @@ export function buildVariant(spec: ThemeSpec, mode: "light" | "dark"): ThemeVari const { bg, surface, fg, accent, accentFg, border } = spec; const sidebar = spec.sidebar ?? surface; const content = spec.content ?? bg; - const terminal = spec.terminal ?? bg; // Derive readable secondary text from fg→bg with a contrast floor. Placeholder // sits a step dimmer than muted; the scrollbar is a translucent muted. @@ -146,7 +142,6 @@ export function buildVariant(spec: ThemeSpec, mode: "light" | "dark"): ThemeVari "--separator": fade(border, 75), "--sidebar-background": sidebar, "--content-background": content, - "--terminal-surface": terminal, "--composer-surface": mix(surface, 90, fg), }; }