feat(web): write custom styles as plain CSS without a version line or checker

Custom styles no longer need @version 1; at the top: the language
version already lives beside the text, so new and converted stylesheets
leave it out, the editor hides it in older ones, and the compiler
ignores it. The validity status and inline diagnostics are gone too:
whatever applies shows in the preview and anything else is left out.
This commit is contained in:
Amruth Pillai
2026-09-29 14:31:59 +02:00
parent a6676b3268
commit 31fb576099
16 changed files with 64 additions and 458 deletions
-1
View File
@@ -26,7 +26,6 @@
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/language": "^6.12.4",
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet";
import type { SemanticNode } from "@reactive-resume/resume/stylesheet";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Transaction } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
@@ -12,7 +12,6 @@ import {
copySourceToClipboard,
createSemanticCssEditorExtensions,
getSemanticCssHoverDocumentation,
mapCompilerDiagnostics,
} from "./editor-extensions";
const semanticTree: SemanticNode = {
@@ -89,7 +88,7 @@ describe("Semantic CSS editor extensions", () => {
expect(variableLabels).toEqual(expect.arrayContaining(["--brand-accent", "--resume-primary-color"]));
expect(systemLabels).toEqual(expect.arrayContaining(["--resume-primary-color", "--resume-sidebar-width"]));
expect(systemLabels).not.toContain("--resume-font-family");
expect(directiveLabels).toEqual(expect.arrayContaining(["@media", "@version 1;"]));
expect(directiveLabels).toEqual(["@media"]);
});
it("offers only the current property's registered compiler vocabulary", () => {
@@ -164,7 +163,7 @@ describe("Semantic CSS editor extensions", () => {
);
});
it("maps compiler offsets and only decorates compiler-confirmed color values", () => {
it("only decorates compiler-confirmed color values", () => {
const source = "@version 1;\nsection { color: #ff0000; background-color: rgb(0 0 0); }\n";
const compiled = compileStylesheet({ languageVersion: 1, text: source });
expect(compiled.program).not.toBeNull();
@@ -178,25 +177,11 @@ describe("Semantic CSS editor extensions", () => {
},
]);
const diagnostic: SemanticCssDiagnostic = {
code: "INVALID_VALUE",
severity: "error",
message: "Bad value",
range: {
start: { line: 1, column: 1, offset: 2 },
end: { line: 1, column: 30, offset: 99 },
},
};
expect(mapCompilerDiagnostics(10, [diagnostic])).toEqual([
expect.objectContaining({ from: 2, to: 10, severity: "error", message: "Bad value" }),
]);
const selected = vi.fn();
const view = new EditorView({
doc: source,
extensions: createSemanticCssEditorExtensions({
metadata,
diagnostics: [],
colorTokens: tokens,
onColorSelect: selected,
}),
@@ -208,19 +193,6 @@ 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 } });
@@ -256,7 +228,6 @@ describe("Semantic CSS editor extensions", () => {
doc: "section { color: red; }",
extensions: createSemanticCssEditorExtensions({
metadata,
diagnostics: [],
colorTokens: [],
onColorSelect: vi.fn(),
}),
@@ -1,12 +1,10 @@
import type { Completion, CompletionContext, CompletionResult, CompletionSource } from "@codemirror/autocomplete";
import type { Diagnostic } from "@codemirror/lint";
import type { EditorState, Extension } from "@codemirror/state";
import type { DecorationSet, EditorView as EditorViewType, ViewUpdate } from "@codemirror/view";
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet/registry";
import type { SemanticNode } from "@reactive-resume/resume/stylesheet/registry";
import type { SemanticCssColorToken } from "./color-tokens";
import type { SemanticCssEditorMetadata } from "./protocol";
import { autocompletion } from "@codemirror/autocomplete";
import { linter, lintGutter } from "@codemirror/lint";
import { search, searchKeymap } from "@codemirror/search";
import { Decoration, EditorView, hoverTooltip, keymap, ViewPlugin, WidgetType } from "@codemirror/view";
import {
@@ -20,7 +18,7 @@ import {
export type SemanticCssColorSelection = (token: SemanticCssColorToken, rect: DOMRect) => void;
const directives = ["@media", "@version 1;"] as const;
const directives = ["@media"] as const;
function walk(root: SemanticNode): SemanticNode[] {
const nodes: SemanticNode[] = [];
@@ -140,19 +138,6 @@ export function getSemanticCssHoverDocumentation(
return;
}
export function mapCompilerDiagnostics(
docLength: number,
diagnostics: readonly SemanticCssDiagnostic[],
): readonly Diagnostic[] {
return diagnostics.map(({ message, severity, range, code }) => ({
from: Math.max(0, Math.min(docLength, range.start.offset)),
to: Math.max(0, Math.min(docLength, Math.max(range.start.offset, range.end.offset))),
severity,
message,
source: code,
}));
}
export function compositionAwareDocumentListener(
onChange: (source: string) => void,
ignore?: (update: ViewUpdate) => boolean,
@@ -301,7 +286,6 @@ function colorExtension(tokens: readonly SemanticCssColorToken[], onSelect: Sema
export function createSemanticCssEditorExtensions(input: {
metadata: SemanticCssEditorMetadata;
diagnostics: readonly SemanticCssDiagnostic[];
colorTokens: readonly SemanticCssColorToken[];
onColorSelect: SemanticCssColorSelection;
}): Extension {
@@ -310,8 +294,6 @@ export function createSemanticCssEditorExtensions(input: {
hoverExtension(input.metadata),
search({ top: true }),
keymap.of(searchKeymap),
lintGutter(),
linter((view) => mapCompilerDiagnostics(view.state.doc.length, input.diagnostics), { delay: 0 }),
colorExtension(input.colorTokens, input.onColorSelect),
];
}
@@ -11,8 +11,6 @@ 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";
const media = vi.hoisted(() => ({ mobile: false }));
const compileWorker = vi.hoisted(() => ({
@@ -60,28 +58,16 @@ vi.mock("./worker-client", () => ({
}),
}));
const recoverableError: SemanticCssDiagnostic = {
code: "INVALID_VALUE",
const fatalError: SemanticCssDiagnostic = {
code: "VERSION_MISMATCH",
severity: "error",
message: "Invalid value",
message: "Version mismatch",
range: {
start: { line: 2, column: 3, offset: 17 },
end: { line: 2, column: 9, offset: 23 },
},
};
const fatalError: SemanticCssDiagnostic = {
...recoverableError,
code: "VERSION_MISMATCH",
message: "Version mismatch",
};
const resolutionError: SemanticCssDiagnostic = {
...recoverableError,
code: "UNRESOLVED_VARIABLE",
message: "Undefined variable --missing",
};
const guideName = /read the applying custom styles guide.*opens in new tab/i;
const expectGuideLink = (root: HTMLElement) => {
@@ -116,7 +102,6 @@ function renderColorEditor(source: string) {
<I18nProvider i18n={i18n}>
<StylesheetCodeEditor
value={value}
diagnostics={[]}
colorTokens={collectCompiledColorTokens(value, compileStylesheet({ languageVersion: 1, text: value }).program)}
theme="light"
onChange={onChange}
@@ -129,43 +114,6 @@ function renderColorEditor(source: string) {
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]} />);
expect(screen.getByText(/preview and export fall back to base styles/i)).toBeInTheDocument();
expect(screen.getByText("Version mismatch")).toBeInTheDocument();
});
it("explains that recoverable errors preserve valid styles", () => {
renderWithI18n(<StylesheetStatus mode="semantic" status="idle" diagnostics={[recoverableError]} />);
expect(screen.getByText("Valid with errors")).toBeInTheDocument();
expect(screen.getByText(/preview and export keep valid styles/i)).toBeInTheDocument();
expect(screen.queryByText(/fall back to base styles/i)).not.toBeInTheDocument();
});
it("labels a valid legacy draft as ready to activate", () => {
renderWithI18n(<StylesheetStatus mode="legacy" status="idle" diagnostics={[]} />);
expect(screen.getByText("Ready to activate")).toBeInTheDocument();
});
it("labels a legacy draft with warnings as ready to activate", () => {
renderWithI18n(
<StylesheetStatus mode="legacy" status="idle" diagnostics={[{ ...recoverableError, severity: "warning" }]} />,
);
expect(screen.getByText("Ready to activate with warnings")).toBeInTheDocument();
});
it("disables activation while the converted draft has errors", () => {
renderWithI18n(<LegacyStylesheetBanner disabled onActivate={vi.fn()} />);
expect(screen.getByRole("button", { name: /activate semantic css/i })).toBeDisabled();
});
});
describe("StylesheetCodeEditor", () => {
it("preserves contextual currentcolor without exposing an editable literal swatch", () => {
const source = "@version 1;\nsection { color: red; border-color: currentcolor; }";
@@ -249,7 +197,6 @@ describe("StylesheetCodeEditor", () => {
const onChange = vi.fn();
const destroy = vi.spyOn(EditorView.prototype, "destroy");
const props = {
diagnostics: [] as const,
theme: "light" as const,
onChange,
onUndo: vi.fn(),
@@ -276,13 +223,7 @@ describe("StylesheetCodeEditor", () => {
rerender(
<div style={{ height: 200 }}>
<StylesheetCodeEditor
value={"@version 1;\nsection { color: red; }\n"}
{...props}
diagnostics={[recoverableError]}
theme="dark"
readOnly
/>
<StylesheetCodeEditor value={"@version 1;\nsection { color: red; }\n"} {...props} theme="dark" readOnly />
</div>,
);
@@ -290,7 +231,6 @@ describe("StylesheetCodeEditor", () => {
"contenteditable",
"false",
);
expect(container.querySelector(".cm-gutter-lint")).toBeInTheDocument();
unmount();
expect(destroy).toHaveBeenCalledOnce();
@@ -304,7 +244,6 @@ describe("StylesheetCodeEditor", () => {
const { container } = renderWithI18n(
<StylesheetCodeEditor
value={source}
diagnostics={[]}
colorTokens={[
{ from: first, to: first + 4, value: "#f00" },
{ from: second, to: second + 4, value: "#fff" },
@@ -340,7 +279,7 @@ describe("StylesheetEditorShell", () => {
expectGuideLink(container);
});
it("has no apply or save action for an already-semantic stylesheet", async () => {
it("has no apply or save action for an already-semantic stylesheet", () => {
if (!builder.data) throw new Error("Missing resume fixture");
builder.data.metadata.stylesheet = {
mode: "semantic",
@@ -355,7 +294,6 @@ describe("StylesheetEditorShell", () => {
</I18nProvider>,
);
await screen.findByText("Valid");
expect(screen.queryByRole("button", { name: /activate semantic css/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /save|apply/i })).not.toBeInTheDocument();
});
@@ -380,7 +318,7 @@ describe("StylesheetEditorShell", () => {
act(() => view.dispatch({ changes: { from: view.state.doc.length, insert: "name { color: blue; }\n" } }));
expect(builder.data.metadata.stylesheet.source.text).toBe("@version 1;\nname { color: blue; }\n");
expect(builder.data.metadata.stylesheet.source.text).toBe("name { color: blue; }\n");
});
it("switches a converted legacy draft through the ordinary resume update", async () => {
@@ -398,29 +336,11 @@ describe("StylesheetEditorShell", () => {
expect(builder.data?.metadata.stylesheet).toEqual({
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\n" },
source: { languageVersion: 1, text: "" },
});
});
it("allows a converted legacy draft with recoverable errors to activate", async () => {
compileWorker.diagnostics = [resolutionError];
render(
<I18nProvider i18n={i18n}>
<TooltipProvider>
<StylesheetEditorShell />
</TooltipProvider>
</I18nProvider>,
);
const activate = await screen.findByRole("button", { name: "Activate Semantic CSS" });
await waitFor(() => expect(activate).toBeEnabled());
fireEvent.click(activate);
expect(builder.data?.metadata.stylesheet?.mode).toBe("semantic");
});
it("keeps legacy activation disabled for fatal diagnostics", async () => {
it("does not block legacy activation on compiler errors", async () => {
compileWorker.diagnostics = [fatalError];
render(
@@ -431,8 +351,11 @@ describe("StylesheetEditorShell", () => {
</I18nProvider>,
);
await screen.findByText("Fatal error");
expect(screen.getByRole("button", { name: "Activate Semantic CSS" })).toBeDisabled();
const activate = await screen.findByRole("button", { name: "Activate Semantic CSS" });
expect(activate).toBeEnabled();
fireEvent.click(activate);
expect(builder.data?.metadata.stylesheet?.mode).toBe("semantic");
});
it("makes editor mutation controls read-only while the resume is locked", () => {
@@ -476,7 +399,6 @@ describe("StylesheetEditorShell", () => {
expect(within(sheet).getByRole("heading", { name: "Semantic CSS stylesheet" })).toBeInTheDocument();
expect(within(sheet).getByRole("button", { name: "Activate Semantic CSS" })).toBeInTheDocument();
expect(within(sheet).getByRole("toolbar", { name: "Stylesheet editor" })).toBeInTheDocument();
await within(sheet).findByText("Ready to activate");
expectGuideLink(sheet);
expect(document.querySelectorAll(".cm-editor")).toHaveLength(1);
media.mobile = false;
@@ -1,5 +1,5 @@
import type { Extension } from "@codemirror/state";
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet";
import type { SemanticNode } from "@reactive-resume/resume/stylesheet";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import type { SemanticCssColorToken } from "./color-tokens";
@@ -27,7 +27,6 @@ import {
semanticNodeKeys,
shouldShowResumeHeader,
} from "@reactive-resume/pdf/semantic-tree";
import { isFatalStylesheetDiagnostic } from "@reactive-resume/resume/stylesheet";
import { Icon } from "@reactive-resume/ui/components/icon";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
@@ -39,7 +38,6 @@ import { serializeStylesheetColor, toStylesheetPickerColor } from "./color-forma
import { compositionAwareDocumentListener, createSemanticCssEditorExtensions } from "./editor-extensions";
import { formatEditorDocument } from "./formatter";
import { LegacyStylesheetBanner } from "./legacy-banner";
import { StylesheetStatus } from "./status";
import { StylesheetToolbar } from "./toolbar";
import { createCompileWorkerClient } from "./worker-client";
@@ -103,7 +101,6 @@ const readOnlyExtensions = (readOnly: boolean): Extension => [
export type StylesheetCodeEditorProps = {
value: string;
diagnostics: readonly SemanticCssDiagnostic[];
colorTokens?: readonly SemanticCssColorToken[];
metadata?: SemanticCssEditorMetadata;
theme: "light" | "dark";
@@ -118,7 +115,6 @@ export type StylesheetCodeEditorProps = {
export function StylesheetCodeEditor({
value,
diagnostics,
colorTokens = [],
metadata = emptyMetadata,
theme,
@@ -133,7 +129,7 @@ export function StylesheetCodeEditor({
const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const compartmentsRef = useRef<EditorCompartments | null>(null);
const initialPropsRef = useRef({ value, diagnostics, colorTokens, metadata, theme, readOnly, label });
const initialPropsRef = useRef({ value, colorTokens, metadata, theme, readOnly, label });
const onChangeRef = useRef(onChange);
const onFocusChangeRef = useRef(onFocusChange);
const onReadyRef = useRef(onReady);
@@ -233,7 +229,6 @@ export function StylesheetCodeEditor({
compartments.intelligence.of(
createSemanticCssEditorExtensions({
metadata: initial.metadata,
diagnostics: initial.diagnostics,
colorTokens: initial.colorTokens,
onColorSelect: selectColor,
}),
@@ -273,13 +268,12 @@ export function StylesheetCodeEditor({
effects: compartments.intelligence.reconfigure(
createSemanticCssEditorExtensions({
metadata,
diagnostics,
colorTokens,
onColorSelect: selectColor,
}),
),
});
}, [colorTokens, diagnostics, metadata, selectColor]);
}, [colorTokens, metadata, selectColor]);
useEffect(() => {
const view = viewRef.current;
@@ -398,9 +392,7 @@ const createEditorMetadata = (data: ResumeData): SemanticCssEditorMetadata => {
function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps) {
const { resolvedTheme: theme } = useTheme();
const [focusOpen, setFocusOpen] = useState(false);
const [diagnostics, setDiagnostics] = useState<readonly SemanticCssDiagnostic[]>([]);
const [colorTokens, setColorTokens] = useState<readonly SemanticCssColorToken[]>([]);
const [status, setStatus] = useState<"idle" | "compiling" | "error">("compiling");
const [compiler, setCompiler] = useState<ReturnType<typeof createCompileWorkerClient>>();
const data = useResumeData();
const updateResumeData = useUpdateResumeData();
@@ -425,21 +417,16 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
const stylesheet = data?.metadata.stylesheet;
const mode = stylesheet?.mode ?? "legacy";
const source = useMemo<StylesheetSource>(
() =>
stylesheet?.source ??
(data ? convertLegacyStyleRules(data).source : { languageVersion: 1, text: "@version 1;\n" }),
() => stylesheet?.source ?? (data ? convertLegacyStyleRules(data).source : { languageVersion: 1, text: "" }),
[data, stylesheet],
);
const metadata = useMemo(() => (data ? createEditorMetadata(data) : emptyMetadata), [data]);
const hasFatalErrors = status === "error" || diagnostics.some(isFatalStylesheetDiagnostic);
const isChecking = status === "compiling";
const disabled = readOnly || isLocked;
useEffect(() => {
if (!compiler || !data) return;
let cancelled = false;
const editGeneration = ++compileGenerationRef.current;
setStatus("compiling");
setColorTokens([]);
const timer = window.setTimeout(() => {
void compiler
@@ -459,13 +446,10 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
})
.then((result) => {
if (cancelled || result.editGeneration !== compileGenerationRef.current) return;
setDiagnostics(result.diagnostics);
setColorTokens(result.colorTokens ?? []);
setStatus(result.program && !result.diagnostics.some(isFatalStylesheetDiagnostic) ? "idle" : "error");
})
.catch(() => {
if (!cancelled && editGeneration === compileGenerationRef.current) setStatus("error");
});
// Swatches are a nicety: a stylesheet that doesn't compile just shows none.
.catch(() => undefined);
}, 180);
return () => {
@@ -476,15 +460,19 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
if (!data) return null;
const setSourceText = (text: string) => {
if (disabled || text === source.text) return;
// Stylesheets saved before the version moved out of the text start with `@version 1;`: it isn't shown, and the
// first edit drops it (the compiler ignores it either way).
const text = source.text.replace(/^\s*@version\s+\d+\s*;[ \t]*\r?\n?/, "");
const setSourceText = (next: string) => {
if (disabled || next === text) return;
updateResumeData((draft) => {
draft.metadata.stylesheet = { mode, source: { ...source, text } };
draft.metadata.stylesheet = { mode, source: { ...source, text: next } };
});
};
const activate = () => {
if (disabled || mode === "semantic" || hasFatalErrors || isChecking) return;
if (disabled || mode === "semantic") return;
updateResumeData((draft) => {
draft.metadata.stylesheet = { mode: "semantic", source };
});
@@ -495,8 +483,7 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
const editor = (
<StylesheetCodeEditor
value={source.text}
diagnostics={diagnostics}
value={text}
colorTokens={colorTokens}
metadata={metadata}
theme={theme}
@@ -512,12 +499,10 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
);
const editorChrome = (
<div className="space-y-3">
{mode === "legacy" && (
<LegacyStylesheetBanner disabled={disabled || hasFatalErrors || isChecking} onActivate={activate} />
)}
{mode === "legacy" && <LegacyStylesheetBanner disabled={disabled} onActivate={activate} />}
<StylesheetToolbar
source={source.text}
source={text}
canUndo={canUndo}
canRedo={canRedo}
focused={focusOpen}
@@ -551,8 +536,6 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
</p>
<div className={focusOpen ? "h-[55svh] sm:h-[calc(100svh-14rem)]" : "h-72"}>{editor}</div>
<StylesheetStatus mode={mode} status={status} diagnostics={diagnostics} />
</div>
);
@@ -1,87 +0,0 @@
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
import { Trans } from "@lingui/react/macro";
import { isFatalStylesheetDiagnostic } from "@reactive-resume/resume/stylesheet";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Icon } from "@reactive-resume/ui/components/icon";
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
export type StylesheetStatusProps = {
mode: "legacy" | "semantic";
status: "idle" | "compiling" | "error";
diagnostics: readonly SemanticCssDiagnostic[];
};
export function StylesheetStatus({ mode, status, diagnostics }: StylesheetStatusProps) {
const errors = diagnostics.filter(({ severity }) => severity === "error");
const warnings = diagnostics.filter(({ severity }) => severity === "warning");
const hasFatalErrors = status === "error" || diagnostics.some(isFatalStylesheetDiagnostic);
const hasRecoverableErrors = !hasFatalErrors && errors.length > 0;
const isPending = status === "compiling";
return (
<div className="space-y-2" aria-live="polite">
{hasFatalErrors ? (
<Badge variant="danger">
<Icon name="error" size={16} />
<Trans>Fatal error</Trans>
</Badge>
) : isPending ? (
<Badge variant="outline">{mode === "legacy" ? <Trans>Checking draft</Trans> : <Trans>Checking</Trans>}</Badge>
) : hasRecoverableErrors ? (
<Badge variant="warn">
<Icon name="error" size={16} />
{mode === "legacy" ? <Trans>Ready to activate with errors</Trans> : <Trans>Valid with errors</Trans>}
</Badge>
) : warnings.length > 0 ? (
<Badge variant="warn">
<Icon name="warning" size={16} />
{mode === "legacy" ? <Trans>Ready to activate with warnings</Trans> : <Trans>Valid with warnings</Trans>}
</Badge>
) : (
<Badge variant="accent">{mode === "legacy" ? <Trans>Ready to activate</Trans> : <Trans>Valid</Trans>}</Badge>
)}
{hasFatalErrors && (
<Alert variant="error">
<Icon name="error" size={16} />
<AlertTitle>
<Trans>Stylesheet has fatal errors</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview and export fall back to base styles.</Trans>
</AlertDescription>
</Alert>
)}
{hasRecoverableErrors && (
<Alert>
<Icon name="error" size={16} />
<AlertTitle>
<Trans>Some styles were ignored</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview and export keep valid styles and ignore invalid styles.</Trans>
</AlertDescription>
</Alert>
)}
{diagnostics.length > 0 && (
<ScrollArea className="max-h-32 rounded-md border">
<ul className="space-y-2 p-3 text-xs">
{diagnostics.map((diagnostic) => (
<li key={`${diagnostic.code}-${diagnostic.range.start.offset}`} className="space-y-0.5">
<p className="font-medium">{diagnostic.message}</p>
<p className="text-ink-3">
<Trans>
Line {diagnostic.range.start.line}, column {diagnostic.range.start.column}
</Trans>
</p>
</li>
))}
</ul>
</ScrollArea>
)}
</div>
);
}
+14 -40
View File
@@ -22,7 +22,7 @@ current rules remain active while you review it.
Open the resume in the builder, select **Design**, then select **Custom Styles**.
</Step>
<Step title="Review the converted draft">
Check the preview and warnings below the editor. The draft starts with `@version 1;`.
Check the preview against the legacy result.
</Step>
<Step title="Activate Semantic CSS">
Select **Activate Semantic CSS** only after the preview matches the legacy result. Reactive Resume never applies both
@@ -35,24 +35,18 @@ current rules remain active while you review it.
Open the resume you want to style, select **Design**, then select **Custom Styles**. Start with a complete stylesheet:
```css
@version 1;
section[type="experience"] > section-heading {
color: #0f766e;
text-transform: uppercase;
}
```
The first line tells Reactive Resume which language version the stylesheet uses. Keep `@version 1;` at the start of
every stylesheet.
<Steps>
<Step title="Paste one focused rule">
Add the stylesheet to the editor. Start with one visual change so it is easy to review in the preview.
</Step>
<Step title="Wait for Valid">
Reactive Resume checks the source in the browser. When the status changes to **Valid**, compare the preview and
export if you are ready to share the resume.
<Step title="Check the preview">
The preview updates as you type. If nothing changes, the rule didn't apply; check the selector and property.
</Step>
<Step title="Build on the working rule">
Add one related change at a time. Your changes use the normal resume autosave and undo history.
@@ -123,8 +117,6 @@ combinators. It also supports `:root`, `:first-child`, `:last-child`, `:only-chi
`:nth-child()`, and `:nth-of-type()`.
```css
@version 1;
section[type="experience"] > section-heading {
border-bottom: 1pt solid #0f766e;
}
@@ -148,8 +140,6 @@ Semantic CSS exposes the resolved builder settings as read-only `--resume-*` var
reuse the builder values instead of duplicating colors or dimensions.
```css
@version 1;
:root {
--accent: var(--resume-primary-color);
--rule: #cbd5e1;
@@ -195,16 +185,13 @@ 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.
Gradient declarations such as `background-image: linear-gradient(...)` remain unsupported; use `background-color` instead.
An unsupported declaration is left out while the declarations around it still apply.
### Style rich-text lists
@@ -212,8 +199,6 @@ declarations remain available for preview and export.
for the bullet or number, and `list-item-content` for the text flow.
```css
@version 1;
rich-text list-item {
gap: 4pt;
}
@@ -232,8 +217,6 @@ list-item-content {
Target `level` to adjust the space between a skill's circles, icons, or other level decorations:
```css
@version 1;
section[type="skills"] level {
column-gap: 4pt;
}
@@ -248,8 +231,6 @@ Named fields let you make a focused change without styling every item value. Use
exists in the selected resume and template.
```css
@version 1;
section[type="experience"] field[name="position"] {
font-weight: 600;
}
@@ -265,8 +246,6 @@ Template parts expose optional visual details that are not shared by every templ
with `resume[template="..."]`; otherwise the selector may match nothing after a template change.
```css
@version 1;
resume[template="azurill"] template-part[name="timeline-line"] {
background-color: #94a3b8;
}
@@ -298,8 +277,6 @@ Use structural declarations sparingly and review the exported PDF after each cha
leave space before a section, or set a custom page size.
```css
@version 1;
page {
size: 210mm 297mm;
}
@@ -317,8 +294,6 @@ item {
browser viewport.
```css
@version 1;
@media (max-width: 600pt) {
region[placement="sidebar"] {
padding: 12pt;
@@ -331,17 +306,16 @@ Supported media features are `width`, `min-width`, `max-width`, `height`, `min-h
## Diagnose and recover safely
The editor saves the current source even when it has an error. A recoverable error ignores only the invalid declaration,
value, selector, or rule; valid parts still appear in preview and PDF export. A fatal version or resource-limit error
ignores the whole stylesheet and renders the resume with its base styles until you fix the source.
The editor saves whatever you write. Anything it can't apply (an unknown property, a value it doesn't accept, a selector
that doesn't parse) is left out, and everything else still appears in the preview and PDF export. If you see no change,
the rule didn't apply.
If a rule does not work:
1. Read the status below the editor. Errors include a line and column number when available.
2. Check the selector's spelling, attribute value, placement, and template guard. A **selector matches nothing**
warning usually means the resume does not contain that semantic node.
3. Simplify the rule to one selector and one declaration, then wait for **Valid** before adding more.
4. Use the stylesheet undo and redo controls to restore an earlier source.
1. Check the selector's spelling, attribute value, placement, and template guard. A selector that matches nothing usually
means the resume does not contain that semantic node.
2. Simplify the rule to one selector and one declaration, then add more once it shows in the preview.
3. Use the stylesheet undo and redo controls to restore an earlier source.
Select **Open focus mode** when you need a taller editor. On mobile, it opens a full-width sheet; switch to
**Preview** to inspect the result.
@@ -358,9 +332,9 @@ starting point. Exact IDs and template parts are intentionally specific to a res
1. Select **Copy stylesheet** in the source resume.
2. Open **Design → Custom Styles** in the destination resume.
3. Paste the stylesheet and review any warnings.
3. Paste the stylesheet.
4. Replace or remove exact IDs and template-part rules that do not apply.
5. Wait for **Valid**, then compare the preview and exported PDF.
5. Compare the preview and exported PDF.
Semantic CSS does not support classes, pseudo-elements, CSS Grid, arbitrary at-rules, `@import`, `@font-face`, `url()`,
browser APIs, animations, filters, gradients, general box shadows, or external assets. Use the normal builder settings
@@ -1,5 +1,3 @@
@version 1;
/* Global text */
section field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"]) {
color: #111111;
@@ -116,9 +116,7 @@ const serializeBlock = (
serializeGeneratedStylesheet({
languageVersion: 1,
blocks: [{ selector, declarations, ...(comment === undefined ? {} : { comment }) }],
})
.replace(/^@version 1;\n\n/, "")
.trimEnd();
}).trimEnd();
const appliesToAwards = (data: ResumeData, rule: StyleRule): boolean => {
if (rule.target.scope === "global") return true;
@@ -257,7 +255,7 @@ export function convertLegacyStyleRules(data: ResumeData): LegacyStyleConversion
if (!rule.enabled) return [disabledRuleChunk(sanitizedData, rule)];
return styleSlots.flatMap((slot) => (rule.slots[slot] ? activeSlotChunks(sanitizedData, rule, slot) : []));
});
const text = `@version 1;\n${chunks.length > 0 ? `\n${chunks.join("\n\n")}\n` : ""}`;
const text = chunks.length > 0 ? `${chunks.join("\n\n")}\n` : "";
return {
source: { languageVersion: 1, text },
-51
View File
@@ -9,10 +9,6 @@ import { SEMANTIC_NODE_KINDS } from "./registry/semantic";
import { SYSTEM_VARIABLE_REGISTRY_V1 } from "./registry/system-variables";
import { compileProgram, cssFunctionDepth } from "./values";
function isPositiveInteger(value: string): boolean {
return /^[1-9]\d*$/.test(value);
}
const compileVersionOne = (rules: readonly CompiledStyleRule[]): StyleProgram =>
Object.freeze({ languageVersion: 1, rules: Object.freeze([...rules]) });
@@ -42,53 +38,6 @@ export function compileStylesheet(source: StylesheetSource): CompileStylesheetRe
const stylesheet = parseStylesheet(source.text);
const diagnostics = [...stylesheet.diagnostics];
const versionDirectives = stylesheet.atRules.filter((atRule) => atRule.name === "version");
if (versionDirectives.length === 0 && source.languageVersion === 1) {
diagnostics.push(
createDiagnostic("MISSING_VERSION_DIRECTIVE", "warning", "Version-one stylesheets should start with @version 1;"),
);
}
if (versionDirectives.length > 1) {
for (const directive of versionDirectives.slice(1)) {
diagnostics.push(
createDiagnostic(
"DUPLICATE_VERSION_DIRECTIVE",
"error",
"A stylesheet can contain only one @version directive.",
directive.range,
),
);
}
}
for (const directive of versionDirectives) {
if (directive.hasBlock || !isPositiveInteger(directive.prelude)) {
diagnostics.push(
createDiagnostic(
"INVALID_VERSION",
"error",
"@version must contain one positive integer and no block.",
directive.range,
),
);
continue;
}
const version = Number(directive.prelude);
if (version !== source.languageVersion) {
diagnostics.push(
createDiagnostic(
"VERSION_MISMATCH",
"error",
"@version must match the stylesheet language version.",
directive.range,
),
);
}
}
const compiler = source.languageVersion === 1 ? compileVersionOne : undefined;
if (!compiler) {
diagnostics.push(
+2 -28
View File
@@ -7,26 +7,6 @@ type DiagnosticReference = {
};
export const SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 = {
MISSING_VERSION_DIRECTIVE: {
severity: "warning",
meaning: "The stylesheet omitted @version.",
action: "Add @version 1; as the first statement.",
},
DUPLICATE_VERSION_DIRECTIVE: {
severity: "error",
meaning: "More than one @version directive was found.",
action: "Keep exactly one version directive.",
},
INVALID_VERSION: {
severity: "error",
meaning: "The version directive is not one positive integer without a block.",
action: "Use @version 1;.",
},
VERSION_MISMATCH: {
severity: "error",
meaning: "The directive and stored language version disagree.",
action: "Set both to version 1.",
},
UNSUPPORTED_VERSION: {
severity: "error",
meaning: "The requested Semantic CSS version is not implemented.",
@@ -50,7 +30,7 @@ export const SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 = {
UNSUPPORTED_AT_RULE: {
severity: "error",
meaning: "The at-rule is not part of Semantic CSS version 1.",
action: "Use only @version and documented @media queries.",
action: "Use only documented @media queries.",
},
INVALID_MEDIA_QUERY: {
severity: "error",
@@ -127,13 +107,7 @@ export const SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 = {
export type SemanticCssCompilerDiagnosticCode = keyof typeof SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1;
const FATAL_DIAGNOSTIC_CODES = new Set<string>([
"DUPLICATE_VERSION_DIRECTIVE",
"INVALID_VERSION",
"RESOURCE_LIMIT",
"UNSUPPORTED_VERSION",
"VERSION_MISMATCH",
]);
const FATAL_DIAGNOSTIC_CODES = new Set<string>(["RESOURCE_LIMIT", "UNSUPPORTED_VERSION"]);
export function isFatalStylesheetDiagnostic({ code }: Pick<SemanticCssDiagnostic, "code">): boolean {
return FATAL_DIAGNOSTIC_CODES.has(code);
@@ -15,7 +15,7 @@ describe("generated Semantic CSS serialization", () => {
});
expect(output).toBe(
'@version 1;\n\n/* Bad *\\/ label */\nsection[id="projects"] > section-heading {\n\tcolor: #123456;\n\tfont-size: 12pt;\n}\n',
'/* Bad *\\/ label */\nsection[id="projects"] > section-heading {\n\tcolor: #123456;\n\tfont-size: 12pt;\n}\n',
);
});
+1 -1
View File
@@ -43,5 +43,5 @@ export function serializeGeneratedStylesheet(stylesheet: GeneratedStylesheet): s
return `${comment}${block.selector} {\n${declarations}\n}`;
});
return `@version ${stylesheet.languageVersion};\n${blocks.length > 0 ? `\n${blocks.join("\n\n")}\n` : ""}`;
return blocks.length > 0 ? `${blocks.join("\n\n")}\n` : "";
}
+1
View File
@@ -648,6 +648,7 @@ export function compileProgram(stylesheet: ParsedStylesheet, languageVersion: nu
}
if (node.type !== "Atrule" || !node.name) continue;
const name = identifier(node.name).toLowerCase();
// Stylesheets saved before the version moved out of the text start with `@version 1;`.
if (name === "version") continue;
if (name !== "media") {
diagnostic(
+8 -54
View File
@@ -2,68 +2,22 @@ import { describe, expect, it } from "vitest";
import { compileStylesheet } from "./compile";
describe("compileStylesheet", () => {
it("compiles canonical version-one source", () => {
const result = compileStylesheet({
languageVersion: 1,
text: "@version 1;\nsection { color: #123456; }\n",
});
it("compiles plain CSS without a version line", () => {
const result = compileStylesheet({ languageVersion: 1, text: "section { color: #123456; }\n" });
expect(result.program?.languageVersion).toBe(1);
expect(result.diagnostics).toEqual([]);
});
it("warns when version-one source omits the directive", () => {
const result = compileStylesheet({ languageVersion: 1, text: "section { color: red; }" });
it("ignores the @version line older stylesheets start with", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version 1;\nsection { color: #123456; }\n" });
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "MISSING_VERSION_DIRECTIVE", severity: "warning" }),
);
});
it("rejects a directive that disagrees with persisted metadata", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version 2;" });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "VERSION_MISMATCH", severity: "error" }));
});
it("rejects duplicate directives", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version 1;\n@version 1;" });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "DUPLICATE_VERSION_DIRECTIVE", severity: "error" }),
);
});
it("rejects a duplicate version directive nested in an at-rule block", () => {
const result = compileStylesheet({
languageVersion: 1,
text: "@version 1; @media (width: 600pt) { @version 1; name { color: red; } }",
});
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(
expect.objectContaining({ code: "DUPLICATE_VERSION_DIRECTIVE", severity: "error" }),
);
});
it("rejects malformed directives", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version one;" });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VERSION", severity: "error" }));
});
it("rejects non-positive directives", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version 0;" });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VERSION", severity: "error" }));
expect(result.program?.rules).toHaveLength(1);
expect(result.diagnostics).toEqual([]);
});
it("rejects unsupported persisted language versions", () => {
const result = compileStylesheet({ languageVersion: 2, text: "@version 2;" });
const result = compileStylesheet({ languageVersion: 2, text: "section { color: red; }" });
expect(result.program).toBeNull();
expect(result.diagnostics).toContainEqual(
@@ -72,7 +26,7 @@ describe("compileStylesheet", () => {
});
it("compiles around a recovered CSS error", () => {
const result = compileStylesheet({ languageVersion: 1, text: "@version 1;\nsection { color red; }" });
const result = compileStylesheet({ languageVersion: 1, text: "section { color red; }" });
expect(result.program).not.toBeNull();
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "CSS_PARSE_ERROR", severity: "error" }));
-12
View File
@@ -522,9 +522,6 @@ importers:
'@codemirror/language':
specifier: ^6.12.4
version: 6.12.4
'@codemirror/lint':
specifier: ^6.9.7
version: 6.9.7
'@codemirror/search':
specifier: ^6.7.2
version: 6.7.2
@@ -2174,9 +2171,6 @@ packages:
'@codemirror/language@6.12.4':
resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
'@codemirror/lint@6.9.7':
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
'@codemirror/search@6.7.2':
resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
@@ -9747,12 +9741,6 @@ snapshots:
'@lezer/lr': 1.4.10
style-mod: 4.1.4
'@codemirror/lint@6.9.7':
dependencies:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
crelt: 1.0.7
'@codemirror/search@6.7.2':
dependencies:
'@codemirror/state': 6.7.6