feat: implement style rules

This commit is contained in:
Amruth Pillai
2026-05-27 10:57:33 +02:00
parent 7bff6644d8
commit b04eef1479
41 changed files with 2995 additions and 396 deletions
+7 -7
View File
@@ -22,10 +22,10 @@
"@ai-sdk/google": "^3.0.79",
"@ai-sdk/openai": "^3.0.65",
"@ai-sdk/openai-compatible": "^2.0.48",
"@aws-sdk/client-s3": "^3.1053.0",
"@aws-sdk/client-s3": "^3.1054.0",
"@better-auth/api-key": "^1.6.11",
"@better-auth/drizzle-adapter": "^1.6.11",
"@better-auth/infra": "^0.2.8",
"@better-auth/infra": "^0.2.10",
"@better-auth/oauth-provider": "^1.6.11",
"@better-auth/passkey": "^1.6.11",
"@hono/node-server": "^2.0.4",
@@ -52,7 +52,7 @@
"better-auth": "1.6.11",
"cjk-regex": "^3.4.0",
"deepmerge-ts": "^7.1.5",
"dompurify": "^3.4.5",
"dompurify": "^3.4.6",
"dotenv": "^17.4.2",
"drizzle-orm": "1.0.0-rc.3",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
@@ -61,12 +61,12 @@
"hono": "^4.12.23",
"jsonrepair": "^3.14.0",
"node-html-parser": "^7.1.0",
"nodemailer": "^8.0.8",
"nodemailer": "^8.0.9",
"ollama-ai-provider-v2": "^3.5.1",
"pg": "^8.21.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.6",
"react-email": "^6.3.3",
"react-email": "^6.4.0",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sharp": "^0.34.5",
@@ -80,8 +80,8 @@
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.15",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"react-doctor": "^0.2.6",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"react-doctor": "^0.2.9",
"tsdown": "^0.22.0",
"tsx": "^4.22.3",
"typescript": "^6.0.3",
+4 -4
View File
@@ -20,7 +20,7 @@
"@ai-sdk/react": "^3.0.193",
"@base-ui/react": "^1.5.0",
"@better-auth/api-key": "^1.6.11",
"@better-auth/infra": "^0.2.8",
"@better-auth/infra": "^0.2.10",
"@better-auth/oauth-provider": "^1.6.11",
"@better-auth/passkey": "^1.6.11",
"@dnd-kit/core": "^6.3.1",
@@ -44,7 +44,7 @@
"@reactive-resume/ui": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-form": "^1.32.0",
"@tanstack/react-form": "^1.32.1",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.8",
@@ -96,11 +96,11 @@
"@types/pg": "^8.20.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260526.1",
"@typescript/native-preview": "7.0.0-dev.20260527.1",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-react-compiler": "^1.0.0",
"react-doctor": "^0.2.6",
"react-doctor": "^0.2.9",
"typescript": "^6.0.3",
"vite": "^8.0.14"
}
+5
View File
@@ -21,6 +21,7 @@ import {
LayoutIcon,
MessengerLogoIcon,
NotepadIcon,
PaintBrushBroadIcon,
PaletteIcon,
PhoneIcon,
ReadCvLogoIcon,
@@ -44,6 +45,7 @@ export type RightSidebarSection =
| "layout"
| "typography"
| "design"
| "styles"
| "page"
| "notes"
| "sharing"
@@ -78,6 +80,7 @@ export const rightSidebarSections: RightSidebarSection[] = [
"layout",
"typography",
"design",
"styles",
"page",
"notes",
"sharing",
@@ -116,6 +119,7 @@ export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string =
.with("layout", () => t`Layout`)
.with("typography", () => t`Typography`)
.with("design", () => t`Design`)
.with("styles", () => t`Custom Styles`)
.with("page", () => t`Page`)
.with("notes", () => t`Notes`)
.with("sharing", () => t`Sharing`)
@@ -159,6 +163,7 @@ export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: Ic
.with("layout", () => <LayoutIcon {...iconProps} />)
.with("typography", () => <TextTIcon {...iconProps} />)
.with("design", () => <PaletteIcon {...iconProps} />)
.with("styles", () => <PaintBrushBroadIcon {...iconProps} />)
.with("page", () => <ReadCvLogoIcon {...iconProps} />)
.with("notes", () => <NotepadIcon {...iconProps} />)
.with("sharing", () => <ShareFatIcon {...iconProps} />)
@@ -17,6 +17,7 @@ import { PageSectionBuilder } from "./sections/page";
import { ResumeAnalysisSectionBuilder } from "./sections/resume-analysis";
import { SharingSectionBuilder } from "./sections/sharing";
import { StatisticsSectionBuilder } from "./sections/statistics";
import { StylesSectionBuilder } from "./sections/styles";
import { TemplateSectionBuilder } from "./sections/template";
import { TypographySectionBuilder } from "./sections/typography";
@@ -26,6 +27,7 @@ function getSectionComponent(type: RightSidebarSection) {
.with("layout", () => <LayoutSectionBuilder />)
.with("typography", () => <TypographySectionBuilder />)
.with("design", () => <DesignSectionBuilder />)
.with("styles", () => <StylesSectionBuilder />)
.with("page", () => <PageSectionBuilder />)
.with("notes", () => <NotesSectionBuilder />)
.with("sharing", () => <SharingSectionBuilder />)
@@ -0,0 +1,322 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, 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(() => [
{
id: "style-global-heading",
label: "All sections: Section heading",
enabled: true,
target: { scope: "global" },
slots: { heading: { color: "rgba(220, 38, 38, 1)" } },
},
]);
vi.mock("../shared/section-base", () => ({
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,
}));
const { StylesSectionBuilder } = await import("./styles");
const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
updateResumeData.mockClear();
});
const renderStyles = () =>
render(
<I18nProvider i18n={i18n}>
<StylesSectionBuilder />
</I18nProvider>,
);
describe("StylesSectionBuilder", () => {
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("renders structured style rule controls", () => {
renderStyles();
expect(screen.getByLabelText("Target Scope")).toBeInTheDocument();
expect(screen.getByLabelText("Style Slot")).toBeInTheDocument();
expect(screen.getByLabelText("Text Color")).toBeInTheDocument();
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.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.getByRole("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", () => {
renderStyles();
const fontWeightSelect = screen.getByLabelText("Font Weight");
expect(within(fontWeightSelect).getByRole("option", { name: "Default" })).toBeInTheDocument();
expect(within(fontWeightSelect).queryByRole("option", { name: "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);
renderStyles();
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);
renderStyles();
expect(screen.queryByLabelText("Padding")).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();
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", () => {
styleRules.splice(0, styleRules.length);
renderStyles();
fireEvent.change(screen.getByLabelText("Text Decoration"), { target: { value: "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);
renderStyles();
expect(screen.getByLabelText("Margin Bottom")).toHaveAttribute("min", "-72");
expect(screen.getByLabelText("Row Gap")).toHaveAttribute("min", "-72");
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("stores list slot rules for rich text lists", () => {
styleRules.splice(0, styleRules.length);
renderStyles();
fireEvent.change(screen.getByLabelText("Style Slot"), { target: { value: "richList" } });
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", () => {
renderStyles();
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", () => {
renderStyles();
expect(screen.getByText("Applied Rules")).toBeInTheDocument();
expect(screen.getByText("All sections: Section heading")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Toggle 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("opens a manage rules dialog with editable rule fields", () => {
renderStyles();
fireEvent.click(screen.getByRole("button", { name: "Manage Rules" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Manage Style Rules")).toBeInTheDocument();
expect(screen.getByLabelText("Rule Label")).toHaveValue("All sections: Section heading");
expect(screen.getByLabelText("Dialog Text Color")).toHaveValue("rgba(220, 38, 38, 1)");
expect(screen.getByLabelText("Dialog Padding Top")).toBeInTheDocument();
expect(screen.getByLabelText("Dialog Padding Right")).toBeInTheDocument();
expect(screen.getByLabelText("Dialog Padding Bottom")).toBeInTheDocument();
expect(screen.getByLabelText("Dialog Padding Left")).toBeInTheDocument();
});
});
File diff suppressed because it is too large Load Diff