feat: choose which side the sidebar sits on

Two-column templates take a sidebarSide from the layout (left or right).
Without one, each template keeps its own side and right-to-left pages
mirror it. The PDF templates reverse only their columns, header band
and sidebar background, and the DOCX export follows the same choice.
Design's Sidebar panel offers Left and Right.
This commit is contained in:
Amruth Pillai
2026-09-29 08:00:33 +02:00
parent 11fe2b7a28
commit 098df60230
21 changed files with 255 additions and 36 deletions
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import type { Resume } from "@/features/resume/builder/draft";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
@@ -33,13 +33,15 @@ afterEach(() => {
toastState.add.mockClear();
});
function renderGroup() {
function renderGroup(edit?: (data: Resume["data"]) => void) {
const data = parseResumeData(structuredClone(sampleResumeData));
edit?.(data);
const resume: Resume = {
id: "template-group",
name: "Template group",
slug: "template-group",
tags: [],
data: parseResumeData(structuredClone(sampleResumeData)),
data,
isLocked: false,
updatedAt: new Date("2026-09-28T00:00:00.000Z"),
};
@@ -130,4 +132,28 @@ describe("TemplateGroup", () => {
expect(screen.getByText(`${twoColumn} of ${Object.keys(templateLayouts).length} shown`)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^Bronzor\b/ })).not.toBeInTheDocument();
});
it("puts a two-column template's sidebar on the side chosen, starting from the side the page shows", () => {
renderGroup((data) => {
data.metadata.template = "azurill";
});
const side = screen.getByRole("radiogroup", { name: "Sidebar side" });
const option = (name: string) => within(side).getByRole("radio", { name });
expect(option("Left")).toBeChecked();
fireEvent.click(option("Right"));
expect(useResumeStore.getState().resume?.data.metadata.layout.sidebarSide).toBe("right");
expect(option("Right")).toBeChecked();
});
it("shows a right-to-left page's mirrored sidebar as the side it's on", () => {
renderGroup((data) => {
data.metadata.template = "azurill";
data.metadata.page.locale = "ar-SA";
});
const side = screen.getByRole("radiogroup", { name: "Sidebar side" });
expect(within(side).getByRole("radio", { name: "Right" })).toBeChecked();
});
});
@@ -9,8 +9,10 @@ import { useEffect, useId, useRef, useState } from "react";
import { templateLayouts } from "@reactive-resume/schema/templates";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
import { Icon } from "@reactive-resume/ui/components/icon";
import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control";
import { Slider } from "@reactive-resume/ui/components/slider";
import { toast } from "@reactive-resume/ui/components/toast";
import { isRTL } from "@reactive-resume/utils/locale";
import { cn } from "@reactive-resume/utils/style";
import { templates } from "@/dialogs/resume/template/data";
import { useResumeData, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
@@ -140,7 +142,7 @@ export function TemplateGroup({ layout = "grid" }: TemplateGroupProps) {
))}
</div>
{templateLayouts[current].columns === 2 && <SidebarPanel data={data} />}
{templateLayouts[current].columns === 2 && <SidebarPanel data={data} template={current} />}
</div>
);
}
@@ -205,9 +207,15 @@ function TemplateCard({ id, data, selected, onPreview }: TemplateCardProps) {
);
}
/** Two-column templates: the sidebar's width and which sections it holds, with the ATS caveat. */
function SidebarPanel({ data }: { data: ResumeData }) {
type SidebarPanelProps = { data: ResumeData; template: Template };
/** Two-column templates: the sidebar's side and width, which sections it holds, and the ATS caveat. */
function SidebarPanel({ data, template }: SidebarPanelProps) {
const updateResumeData = useUpdateResumeData();
// The side the page shows now: the chosen one, else the template's own (mirrored on right-to-left pages).
const ownSide = templateLayouts[template].sidebarSide ?? "left";
const shownOwnSide = isRTL(data.metadata.page.locale) ? (ownSide === "left" ? "right" : "left") : ownSide;
const side = data.metadata.layout.sidebarSide ?? shownOwnSide;
const rows = getOutlineRows(data);
const width = Math.min(42, Math.max(26, data.metadata.layout.sidebarWidth));
const titleId = useId();
@@ -244,6 +252,26 @@ function SidebarPanel({ data }: { data: ResumeData }) {
<p id={titleId} className="font-medium text-sm">
<Trans>Sidebar</Trans>
</p>
<SegmentedControl
aria-label={t`Sidebar side`}
value={side}
onValueChange={(value) =>
updateResumeData(
(draft) => {
draft.metadata.layout.sidebarSide = value === "right" ? "right" : "left";
},
{ newStep: true },
)
}
className="w-full"
>
<SegmentedControlItem value="left">
<Trans>Left</Trans>
</SegmentedControlItem>
<SegmentedControlItem value="right">
<Trans>Right</Trans>
</SegmentedControlItem>
</SegmentedControl>
<div className="grid gap-2">
<div className="flex items-center justify-between text-[13px]">
<span id={widthId}>
+8
View File
@@ -4415,6 +4415,14 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"minimum": 10,
"maximum": 50
},
"sidebarSide": {
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it).",
"type": "string",
"enum": [
"left",
"right"
]
},
"pages": {
"type": "array",
"items": {
+56
View File
@@ -23527,6 +23527,14 @@
"maximum": 50,
"description": "The width of the sidebar column, defined as a percentage of the page width."
},
"sidebarSide": {
"enum": [
"left",
"right"
],
"type": "string",
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
},
"pages": {
"type": "array",
"items": {
@@ -27886,6 +27894,14 @@
"maximum": 50,
"description": "The width of the sidebar column, defined as a percentage of the page width."
},
"sidebarSide": {
"enum": [
"left",
"right"
],
"type": "string",
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
},
"pages": {
"type": "array",
"items": {
@@ -32380,6 +32396,14 @@
"maximum": 50,
"description": "The width of the sidebar column, defined as a percentage of the page width."
},
"sidebarSide": {
"enum": [
"left",
"right"
],
"type": "string",
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
},
"pages": {
"type": "array",
"items": {
@@ -36845,6 +36869,14 @@
"maximum": 50,
"description": "The width of the sidebar column, defined as a percentage of the page width."
},
"sidebarSide": {
"enum": [
"left",
"right"
],
"type": "string",
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
},
"pages": {
"type": "array",
"items": {
@@ -41798,6 +41830,14 @@
"maximum": 50,
"description": "The width of the sidebar column, defined as a percentage of the page width."
},
"sidebarSide": {
"enum": [
"left",
"right"
],
"type": "string",
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
},
"pages": {
"type": "array",
"items": {
@@ -46255,6 +46295,14 @@
"maximum": 50,
"description": "The width of the sidebar column, defined as a percentage of the page width."
},
"sidebarSide": {
"enum": [
"left",
"right"
],
"type": "string",
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
},
"pages": {
"type": "array",
"items": {
@@ -51054,6 +51102,14 @@
"minimum": 10,
"maximum": 50
},
"sidebarSide": {
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it).",
"type": "string",
"enum": [
"left",
"right"
]
},
"pages": {
"type": "array",
"items": {
+2 -1
View File
@@ -453,7 +453,8 @@ export function buildDocument(data: ResumeData, resolveTitle?: SectionTitleResol
sidebarParagraphs,
sidebarWidth,
gapXTwips,
templateConfig.sidebarSide,
// The side chosen in Design, else the template's own.
data.metadata.layout.sidebarSide ?? templateConfig.sidebarSide,
sidebarShadingHex,
),
);
+14 -2
View File
@@ -1,7 +1,9 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { ReactNode } from "react";
import type { SectionTitleResolver } from "./section-title";
import { createContext, use, useMemo } from "react";
import { templateLayouts } from "@reactive-resume/schema/templates";
import { isRTL } from "@reactive-resume/utils/locale";
export type ResumeRenderOptions = {
@@ -12,6 +14,11 @@ type RenderContextValue = ResumeData & {
resolveSectionTitle?: SectionTitleResolver | undefined;
renderOptions: ResumeRenderOptions;
rtl: boolean;
/**
* Whether a two-column template lays its columns out from the right: its sidebar goes to the other side from
* where the template draws it. That's the side chosen in Design, or, without a choice, a right-to-left page.
*/
columnsReversed: boolean;
};
const RenderContext = createContext<RenderContextValue | null>(null);
@@ -21,6 +28,7 @@ type RenderProviderProps = {
data: ResumeData;
resolveSectionTitle?: SectionTitleResolver | undefined;
renderOptions?: ResumeRenderOptions | undefined;
template?: Template | undefined;
children: ReactNode;
};
@@ -28,12 +36,16 @@ export const RenderProvider = ({
data,
resolveSectionTitle,
renderOptions = defaultRenderOptions,
template,
children,
}: RenderProviderProps) => {
const rtl = isRTL(data.metadata.page.locale);
const chosenSide = data.metadata.layout.sidebarSide;
const ownSide = template ? templateLayouts[template].sidebarSide : null;
const columnsReversed = chosenSide && ownSide ? chosenSide !== ownSide : rtl;
const contextValue = useMemo<RenderContextValue>(
() => ({ ...data, resolveSectionTitle, renderOptions, rtl }),
[data, resolveSectionTitle, renderOptions, rtl],
() => ({ ...data, resolveSectionTitle, renderOptions, rtl, columnsReversed }),
[data, resolveSectionTitle, renderOptions, rtl, columnsReversed],
);
return <RenderContext.Provider value={contextValue}>{children}</RenderContext.Provider>;
+6 -1
View File
@@ -78,7 +78,12 @@ export const ResumeDocument = ({
sourceTree={runtime.sourceTree}
renderTree={runtime.renderTree}
>
<RenderProvider data={resumeData} resolveSectionTitle={resolveSectionTitle} renderOptions={renderOptions}>
<RenderProvider
data={resumeData}
template={template}
resolveSectionTitle={resolveSectionTitle}
renderOptions={renderOptions}
>
<Document
pageMode="useNone"
creationDate={creationDate}
@@ -135,7 +135,7 @@ const useAzurillTemplate = (): AzurillTemplate => {
color: primary,
},
contentRow: {
flexDirection: r.row,
flexDirection: r.columns,
},
sidebarColumn: {},
mainColumn: {
@@ -264,5 +264,6 @@ const useAzurillTemplate = (): AzurillTemplate => {
metrics.gapY,
foreground,
background,
r.columns,
]);
};
@@ -61,6 +61,7 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader,
const { metadata, picture } = data;
const { colors, styles } = useChikoritaTemplate();
const metrics = getTemplateMetrics(metadata.page);
const { r } = useTemplateBase();
const hasPicture = hasTemplatePicture(picture);
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
@@ -90,8 +91,10 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader,
style={composeStyles(styles.mainColumn, {
marginTop: -metrics.page.paddingVertical,
paddingTop: metrics.page.paddingVertical,
paddingRight: page.fullWidth ? metrics.page.paddingHorizontal : metrics.columnGap,
paddingLeft: metrics.page.paddingHorizontal,
...r.columnInset(
metrics.page.paddingHorizontal,
page.fullWidth ? metrics.page.paddingHorizontal : metrics.columnGap,
),
rowGap: metrics.sectionGap,
})}
>
@@ -112,8 +115,7 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader,
showHeader && hasPicture
? metrics.page.paddingVertical + picture.size + metrics.itemGapY * 3
: metrics.page.paddingVertical,
paddingRight: metrics.page.paddingHorizontal,
paddingLeft: metrics.columnGap,
...r.columnInset(metrics.columnGap, metrics.page.paddingHorizontal),
rowGap: metrics.sectionGap,
})}
>
@@ -178,7 +180,7 @@ const useChikoritaTemplate = (): ChikoritaTemplate => {
inline: { ...base.inline, columnGap: metrics.gapX(0.25) },
page: {
...base.page,
flexDirection: r.row,
flexDirection: r.columns,
},
section: {
flexDirection: "column",
@@ -282,5 +284,5 @@ const useChikoritaTemplate = (): ChikoritaTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies ChikoritaStyles,
};
}, [metadata, r.row, r.headerIdentity, metrics.gapX, primary, foreground, base, metrics.gapY, background]);
}, [metadata, r.row, r.headerIdentity, metrics.gapX, primary, foreground, base, metrics.gapY, background, r.columns]);
};
@@ -218,7 +218,7 @@ const useDitgarTemplate = (): DitgarTemplate => {
...base,
page: {
...base.page,
flexDirection: r.row,
flexDirection: r.columns,
},
section: {
flexDirection: "column",
@@ -356,5 +356,6 @@ const useDitgarTemplate = (): DitgarTemplate => {
metrics.page.paddingHorizontal,
foreground,
background,
r.columns,
]);
};
@@ -65,6 +65,7 @@ export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader, page
const { metadata, picture } = data;
const { colors, styles } = useDittoTemplate();
const metrics = getTemplateMetrics(metadata.page);
const { r } = useTemplateBase();
const hasPicture = hasTemplatePicture(picture);
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
@@ -89,7 +90,7 @@ export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader, page
style={composeStyles(styles.sidebarColumn, {
display: page.fullWidth ? "none" : "flex",
width: `${metadata.layout.sidebarWidth}%`,
paddingLeft: metrics.page.paddingHorizontal,
...r.columnInset(metrics.page.paddingHorizontal, 0),
paddingTop: showHeader && hasPicture ? metrics.page.paddingVertical : 0,
rowGap: metrics.sectionGap,
})}
@@ -104,8 +105,7 @@ export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader, page
<SemanticRegionView
region="main"
style={composeStyles(styles.mainColumn, {
paddingLeft: metrics.columnGap,
paddingRight: metrics.page.paddingHorizontal,
...r.columnInset(metrics.columnGap, metrics.page.paddingHorizontal),
rowGap: metrics.sectionGap,
})}
>
@@ -201,7 +201,7 @@ const useDittoTemplate = (): DittoTemplate => {
headerBand: {
backgroundColor: primary,
color: background,
flexDirection: r.row,
flexDirection: r.columns,
...(hasPicture ? { minHeight: picture.size * 0.6 } : {}),
},
pictureAnchor: {
@@ -232,7 +232,7 @@ const useDittoTemplate = (): DittoTemplate => {
color: background,
},
contactRow: {
flexDirection: r.row,
flexDirection: r.columns,
alignItems: "flex-start",
},
contactOffset: {
@@ -256,7 +256,7 @@ const useDittoTemplate = (): DittoTemplate => {
columnGap: metrics.gapX(1 / 6),
},
contentRow: {
flexDirection: r.row,
flexDirection: r.columns,
},
sidebarColumn: {
flexShrink: 0,
@@ -308,5 +308,6 @@ const useDittoTemplate = (): DittoTemplate => {
base,
metrics.gapY,
background,
r.columns,
]);
};
@@ -199,7 +199,7 @@ const useGengarTemplate = (): GengarTemplate => {
...base,
page: {
...base.page,
flexDirection: r.row,
flexDirection: r.columns,
},
section: {
flexDirection: "column",
@@ -333,5 +333,6 @@ const useGengarTemplate = (): GengarTemplate => {
metrics.page.paddingHorizontal,
foreground,
background,
r.columns,
]);
};
@@ -168,12 +168,12 @@ const useGlalieTemplate = (): GlalieTemplate => {
position: "absolute",
top: 0,
bottom: 0,
...r.anchorToStart(0),
...r.anchorToColumnStart(0),
width: `${metadata.layout.sidebarWidth}%`,
backgroundColor: primaryTint,
},
layout: {
flexDirection: r.row,
flexDirection: r.columns,
minHeight: "100%",
},
sidebarColumn: {
@@ -247,7 +247,6 @@ const useGlalieTemplate = (): GlalieTemplate => {
}, [
metadata,
r.row,
r.anchorToStart,
primary,
metrics.sectionGap,
metrics.gapY,
@@ -257,5 +256,7 @@ const useGlalieTemplate = (): GlalieTemplate => {
metrics.page.paddingHorizontal,
foreground,
background,
r.columns,
r.anchorToColumnStart,
]);
};
@@ -219,7 +219,7 @@ const useLeafishTemplate = (): LeafishTemplate => {
columnGap: metrics.gapX(1 / 6),
},
body: {
flexDirection: r.row,
flexDirection: r.columns,
columnGap: metrics.columnGap,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingTop: metrics.page.paddingVertical,
@@ -257,5 +257,6 @@ const useLeafishTemplate = (): LeafishTemplate => {
metrics.columnGap,
foreground,
background,
r.columns,
]);
};
@@ -210,7 +210,7 @@ const usePikachuTemplate = (): PikachuTemplate => {
backgroundColor: primary,
},
layout: {
flexDirection: r.row,
flexDirection: r.columns,
columnGap: metrics.columnGap,
},
sidebarColumn: {
@@ -302,5 +302,6 @@ const usePikachuTemplate = (): PikachuTemplate => {
base,
metrics.gapX,
background,
r.columns,
]);
};
+11 -1
View File
@@ -13,10 +13,20 @@ type RtlStyleHelpers = {
contactSeparator: (color: string, gap: number) => Style;
contactSeparatorClear: Style;
anchorToStart: (offset?: number | string) => Style;
/** The flex direction of the row that holds a two-column template's columns. */
columns: "row" | "row-reverse";
/** Like `anchorToStart`, for the side the template's own sidebar starts from. */
anchorToColumnStart: (offset?: number | string) => Style;
/** Left and right padding for a column, given as the template draws it: `start` on its own left. */
columnInset: (start: number, end: number) => Pick<Style, "paddingLeft" | "paddingRight">;
};
export function createRtlStyleHelpers(rtl: boolean): RtlStyleHelpers {
export function createRtlStyleHelpers(rtl: boolean, columnsReversed = rtl): RtlStyleHelpers {
return {
columns: columnsReversed ? "row-reverse" : "row",
anchorToColumnStart: (offset = 0) => (columnsReversed ? { right: offset } : { left: offset }),
columnInset: (start, end) =>
columnsReversed ? { paddingLeft: end, paddingRight: start } : { paddingLeft: start, paddingRight: end },
rtl,
pageDirection: rtl ? "rtl" : "ltr",
row: rtl ? "row-reverse" : "row",
@@ -14,7 +14,7 @@ type SidebarBackgroundProps = {
* continues on a later page still reads as one band. Render it as a direct child of the page.
*/
export const SidebarBackground = ({ color, width, end = false }: SidebarBackgroundProps) => {
const { rtl } = useRender();
const side = end !== rtl ? "right" : "left";
const { columnsReversed } = useRender();
const side = end !== columnsReversed ? "right" : "left";
return <View fixed style={{ position: "absolute", top: 0, bottom: 0, [side]: 0, width, backgroundColor: color }} />;
};
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import * as forme from "@formepdf/core";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { templateLayouts, templateSchema } from "@reactive-resume/schema/templates";
import { renderResume } from "../../forme/render";
const twoColumn = templateSchema.options.filter((template) => templateLayouts[template].columns === 2);
/** Where the first page's sidebar and main sections sit, by their left edges. */
const columnEdges = async (template: (typeof twoColumn)[number], sidebarSide?: "left" | "right", locale = "en-US") => {
const data = parseResumeData({
...sampleResumeData,
picture: { ...sampleResumeData.picture, hidden: true },
metadata: {
...sampleResumeData.metadata,
template,
page: { ...sampleResumeData.metadata.page, locale },
layout: { ...sampleResumeData.metadata.layout, sidebarSide },
},
});
const { pageMap } = await renderResume(forme, { data, template });
const firstPage = data.metadata.layout.pages[0];
const x = (column: "main" | "sidebar") =>
Math.min(
...pageMap.nodes
// Some templates print the summary across the page, in the header.
.filter(
(node) =>
node.page === 0 &&
node.kind === "section" &&
node.sectionId !== "summary" &&
firstPage?.[column].includes(node.sectionId),
)
.map((node) => node.x),
);
return { sidebar: x("sidebar"), main: x("main") };
};
describe("sidebar side", () => {
it.each(twoColumn)("puts %s's sidebar on the side chosen", { timeout: 60_000 }, async (template) => {
const left = await columnEdges(template, "left");
const right = await columnEdges(template, "right");
expect(left.sidebar).toBeLessThan(left.main);
expect(right.sidebar).toBeGreaterThan(right.main);
});
it("keeps a chosen side on a right-to-left page, and mirrors the template's own side without a choice", async () => {
const chosen = await columnEdges("azurill", "left", "ar-SA");
const own = await columnEdges("azurill", undefined, "ar-SA");
expect(chosen.sidebar).toBeLessThan(chosen.main);
expect(own.sidebar).toBeGreaterThan(own.main);
});
});
@@ -19,10 +19,10 @@ import { Heading, SemanticContactListView, SemanticHeaderPicture, SemanticHeader
import { createRtlStyleHelpers } from "./rtl";
export const useTemplateBase = () => {
const { picture, metadata, rtl } = useRender();
const { picture, metadata, rtl, columnsReversed } = useRender();
return useMemo(() => {
const r = createRtlStyleHelpers(rtl);
const r = createRtlStyleHelpers(rtl, columnsReversed);
const foreground = rgbaStringToHex(metadata.design.colors.text);
const background = rgbaStringToHex(metadata.design.colors.background);
const primary = rgbaStringToHex(metadata.design.colors.primary);
@@ -30,7 +30,7 @@ export const useTemplateBase = () => {
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
return { picture, metadata, rtl, r, foreground, background, primary, metrics, base };
}, [picture, metadata, rtl]);
}, [picture, metadata, rtl, columnsReversed]);
};
export const createIconSlot = ({
+7
View File
@@ -525,6 +525,13 @@ export const layoutSchema = z.object({
.max(50)
.catch(35)
.describe("The width of the sidebar column, defined as a percentage of the page width."),
sidebarSide: z
.enum(["left", "right"])
.optional()
.catch(undefined)
.describe(
"Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it).",
),
pages: z.array(pageLayoutSchema).describe("The pages to display in the layout."),
});
@@ -835,6 +835,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `metadata.template` | `string` | yes | enum: ["azurill","bronzor","chikorita","ditgar","ditto","gengar","glalie","kakuna","lapras","leafish","meowth","onyx","pikachu","rhyhorn","scizor"]; default: "onyx" | The template to use for the resume. Determines the overall design and appearance of the resume. |
| `metadata.layout` | `object` | yes | — | The layout of the resume. Determines the structure and arrangement of the sections on the resume. |
| `metadata.layout.sidebarWidth` | `number` | yes | minimum: 10; maximum: 50; default: 35 | The width of the sidebar column, defined as a percentage of the page width. |
| `metadata.layout.sidebarSide` | `string` | no | enum: ["left","right"] | Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it). |
| `metadata.layout.pages` | `array` | yes | — | The pages to display in the layout. |
| `metadata.layout.pages[]` | `object` | — | — | — |
| `metadata.layout.pages[].fullWidth` | `boolean` | yes | — | Whether the layout of the page should be full width. If true, the main column will span the entire width of the page. This means that there should be no items in the sidebar column. |