feat: improvements to custom styles

This commit is contained in:
Amruth Pillai
2026-05-27 22:16:14 +02:00
parent 8461aa65d5
commit c6a654191c
31 changed files with 622 additions and 522 deletions
+5 -5
View File
@@ -17,11 +17,11 @@
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.79",
"@ai-sdk/google": "^3.0.79",
"@ai-sdk/anthropic": "^3.0.80",
"@ai-sdk/google": "^3.0.80",
"@ai-sdk/openai": "^3.0.65",
"@ai-sdk/openai-compatible": "^2.0.48",
"@aws-sdk/client-s3": "^3.1054.0",
"@aws-sdk/client-s3": "^3.1055.0",
"@better-auth/api-key": "^1.6.11",
"@better-auth/drizzle-adapter": "^1.6.11",
"@better-auth/infra": "^0.2.10",
@@ -51,7 +51,7 @@
"better-auth": "1.6.11",
"cjk-regex": "^3.4.0",
"deepmerge-ts": "^7.1.5",
"dompurify": "^3.4.6",
"dompurify": "^3.4.7",
"dotenv": "^17.4.2",
"drizzle-orm": "1.0.0-rc.3",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
@@ -65,7 +65,7 @@
"pg": "^8.21.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.6",
"react-email": "^6.4.0",
"react-email": "^6.5.0",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sharp": "^0.34.5",
@@ -32,6 +32,14 @@ describe("Combobox", () => {
expect(screen.getAllByText("Beta").length).toBeGreaterThan(0);
});
it("places the clear action inside the trigger footprint", () => {
const { container } = wrap(<Combobox options={[...options]} value="beta" showClear />);
expect(screen.getByRole("button", { name: "Clear selection" })).toHaveClass("absolute", "end-7");
expect(screen.getByRole("combobox")).not.toHaveClass("pe-14");
expect(container.querySelector("[data-slot='combobox-trigger'] > span")).toHaveClass("pe-7");
});
it("renders all option labels for the multi-select default values", () => {
wrap(<Combobox multiple options={[...options]} defaultValue={["alpha", "gamma"]} />);
expect(screen.getAllByText(/Alpha/).length).toBeGreaterThan(0);
+13 -3
View File
@@ -147,6 +147,10 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
[contains],
);
const hasSelectedValue = multiple
? Array.isArray(selectedValue) && selectedValue.length > 0
: selectedValue !== null && selectedValue !== undefined;
const listContent = (item: ComboboxOption<TValue>) => (
<ComboboxItem key={String(item.value)} value={item} disabled={item.disabled}>
{item.label}
@@ -166,7 +170,7 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
)
}
>
<span className="min-w-0 flex-1 truncate text-left">
<span className={cn("min-w-0 flex-1 truncate text-left", showClear && hasSelectedValue && "pe-7")}>
<ComboboxValue placeholder={placeholder ?? t`Select...`} />
</span>
</ComboboxTrigger>
@@ -185,9 +189,15 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
{...(multiple ? { multiple: true } : {})}
>
{showClear ? (
<div className="flex items-center gap-1">
<div className="relative w-full min-w-0">
{triggerNode}
<ComboboxClear disabled={disabled} />
{hasSelectedValue && (
<ComboboxClear
aria-label={t`Clear selection`}
disabled={disabled}
className="absolute end-7 top-1/2 z-10 -translate-y-1/2 text-muted-foreground opacity-70 hover:opacity-100 focus-visible:opacity-100"
/>
)}
</div>
) : (
triggerNode
@@ -8,6 +8,7 @@ import { Copyright } from "@/components/ui/copyright";
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
import { BuilderSidebarEdge } from "../../-components/edge";
import { useBuilderSidebar } from "../../-store/sidebar";
import { CustomStylesSectionBuilder } from "./sections/custom-styles";
import { DesignSectionBuilder } from "./sections/design";
import { ExportSectionBuilder } from "./sections/export";
import { InformationSectionBuilder } from "./sections/information";
@@ -17,7 +18,6 @@ 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";
@@ -27,7 +27,7 @@ function getSectionComponent(type: RightSidebarSection) {
.with("layout", () => <LayoutSectionBuilder />)
.with("typography", () => <TypographySectionBuilder />)
.with("design", () => <DesignSectionBuilder />)
.with("styles", () => <StylesSectionBuilder />)
.with("styles", () => <CustomStylesSectionBuilder />)
.with("page", () => <PageSectionBuilder />)
.with("notes", () => <NotesSectionBuilder />)
.with("sharing", () => <SharingSectionBuilder />)
@@ -1,13 +1,14 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen, within } from "@testing-library/react";
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 { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { isValidElement } from "react";
const updateResumeData = vi.hoisted(() => vi.fn());
const styleRules = vi.hoisted(() => [
const styleRules = vi.hoisted<StyleRule[]>(() => [
{
id: "style-global-heading",
label: "All sections: Section heading",
@@ -35,7 +36,7 @@ vi.mock("@/features/resume/builder/draft", () => ({
useUpdateResumeData: () => updateResumeData,
}));
const { StylesSectionBuilder } = await import("./styles");
const { CustomStylesSectionBuilder } = await import("./custom-styles");
const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section");
beforeAll(() => {
@@ -46,14 +47,19 @@ beforeEach(() => {
updateResumeData.mockClear();
});
const renderStyles = () =>
const renderCustomStyles = () =>
render(
<I18nProvider i18n={i18n}>
<StylesSectionBuilder />
<CustomStylesSectionBuilder />
</I18nProvider>,
);
describe("StylesSectionBuilder", () => {
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({
@@ -65,8 +71,8 @@ describe("StylesSectionBuilder", () => {
});
});
it("renders structured style rule controls", () => {
renderStyles();
it("renders structured style rule controls", async () => {
renderCustomStyles();
expect(screen.getByLabelText("Target Scope")).toBeInTheDocument();
expect(screen.getByLabelText("Style Slot")).toBeInTheDocument();
@@ -91,18 +97,18 @@ describe("StylesSectionBuilder", () => {
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();
fireEvent.click(screen.getByLabelText("Style Slot"));
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", () => {
renderStyles();
renderCustomStyles();
const fontWeightSelect = screen.getByLabelText("Font Weight");
expect(within(fontWeightSelect).getByRole("option", { name: "Default" })).toBeInTheDocument();
expect(within(fontWeightSelect).queryByRole("option", { name: "Template default" })).not.toBeInTheDocument();
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", () => {
@@ -117,7 +123,7 @@ describe("StylesSectionBuilder", () => {
it("upserts one style rule for the selected target and slot", () => {
styleRules.splice(0, styleRules.length);
renderStyles();
renderCustomStyles();
fireEvent.change(screen.getByLabelText("Text Color"), { target: { value: "rgba(220, 38, 38, 1)" } });
@@ -139,13 +145,16 @@ describe("StylesSectionBuilder", () => {
it("stores padding as per-side values", () => {
styleRules.splice(0, styleRules.length);
renderStyles();
renderCustomStyles();
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();
expect(screen.getByLabelText("Padding Top").closest("div")?.parentElement).toHaveClass(
"grid-cols-[repeat(auto-fit,minmax(7rem,1fr))]",
);
fireEvent.change(screen.getByLabelText("Padding Top"), { target: { value: "12" } });
@@ -165,11 +174,11 @@ describe("StylesSectionBuilder", () => {
]);
});
it("stores text decoration intent", () => {
it("stores text decoration intent", async () => {
styleRules.splice(0, styleRules.length);
renderStyles();
renderCustomStyles();
fireEvent.change(screen.getByLabelText("Text Decoration"), { target: { value: "underline" } });
await chooseComboboxOption("Text Decoration", "Underline");
expect(updateResumeData).toHaveBeenCalledTimes(1);
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
@@ -189,7 +198,7 @@ describe("StylesSectionBuilder", () => {
it("stores margin and gap intent", () => {
styleRules.splice(0, styleRules.length);
renderStyles();
renderCustomStyles();
expect(screen.getByLabelText("Margin Bottom")).toHaveAttribute("min", "-72");
expect(screen.getByLabelText("Row Gap")).toHaveAttribute("min", "-72");
@@ -230,11 +239,11 @@ describe("StylesSectionBuilder", () => {
]);
});
it("stores list slot rules for rich text lists", () => {
it("stores list slot rules for rich text lists", async () => {
styleRules.splice(0, styleRules.length);
renderStyles();
renderCustomStyles();
fireEvent.change(screen.getByLabelText("Style Slot"), { target: { value: "richList" } });
await chooseComboboxOption("Style Slot", "List");
fireEvent.change(screen.getByLabelText("Row Gap"), { target: { value: "8" } });
expect(updateResumeData).toHaveBeenCalledTimes(1);
@@ -254,7 +263,7 @@ describe("StylesSectionBuilder", () => {
});
it("can reset the selected style rule", () => {
renderStyles();
renderCustomStyles();
fireEvent.click(screen.getByRole("button", { name: "Reset Style" }));
@@ -288,12 +297,25 @@ describe("StylesSectionBuilder", () => {
});
it("lists applied style rules and toggles individual rules", () => {
renderStyles();
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.getByText("All sections: Section heading")).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.getByLabelText("Toggle All sections: Section heading"));
fireEvent.click(screen.getByRole("button", { name: "Disable All sections: Section heading" }));
expect(updateResumeData).toHaveBeenCalledTimes(1);
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
@@ -305,18 +327,21 @@ describe("StylesSectionBuilder", () => {
expect(draft.metadata.styleRules[0]?.enabled).toBe(false);
});
it("opens a manage rules dialog with editable rule fields", () => {
renderStyles();
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: "Manage Rules" }));
fireEvent.click(screen.getByRole("button", { name: "Edit Experience: List item content" }));
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();
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);
});
});
@@ -5,26 +5,19 @@ import type {
StyleRuleTarget,
StyleSlot,
} from "@reactive-resume/schema/resume/data";
import type { ComponentProps, ReactNode } from "react";
import type { ReactNode } from "react";
import type { ComboboxOption } from "@/components/ui/combobox";
import { Trans } from "@lingui/react/macro";
import { SlidersHorizontalIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { EyeIcon, EyeSlashIcon, PencilSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useMemo, useState } from "react";
import { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Separator } from "@reactive-resume/ui/components/separator";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { ColorPicker } from "@/components/input/color-picker";
import { Combobox } from "@/components/ui/combobox";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { getSectionTitle } from "@/libs/resume/section";
import { SectionBase } from "../shared/section-base";
@@ -37,6 +30,12 @@ type StyleSlotOption = {
group: "Section" | "Rich text";
};
const targetScopeOptions: ComboboxOption<TargetScope>[] = [
{ value: "global", label: "All sections" },
{ value: "sectionType", label: "Section type" },
{ value: "sectionId", label: "Specific section" },
];
const styleSlotOptions: StyleSlotOption[] = [
{ value: "section", label: "Section container", group: "Section" },
{ value: "heading", label: "Section heading", group: "Section" },
@@ -55,10 +54,11 @@ const styleSlotOptions: StyleSlotOption[] = [
{ value: "richMark", label: "Highlight", group: "Rich text" },
];
const groupedStyleSlotOptions: [StyleSlotOption["group"], StyleSlotOption[]][] = [
["Section", styleSlotOptions.filter((option) => option.group === "Section")],
["Rich text", styleSlotOptions.filter((option) => option.group === "Rich text")],
];
const styleSlotComboboxOptions: ComboboxOption<StyleSlot>[] = styleSlotOptions.map((option) => ({
value: option.value,
label: option.label,
keywords: [option.group],
}));
const fontWeightOptions = ["100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
const fontStyleOptions = [
@@ -93,26 +93,32 @@ const borderStyleOptions = [
{ value: "dotted", label: "Dotted" },
] as const satisfies readonly { value: NonNullable<StyleIntent["borderStyle"]>; label: string }[];
export function StylesSectionBuilder() {
const controlGridClassName = "grid grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] gap-3";
const compactControlGridClassName = "grid grid-cols-[repeat(auto-fit,minmax(7rem,1fr))] gap-3";
export function CustomStylesSectionBuilder() {
return (
<SectionBase type="styles" className="space-y-4">
<StylesSectionForm />
<CustomStylesSectionForm />
</SectionBase>
);
}
function StylesSectionForm() {
function CustomStylesSectionForm() {
const resume = useCurrentResume();
const data = resume.data;
const updateResumeData = useUpdateResumeData();
const sectionOptions = useMemo(() => getSectionIdOptions(data), [data]);
const sectionOptions = useMemo<ComboboxOption<string>[]>(() => getSectionIdOptions(data), [data]);
const sectionTypeOptions = useMemo<ComboboxOption<string>[]>(
() => sectionTypeSchema.options.map((type) => ({ value: type, label: getSectionTitle(type) })),
[],
);
const styleRules = data.metadata.styleRules ?? [];
const [targetScope, setTargetScope] = useState<TargetScope>("global");
const [sectionType, setSectionType] = useState("summary");
const [sectionId, setSectionId] = useState("summary");
const [slot, setSlot] = useState<StyleSlot>("heading");
const [isManageDialogOpen, setManageDialogOpen] = useState(false);
const target = createTarget({ targetScope, sectionType, sectionId });
const ruleId = getStyleRuleId(target, slot);
@@ -161,26 +167,14 @@ function StylesSectionForm() {
});
};
const updateRuleLabel = (ruleId: string, label: string) => {
updateResumeData((draft) => {
const rule = (draft.metadata.styleRules ?? []).find((rule) => rule.id === ruleId);
if (rule) rule.label = label;
});
};
const editRule = (rule: StyleRule) => {
const nextSlot = getConfiguredSlots(rule)[0];
if (!nextSlot) return;
const updateRuleIntent = (ruleId: string, slot: StyleSlot, patch: Partial<StyleIntent>) => {
updateResumeData((draft) => {
const rules = draft.metadata.styleRules ?? [];
const ruleIndex = rules.findIndex((rule) => rule.id === ruleId);
const rule = rules[ruleIndex];
if (!rule) return;
const nextIntent = compactIntent({ ...(rule.slots[slot] ?? {}), ...patch });
if (Object.keys(nextIntent).length === 0) delete rule.slots[slot];
else rule.slots[slot] = nextIntent;
if (getConfiguredSlots(rule).length === 0) rules.splice(ruleIndex, 1);
});
setTargetScope(rule.target.scope);
if (rule.target.scope === "sectionType") setSectionType(rule.target.sectionType);
if (rule.target.scope === "sectionId") setSectionId(rule.target.sectionId);
setSlot(nextSlot);
};
const deleteRule = (ruleId: string) => {
@@ -191,59 +185,65 @@ function StylesSectionForm() {
return (
<div className="space-y-4">
<div className="grid @md:grid-cols-2 grid-cols-1 gap-3">
<div className={controlGridClassName}>
<Field label="Target Scope" id="style-target-scope">
<Select
<Combobox
id="style-target-scope"
options={targetScopeOptions}
value={targetScope}
onChange={(event) => setTargetScope(event.target.value as TargetScope)}
>
<option value="global">All sections</option>
<option value="sectionType">Section type</option>
<option value="sectionId">Specific section</option>
</Select>
onValueChange={(value) => {
if (value) setTargetScope(value);
}}
className="w-full"
placeholder="Target scope"
searchPlaceholder="Search scopes..."
/>
</Field>
{targetScope === "sectionType" && (
<Field label="Section Type" id="style-section-type">
<Select
<Combobox
id="style-section-type"
options={sectionTypeOptions}
value={sectionType}
onChange={(event) => setSectionType(event.target.value)}
>
{sectionTypeSchema.options.map((type) => (
<option key={type} value={type}>
{getSectionTitle(type)}
</option>
))}
</Select>
onValueChange={(value) => {
if (value) setSectionType(value);
}}
className="w-full"
placeholder="Section type"
searchPlaceholder="Search section types..."
/>
</Field>
)}
{targetScope === "sectionId" && (
<Field label="Section" id="style-section-id">
<Select id="style-section-id" value={sectionId} onChange={(event) => setSectionId(event.target.value)}>
{sectionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
<Combobox
id="style-section-id"
options={sectionOptions}
value={sectionId}
onValueChange={(value) => {
if (value) setSectionId(value);
}}
className="w-full"
placeholder="Section"
searchPlaceholder="Search sections..."
/>
</Field>
)}
<Field label="Style Slot" id="style-slot">
<Select id="style-slot" value={slot} onChange={(event) => setSlot(event.target.value as StyleSlot)}>
{groupedStyleSlotOptions.map(([group, options]) => (
<optgroup key={group} label={group}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</Select>
<Combobox
id="style-slot"
options={styleSlotComboboxOptions}
value={slot}
onValueChange={(value) => {
if (value) setSlot(value);
}}
className="w-full"
placeholder="Style slot"
searchPlaceholder="Search style slots..."
/>
</Field>
</div>
@@ -263,18 +263,7 @@ function StylesSectionForm() {
data={data}
rules={styleRules}
onToggleRule={updateRuleEnabled}
onDeleteRule={deleteRule}
onManageRules={() => setManageDialogOpen(true)}
/>
<ManageStyleRulesDialog
data={data}
rules={styleRules}
open={isManageDialogOpen}
onOpenChange={setManageDialogOpen}
onToggleRule={updateRuleEnabled}
onUpdateRuleLabel={updateRuleLabel}
onUpdateRuleIntent={updateRuleIntent}
onEditRule={editRule}
onDeleteRule={deleteRule}
/>
</div>
@@ -283,26 +272,15 @@ function StylesSectionForm() {
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<div className="min-w-0 space-y-2">
<Label htmlFor={id} className="block min-w-0 text-pretty leading-snug">
{label}
</Label>
{children}
</div>
);
}
function Select({ className, ...props }: ComponentProps<"select">) {
return (
<select
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none transition-[color,box-shadow]",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
function ColorField({
label,
id,
@@ -320,10 +298,11 @@ function ColorField({
}) {
return (
<Field label={label} id={id}>
<div className="flex items-center gap-3">
<div className="flex min-w-0 items-center gap-2">
<ColorPicker value={value ?? fallback} defaultValue={fallback} onChange={(color) => onChange(color)} />
<Input
id={id}
className="min-w-0"
value={value ?? ""}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value.trim() || undefined)}
@@ -356,6 +335,7 @@ function NumberInput({
<Field label={label} id={inputId}>
<Input
id={inputId}
className="tabular-nums"
value={value ?? ""}
type="number"
min={min}
@@ -374,31 +354,24 @@ function AppliedRulesList({
data,
rules,
onToggleRule,
onEditRule,
onDeleteRule,
onManageRules,
}: {
data: ResumeData;
rules: StyleRule[];
onToggleRule: (ruleId: string, enabled: boolean) => void;
onEditRule: (rule: StyleRule) => void;
onDeleteRule: (ruleId: string) => void;
onManageRules: () => void;
}) {
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<h3 className="font-medium text-sm">
<Trans>Applied Rules</Trans>
</h3>
<p className="text-muted-foreground text-xs tabular-nums">
{rules.length} {rules.length === 1 ? <Trans>rule</Trans> : <Trans>rules</Trans>}
</p>
</div>
<Button type="button" variant="ghost" size="sm" onClick={onManageRules}>
<SlidersHorizontalIcon data-icon="inline-start" />
<Trans>Manage Rules</Trans>
</Button>
<div className="space-y-0.5">
<h3 className="font-medium text-sm">
<Trans>Applied Rules</Trans>
</h3>
<p className="text-muted-foreground text-xs tabular-nums">
{rules.length} {rules.length === 1 ? <Trans>rule</Trans> : <Trans>rules</Trans>}
</p>
</div>
{rules.length === 0 ? (
@@ -413,6 +386,7 @@ function AppliedRulesList({
data={data}
rule={rule}
onToggleRule={onToggleRule}
onEditRule={onEditRule}
onDeleteRule={onDeleteRule}
/>
))}
@@ -426,54 +400,58 @@ function AppliedRuleCard({
data,
rule,
onToggleRule,
onEditRule,
onDeleteRule,
}: {
data: ResumeData;
rule: StyleRule;
onToggleRule: (ruleId: string, enabled: boolean) => void;
onEditRule: (rule: StyleRule) => void;
onDeleteRule: (ruleId: string) => void;
}) {
const slots = getConfiguredSlots(rule);
const primaryIntent = slots[0] ? rule.slots[slots[0]] : undefined;
const fallbackLabel = getRuleFallbackLabel(data, rule);
const ruleLabel = rule.label || fallbackLabel;
const targetLabel = getTargetLabel(data, rule.target);
const slotLabel = slots.length > 0 ? slots.map(getSlotLabel).join(", ") : "No slot";
return (
<div className={cn("rounded-lg border bg-background/70 p-3 transition-opacity", !rule.enabled && "opacity-60")}>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1 space-y-2">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate font-medium text-sm">{rule.label || fallbackLabel}</span>
{!rule.enabled && (
<Badge variant="outline" className="text-muted-foreground">
<Trans>Off</Trans>
</Badge>
)}
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant="secondary">{getTargetLabel(data, rule.target)}</Badge>
{slots.map((slot) => (
<Badge key={slot} variant="outline">
{getSlotLabel(slot)}
</Badge>
))}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<RuleScopePill target={targetLabel} slot={slotLabel} />
</div>
{primaryIntent && <RulePropertySummary intent={primaryIntent} />}
</div>
<div className="flex shrink-0 items-center gap-1">
<Switch
size="sm"
checked={rule.enabled}
aria-label={`Toggle ${rule.label || fallbackLabel}`}
onCheckedChange={(checked) => onToggleRule(rule.id, checked)}
/>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Delete ${rule.label || fallbackLabel}`}
aria-label={`${rule.enabled ? "Disable" : "Enable"} ${ruleLabel}`}
aria-pressed={rule.enabled}
onClick={() => onToggleRule(rule.id, !rule.enabled)}
>
{rule.enabled ? <EyeIcon /> : <EyeSlashIcon />}
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Edit ${ruleLabel}`}
onClick={() => onEditRule(rule)}
>
<PencilSimpleIcon />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Delete ${ruleLabel}`}
onClick={() => onDeleteRule(rule.id)}
>
<TrashSimpleIcon />
@@ -484,130 +462,13 @@ function AppliedRuleCard({
);
}
function ManageStyleRulesDialog({
data,
rules,
open,
onOpenChange,
onToggleRule,
onUpdateRuleLabel,
onUpdateRuleIntent,
onDeleteRule,
}: {
data: ResumeData;
rules: StyleRule[];
open: boolean;
onOpenChange: (open: boolean) => void;
onToggleRule: (ruleId: string, enabled: boolean) => void;
onUpdateRuleLabel: (ruleId: string, label: string) => void;
onUpdateRuleIntent: (ruleId: string, slot: StyleSlot, patch: Partial<StyleIntent>) => void;
onDeleteRule: (ruleId: string) => void;
}) {
function RuleScopePill({ target, slot }: { target: string; slot: string }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(760px,calc(100svh-2rem))] gap-4 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
<Trans>Manage Style Rules</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Review and edit the style rules saved on this resume.</Trans>
</DialogDescription>
</DialogHeader>
{rules.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/20 px-3 py-5 text-muted-foreground text-sm">
<Trans>No style rules yet.</Trans>
</div>
) : (
<div className="space-y-3">
{rules.map((rule) => (
<ManagedRuleCard
key={rule.id}
data={data}
rule={rule}
onToggleRule={onToggleRule}
onUpdateRuleLabel={onUpdateRuleLabel}
onUpdateRuleIntent={onUpdateRuleIntent}
onDeleteRule={onDeleteRule}
/>
))}
</div>
)}
</DialogContent>
</Dialog>
);
}
function ManagedRuleCard({
data,
rule,
onToggleRule,
onUpdateRuleLabel,
onUpdateRuleIntent,
onDeleteRule,
}: {
data: ResumeData;
rule: StyleRule;
onToggleRule: (ruleId: string, enabled: boolean) => void;
onUpdateRuleLabel: (ruleId: string, label: string) => void;
onUpdateRuleIntent: (ruleId: string, slot: StyleSlot, patch: Partial<StyleIntent>) => void;
onDeleteRule: (ruleId: string) => void;
}) {
const slots = getConfiguredSlots(rule);
const fallbackLabel = getRuleFallbackLabel(data, rule);
const labelId = `style-dialog-${slugify(rule.id)}-label`;
return (
<div className="space-y-3 rounded-lg border bg-background/70 p-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1 space-y-2">
<Field label="Rule Label" id={labelId}>
<Input
id={labelId}
value={rule.label}
onChange={(event) => onUpdateRuleLabel(rule.id, event.target.value)}
/>
</Field>
<div className="flex flex-wrap gap-1.5">
<Badge variant="secondary">{getTargetLabel(data, rule.target)}</Badge>
{slots.map((slot) => (
<Badge key={slot} variant="outline">
{getSlotLabel(slot)}
</Badge>
))}
</div>
</div>
<div className="flex shrink-0 items-center gap-1 pt-7">
<Switch
size="sm"
checked={rule.enabled}
aria-label={`Toggle ${rule.label || fallbackLabel}`}
onCheckedChange={(checked) => onToggleRule(rule.id, checked)}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Delete ${rule.label || fallbackLabel}`}
onClick={() => onDeleteRule(rule.id)}
>
<TrashSimpleIcon />
</Button>
</div>
</div>
{slots.map((slot) => (
<RuleIntentEditor
key={slot}
idPrefix={`style-dialog-${slugify(rule.id)}-${slot}`}
intent={rule.slots[slot] ?? {}}
labelPrefix="Dialog"
onChange={(patch) => onUpdateRuleIntent(rule.id, slot, patch)}
/>
))}
<div className="inline-flex max-w-full overflow-hidden rounded-sm border border-border/80 bg-background text-xs shadow-xs">
<span className="min-w-0 max-w-32 truncate bg-secondary px-2.5 py-1 font-medium text-secondary-foreground">
{target}
</span>
<span className="min-w-0 max-w-40 truncate px-2.5 py-1 font-medium text-foreground">{slot}</span>
</div>
);
}
@@ -628,7 +489,7 @@ function RuleIntentEditor({
return (
<div className="space-y-3">
<ControlPanel title="Color">
<div className="grid @md:grid-cols-2 grid-cols-1 gap-3">
<div className={controlGridClassName}>
<ColorField
label={`${labelStart}Text Color`}
id={`${idPrefix}-color`}
@@ -666,7 +527,7 @@ function RuleIntentEditor({
</ControlPanel>
<ControlPanel title="Text">
<div className="grid @md:grid-cols-2 grid-cols-1 gap-3">
<div className={controlGridClassName}>
<NumberInput
label={`${labelStart}Font Size`}
id={`${idPrefix}-font-size`}
@@ -746,7 +607,7 @@ function RuleIntentEditor({
<MarginSideInputs idPrefix={idPrefix} intent={intent} labelPrefix={labelPrefix} onChange={onChange} />
</ControlSubsection>
<ControlSubsection title="Gap">
<div className="grid @md:grid-cols-2 grid-cols-1 gap-3">
<div className={controlGridClassName}>
<NumberInput
label={`${labelStart}Row Gap`}
id={`${idPrefix}-row-gap`}
@@ -769,7 +630,7 @@ function RuleIntentEditor({
</ControlPanel>
<ControlPanel title="Border">
<div className="grid @md:grid-cols-3 grid-cols-1 gap-3">
<div className={controlGridClassName}>
<IntentSelectField
label={`${labelStart}Border Style`}
id={`${idPrefix}-border-style`}
@@ -835,23 +696,21 @@ function IntentSelectField<TValue extends string>({
label: string;
id: string;
value: TValue | undefined;
options: readonly { value: TValue; label: string }[];
options: readonly ComboboxOption<TValue>[];
onChange: (value: TValue | undefined) => void;
}) {
return (
<Field label={label} id={id}>
<Select
<Combobox
id={id}
value={value ?? ""}
onChange={(event) => onChange((event.target.value || undefined) as TValue | undefined)}
>
<option value="">Default</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
options={[...options]}
value={value ?? null}
onValueChange={(nextValue) => onChange(nextValue ?? undefined)}
className="w-full"
showClear
placeholder="Default"
searchPlaceholder={`Search ${label.toLowerCase()}...`}
/>
</Field>
);
}
@@ -867,20 +726,23 @@ function FontWeightField({
value: StyleIntent["fontWeight"] | undefined;
onChange: (value: StyleIntent["fontWeight"] | undefined) => void;
}) {
const options: ComboboxOption<NonNullable<StyleIntent["fontWeight"]>>[] = fontWeightOptions.map((weight) => ({
value: weight,
label: weight,
}));
return (
<Field label={label} id={id}>
<Select
<Combobox
id={id}
value={value ?? ""}
onChange={(event) => onChange((event.target.value || undefined) as StyleIntent["fontWeight"] | undefined)}
>
<option value="">Default</option>
{fontWeightOptions.map((weight) => (
<option key={weight} value={weight}>
{weight}
</option>
))}
</Select>
options={options}
value={value ?? null}
onValueChange={(nextValue) => onChange(nextValue ?? undefined)}
className="w-full"
showClear
placeholder="Default"
searchPlaceholder="Search font weights..."
/>
</Field>
);
}
@@ -917,7 +779,7 @@ function PaddingSideInputs({
const labelStart = labelPrefix ? `${labelPrefix} ` : "";
return (
<div className="grid @lg:grid-cols-4 grid-cols-2 gap-3">
<div className={compactControlGridClassName}>
{paddingSideOptions.map((side) => (
<NumberInput
key={side.property}
@@ -947,7 +809,7 @@ function MarginSideInputs({
const labelStart = labelPrefix ? `${labelPrefix} ` : "";
return (
<div className="grid @lg:grid-cols-4 grid-cols-2 gap-3">
<div className={compactControlGridClassName}>
{marginSideOptions.map((side) => (
<NumberInput
key={side.property}
+2 -2
View File
@@ -13,7 +13,7 @@
"type": "git",
"url": "https://github.com/amruthpillai/reactive-resume.git"
},
"packageManager": "pnpm@11.3.0+sha512.2c403d6594527287672b1f7056343a1f7c3634036a67ffabfcc2b3d7595d843768f8787148d1b57cf7956c90606bbd192857c363af19e96d2d0ec9ec5741d215",
"packageManager": "pnpm@11.4.0",
"workspaces": [
"apps/*",
"packages/*",
@@ -38,7 +38,7 @@
"test:agent": "turbo run test:agent"
},
"devDependencies": {
"@biomejs/biome": "^2.4.15",
"@biomejs/biome": "^2.4.16",
"@commitlint/cli": "^21.0.1",
"@commitlint/config-conventional": "^21.0.1",
"@reactive-resume/config": "workspace:*",
+3 -3
View File
@@ -19,11 +19,11 @@
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.79",
"@ai-sdk/google": "^3.0.79",
"@ai-sdk/anthropic": "^3.0.80",
"@ai-sdk/google": "^3.0.80",
"@ai-sdk/openai": "^3.0.65",
"@ai-sdk/openai-compatible": "^2.0.48",
"@aws-sdk/client-s3": "^3.1054.0",
"@aws-sdk/client-s3": "^3.1055.0",
"@orpc/client": "^1.14.3",
"@orpc/experimental-ratelimit": "^1.14.3",
"@orpc/server": "^1.14.3",
+1 -1
View File
@@ -16,7 +16,7 @@
"dependencies": {
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"docx": "^9.7.0"
"docx": "^9.7.1"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
+2 -2
View File
@@ -19,10 +19,10 @@
"@reactive-resume/env": "workspace:*",
"nodemailer": "^8.0.9",
"react": "^19.2.6",
"react-email": "^6.4.0"
"react-email": "^6.5.0"
},
"devDependencies": {
"@react-email/ui": "^6.4.0",
"@react-email/ui": "^6.5.0",
"@reactive-resume/config": "workspace:*",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19.2.15",
@@ -210,7 +210,6 @@ const useAzurillTemplate = (): AzurillTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -206,7 +206,6 @@ const useBronzorTemplate = (): BronzorTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -220,7 +220,6 @@ const useChikoritaTemplate = (): ChikoritaTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -248,7 +248,6 @@ const useDitgarTemplate = (): DitgarTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -216,7 +216,6 @@ const useDittoTemplate = (): DittoTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -245,7 +245,6 @@ const useGengarTemplate = (): GengarTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -225,7 +225,6 @@ const useGlalieTemplate = (): GlalieTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -188,7 +188,6 @@ const useKakunaTemplate = (): KakunaTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -190,7 +190,6 @@ const useLaprasTemplate = (): LaprasTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -216,7 +216,6 @@ const useLeafishTemplate = (): LeafishTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -191,7 +191,6 @@ const useMeowthTemplate = (): MeowthTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -186,7 +186,6 @@ const useOnyxTemplate = (): OnyxTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -223,7 +223,6 @@ const usePikachuTemplate = (): PikachuTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -236,7 +236,6 @@ const useRhyhornTemplate = (): RhyhornTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -183,7 +183,6 @@ const useScizorTemplate = (): ScizorTemplate => {
richListItemContent: {
...bodyText,
flex: 1,
lineHeight: metadata.typography.body.lineHeight * 0.5,
},
splitRow: {
flexDirection: r.row,
@@ -0,0 +1,28 @@
import { readdirSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const templatesDir = fileURLToPath(new URL("../", import.meta.url));
const templatePageFiles = readdirSync(templatesDir, { withFileTypes: true }).flatMap((entry) => {
if (!entry.isDirectory() || entry.name === "shared") return [];
const templateDir = join(templatesDir, entry.name);
const pageFile = readdirSync(templateDir).find((file) => file.endsWith("Page.tsx"));
return pageFile ? [join(templateDir, pageFile)] : [];
});
describe("rich text template styles", () => {
it.each(
templatePageFiles.map((file) => [basename(file), file]),
)("%s keeps list item rich text on the global body line height", (_name, file) => {
const source = readFileSync(file, "utf8");
const richListItemContentBlock = source.match(/richListItemContent:\s*{(?<body>[\s\S]*?)^\s*},/m);
expect(richListItemContentBlock?.groups?.body).toBeDefined();
expect(richListItemContentBlock?.groups?.body).toContain("...bodyText");
expect(richListItemContentBlock?.groups?.body).not.toMatch(/\blineHeight:/);
});
});
+1 -1
View File
@@ -30,7 +30,7 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-resizable-panels": "^4.11.2",
"shadcn": "^4.8.1",
"shadcn": "^4.8.2",
"sonner": "^2.0.7",
"tw-animate-css": "^1.4.0"
},
+1 -1
View File
@@ -10,7 +10,7 @@ const buttonVariants = cva(
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
"border-border bg-transparent hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground",
+1 -1
View File
@@ -194,7 +194,7 @@ function ComboboxChips({
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-9 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1 has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
"flex min-h-9 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1 has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20",
className,
)}
{...props}
+1 -1
View File
@@ -30,7 +30,7 @@
"@sindresorhus/slugify": "^3.0.0",
"@uiw/color-convert": "^2.10.3",
"clsx": "^2.1.1",
"dompurify": "^3.4.6",
"dompurify": "^3.4.7",
"tailwind-merge": "^3.6.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.0",
+337 -155
View File
File diff suppressed because it is too large Load Diff