From cf60aa5874920dc63cef5934e01b12102ddb7940 Mon Sep 17 00:00:00 2001 From: Dmitry Kryaklin Date: Tue, 8 Sep 2026 00:42:22 +0300 Subject: [PATCH] fix(studio): parse modern CSS colors in the inspector and gradient editor parseCssColor only read hex and comma-separated rgb(). Computed styles arrive as oklch(), oklab() or color(srgb ...), and gradient stops as whatever the author wrote, so the inspector showed black for all of them and committing wrote black over the author's color. Parse with @colordx/core, which covers CSS Color 4. The gradient editor and the inline text toolbar share the same parser. --- bun.lock | 3 ++ packages/studio/package.json | 1 + .../components/editor/InlineTextToolbar.tsx | 26 ++------- .../src/components/editor/colorValue.test.ts | 34 ++++++++++++ .../src/components/editor/colorValue.ts | 53 +++++-------------- .../components/editor/gradientValue.test.ts | 26 +++++++++ .../src/components/editor/gradientValue.ts | 36 ++----------- 7 files changed, 84 insertions(+), 95 deletions(-) diff --git a/bun.lock b/bun.lock index 9b76975680..23953f88e2 100644 --- a/bun.lock +++ b/bun.lock @@ -326,6 +326,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "6.40.0", + "@colordx/core": "^6.4.0", "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", "@hyperframes/player": "workspace:*", @@ -550,6 +551,8 @@ "@codemirror/view": ["@codemirror/view@6.40.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg=="], + "@colordx/core": ["@colordx/core@6.4.0", "", {}, "sha512-KJf2x955gXdCXTVQVd4kh4V89UJq/iUg366DHL1dv5+Bdugehak9lK2veBvOBwOqlusCCo+hKpVXDG437EW2/g=="], + "@commitlint/cli": ["@commitlint/cli@20.5.3", "", { "dependencies": { "@commitlint/format": "^20.5.0", "@commitlint/lint": "^20.5.3", "@commitlint/load": "^20.5.3", "@commitlint/read": "^20.5.0", "@commitlint/types": "^20.5.0", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, "bin": { "commitlint": "./cli.js" } }, "sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA=="], "@commitlint/config-conventional": ["@commitlint/config-conventional@20.5.3", "", { "dependencies": { "@commitlint/types": "^20.5.0", "conventional-changelog-conventionalcommits": "^9.2.0" } }, "sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ=="], diff --git a/packages/studio/package.json b/packages/studio/package.json index 50f8e6ebfc..702db8a051 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -67,6 +67,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "6.40.0", + "@colordx/core": "^6.4.0", "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", "@hyperframes/player": "workspace:*", 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..f28273a97f 100644 --- a/packages/studio/src/components/editor/colorValue.test.ts +++ b/packages/studio/src/components/editor/colorValue.test.ts @@ -36,12 +36,46 @@ 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 }], + ["white", { red: 255, green: 255, blue: 255, alpha: 1 }], + ["rgb(255 0 0 / 50%)", { red: 255, green: 0, blue: 0, alpha: 0.5 }], + ["hsl(210 40% 50%)", { red: 77, green: 127, blue: 179, alpha: 1 }], + ["color(srgb 0.4 0 0.6)", { red: 102, green: 0, blue: 153, alpha: 1 }], + ["oklch(0.7 0.15 200)", { red: 0, green: 185, blue: 195, alpha: 1 }], + ["oklab(0.6 0.1 0.1)", { red: 195, green: 96, blue: 46, alpha: 1 }], + ])("parses %s", (input, expected) => { + expect(parseCssColor(input)).toEqual(expected); + }); + + it("clips out-of-gamut colors to srgb", () => { + expect(parseCssColor("oklch(0.9 0.35 140)")).toEqual({ + red: 0, + green: 255, + blue: 0, + alpha: 1, + }); + }); + + it.each(["", "#12", "notacolor", "currentcolor", "none"])("rejects %s", (input) => { + expect(parseCssColor(input)).toBeNull(); + }); }); describe("toColorPickerValue", () => { it("converts css color to hex", () => { expect(toColorPickerValue("rgba(15, 23, 42, 0.64)")).toBe("#0f172a"); }); + + it("converts modern css colors to hex", () => { + expect(toColorPickerValue("oklch(0.7 0.15 200)")).toBe("#00b9c3"); + }); + + 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..f5e1f56c3f 100644 --- a/packages/studio/src/components/editor/colorValue.ts +++ b/packages/studio/src/components/editor/colorValue.ts @@ -1,5 +1,9 @@ +import { colordx, extend } from "@colordx/core"; +import names from "@colordx/core/plugins/names"; import { roundToCenti } from "../../utils/rounding"; +extend([names]); + export interface ParsedColor { red: number; green: number; @@ -30,46 +34,15 @@ function formatAlpha(value: number): string { } export function parseCssColor(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); - 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 { - 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), - }; - } - - return null; + const color = colordx(value.trim()); + if (!color.isValid()) return null; + const { r, g, b, alpha } = color.toRgb(); + return { + red: clampChannel(r), + green: clampChannel(g), + blue: clampChannel(b), + alpha: clampAlpha(alpha), + }; } export function toColorPickerValue(value: string): string { diff --git a/packages/studio/src/components/editor/gradientValue.test.ts b/packages/studio/src/components/editor/gradientValue.test.ts index 4a5dc6ca42..04a9e2b513 100644 --- a/packages/studio/src/components/editor/gradientValue.test.ts +++ b/packages/studio/src/components/editor/gradientValue.test.ts @@ -86,4 +86,30 @@ describe("insertGradientStop", () => { ], }); }); + + it("interpolates between named stops", () => { + const parsed = parseGradient("linear-gradient(90deg, black 0%, white 100%)"); + expect(parsed).not.toBeNull(); + + expect(insertGradientStop(parsed!, 50)).toMatchObject({ + stops: [ + { color: "black", position: 0 }, + { color: "#808080", position: 50 }, + { color: "white", position: 100 }, + ], + }); + }); + + 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, - }; -}