mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-26 00:02:32 +10:00
feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
4ac19f81b3
commit
d2ffbf9618
@@ -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>
|
||||
))}
|
||||
|
||||
+23
-2
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user