diff --git a/packages/studio/package.json b/packages/studio/package.json index ed578630c1..d55db2d58f 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -52,6 +52,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" }, diff --git a/packages/studio/src/components/editor/colorValue.test.ts b/packages/studio/src/components/editor/colorValue.test.ts index fe0cb0b97d..b3cd2eefb2 100644 --- a/packages/studio/src/components/editor/colorValue.test.ts +++ b/packages/studio/src/components/editor/colorValue.test.ts @@ -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)"])( + "rejects %s without a browser", + (input) => { + expect(parseCssColor(input)).toBeNull(); + }, + ); + it("parses transparent", () => { expect(parseCssColor("transparent")).toEqual({ red: 0, diff --git a/packages/studio/src/components/editor/colorValue.ts b/packages/studio/src/components/editor/colorValue.ts index b2cb7bef8b..4a2a6ee4ef 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -29,49 +29,94 @@ 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); +} + +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})$/i); 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( + /^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/, + ) ?? + trimmed.match(/^rgba?\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+%?)(?:\s*\/\s*([\d.]+%?))?\s*\)$/); 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 the browser convert CSS Color 4 to sRGB without rasterizing a pixel. +// Pixel readback would quantize alpha and lose RGB entirely for transparent colors. +let colorContext: CanvasRenderingContext2D | null = null; + +function parseBrowserColor(value: string): ParsedColor | null { + if ( + typeof document === "undefined" || + typeof CSS === "undefined" || + !CSS.supports("color", value) || + /\bcurrentcolor\b|\bvar\s*\(/i.test(value) + ) + return null; + try { + colorContext ??= document.createElement("canvas").getContext("2d"); + if (!colorContext) return null; + // Invalid assignments leave fillStyle unchanged. Reset it on every call so + // invalid input cannot accidentally reuse the previous valid color. + colorContext.fillStyle = "#000000"; + colorContext.fillStyle = `color(from ${value} srgb r g b / alpha)`; + return parseSrgbSerialization(colorContext.fillStyle); + } catch { + // Server rendering and DOM-only tests may not provide a canvas context. + return null; + } +} + +function parseSrgbSerialization( + serialized: string | CanvasGradient | CanvasPattern, +): ParsedColor | null { + if (typeof serialized !== "string") return 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 { + const parsed = parseSerializedColor(value); + if (parsed && Object.values(parsed).every(Number.isFinite)) return parsed; + return parseBrowserColor(value.trim()); +} + export function toColorPickerValue(value: string): string { const parsed = parseCssColor(value); if (!parsed) return "#000000"; diff --git a/packages/studio/src/components/editor/gradientValue.ts b/packages/studio/src/components/editor/gradientValue.ts index 95f9c5a138..0f4f0bd11e 100644 --- a/packages/studio/src/components/editor/gradientValue.ts +++ b/packages/studio/src/components/editor/gradientValue.ts @@ -1,4 +1,5 @@ import { roundToCenti } from "../../utils/rounding"; +import { parseCssColor } from "./colorValue"; export type GradientKind = "linear" | "radial" | "conic"; @@ -384,8 +385,8 @@ function interpolateGradientStopColor(model: GradientModel, position: number): s const leftColor = left.color; const rightColor = right.color; - const leftParsed = leftColor ? parseColorString(leftColor) : null; - const rightParsed = rightColor ? parseColorString(rightColor) : null; + const leftParsed = leftColor ? parseCssColor(leftColor) : null; + const rightParsed = rightColor ? parseCssColor(rightColor) : null; if (!leftParsed || !rightParsed) return left.color; const ratio = (clampedPosition - left.position) / Math.max(1, right.position - left.position); @@ -412,34 +413,3 @@ export function insertGradientStop(model: GradientModel, position: number): Grad stops: nextStops, }; } - -function parseColorString( - value: string, -): { red: number; green: number; blue: number; alpha: number } | null { - const trimmed = value.trim().toLowerCase(); - if (trimmed === "transparent") { - return { red: 0, green: 0, blue: 0, alpha: 0 }; - } - - const hex = trimmed.match(/^#([0-9a-f]{6})$/i); - if (hex) { - 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, - }; - } - - const rgba = trimmed.match( - /^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i, - ); - if (!rgba) return null; - - return { - red: Number.parseFloat(rgba[1]), - green: Number.parseFloat(rgba[2]), - blue: Number.parseFloat(rgba[3]), - alpha: rgba[4] != null ? Number.parseFloat(rgba[4]) : 1, - }; -} diff --git a/packages/studio/tests/e2e/native-css-colors.mjs b/packages/studio/tests/e2e/native-css-colors.mjs new file mode 100644 index 0000000000..734efe5c46 --- /dev/null +++ b/packages/studio/tests/e2e/native-css-colors.mjs @@ -0,0 +1,129 @@ +// Run with: node packages/studio/tests/e2e/native-css-colors.mjs +// Uses real browser CSS parsing; happy-dom cannot verify these conversions. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import puppeteer from "puppeteer-core"; +import { resolveChromeExecutable } from "./chrome-executable.mjs"; + +const output = mkdtempSync(join(tmpdir(), "native-css-colors-")); +let browser; +try { + execFileSync( + "bun", + [ + "build", + "packages/studio/src/components/editor/colorValue.ts", + "packages/studio/src/components/editor/gradientValue.ts", + "--target", + "browser", + "--outdir", + output, + ], + { cwd: resolve(dirname(fileURLToPath(import.meta.url)), "../../../..") }, + ); + const moduleUrl = (name) => + `data:text/javascript;base64,${readFileSync(join(output, `${name}.js`)).toString("base64")}`; + browser = await puppeteer.launch({ + executablePath: resolveChromeExecutable(), + headless: true, + pipe: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + const page = await browser.newPage(); + const results = await page.evaluate( + async (colorUrl, gradientUrl) => { + const { parseCssColor, toColorPickerValue, mergeColorWithExistingAlpha } = await import( + colorUrl + ); + const { parseGradient, insertGradientStop } = await import(gradientUrl); + const inputs = [ + "white", + "rebeccapurple", + "#0f172acc", + "rgb(255 0 0 / 50%)", + "hsl(210 40% 50%)", + "color(srgb 0.4 0 0.6)", + "oklch(0.7 0.15 200)", + "oklab(0.6 0.1 0.1)", + "lab(100 0 0)", + "color(display-p3 1 0 0)", + "oklch(0.7 0.15 200 / 0)", + "color(srgb 0.4 0 0.6 / 0.001)", + "color-mix(in srgb, red 40%, blue)", + "notacolor", + "#12", + "currentcolor", + "none", + "var(--color)", + ]; + const colors = inputs.map((input) => parseCssColor(input)); + const gradient = parseGradient("linear-gradient(90deg, black 0%, white 100%)"); + const alphaGradient = parseGradient("linear-gradient(90deg, #00000000 0%, #000000ff 100%)"); + const element = document.createElement("span"); + element.style.color = "oklch(0.7 0.15 200)"; + document.body.append(element); + return { + colors, + picker: toColorPickerValue(getComputedStyle(element).color), + alpha: mergeColorWithExistingAlpha("#123456", "color(srgb 0.4 0 0.6 / 0.25)"), + gradient: insertGradientStop(gradient, 50).stops[1].color, + alphaGradient: insertGradientStop(alphaGradient, 50).stops[1].color, + }; + }, + moduleUrl("colorValue"), + moduleUrl("gradientValue"), + ); + const expected = [ + [255, 255, 255, 1], + [102, 51, 153, 1], + [15, 23, 42, 0.8], + [255, 0, 0, 0.5], + [77, 128, 179, 1], + [102, 0, 153, 1], + [0, 185, 195, 1], + [195, 96, 46, 1], + [255, 255, 255, 1], + [255, 0, 0, 1], + [0, 185, 195, 0], + [102, 0, 153, 0.001], + [102, 0, 153, 1], + null, + null, + null, + null, + null, + ]; + for (const [index, channels] of expected.entries()) { + const actual = results.colors[index]; + if (!channels) { + assert.equal(actual, null, `invalid color ${index}`); + continue; + } + assert.ok(actual, `color ${index} parsed`); + for (const [channel, value] of Object.entries({ + red: channels[0], + green: channels[1], + blue: channels[2], + })) { + assert.ok( + Math.abs(actual[channel] - value) <= 1, + `color ${index} ${channel}: ${actual[channel]} vs ${value}`, + ); + } + assert.ok(Math.abs(actual.alpha - channels[3]) < 0.000001, `color ${index} alpha`); + } + assert.equal(results.picker, "#00b9c3"); + assert.equal(results.alpha, "rgba(18, 52, 86, 0.25)"); + assert.equal(results.gradient, "#808080"); + assert.equal(results.alphaGradient, "rgba(0, 0, 0, 0.5)"); + console.log( + "Passed 18 native CSS color cases, computed-style picker, alpha preservation, and two gradient cases.", + ); +} finally { + await browser?.close(); + rmSync(output, { recursive: true, force: true }); +}