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
112 changes: 72 additions & 40 deletions src/renderer/components/terminal/terminalColors.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { serialize: string; raster: [number, number, number, number] }> = {
"#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;
});

Expand All @@ -46,22 +71,33 @@ 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", () => {
expect(resolveTerminalColor("#abc")).toBe("#aabbcc");
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", () => {
Expand All @@ -73,23 +109,19 @@ 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();
expect(resolved).toMatch(/^(#[0-9a-f]{3,8}|rgba?\()/i);
expect(resolved).not.toContain("oklch");
expect(resolved).not.toContain("oklab");
expect(resolved).not.toContain("color-mix");
expect(resolved).not.toContain("color(");
}
});
});
55 changes: 48 additions & 7 deletions src/renderer/components/terminal/terminalColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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);
}
3 changes: 0 additions & 3 deletions src/renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down
Loading