mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-15 02:53:25 +10:00
feat: enable semantic CSS by default
This commit is contained in:
@@ -27,17 +27,3 @@ export function LegacyStylesheetBanner({ disabled, onActivate }: LegacyStyleshee
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function SemanticStylesheetReadOnlyNotice() {
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertTitle>
|
||||
<Trans>Semantic styles remain active</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>This instance does not currently allow Semantic CSS editing.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -23,13 +22,13 @@ import { StatisticsSectionBuilder } from "./sections/statistics";
|
||||
import { TemplateSectionBuilder } from "./sections/template";
|
||||
import { TypographySectionBuilder } from "./sections/typography";
|
||||
|
||||
function getSectionComponent(type: RightSidebarSection, semanticCssAuthoring: boolean) {
|
||||
function getSectionComponent(type: RightSidebarSection) {
|
||||
return match(type)
|
||||
.with("template", () => <TemplateSectionBuilder />)
|
||||
.with("layout", () => <LayoutSectionBuilder />)
|
||||
.with("typography", () => <TypographySectionBuilder />)
|
||||
.with("design", () => <DesignSectionBuilder />)
|
||||
.with("styles", () => <CustomStylesSectionBuilder authoringEnabled={semanticCssAuthoring} />)
|
||||
.with("styles", () => <CustomStylesSectionBuilder />)
|
||||
.with("page", () => <PageSectionBuilder />)
|
||||
.with("notes", () => <NotesSectionBuilder />)
|
||||
.with("sharing", () => <SharingSectionBuilder />)
|
||||
@@ -42,8 +41,6 @@ function getSectionComponent(type: RightSidebarSection, semanticCssAuthoring: bo
|
||||
|
||||
export function BuilderSidebarRight() {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const context = useRouteContext({ strict: false });
|
||||
const semanticCssAuthoring = context.flags?.semanticCssAuthoring ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -56,7 +53,7 @@ export function BuilderSidebarRight() {
|
||||
<div className="space-y-4 p-4">
|
||||
{rightSidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
{getSectionComponent(section, semanticCssAuthoring)}
|
||||
{getSectionComponent(section)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
+13
-456
@@ -1,43 +1,12 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { StyleRule } from "@reactive-resume/schema/resume/data";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { isValidElement } from "react";
|
||||
|
||||
const updateResumeData = vi.hoisted(() => vi.fn());
|
||||
const styleRules = vi.hoisted<StyleRule[]>(() => [
|
||||
{
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { color: "rgba(220, 38, 38, 1)" } },
|
||||
},
|
||||
]);
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
metadata: { styleRules },
|
||||
sections: {
|
||||
experience: { title: "Experience" },
|
||||
skills: { title: "Skills" },
|
||||
},
|
||||
customSections: [{ id: "custom-1", title: "Open Source", type: "projects" }],
|
||||
},
|
||||
}),
|
||||
useUpdateResumeData: () => updateResumeData,
|
||||
SectionBase: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resume/stylesheet/editor", () => ({
|
||||
@@ -45,439 +14,27 @@ vi.mock("@/features/resume/stylesheet/editor", () => ({
|
||||
}));
|
||||
|
||||
const { CustomStylesSectionBuilder } = await import("./custom-styles");
|
||||
const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section");
|
||||
const { useStylesheetStore } = await import("@/features/resume/stylesheet/store");
|
||||
const { useSectionStore } = await import("../../../-store/section");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
updateResumeData.mockClear();
|
||||
useStylesheetStore.setState({ mode: "legacy" });
|
||||
useSectionStore.setState((state) => ({
|
||||
sections: {
|
||||
...state.sections,
|
||||
styles: { ...state.sections.styles, collapsed: false },
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const renderCustomStyles = (authoringEnabled = false) =>
|
||||
it("always loads the Semantic CSS shell", async () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<CustomStylesSectionBuilder authoringEnabled={authoringEnabled} />
|
||||
<CustomStylesSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const chooseComboboxOption = async (label: string, option: string) => {
|
||||
fireEvent.click(screen.getByLabelText(label));
|
||||
fireEvent.click(await screen.findByRole("option", { name: option }));
|
||||
};
|
||||
|
||||
describe("CustomStylesSectionBuilder", () => {
|
||||
beforeEach(() => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
styleRules.push({
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { color: "rgba(220, 38, 38, 1)" } },
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
expect(screen.getByLabelText("Target Scope")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Style Slot")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Text Color")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Text Color").parentElement).toHaveClass("gap-3");
|
||||
expect(screen.getByLabelText("Text Color").parentElement?.parentElement?.parentElement).toHaveClass(
|
||||
"grid-cols-1",
|
||||
"@min-[20rem]:grid-cols-2",
|
||||
"@min-[35rem]:grid-cols-4",
|
||||
);
|
||||
expect(screen.getByLabelText("Text Color").parentElement?.parentElement?.parentElement).not.toHaveClass(
|
||||
"grid-cols-[repeat(auto-fit,minmax(8rem,1fr))]",
|
||||
);
|
||||
expect(screen.getByLabelText("Text Decoration Color")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Color" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Text" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Spacing" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Border" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Font Style")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Line Height")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Letter Spacing")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Text Decoration")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Decoration Style")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Text Align")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Text Transform")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Opacity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Padding")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Margin Top")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Margin Right")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Margin Bottom")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Margin Left")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Row Gap")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Column Gap")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Border Style")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Border Width").parentElement?.parentElement).toHaveClass(
|
||||
"grid-cols-1",
|
||||
"@min-[20rem]:grid-cols-2",
|
||||
"@min-[35rem]:grid-cols-4",
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("Style Slot"));
|
||||
expect(await screen.findByText("Section")).toBeInTheDocument();
|
||||
expect(screen.getByText("Rich text")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("option", { name: "Section heading" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "List" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "List item content" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "Bullet or number" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels the empty font weight option as default", () => {
|
||||
renderCustomStyles();
|
||||
|
||||
expect(screen.getByLabelText("Font Weight")).toHaveTextContent("Default");
|
||||
expect(screen.queryByText("Template default")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renames the sidebar entry and uses a distinct icon from design", () => {
|
||||
const designIcon = getSectionIcon("design");
|
||||
const stylesIcon = getSectionIcon("styles");
|
||||
|
||||
expect(getSectionTitle("styles")).toBe("Custom Styles");
|
||||
expect(isValidElement(designIcon)).toBe(true);
|
||||
expect(isValidElement(stylesIcon)).toBe(true);
|
||||
expect(isValidElement(designIcon) && isValidElement(stylesIcon) && designIcon.type !== stylesIcon.type).toBe(true);
|
||||
});
|
||||
|
||||
it("upserts one style rule for the selected target and slot", () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderCustomStyles();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Text Color"), { target: { value: "rgba(220, 38, 38, 1)" } });
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(1);
|
||||
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
|
||||
const draft = { metadata: { styleRules: [] } };
|
||||
recipe(draft);
|
||||
|
||||
expect(draft.metadata.styleRules).toEqual([
|
||||
{
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { color: "rgba(220, 38, 38, 1)" } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores padding as per-side values", () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderCustomStyles();
|
||||
|
||||
expect(screen.getByText("Padding")).toBeInTheDocument();
|
||||
expect(screen.getByText("Padding")).toHaveClass("shrink-0");
|
||||
expect(screen.getByText("Padding").parentElement).toHaveClass("flex");
|
||||
expect(screen.queryByText("Padding Top")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Top")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Right")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Bottom")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Left")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Top")).toHaveAttribute("placeholder", "top");
|
||||
expect(screen.getByLabelText("Padding Right")).toHaveClass("text-center", "tabular-nums");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Padding Top"), { target: { value: "12" } });
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(1);
|
||||
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
|
||||
const draft = { metadata: { styleRules: [] } };
|
||||
recipe(draft);
|
||||
|
||||
expect(draft.metadata.styleRules).toEqual([
|
||||
{
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { paddingTop: 12 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores text decoration intent", async () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderCustomStyles();
|
||||
|
||||
await chooseComboboxOption("Text Decoration", "Underline");
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(1);
|
||||
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
|
||||
const draft = { metadata: { styleRules: [] } };
|
||||
recipe(draft);
|
||||
|
||||
expect(draft.metadata.styleRules).toEqual([
|
||||
{
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { textDecoration: "underline" } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores margin and gap intent", () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderCustomStyles();
|
||||
|
||||
expect(screen.getByText("Margin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Margin")).toHaveClass("shrink-0");
|
||||
expect(screen.queryByText("Margin Bottom")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Margin Bottom")).toHaveAttribute("min", "-72");
|
||||
expect(screen.getByLabelText("Margin Bottom")).toHaveAttribute("placeholder", "bottom");
|
||||
expect(screen.getByText("Gap")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Row Gap")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Row Gap")).toHaveAttribute("min", "-72");
|
||||
expect(screen.getByLabelText("Row Gap")).toHaveAttribute("placeholder", "row");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Margin Bottom"), { target: { value: "-10" } });
|
||||
fireEvent.change(screen.getByLabelText("Row Gap"), { target: { value: "-6" } });
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(2);
|
||||
|
||||
const marginRecipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
|
||||
metadata: { styleRules: unknown[] };
|
||||
}) => void;
|
||||
const marginDraft = { metadata: { styleRules: [] } };
|
||||
marginRecipe(marginDraft);
|
||||
|
||||
expect(marginDraft.metadata.styleRules).toEqual([
|
||||
{
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { marginBottom: -10 } },
|
||||
},
|
||||
]);
|
||||
|
||||
const gapRecipe = updateResumeData.mock.calls[1]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
|
||||
const gapDraft = { metadata: { styleRules: [] } };
|
||||
gapRecipe(gapDraft);
|
||||
|
||||
expect(gapDraft.metadata.styleRules).toEqual([
|
||||
{
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { rowGap: -6 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("clamps manually typed style values to the schema bounds", () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderCustomStyles();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Margin Top"), { target: { value: "100" } });
|
||||
fireEvent.change(screen.getByLabelText("Font Size"), { target: { value: "2" } });
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(2);
|
||||
|
||||
const marginRecipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
|
||||
metadata: { styleRules: unknown[] };
|
||||
}) => void;
|
||||
const marginDraft = { metadata: { styleRules: [] } };
|
||||
marginRecipe(marginDraft);
|
||||
|
||||
expect(marginDraft.metadata.styleRules).toEqual([
|
||||
{
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { marginTop: 72 } },
|
||||
},
|
||||
]);
|
||||
|
||||
const fontSizeRecipe = updateResumeData.mock.calls[1]?.[0] as (draft: {
|
||||
metadata: { styleRules: unknown[] };
|
||||
}) => void;
|
||||
const fontSizeDraft = { metadata: { styleRules: [] } };
|
||||
fontSizeRecipe(fontSizeDraft);
|
||||
|
||||
expect(fontSizeDraft.metadata.styleRules).toEqual([
|
||||
{
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { fontSize: 6 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps intermediate numeric text while the input is focused", () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderCustomStyles();
|
||||
|
||||
const fontSizeInput = screen.getByLabelText("Font Size");
|
||||
fireEvent.focus(fontSizeInput);
|
||||
fireEvent.change(fontSizeInput, { target: { value: "1" } });
|
||||
|
||||
expect(fontSizeInput).toHaveValue(1);
|
||||
|
||||
fireEvent.change(fontSizeInput, { target: { value: "12" } });
|
||||
expect(fontSizeInput).toHaveValue(12);
|
||||
|
||||
fireEvent.blur(fontSizeInput);
|
||||
expect(fontSizeInput).toHaveValue(12);
|
||||
});
|
||||
|
||||
it("commits normalized legacy values when the input loses focus", () => {
|
||||
styleRules[0] = {
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { borderWidth: 100 } },
|
||||
};
|
||||
renderCustomStyles();
|
||||
|
||||
const borderWidthInput = screen.getByLabelText("Border Width");
|
||||
expect(borderWidthInput).toHaveValue(100);
|
||||
|
||||
fireEvent.focus(borderWidthInput);
|
||||
fireEvent.blur(borderWidthInput);
|
||||
|
||||
expect(borderWidthInput).toHaveValue(24);
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(1);
|
||||
|
||||
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: StyleRule[] } }) => void;
|
||||
const draft = { metadata: { styleRules: structuredClone(styleRules) } };
|
||||
recipe(draft);
|
||||
|
||||
expect(draft.metadata.styleRules[0]?.slots.heading?.borderWidth).toBe(24);
|
||||
});
|
||||
|
||||
it("stores list slot rules for rich text lists", async () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderCustomStyles();
|
||||
|
||||
await chooseComboboxOption("Style Slot", "List");
|
||||
fireEvent.change(screen.getByLabelText("Row Gap"), { target: { value: "8" } });
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(1);
|
||||
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
|
||||
const draft = { metadata: { styleRules: [] } };
|
||||
recipe(draft);
|
||||
|
||||
expect(draft.metadata.styleRules).toEqual([
|
||||
{
|
||||
id: "style-global-richList",
|
||||
label: "All sections: List",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { richList: { rowGap: 8 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("can reset the selected style rule", () => {
|
||||
renderCustomStyles();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset Style" }));
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(1);
|
||||
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
|
||||
metadata: { styleRules: { id: string }[] };
|
||||
}) => void;
|
||||
const draft = {
|
||||
metadata: {
|
||||
styleRules: [
|
||||
{
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { color: "rgba(0, 0, 0, 1)" } },
|
||||
},
|
||||
{
|
||||
id: "style-global-section",
|
||||
label: "All sections: Section container",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { section: { padding: 4 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
recipe(draft);
|
||||
|
||||
expect(draft.metadata.styleRules.map((rule) => rule.id)).toEqual(["style-global-section"]);
|
||||
});
|
||||
|
||||
it("lists applied style rules and toggles individual rules", () => {
|
||||
styleRules.push({
|
||||
id: "style-global-section",
|
||||
label: "All sections: Section container",
|
||||
enabled: false,
|
||||
target: { scope: "global" },
|
||||
slots: { section: { paddingTop: 4 } },
|
||||
});
|
||||
renderCustomStyles();
|
||||
|
||||
expect(screen.getByText("Applied Rules")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Manage Rules" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("All sections: Section heading")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Off")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("All sections").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Section heading").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Enable All sections: Section container" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Disable All sections: Section heading" }));
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(1);
|
||||
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
|
||||
metadata: { styleRules: { id: string; enabled: boolean }[] };
|
||||
}) => void;
|
||||
const draft = { metadata: { styleRules: [{ id: "style-global-heading", enabled: true }] } };
|
||||
recipe(draft);
|
||||
|
||||
expect(draft.metadata.styleRules[0]?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("loads a selected applied rule into the editor form", () => {
|
||||
styleRules.push({
|
||||
id: "style-section-type-experience-richListItemContent",
|
||||
label: "Experience: List item content",
|
||||
enabled: true,
|
||||
target: { scope: "sectionType", sectionType: "experience" },
|
||||
slots: { richListItemContent: { lineHeight: 1.4 } },
|
||||
});
|
||||
renderCustomStyles();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit Experience: List item content" }));
|
||||
|
||||
expect(screen.getByLabelText("Target Scope")).toHaveTextContent("Section type");
|
||||
expect(screen.getByLabelText("Section Type")).toHaveTextContent("Experience");
|
||||
expect(screen.getByLabelText("Style Slot")).toHaveTextContent("List item content");
|
||||
expect(screen.getByLabelText("Line Height")).toHaveValue(1.4);
|
||||
});
|
||||
expect(await screen.findByTestId("semantic-css-editor-shell")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ rss: true
|
||||
- Added the Semantic CSS editor under **Design → Custom Styles**, with live status, error line and column numbers, "selector matches nothing" warnings, stylesheet undo and redo, **Reset to applied stylesheet**, **Copy stylesheet**, and a focus mode that opens full-width on mobile. [d2ffbf961](https://github.com/amruthpillai/reactive-resume/commit/d2ffbf961)
|
||||
- Resumes still using the previous Custom Styles form get a converted stylesheet draft to review; the original legacy rules stay active until you select **Activate Semantic CSS**, and remain available for rollback. [d2ffbf961](https://github.com/amruthpillai/reactive-resume/commit/d2ffbf961)
|
||||
- Added semantic selectors, attributes, and template-specific parts for all 15 templates, plus pagination and page-size controls (`break-inside`, `orphans`, `widows`, `-resume-min-presence-ahead`, `size`) and PDF-dimension media queries. [d2ffbf961](https://github.com/amruthpillai/reactive-resume/commit/d2ffbf961)
|
||||
- Self-hosters control the rollout with the new `FLAG_SEMANTIC_CSS_AUTHORING` and `FLAG_SEMANTIC_CSS_DEFAULT` environment variables. [d2ffbf961](https://github.com/amruthpillai/reactive-resume/commit/d2ffbf961)
|
||||
- New resumes start in Semantic CSS mode automatically, while existing legacy styles remain active until their converted draft is explicitly activated.
|
||||
- Invalid style intents are now filtered out instead of discarding the whole rule set, so one bad entry no longer drops your valid custom styles. [689e7e24d](https://github.com/amruthpillai/reactive-resume/commit/689e7e24d)
|
||||
- Custom style numeric inputs are clamped to their supported ranges. [2a0782517](https://github.com/amruthpillai/reactive-resume/commit/2a0782517)
|
||||
- Award titles can now be un-bolded through custom styles. [08d859010](https://github.com/amruthpillai/reactive-resume/commit/08d859010)
|
||||
|
||||
+1
-11
@@ -9969,23 +9969,13 @@
|
||||
"smtpEnabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether outbound email (SMTP) is configured on this instance."
|
||||
},
|
||||
"semanticCssAuthoring": {
|
||||
"type": "boolean",
|
||||
"description": "Whether Semantic CSS authoring is enabled on this instance."
|
||||
},
|
||||
"semanticCssDefault": {
|
||||
"type": "boolean",
|
||||
"description": "Whether new resumes start in Semantic CSS mode."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"disableSignups",
|
||||
"disableEmailAuth",
|
||||
"showSponsors",
|
||||
"smtpEnabled",
|
||||
"semanticCssAuthoring",
|
||||
"semanticCssDefault"
|
||||
"smtpEnabled"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ export type FeatureFlags = {
|
||||
disableEmailAuth: boolean;
|
||||
showSponsors: boolean;
|
||||
smtpEnabled: boolean;
|
||||
semanticCssAuthoring: boolean;
|
||||
semanticCssDefault: boolean;
|
||||
};
|
||||
|
||||
// Mirrors isSmtpEnabled() in packages/email/src/transport.ts (kept local to avoid an api -> email dependency).
|
||||
@@ -32,8 +30,6 @@ export const flagsRouter = {
|
||||
disableEmailAuth: z.boolean().describe("Whether email-based authentication is disabled on this instance."),
|
||||
showSponsors: z.boolean().describe("Whether sponsor placements are shown on this instance."),
|
||||
smtpEnabled: z.boolean().describe("Whether outbound email (SMTP) is configured on this instance."),
|
||||
semanticCssAuthoring: z.boolean().describe("Whether Semantic CSS authoring is enabled on this instance."),
|
||||
semanticCssDefault: z.boolean().describe("Whether new resumes start in Semantic CSS mode."),
|
||||
}),
|
||||
)
|
||||
.handler(
|
||||
@@ -42,8 +38,6 @@ export const flagsRouter = {
|
||||
disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH,
|
||||
showSponsors: env.FLAG_SHOW_SPONSORS,
|
||||
smtpEnabled: isSmtpEnabled(),
|
||||
semanticCssAuthoring: env.FLAG_SEMANTIC_CSS_AUTHORING,
|
||||
semanticCssDefault: env.FLAG_SEMANTIC_CSS_DEFAULT,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -8,8 +8,6 @@ const mocks = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: { FLAG_SEMANTIC_CSS_DEFAULT: false } }));
|
||||
|
||||
vi.mock("../../context", async () => {
|
||||
const { os } = await vi.importActual<typeof import("@orpc/server")>("@orpc/server");
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { generateId, generateRandomName, slugify } from "@reactive-resume/utils/string";
|
||||
import { protectedProcedure } from "../../context";
|
||||
@@ -74,7 +73,6 @@ export const crudRouter = {
|
||||
locale: context.locale,
|
||||
userId: context.user.id,
|
||||
data: createResumeData({
|
||||
semanticCssDefault: env.FLAG_SEMANTIC_CSS_DEFAULT,
|
||||
withSampleData: input.withSampleData,
|
||||
name: input.name,
|
||||
locale: context.locale,
|
||||
|
||||
@@ -95,19 +95,17 @@ describe("hasRenderDataChanged", () => {
|
||||
});
|
||||
|
||||
describe("createResumeData", () => {
|
||||
it("seeds empty semantic source only for the default-enabled cohort", () => {
|
||||
expect(createResumeData({ semanticCssDefault: true }).metadata.stylesheet).toEqual({
|
||||
it("always seeds an empty semantic stylesheet", () => {
|
||||
expect(createResumeData({}).metadata.stylesheet).toEqual({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
});
|
||||
expect(createResumeData({ semanticCssDefault: false }).metadata.stylesheet).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clones normal and sample defaults instead of mutating shared data", () => {
|
||||
const normal = createResumeData({ semanticCssDefault: false, locale: "de-DE" });
|
||||
const normal = createResumeData({ locale: "de-DE" });
|
||||
const sample = createResumeData({
|
||||
semanticCssDefault: true,
|
||||
withSampleData: true,
|
||||
name: "Sample Person",
|
||||
locale: "de-DE",
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createSampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
|
||||
|
||||
type CreateResumeDataOptions = {
|
||||
semanticCssDefault: boolean;
|
||||
withSampleData?: boolean;
|
||||
name?: string;
|
||||
locale?: Locale;
|
||||
@@ -30,13 +29,11 @@ export function createResumeData(options: CreateResumeDataOptions): ResumeData {
|
||||
const data = structuredClone(options.withSampleData ? createSampleResumeData(options.name) : defaultResumeData);
|
||||
|
||||
if (options.locale) data.metadata.page.locale = options.locale;
|
||||
if (options.semanticCssDefault) {
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE },
|
||||
applied: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE },
|
||||
};
|
||||
}
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE },
|
||||
applied: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE },
|
||||
};
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
Vendored
-2
@@ -83,8 +83,6 @@ export const env = createEnv({
|
||||
FLAG_SHOW_SPONSORS: z.stringbool().default(false),
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL: z.stringbool().default(false),
|
||||
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI: z.stringbool().default(false),
|
||||
FLAG_SEMANTIC_CSS_AUTHORING: z.stringbool().default(false),
|
||||
FLAG_SEMANTIC_CSS_DEFAULT: z.stringbool().default(false),
|
||||
},
|
||||
runtimeEnv: process.env,
|
||||
emptyStringAsUndefined: true,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
|
||||
const baseURL = process.env.APP_URL ?? `http://localhost:${port}`;
|
||||
const isCI = process.env.CI === "true" || process.env.CI === "1";
|
||||
const isSemanticCssAuthoringRun = process.env.FLAG_SEMANTIC_CSS_AUTHORING === "true";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/specs",
|
||||
@@ -12,7 +11,7 @@ export default defineConfig({
|
||||
retries: 0,
|
||||
// Semantic CSS acceptance includes deterministic PDF preflight and 15 visual renders. Keep it serial so
|
||||
// independent browser workers do not contend for the fixed production preflight deadline.
|
||||
workers: isSemanticCssAuthoringRun ? 1 : isCI ? 2 : undefined,
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
|
||||
+5
-38
@@ -26,50 +26,18 @@ Run tests:
|
||||
|
||||
`APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS=false FLAG_DISABLE_EMAIL_AUTH=false FLAG_DISABLE_API_RATE_LIMIT=true LOCAL_STORAGE_PATH=/workspace/data/e2e pnpm test:e2e`
|
||||
|
||||
## Semantic CSS flag matrix
|
||||
## Semantic CSS
|
||||
|
||||
Run the ordinary suite with both Semantic CSS rollout flags disabled:
|
||||
Run opt-in conversion, editing, conflict, last-valid, default-mode, and visual acceptance:
|
||||
|
||||
```bash
|
||||
FLAG_SEMANTIC_CSS_AUTHORING=false FLAG_SEMANTIC_CSS_DEFAULT=false \
|
||||
pnpm exec playwright test --grep-invert "@semantic-css"
|
||||
```
|
||||
|
||||
Run opt-in conversion, editing, conflict, last-valid, and visual acceptance. With authoring enabled, the Playwright
|
||||
configuration automatically uses one worker so deterministic heavy browser preflight and visual checks do not compete
|
||||
for the fixed production five-second deadline:
|
||||
|
||||
```bash
|
||||
FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false \
|
||||
pnpm exec playwright test \
|
||||
tests/e2e/specs/semantic-css/legacy-conversion.spec.ts \
|
||||
tests/e2e/specs/semantic-css/invalid-last-valid.spec.ts \
|
||||
tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts \
|
||||
tests/e2e/specs/semantic-css/revision-conflict.spec.ts \
|
||||
tests/e2e/specs/semantic-css/template-visual.spec.ts
|
||||
```
|
||||
|
||||
Verify the default-on state for newly created resumes:
|
||||
|
||||
```bash
|
||||
FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=true \
|
||||
pnpm exec playwright test tests/e2e/specs/semantic-css/default-mode.spec.ts
|
||||
```
|
||||
|
||||
Verify dormant authoring and persisted semantic rendering:
|
||||
|
||||
```bash
|
||||
FLAG_SEMANTIC_CSS_AUTHORING=false FLAG_SEMANTIC_CSS_DEFAULT=false \
|
||||
pnpm exec playwright test \
|
||||
tests/e2e/specs/semantic-css/dormant-mode.spec.ts \
|
||||
tests/e2e/specs/semantic-css/flag-off-semantic.spec.ts
|
||||
pnpm exec playwright test tests/e2e/specs/semantic-css
|
||||
```
|
||||
|
||||
Linux/Chromium visual baselines are updated intentionally with:
|
||||
|
||||
```bash
|
||||
FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false \
|
||||
pnpm exec playwright test tests/e2e/specs/semantic-css/template-visual.spec.ts \
|
||||
pnpm exec playwright test tests/e2e/specs/semantic-css/template-visual.spec.ts \
|
||||
--project=chromium --update-snapshots
|
||||
```
|
||||
|
||||
@@ -80,7 +48,6 @@ FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false \
|
||||
- Builder basics edit and autosave persistence.
|
||||
- JSON export/import.
|
||||
- Public sharing for anonymous visitors.
|
||||
- Semantic CSS rollout states, legacy conversion, last-valid recovery, portability, revision conflicts, and all-template
|
||||
visual regression.
|
||||
- Semantic CSS legacy conversion, last-valid recovery, portability, revision conflicts, and all-template visual regression.
|
||||
|
||||
PDF, DOCX, OAuth, passkeys, 2FA, password reset, and AI flows are intentionally outside the initial PR gate.
|
||||
|
||||
@@ -120,6 +120,7 @@ export async function updateSemanticCssFixture(
|
||||
}
|
||||
if (update.legacyStyleRule) {
|
||||
const metadata = data.metadata as Record<string, unknown>;
|
||||
delete metadata.stylesheet;
|
||||
metadata.styleRules = structuredClone(legacyParityRules);
|
||||
}
|
||||
if (update.hidePicture) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createSemanticCssResume, readStylesheetSource } from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test("@semantic-css starts new resumes in semantic mode when default-on is enabled", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
test("@semantic-css starts new resumes in semantic mode", async ({ authPage: page }, testInfo) => {
|
||||
await createSemanticCssResume(page, testInfo);
|
||||
|
||||
await expect(page.getByText("Converted stylesheet draft", { exact: true })).toHaveCount(0);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { updateSemanticCssFixture } from "../../fixtures/db";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../../fixtures/resume";
|
||||
import { resumeIdFromPage, waitForStablePreview } from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test("@semantic-css keeps persisted semantic rendering active when authoring is off", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
const beforeCanvas = await waitForStablePreview(page);
|
||||
const before = await beforeCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL());
|
||||
const source = { languageVersion: 1, text: "@version 1;\nname { color: #2563eb; font-size: 30pt; }\n" };
|
||||
await updateSemanticCssFixture(resumeIdFromPage(page), {
|
||||
stylesheet: { mode: "semantic", source, applied: source },
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
const afterCanvas = await waitForStablePreview(page);
|
||||
await expect
|
||||
.poll(() => afterCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL()))
|
||||
.not.toBe(before);
|
||||
await waitForStablePreview(page);
|
||||
await openSidebarSection(page, "Custom Styles");
|
||||
await expect(page.getByText("Semantic styles remain active", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("This instance does not currently allow Semantic CSS editing.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("Target Scope")).toHaveCount(0);
|
||||
});
|
||||
+1
-11
@@ -1,18 +1,8 @@
|
||||
import { readSemanticCssFixture, updateSemanticCssFixture } from "../../fixtures/db";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../../fixtures/resume";
|
||||
import { createSampleResumeFromDashboard } from "../../fixtures/resume";
|
||||
import { resumeIdFromPage } from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test("@semantic-css keeps the legacy editor available while both flags are off", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
await openSidebarSection(page, "Custom Styles");
|
||||
|
||||
await expect(page.getByLabel("Target Scope")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("@semantic-css preserves a persisted stylesheet through an old-client resume update", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
+1
-3
@@ -86,9 +86,7 @@
|
||||
"FLAG_DISABLE_API_RATE_LIMIT",
|
||||
"FLAG_SHOW_SPONSORS",
|
||||
"FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI",
|
||||
"FLAG_ALLOW_UNSAFE_AI_BASE_URL",
|
||||
"FLAG_SEMANTIC_CSS_AUTHORING",
|
||||
"FLAG_SEMANTIC_CSS_DEFAULT"
|
||||
"FLAG_ALLOW_UNSAFE_AI_BASE_URL"
|
||||
],
|
||||
"tasks": {
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user