From a50373108fd906827136a8307e7e2969413d0e99 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 07:07:44 +0000 Subject: [PATCH 01/15] fix(studio): parse modern CSS colors without a new dependency --- packages/studio/package.json | 1 + .../src/components/editor/colorValue.test.ts | 16 +++ .../src/components/editor/colorValue.ts | 93 +++++++++---- .../src/components/editor/gradientValue.ts | 36 +---- .../studio/tests/e2e/native-css-colors.mjs | 129 ++++++++++++++++++ 5 files changed, 218 insertions(+), 57 deletions(-) create mode 100644 packages/studio/tests/e2e/native-css-colors.mjs diff --git a/packages/studio/package.json b/packages/studio/package.json index 6570605f443..a86dcb0f9a3 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -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" }, diff --git a/packages/studio/src/components/editor/colorValue.test.ts b/packages/studio/src/components/editor/colorValue.test.ts index fe0cb0b97d5..b3cd2eefb2c 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 b2cb7bef8b2..4a2a6ee4efc 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 95f9c5a1386..0f4f0bd11e4 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 00000000000..734efe5c46b --- /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 }); +} From 37e5e3cd21c2c2a245bac6236f20f3df3b32dbba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 2 Oct 2026 08:56:50 -0400 Subject: [PATCH 02/15] refactor(studio): one colour parser for the text toolbar, stricter rgb numbers The inline text toolbar kept its own canvas fallback for named colours. parseCssColor now resolves them, so the toolbar uses it alone. rgb() channels must be well-formed numbers; that replaces the NaN check after parsing and rejects rgb(1. 2 3) as CSS does. --- .../components/editor/InlineTextToolbar.tsx | 26 +++---------------- .../src/components/editor/colorValue.test.ts | 2 +- .../src/components/editor/colorValue.ts | 12 ++++----- 3 files changed, 11 insertions(+), 29 deletions(-) diff --git a/packages/studio/src/components/editor/InlineTextToolbar.tsx b/packages/studio/src/components/editor/InlineTextToolbar.tsx index cc8f8954905..2c0cfe697bf 100644 --- a/packages/studio/src/components/editor/InlineTextToolbar.tsx +++ b/packages/studio/src/components/editor/InlineTextToolbar.tsx @@ -248,7 +248,7 @@ function placeOverSelection( placeBelow, styles, colours, - pickerColour: toPickerColour(styles.color ?? colours[0], doc), + pickerColour: toPickerColour(styles.color ?? colours[0]), }; } @@ -259,25 +259,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 b3cd2eefb2c..62e167744a8 100644 --- a/packages/studio/src/components/editor/colorValue.test.ts +++ b/packages/studio/src/components/editor/colorValue.test.ts @@ -37,7 +37,7 @@ describe("parseCssColor", () => { expect(parseCssColor(input)).toEqual(expected); }); - it.each(["", "#12", "notacolor", "currentcolor", "none", "rgb(1..2, 3, 4)"])( + 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(); diff --git a/packages/studio/src/components/editor/colorValue.ts b/packages/studio/src/components/editor/colorValue.ts index 4a2a6ee4efc..b76659e6644 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -40,7 +40,7 @@ function parseSerializedColor(value: string): ParsedColor | null { return { red: 0, green: 0, blue: 0, alpha: 0 }; } - const hex = trimmed.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/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 { @@ -53,9 +53,11 @@ function parseSerializedColor(value: string): ParsedColor | null { const rgba = trimmed.match( - /^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/, + /^rgba?\(\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)(?:\s*,\s*(\d*\.?\d+))?\s*\)$/, ) ?? - trimmed.match(/^rgba?\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+%?)(?:\s*\/\s*([\d.]+%?))?\s*\)$/); + trimmed.match( + /^rgba?\(\s*(\d*\.?\d+%?)\s+(\d*\.?\d+%?)\s+(\d*\.?\d+%?)(?:\s*\/\s*(\d*\.?\d+%?))?\s*\)$/, + ); if (rgba) { return { red: clampChannel(parseComponent(rgba[1], 255)), @@ -112,9 +114,7 @@ function parseSrgbSerialization( } 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()); + return parseSerializedColor(value) ?? parseBrowserColor(value.trim()); } export function toColorPickerValue(value: string): string { From 96bbfc81c5e76a5afcc38647fa65580b48f87402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 2 Oct 2026 09:13:28 -0400 Subject: [PATCH 03/15] refactor(studio): drop comment lines the comment ratchet refuses in colorValue --- packages/studio/src/components/editor/colorValue.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/studio/src/components/editor/colorValue.ts b/packages/studio/src/components/editor/colorValue.ts index b76659e6644..23d62fb991c 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -70,8 +70,6 @@ function parseSerializedColor(value: string): ParsedColor | null { 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 { @@ -85,13 +83,10 @@ function parseBrowserColor(value: string): ParsedColor | 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; } } From d9f97c0ca997a87becd3f6c654054f2d5c8c713f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 2 Oct 2026 09:35:09 -0400 Subject: [PATCH 04/15] fix(studio): linear-time rgb() patterns and no new colour literal in the parser The rgb() number pattern now has one way to match each digit run, which clears CodeQL's slow-regex alerts. The canvas reset uses transparent so the Studio colour-literal ratchet stays at its baseline. --- .../studio/src/components/editor/colorValue.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/studio/src/components/editor/colorValue.ts b/packages/studio/src/components/editor/colorValue.ts index 23d62fb991c..8aa567554d2 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -33,6 +33,14 @@ 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; @@ -51,13 +59,7 @@ function parseSerializedColor(value: string): ParsedColor | null { }; } - 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*\)$/, - ); + const rgba = trimmed.match(COMMA_RGB) ?? trimmed.match(SPACE_RGB); if (rgba) { return { red: clampChannel(parseComponent(rgba[1], 255)), @@ -83,7 +85,7 @@ function parseBrowserColor(value: string): ParsedColor | null { try { colorContext ??= document.createElement("canvas").getContext("2d"); if (!colorContext) return null; - colorContext.fillStyle = "#000000"; + colorContext.fillStyle = "transparent"; colorContext.fillStyle = `color(from ${value} srgb r g b / alpha)`; return parseSrgbSerialization(colorContext.fillStyle); } catch { From eaed786407239699cd8ffa6eb8a2a18e9418cfa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 2 Oct 2026 10:42:05 -0400 Subject: [PATCH 05/15] fix(studio): legacy colours resolve in browsers without relative colour syntax parseBrowserColor reads the colour itself from the canvas first and accepts a #rrggbb or rgba() result, then tries color(from ...). Named, hsl() and percent rgb() colours resolve again in the text toolbar on Chrome before 119 and Safari 17. --- .../src/components/editor/colorValue.test.ts | 37 +++++++++++++- .../src/components/editor/colorValue.ts | 49 ++++++++++++++----- 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/packages/studio/src/components/editor/colorValue.test.ts b/packages/studio/src/components/editor/colorValue.test.ts index 62e167744a8..eddc8bab27b 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, describe, expect, it, vi } from "vitest"; import { formatCssColor, hsvToRgb, @@ -44,6 +44,41 @@ describe("parseCssColor", () => { }, ); + describe("in a browser without relative colour syntax", () => { + 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 }], + ])("resolves %s from the canvas's legacy serialization", async (input, expected) => { + const legacy: Record = { + transparent: "rgba(0, 0, 0, 0)", + white: "#ffffff", + red: "#ff0000", + "hsl(210 40% 50%)": "#4d80b3", + "rgb(100%, 0%, 0%)": "#ff0000", + }; + let fillStyle = "#000000"; + const context = { + get fillStyle() { + return fillStyle; + }, + set fillStyle(next: string) { + fillStyle = legacy[next] ?? fillStyle; + }, + }; + vi.stubGlobal("CSS", { supports: () => true }); + vi.stubGlobal("document", { createElement: () => ({ getContext: () => context }) }); + vi.resetModules(); + const { parseCssColor: parseFresh } = await import("./colorValue"); + expect(parseFresh(input)).toEqual(expected); + }); + }); + 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 8aa567554d2..43a77dbfe82 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -74,29 +74,52 @@ function parseSerializedColor(value: string): ParsedColor | 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 ( - typeof document === "undefined" || - typeof CSS === "undefined" || - !CSS.supports("color", value) || - /\bcurrentcolor\b|\bvar\s*\(/i.test(value) - ) - return null; + if (!canResolveInBrowser(value)) return null; try { colorContext ??= document.createElement("canvas").getContext("2d"); if (!colorContext) return null; - colorContext.fillStyle = "transparent"; - colorContext.fillStyle = `color(from ${value} srgb r g b / alpha)`; - return parseSrgbSerialization(colorContext.fillStyle); + return ( + parseCanvasColor(colorContext, value, parseSerializedColor) ?? + parseCanvasColor( + colorContext, + `color(from ${value} srgb r g b / alpha)`, + parseSrgbSerialization, + ) + ); } catch { return null; } } -function parseSrgbSerialization( - serialized: string | CanvasGradient | CanvasPattern, +function parseCanvasColor( + context: CanvasRenderingContext2D, + value: string, + parse: (serialized: string) => ParsedColor | null, ): ParsedColor | null { - if (typeof serialized !== "string") return 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); From 37f7b4a2c8a704ab6f5396d818307895f7d619bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 2 Oct 2026 11:33:37 -0400 Subject: [PATCH 06/15] test(studio): a colour the canvas rejects parses to null, not transparent black --- .../src/components/editor/colorValue.test.ts | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/packages/studio/src/components/editor/colorValue.test.ts b/packages/studio/src/components/editor/colorValue.test.ts index eddc8bab27b..c33be20be02 100644 --- a/packages/studio/src/components/editor/colorValue.test.ts +++ b/packages/studio/src/components/editor/colorValue.test.ts @@ -54,29 +54,33 @@ describe("parseCssColor", () => { ["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 }], - ])("resolves %s from the canvas's legacy serialization", async (input, expected) => { - const legacy: Record = { - transparent: "rgba(0, 0, 0, 0)", - white: "#ffffff", - red: "#ff0000", - "hsl(210 40% 50%)": "#4d80b3", - "rgb(100%, 0%, 0%)": "#ff0000", - }; - let fillStyle = "#000000"; - const context = { - get fillStyle() { - return fillStyle; - }, - set fillStyle(next: string) { - fillStyle = legacy[next] ?? fillStyle; - }, - }; - vi.stubGlobal("CSS", { supports: () => true }); - vi.stubGlobal("document", { createElement: () => ({ getContext: () => context }) }); - vi.resetModules(); - const { parseCssColor: parseFresh } = await import("./colorValue"); - expect(parseFresh(input)).toEqual(expected); - }); + ["inherit", null], + ])( + "resolves %s from the canvas's legacy serialization, or null if it rejects it", + async (input, expected) => { + const legacy: Record = { + transparent: "rgba(0, 0, 0, 0)", + white: "#ffffff", + red: "#ff0000", + "hsl(210 40% 50%)": "#4d80b3", + "rgb(100%, 0%, 0%)": "#ff0000", + }; + let fillStyle = "#000000"; + const context = { + get fillStyle() { + return fillStyle; + }, + set fillStyle(next: string) { + fillStyle = legacy[next] ?? fillStyle; + }, + }; + vi.stubGlobal("CSS", { supports: () => true }); + vi.stubGlobal("document", { createElement: () => ({ getContext: () => context }) }); + vi.resetModules(); + const { parseCssColor: parseFresh } = await import("./colorValue"); + expect(parseFresh(input)).toEqual(expected); + }, + ); }); it("parses transparent", () => { From 3a6d0e47baa95f90db93752ddfe8809a640a9b54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 2 Oct 2026 13:12:13 -0400 Subject: [PATCH 07/15] fix(studio): an inserted gradient stop takes the colour the gradient paints there The stop colour is color-mix() in oklab, or srgb when both stops are legacy, resolved by the browser, so it matches the painted midpoint including premultiplied alpha; the channel blend stays as the fallback. The parser tries color(from ...) before the legacy read, keeping alpha precise, and calls CSS.supports inside its try. A Chrome-147-shaped fake canvas covers the modern path in unit tests. --- .../components/editor/colorCanvasTestUtils.ts | 43 +++++++++++++++++++ .../src/components/editor/colorValue.test.ts | 37 +++++----------- .../src/components/editor/colorValue.ts | 5 +-- .../components/editor/gradientValue.test.ts | 21 ++++++++- .../src/components/editor/gradientValue.ts | 23 ++++++++-- .../components/editor/propertyPanelColor.tsx | 2 +- 6 files changed, 95 insertions(+), 36 deletions(-) create mode 100644 packages/studio/src/components/editor/colorCanvasTestUtils.ts diff --git a/packages/studio/src/components/editor/colorCanvasTestUtils.ts b/packages/studio/src/components/editor/colorCanvasTestUtils.ts new file mode 100644 index 00000000000..08514fbd963 --- /dev/null +++ b/packages/studio/src/components/editor/colorCanvasTestUtils.ts @@ -0,0 +1,43 @@ +import { vi } from "vitest"; + +const CHROME_147_FILL_STYLE: Record = { + 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 = { + 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)", +}; + +export 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")) }; +} diff --git a/packages/studio/src/components/editor/colorValue.test.ts b/packages/studio/src/components/editor/colorValue.test.ts index c33be20be02..7c2ef391d7c 100644 --- a/packages/studio/src/components/editor/colorValue.test.ts +++ b/packages/studio/src/components/editor/colorValue.test.ts @@ -8,6 +8,7 @@ import { toColorPickerValue, toHexColor, } from "./colorValue"; +import { loadColorModulesWithChromeCanvas } from "./colorCanvasTestUtils"; describe("parseCssColor", () => { it("parses rgb values", () => { @@ -44,7 +45,7 @@ describe("parseCssColor", () => { }, ); - describe("in a browser without relative colour syntax", () => { + describe("with a browser canvas", () => { afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); @@ -54,33 +55,15 @@ describe("parseCssColor", () => { ["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], - ])( - "resolves %s from the canvas's legacy serialization, or null if it rejects it", - async (input, expected) => { - const legacy: Record = { - transparent: "rgba(0, 0, 0, 0)", - white: "#ffffff", - red: "#ff0000", - "hsl(210 40% 50%)": "#4d80b3", - "rgb(100%, 0%, 0%)": "#ff0000", - }; - let fillStyle = "#000000"; - const context = { - get fillStyle() { - return fillStyle; - }, - set fillStyle(next: string) { - fillStyle = legacy[next] ?? fillStyle; - }, - }; - vi.stubGlobal("CSS", { supports: () => true }); - vi.stubGlobal("document", { createElement: () => ({ getContext: () => context }) }); - vi.resetModules(); - const { parseCssColor: parseFresh } = await import("./colorValue"); - expect(parseFresh(input)).toEqual(expected); - }, - ); + ["currentcolor", 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); + }); }); it("parses transparent", () => { diff --git a/packages/studio/src/components/editor/colorValue.ts b/packages/studio/src/components/editor/colorValue.ts index 43a77dbfe82..02b7526cb26 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -84,17 +84,16 @@ function canResolveInBrowser(value: string): boolean { } function parseBrowserColor(value: string): ParsedColor | null { - if (!canResolveInBrowser(value)) return null; try { + if (!canResolveInBrowser(value)) return null; colorContext ??= document.createElement("canvas").getContext("2d"); if (!colorContext) return null; return ( - parseCanvasColor(colorContext, value, parseSerializedColor) ?? parseCanvasColor( colorContext, `color(from ${value} srgb r g b / alpha)`, parseSrgbSerialization, - ) + ) ?? parseCanvasColor(colorContext, value, parseSerializedColor) ); } catch { return null; diff --git a/packages/studio/src/components/editor/gradientValue.test.ts b/packages/studio/src/components/editor/gradientValue.test.ts index 4a5dc6ca426..93be99b4cab 100644 --- a/packages/studio/src/components/editor/gradientValue.test.ts +++ b/packages/studio/src/components/editor/gradientValue.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildDefaultGradientModel, insertGradientStop, parseGradient, serializeGradient, } from "./gradientValue"; +import { loadColorModulesWithChromeCanvas } from "./colorCanvasTestUtils"; describe("parseGradient", () => { it("parses linear gradients", () => { @@ -87,3 +88,21 @@ describe("insertGradientStop", () => { }); }); }); + +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); + }); +}); diff --git a/packages/studio/src/components/editor/gradientValue.ts b/packages/studio/src/components/editor/gradientValue.ts index 0f4f0bd11e4..09cc3d84098 100644 --- a/packages/studio/src/components/editor/gradientValue.ts +++ b/packages/studio/src/components/editor/gradientValue.ts @@ -1,5 +1,5 @@ import { roundToCenti } from "../../utils/rounding"; -import { parseCssColor } from "./colorValue"; +import { parseCssColor, type ParsedColor } from "./colorValue"; export type GradientKind = "linear" | "radial" | "conic"; @@ -385,20 +385,35 @@ function interpolateGradientStopColor(model: GradientModel, position: number): s const leftColor = left.color; const rightColor = right.color; + const ratio = (clampedPosition - left.position) / Math.max(1, right.position - left.position); + const mixed = parseCssColor(mixLikeTheGradient(leftColor, rightColor, ratio)); + if (mixed) return formatStopColor(mixed); + 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); const red = blendChannel(leftParsed.red, rightParsed.red, ratio); const green = blendChannel(leftParsed.green, rightParsed.green, ratio); const blue = blendChannel(leftParsed.blue, rightParsed.blue, ratio); - const alpha = round(leftParsed.alpha + (rightParsed.alpha - leftParsed.alpha) * ratio); + const alpha = leftParsed.alpha + (rightParsed.alpha - leftParsed.alpha) * ratio; + return formatStopColor({ red, green, blue, alpha }); +} +const LEGACY_COLOR = /^(?:#|(?:rgba?|hsla?)\(|[a-z]+$)/i; + +function mixLikeTheGradient(left: string, right: string, ratio: number): string { + const space = + LEGACY_COLOR.test(left.trim()) && LEGACY_COLOR.test(right.trim()) ? "srgb" : "oklab"; + return `color-mix(in ${space}, ${left}, ${right} ${round(ratio * 100)}%)`; +} + +function formatStopColor(color: ParsedColor): string { + const { red, green, blue } = color; + const alpha = round(color.alpha); if (alpha >= 1) { return `#${formatHex(red)}${formatHex(green)}${formatHex(blue)}`.toUpperCase(); } - return `rgba(${red}, ${green}, ${blue}, ${alpha})`; } diff --git a/packages/studio/src/components/editor/propertyPanelColor.tsx b/packages/studio/src/components/editor/propertyPanelColor.tsx index e46455dce78..6984e1aa8fe 100644 --- a/packages/studio/src/components/editor/propertyPanelColor.tsx +++ b/packages/studio/src/components/editor/propertyPanelColor.tsx @@ -193,7 +193,7 @@ export function ColorField({ const resolveColorGestureValue = useCallback((nextValue: string) => { const source = nextValue.startsWith("#") ? "hex" : "picker"; // Only a COMPLETE hex resolves, so a half-typed one neither previews nor - // commits. Both lengths parseCssColor accepts count as complete: gating on + // commits. 3 and 6 digits count as complete (the field edits RGB only): gating on // 6 alone silently dropped #F00 and friends, which the old onBlur committed. if (source === "hex" && !/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(nextValue)) return null; const nextColor = parseCssColor(nextValue); From 17315476b4e2eded53b27081fb7dc6b0bcdf6042 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 10:54:33 -0700 Subject: [PATCH 08/15] test(studio): keep the fake browser canvas inside its test file --- ...estUtils.ts => colorBrowserCanvas.test.ts} | 43 ++++++++++++++++++- .../src/components/editor/colorValue.test.ts | 24 +---------- .../components/editor/gradientValue.test.ts | 21 +-------- 3 files changed, 43 insertions(+), 45 deletions(-) rename packages/studio/src/components/editor/{colorCanvasTestUtils.ts => colorBrowserCanvas.test.ts} (50%) diff --git a/packages/studio/src/components/editor/colorCanvasTestUtils.ts b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts similarity index 50% rename from packages/studio/src/components/editor/colorCanvasTestUtils.ts rename to packages/studio/src/components/editor/colorBrowserCanvas.test.ts index 08514fbd963..b92efd54e0d 100644 --- a/packages/studio/src/components/editor/colorCanvasTestUtils.ts +++ b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts @@ -1,4 +1,4 @@ -import { vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; const CHROME_147_FILL_STYLE: Record = { transparent: "rgba(0, 0, 0, 0)", @@ -23,7 +23,7 @@ const CHROME_147_RELATIVE_SRGB: Record = { "color-mix(in srgb, #ff000000, #0000ffff 50%)": "color(srgb 0 0 1 / 0.5)", }; -export async function loadColorModulesWithChromeCanvas() { +async function loadColorModulesWithChromeCanvas() { let fillStyle = "#000000"; const context = { get fillStyle() { @@ -41,3 +41,42 @@ export async function loadColorModulesWithChromeCanvas() { 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], + ])("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); + }); +}); diff --git a/packages/studio/src/components/editor/colorValue.test.ts b/packages/studio/src/components/editor/colorValue.test.ts index 7c2ef391d7c..62e167744a8 100644 --- a/packages/studio/src/components/editor/colorValue.test.ts +++ b/packages/studio/src/components/editor/colorValue.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { formatCssColor, hsvToRgb, @@ -8,7 +8,6 @@ import { toColorPickerValue, toHexColor, } from "./colorValue"; -import { loadColorModulesWithChromeCanvas } from "./colorCanvasTestUtils"; describe("parseCssColor", () => { it("parses rgb values", () => { @@ -45,27 +44,6 @@ describe("parseCssColor", () => { }, ); - describe("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], - ])("resolves %s through the canvas, or null if it must not", async (input, expected) => { - const { parseCssColor: parseFresh } = await loadColorModulesWithChromeCanvas(); - expect(parseFresh(input)).toEqual(expected); - }); - }); - it("parses transparent", () => { expect(parseCssColor("transparent")).toEqual({ red: 0, diff --git a/packages/studio/src/components/editor/gradientValue.test.ts b/packages/studio/src/components/editor/gradientValue.test.ts index 93be99b4cab..4a5dc6ca426 100644 --- a/packages/studio/src/components/editor/gradientValue.test.ts +++ b/packages/studio/src/components/editor/gradientValue.test.ts @@ -1,11 +1,10 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { buildDefaultGradientModel, insertGradientStop, parseGradient, serializeGradient, } from "./gradientValue"; -import { loadColorModulesWithChromeCanvas } from "./colorCanvasTestUtils"; describe("parseGradient", () => { it("parses linear gradients", () => { @@ -88,21 +87,3 @@ describe("insertGradientStop", () => { }); }); }); - -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); - }); -}); From 646c2205169642d157d3b99348dc2b000fc59e98 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 11:24:33 -0700 Subject: [PATCH 09/15] fix(studio): mix an inserted gradient stop in the space the whole gradient uses --- .../editor/colorBrowserCanvas.test.ts | 19 +++++++++++++++++++ .../src/components/editor/gradientValue.ts | 14 +++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/components/editor/colorBrowserCanvas.test.ts b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts index b92efd54e0d..fd56fdfdb93 100644 --- a/packages/studio/src/components/editor/colorBrowserCanvas.test.ts +++ b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts @@ -21,6 +21,8 @@ const CHROME_147_RELATIVE_SRGB: Record = { "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)", }; async function loadColorModulesWithChromeCanvas() { @@ -79,4 +81,21 @@ describe("insertGradientStop with a browser canvas", () => { 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", + ], + ])("%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); + }); }); diff --git a/packages/studio/src/components/editor/gradientValue.ts b/packages/studio/src/components/editor/gradientValue.ts index 09cc3d84098..dc949869475 100644 --- a/packages/studio/src/components/editor/gradientValue.ts +++ b/packages/studio/src/components/editor/gradientValue.ts @@ -386,7 +386,7 @@ function interpolateGradientStopColor(model: GradientModel, position: number): s const leftColor = left.color; const rightColor = right.color; const ratio = (clampedPosition - left.position) / Math.max(1, right.position - left.position); - const mixed = parseCssColor(mixLikeTheGradient(leftColor, rightColor, ratio)); + const mixed = parseCssColor(mixLikeTheGradient(sortedStops, leftColor, rightColor, ratio)); if (mixed) return formatStopColor(mixed); const leftParsed = leftColor ? parseCssColor(leftColor) : null; @@ -400,11 +400,15 @@ function interpolateGradientStopColor(model: GradientModel, position: number): s return formatStopColor({ red, green, blue, alpha }); } -const LEGACY_COLOR = /^(?:#|(?:rgba?|hsla?)\(|[a-z]+$)/i; +const LEGACY_COLOR = /^(?:#|(?:rgba?|hsla?|hwb)\(|[a-z]+$)/i; -function mixLikeTheGradient(left: string, right: string, ratio: number): string { - const space = - LEGACY_COLOR.test(left.trim()) && LEGACY_COLOR.test(right.trim()) ? "srgb" : "oklab"; +function mixLikeTheGradient( + stops: GradientStop[], + left: string, + right: string, + ratio: number, +): string { + const space = stops.every((stop) => LEGACY_COLOR.test(stop.color.trim())) ? "srgb" : "oklab"; return `color-mix(in ${space}, ${left}, ${right} ${round(ratio * 100)}%)`; } From fcd1d111e188ed9956d0d814443515181bb74972 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 11:39:03 -0700 Subject: [PATCH 10/15] fix(studio): let an unresolved var() stop keep a legacy gradient in sRGB --- .../src/components/editor/colorBrowserCanvas.test.ts | 7 +++++++ packages/studio/src/components/editor/gradientValue.ts | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/colorBrowserCanvas.test.ts b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts index fd56fdfdb93..683ed2ecd30 100644 --- a/packages/studio/src/components/editor/colorBrowserCanvas.test.ts +++ b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts @@ -23,6 +23,7 @@ const CHROME_147_RELATIVE_SRGB: Record = { "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)", }; async function loadColorModulesWithChromeCanvas() { @@ -90,6 +91,12 @@ describe("insertGradientStop with a browser canvas", () => { 25, "#8C53A2", ], + [ + "an unresolved var() stop leaves a legacy interval in sRGB", + "red 0%, blue 50%, var(--third, red) 100%", + 25, + "#800080", + ], ])("%s", async (_case, stops, position, expected) => { const { parseGradient: parse, insertGradientStop: insert } = await loadColorModulesWithChromeCanvas(); diff --git a/packages/studio/src/components/editor/gradientValue.ts b/packages/studio/src/components/editor/gradientValue.ts index dc949869475..793976f4ded 100644 --- a/packages/studio/src/components/editor/gradientValue.ts +++ b/packages/studio/src/components/editor/gradientValue.ts @@ -402,13 +402,17 @@ function interpolateGradientStopColor(model: GradientModel, position: number): s const LEGACY_COLOR = /^(?:#|(?:rgba?|hsla?|hwb)\(|[a-z]+$)/i; +const isLegacy = (color: string) => LEGACY_COLOR.test(color.trim()); +const isModern = (color: string) => !isLegacy(color) && !/\bvar\(/i.test(color); + function mixLikeTheGradient( stops: GradientStop[], left: string, right: string, ratio: number, ): string { - const space = stops.every((stop) => LEGACY_COLOR.test(stop.color.trim())) ? "srgb" : "oklab"; + const oklab = stops.some((stop) => isModern(stop.color)) || !isLegacy(left) || !isLegacy(right); + const space = oklab ? "oklab" : "srgb"; return `color-mix(in ${space}, ${left}, ${right} ${round(ratio * 100)}%)`; } From 1dc7875f007faf2310e0c85209ab52a8b7804fde Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 11:55:31 -0700 Subject: [PATCH 11/15] fix(studio): only a bare var() stop leaves the gradient space to its neighbours --- .../studio/src/components/editor/colorBrowserCanvas.test.ts | 6 ++++++ packages/studio/src/components/editor/gradientValue.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/colorBrowserCanvas.test.ts b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts index 683ed2ecd30..f6189711fde 100644 --- a/packages/studio/src/components/editor/colorBrowserCanvas.test.ts +++ b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts @@ -97,6 +97,12 @@ describe("insertGradientStop with a browser canvas", () => { 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(); diff --git a/packages/studio/src/components/editor/gradientValue.ts b/packages/studio/src/components/editor/gradientValue.ts index 793976f4ded..a9b61bf484c 100644 --- a/packages/studio/src/components/editor/gradientValue.ts +++ b/packages/studio/src/components/editor/gradientValue.ts @@ -403,7 +403,7 @@ function interpolateGradientStopColor(model: GradientModel, position: number): s const LEGACY_COLOR = /^(?:#|(?:rgba?|hsla?|hwb)\(|[a-z]+$)/i; const isLegacy = (color: string) => LEGACY_COLOR.test(color.trim()); -const isModern = (color: string) => !isLegacy(color) && !/\bvar\(/i.test(color); +const isModern = (color: string) => !isLegacy(color) && !/^var\(/i.test(color.trim()); function mixLikeTheGradient( stops: GradientStop[], From bcd9cecda0af807ee0f75b95a138972e8e90d674 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 13:27:50 -0700 Subject: [PATCH 12/15] fix(studio): pick light-dark() text colour from what the element paints --- .../components/editor/InlineTextToolbar.tsx | 15 +++++++++------ .../editor/colorBrowserCanvas.test.ts | 2 ++ .../src/components/editor/colorValue.ts | 14 +++++++++++++- .../studio/tests/e2e/native-css-colors.mjs | 19 +++++++++++++++---- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/studio/src/components/editor/InlineTextToolbar.tsx b/packages/studio/src/components/editor/InlineTextToolbar.tsx index 2c0cfe697bf..4b9c0098d5d 100644 --- a/packages/studio/src/components/editor/InlineTextToolbar.tsx +++ b/packages/studio/src/components/editor/InlineTextToolbar.tsx @@ -2,7 +2,7 @@ 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 { resolvePickerColor } from "./colorValue"; import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit"; /** @@ -248,7 +248,11 @@ function placeOverSelection( placeBelow, styles, colours, - pickerColour: toPickerColour(styles.color ?? colours[0]), + pickerColour: resolvePickerColor( + styles.color ?? colours[0], + rangeStartElement(range), + DEFAULT_COLOR, + ), }; } @@ -258,8 +262,7 @@ function isBold(weight: string | undefined): boolean { return Number.parseInt(weight, 10) >= 600; } -/** A colour input accepts only `#rrggbb`; normalise any valid CSS colour to it. */ -function toPickerColour(value: string | undefined): string { - const parsed = value ? parseCssColor(value) : null; - return parsed ? toHexColor(parsed) : DEFAULT_COLOR; +function rangeStartElement(range: Range): Element | null { + const node = range.startContainer; + return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; } diff --git a/packages/studio/src/components/editor/colorBrowserCanvas.test.ts b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts index f6189711fde..63aed15c0a4 100644 --- a/packages/studio/src/components/editor/colorBrowserCanvas.test.ts +++ b/packages/studio/src/components/editor/colorBrowserCanvas.test.ts @@ -24,6 +24,7 @@ const CHROME_147_RELATIVE_SRGB: Record = { "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() { @@ -60,6 +61,7 @@ describe("parseCssColor with a browser canvas", () => { ["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); diff --git a/packages/studio/src/components/editor/colorValue.ts b/packages/studio/src/components/editor/colorValue.ts index 02b7526cb26..b9240db567c 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -79,7 +79,7 @@ function canResolveInBrowser(value: string): boolean { typeof document !== "undefined" && typeof CSS !== "undefined" && CSS.supports("color", value) && - !/\bcurrentcolor\b|\bvar\s*\(/i.test(value) + !/\bcurrentcolor\b|\bvar\s*\(|\blight-dark\s*\(/i.test(value) ); } @@ -136,6 +136,18 @@ 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"; diff --git a/packages/studio/tests/e2e/native-css-colors.mjs b/packages/studio/tests/e2e/native-css-colors.mjs index 734efe5c46b..16bc7097ad0 100644 --- a/packages/studio/tests/e2e/native-css-colors.mjs +++ b/packages/studio/tests/e2e/native-css-colors.mjs @@ -36,9 +36,8 @@ try { const page = await browser.newPage(); const results = await page.evaluate( async (colorUrl, gradientUrl) => { - const { parseCssColor, toColorPickerValue, mergeColorWithExistingAlpha } = await import( - colorUrl - ); + const { parseCssColor, toColorPickerValue, mergeColorWithExistingAlpha, resolvePickerColor } = + await import(colorUrl); const { parseGradient, insertGradientStop } = await import(gradientUrl); const inputs = [ "white", @@ -66,8 +65,18 @@ try { const element = document.createElement("span"); element.style.color = "oklch(0.7 0.15 200)"; document.body.append(element); + const frame = document.createElement("iframe"); + frame.srcdoc = + 'x'; + await new Promise((loaded) => { + frame.onload = loaded; + document.body.append(frame); + }); + const darkSpan = frame.contentDocument.querySelector("span"); return { colors, + darkParse: parseCssColor(darkSpan.style.color), + darkPicker: resolvePickerColor(darkSpan.style.color, darkSpan, "#ffffff"), picker: toColorPickerValue(getComputedStyle(element).color), alpha: mergeColorWithExistingAlpha("#123456", "color(srgb 0.4 0 0.6 / 0.25)"), gradient: insertGradientStop(gradient, 50).stops[1].color, @@ -116,12 +125,14 @@ try { } assert.ok(Math.abs(actual.alpha - channels[3]) < 0.000001, `color ${index} alpha`); } + assert.equal(results.darkParse, null, "light-dark() needs the element's colour scheme"); + assert.equal(results.darkPicker, "#ff0000", "dark-scheme light-dark() picks what it paints"); 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.", + "Passed 18 native CSS color cases, computed-style and dark-scheme pickers, alpha preservation, and two gradient cases.", ); } finally { await browser?.close(); From efe8f023d416d6a5f47bd6566858666a7fe348c4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 13:34:27 -0700 Subject: [PATCH 13/15] refactor(studio): drop the neighbour clause the whole-gradient check already covers --- packages/studio/src/components/editor/gradientValue.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/studio/src/components/editor/gradientValue.ts b/packages/studio/src/components/editor/gradientValue.ts index a9b61bf484c..9547920ce7d 100644 --- a/packages/studio/src/components/editor/gradientValue.ts +++ b/packages/studio/src/components/editor/gradientValue.ts @@ -411,8 +411,7 @@ function mixLikeTheGradient( right: string, ratio: number, ): string { - const oklab = stops.some((stop) => isModern(stop.color)) || !isLegacy(left) || !isLegacy(right); - const space = oklab ? "oklab" : "srgb"; + const space = stops.some((stop) => isModern(stop.color)) ? "oklab" : "srgb"; return `color-mix(in ${space}, ${left}, ${right} ${round(ratio * 100)}%)`; } From c61ea52b8e4bc7b0a3a6b38c5c8ec1849ff62539 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 14:18:16 -0700 Subject: [PATCH 14/15] fix(studio): resolve the picker colour from the element that paints the selected text --- .../components/editor/InlineTextToolbar.tsx | 13 +++++------ .../editor/inlineTextStyleRange.test.ts | 22 ++++++++++++++++++- .../components/editor/inlineTextStyleRange.ts | 7 ++++-- .../components/editor/inlineTextStyleRead.ts | 6 +++++ .../studio/tests/e2e/native-css-colors.mjs | 21 +++++++++++++----- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/packages/studio/src/components/editor/InlineTextToolbar.tsx b/packages/studio/src/components/editor/InlineTextToolbar.tsx index 4b9c0098d5d..0efec607282 100644 --- a/packages/studio/src/components/editor/InlineTextToolbar.tsx +++ b/packages/studio/src/components/editor/InlineTextToolbar.tsx @@ -1,7 +1,11 @@ import { useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { applyInlineStyle } from "./inlineTextStyleRange"; -import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead"; +import { + readFirstPaintedElement, + readInlineStyle, + readInlineStyleSpread, +} from "./inlineTextStyleRead"; import { resolvePickerColor } from "./colorValue"; import type { InlineTextEditSession } from "../../hooks/useInlineTextEdit"; @@ -250,7 +254,7 @@ function placeOverSelection( colours, pickerColour: resolvePickerColor( styles.color ?? colours[0], - rangeStartElement(range), + readFirstPaintedElement(range), DEFAULT_COLOR, ), }; @@ -261,8 +265,3 @@ function isBold(weight: string | undefined): boolean { if (weight === "bold" || weight === "bolder") return true; return Number.parseInt(weight, 10) >= 600; } - -function rangeStartElement(range: Range): Element | null { - const node = range.startContainer; - return node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; -} diff --git a/packages/studio/src/components/editor/inlineTextStyleRange.test.ts b/packages/studio/src/components/editor/inlineTextStyleRange.test.ts index 322a4978e9b..2a31d5f6629 100644 --- a/packages/studio/src/components/editor/inlineTextStyleRange.test.ts +++ b/packages/studio/src/components/editor/inlineTextStyleRange.test.ts @@ -2,7 +2,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { applyInlineStyle } from "./inlineTextStyleRange"; -import { readInlineStyle, readInlineStyleSpread } from "./inlineTextStyleRead"; +import { + readFirstPaintedElement, + readInlineStyle, + readInlineStyleSpread, +} from "./inlineTextStyleRead"; afterEach(() => { vi.restoreAllMocks(); @@ -741,3 +745,19 @@ describe("readInlineStyleSpread", () => { expect(readInlineStyleSpread(rangeOver(host, 0, 5), "color")).toEqual([]); }); }); + +describe("readFirstPaintedElement", () => { + it("names the span that paints the text when the selection starts on its parent", () => { + const host = mount('Hello'); + const range = document.createRange(); + range.selectNodeContents(host); + + expect(readFirstPaintedElement(range)).toBe(host.querySelector("span")); + }); + + it("skips a leading space, which paints nothing", () => { + const host = mount(' Hello'); + + expect(readFirstPaintedElement(rangeOver(host, 0, 6))).toBe(host.querySelector("span")); + }); +}); diff --git a/packages/studio/src/components/editor/inlineTextStyleRange.ts b/packages/studio/src/components/editor/inlineTextStyleRange.ts index f48c72424ec..6952836a08b 100644 --- a/packages/studio/src/components/editor/inlineTextStyleRange.ts +++ b/packages/studio/src/components/editor/inlineTextStyleRange.ts @@ -49,6 +49,7 @@ interface StyledRun { * identity to lose, and merges with its neighbour exactly as before. */ identity: string; + painter?: Element | null; } export type InlineStyleDelta = Record; @@ -185,6 +186,7 @@ function holdsBothEnds(host: Element, range: Range): boolean { export interface InlineStyleChar { char: string; style: Readonly>; + painter: Element | null; } /** Every character the range covers with its style, or null when it covers none. */ @@ -197,7 +199,7 @@ export function readCoveredInlineStyleChars(range: Range): readonly InlineStyleC const collapsed = start === end; const covered = charRuns(readRuns(host)) .slice(collapsed ? Math.max(0, start - 1) : start, collapsed ? Math.max(1, start) : end) - .map((entry) => ({ char: entry.char, style: entry.style })); + .map((entry) => ({ char: entry.char, style: entry.style, painter: entry.painter ?? null })); return covered.length > 0 ? covered : null; } @@ -244,7 +246,7 @@ function visitRunFrame(frame: RunWalkFrame, runs: StyledRun[]): RunWalkFrame[] { const { node, inherited, origin } = frame; if (node.nodeType === 3) { const text = node.textContent ?? ""; - if (text) runs.push(styledRun(text, inherited, origin)); + if (text) runs.push({ ...styledRun(text, inherited, origin), painter: node.parentElement }); return []; } if (!isStyleElement(node)) return []; @@ -312,6 +314,7 @@ function charRuns(runs: StyledRun[]): Array & { char: st style: run.style, origin: run.origin, identity: run.identity, + painter: run.painter, }); } } diff --git a/packages/studio/src/components/editor/inlineTextStyleRead.ts b/packages/studio/src/components/editor/inlineTextStyleRead.ts index 3460e48dd9f..76c8a48abfb 100644 --- a/packages/studio/src/components/editor/inlineTextStyleRead.ts +++ b/packages/studio/src/components/editor/inlineTextStyleRead.ts @@ -45,3 +45,9 @@ export function readInlineStyleSpread(range: Range, property: string): string[] } return spread; } + +export function readFirstPaintedElement(range: Range): Element | null { + const covered = readCoveredInlineStyleChars(range); + if (!covered) return null; + return (covered.find(({ char }) => char.trim()) ?? covered[0])?.painter ?? null; +} diff --git a/packages/studio/tests/e2e/native-css-colors.mjs b/packages/studio/tests/e2e/native-css-colors.mjs index 16bc7097ad0..267af5e529b 100644 --- a/packages/studio/tests/e2e/native-css-colors.mjs +++ b/packages/studio/tests/e2e/native-css-colors.mjs @@ -18,6 +18,7 @@ try { "build", "packages/studio/src/components/editor/colorValue.ts", "packages/studio/src/components/editor/gradientValue.ts", + "packages/studio/src/components/editor/inlineTextStyleRead.ts", "--target", "browser", "--outdir", @@ -35,10 +36,11 @@ try { }); const page = await browser.newPage(); const results = await page.evaluate( - async (colorUrl, gradientUrl) => { + async (colorUrl, gradientUrl, readUrl) => { const { parseCssColor, toColorPickerValue, mergeColorWithExistingAlpha, resolvePickerColor } = await import(colorUrl); const { parseGradient, insertGradientStop } = await import(gradientUrl); + const { readFirstPaintedElement, readInlineStyleSpread } = await import(readUrl); const inputs = [ "white", "rebeccapurple", @@ -67,16 +69,24 @@ try { document.body.append(element); const frame = document.createElement("iframe"); frame.srcdoc = - 'x'; + '
' + + 'x
'; await new Promise((loaded) => { frame.onload = loaded; document.body.append(frame); }); - const darkSpan = frame.contentDocument.querySelector("span"); + const darkDoc = frame.contentDocument; + const darkSelection = darkDoc.createRange(); + darkSelection.selectNodeContents(darkDoc.querySelector("div")); + const darkAuthored = readInlineStyleSpread(darkSelection, "color")[0]; return { colors, - darkParse: parseCssColor(darkSpan.style.color), - darkPicker: resolvePickerColor(darkSpan.style.color, darkSpan, "#ffffff"), + darkParse: parseCssColor(darkAuthored), + darkPicker: resolvePickerColor( + darkAuthored, + readFirstPaintedElement(darkSelection), + "#ffffff", + ), picker: toColorPickerValue(getComputedStyle(element).color), alpha: mergeColorWithExistingAlpha("#123456", "color(srgb 0.4 0 0.6 / 0.25)"), gradient: insertGradientStop(gradient, 50).stops[1].color, @@ -85,6 +95,7 @@ try { }, moduleUrl("colorValue"), moduleUrl("gradientValue"), + moduleUrl("inlineTextStyleRead"), ); const expected = [ [255, 255, 255, 1], From 27ccd1327d52968f71d1515e37097aed0ba02e94 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 14:46:21 -0700 Subject: [PATCH 15/15] fix(studio): read the picker colour from the character the swatch colour came from --- .../components/editor/InlineTextToolbar.tsx | 2 +- .../editor/inlineTextStyleRange.test.ts | 14 ++++++++-- .../components/editor/inlineTextStyleRead.ts | 7 +++-- .../studio/tests/e2e/native-css-colors.mjs | 28 +++++++++++++------ 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/packages/studio/src/components/editor/InlineTextToolbar.tsx b/packages/studio/src/components/editor/InlineTextToolbar.tsx index 0efec607282..7b633a599d2 100644 --- a/packages/studio/src/components/editor/InlineTextToolbar.tsx +++ b/packages/studio/src/components/editor/InlineTextToolbar.tsx @@ -254,7 +254,7 @@ function placeOverSelection( colours, pickerColour: resolvePickerColor( styles.color ?? colours[0], - readFirstPaintedElement(range), + readFirstPaintedElement(range, "color"), DEFAULT_COLOR, ), }; diff --git a/packages/studio/src/components/editor/inlineTextStyleRange.test.ts b/packages/studio/src/components/editor/inlineTextStyleRange.test.ts index 2a31d5f6629..aaf24f78e45 100644 --- a/packages/studio/src/components/editor/inlineTextStyleRange.test.ts +++ b/packages/studio/src/components/editor/inlineTextStyleRange.test.ts @@ -752,12 +752,22 @@ describe("readFirstPaintedElement", () => { const range = document.createRange(); range.selectNodeContents(host); - expect(readFirstPaintedElement(range)).toBe(host.querySelector("span")); + expect(readFirstPaintedElement(range, "color")).toBe(host.querySelector("span")); }); it("skips a leading space, which paints nothing", () => { const host = mount(' Hello'); - expect(readFirstPaintedElement(rangeOver(host, 0, 6))).toBe(host.querySelector("span")); + expect(readFirstPaintedElement(rangeOver(host, 0, 6), "color")).toBe( + host.querySelector("span"), + ); + }); + + it("names the element of the colour the swatch shows, past text with no colour of its own", () => { + const host = mount(' xy'); + const range = rangeOver(host, 0, 3); + + expect(readInlineStyleSpread(range, "color")).toEqual(["lime"]); + expect(readFirstPaintedElement(range, "color")).toBe(host.querySelectorAll("span")[1]); }); }); diff --git a/packages/studio/src/components/editor/inlineTextStyleRead.ts b/packages/studio/src/components/editor/inlineTextStyleRead.ts index 76c8a48abfb..37a13666459 100644 --- a/packages/studio/src/components/editor/inlineTextStyleRead.ts +++ b/packages/studio/src/components/editor/inlineTextStyleRead.ts @@ -46,8 +46,11 @@ export function readInlineStyleSpread(range: Range, property: string): string[] return spread; } -export function readFirstPaintedElement(range: Range): Element | null { +export function readFirstPaintedElement(range: Range, property: string): Element | null { const covered = readCoveredInlineStyleChars(range); if (!covered) return null; - return (covered.find(({ char }) => char.trim()) ?? covered[0])?.painter ?? null; + const painted = covered.filter(({ char }) => char.trim()); + const owner = + painted.find(({ style }) => style[property] !== undefined) ?? painted[0] ?? covered[0]; + return owner?.painter ?? null; } diff --git a/packages/studio/tests/e2e/native-css-colors.mjs b/packages/studio/tests/e2e/native-css-colors.mjs index 267af5e529b..eaba42e57b4 100644 --- a/packages/studio/tests/e2e/native-css-colors.mjs +++ b/packages/studio/tests/e2e/native-css-colors.mjs @@ -70,23 +70,32 @@ try { const frame = document.createElement("iframe"); frame.srcdoc = '
' + - 'x
'; + 'x' + + '
' + + 'xy
'; await new Promise((loaded) => { frame.onload = loaded; document.body.append(frame); }); const darkDoc = frame.contentDocument; + const [wholeHost, mixedHost] = darkDoc.querySelectorAll("div"); const darkSelection = darkDoc.createRange(); - darkSelection.selectNodeContents(darkDoc.querySelector("div")); - const darkAuthored = readInlineStyleSpread(darkSelection, "color")[0]; + darkSelection.selectNodeContents(wholeHost); + const mixedSpans = mixedHost.querySelectorAll("span"); + const mixedSelection = darkDoc.createRange(); + mixedSelection.setStart(mixedSpans[0].firstChild, 0); + mixedSelection.setEnd(mixedSpans[1].firstChild, 1); + const pickFor = (selection) => + resolvePickerColor( + readInlineStyleSpread(selection, "color")[0], + readFirstPaintedElement(selection, "color"), + "#ffffff", + ); return { colors, - darkParse: parseCssColor(darkAuthored), - darkPicker: resolvePickerColor( - darkAuthored, - readFirstPaintedElement(darkSelection), - "#ffffff", - ), + darkParse: parseCssColor(readInlineStyleSpread(darkSelection, "color")[0]), + darkPicker: pickFor(darkSelection), + mixedPicker: pickFor(mixedSelection), picker: toColorPickerValue(getComputedStyle(element).color), alpha: mergeColorWithExistingAlpha("#123456", "color(srgb 0.4 0 0.6 / 0.25)"), gradient: insertGradientStop(gradient, 50).stops[1].color, @@ -138,6 +147,7 @@ try { } assert.equal(results.darkParse, null, "light-dark() needs the element's colour scheme"); assert.equal(results.darkPicker, "#ff0000", "dark-scheme light-dark() picks what it paints"); + assert.equal(results.mixedPicker, "#ff0000", "the picker reads the element its colour came from"); assert.equal(results.picker, "#00b9c3"); assert.equal(results.alpha, "rgba(18, 52, 86, 0.25)"); assert.equal(results.gradient, "#808080");