diff --git a/apps/web/src/features/resume/editor/design/template-group.test.tsx b/apps/web/src/features/resume/editor/design/template-group.test.tsx
index a4aca3bea..8eb41c336 100644
--- a/apps/web/src/features/resume/editor/design/template-group.test.tsx
+++ b/apps/web/src/features/resume/editor/design/template-group.test.tsx
@@ -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();
+ });
});
diff --git a/apps/web/src/features/resume/editor/design/template-group.tsx b/apps/web/src/features/resume/editor/design/template-group.tsx
index 2c34aadeb..eeff46619 100644
--- a/apps/web/src/features/resume/editor/design/template-group.tsx
+++ b/apps/web/src/features/resume/editor/design/template-group.tsx
@@ -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) {
))}
- {templateLayouts[current].columns === 2 && }
+ {templateLayouts[current].columns === 2 && }
);
}
@@ -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 }) {
diff --git a/docs/guides/json-resume-schema.mdx b/docs/guides/json-resume-schema.mdx
index 382090516..96c2d0a91 100644
--- a/docs/guides/json-resume-schema.mdx
+++ b/docs/guides/json-resume-schema.mdx
@@ -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": {
diff --git a/docs/spec.json b/docs/spec.json
index 475efd9ca..af774fdfe 100644
--- a/docs/spec.json
+++ b/docs/spec.json
@@ -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": {
diff --git a/packages/docx/src/builder.ts b/packages/docx/src/builder.ts
index bb392c3c4..738dd32a5 100644
--- a/packages/docx/src/builder.ts
+++ b/packages/docx/src/builder.ts
@@ -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,
),
);
diff --git a/packages/pdf/src/context.tsx b/packages/pdf/src/context.tsx
index 83b4ebb9b..2753b2553 100644
--- a/packages/pdf/src/context.tsx
+++ b/packages/pdf/src/context.tsx
@@ -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(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(
- () => ({ ...data, resolveSectionTitle, renderOptions, rtl }),
- [data, resolveSectionTitle, renderOptions, rtl],
+ () => ({ ...data, resolveSectionTitle, renderOptions, rtl, columnsReversed }),
+ [data, resolveSectionTitle, renderOptions, rtl, columnsReversed],
);
return {children};
diff --git a/packages/pdf/src/document.tsx b/packages/pdf/src/document.tsx
index d5410e156..132bfb462 100644
--- a/packages/pdf/src/document.tsx
+++ b/packages/pdf/src/document.tsx
@@ -78,7 +78,12 @@ export const ResumeDocument = ({
sourceTree={runtime.sourceTree}
renderTree={runtime.renderTree}
>
-
+
{
color: primary,
},
contentRow: {
- flexDirection: r.row,
+ flexDirection: r.columns,
},
sidebarColumn: {},
mainColumn: {
@@ -264,5 +264,6 @@ const useAzurillTemplate = (): AzurillTemplate => {
metrics.gapY,
foreground,
background,
+ r.columns,
]);
};
diff --git a/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx b/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx
index 498aec098..30d2de162 100644
--- a/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx
+++ b/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx
@@ -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]);
};
diff --git a/packages/pdf/src/templates/ditgar/DitgarPage.tsx b/packages/pdf/src/templates/ditgar/DitgarPage.tsx
index c3ccd9dba..227c3c090 100644
--- a/packages/pdf/src/templates/ditgar/DitgarPage.tsx
+++ b/packages/pdf/src/templates/ditgar/DitgarPage.tsx
@@ -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,
]);
};
diff --git a/packages/pdf/src/templates/ditto/DittoPage.tsx b/packages/pdf/src/templates/ditto/DittoPage.tsx
index 2c948c12d..c32a6d108 100644
--- a/packages/pdf/src/templates/ditto/DittoPage.tsx
+++ b/packages/pdf/src/templates/ditto/DittoPage.tsx
@@ -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
@@ -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,
]);
};
diff --git a/packages/pdf/src/templates/gengar/GengarPage.tsx b/packages/pdf/src/templates/gengar/GengarPage.tsx
index 60ec33cce..982821e0e 100644
--- a/packages/pdf/src/templates/gengar/GengarPage.tsx
+++ b/packages/pdf/src/templates/gengar/GengarPage.tsx
@@ -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,
]);
};
diff --git a/packages/pdf/src/templates/glalie/GlaliePage.tsx b/packages/pdf/src/templates/glalie/GlaliePage.tsx
index d2280f6a3..92349a289 100644
--- a/packages/pdf/src/templates/glalie/GlaliePage.tsx
+++ b/packages/pdf/src/templates/glalie/GlaliePage.tsx
@@ -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,
]);
};
diff --git a/packages/pdf/src/templates/leafish/LeafishPage.tsx b/packages/pdf/src/templates/leafish/LeafishPage.tsx
index bbc2db901..31f2d1976 100644
--- a/packages/pdf/src/templates/leafish/LeafishPage.tsx
+++ b/packages/pdf/src/templates/leafish/LeafishPage.tsx
@@ -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,
]);
};
diff --git a/packages/pdf/src/templates/pikachu/PikachuPage.tsx b/packages/pdf/src/templates/pikachu/PikachuPage.tsx
index caed24ee1..2d7cf004f 100644
--- a/packages/pdf/src/templates/pikachu/PikachuPage.tsx
+++ b/packages/pdf/src/templates/pikachu/PikachuPage.tsx
@@ -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,
]);
};
diff --git a/packages/pdf/src/templates/shared/rtl.ts b/packages/pdf/src/templates/shared/rtl.ts
index 901d49ecd..25b746a6d 100644
--- a/packages/pdf/src/templates/shared/rtl.ts
+++ b/packages/pdf/src/templates/shared/rtl.ts
@@ -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