feat: enable semantic CSS by default

This commit is contained in:
Amruth Pillai
2026-07-30 16:28:44 +02:00
parent 1ee24e5a9f
commit 3589b534f5
19 changed files with 46 additions and 1696 deletions
@@ -27,17 +27,3 @@ export function LegacyStylesheetBanner({ disabled, onActivate }: LegacyStyleshee
</Alert> </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 type { RightSidebarSection } from "@/libs/resume/section";
import { useRouteContext } from "@tanstack/react-router";
import { Fragment, useCallback, useRef } from "react"; import { Fragment, useCallback, useRef } from "react";
import { match } from "ts-pattern"; import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button"; import { Button } from "@reactive-resume/ui/components/button";
@@ -23,13 +22,13 @@ import { StatisticsSectionBuilder } from "./sections/statistics";
import { TemplateSectionBuilder } from "./sections/template"; import { TemplateSectionBuilder } from "./sections/template";
import { TypographySectionBuilder } from "./sections/typography"; import { TypographySectionBuilder } from "./sections/typography";
function getSectionComponent(type: RightSidebarSection, semanticCssAuthoring: boolean) { function getSectionComponent(type: RightSidebarSection) {
return match(type) return match(type)
.with("template", () => <TemplateSectionBuilder />) .with("template", () => <TemplateSectionBuilder />)
.with("layout", () => <LayoutSectionBuilder />) .with("layout", () => <LayoutSectionBuilder />)
.with("typography", () => <TypographySectionBuilder />) .with("typography", () => <TypographySectionBuilder />)
.with("design", () => <DesignSectionBuilder />) .with("design", () => <DesignSectionBuilder />)
.with("styles", () => <CustomStylesSectionBuilder authoringEnabled={semanticCssAuthoring} />) .with("styles", () => <CustomStylesSectionBuilder />)
.with("page", () => <PageSectionBuilder />) .with("page", () => <PageSectionBuilder />)
.with("notes", () => <NotesSectionBuilder />) .with("notes", () => <NotesSectionBuilder />)
.with("sharing", () => <SharingSectionBuilder />) .with("sharing", () => <SharingSectionBuilder />)
@@ -42,8 +41,6 @@ function getSectionComponent(type: RightSidebarSection, semanticCssAuthoring: bo
export function BuilderSidebarRight() { export function BuilderSidebarRight() {
const scrollAreaRef = useRef<HTMLDivElement | null>(null); const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const context = useRouteContext({ strict: false });
const semanticCssAuthoring = context.flags?.semanticCssAuthoring ?? false;
return ( return (
<> <>
@@ -56,7 +53,7 @@ export function BuilderSidebarRight() {
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
{rightSidebarSections.map((section) => ( {rightSidebarSections.map((section) => (
<Fragment key={section}> <Fragment key={section}>
{getSectionComponent(section, semanticCssAuthoring)} {getSectionComponent(section)}
<Separator /> <Separator />
</Fragment> </Fragment>
))} ))}
@@ -1,43 +1,12 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import type { StyleRule } from "@reactive-resume/schema/resume/data"; import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react"; import { beforeAll, beforeEach, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core"; import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react"; 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", () => ({ vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>, SectionBase: ({ children }: { children: React.ReactNode }) => <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,
})); }));
vi.mock("@/features/resume/stylesheet/editor", () => ({ vi.mock("@/features/resume/stylesheet/editor", () => ({
@@ -45,439 +14,27 @@ vi.mock("@/features/resume/stylesheet/editor", () => ({
})); }));
const { CustomStylesSectionBuilder } = await import("./custom-styles"); const { CustomStylesSectionBuilder } = await import("./custom-styles");
const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section"); const { useSectionStore } = await import("../../../-store/section");
const { useStylesheetStore } = await import("@/features/resume/stylesheet/store");
beforeAll(() => { beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} }); i18n.loadAndActivate({ locale: "en", messages: {} });
}); });
beforeEach(() => { beforeEach(() => {
updateResumeData.mockClear(); useSectionStore.setState((state) => ({
useStylesheetStore.setState({ mode: "legacy" }); sections: {
...state.sections,
styles: { ...state.sections.styles, collapsed: false },
},
}));
}); });
const renderCustomStyles = (authoringEnabled = false) => it("always loads the Semantic CSS shell", async () => {
render( render(
<I18nProvider i18n={i18n}> <I18nProvider i18n={i18n}>
<CustomStylesSectionBuilder authoringEnabled={authoringEnabled} /> <CustomStylesSectionBuilder />
</I18nProvider>, </I18nProvider>,
); );
const chooseComboboxOption = async (label: string, option: string) => { expect(await screen.findByTestId("semantic-css-editor-shell")).toBeInTheDocument();
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);
});
}); });
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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) - 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) - 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) - 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) - 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) - 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) - Award titles can now be un-bolded through custom styles. [08d859010](https://github.com/amruthpillai/reactive-resume/commit/08d859010)
+1 -11
View File
@@ -9969,23 +9969,13 @@
"smtpEnabled": { "smtpEnabled": {
"type": "boolean", "type": "boolean",
"description": "Whether outbound email (SMTP) is configured on this instance." "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": [ "required": [
"disableSignups", "disableSignups",
"disableEmailAuth", "disableEmailAuth",
"showSponsors", "showSponsors",
"smtpEnabled", "smtpEnabled"
"semanticCssAuthoring",
"semanticCssDefault"
] ]
} }
} }
@@ -7,8 +7,6 @@ export type FeatureFlags = {
disableEmailAuth: boolean; disableEmailAuth: boolean;
showSponsors: boolean; showSponsors: boolean;
smtpEnabled: boolean; smtpEnabled: boolean;
semanticCssAuthoring: boolean;
semanticCssDefault: boolean;
}; };
// Mirrors isSmtpEnabled() in packages/email/src/transport.ts (kept local to avoid an api -> email dependency). // 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."), 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."), 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."), 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( .handler(
@@ -42,8 +38,6 @@ export const flagsRouter = {
disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH, disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH,
showSponsors: env.FLAG_SHOW_SPONSORS, showSponsors: env.FLAG_SHOW_SPONSORS,
smtpEnabled: isSmtpEnabled(), 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(), getById: vi.fn(),
})); }));
vi.mock("@reactive-resume/env/server", () => ({ env: { FLAG_SEMANTIC_CSS_DEFAULT: false } }));
vi.mock("../../context", async () => { vi.mock("../../context", async () => {
const { os } = await vi.importActual<typeof import("@orpc/server")>("@orpc/server"); const { os } = await vi.importActual<typeof import("@orpc/server")>("@orpc/server");
return { return {
-2
View File
@@ -1,4 +1,3 @@
import { env } from "@reactive-resume/env/server";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { generateId, generateRandomName, slugify } from "@reactive-resume/utils/string"; import { generateId, generateRandomName, slugify } from "@reactive-resume/utils/string";
import { protectedProcedure } from "../../context"; import { protectedProcedure } from "../../context";
@@ -74,7 +73,6 @@ export const crudRouter = {
locale: context.locale, locale: context.locale,
userId: context.user.id, userId: context.user.id,
data: createResumeData({ data: createResumeData({
semanticCssDefault: env.FLAG_SEMANTIC_CSS_DEFAULT,
withSampleData: input.withSampleData, withSampleData: input.withSampleData,
name: input.name, name: input.name,
locale: context.locale, locale: context.locale,
@@ -95,19 +95,17 @@ describe("hasRenderDataChanged", () => {
}); });
describe("createResumeData", () => { describe("createResumeData", () => {
it("seeds empty semantic source only for the default-enabled cohort", () => { it("always seeds an empty semantic stylesheet", () => {
expect(createResumeData({ semanticCssDefault: true }).metadata.stylesheet).toEqual({ expect(createResumeData({}).metadata.stylesheet).toEqual({
mode: "semantic", mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\n" }, source: { languageVersion: 1, text: "@version 1;\n" },
applied: { 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", () => { 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({ const sample = createResumeData({
semanticCssDefault: true,
withSampleData: true, withSampleData: true,
name: "Sample Person", name: "Sample Person",
locale: "de-DE", 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"; import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
type CreateResumeDataOptions = { type CreateResumeDataOptions = {
semanticCssDefault: boolean;
withSampleData?: boolean; withSampleData?: boolean;
name?: string; name?: string;
locale?: Locale; locale?: Locale;
@@ -30,13 +29,11 @@ export function createResumeData(options: CreateResumeDataOptions): ResumeData {
const data = structuredClone(options.withSampleData ? createSampleResumeData(options.name) : defaultResumeData); const data = structuredClone(options.withSampleData ? createSampleResumeData(options.name) : defaultResumeData);
if (options.locale) data.metadata.page.locale = options.locale; if (options.locale) data.metadata.page.locale = options.locale;
if (options.semanticCssDefault) { data.metadata.stylesheet = {
data.metadata.stylesheet = { mode: "semantic",
mode: "semantic", source: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE },
source: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE }, applied: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE },
applied: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE }, };
};
}
return data; return data;
} }
-2
View File
@@ -83,8 +83,6 @@ export const env = createEnv({
FLAG_SHOW_SPONSORS: z.stringbool().default(false), FLAG_SHOW_SPONSORS: z.stringbool().default(false),
FLAG_ALLOW_UNSAFE_AI_BASE_URL: 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_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, runtimeEnv: process.env,
emptyStringAsUndefined: true, emptyStringAsUndefined: true,
+1 -2
View File
@@ -3,7 +3,6 @@ import { defineConfig, devices } from "@playwright/test";
const port = Number.parseInt(process.env.PORT ?? "3000", 10); const port = Number.parseInt(process.env.PORT ?? "3000", 10);
const baseURL = process.env.APP_URL ?? `http://localhost:${port}`; const baseURL = process.env.APP_URL ?? `http://localhost:${port}`;
const isCI = process.env.CI === "true" || process.env.CI === "1"; const isCI = process.env.CI === "true" || process.env.CI === "1";
const isSemanticCssAuthoringRun = process.env.FLAG_SEMANTIC_CSS_AUTHORING === "true";
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e/specs", testDir: "./tests/e2e/specs",
@@ -12,7 +11,7 @@ export default defineConfig({
retries: 0, retries: 0,
// Semantic CSS acceptance includes deterministic PDF preflight and 15 visual renders. Keep it serial so // 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. // independent browser workers do not contend for the fixed production preflight deadline.
workers: isSemanticCssAuthoringRun ? 1 : isCI ? 2 : undefined, workers: 1,
timeout: 30_000, timeout: 30_000,
expect: { expect: {
timeout: 10_000, timeout: 10_000,
+5 -38
View File
@@ -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` `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 ```bash
FLAG_SEMANTIC_CSS_AUTHORING=false FLAG_SEMANTIC_CSS_DEFAULT=false \ pnpm exec playwright test tests/e2e/specs/semantic-css
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
``` ```
Linux/Chromium visual baselines are updated intentionally with: Linux/Chromium visual baselines are updated intentionally with:
```bash ```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 --project=chromium --update-snapshots
``` ```
@@ -80,7 +48,6 @@ FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false \
- Builder basics edit and autosave persistence. - Builder basics edit and autosave persistence.
- JSON export/import. - JSON export/import.
- Public sharing for anonymous visitors. - Public sharing for anonymous visitors.
- Semantic CSS rollout states, legacy conversion, last-valid recovery, portability, revision conflicts, and all-template - Semantic CSS legacy conversion, last-valid recovery, portability, revision conflicts, and all-template visual regression.
visual regression.
PDF, DOCX, OAuth, passkeys, 2FA, password reset, and AI flows are intentionally outside the initial PR gate. PDF, DOCX, OAuth, passkeys, 2FA, password reset, and AI flows are intentionally outside the initial PR gate.
+1
View File
@@ -120,6 +120,7 @@ export async function updateSemanticCssFixture(
} }
if (update.legacyStyleRule) { if (update.legacyStyleRule) {
const metadata = data.metadata as Record<string, unknown>; const metadata = data.metadata as Record<string, unknown>;
delete metadata.stylesheet;
metadata.styleRules = structuredClone(legacyParityRules); metadata.styleRules = structuredClone(legacyParityRules);
} }
if (update.hidePicture) { if (update.hidePicture) {
@@ -1,9 +1,7 @@
import { createSemanticCssResume, readStylesheetSource } from "../../fixtures/semantic-css"; import { createSemanticCssResume, readStylesheetSource } from "../../fixtures/semantic-css";
import { expect, test } from "../../fixtures/test"; import { expect, test } from "../../fixtures/test";
test("@semantic-css starts new resumes in semantic mode when default-on is enabled", async ({ test("@semantic-css starts new resumes in semantic mode", async ({ authPage: page }, testInfo) => {
authPage: page,
}, testInfo) => {
await createSemanticCssResume(page, testInfo); await createSemanticCssResume(page, testInfo);
await expect(page.getByText("Converted stylesheet draft", { exact: true })).toHaveCount(0); 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,18 +1,8 @@
import { readSemanticCssFixture, updateSemanticCssFixture } from "../../fixtures/db"; import { readSemanticCssFixture, updateSemanticCssFixture } from "../../fixtures/db";
import { createSampleResumeFromDashboard, openSidebarSection } from "../../fixtures/resume"; import { createSampleResumeFromDashboard } from "../../fixtures/resume";
import { resumeIdFromPage } from "../../fixtures/semantic-css"; import { resumeIdFromPage } from "../../fixtures/semantic-css";
import { expect, test } from "../../fixtures/test"; 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 ({ test("@semantic-css preserves a persisted stylesheet through an old-client resume update", async ({
authPage: page, authPage: page,
}, testInfo) => { }, testInfo) => {
+1 -3
View File
@@ -86,9 +86,7 @@
"FLAG_DISABLE_API_RATE_LIMIT", "FLAG_DISABLE_API_RATE_LIMIT",
"FLAG_SHOW_SPONSORS", "FLAG_SHOW_SPONSORS",
"FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI", "FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI",
"FLAG_ALLOW_UNSAFE_AI_BASE_URL", "FLAG_ALLOW_UNSAFE_AI_BASE_URL"
"FLAG_SEMANTIC_CSS_AUTHORING",
"FLAG_SEMANTIC_CSS_DEFAULT"
], ],
"tasks": { "tasks": {
"build": { "build": {