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
@@ -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");
});