feat: add semantic CSS stylesheets (#3274)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Amruth Pillai
2026-07-30 12:39:15 +02:00
committed by GitHub
co-authored by Cursor Agent
parent 4ac19f81b3
commit d2ffbf9618
320 changed files with 78393 additions and 2915 deletions
@@ -17,6 +17,11 @@ import {
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useResumeStore } from "@/features/resume/builder/draft";
import {
lockStylesheetStoreForRestore,
replaceStylesheetStoreAfterRestore,
unlockStylesheetStoreAfterRestore,
} from "@/features/resume/stylesheet/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { formatRelativeTime } from "@/libs/locale";
@@ -39,7 +44,7 @@ export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps)
enabled: open,
});
const { mutate: restoreVersion, isPending } = useMutation(orpc.resume.restoreVersion.mutationOptions());
const { mutateAsync: restoreVersion, isPending } = useMutation(orpc.resume.restoreVersion.mutationOptions());
const handleRestore = async (versionId: string) => {
const confirmed = await confirm(t`Restore this version?`, {
@@ -48,18 +53,28 @@ export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps)
if (!confirmed) return;
restoreVersion(
{ resumeId, versionId },
{
onSuccess: (restored) => {
replaceResumeFromServer(restored as Resume);
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored);
void queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId } }) });
toast.success(t`Your resume has been restored to the selected version.`);
},
onError: (error) => toast.error(getResumeErrorMessage(error)),
},
);
const token = lockStylesheetStoreForRestore(resumeId);
if (!token) return;
try {
const restored = await restoreVersion({ resumeId, versionId });
const applied = replaceStylesheetStoreAfterRestore({
resumeId,
resumeData: restored.resume.data,
initial: restored.stylesheetState,
token,
});
if (!applied) {
unlockStylesheetStoreAfterRestore(token);
return;
}
replaceResumeFromServer(restored.resume as Resume);
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored.resume);
void queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId } }) });
toast.success(t`Your resume has been restored to the selected version.`);
} catch (error) {
unlockStylesheetStoreAfterRestore(token);
toast.error(getResumeErrorMessage(error));
}
};
return (
@@ -1,4 +1,5 @@
import type { RightSidebarSection } from "@/libs/resume/section";
import { useRouteContext } from "@tanstack/react-router";
import { Fragment, useCallback, useRef } from "react";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
@@ -22,13 +23,13 @@ import { StatisticsSectionBuilder } from "./sections/statistics";
import { TemplateSectionBuilder } from "./sections/template";
import { TypographySectionBuilder } from "./sections/typography";
function getSectionComponent(type: RightSidebarSection) {
function getSectionComponent(type: RightSidebarSection, semanticCssAuthoring: boolean) {
return match(type)
.with("template", () => <TemplateSectionBuilder />)
.with("layout", () => <LayoutSectionBuilder />)
.with("typography", () => <TypographySectionBuilder />)
.with("design", () => <DesignSectionBuilder />)
.with("styles", () => <CustomStylesSectionBuilder />)
.with("styles", () => <CustomStylesSectionBuilder authoringEnabled={semanticCssAuthoring} />)
.with("page", () => <PageSectionBuilder />)
.with("notes", () => <NotesSectionBuilder />)
.with("sharing", () => <SharingSectionBuilder />)
@@ -41,6 +42,8 @@ function getSectionComponent(type: RightSidebarSection) {
export function BuilderSidebarRight() {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const context = useRouteContext({ strict: false });
const semanticCssAuthoring = context.flags?.semanticCssAuthoring ?? false;
return (
<>
@@ -53,7 +56,7 @@ export function BuilderSidebarRight() {
<div className="space-y-4 p-4">
{rightSidebarSections.map((section) => (
<Fragment key={section}>
{getSectionComponent(section)}
{getSectionComponent(section, semanticCssAuthoring)}
<Separator />
</Fragment>
))}
@@ -40,8 +40,13 @@ vi.mock("@/features/resume/builder/draft", () => ({
useUpdateResumeData: () => updateResumeData,
}));
vi.mock("@/features/resume/stylesheet/editor", () => ({
default: () => <div data-testid="semantic-css-editor-shell">Semantic CSS editor</div>,
}));
const { CustomStylesSectionBuilder } = await import("./custom-styles");
const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section");
const { useStylesheetStore } = await import("@/features/resume/stylesheet/store");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -49,12 +54,13 @@ beforeAll(() => {
beforeEach(() => {
updateResumeData.mockClear();
useStylesheetStore.setState({ mode: "legacy" });
});
const renderCustomStyles = () =>
const renderCustomStyles = (authoringEnabled = false) =>
render(
<I18nProvider i18n={i18n}>
<CustomStylesSectionBuilder />
<CustomStylesSectionBuilder authoringEnabled={authoringEnabled} />
</I18nProvider>,
);
@@ -75,6 +81,21 @@ describe("CustomStylesSectionBuilder", () => {
});
});
it("loads the Semantic CSS shell only when authoring is enabled", async () => {
renderCustomStyles(true);
expect(await screen.findByTestId("semantic-css-editor-shell")).toBeInTheDocument();
expect(screen.queryByLabelText("Target Scope")).not.toBeInTheDocument();
});
it("shows a read-only notice for active Semantic CSS when authoring is disabled", () => {
useStylesheetStore.setState({ mode: "semantic" });
renderCustomStyles();
expect(screen.getByText(/semantic styles remain active/i)).toBeInTheDocument();
expect(screen.queryByLabelText("Target Scope")).not.toBeInTheDocument();
});
it("renders structured style rule controls", async () => {
renderCustomStyles();
@@ -9,7 +9,7 @@ import type { ReactNode } from "react";
import type { ComboboxOption } from "@/components/ui/combobox";
import { Trans } from "@lingui/react/macro";
import { EyeIcon, EyeSlashIcon, PencilSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
@@ -20,9 +20,14 @@ import { cn } from "@reactive-resume/utils/style";
import { ColorPicker } from "@/components/input/color-picker";
import { Combobox } from "@/components/ui/combobox";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SemanticStylesheetReadOnlyNotice } from "@/features/resume/stylesheet/legacy-banner";
import { useStylesheetStore } from "@/features/resume/stylesheet/store";
import { getSectionTitle } from "@/libs/resume/section";
import { useSectionStore } from "../../../-store/section";
import { SectionBase } from "../shared/section-base";
const StylesheetEditorShell = lazy(() => import("@/features/resume/stylesheet/editor"));
type TargetScope = StyleRuleTarget["scope"];
type StyleSlotOption = {
@@ -100,15 +105,36 @@ const exactFourControlGridClassName = "grid grid-cols-1 gap-3 @min-[20rem]:grid-
const compactSpacingInputClassName =
"h-8 w-18 max-w-18 min-w-0 px-1.5 text-center text-xs tabular-nums placeholder:text-[0.68rem] placeholder:uppercase placeholder:tracking-wide";
export function CustomStylesSectionBuilder() {
export type CustomStylesSectionBuilderProps = {
authoringEnabled?: boolean;
};
export function CustomStylesSectionBuilder({ authoringEnabled = false }: CustomStylesSectionBuilderProps) {
const mode = useStylesheetStore((state) => state.mode);
const collapsed = useSectionStore((state) => state.sections.styles?.collapsed ?? false);
return (
<SectionBase type="styles" className="space-y-4">
<CustomStylesSectionForm />
{authoringEnabled ? (
collapsed ? null : (
<Suspense
fallback={
<div role="status" className="h-72 animate-pulse rounded-md bg-muted" aria-label="Loading editor" />
}
>
<StylesheetEditorShell />
</Suspense>
)
) : mode === "semantic" ? (
<SemanticStylesheetReadOnlyNotice />
) : (
<LegacyCustomStylesSectionForm />
)}
</SectionBase>
);
}
function CustomStylesSectionForm() {
function LegacyCustomStylesSectionForm() {
const resume = useCurrentResume();
const data = resume.data;
const updateResumeData = useUpdateResumeData();
@@ -19,6 +19,14 @@ const resumeMock = vi.hoisted(() => ({
slug: string;
data: typeof defaultResumeData;
},
stylesheet: {
resumeId: "r1" as string | undefined,
mode: "semantic" as "legacy" | "semantic",
source: { languageVersion: 1, text: "@version 1;\nname {" },
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
revision: 42,
renderDataVersion: 7,
},
}));
type SectionBaseProps = {
@@ -42,6 +50,9 @@ vi.mock("@/libs/resume/section-title-locale", () => ({
vi.mock("@/features/resume/builder/draft", () => ({
useResume: () => resumeMock.resume,
}));
vi.mock("@/features/resume/stylesheet/store", () => ({
useStylesheetStore: (selector: (state: typeof resumeMock.stylesheet) => unknown) => selector(resumeMock.stylesheet),
}));
const { ExportSectionBuilder } = await import("./export");
@@ -51,6 +62,14 @@ beforeAll(() => {
beforeEach(() => {
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: defaultResumeData };
resumeMock.stylesheet = {
resumeId: "r1",
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\nname {" },
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
revision: 42,
renderDataVersion: 7,
};
});
afterEach(() => {
@@ -96,7 +115,7 @@ describe("ExportSectionBuilder", () => {
expect(filename).toBe("My Resume.md");
});
it("downloads a JSON blob when the JSON button is clicked", () => {
it("downloads canonical stylesheet content in JSON without concurrency metadata", async () => {
renderExport();
openDialog();
fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
@@ -107,6 +126,14 @@ describe("ExportSectionBuilder", () => {
expect(blob).toBeInstanceOf(Blob);
expect((blob as Blob).type).toBe("application/json");
expect(filename).toBe("My Resume.json");
const exported = JSON.parse(await (blob as Blob).text());
expect(exported.metadata.stylesheet).toEqual({
mode: "semantic",
source: resumeMock.stylesheet.source,
applied: resumeMock.stylesheet.applied,
});
expect(JSON.stringify(exported)).not.toContain("revision");
expect(JSON.stringify(exported)).not.toContain("renderDataVersion");
});
it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => {
@@ -128,6 +155,12 @@ describe("ExportSectionBuilder", () => {
await Promise.resolve();
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
expect(createResumePdfBlob).toHaveBeenCalledWith(defaultResumeData, undefined, undefined, {
stylesheet: {
mode: "semantic",
applied: resumeMock.stylesheet.applied,
},
});
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf");
});
@@ -1,9 +1,10 @@
import type { BuilderLayout } from "./-store/sidebar";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useMediaQuery } from "usehooks-ts";
import { useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore } from "@/features/resume/builder/draft";
import { initializeStylesheetStore, useStylesheetStore } from "@/features/resume/stylesheet/store";
import { orpc } from "@/libs/orpc/client";
import { createNoindexFollowMeta } from "@/libs/seo";
import { DesktopBuilderShell } from "./-components/desktop-builder-shell";
@@ -20,6 +21,9 @@ export const Route = createFileRoute("/builder/$resumeId")({
const [layout, resume] = await Promise.all([
getBuilderLayout(),
context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } })),
context.queryClient.ensureQueryData(
orpc.resume.stylesheet.getState.queryOptions({ input: { id: params.resumeId } }),
),
]);
return { layout, name: resume.name };
@@ -36,11 +40,17 @@ function RouteComponent() {
const { resumeId } = Route.useParams();
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: resumeId } }));
const { data: stylesheet } = useSuspenseQuery(
orpc.resume.stylesheet.getState.queryOptions({ input: { id: resumeId } }),
);
const initializeResumeStore = useResumeStore((state) => state.initialize);
const mergeResumeMetadata = useResumeStore((state) => state.mergeResumeMetadata);
const isReady = useResumeStore((state) => state.isReady);
const initializedResumeId = useResumeStore((state) => state.resumeId);
const isInitialized = isReady && initializedResumeId === resumeId;
const isStylesheetInitialized = useStylesheetStore((state) => state.resumeId === resumeId);
const stylesheetInitialization = useRef({ resume, stylesheet });
stylesheetInitialization.current = { resume, stylesheet };
useResumeCleanup();
useBuilderResumeUpdateSubscription();
@@ -50,6 +60,16 @@ function RouteComponent() {
initializeResumeStore(resume);
}, [initializeResumeStore, isInitialized, resume]);
useEffect(() => {
if (!isInitialized) return;
const initial = stylesheetInitialization.current;
return initializeStylesheetStore({
resumeId,
initial: initial.stylesheet,
resumeData: initial.resume.data,
});
}, [isInitialized, resumeId]);
useEffect(() => {
mergeResumeMetadata(resume);
}, [
@@ -65,7 +85,7 @@ function RouteComponent() {
resume,
]);
if (!isInitialized) return null;
if (!isInitialized || !isStylesheetInitialized) return null;
return <BuilderLayoutShell initialLayout={initialLayout} />;
}