From 85352ee11bbdefb69d019efd785049d0aef648c5 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 28 Sep 2026 19:04:03 +0200 Subject: [PATCH] feat(schema): describe template layouts in one place Columns, sidebar side, header placement and ATS safety for every template now live in templateLayouts. The template gallery metadata and the layout editor read it, and a DOCX test checks the two-column configurations against it. --- .../src/dialogs/resume/template/data.test.ts | 10 +++--- apps/web/src/dialogs/resume/template/data.ts | 16 --------- .../-sidebar/right/sections/layout/pages.tsx | 6 ++-- packages/docx/src/builder.ts | 2 +- packages/docx/src/template-layouts.test.ts | 16 +++++++++ packages/schema/src/templates.ts | 33 +++++++++++++++++++ 6 files changed, 59 insertions(+), 24 deletions(-) create mode 100644 packages/docx/src/template-layouts.test.ts 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;