Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
26 changes: 4 additions & 22 deletions packages/studio/src/components/editor/InlineTextToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ function placeOverSelection(
placeBelow,
styles,
colours,
pickerColour: toPickerColour(styles.color ?? colours[0], doc),
pickerColour: toPickerColour(styles.color ?? colours[0]),
};
}

Expand All @@ -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;
}
102 changes: 101 additions & 1 deletion packages/studio/src/components/editor/colorValue.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
formatCssColor,
hsvToRgb,
Expand Down Expand Up @@ -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<string, string>) {
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", () => {
Expand Down
104 changes: 80 additions & 24 deletions packages/studio/src/components/editor/colorValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,49 +29,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";
Expand Down
13 changes: 13 additions & 0 deletions packages/studio/src/components/editor/gradientValue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
],
});
});
});
36 changes: 3 additions & 33 deletions packages/studio/src/components/editor/gradientValue.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { roundToCenti } from "../../utils/rounding";
import { parseCssColor } from "./colorValue";

export type GradientKind = "linear" | "radial" | "conic";

Expand Down Expand Up @@ -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);
Expand All @@ -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,
};
}
Loading