diff --git a/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts b/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts index 2400e1904..6630991b3 100644 --- a/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts +++ b/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts @@ -208,6 +208,19 @@ describe("Semantic CSS editor extensions", () => { expect(selected).toHaveBeenCalledWith(tokens[0], expect.any(DOMRect)); }); + it("keeps unsupported-gradient diagnostics visible in the editor", () => { + const source = "@version 1;\nheader { background-image: linear-gradient(red, blue); }\n"; + const compiled = compileStylesheet({ languageVersion: 1, text: source }); + + expect(mapCompilerDiagnostics(source.length, compiled.diagnostics)).toContainEqual( + expect.objectContaining({ + severity: "error", + message: "Gradients are not supported by Semantic CSS. Use background-color or another supported property.", + source: "UNSUPPORTED_PROPERTY", + }), + ); + }); + it("preserves exact clipboard text and emits one change for an IME composition", async () => { const writeText = vi.fn().mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); diff --git a/apps/web/src/features/resume/stylesheet/stylesheet.worker.test.ts b/apps/web/src/features/resume/stylesheet/stylesheet.worker.test.ts index 1beee6d5a..fc77f7088 100644 --- a/apps/web/src/features/resume/stylesheet/stylesheet.worker.test.ts +++ b/apps/web/src/features/resume/stylesheet/stylesheet.worker.test.ts @@ -59,6 +59,23 @@ describe("stylesheet worker", () => { ); }); + it("explains that unsupported gradients need a supported background replacement", () => { + handleMessage?.( + new MessageEvent("message", { + data: request("@version 1;\nheader { background-image: linear-gradient(red, blue); }\n"), + }), + ); + + const response = postMessage.mock.calls[0]?.[0] as CompileWorkerResponse | undefined; + expect(response?.diagnostics).toContainEqual( + expect.objectContaining({ + code: "UNSUPPORTED_PROPERTY", + severity: "error", + message: "Gradients are not supported by Semantic CSS. Use background-color or another supported property.", + }), + ); + }); + it("returns overlapping semantic diagnostics once", () => { handleMessage?.( new MessageEvent("message", { diff --git a/docs/applying-custom-styles.mdx b/docs/applying-custom-styles.mdx index 031d57670..66ea12a3e 100644 --- a/docs/applying-custom-styles.mdx +++ b/docs/applying-custom-styles.mdx @@ -192,6 +192,20 @@ The most useful declarations usually fall into a few groups: Use `display: none` only to hide an existing semantic node. Semantic CSS cannot add, remove, duplicate, or re-parent resume data. +Semantic CSS keeps background styling PDF-safe. Use a flat color for headers and regions: + +```css +@version 1; + +header { + background-color: #1e293b; +} +``` + +Gradient declarations such as `background-image: linear-gradient(...)` remain unsupported. The editor reports that limitation +and suggests `background-color` or another supported property; the unsupported declaration is omitted while neighboring valid +declarations remain available for preview and export. + ### Style rich-text lists `list-item` is the outer row that holds a marker and its content. Use it for row layout and spacing. Use `list-marker` diff --git a/packages/pdf/src/semantic/issue-fixtures.test.tsx b/packages/pdf/src/semantic/issue-fixtures.test.tsx index c513847f4..0825b209f 100644 --- a/packages/pdf/src/semantic/issue-fixtures.test.tsx +++ b/packages/pdf/src/semantic/issue-fixtures.test.tsx @@ -42,6 +42,11 @@ const mergedStyle = (node: HostNode | undefined): Record => const containsStyle = (node: HostNode, property: string, value: unknown): boolean => mergedStyle(node)[property] === value || (node.children ?? []).some((child) => containsStyle(child, property, value)); +const nodesWithStyle = (node: HostNode, property: string, value: unknown): HostNode[] => [ + ...(mergedStyle(node)[property] === value ? [node] : []), + ...(node.children ?? []).flatMap((child) => nodesWithStyle(child, property, value)), +]; + const textRuns = (node: HostNode): string[] => [ ...(node.type === "TEXT" ? [nodeText(node)] : []), ...(node.children ?? []).flatMap((child) => textRuns(child)), @@ -53,7 +58,11 @@ const buildIssueFixture = (): ResumeData => { data.basics = { ...data.basics, name: "Ada Lovelace", + headline: "Computing pioneer", email: "ada@example.com", + phone: "+44 123", + location: "London", + customFields: [{ id: "custom-1", icon: "globe", text: "Ada Labs", link: "" }], }; data.sections.experience.items = [ { @@ -200,8 +209,12 @@ describe("semantic issue fixtures", () => { const data = buildIssueFixture(); const stylesheet = source(` @version 1; - header { background-color: #1e293b; } + header { background-color: #1e293b; padding: 10pt; } name { color: white; } + headline { font-size: 14pt; } + contact-list { gap: 8pt; } + contact-item { padding: 1pt; } + icon { font-size: 16pt; } link { text-decoration: none; } section[type="experience"] field[name="company"] { font-weight: 400; } section[type="skills"] field[name="name"] { font-weight: 400; } @@ -214,12 +227,18 @@ describe("semantic issue fixtures", () => { const document = instance.container.document as HostNode; expect(mergedStyle(findText(document, "Ada Lovelace"))).toMatchObject({ color: "white" }); + expect(mergedStyle(findText(document, "Computing pioneer"))).toMatchObject({ fontSize: 14 }); expect(mergedStyle(findText(document, "Analytical Engines"))).toMatchObject({ fontWeight: "400" }); expect(mergedStyle(findText(document, "TypeScript"))).toMatchObject({ fontWeight: "400" }); + expect(mergedStyle(findPrimitive(document, "LINK", "ada@example.com")).paddingTop).toBe(1); + expect(mergedStyle(findPrimitive(document, "LINK", "+44 123")).paddingTop).toBe(1); + expect(nodesWithStyle(document, "fontSize", 16).some(({ type }) => type === "SVG")).toBe(true); expect(mergedStyle(findPrimitive(document, "LINK", "ada@example.com"))).toMatchObject({ textDecoration: "none", }); expect(containsStyle(document, "backgroundColor", "#1e293b")).toBe(true); + expect(containsStyle(document, "paddingTop", 10)).toBe(true); + expect(containsStyle(document, "rowGap", 8)).toBe(true); expect(containsStyle(document, "opacity", 0.2)).toBe(true); }); diff --git a/packages/resume/src/stylesheet/values.test.ts b/packages/resume/src/stylesheet/values.test.ts index c2dda7c22..5f64e6209 100644 --- a/packages/resume/src/stylesheet/values.test.ts +++ b/packages/resume/src/stylesheet/values.test.ts @@ -70,6 +70,64 @@ describe("Semantic CSS value compilation", () => { ); }); + it("explains that gradient backgrounds are unsupported and suggests a safe replacement", () => { + const result = compileStylesheet({ + languageVersion: 1, + text: "@version 1; header { background-image: linear-gradient(red, blue); color: white; }", + }); + + expect(result.program).not.toBeNull(); + expect(result.program?.rules[0]?.declarations).toContainEqual( + expect.objectContaining({ property: "color", value: "white" }), + ); + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "UNSUPPORTED_PROPERTY", + severity: "error", + message: "Gradients are not supported by Semantic CSS. Use background-color or another supported property.", + }), + ); + }); + + it.each([ + "linear-gradient(red, blue)", + "radial-gradient(red, blue)", + "conic-gradient(red, blue)", + "repeating-linear-gradient(red, blue)", + "repeating-radial-gradient(red, blue)", + "repeating-conic-gradient(red, blue)", + ])("recognizes the standard gradient function %s", (value) => { + const result = compileStylesheet({ + languageVersion: 1, + text: `@version 1; header { background-image: ${value}; }`, + }); + + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "UNSUPPORTED_PROPERTY", + message: "Gradients are not supported by Semantic CSS. Use background-color or another supported property.", + }), + ); + }); + + it.each([ + ["custom function names", "background-image: not-linear-gradient(red, blue)", "background-image"], + ["quoted function-like text", 'content: "linear-gradient(red, blue)"', "content"], + ] as const)("does not treat %s as a standard gradient function", (_case, declaration, property) => { + const result = compileStylesheet({ + languageVersion: 1, + text: `@version 1; header { ${declaration}; }`, + }); + + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "UNSUPPORTED_PROPERTY", + severity: "error", + message: `The ${property} property is not supported by Semantic CSS.`, + }), + ); + }); + it("omits an invalid value without dropping valid declarations in the rule", () => { const result = compileStylesheet({ languageVersion: 1, diff --git a/packages/resume/src/stylesheet/values.ts b/packages/resume/src/stylesheet/values.ts index 71948f75c..e04c34408 100644 --- a/packages/resume/src/stylesheet/values.ts +++ b/packages/resume/src/stylesheet/values.ts @@ -149,6 +149,29 @@ function diagnostic( diagnostics.push(createDiagnostic(code, severity, message, range(node?.loc))); } +const gradientFunctionNames = new Set([ + "linear-gradient", + "radial-gradient", + "conic-gradient", + "repeating-linear-gradient", + "repeating-radial-gradient", + "repeating-conic-gradient", +]); + +function containsGradientFunction(node: AstNode | string | null | undefined): boolean { + if (!node || typeof node === "string") return false; + if (node.type === "Function" && node.name && gradientFunctionNames.has(identifier(node.name).toLowerCase())) + return true; + return children(node).some(containsGradientFunction); +} + +function unsupportedPropertyMessage(property: string, value: AstNode | string | null | undefined): string { + if (containsGradientFunction(value)) { + return "Gradients are not supported by Semantic CSS. Use background-color or another supported property."; + } + return `The ${property} property is not supported by Semantic CSS.`; +} + function splitValue(value: string): string[] { const parts: string[] = []; let start = 0; @@ -560,6 +583,8 @@ export function compileProgram(stylesheet: ParsedStylesheet, languageVersion: nu const decodedProperty = identifier(declaration.property); const property = decodedProperty.startsWith("--") ? decodedProperty : decodedProperty.toLowerCase(); const lowerProperty = property.toLowerCase(); + const value = + typeof declaration.value === "string" ? declaration.value : csstree.generate(declaration.value as CssNode); if (lowerProperty.startsWith("--resume-")) { diagnostic( diagnostics, @@ -573,7 +598,7 @@ export function compileProgram(stylesheet: ParsedStylesheet, languageVersion: nu diagnostic( diagnostics, property === "src" ? "FORBIDDEN_CSS_VALUE" : "UNSUPPORTED_PROPERTY", - `The ${property} property is not supported by Semantic CSS.`, + unsupportedPropertyMessage(property, declaration.value), declaration, ); continue; @@ -583,8 +608,6 @@ export function compileProgram(stylesheet: ParsedStylesheet, languageVersion: nu continue; } - const value = - typeof declaration.value === "string" ? declaration.value : csstree.generate(declaration.value as CssNode); const trimmedValue = value.trim(); const expanded = /var\s*\(/i.test(decodeCssEscapes(trimmedValue)) ? ([[property, trimmedValue]] as const)