diff --git a/src/components/SkillDiffCard.test.tsx b/src/components/SkillDiffCard.test.tsx
index 7451d2e0..4d6502d9 100644
--- a/src/components/SkillDiffCard.test.tsx
+++ b/src/components/SkillDiffCard.test.tsx
@@ -1,13 +1,16 @@
/* @vitest-environment jsdom */
+import type { Monaco } from "@monaco-editor/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useEffect } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Doc, Id } from "../../convex/_generated/dataModel";
-import { SkillDiffCard } from "./SkillDiffCard";
+import { SkillDiffCard, cssColor4ToHex } from "./SkillDiffCard";
const getFilePreviewMock = vi.fn();
let diffEditorMounts = 0;
let diffEditorUnmounts = 0;
+// oxlint-disable-next-line typescript/no-redundant-type-constituents -- Monaco resolves to any via @monaco-editor/react types
+let monacoInstanceMock: Monaco | null = null;
vi.mock("convex/react", () => ({
useAction: () => getFilePreviewMock,
@@ -25,7 +28,7 @@ vi.mock("@monaco-editor/react", () => ({
className?: string;
options?: { renderSideBySide?: boolean; useInlineViewWhenSpaceIsLimited?: boolean };
}) => ,
- useMonaco: () => null,
+ useMonaco: () => monacoInstanceMock,
}));
function MockDiffEditor({
@@ -91,6 +94,11 @@ describe("SkillDiffCard", () => {
getFilePreviewMock.mockResolvedValue({ text: "content" });
diffEditorMounts = 0;
diffEditorUnmounts = 0;
+ monacoInstanceMock = null;
+ document.documentElement.removeAttribute("data-theme-resolved");
+ for (const property of ["--oc-bg-elevated", "--oc-text-primary", "--oc-text-secondary"]) {
+ document.documentElement.style.removeProperty(property);
+ }
});
it("defaults to inline mode on narrow screens", async () => {
@@ -185,4 +193,109 @@ describe("SkillDiffCard", () => {
).toBeTruthy();
expect(screen.queryByTestId("diff-editor")).toBeNull();
});
+
+ it("passes only Monaco-safe colors to defineTheme when tokens use CSS Color 4", async () => {
+ installMatchMedia(false);
+ // Sanity guard: if jsdom stops resolving custom properties through
+ // getComputedStyle, this test must fail loudly instead of asserting fallbacks.
+ document.documentElement.style.setProperty("--oc-bg-elevated", "oklch(0.205 0 0)");
+ document.documentElement.style.setProperty("--oc-text-primary", "oklch(0.985 0 0)");
+ document.documentElement.style.setProperty("--oc-text-secondary", "lab(98.26% 0 0)");
+ expect(getComputedStyle(document.documentElement).getPropertyValue("--oc-text-primary")).toBe(
+ "oklch(0.985 0 0)",
+ );
+
+ const defineTheme = vi.fn();
+ monacoInstanceMock = {
+ editor: { defineTheme, setTheme: vi.fn() },
+ } as unknown as Monaco;
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(defineTheme).toHaveBeenCalled();
+ });
+ const themeData = defineTheme.mock.calls[0][1] as {
+ rules: { foreground: string }[];
+ colors: Record;
+ };
+ for (const rule of themeData.rules) {
+ expect(rule.foreground).toMatch(/^#[0-9a-f]{6,8}$/i);
+ }
+ expect(themeData.rules[0].foreground).toBe("#fafafa");
+ expect(themeData.rules[1].foreground).toBe("#fafafa");
+ expect(themeData.colors["editor.background"]).toBe("#171717");
+ expect(themeData.colors["editor.foreground"]).toBe("#fafafa");
+ });
+
+ it("falls back to theme-safe colors when a token uses syntax Monaco cannot parse", async () => {
+ installMatchMedia(false);
+ document.documentElement.dataset.themeResolved = "dark";
+ document.documentElement.style.setProperty("--oc-text-primary", "color(display-p3 1 1 1)");
+
+ const defineTheme = vi.fn();
+ monacoInstanceMock = {
+ editor: { defineTheme, setTheme: vi.fn() },
+ } as unknown as Monaco;
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(defineTheme).toHaveBeenCalled();
+ });
+ const themeData = defineTheme.mock.calls[0][1] as {
+ rules: { foreground: string }[];
+ };
+ expect(themeData.rules[0].foreground).toBe("#fafafa");
+ });
+});
+
+describe("cssColor4ToHex", () => {
+ // Expected values computed independently from the CSS Color 4 conversion
+ // matrices (Ottosson's OKLab; CIE Lab with D50 white and Bradford adaptation).
+ it.each([
+ ["oklch(0.985 0 0)", "#fafafa"],
+ ["oklab(0.985 0 0)", "#fafafa"],
+ ["lab(98.26% 0 0)", "#fafafa"],
+ ["lch(98.26% 0 0)", "#fafafa"],
+ ["lab(0% 0 0)", "#000000"],
+ ["oklch(1 0 0)", "#ffffff"],
+ ["oklch(0.7 0.15 180)", "#00bca2"],
+ ["oklch(0.6 0.2 30)", "#de3e2d"],
+ ["lab(60% 40 -30)", "#c175c7"],
+ ["lch(60% 50 250)", "#009ce3"],
+ ["oklch(0.87 0 0)", "#d4d4d4"],
+ ["oklch(0.205 0 0)", "#171717"],
+ ["lab(50% 20% -20%)", "#9168a2"],
+ ])("converts %s to %s", (input, expected) => {
+ expect(cssColor4ToHex(input)).toBe(expected);
+ });
+
+ it("keeps an alpha channel as a fourth hex pair", () => {
+ expect(cssColor4ToHex("oklch(0 0 0 / 0.3)")).toBe("#0000004d");
+ expect(cssColor4ToHex("oklch(0 0 0 / 30%)")).toBe("#0000004d");
+ });
+
+ it("returns null for syntaxes it does not convert", () => {
+ expect(cssColor4ToHex("color(display-p3 1 0 0)")).toBeNull();
+ expect(cssColor4ToHex("hsl(120 50% 50%)")).toBeNull();
+ expect(cssColor4ToHex("oklch(0.5 0.1 0.5turn)")).toBeNull();
+ expect(cssColor4ToHex("rebeccapurple")).toBeNull();
+ });
});
diff --git a/src/components/SkillDiffCard.tsx b/src/components/SkillDiffCard.tsx
index 52416ca4..7aea18d3 100644
--- a/src/components/SkillDiffCard.tsx
+++ b/src/components/SkillDiffCard.tsx
@@ -744,28 +744,37 @@ function buildDiffOptions(
function applyMonacoTheme(monaco: NonNullable>) {
const styles = getComputedStyle(document.documentElement);
- const surface = normalizeHex(styles.getPropertyValue("--oc-bg-elevated").trim() || "#ffffff");
- const surfaceMuted = styles.getPropertyValue("--oc-bg-surface").trim() || "#f6f1ec";
- const ink = styles.getPropertyValue("--oc-text-primary").trim() || "#1d1a17";
- const inkSoft = styles.getPropertyValue("--oc-text-secondary").trim() || "#4c463f";
- const line = styles.getPropertyValue("--oc-border-subtle").trim() || "rgba(29, 26, 23, 0.12)";
- const accent = styles.getPropertyValue("--oc-accent-primary").trim() || "#e65c46";
- const seafoam = styles.getPropertyValue("--oc-accent-secondary").trim() || "#2bc6a4";
- const diffAdded = styles.getPropertyValue("--oc-diff-added").trim() || "#9bb955";
- const diffAddedStrong = styles.getPropertyValue("--oc-diff-added-strong").trim() || seafoam;
- const diffRemoved = styles.getPropertyValue("--oc-diff-removed").trim() || "#e47866";
- const diffRemovedStrong = styles.getPropertyValue("--oc-diff-removed-strong").trim() || accent;
+ const isDark = isDarkThemeResolved();
+ const surface = monacoColor(styles.getPropertyValue("--oc-bg-elevated"), "#ffffff");
+ const surfaceMuted = monacoColor(styles.getPropertyValue("--oc-bg-surface"), "#f6f1ec");
+ const ink = monacoColor(
+ styles.getPropertyValue("--oc-text-primary"),
+ isDark ? "#fafafa" : "#1d1a17",
+ );
+ const inkSoft = monacoColor(
+ styles.getPropertyValue("--oc-text-secondary"),
+ isDark ? "#d4d4d4" : "#4c463f",
+ );
+ const line = monacoColor(styles.getPropertyValue("--oc-border-subtle"), "rgba(29, 26, 23, 0.12)");
+ const accent = monacoColor(styles.getPropertyValue("--oc-accent-primary"), "#e65c46");
+ const seafoam = monacoColor(styles.getPropertyValue("--oc-accent-secondary"), "#2bc6a4");
+ const diffAdded = monacoColor(styles.getPropertyValue("--oc-diff-added"), "#9bb955");
+ const diffAddedStrong = monacoColor(styles.getPropertyValue("--oc-diff-added-strong"), seafoam);
+ const diffRemoved = monacoColor(styles.getPropertyValue("--oc-diff-removed"), "#e47866");
+ const diffRemovedStrong = monacoColor(
+ styles.getPropertyValue("--oc-diff-removed-strong"),
+ accent,
+ );
const diffDiagonal = toMonacoColor(
- styles.getPropertyValue("--diff-diagonal").trim() || "#22222233",
+ monacoColor(styles.getPropertyValue("--diff-diagonal"), "#22222233"),
+ );
+ const diffBorder = toMonacoColor(monacoColor(styles.getPropertyValue("--diff-border"), line));
+ const lineNumber = monacoColor(
+ styles.getPropertyValue("--diff-line-number").trim() || styles.getPropertyValue("--ink-soft"),
+ "#4c463f",
);
- const diffBorder = toMonacoColor(styles.getPropertyValue("--diff-border").trim() || line);
- const lineNumber =
- styles.getPropertyValue("--diff-line-number").trim() ||
- styles.getPropertyValue("--ink-soft").trim() ||
- "#4c463f";
const background = surface;
const gutter = surfaceMuted;
- const isDark = isDarkThemeResolved();
const base = isDark ? "vs-dark" : "vs";
const diffInserted = withAlpha(diffAdded, isDark ? 0.26 : 0.14);
@@ -861,3 +870,132 @@ function toMonacoColor(color: string) {
.map((value) => Math.round(value).toString(16).padStart(2, "0"))
.join("")}${channel}`;
}
+
+// Monaco's theme parsers reject CSS Color 4 functions such as oklch() and lab(),
+// which the Carapace themes write into the --oc-* tokens this component reads
+// (#3440). monacoColor resolves a token to a Monaco-safe #hex color and swaps in
+// the fallback when the syntax is unknown, so defineTheme() never sees a value
+// it cannot parse.
+function monacoColor(rawValue: string, fallback: string): string {
+ const value = rawValue.trim();
+ if (!value) return fallback;
+ const hex = normalizeHex(value);
+ if (hex.startsWith("#")) return hex;
+ const rgbHex = toMonacoColor(value);
+ if (rgbHex.startsWith("#")) return rgbHex;
+ return cssColor4ToHex(value) ?? fallback;
+}
+
+type LinearSrgb = { r: number; g: number; b: number };
+
+// Converts a lab(), lch(), oklab() or oklch() color to #rrggbb (or #rrggbbaa
+// when it carries an alpha). Returns null for anything else so callers can
+// fall back instead of forwarding an unparseable value to Monaco.
+export function cssColor4ToHex(value: string): string | null {
+ const match = /^(ok)?(lab|lch)\(([^)]+)\)$/i.exec(value.trim());
+ if (!match) return null;
+ const isOk = Boolean(match[1]);
+ const isPolar = match[2].toLowerCase() === "lch";
+ const [componentsRaw, alphaRaw] = match[3].split("/");
+ const parts = componentsRaw.trim().split(/\s+/);
+ if (parts.length !== 3) return null;
+
+ // Percentages resolve per CSS Color 4: lightness against 100 (lab) or 1
+ // (oklab), a/b against 125 (lab) or 0.4 (oklab), chroma against 150 (lch)
+ // or 0.4 (oklch).
+ const lightness = parseColor4Component(parts[0], isOk ? 1 : 100);
+ const second = parseColor4Component(parts[1], isPolar ? (isOk ? 0.4 : 150) : isOk ? 0.4 : 125);
+ if (lightness === null || second === null) return null;
+
+ let a: number;
+ let b: number;
+ if (isPolar) {
+ const hue = parseHue(parts[2]);
+ if (hue === null) return null;
+ a = second * Math.cos((hue * Math.PI) / 180);
+ b = second * Math.sin((hue * Math.PI) / 180);
+ } else {
+ const third = parseColor4Component(parts[2], isOk ? 0.4 : 125);
+ if (third === null) return null;
+ a = second;
+ b = third;
+ }
+
+ let alpha = 1;
+ if (alphaRaw !== undefined) {
+ const parsedAlpha = parseColor4Component(alphaRaw, 1);
+ if (parsedAlpha === null) return null;
+ alpha = Math.min(1, Math.max(0, parsedAlpha));
+ }
+
+ const rgb = isOk ? oklabToLinearSrgb(lightness, a, b) : labToLinearSrgb(lightness, a, b);
+ const hex = `#${linearToSrgbHex(rgb.r)}${linearToSrgbHex(rgb.g)}${linearToSrgbHex(rgb.b)}`;
+ if (alpha >= 1) return hex;
+ return `${hex}${Math.round(alpha * 255)
+ .toString(16)
+ .padStart(2, "0")}`;
+}
+
+function parseColor4Component(raw: string, percentScale: number): number | null {
+ const value = raw.trim();
+ if (value === "none") return 0;
+ if (value.endsWith("%")) {
+ const parsed = Number.parseFloat(value.slice(0, -1));
+ return Number.isNaN(parsed) ? null : (parsed / 100) * percentScale;
+ }
+ const parsed = Number.parseFloat(value);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+function parseHue(raw: string): number | null {
+ const value = raw.trim();
+ if (value === "none") return 0;
+ // Only plain degrees are resolved; turn/rad/grad fall back rather than
+ // converting with the wrong unit.
+ if (/[a-z%]/i.test(value) && !/deg$/i.test(value)) return null;
+ const parsed = Number.parseFloat(value);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+function oklabToLinearSrgb(lightness: number, a: number, b: number): LinearSrgb {
+ const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b;
+ const mPrime = lightness - 0.1055613458 * a - 0.0638541729 * b;
+ const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b;
+ const l = lPrime ** 3;
+ const m = mPrime ** 3;
+ const s = sPrime ** 3;
+ return {
+ r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+ g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+ b: -0.0041960863 * l - 0.7034186149 * m + 1.707614701 * s,
+ };
+}
+
+function labToLinearSrgb(lightness: number, a: number, b: number): LinearSrgb {
+ const fy = (lightness + 16) / 116;
+ const fx = fy + a / 500;
+ const fz = fy - b / 200;
+ const delta = 6 / 29;
+ const toXyz = (f: number) => (f > delta ? f ** 3 : 3 * delta * delta * (f - 4 / 29));
+ // Lab is defined against the D50 white point.
+ const x = toXyz(fx) * 0.96422;
+ const y = toXyz(fy);
+ const z = toXyz(fz) * 0.82521;
+ // Bradford chromatic adaptation from D50 to sRGB's D65.
+ const xd = 0.9555766 * x - 0.0230393 * y + 0.0631636 * z;
+ const yd = -0.0282895 * x + 1.0099416 * y + 0.0210077 * z;
+ const zd = 0.0122982 * x - 0.020483 * y + 1.3299098 * z;
+ return {
+ r: 3.2404542 * xd - 1.5371385 * yd - 0.4985314 * zd,
+ g: -0.969266 * xd + 1.8760108 * yd + 0.041556 * zd,
+ b: 0.0556434 * xd - 0.2040259 * yd + 1.0572252 * zd,
+ };
+}
+
+function linearToSrgbHex(channel: number): string {
+ const clamped = Math.min(1, Math.max(0, channel));
+ const srgb = clamped <= 0.0031308 ? 12.92 * clamped : 1.055 * clamped ** (1 / 2.4) - 0.055;
+ return Math.round(srgb * 255)
+ .toString(16)
+ .padStart(2, "0");
+}