Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
a503731
fix(studio): parse modern CSS colors without a new dependency
jrusso1020 Sep 14, 2026
37e5e3c
refactor(studio): one colour parser for the text toolbar, stricter rg…
miguel-heygen Oct 2, 2026
96bbfc8
refactor(studio): drop comment lines the comment ratchet refuses in c…
miguel-heygen Oct 2, 2026
d9f97c0
fix(studio): linear-time rgb() patterns and no new colour literal in …
miguel-heygen Oct 2, 2026
eaed786
fix(studio): legacy colours resolve in browsers without relative colo…
miguel-heygen Oct 2, 2026
37f7b4a
test(studio): a colour the canvas rejects parses to null, not transpa…
miguel-heygen Oct 2, 2026
3a6d0e4
fix(studio): an inserted gradient stop takes the colour the gradient …
miguel-heygen Oct 2, 2026
1731547
test(studio): keep the fake browser canvas inside its test file
miguel-heygen Oct 2, 2026
646c220
fix(studio): mix an inserted gradient stop in the space the whole gra…
miguel-heygen Oct 2, 2026
fcd1d11
fix(studio): let an unresolved var() stop keep a legacy gradient in sRGB
miguel-heygen Oct 2, 2026
1dc7875
fix(studio): only a bare var() stop leaves the gradient space to its …
miguel-heygen Oct 2, 2026
bcd9cec
fix(studio): pick light-dark() text colour from what the element paints
miguel-heygen Oct 2, 2026
efe8f02
refactor(studio): drop the neighbour clause the whole-gradient check …
miguel-heygen Oct 2, 2026
c61ea52
fix(studio): resolve the picker colour from the element that paints t…
miguel-heygen Oct 2, 2026
27ccd13
fix(studio): read the picker colour from the character the swatch col…
miguel-heygen Oct 2, 2026
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
1 change: 1 addition & 0 deletions packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs",
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
"test:watch": "vitest",
"test:colors:browser": "node tests/e2e/native-css-colors.mjs",
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
"test:timeline-default": "bun run test:timeline-virtualization"
},
Expand Down
38 changes: 11 additions & 27 deletions packages/studio/src/components/editor/InlineTextToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { applyInlineStyle } from "./inlineTextStyleRange";
import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead";
import { parseCssColor, toHexColor } from "./colorValue";
import {
readFirstPaintedElement,
readInlineStyle,
readInlineStyleSpread,
} from "./inlineTextStyleRead";
import { resolvePickerColor } from "./colorValue";
import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit";

/**
Expand Down Expand Up @@ -248,7 +252,11 @@ function placeOverSelection(
placeBelow,
styles,
colours,
pickerColour: toPickerColour(styles.color ?? colours[0], doc),
pickerColour: resolvePickerColor(
styles.color ?? colours[0],
readFirstPaintedElement(range, "color"),
DEFAULT_COLOR,
),
};
}

Expand All @@ -257,27 +265,3 @@ function isBold(weight: string | undefined): boolean {
if (weight === "bold" || weight === "bolder") return true;
return Number.parseInt(weight, 10) >= 600;
}

/** A colour input accepts only `#rrggbb`; normalise any valid CSS colour to it. */
function toPickerColour(value: string | undefined, doc: Document): string {
if (!value) return DEFAULT_COLOR;
const parsed = parseCssColor(value);
if (parsed) return toHexColor(parsed);

// Canvas delegates the full CSS colour grammar to the browser, including
// named colours that the small serialisation parser intentionally omits.
// DOM-only test environments can lack a canvas implementation, in which
// case the picker degrades to its explicit default while the swatch remains
// truthful because CSS still paints the original value.
try {
const context = doc.createElement("canvas").getContext("2d");
if (!context) return DEFAULT_COLOR;
context.fillStyle = DEFAULT_COLOR;
context.fillStyle = value;
const normalised =
typeof context.fillStyle === "string" ? parseCssColor(context.fillStyle) : null;
return normalised ? toHexColor(normalised) : DEFAULT_COLOR;
} catch {
return DEFAULT_COLOR;
}
}
116 changes: 116 additions & 0 deletions packages/studio/src/components/editor/colorBrowserCanvas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { afterEach, describe, expect, it, vi } from "vitest";

const CHROME_147_FILL_STYLE: Record<string, string> = {
transparent: "rgba(0, 0, 0, 0)",
white: "#ffffff",
red: "#ff0000",
currentcolor: "#000000",
"hsl(210 40% 50%)": "#4d80b3",
"rgb(100%, 0%, 0%)": "#ff0000",
"hsl(0 100% 50% / 0.001)": "rgba(255, 0, 0, 0)",
"oklch(0.7 0.15 200)": "oklch(0.7 0.15 200)",
"oklch(0.7 0.15 200 / 0.25)": "oklch(0.7 0.15 200 / 0.25)",
"color-mix(in oklab, oklch(0.7 0.15 200), red 50%)": "oklab(0.663983 0.0419629 0.0372782)",
"color-mix(in srgb, #ff000000, #0000ffff 50%)": "color(srgb 0 0 1 / 0.5)",
};

const CHROME_147_RELATIVE_SRGB: Record<string, string> = {
currentcolor: "color(srgb 0 0 0)",
"hsl(0 100% 50% / 0.001)": "color(srgb 1 0 0 / 0.001)",
"oklch(0.7 0.15 200)": "color(srgb -0.316663 0.724435 0.764448)",
"oklch(0.7 0.15 200 / 0.25)": "color(srgb -0.316663 0.724435 0.764448 / 0.25)",
"color-mix(in oklab, oklch(0.7 0.15 200), red 50%)": "color(srgb 0.698277 0.535308 0.473819)",
"color-mix(in srgb, #ff000000, #0000ffff 50%)": "color(srgb 0 0 1 / 0.5)",
"color-mix(in srgb, hwb(0 0% 0%), hwb(240 0% 0%) 50%)": "color(srgb 0.5 0 0.5)",
"color-mix(in oklab, red, blue 50%)": "color(srgb 0.550402 0.325634 0.636508)",
"color-mix(in srgb, red, blue 50%)": "color(srgb 0.5 0 0.5)",
"light-dark(white, red)": "color(srgb 1 1 1)",
};

async function loadColorModulesWithChromeCanvas() {
let fillStyle = "#000000";
const context = {
get fillStyle() {
return fillStyle;
},
set fillStyle(next: string) {
const relative = /^color\(from (.+) srgb r g b \/ alpha\)$/.exec(next);
fillStyle =
(relative ? CHROME_147_RELATIVE_SRGB[relative[1]] : CHROME_147_FILL_STYLE[next]) ??
fillStyle;
},
};
vi.stubGlobal("CSS", { supports: () => true });
vi.stubGlobal("document", { createElement: () => ({ getContext: () => context }) });
vi.resetModules();
return { ...(await import("./colorValue")), ...(await import("./gradientValue")) };
}

describe("parseCssColor with a browser canvas", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});

it.each([
["red", { red: 255, green: 0, blue: 0, alpha: 1 }],
["hsl(210 40% 50%)", { red: 77, green: 128, blue: 179, alpha: 1 }],
["rgb(100%, 0%, 0%)", { red: 255, green: 0, blue: 0, alpha: 1 }],
["oklch(0.7 0.15 200)", { red: 0, green: 185, blue: 195, alpha: 1 }],
["oklch(0.7 0.15 200 / 0.25)", { red: 0, green: 185, blue: 195, alpha: 0.25 }],
["hsl(0 100% 50% / 0.001)", { red: 255, green: 0, blue: 0, alpha: 0.001 }],
["inherit", null],
["currentcolor", null],
["light-dark(white, red)", null],
])("resolves %s through the canvas, or null if it must not", async (input, expected) => {
const { parseCssColor: parseFresh } = await loadColorModulesWithChromeCanvas();
expect(parseFresh(input)).toEqual(expected);
});
});

describe("insertGradientStop with a browser canvas", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
});

it.each([
["oklch(0.7 0.15 200)", "red", "#B28979"],
["#ff000000", "#0000ffff", "rgba(0, 0, 255, 0.5)"],
])("gives %s to %s the midpoint the browser paints", async (left, right, expected) => {
const { parseGradient: parse, insertGradientStop: insert } =
await loadColorModulesWithChromeCanvas();
const model = parse(`linear-gradient(90deg, ${left} 0%, ${right} 100%)`);
expect(model).not.toBeNull();
expect(insert(model!, 50).stops[1].color).toBe(expected);
});

it.each([
["hwb stops mix in sRGB like rgb()", "hwb(0 0% 0%) 0%, hwb(240 0% 0%) 100%", 50, "#800080"],
[
"one modern stop moves every interval to Oklab",
"red 0%, blue 50%, oklch(0.7 0.15 200) 100%",
25,
"#8C53A2",
],
[
"an unresolved var() stop leaves a legacy interval in sRGB",
"red 0%, blue 50%, var(--third, red) 100%",
25,
"#800080",
],
[
"a modern stop with a var() inside still moves every interval to Oklab",
"red 0%, blue 50%, oklch(var(--l, 0.7) 0.1 200) 100%",
25,
"#8C53A2",
],
])("%s", async (_case, stops, position, expected) => {
const { parseGradient: parse, insertGradientStop: insert } =
await loadColorModulesWithChromeCanvas();
const model = parse(`linear-gradient(90deg, ${stops})`);
expect(model).not.toBeNull();
const inserted = insert(model!, position).stops.find((stop) => stop.position === position);
expect(inserted?.color).toBe(expected);
});
});
16 changes: 16 additions & 0 deletions packages/studio/src/components/editor/colorValue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ describe("parseCssColor", () => {
});
});

it.each([
["#0f172acc", { red: 15, green: 23, blue: 42, alpha: 0.8 }],
["#f008", { red: 255, green: 0, blue: 0, alpha: 136 / 255 }],
["rgb(255 0 0 / 50%)", { red: 255, green: 0, blue: 0, alpha: 0.5 }],
["rgb(100% 0% 0% / 0.001)", { red: 255, green: 0, blue: 0, alpha: 0.001 }],
])("parses %s without a browser", (input, expected) => {
expect(parseCssColor(input)).toEqual(expected);
});

it.each(["", "#12", "notacolor", "currentcolor", "none", "rgb(1..2, 3, 4)", "rgb(1. 2 3)"])(
"rejects %s without a browser",
(input) => {
expect(parseCssColor(input)).toBeNull();
},
);

it("parses transparent", () => {
expect(parseCssColor("transparent")).toEqual({
red: 0,
Expand Down
124 changes: 100 additions & 24 deletions packages/studio/src/components/editor/colorValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,49 +29,125 @@ function formatAlpha(value: number): string {
return `${roundToCenti(clampAlpha(value))}`;
}

export function parseCssColor(value: string): ParsedColor | null {
function parseComponent(value: string, scale: number): number {
return value.endsWith("%") ? (Number(value.slice(0, -1)) * scale) / 100 : Number(value);
}

const NUMBER = String.raw`(?:\d+(?:\.\d+)?|\.\d+)`;
const COMMA_RGB = new RegExp(
String.raw`^rgba?\(\s*(${NUMBER})\s*,\s*(${NUMBER})\s*,\s*(${NUMBER})(?:\s*,\s*(${NUMBER}))?\s*\)$`,
);
const SPACE_RGB = new RegExp(
String.raw`^rgba?\(\s*(${NUMBER}%?)\s+(${NUMBER}%?)\s+(${NUMBER}%?)(?:\s*\/\s*(${NUMBER}%?))?\s*\)$`,
);

function parseSerializedColor(value: string): ParsedColor | null {
const trimmed = value.trim().toLowerCase();
if (!trimmed) return null;
if (trimmed === "transparent") {
return { red: 0, green: 0, blue: 0, alpha: 0 };
}

const shortHex = trimmed.match(/^#([0-9a-f]{3})$/i);
if (shortHex) {
const [r, g, b] = shortHex[1].split("");
return {
red: Number.parseInt(r + r, 16),
green: Number.parseInt(g + g, 16),
blue: Number.parseInt(b + b, 16),
alpha: 1,
};
}

const hex = trimmed.match(/^#([0-9a-f]{6})$/i);
const hex = trimmed.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
if (hex) {
const digits = hex[1].length <= 4 ? [...hex[1]].map((digit) => digit + digit).join("") : hex[1];
return {
red: Number.parseInt(hex[1].slice(0, 2), 16),
green: Number.parseInt(hex[1].slice(2, 4), 16),
blue: Number.parseInt(hex[1].slice(4, 6), 16),
alpha: 1,
red: Number.parseInt(digits.slice(0, 2), 16),
green: Number.parseInt(digits.slice(2, 4), 16),
blue: Number.parseInt(digits.slice(4, 6), 16),
alpha: digits.length === 8 ? Number.parseInt(digits.slice(6, 8), 16) / 255 : 1,
};
}

const rgba = trimmed.match(
/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i,
);
const rgba = trimmed.match(COMMA_RGB) ?? trimmed.match(SPACE_RGB);
if (rgba) {
return {
red: clampChannel(Number.parseFloat(rgba[1])),
green: clampChannel(Number.parseFloat(rgba[2])),
blue: clampChannel(Number.parseFloat(rgba[3])),
alpha: clampAlpha(rgba[4] != null ? Number.parseFloat(rgba[4]) : 1),
red: clampChannel(parseComponent(rgba[1], 255)),
green: clampChannel(parseComponent(rgba[2], 255)),
blue: clampChannel(parseComponent(rgba[3], 255)),
alpha: clampAlpha(rgba[4] != null ? parseComponent(rgba[4], 1) : 1),
};
}

return null;
}

let colorContext: CanvasRenderingContext2D | null = null;

function canResolveInBrowser(value: string): boolean {
return (
typeof document !== "undefined" &&
typeof CSS !== "undefined" &&
CSS.supports("color", value) &&
!/\bcurrentcolor\b|\bvar\s*\(|\blight-dark\s*\(/i.test(value)
);
}

function parseBrowserColor(value: string): ParsedColor | null {
try {
if (!canResolveInBrowser(value)) return null;
colorContext ??= document.createElement("canvas").getContext("2d");
if (!colorContext) return null;
return (
parseCanvasColor(
colorContext,
`color(from ${value} srgb r g b / alpha)`,
parseSrgbSerialization,
) ?? parseCanvasColor(colorContext, value, parseSerializedColor)
);
} catch {
return null;
}
}

function parseCanvasColor(
context: CanvasRenderingContext2D,
value: string,
parse: (serialized: string) => ParsedColor | null,
): ParsedColor | null {
const serialized = readCanvasColor(context, value);
return serialized === null ? null : parse(serialized);
}

function readCanvasColor(context: CanvasRenderingContext2D, value: string): string | null {
context.fillStyle = "transparent";
context.fillStyle = value;
const first = context.fillStyle;
context.fillStyle = "white";
context.fillStyle = value;
return typeof first === "string" && first === context.fillStyle ? first : null;
}

function parseSrgbSerialization(serialized: string): ParsedColor | null {
const match = serialized.match(/^color\(srgb ([^ ]+) ([^ ]+) ([^ /)]+)(?: \/ ([^)]+))?\)$/);
if (!match) return null;
const channels = match.slice(1, 4).map(Number);
const alpha = match[4] === undefined ? 1 : Number(match[4]);
if (!channels.every(Number.isFinite) || !Number.isFinite(alpha)) return null;
return {
red: clampChannel(channels[0] * 255),
green: clampChannel(channels[1] * 255),
blue: clampChannel(channels[2] * 255),
alpha: clampAlpha(alpha),
};
}

export function parseCssColor(value: string): ParsedColor | null {
return parseSerializedColor(value) ?? parseBrowserColor(value.trim());
}

export function resolvePickerColor(
value: string | undefined,
element: Element | null,
fallback: string,
): string {
const view = element?.ownerDocument.defaultView;
const parsed =
(value ? parseCssColor(value) : null) ??
(element && view ? parseCssColor(view.getComputedStyle(element).color) : null);
return parsed ? toHexColor(parsed) : fallback;
}

export function toColorPickerValue(value: string): string {
const parsed = parseCssColor(value);
if (!parsed) return "#000000";
Expand Down
Loading
Loading