diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dd19e48f0..7d388683d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -566,6 +566,8 @@ jobs: with: node-version: 22 - run: bash scripts/ci/install-workspace-dependencies.sh + - name: Parse CSS colors in a real browser + run: bun run --cwd packages/studio test:colors:browser # Same reason as studio-load-smoke: vite.config.ts is loaded by Node and # resolves the workspace packages through their "node" export condition. - run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build 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/InlineTextToolbar.tsx b/packages/studio/src/components/editor/InlineTextToolbar.tsx index 0d75ddcfeb..fe3711c43c 100644 --- a/packages/studio/src/components/editor/InlineTextToolbar.tsx +++ b/packages/studio/src/components/editor/InlineTextToolbar.tsx @@ -246,7 +246,7 @@ function placeOverSelection( placeBelow, styles, colours, - pickerColour: toPickerColour(styles.color ?? colours[0], doc), + pickerColour: toPickerColour(styles.color ?? colours[0]), }; } @@ -257,25 +257,7 @@ function isBold(weight: string | undefined): boolean { } /** 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; - } +function toPickerColour(value: string | undefined): string { + const parsed = value ? parseCssColor(value) : null; + return parsed ? toHexColor(parsed) : DEFAULT_COLOR; } diff --git a/packages/studio/src/components/editor/colorValue.test.ts b/packages/studio/src/components/editor/colorValue.test.ts index fe0cb0b97d..7304ca84f5 100644 --- a/packages/studio/src/components/editor/colorValue.test.ts +++ b/packages/studio/src/components/editor/colorValue.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatCssColor, hsvToRgb, @@ -36,12 +36,112 @@ describe("parseCssColor", () => { alpha: 0, }); }); + + it.each([ + ["#fff", { red: 255, green: 255, blue: 255, alpha: 1 }], + ["#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(); + }, + ); +}); + +describe("parseCssColor with a canvas", () => { + function stubCanvas(serializations: Record) { + const known = new Map(Object.entries(serializations)); + let fillStyle = "#000000"; + const context = { + get fillStyle() { + return fillStyle; + }, + set fillStyle(value: string) { + if (value === "#000000" || value === "#ffffff") fillStyle = value; + else fillStyle = known.get(value) ?? fillStyle; + }, + }; + vi.stubGlobal("CSS", { supports: () => true }); + vi.stubGlobal("document", { createElement: () => ({ getContext: () => context }) }); + } + + async function parseInBrowser(value: string) { + const module = await import("./colorValue"); + return module.parseCssColor(value); + } + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("converts through relative color syntax", async () => { + stubCanvas({ + "color(from oklch(0.7 0.15 200) srgb r g b / alpha)": + "color(srgb -0.316663 0.724435 0.764448)", + }); + expect(await parseInBrowser("oklch(0.7 0.15 200)")).toEqual({ + red: 0, + green: 185, + blue: 195, + alpha: 1, + }); + }); + + it("reads relative colors that the canvas serializes as rgba()", async () => { + stubCanvas({ + "color(from oklch(0.7 0.15 200 / 0.5) srgb r g b / alpha)": "rgba(0, 185, 195, 0.5)", + }); + expect(await parseInBrowser("oklch(0.7 0.15 200 / 0.5)")).toEqual({ + red: 0, + green: 185, + blue: 195, + alpha: 0.5, + }); + }); + + it("falls back to the plain value when the canvas lacks relative colors", async () => { + stubCanvas({ white: "#ffffff" }); + expect(await parseInBrowser("white")).toEqual({ red: 255, green: 255, blue: 255, alpha: 1 }); + }); + + it("accepts a color that serializes the same as a sentinel", async () => { + stubCanvas({ "color(from black srgb r g b / alpha)": "#000000" }); + expect(await parseInBrowser("black")).toEqual({ red: 0, green: 0, blue: 0, alpha: 1 }); + }); + + it("rejects values the canvas ignores instead of reading its previous color", async () => { + stubCanvas({}); + expect(await parseInBrowser("notacolor")).toBeNull(); + }); + + it.each(["currentcolor", "var(--color)"])( + "rejects %s, which has no value outside an element", + async (input) => { + stubCanvas({ [`color(from ${input} srgb r g b / alpha)`]: "color(srgb 1 0 0)" }); + expect(await parseInBrowser(input)).toBeNull(); + }, + ); }); describe("toColorPickerValue", () => { it("converts css color to hex", () => { expect(toColorPickerValue("rgba(15, 23, 42, 0.64)")).toBe("#0f172a"); }); + + it("falls back to black for values that are not colors", () => { + expect(toColorPickerValue("currentcolor")).toBe("#000000"); + }); }); describe("toHexColor", () => { diff --git a/packages/studio/src/components/editor/colorValue.ts b/packages/studio/src/components/editor/colorValue.ts index b2cb7bef8b..a5d595c15f 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -29,49 +29,105 @@ 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})$/); 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*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)(?:\s*,\s*(\d*\.?\d+))?\s*\)$/, + ) ?? + trimmed.match( + /^rgba?\(\s*(\d*\.?\d+%?)\s+(\d*\.?\d+%?)\s+(\d*\.?\d+%?)(?:\s*\/\s*(\d*\.?\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 colorContext: CanvasRenderingContext2D | null = null; + +function canResolveInBrowser(value: string): boolean { + return ( + typeof document !== "undefined" && + typeof CSS !== "undefined" && + CSS.supports("color", value) && + !/\bcurrentcolor\b|\bvar\s*\(/i.test(value) + ); +} + +function parseBrowserColor(value: string): ParsedColor | null { + if (!canResolveInBrowser(value)) return null; + try { + colorContext ??= document.createElement("canvas").getContext("2d"); + if (!colorContext) return null; + return ( + parseCanvasColor(colorContext, `color(from ${value} srgb r g b / alpha)`) ?? + parseCanvasColor(colorContext, value) + ); + } catch { + return null; + } +} + +function parseCanvasColor(context: CanvasRenderingContext2D, value: string): ParsedColor | null { + const serialized = readCanvasColor(context, value); + if (serialized === null) return null; + return parseSrgbSerialization(serialized) ?? parseSerializedColor(serialized); +} + +function readCanvasColor(context: CanvasRenderingContext2D, value: string): string | null { + context.fillStyle = "#000000"; + context.fillStyle = value; + const serialized = context.fillStyle; + context.fillStyle = "#ffffff"; + context.fillStyle = value; + return typeof serialized === "string" && serialized === context.fillStyle ? serialized : 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 toColorPickerValue(value: string): string { const parsed = parseCssColor(value); if (!parsed) return "#000000"; diff --git a/packages/studio/src/components/editor/gradientValue.test.ts b/packages/studio/src/components/editor/gradientValue.test.ts index 4a5dc6ca42..e723236675 100644 --- a/packages/studio/src/components/editor/gradientValue.test.ts +++ b/packages/studio/src/components/editor/gradientValue.test.ts @@ -86,4 +86,17 @@ describe("insertGradientStop", () => { ], }); }); + + it("interpolates the alpha of 8-digit hex stops", () => { + const parsed = parseGradient("linear-gradient(90deg, #00000000 0%, #000000ff 100%)"); + expect(parsed).not.toBeNull(); + + expect(insertGradientStop(parsed!, 50)).toMatchObject({ + stops: [ + { color: "#00000000", position: 0 }, + { color: "rgba(0, 0, 0, 0.5)", position: 50 }, + { color: "#000000ff", position: 100 }, + ], + }); + }); }); 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..b3d271aa6d --- /dev/null +++ b/packages/studio/tests/e2e/native-css-colors.mjs @@ -0,0 +1,111 @@ +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 cases = [ + ["white", [255, 255, 255, 1]], + ["rebeccapurple", [102, 51, 153, 1]], + ["#0f172acc", [15, 23, 42, 0.8]], + ["rgb(255 0 0 / 50%)", [255, 0, 0, 0.5]], + ["hsl(210 40% 50%)", [77, 128, 179, 1]], + ["color(srgb 0.4 0 0.6)", [102, 0, 153, 1]], + ["oklch(0.7 0.15 200)", [0, 185, 195, 1]], + ["oklab(0.6 0.1 0.1)", [195, 96, 46, 1]], + ["lab(100 0 0)", [255, 255, 255, 1]], + ["color(display-p3 1 0 0)", [255, 0, 0, 1]], + ["oklch(0.9 0.35 140)", [0, 255, 0, 1]], + ["oklch(0.7 0.15 200 / 0)", [0, 185, 195, 0]], + ["color(srgb 0.4 0 0.6 / 0.001)", [102, 0, 153, 0.001]], + ["color-mix(in srgb, red 40%, blue)", [102, 0, 153, 1]], + ["notacolor", null], + ["#12", null], + ["rgb(1. 2 3)", null], + ["currentcolor", null], + ["none", null], + ["var(--color)", null], + ["inherit", null], + ["env(safe-area-inset-top)", null], +]; + +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, inputs) => { + const { parseCssColor, toColorPickerValue, mergeColorWithExistingAlpha } = await import( + colorUrl + ); + const { parseGradient, insertGradientStop } = await import(gradientUrl); + 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: inputs.map((input) => parseCssColor(input)), + 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"), + cases.map(([input]) => input), + ); + for (const [index, [input, channels]] of cases.entries()) { + const actual = results.colors[index]; + if (!channels) { + assert.equal(actual, null, `${input} should be rejected`); + continue; + } + assert.ok(actual, `${input} should parse`); + for (const [channelIndex, channel] of ["red", "green", "blue"].entries()) { + assert.ok( + Math.abs(actual[channel] - channels[channelIndex]) <= 1, + `${input} ${channel}: ${actual[channel]} vs ${channels[channelIndex]}`, + ); + } + assert.ok( + Math.abs(actual.alpha - channels[3]) < 0.000001, + `${input} alpha: ${actual.alpha} vs ${channels[3]}`, + ); + } + 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 ${cases.length} native CSS color cases, computed-style picker, alpha preservation, and two gradient cases.`, + ); +} finally { + await browser?.close(); + rmSync(output, { recursive: true, force: true }); +}