diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.test.tsx
new file mode 100644
index 000000000..e4e948456
--- /dev/null
+++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.test.tsx
@@ -0,0 +1,120 @@
+// @vitest-environment happy-dom
+
+import { 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 { SectionDropdownMenu } from "./section-menu";
+
+const mockUpdateResumeData = vi.fn();
+
+vi.mock("@/features/resume/builder/draft", () => ({
+ useCurrentResume: () => ({
+ data: {
+ sections: {
+ skills: {
+ title: "Skills",
+ columns: 2,
+ hidden: false,
+ layout: "default",
+ items: [],
+ },
+ },
+ },
+ }),
+ useUpdateResumeData: () => mockUpdateResumeData,
+}));
+
+vi.mock("@/hooks/use-confirm", () => ({
+ useConfirm: () => vi.fn(),
+}));
+
+vi.mock("@/hooks/use-prompt", () => ({
+ usePrompt: () => vi.fn(),
+}));
+
+vi.mock("@/dialogs/store", () => ({
+ useDialogStore: () => ({
+ openDialog: vi.fn(),
+ }),
+}));
+
+beforeAll(() => {
+ i18n.loadAndActivate({ locale: "en", messages: {} });
+});
+
+beforeEach(() => {
+ mockUpdateResumeData.mockClear();
+});
+
+describe("SkillsSectionDropdownMenu", () => {
+ it("updates resume data correctly when selecting 'inline' for skills section", async () => {
+ render(
+
+
+ ,
+ );
+
+ screen.getByRole("button", { name: "Section options" }).click();
+
+ (await screen.findByRole("menuitem", { name: /columns/i })).click();
+
+ (await screen.findByRole("menuitemradio", { name: /inline/i })).click();
+
+ expect(mockUpdateResumeData).toHaveBeenCalledTimes(1);
+ expect(mockUpdateResumeData).toHaveBeenCalledWith(expect.any(Function));
+
+ const mutation = mockUpdateResumeData.mock.calls[0][0];
+ const mockDraft = {
+ sections: {
+ skills: {
+ title: "Skills",
+ columns: 2,
+ hidden: false,
+ layout: "default",
+ items: [],
+ },
+ },
+ };
+
+ mutation(mockDraft);
+
+ expect(mockDraft.sections.skills.layout).toBe("inline");
+ expect(mockDraft.sections.skills.columns).toBe(1);
+ });
+
+ it("updates resume data correctly when selecting numeric columns for skills section", async () => {
+ render(
+
+
+ ,
+ );
+
+ screen.getByRole("button", { name: "Section options" }).click();
+
+ (await screen.findByRole("menuitem", { name: /columns/i })).click();
+
+ (await screen.findByRole("menuitemradio", { name: /3 columns/i })).click();
+
+ expect(mockUpdateResumeData).toHaveBeenCalledTimes(1);
+ expect(mockUpdateResumeData).toHaveBeenCalledWith(expect.any(Function));
+
+ const mutation = mockUpdateResumeData.mock.calls[0][0];
+ const mockDraft = {
+ sections: {
+ skills: {
+ title: "Skills",
+ columns: 2,
+ hidden: false,
+ layout: "default",
+ items: [],
+ },
+ },
+ };
+
+ mutation(mockDraft);
+
+ expect(mockDraft.sections.skills.layout).toBe("default");
+ expect(mockDraft.sections.skills.columns).toBe(3);
+ });
+});
diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx
index 09dd75260..3bae583e2 100644
--- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx
@@ -41,6 +41,8 @@ export function SectionDropdownMenu({ type }: Props) {
const updateResumeData = useUpdateResumeData();
const resume = useCurrentResume();
const section = type === "summary" ? resume.data.summary : resume.data.sections[type];
+ const dropDownValue =
+ type === "skills" && resume.data.sections[type].layout === "inline" ? "inline" : section.columns.toString();
const onAddItem = () => {
if (type === "summary") return;
@@ -76,6 +78,15 @@ export function SectionDropdownMenu({ type }: Props) {
const onSetColumns = (value: string) => {
updateResumeData((draft) => {
+ if (type === "skills") {
+ if (value === "inline") {
+ draft.sections[type].layout = value;
+ draft.sections[type].columns = 1;
+ return;
+ }
+ draft.sections[type].layout = "default";
+ }
+
if (type === "summary") {
draft.summary.columns = Number.parseInt(value, 10);
} else {
@@ -150,12 +161,21 @@ export function SectionDropdownMenu({ type }: Props) {
-
+
{[1, 2, 3, 4, 5, 6].map((column) => (
))}
+
+ {type === "skills" && (
+ <>
+
+
+ 1 Column / Inline
+
+ >
+ )}
diff --git a/docs/guides/json-resume-schema.mdx b/docs/guides/json-resume-schema.mdx
index eea138b28..a17e2c427 100644
--- a/docs/guides/json-resume-schema.mdx
+++ b/docs/guides/json-resume-schema.mdx
@@ -876,6 +876,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"description": "The section to display the projects of the author."
},
"skills": {
+ "description": "The section to display the skills of the author.",
"type": "object",
"properties": {
"title": {
@@ -967,6 +968,15 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
]
},
"description": "The items to display in the skills section."
+ },
+ "layout": {
+ "default": "default",
+ "description": "The layout style for skill items. 'inline' places item fields next to name",
+ "type": "string",
+ "enum": [
+ "default",
+ "inline"
+ ]
}
},
"required": [
@@ -977,8 +987,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
"keepTogether",
"startOnNewPage",
"items"
- ],
- "description": "The section to display the skills of the author."
+ ]
},
"languages": {
"type": "object",
diff --git a/packages/docx/src/section-renderers.test.ts b/packages/docx/src/section-renderers.test.ts
index fa31e62ce..053109ee0 100644
--- a/packages/docx/src/section-renderers.test.ts
+++ b/packages/docx/src/section-renderers.test.ts
@@ -84,6 +84,7 @@ const emptySection = (type: T): ResumeData["sections"][T]
keepTogether: false,
startOnNewPage: false,
items: [],
+ ...(type === "skills" ? { layout: "default" as const } : {}),
}) as ResumeData["sections"][T];
describe("renderBuiltInSection", () => {
diff --git a/packages/import/src/reactive-resume-v4-json.test.ts b/packages/import/src/reactive-resume-v4-json.test.ts
index 1d3466c5b..8c46bd448 100644
--- a/packages/import/src/reactive-resume-v4-json.test.ts
+++ b/packages/import/src/reactive-resume-v4-json.test.ts
@@ -424,3 +424,13 @@ describe("parseReactiveResumeV4JSON – rejects non-v4 input gracefully", () =>
expect(() => parseReactiveResumeV4JSON(arrayBranches)).toThrow(/v4/i);
});
});
+
+describe("parseReactiveResumeV4JSON – skills section", () => {
+ it("defaults skills layout to 'default' when 'layout' is missing from v4 data", () => {
+ // 'layout' already omitted
+ const v4 = makeV4Base({});
+
+ const result = parseReactiveResumeV4JSON(JSON.stringify(v4));
+ expect(result.sections.skills.layout).toBe("default");
+ });
+});
diff --git a/packages/import/src/reactive-resume-v4-json.tsx b/packages/import/src/reactive-resume-v4-json.tsx
index 0f5cc01f0..fcb1b8cb0 100644
--- a/packages/import/src/reactive-resume-v4-json.tsx
+++ b/packages/import/src/reactive-resume-v4-json.tsx
@@ -385,6 +385,7 @@ export function parseReactiveResumeV4JSON(json: string): ResumeData {
title: v4Data.sections.skills?.name ?? "",
icon: "",
columns: v4Data.sections.skills?.columns ?? 1,
+ layout: "default",
hidden: !(v4Data.sections.skills?.visible ?? true),
keepTogether: false,
startOnNewPage: false,
diff --git a/packages/pdf/src/templates/shared/sections.test.ts b/packages/pdf/src/templates/shared/sections.test.ts
index 8df871ebf..776e479c4 100644
--- a/packages/pdf/src/templates/shared/sections.test.ts
+++ b/packages/pdf/src/templates/shared/sections.test.ts
@@ -1,7 +1,9 @@
+import type { SkillItem } from "@reactive-resume/schema/resume/data";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
-import { getSectionHeadingTextStyle } from "./sections";
+import { getTemplateMetrics } from "./metrics";
+import { getSectionHeadingTextStyle, getSkillsItemStyle } from "./sections";
const source = readFileSync(fileURLToPath(new URL("./sections.tsx", import.meta.url)), "utf8");
@@ -54,3 +56,70 @@ describe("SectionShell", () => {
expect(source).not.toContain('width: "auto"');
});
});
+
+const mockMetrics = getTemplateMetrics({ gapX: 10, gapY: 10, marginX: 10, marginY: 10 });
+
+const createSkillItem = (overrides: Partial = {}): SkillItem => ({
+ id: "1",
+ name: "JavaScript",
+ level: 0,
+ keywords: [],
+ hidden: false,
+ proficiency: "",
+ icon: "",
+ iconColor: "",
+ ...overrides,
+});
+
+describe("SkillsSectionInlineFormat", () => {
+ it("uses isInlineSkillsItem in sections.tsx", () => {
+ expect(source).toContain("isInlineSkillsItem");
+ });
+
+ it("returns default rowGap style when isInline is false", () => {
+ const mockItem = createSkillItem();
+ expect(getSkillsItemStyle(false, mockItem, mockMetrics)).toEqual({ rowGap: 2.5 });
+ });
+
+ it("does not apply alignItems: center when isInline is true and 0 secondary fields are present", () => {
+ // 0 secondary fields: no proficiency, no level, no keywords
+ const mockItem = createSkillItem();
+ const style = getSkillsItemStyle(true, mockItem, mockMetrics);
+ expect(style).not.toEqual(expect.arrayContaining([{ alignItems: "center" }]));
+ });
+
+ it("applies alignItems: center when isInline is true and exactly 1 secondary field is present", () => {
+ // 1 secondary field: level only
+ const mockItem = createSkillItem({ level: 3 });
+ const style = getSkillsItemStyle(true, mockItem, mockMetrics);
+ expect(style).toEqual(expect.arrayContaining([{ alignItems: "center" }]));
+ });
+
+ it("applies alignItems: center when isInline is true and proficiency is the only secondary field", () => {
+ // 1 secondary field: proficiency only
+ const mockItem = createSkillItem({ proficiency: "Advanced" });
+ const style = getSkillsItemStyle(true, mockItem, mockMetrics);
+ expect(style).toEqual(expect.arrayContaining([{ alignItems: "center" }]));
+ });
+
+ it("applies alignItems: center when isInline is true and keywords is the only secondary field", () => {
+ // 1 secondary field: keywords only
+ const mockItem = createSkillItem({ keywords: ["React", "TypeScript"] });
+ const style = getSkillsItemStyle(true, mockItem, mockMetrics);
+ expect(style).toEqual(expect.arrayContaining([{ alignItems: "center" }]));
+ });
+
+ it("does not apply alignItems: center when isInline is true and 2 secondary fields are present", () => {
+ // 2 secondary fields: level AND keywords
+ const mockItem = createSkillItem({ level: 3, keywords: ["React"] });
+ const style = getSkillsItemStyle(true, mockItem, mockMetrics);
+ expect(style).not.toEqual(expect.arrayContaining([{ alignItems: "center" }]));
+ });
+
+ it("does not apply alignItems: center when isInline is true and 3 secondary fields are present", () => {
+ // 3 secondary fields: proficiency, level, AND keywords
+ const mockItem = createSkillItem({ proficiency: "Advanced", level: 3, keywords: ["React"] });
+ const style = getSkillsItemStyle(true, mockItem, mockMetrics);
+ expect(style).not.toEqual(expect.arrayContaining([{ alignItems: "center" }]));
+ });
+});
diff --git a/packages/pdf/src/templates/shared/sections.tsx b/packages/pdf/src/templates/shared/sections.tsx
index 7b191b443..7d02b7916 100644
--- a/packages/pdf/src/templates/shared/sections.tsx
+++ b/packages/pdf/src/templates/shared/sections.tsx
@@ -1164,6 +1164,28 @@ const ProjectsSection = ({ sectionId = "projects", sectionData }: ItemSectionPro
);
};
+const inlineSkillsItemStyle = {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ columnGap: 4,
+} satisfies Style;
+
+export const getSkillsItemStyle = (
+ isInline: boolean,
+ item: SkillItem,
+ metrics: ReturnType,
+) => {
+ if (isInline) {
+ return composeStyles(
+ inlineSkillsItemStyle,
+ [hasSplitRowText(item.proficiency), Boolean(item.level), item.keywords.length > 0].filter(Boolean).length === 1
+ ? { alignItems: "center" }
+ : undefined,
+ );
+ }
+ return { rowGap: metrics.gapY(0.25) };
+};
+
const SkillsSection = ({ sectionId = "skills", sectionData }: ItemSectionProps = {}) => {
const data = useRender();
const skills = sectionData ?? data.sections.skills;
@@ -1173,15 +1195,17 @@ const SkillsSection = ({ sectionId = "skills", sectionData }: ItemSectionProps
{items.map((item) => (
-
+
-
+
{item.name}
@@ -1190,9 +1214,9 @@ const SkillsSection = ({ sectionId = "skills", sectionData }: ItemSectionProps 1 ? 1 : 0 }}>
{hasSplitRowText(item.proficiency) && {item.proficiency}}
{item.keywords.join(", ")}
+ {isInlineSkillsItem && }
-
-
+ {!isInlineSkillsItem && }
))}
diff --git a/packages/schema/src/resume/data.test.ts b/packages/schema/src/resume/data.test.ts
index 3f6000fa8..0a5956a25 100644
--- a/packages/schema/src/resume/data.test.ts
+++ b/packages/schema/src/resume/data.test.ts
@@ -14,6 +14,7 @@ import {
resumeDataSchema,
sectionTypeSchema,
skillItemSchema,
+ skillsSectionSchema,
styleRuleSchema,
styleRulesSchema,
summarySchema,
@@ -684,3 +685,40 @@ describe("styleRulesSchema", () => {
).toBe(false);
});
});
+
+describe("skillsSectionSchema", () => {
+ it("forces columns to 1 when layout is 'inline'", () => {
+ const result = skillsSectionSchema.parse({
+ title: "Skills",
+ columns: 2,
+ hidden: false,
+ layout: "inline",
+ items: [],
+ });
+ expect(result.columns).toBe(1);
+ expect(result.layout).toBe("inline");
+ });
+
+ it("preserves columns when layout is 'default'", () => {
+ const result = skillsSectionSchema.parse({
+ title: "Skills",
+ columns: 3,
+ hidden: false,
+ layout: "default",
+ items: [],
+ });
+ expect(result.columns).toBe(3);
+ expect(result.layout).toBe("default");
+ });
+
+ it("defaults layout to 'default' when missing", () => {
+ const result = skillsSectionSchema.parse({
+ title: "Skills",
+ columns: 2,
+ hidden: false,
+ items: [],
+ });
+ expect(result.layout).toBe("default");
+ expect(result.columns).toBe(2);
+ });
+});
diff --git a/packages/schema/src/resume/data.ts b/packages/schema/src/resume/data.ts
index 70cb0f1b6..1264596e3 100644
--- a/packages/schema/src/resume/data.ts
+++ b/packages/schema/src/resume/data.ts
@@ -318,7 +318,15 @@ const publicationsSectionSchema = itemSection(
"The items to display in the publications section.",
);
const referencesSectionSchema = itemSection(referenceItemSchema, "The items to display in the references section.");
-const skillsSectionSchema = itemSection(skillItemSchema, "The items to display in the skills section.");
+export const skillsSectionSchema = itemSection(skillItemSchema, "The items to display in the skills section.")
+ .extend({
+ layout: z
+ .enum(["default", "inline"])
+ .default("default")
+ .catch("default")
+ .describe("The layout style for skill items. 'inline' places item fields next to name"),
+ })
+ .transform((section) => (section.layout === "inline" ? { ...section, columns: 1 } : section));
const volunteerSectionSchema = itemSection(volunteerItemSchema, "The items to display in the volunteer section.");
const sectionsSchema = z.object({
diff --git a/packages/schema/src/resume/default.ts b/packages/schema/src/resume/default.ts
index 88d29b2b6..12798a2c7 100644
--- a/packages/schema/src/resume/default.ts
+++ b/packages/schema/src/resume/default.ts
@@ -47,7 +47,7 @@ export const defaultResumeData: ResumeData = {
experience: section("briefcase"),
education: section("graduation-cap"),
projects: section("code-simple"),
- skills: section("compass-tool"),
+ skills: { ...section("compass-tool"), layout: "default" },
languages: section("translate"),
interests: section("football"),
awards: section("trophy"),
diff --git a/packages/schema/src/resume/sample.ts b/packages/schema/src/resume/sample.ts
index dfc50667e..df1510e1c 100644
--- a/packages/schema/src/resume/sample.ts
+++ b/packages/schema/src/resume/sample.ts
@@ -191,6 +191,7 @@ export const sampleResumeData: ResumeData = {
title: "",
icon: "compass-tool",
columns: 1,
+ layout: "default",
hidden: false,
keepTogether: false,
startOnNewPage: false,
diff --git a/skills/resume-builder/references/schema.md b/skills/resume-builder/references/schema.md
index f33c2e2bc..641b256be 100644
--- a/skills/resume-builder/references/schema.md
+++ b/skills/resume-builder/references/schema.md
@@ -171,6 +171,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.skills.items[].level` | `number` | yes | minimum: 0; maximum: 5; default: 0 | The proficiency level of the skill, defined as a number between 0 and 5. If set to 0, the icons displaying the level will be hidden. |
| `sections.skills.items[].keywords` | `array` | yes | default: [] | The keywords associated with the skill, if any. These are displayed as tags below the name. |
| `sections.skills.items[].keywords[]` | `string` | — | — | — |
+| `sections.skills.layout` | `string` | no | enum: ["default","inline"]; default: "default" | The layout style for skill items. 'inline' places item fields next to name |
| `sections.languages` | `object` | yes | — | The section to display the languages of the author. |
| `sections.languages.title` | `string` | yes | — | The title of the section. |
| `sections.languages.icon` | `string` | yes | default: "" | Phosphor icon name to display before the section title in the PDF output. Empty string uses the default section icon; 'none' hides the icon. |