diff --git a/apps/web/src/dialogs/resume/template/data.test.ts b/apps/web/src/dialogs/resume/template/data.test.ts index 22949444a..2a77047ca 100644 --- a/apps/web/src/dialogs/resume/template/data.test.ts +++ b/apps/web/src/dialogs/resume/template/data.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { templateLayouts } from "@reactive-resume/schema/templates"; import { templates } from "./data"; describe("templates metadata", () => { @@ -37,10 +38,11 @@ describe("templates metadata", () => { } }); - it("uses a recognized sidebar position for every template", () => { - const validPositions = new Set(["left", "right", "none"]); - for (const [id, meta] of entries) { - expect(validPositions.has(meta.sidebarPosition), `${id}: ${meta.sidebarPosition}`).toBe(true); + it("takes each template's layout from the shared metadata", () => { + for (const [id] of entries) { + const layout = templateLayouts[id as keyof typeof templateLayouts]; + expect(layout, id).toBeDefined(); + expect(layout.columns === 2, id).toBe(layout.sidebarSide !== null); } }); diff --git a/apps/web/src/dialogs/resume/template/data.ts b/apps/web/src/dialogs/resume/template/data.ts index e66f41a98..623859629 100644 --- a/apps/web/src/dialogs/resume/template/data.ts +++ b/apps/web/src/dialogs/resume/template/data.ts @@ -7,7 +7,6 @@ export type TemplateMetadata = { description: MessageDescriptor; imageUrl: string; tags: string[]; - sidebarPosition: "left" | "right" | "none"; }; export const templates = { @@ -16,104 +15,89 @@ export const templates = { description: msg`Two-column with a bold colored sidebar and skill bars; great for creative or tech roles where visual flair is welcome.`, imageUrl: "/templates/jpg/azurill.jpg", tags: ["Two-column", "Creative", "Tech", "Visual flair"], - sidebarPosition: "left", }, bronzor: { name: "Bronzor", description: msg`Two-column, clean and professional with subtle section dividers; suits corporate, finance, or consulting positions.`, imageUrl: "/templates/jpg/bronzor.jpg", tags: ["Two-column", "Clean", "Professional", "Corporate", "Finance", "Consulting"], - sidebarPosition: "none", }, chikorita: { name: "Chikorita", description: msg`Two-column with a soft header accent and circular profile photo; ideal for marketing, HR, or client-facing roles.`, imageUrl: "/templates/jpg/chikorita.jpg", tags: ["Two-column", "Soft accent", "Marketing", "HR", "Client-facing"], - sidebarPosition: "right", }, ditgar: { name: "Ditgar", description: msg`Two-column with a dark teal sidebar and skills grid; modern feel for developers, data scientists, or technical PMs.`, imageUrl: "/templates/jpg/ditgar.jpg", tags: ["Two-column", "Modern", "Developer", "Data science", "Technical PM", "Dark sidebar"], - sidebarPosition: "left", }, ditto: { name: "Ditto", description: msg`Two-column, minimal and text-dense with no decorative elements; perfect for traditional industries or ATS-heavy applications.`, imageUrl: "/templates/jpg/ditto.jpg", tags: ["Two-column", "ATS friendly", "Minimal", "Text-dense", "Traditional", "No decoration"], - sidebarPosition: "left", }, gengar: { name: "Gengar", description: msg`Two-column with accent colors and clean typography; balanced choice for business analysts or operations roles.`, imageUrl: "/templates/jpg/gengar.jpg", tags: ["Two-column", "Accent colors", "Clean typography", "Business analyst", "Operations"], - sidebarPosition: "left", }, glalie: { name: "Glalie", description: msg`Two-column, minimal with light gray sidebar and subtle icons; professional and understated for legal, finance, or executive roles.`, imageUrl: "/templates/jpg/glalie.jpg", tags: ["Two-column", "Minimal", "Professional", "Legal", "Finance", "Executive", "Understated"], - sidebarPosition: "left", }, kakuna: { name: "Kakuna", description: msg`Single-column with a magenta left border accent; compact and efficient for entry-level or internship applications.`, imageUrl: "/templates/jpg/kakuna.jpg", tags: ["Single-column", "ATS friendly", "Compact", "Efficient", "Entry level", "Internship", "Magenta accent"], - sidebarPosition: "none", }, lapras: { name: "Lapras", description: msg`Single-column; polished and serious for senior or enterprise-level positions.`, imageUrl: "/templates/jpg/lapras.jpg", tags: ["Single-column", "ATS friendly", "Polished", "Senior", "Enterprise"], - sidebarPosition: "none", }, leafish: { name: "Leafish", description: msg`Two-column with a muted color sidebar; earthy and calm, suits sustainability, healthcare, or nonprofit sectors.`, imageUrl: "/templates/jpg/leafish.jpg", tags: ["Two-column", "Muted sidebar", "Earthy", "Calm", "Sustainability", "Healthcare", "Nonprofit"], - sidebarPosition: "right", }, meowth: { name: "Meowth", description: msg`Single-column with an inline three-column entry header (position · organization · period); compact and ATS-friendly, well-suited for Asian resume conventions (CN/JP/KR).`, imageUrl: "/templates/jpg/meowth.jpg", tags: ["Single-column", "ATS friendly", "Inline header", "Compact", "Asian style", "CN/JP/KR"], - sidebarPosition: "none", }, onyx: { name: "Onyx", description: msg`Single-column with a sidebar and clean grid layout; versatile for any professional or technical role.`, imageUrl: "/templates/jpg/onyx.jpg", tags: ["Single-column", "ATS friendly", "Sidebar", "Grid layout", "Versatile", "Professional", "Technical"], - sidebarPosition: "none", }, pikachu: { name: "Pikachu", description: msg`Two-column with a left margin color; simple and approachable for creative, editorial, or junior roles.`, imageUrl: "/templates/jpg/pikachu.jpg", tags: ["Two-column", "Simple", "Creative", "Editorial", "Junior", "Accent colors"], - sidebarPosition: "left", }, rhyhorn: { name: "Rhyhorn", description: msg`Single-column with a minimal top header and lots of whitespace; clean and modern for designers or content creators.`, imageUrl: "/templates/jpg/rhyhorn.jpg", tags: ["Single-column", "ATS friendly", "Minimal", "Clean", "Modern", "Designer", "Content creator", "Whitespace"], - sidebarPosition: "none", }, scizor: { name: "Scizor", description: msg`Single-column with uppercase section headings and a primary-color top rule on every page; polished for executive, consulting, or startup resumes.`, imageUrl: "/templates/jpg/scizor.jpg", tags: ["Single-column", "ATS friendly", "Uppercase headings", "Executive", "Consulting", "Startup"], - sidebarPosition: "none", }, } as const satisfies Record; diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx index 442592e54..2559b413e 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx @@ -25,6 +25,7 @@ import { } from "@phosphor-icons/react"; import { useCallback, useId, useState } from "react"; import { match } from "ts-pattern"; +import { templateLayouts } from "@reactive-resume/schema/templates"; import { Button } from "@reactive-resume/ui/components/button"; import { DropdownMenu, @@ -39,7 +40,6 @@ import { } from "@reactive-resume/ui/components/dropdown-menu"; import { Switch } from "@reactive-resume/ui/components/switch"; import { cn } from "@reactive-resume/utils/style"; -import { templates } from "@/dialogs/resume/template/data"; import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft"; import { EASE_OUT_STRONG } from "@/libs/motion"; import { resolveLayoutSectionTitle } from "./title"; @@ -97,7 +97,7 @@ export function LayoutPages() { const resume = useCurrentResume(); const template = resume.data.metadata.template; - const templateSidebarPosition = templates[template].sidebarPosition; + const templateSidebarPosition = templateLayouts[template].sidebarSide ?? "none"; const layout = resume.data.metadata.layout; const updateResumeData = useUpdateResumeData(); @@ -450,7 +450,7 @@ function MoveToSubmenu({ id, pageIndex, columnId }: MoveToSubmenuProps) { const pages = resume.data.metadata.layout.pages; // When the template collapses the sidebar, no page has a usable sidebar column. - const sidebarCollapsed = templates[resume.data.metadata.template].sidebarPosition === "none"; + const sidebarCollapsed = templateLayouts[resume.data.metadata.template].columns === 1; const moveTo = (targetPageIndex: number, targetColumnId: ColumnId) => { updateResumeData((draft) => { diff --git a/packages/docx/src/builder.ts b/packages/docx/src/builder.ts index 363227781..aa6e146ec 100644 --- a/packages/docx/src/builder.ts +++ b/packages/docx/src/builder.ts @@ -68,7 +68,7 @@ interface TemplateConfig { headerPosition: "full-width" | "main-only" | "sidebar-only"; } -const TEMPLATE_CONFIGS: Record = { +export const TEMPLATE_CONFIGS: Record = { azurill: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" }, bronzor: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" }, chikorita: { sidebarSide: "right", sidebarBackground: "solid", headerPosition: "main-only" }, diff --git a/packages/docx/src/template-layouts.test.ts b/packages/docx/src/template-layouts.test.ts new file mode 100644 index 000000000..5904783c9 --- /dev/null +++ b/packages/docx/src/template-layouts.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { templateLayouts } from "@reactive-resume/schema/templates"; +import { TEMPLATE_CONFIGS } from "./builder"; + +describe("DOCX template configs", () => { + it("put the sidebar and header where the PDF does for two-column templates", () => { + for (const [template, layout] of Object.entries(templateLayouts)) { + if (layout.columns !== 2) continue; + const config = TEMPLATE_CONFIGS[template as keyof typeof TEMPLATE_CONFIGS]; + expect({ sidebarSide: config.sidebarSide, headerPosition: config.headerPosition }, template).toEqual({ + sidebarSide: layout.sidebarSide, + headerPosition: layout.headerPlacement, + }); + } + }); +}); diff --git a/packages/schema/src/templates.ts b/packages/schema/src/templates.ts index e61a97538..a4f4c2d50 100644 --- a/packages/schema/src/templates.ts +++ b/packages/schema/src/templates.ts @@ -19,3 +19,36 @@ export const templateSchema = z.enum([ ]); export type Template = z.infer; + +/** How a template lays out the page. The single source for the gallery filters, the Check layout rule and DOCX. */ +export type TemplateLayout = { + /** Two-column templates print the sidebar as a column of its own; one-column templates read top to bottom. */ + columns: 1 | 2; + /** Where the sidebar column sits by default. Null for one-column templates. */ + sidebarSide: "left" | "right" | null; + /** Where the header sits: across the page, over the main column only, or in the sidebar. */ + headerPlacement: "full-width" | "main-only" | "sidebar-only"; + /** One reading order and plain text headings, which applicant tracking systems read cleanly. */ + atsSafe: boolean; +}; + +const oneColumn: TemplateLayout = { columns: 1, sidebarSide: null, headerPlacement: "full-width", atsSafe: true }; + +export const templateLayouts = { + azurill: { columns: 2, sidebarSide: "left", headerPlacement: "full-width", atsSafe: false }, + // Bronzor prints sidebar sections as labelled rows inside one column. + bronzor: oneColumn, + chikorita: { columns: 2, sidebarSide: "right", headerPlacement: "main-only", atsSafe: false }, + ditgar: { columns: 2, sidebarSide: "left", headerPlacement: "sidebar-only", atsSafe: false }, + ditto: { columns: 2, sidebarSide: "left", headerPlacement: "full-width", atsSafe: false }, + gengar: { columns: 2, sidebarSide: "left", headerPlacement: "sidebar-only", atsSafe: false }, + glalie: { columns: 2, sidebarSide: "left", headerPlacement: "sidebar-only", atsSafe: false }, + kakuna: oneColumn, + lapras: oneColumn, + leafish: { columns: 2, sidebarSide: "right", headerPlacement: "full-width", atsSafe: false }, + meowth: oneColumn, + onyx: oneColumn, + pikachu: { columns: 2, sidebarSide: "left", headerPlacement: "main-only", atsSafe: false }, + rhyhorn: oneColumn, + scizor: oneColumn, +} as const satisfies Record;