fix(stylesheet): keep color picker state aligned with source (#3431)

* fix(stylesheet): keep color picker state aligned with source

* fix(stylesheet): serialize picker edits as hex with alpha

* fix: preserve contextual colors in stylesheet editor
This commit is contained in:
Amruth Pillai
2026-09-05 07:29:39 -07:00
committed by GitHub
parent a9973c0054
commit 735e700929
8 changed files with 409 additions and 24 deletions
+12 -2
View File
@@ -32,11 +32,21 @@ type ColorPickerProps = {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
open?: boolean;
onOpenChange?: React.ComponentProps<typeof Popover>["onOpenChange"];
trigger?: React.ReactNode;
children?: React.ReactNode;
};
export function ColorPicker({ value, defaultValue, onChange, trigger, children }: ColorPickerProps) {
export function ColorPicker({
value,
defaultValue,
onChange,
open,
onOpenChange,
trigger,
children,
}: ColorPickerProps) {
const [currentValue, setCurrentValue] = useControlledState<string>({
value,
defaultValue,
@@ -51,7 +61,7 @@ export function ColorPicker({ value, defaultValue, onChange, trigger, children }
}
return (
<Popover>
<Popover open={open} onOpenChange={onOpenChange}>
{trigger ?? (
<PopoverTrigger>
<div
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { serializeStylesheetColor, toStylesheetPickerColor } from "./color-format";
describe("stylesheet picker color formatting", () => {
it.each([
["rgba(231, 0, 11, 1)", "#e7000b"],
["rgba(21, 93, 252, 0)", "#155dfc00"],
["rgba(21, 93, 252, 0.5)", "#155dfc80"],
["rgba(255, 255, 255, 0.25)", "#ffffff40"],
])("serializes %s with alpha only when needed", (input, expected) => {
expect(serializeStylesheetColor(input)).toBe(expected);
});
it("does not invent a resolved value for currentcolor", () => {
expect(toStylesheetPickerColor("currentcolor")).toBe("currentcolor");
});
it.each(["#e7000b", "#155dfc00", "#155dfc80", "#ffffff40", "#abcdef01", "#abcdeffe"])(
"retains every color and alpha byte when reopening %s",
(value) => expect(serializeStylesheetColor(toStylesheetPickerColor(value))).toBe(value),
);
it.each([
["red", "#ff0000"],
["BLUE", "#0000ff"],
["rgb(100% 0% 0% / 50%)", "#ff000080"],
["rgb(21 93 252 / 0.5)", "#155dfc80"],
["#F00", "#ff0000"],
["#F000", "#ff000000"],
["#f008", "#ff000088"],
["hsl(120, 100%, 50%)", "#00ff00"],
["hsla(120, 100%, 50%, 0.5)", "#00ff0080"],
["hsl(120 100% 50% / 25%)", "#00ff0040"],
["rgba(21, 93, 252, 0.5)", "#155dfc80"],
["transparent", "#00000000"],
])("adapts existing %s to the picker without losing its color", (input, expected) => {
expect(serializeStylesheetColor(toStylesheetPickerColor(input))).toBe(expected);
});
it.each(["invalid", "rgba(1, 2, 3, nope)", "rgba(256, 0, 0, 1)", "rgba(1, 2, 3, 1.1)"])(
"rejects invalid picker output %s",
(value) => expect(serializeStylesheetColor(value)).toBeNull(),
);
});
@@ -0,0 +1,57 @@
import { hexToRgba, hslaStringToHsva, hsvaToRgbaString, rgbaStringToHsva } from "@uiw/color-convert";
import { parseColorString } from "@reactive-resume/utils/color";
// Literal names exposed by compiler-confirmed editor swatches. currentcolor stays contextual.
const namedColors: Readonly<Record<string, string>> = {
aqua: "#00ffff",
black: "#000000",
blue: "#0000ff",
fuchsia: "#ff00ff",
gray: "#808080",
green: "#008000",
lime: "#00ff00",
maroon: "#800000",
navy: "#000080",
olive: "#808000",
orange: "#ffa500",
purple: "#800080",
red: "#ff0000",
silver: "#c0c0c0",
teal: "#008080",
white: "#ffffff",
yellow: "#ffff00",
};
// The shared picker consumes RGBA; keep stylesheet hex/HSL source intact until an edit.
export function toStylesheetPickerColor(value: string): string {
const literal = value.replaceAll(/\/\*[\s\S]*?\*\//g, " ").trim();
const normalized = namedColors[literal.toLowerCase()] ?? literal;
if (/^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(normalized)) {
const hex =
normalized.length <= 5 ? `#${[...normalized.slice(1)].map((digit) => digit + digit).join("")}` : normalized;
const { r, g, b, a } = hexToRgba(hex);
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
if (/^hsla?\(/i.test(normalized)) return hsvaToRgbaString(hslaStringToHsva(normalized));
if (/^rgba?\(/i.test(normalized)) return hsvaToRgbaString(rgbaStringToHsva(normalized));
if (normalized.toLowerCase() === "transparent") return "rgba(0, 0, 0, 0)";
return normalized;
}
export function serializeStylesheetColor(value: string): string | null {
const color = parseColorString(value);
if (!color) return null;
const { r, g, b, a } = color;
if (
![r, g, b, a].every(Number.isFinite) ||
[r, g, b].some((channel) => channel < 0 || channel > 255) ||
a < 0 ||
a > 1
)
return null;
const byte = (channel: number) =>
Math.round(Math.max(0, Math.min(255, channel)))
.toString(16)
.padStart(2, "0");
return `#${byte(r)}${byte(g)}${byte(b)}${a < 1 ? byte(a * 255) : ""}`;
}
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
import { collectCompiledColorTokens } from "./color-tokens";
describe("compiler color source ranges", () => {
it.each([
"rgba(231, 0, 11, 1)",
"rgb(0 0 0 / 50%)",
"hsl(210, 50%, 40%)",
"hsla(210, 50%, 40%, 0.7)",
"hsl(210 50% 40% / 0.7)",
])("keeps the exact %s source text after compiler normalization", (value) => {
const source = `@version 1;\nsection { color: ${value} !important; }`;
const compiled = compileStylesheet({ languageVersion: 1, text: source });
expect(compiled.diagnostics).toEqual([]);
const from = source.indexOf(value);
expect(collectCompiledColorTokens(source, compiled.program)).toEqual([{ from, to: from + value.length, value }]);
});
it("ignores comments, strings, and uncompiled values while deduplicating expanded border colors", () => {
const value = "rgba(231, 0, 11, 1)";
const source = `@version 1;\nsection { color /*: red */: /* red */ red; border: 1pt solid ${value}; color: "red"; font-family: red; }`;
const compiled = compileStylesheet({ languageVersion: 1, text: source });
const red = source.indexOf("red;");
const rgba = source.indexOf(value);
expect(collectCompiledColorTokens(source, compiled.program)).toEqual([
{ from: red, to: red + 3, value: "red" },
{ from: rgba, to: rgba + value.length, value },
]);
});
});
@@ -7,8 +7,22 @@ export type SemanticCssColorToken = {
value: string;
};
// Contextual currentcolor cannot be resolved by the literal color picker.
const colorValue =
/^(?:#[\da-f]{3,8}|(?:rgb|rgba|hsl|hsla)\([^)]*\)|(?:aqua|black|blue|currentcolor|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|transparent|white|yellow))$/i;
/^(?:#[\da-f]{3,8}|(?:rgb|rgba|hsl|hsla)\([^)]*\)|(?:aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|transparent|white|yellow))$/i;
// Keep comments and strings whole so their contents cannot be mistaken for editable color values.
const declarationToken =
/\/\*[\s\S]*?\*\/|"(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|:|(?:rgba?|hsla?)\([^)]*\)|#[\da-f]{3,8}\b|\b[a-z]+\b/gi;
function normalizeColor(value: string): string {
return value
.replaceAll(/\/\*[\s\S]*?\*\//g, "")
.replaceAll(/\s+/g, " ")
.replaceAll(/\s*([(),/])\s*/g, "$1")
.trim()
.toLowerCase();
}
const isColorProperty = (property: string) =>
PROPERTY_REGISTRY_V1[property] !== undefined &&
@@ -25,11 +39,16 @@ export function collectCompiledColorTokens(
for (const declaration of rule.declarations) {
if (!isColorProperty(declaration.property) || !colorValue.test(declaration.value)) continue;
const declarationSource = source.slice(declaration.range.start.offset, declaration.range.end.offset);
const valueOffset = declarationSource.indexOf(declaration.value, declarationSource.indexOf(":") + 1);
if (valueOffset < 0) continue;
const from = declaration.range.start.offset + valueOffset;
const token = { from, to: from + declaration.value.length, value: declaration.value };
tokens.set(`${token.from}:${token.to}`, token);
const compiledColor = normalizeColor(declaration.value);
let inValue = false;
for (const match of declarationSource.matchAll(declarationToken)) {
const value = match[0];
if (value === ":") inValue = true;
if (!inValue || !colorValue.test(value) || normalizeColor(value) !== compiledColor) continue;
const from = declaration.range.start.offset + match.index;
const token = { from, to: from + value.length, value };
tokens.set(`${token.from}:${token.to}`, token);
}
}
}
@@ -6,8 +6,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorView } from "@codemirror/view";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
import { collectCompiledColorTokens } from "./color-tokens";
import StylesheetEditorShell, { StylesheetCodeEditor } from "./editor";
import { LegacyStylesheetBanner } from "./legacy-banner";
import { StylesheetStatus } from "./status";
@@ -109,6 +111,25 @@ beforeEach(() => {
const renderWithI18n = (element: React.ReactNode) => render(<I18nProvider i18n={i18n}>{element}</I18nProvider>);
function renderColorEditor(source: string) {
const onChange = vi.fn();
const editor = (value: string) => (
<I18nProvider i18n={i18n}>
<StylesheetCodeEditor
value={value}
diagnostics={[]}
colorTokens={collectCompiledColorTokens(value, compileStylesheet({ languageVersion: 1, text: value }).program)}
theme="light"
onChange={onChange}
onUndo={vi.fn()}
onRedo={vi.fn()}
/>
</I18nProvider>
);
const result = render(editor(source));
return { ...result, onChange, replaceSource: (value: string) => result.rerender(editor(value)) };
}
describe("stylesheet editor status", () => {
it("explains that fatal source falls back to base styles", () => {
renderWithI18n(<StylesheetStatus mode="semantic" status="idle" diagnostics={[fatalError]} />);
@@ -147,6 +168,84 @@ describe("stylesheet editor status", () => {
});
describe("StylesheetCodeEditor", () => {
it("preserves contextual currentcolor without exposing an editable literal swatch", () => {
const source = "@version 1;\nsection { color: red; border-color: currentcolor; }";
expect(compileStylesheet({ languageVersion: 1, text: source }).diagnostics).toEqual([]);
const { onChange } = renderColorEditor(source);
expect(screen.getByRole("button", { name: "Edit color red" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Edit color currentcolor" })).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveTextContent(
"border-color: currentcolor",
);
expect(onChange).not.toHaveBeenCalled();
});
it("removes the temporary color trigger when the picker closes", async () => {
const { container } = renderColorEditor("@version 1;\nsection { color: #f00; }");
fireEvent.click(screen.getByRole("button", { name: "Edit color #f00" }));
fireEvent.click(await screen.findByRole("button", { name: "Use color rgba(231, 0, 11, 1)" }));
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
await waitFor(() => expect(screen.queryByText("Presets")).not.toBeInTheDocument());
expect(container.querySelector("[data-semantic-css-color-picker-trigger]")).not.toBeInTheDocument();
});
it("keeps the picker open for successive presets without replacing the next color", async () => {
const source = "@version 1;\nsection { color: #f00; background-color: #fff; }";
const { onChange, replaceSource } = renderColorEditor(source);
fireEvent.click(screen.getByRole("button", { name: "Edit color #f00" }));
fireEvent.click(await screen.findByRole("button", { name: "Use color rgba(0, 0, 0, 1)" }));
const first = source.replace("#f00", "#000000");
expect(onChange).toHaveBeenLastCalledWith(first);
replaceSource(first);
fireEvent.click(screen.getByRole("button", { name: "Use color rgba(231, 0, 11, 1)" }));
expect(onChange).toHaveBeenLastCalledWith(source.replace("#f00", "#e7000b"));
expect(screen.getByText("Presets")).toBeInTheDocument();
});
it.each([
["undo", "rgba(231, 0, 11, 1)", "#f00"],
["redo", "#f00", "rgba(231, 0, 11, 1)"],
])("closes a stale selection when %s replaces the document", async (_action, initial, replacement) => {
const source = `@version 1;\nsection { color: ${initial}; background-color: #fff; }`;
const { container, onChange, replaceSource } = renderColorEditor(source);
fireEvent.click(screen.getByRole("button", { name: `Edit color ${initial}` }));
await screen.findByText("Presets");
const nextSource = source.replace(initial, replacement);
replaceSource(nextSource);
await waitFor(() => expect(screen.queryByText("Presets")).not.toBeInTheDocument());
expect(container.querySelector("[data-semantic-css-color-picker-trigger]")).not.toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Edit color #fff" }));
fireEvent.click(await screen.findByRole("button", { name: "Use color rgba(0, 0, 0, 1)" }));
expect(onChange).toHaveBeenLastCalledWith(nextSource.replace("#fff", "#000000"));
});
it("closes the picker after text edits and rejects stale compiler color ranges", async () => {
const source = "@version 1;\nsection { color: #f00; background-color: #fff; }";
const { container, onChange } = renderColorEditor(source);
fireEvent.click(screen.getByRole("button", { name: "Edit color #f00" }));
await screen.findByText("Presets");
const view = EditorView.findFromDOM(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" }));
if (!view) throw new Error("Missing editor view");
const from = source.indexOf("#f00");
act(() => view.dispatch({ changes: { from, to: from + 4, insert: "#00f" } }));
await waitFor(() => expect(screen.queryByText("Presets")).not.toBeInTheDocument());
expect(container.querySelector("[data-semantic-css-color-picker-trigger]")).not.toBeInTheDocument();
onChange.mockClear();
// The compile worker has not sent updated tokens yet; the old swatch must not overwrite the edited color.
fireEvent.click(screen.getByRole("button", { name: "Edit color #f00" }));
fireEvent.click(await screen.findByRole("button", { name: "Use color rgba(0, 0, 0, 1)" }));
expect(view.state.doc.toString()).toBe(source.replace("#f00", "#00f"));
expect(onChange).not.toHaveBeenCalled();
});
it("owns one LTR EditorView and ignores externally replaced documents", () => {
const onChange = vi.fn();
const destroy = vi.spyOn(EditorView.prototype, "destroy");
@@ -203,7 +302,7 @@ describe("StylesheetCodeEditor", () => {
const source = "section { color: #f00; background-color: #fff; }";
const first = source.indexOf("#f00");
const second = source.indexOf("#fff");
const { container } = render(
const { container } = renderWithI18n(
<StylesheetCodeEditor
value={source}
diagnostics={[]}
@@ -220,9 +319,10 @@ describe("StylesheetCodeEditor", () => {
const swatches = container.querySelectorAll<HTMLButtonElement>(".semantic-css-color-swatch");
expect(swatches).toHaveLength(2);
swatches[0]?.click();
if (!swatches[0] || !swatches[1]) throw new Error("Missing color swatches");
fireEvent.click(swatches[0]);
await waitFor(() => expect(container.querySelectorAll("[data-semantic-css-color-picker-trigger]")).toHaveLength(1));
swatches[1]?.click();
fireEvent.click(swatches[1]);
await waitFor(() => expect(container.querySelectorAll("[data-semantic-css-color-picker-trigger]")).toHaveLength(1));
});
});
@@ -35,6 +35,7 @@ import { ColorPicker } from "@/components/input/color-picker";
import { useIsResumeLocked, useResumeData, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useTheme } from "@/features/theme/provider";
import { useBuilderSidebarStore } from "@/routes/builder/$resumeId/-store/sidebar";
import { serializeStylesheetColor, toStylesheetPickerColor } from "./color-format";
import { compositionAwareDocumentListener, createSemanticCssEditorExtensions } from "./editor-extensions";
import { enterStylesheetFocusMode } from "./focus-mode";
import { formatEditorDocument } from "./formatter";
@@ -44,6 +45,7 @@ import { StylesheetToolbar } from "./toolbar";
import { createCompileWorkerClient } from "./worker-client";
const externalReplacement = Annotation.define<boolean>();
const colorPickerEdit = Annotation.define<boolean>();
const emptyMetadata: SemanticCssEditorMetadata = {
semanticTree: { key: "resume", kind: "resume", attributes: {}, roles: [], children: [] },
templateParts: [],
@@ -118,8 +120,6 @@ export function StylesheetCodeEditor({
}: StylesheetCodeEditorProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const colorTriggerRef = useRef<HTMLButtonElement | null>(null);
const openColorPickerRef = useRef(false);
const compartmentsRef = useRef<EditorCompartments | null>(null);
const initialPropsRef = useRef({ value, diagnostics, colorTokens, metadata, theme, readOnly, label });
const onChangeRef = useRef(onChange);
@@ -135,7 +135,6 @@ export function StylesheetCodeEditor({
const selectColor = useCallback((token: SemanticCssColorToken, rect: DOMRect) => {
const hostRect = hostRef.current?.getBoundingClientRect();
if (!hostRect) return;
openColorPickerRef.current = true;
setSelectedColor({ token, left: rect.left - hostRect.left, top: rect.top - hostRect.top });
}, []);
@@ -206,6 +205,15 @@ export function StylesheetCodeEditor({
(source) => onChangeRef.current(source),
(update) => update.transactions.some((transaction) => transaction.annotation(externalReplacement)),
),
EditorView.updateListener.of((update) => {
if (
update.transactions.some(
(transaction) => transaction.docChanged && !transaction.annotation(colorPickerEdit),
)
) {
setSelectedColor(null);
}
}),
compartments.theme.of(editorTheme(initial.theme === "dark")),
compartments.readOnly.of(readOnlyExtensions(initial.readOnly)),
compartments.intelligence.of(
@@ -268,19 +276,25 @@ export function StylesheetCodeEditor({
});
}, [value]);
useEffect(() => {
if (!selectedColor || !openColorPickerRef.current) return;
openColorPickerRef.current = false;
queueMicrotask(() => colorTriggerRef.current?.click());
}, [selectedColor]);
const updateColor = (value: string) => {
const updateColor = (pickerValue: string) => {
const value = serializeStylesheetColor(pickerValue);
if (value === null) return;
const view = viewRef.current;
if (!view || !selectedColor) return;
const { from, to } = selectedColor.token;
if (
view.state.readOnly ||
from < 0 ||
to > view.state.doc.length ||
from >= to ||
view.state.doc.sliceString(from, to) !== selectedColor.token.value
) {
setSelectedColor(null);
return;
}
view.dispatch({
changes: { from, to, insert: value },
annotations: Transaction.userEvent.of("input"),
annotations: [Transaction.userEvent.of("input"), colorPickerEdit.of(true)],
});
setSelectedColor((current) =>
current
@@ -297,13 +311,23 @@ export function StylesheetCodeEditor({
{selectedColor && (
<div className="pointer-events-none absolute z-20" style={{ left: selectedColor.left, top: selectedColor.top }}>
<ColorPicker
value={selectedColor.token.value}
open
onOpenChange={(open, details) => {
const target = details.event.target;
if (
target instanceof Element &&
target.closest(".semantic-css-color-swatch") &&
hostRef.current?.contains(target)
)
return;
if (!open) setSelectedColor(null);
}}
value={toStylesheetPickerColor(selectedColor.token.value)}
onChange={updateColor}
trigger={
<PopoverTrigger
render={
<button
ref={colorTriggerRef}
data-semantic-css-color-picker-trigger=""
type="button"
title={t`Edit color ${selectedColor.token.value}`}
@@ -0,0 +1,100 @@
import { createSemanticCssResume, readStylesheetSource, seedSemanticCssResume } from "../../fixtures/semantic-css";
import { expect, test } from "../../fixtures/test";
test("@semantic-css keeps color picker edits and swatches aligned through dismissal and undo", async ({
authPage: page,
}, testInfo) => {
test.setTimeout(60_000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const resumeId = await createSemanticCssResume(page, testInfo);
const source = "@version 1;\nsection {\n\tcolor: #f00;\n\tbackground-color: #fff;\n}";
await seedSemanticCssResume(page, resumeId, {
stylesheet: { mode: "semantic", source: { languageVersion: 1, text: source } },
});
const swatches = page.locator(".semantic-css-color-swatch");
const trigger = page.locator("[data-semantic-css-color-picker-trigger]");
const picker = page.getByRole("dialog").filter({ has: page.getByText("Presets", { exact: true }) });
await expect(swatches).toHaveCount(2);
await page.getByRole("button", { name: "Edit color #f00", exact: true }).click();
const first = source.replace("#f00", "#000000");
await picker.getByRole("button", { name: "Use color rgba(0, 0, 0, 1)", exact: true }).click();
await expect.poll(() => readStylesheetSource(page)).toBe(first);
await expect(page.getByText("Presets", { exact: true })).toBeVisible();
const second = source.replace("#f00", "#e7000b");
await picker.getByRole("button", { name: "Use color rgba(231, 0, 11, 1)", exact: true }).click();
await expect.poll(() => readStylesheetSource(page)).toBe(second);
await expect(swatches).toHaveCount(2);
await page.keyboard.press("Escape");
await expect(page.getByText("Presets", { exact: true })).toHaveCount(0);
await expect(trigger).toHaveCount(0);
await page.getByRole("button", { name: "Edit color #e7000b", exact: true }).click();
await expect(page.getByText("Presets", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(trigger).toHaveCount(0);
await page.getByRole("button", { name: "Undo stylesheet edit", exact: true }).click();
// Rapid presets can coalesce into one undo step; either complete prior stylesheet is valid.
await expect.poll(async () => [source, first].includes(await readStylesheetSource(page))).toBe(true);
await expect(trigger).toHaveCount(0);
await page.getByRole("button", { name: "Redo stylesheet edit", exact: true }).click();
await expect.poll(() => readStylesheetSource(page)).toBe(second);
await expect(swatches).toHaveCount(2);
await page.getByRole("button", { name: "Edit color #fff", exact: true }).click();
await picker.getByRole("button", { name: "Use color rgba(21, 93, 252, 1)", exact: true }).click();
await expect.poll(() => readStylesheetSource(page)).toBe(second.replace("#fff", "#155dfc"));
await page.keyboard.press("Escape");
await expect(trigger).toHaveCount(0);
await expect(swatches).toHaveCount(2);
await expect(page.getByRole("button", { name: "Edit color #e7000b", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Edit color #155dfc", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Open focus mode", exact: true }).click();
await expect(page.getByRole("button", { name: "Edit color #e7000b", exact: true })).toBeInViewport();
await expect(page.getByRole("button", { name: "Edit color #155dfc", exact: true })).toBeInViewport();
await expect(page.getByText("Saved", { exact: true })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("color-picker-after-undo.png"), animations: "disabled" });
expect(errors).toEqual([]);
});
for (const inputColor of ["#FF000080", "red", "rgb(100% 0% 0% / 50%)"]) {
test(`@semantic-css preserves alpha when editing ${inputColor} and reopening hex colors`, async ({
authPage: page,
}, testInfo) => {
const resumeId = await createSemanticCssResume(page, testInfo);
const source = `@version 1;\nsection { color: ${inputColor}; background-color: #fff; }`;
await seedSemanticCssResume(page, resumeId, {
stylesheet: { mode: "semantic", source: { languageVersion: 1, text: source } },
});
await page.getByRole("button", { name: `Edit color ${inputColor}`, exact: true }).click();
const picker = page.getByRole("dialog").filter({ has: page.getByText("Presets", { exact: true }) });
await expect(picker).toBeVisible();
expect(await readStylesheetSource(page)).toBe(source);
const alpha = picker.locator(".w-color-alpha:not(.w-color-hue)");
const bounds = await alpha.boundingBox();
if (!bounds) throw new Error("Missing alpha control");
await alpha.click({ position: { x: 0, y: bounds.height / 2 } });
await expect.poll(() => readStylesheetSource(page)).toBe(source.replace(inputColor, "#ff000000"));
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Edit color #ff000000", exact: true }).click();
await expect(picker).toBeVisible();
await alpha.click({ position: { x: bounds.width / 2, y: bounds.height / 2 } });
await expect
.poll(async () => {
const value = await readStylesheetSource(page);
const match = value.match(/color: #ff0000([\da-f]{2});/);
return match ? Number.parseInt(match[1] ?? "", 16) : -1;
})
.toBeGreaterThanOrEqual(127);
const halfTransparent = await readStylesheetSource(page);
// Browser pointer coordinates round to pixels; the midpoint can land on either adjacent alpha byte.
expect(halfTransparent).toMatch(/color: #ff0000(?:7f|80);/);
expect(halfTransparent).toContain("background-color: #fff;");
await page.keyboard.press("Escape");
await page.getByRole("button", { name: /^Edit color #ff0000(?:7f|80)$/ }).click();
await expect(picker).toBeVisible();
expect(await readStylesheetSource(page)).toBe(halfTransparent);
});
}