From 735e70092974055ad010cff85ea91f3336c69123 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 07:29:39 -0700 Subject: [PATCH] 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 --- .../web/src/components/input/color-picker.tsx | 14 ++- .../resume/stylesheet/color-format.test.ts | 44 ++++++++ .../resume/stylesheet/color-format.ts | 57 ++++++++++ .../resume/stylesheet/color-tokens.test.ts | 31 +++++ .../resume/stylesheet/color-tokens.ts | 31 ++++- .../resume/stylesheet/editor.test.tsx | 106 +++++++++++++++++- .../src/features/resume/stylesheet/editor.tsx | 50 ++++++--- .../specs/semantic-css/color-picker.spec.ts | 100 +++++++++++++++++ 8 files changed, 409 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/features/resume/stylesheet/color-format.test.ts create mode 100644 apps/web/src/features/resume/stylesheet/color-format.ts create mode 100644 apps/web/src/features/resume/stylesheet/color-tokens.test.ts create mode 100644 tests/e2e/specs/semantic-css/color-picker.spec.ts diff --git a/apps/web/src/components/input/color-picker.tsx b/apps/web/src/components/input/color-picker.tsx index 7bf0c06b3..c73dbf988 100644 --- a/apps/web/src/components/input/color-picker.tsx +++ b/apps/web/src/components/input/color-picker.tsx @@ -32,11 +32,21 @@ type ColorPickerProps = { value?: string; defaultValue?: string; onChange?: (value: string) => void; + open?: boolean; + onOpenChange?: React.ComponentProps["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({ value, defaultValue, @@ -51,7 +61,7 @@ export function ColorPicker({ value, defaultValue, onChange, trigger, children } } return ( - + {trigger ?? (
{ + 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(), + ); +}); diff --git a/apps/web/src/features/resume/stylesheet/color-format.ts b/apps/web/src/features/resume/stylesheet/color-format.ts new file mode 100644 index 000000000..510c5e2d8 --- /dev/null +++ b/apps/web/src/features/resume/stylesheet/color-format.ts @@ -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> = { + 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) : ""}`; +} diff --git a/apps/web/src/features/resume/stylesheet/color-tokens.test.ts b/apps/web/src/features/resume/stylesheet/color-tokens.test.ts new file mode 100644 index 000000000..7546bf1ff --- /dev/null +++ b/apps/web/src/features/resume/stylesheet/color-tokens.test.ts @@ -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 }, + ]); + }); +}); diff --git a/apps/web/src/features/resume/stylesheet/color-tokens.ts b/apps/web/src/features/resume/stylesheet/color-tokens.ts index 69f40c9c1..2e475a043 100644 --- a/apps/web/src/features/resume/stylesheet/color-tokens.ts +++ b/apps/web/src/features/resume/stylesheet/color-tokens.ts @@ -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); + } } } diff --git a/apps/web/src/features/resume/stylesheet/editor.test.tsx b/apps/web/src/features/resume/stylesheet/editor.test.tsx index 02a8d313f..d046a5381 100644 --- a/apps/web/src/features/resume/stylesheet/editor.test.tsx +++ b/apps/web/src/features/resume/stylesheet/editor.test.tsx @@ -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({element}); +function renderColorEditor(source: string) { + const onChange = vi.fn(); + const editor = (value: string) => ( + + + + ); + 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(); @@ -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( { const swatches = container.querySelectorAll(".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)); }); }); diff --git a/apps/web/src/features/resume/stylesheet/editor.tsx b/apps/web/src/features/resume/stylesheet/editor.tsx index c286485ad..88da18522 100644 --- a/apps/web/src/features/resume/stylesheet/editor.tsx +++ b/apps/web/src/features/resume/stylesheet/editor.tsx @@ -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(); +const colorPickerEdit = Annotation.define(); const emptyMetadata: SemanticCssEditorMetadata = { semanticTree: { key: "resume", kind: "resume", attributes: {}, roles: [], children: [] }, templateParts: [], @@ -118,8 +120,6 @@ export function StylesheetCodeEditor({ }: StylesheetCodeEditorProps) { const hostRef = useRef(null); const viewRef = useRef(null); - const colorTriggerRef = useRef(null); - const openColorPickerRef = useRef(false); const compartmentsRef = useRef(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 && (
{ + 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={ { + 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); + }); +}