feat: add section heading icons to PDF templates (#3127)

* feat: add section heading icons to PDF templates

Add customizable Phosphor icons before section titles in PDF output.
Users can toggle visibility globally via a new "Hide section heading icons"
switch (independent of item-level icons) and customize individual section
icons through the builder sidebar icon picker.

- Add `icon` field to `baseSectionSchema` and `summarySchema`
- Add `hideSectionIcons` to `pageSchema` (defaults to true for backward compat)
- Implement `SectionHeadingIcon` component with heading font-size scaling
- Support "none" sentinel for per-section icon hiding
- Fallback to sensible defaults (briefcase, graduation-cap, etc.) for legacy data
- Add icon picker to builder sidebar sections and custom section dialogs

Closes #2632

* test: add unit tests for section heading icons

- Add tests for getResumeSectionIcon() covering built-in sections,
  summary, custom sections, "none" sentinel, and default fallbacks
- Add schema tests for baseSectionSchema icon field, summarySchema icon,
  and pageSchema hideSectionIcons default behavior

* refactor: minor updates to icon display

* Update apps/web/locales/es-ES.po

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
JamesGoslings
2026-06-01 14:02:42 +02:00
committed by GitHub
co-authored by coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Amruth Pillai
parent 1522794733
commit b932711f08
81 changed files with 1153 additions and 306 deletions
+60 -2
View File
@@ -23,6 +23,7 @@ import { Children, createContext, isValidElement, use } from "react";
import { match } from "ts-pattern";
import { useRender } from "../../context";
import { View } from "../../renderer";
import { getResumeSectionIcon } from "../../section-icon";
import { getResumeSectionTitle } from "../../section-title";
import { getSectionItemRows, getSectionItemsLayout, shouldUseSectionTimeline } from "./columns";
import { getWebsiteDisplayText } from "./contact";
@@ -38,7 +39,7 @@ import {
import { filterItems, hasVisibleItems, isSectionVisible, isVisibleSummary } from "./filtering";
import { LevelDisplay } from "./level-display";
import { getTemplateMetrics } from "./metrics";
import { Bold, Div, Heading, Icon, Link, Small, Text } from "./primitives";
import { Bold, Div, Heading, Icon, Link, SectionHeadingIcon, Small, Text } from "./primitives";
import { RichText } from "./rich-text";
import { createRtlStyleHelpers } from "./rtl";
import { getInlineItemWebsiteUrl, shouldRenderSeparateItemWebsite } from "./section-links";
@@ -132,6 +133,35 @@ const SECTION_ITEM_PLACEHOLDER_KEYS = [
"placeholder-6",
] as const;
const defaultSectionHeadingContainerStyle = {
flexDirection: "row",
alignItems: "flex-start",
columnGap: 4,
} satisfies Style;
const getSectionHeadingTextStyle = (...styles: StyleInput[]): Style[] =>
composeStyles(...styles).map(
({
borderBottomWidth: _borderBottomWidth,
borderLeftWidth: _borderLeftWidth,
borderRightWidth: _borderRightWidth,
borderTopWidth: _borderTopWidth,
borderWidth: _borderWidth,
flexGrow: _flexGrow,
flexShrink: _flexShrink,
marginBottom: _marginBottom,
marginLeft: _marginLeft,
marginRight: _marginRight,
marginTop: _marginTop,
paddingBottom: _paddingBottom,
paddingLeft: _paddingLeft,
paddingRight: _paddingRight,
paddingTop: _paddingTop,
width: _width,
...textStyle
}) => textStyle,
);
const useSectionItemsContext = () => use(SectionItemsContext);
const getChildKey = (child: ReactNode, fallbackIndex: number) => {
@@ -154,13 +184,41 @@ const SectionShell = ({ sectionId, title, showHeading = true, children }: Sectio
const sectionStyle = useTemplateStyle("section");
const sectionRuleStyle = useSectionStyleRule("section");
const sectionHeadingStyle = useTemplateStyle("sectionHeading");
const sectionHeadingContainerStyle = useTemplateStyle("sectionHeadingContainer");
const sectionHeadingRuleStyle = useSectionStyleRule("heading");
const sectionTitle = getResumeSectionTitle(data, sectionId, title);
const sectionIcon = getResumeSectionIcon(data, sectionId);
const showIcon = Boolean(sectionIcon) && !data.metadata.page.hideSectionIcons;
if (!showIcon) {
// No icon: render heading exactly as before (no structural change)
return (
<View style={composeStyles(sectionStyle, sectionRuleStyle)}>
{showHeading && (
<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>{sectionTitle}</Heading>
)}
{children}
</View>
);
}
// With icon: wrap in a flex row container that inherits the heading's border/decoration
return (
<View style={composeStyles(sectionStyle, sectionRuleStyle)}>
{showHeading && (
<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>{sectionTitle}</Heading>
<View
style={composeStyles(
sectionHeadingStyle,
defaultSectionHeadingContainerStyle,
sectionHeadingContainerStyle,
sectionHeadingRuleStyle,
)}
>
<SectionHeadingIcon name={sectionIcon as IconName} />
<Heading style={getSectionHeadingTextStyle(sectionHeadingStyle, sectionHeadingRuleStyle)}>
{sectionTitle}
</Heading>
</View>
)}
{children}
</View>