From 1414fecade600e429f26acca4ae0e7c9f21bf07d Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Fri, 29 May 2026 00:41:21 +0200 Subject: [PATCH] fix(pdf): apply custom style fontSize to icons and level indicators (#3120) and * fix(pdf): apply custom style fontSize to icon and level indicator sizes Map fontSize from Icon and Level Indicator custom style slots to Phosphor icon size and level indicator dimensions, since react-pdf icons ignore fontSize in favor of the size prop. * fix: separate global icon and scoped level indicator font sizes Icon slot fontSize now drives all resume icons plus level display decorations. Level indicator fontSize overrides only within level display. Shared sizing logic lives in schema; design sidebar preview uses global rules. --------- Co-authored-by: Cursor Agent --- .../web/src/components/level/display.test.tsx | 15 ++++ apps/web/src/components/level/display.tsx | 41 ++++++++-- .../-sidebar/right/sections/design.tsx | 18 ++++- .../src/templates/shared/icon-size.test.ts | 44 +++++++++++ .../pdf/src/templates/shared/icon-size.ts | 36 +++++++++ .../src/templates/shared/level-display.tsx | 17 +++-- .../pdf/src/templates/shared/primitives.tsx | 16 +++- .../pdf/src/templates/shared/style-rules.ts | 65 ++-------------- .../src/resume/level-display-sizes.test.ts | 24 ++++++ .../schema/src/resume/level-display-sizes.ts | 27 +++++++ .../schema/src/resume/style-rules.test.ts | 45 +++++++++++ packages/schema/src/resume/style-rules.ts | 74 +++++++++++++++++++ 12 files changed, 350 insertions(+), 72 deletions(-) create mode 100644 packages/pdf/src/templates/shared/icon-size.test.ts create mode 100644 packages/pdf/src/templates/shared/icon-size.ts create mode 100644 packages/schema/src/resume/level-display-sizes.test.ts create mode 100644 packages/schema/src/resume/level-display-sizes.ts create mode 100644 packages/schema/src/resume/style-rules.test.ts create mode 100644 packages/schema/src/resume/style-rules.ts diff --git a/apps/web/src/components/level/display.test.tsx b/apps/web/src/components/level/display.test.tsx index 6209137ae..894d2400d 100644 --- a/apps/web/src/components/level/display.test.tsx +++ b/apps/web/src/components/level/display.test.tsx @@ -71,6 +71,21 @@ describe("LevelDisplay", () => { expect(wrapper.getAttribute("aria-label")).toContain("4"); }); + it("applies custom decoration and icon sizes in pixels", () => { + const { container } = render( + , + ); + + const shapes = container.querySelectorAll("div[data-active]"); + expect(shapes[0]).toHaveStyle({ width: "18px", height: "18px" }); + + const { container: iconContainer } = render( + , + ); + const icon = iconContainer.querySelector("i"); + expect(icon).toHaveStyle({ fontSize: "16px", width: "16px", height: "16px" }); + }); + it("merges extra className into the wrapper", () => { const { container } = render(); const wrapper = container.firstChild as HTMLElement; diff --git a/apps/web/src/components/level/display.tsx b/apps/web/src/components/level/display.tsx index 774fb9396..13d42b5a8 100644 --- a/apps/web/src/components/level/display.tsx +++ b/apps/web/src/components/level/display.tsx @@ -3,14 +3,35 @@ import type z from "zod"; import { t } from "@lingui/core/macro"; import { cn } from "@reactive-resume/utils/style"; -type Props = z.infer & React.ComponentProps<"div"> & { level: number }; +type Props = z.infer & + React.ComponentProps<"div"> & { + level: number; + decorationSizePx?: number | undefined; + iconSizePx?: number | undefined; + }; const LEVEL_ITEM_KEYS = ["level-1", "level-2", "level-3", "level-4", "level-5"] as const; -export function LevelDisplay({ icon, type, level, className, ...props }: Props) { +const defaultDecorationClassName = "size-2.5"; + +export function LevelDisplay({ icon, type, level, className, decorationSizePx, iconSizePx, ...props }: Props) { if (level === 0) return null; if (type === "hidden" || icon === "") return null; + const decorationStyle = + decorationSizePx === undefined + ? undefined + : ({ width: decorationSizePx, height: decorationSizePx } satisfies React.CSSProperties); + const resolvedIconSizePx = iconSizePx ?? decorationSizePx; + const iconStyle = + resolvedIconSizePx === undefined + ? undefined + : ({ + fontSize: resolvedIconSizePx, + width: resolvedIconSizePx, + height: resolvedIconSizePx, + } satisfies React.CSSProperties); + return (
@@ -41,7 +64,13 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props) return ( ); } @@ -50,8 +79,10 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
s.values.type); const previewIcon = useStore(form.store, (s) => s.values.icon); + const iconFontSize = resolveStyleRuleFontSize(resume.data, { slot: "icon" }); + const levelFontSize = resolveStyleRuleFontSize(resume.data, { slot: "level" }); + const { decorationSize, levelIconExplicitSize } = resolveLevelDisplaySizes({ + bodyFontSize: resume.data.metadata.typography.body.fontSize, + iconFontSize, + levelFontSize, + }); return (
- +
diff --git a/packages/pdf/src/templates/shared/icon-size.test.ts b/packages/pdf/src/templates/shared/icon-size.test.ts new file mode 100644 index 000000000..338f79618 --- /dev/null +++ b/packages/pdf/src/templates/shared/icon-size.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { parseStyleFontSize, resolveIconSize, resolveStyleFontSize } from "./icon-size"; + +describe("parseStyleFontSize", () => { + it("parses numeric and px font sizes", () => { + expect(parseStyleFontSize(14)).toBe(14); + expect(parseStyleFontSize("18px")).toBe(18); + expect(parseStyleFontSize("invalid")).toBeUndefined(); + }); +}); + +describe("resolveStyleFontSize", () => { + it("returns the last font size across composed style inputs", () => { + expect(resolveStyleFontSize({ fontSize: 10 }, [{ fontSize: 12 }, { fontSize: 16 }])).toBe(16); + }); +}); + +describe("resolveIconSize", () => { + it("prefers an explicit size over custom style font sizes", () => { + expect( + resolveIconSize({ + size: 20, + styles: [{ fontSize: 12 }], + }), + ).toBe(20); + }); + + it("falls back to custom style font sizes when size is omitted", () => { + expect( + resolveIconSize({ + styles: [{ fontSize: 10 }, { fontSize: 18 }], + }), + ).toBe(18); + }); + + it("allows a template default size to be supplied by the caller", () => { + expect( + resolveIconSize({ + styles: [{ fontSize: 12 }], + }) ?? 10, + ).toBe(12); + expect(resolveIconSize({ styles: [] }) ?? 10).toBe(10); + }); +}); diff --git a/packages/pdf/src/templates/shared/icon-size.ts b/packages/pdf/src/templates/shared/icon-size.ts new file mode 100644 index 000000000..8929934ff --- /dev/null +++ b/packages/pdf/src/templates/shared/icon-size.ts @@ -0,0 +1,36 @@ +import type { Style } from "@react-pdf/types"; +import type { StyleInput } from "./styles"; +import { composeStyles } from "./styles"; + +const parseFiniteNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +const parsePxValue = (value: unknown): number | undefined => { + if (typeof value !== "string" || !value.endsWith("px")) return undefined; + + const parsedValue = Number.parseFloat(value); + return Number.isFinite(parsedValue) ? parsedValue : undefined; +}; + +export const parseStyleFontSize = (fontSize: Style["fontSize"] | undefined): number | undefined => + parseFiniteNumber(fontSize) ?? parsePxValue(fontSize); + +export const resolveStyleFontSize = (...styles: StyleInput[]): number | undefined => { + for (let index = styles.length - 1; index >= 0; index -= 1) { + for (const style of composeStyles(styles[index]).toReversed()) { + const fontSize = parseStyleFontSize(style.fontSize); + if (fontSize !== undefined) return fontSize; + } + } + + return undefined; +}; + +export const resolveIconSize = (options: { + size?: number | string | undefined; + styles?: StyleInput[]; +}): number | string | undefined => { + if (options.size !== undefined) return options.size; + + return resolveStyleFontSize(...(options.styles ?? [])); +}; diff --git a/packages/pdf/src/templates/shared/level-display.tsx b/packages/pdf/src/templates/shared/level-display.tsx index 7472c265e..aff4d85cc 100644 --- a/packages/pdf/src/templates/shared/level-display.tsx +++ b/packages/pdf/src/templates/shared/level-display.tsx @@ -1,8 +1,10 @@ import type { Style } from "@react-pdf/types"; import type { IconName } from "phosphor-icons-react-pdf/dynamic"; +import { resolveLevelDisplaySizes } from "@reactive-resume/schema/resume/level-display-sizes"; import { useRender } from "../../context"; import { View } from "../../renderer"; import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context"; +import { resolveStyleFontSize } from "./icon-size"; import { getTemplateMetrics } from "./metrics"; import { Icon } from "./primitives"; import { composeStyles } from "./styles"; @@ -16,14 +18,19 @@ type LevelDisplayProps = { export const LevelDisplay = ({ level }: LevelDisplayProps) => { const data = useRender(); const levelDesign = data.metadata.design.level; - const iconSize = data.metadata.typography.body.fontSize - 2; const metrics = getTemplateMetrics(data.metadata.page); const iconProps = useTemplateIconSlot("icon"); const levelContainerStyle = useTemplateStyle("levelContainer"); const levelItemStyle = useTemplateStyle("levelItem"); const levelItemActiveStyle = useTemplateStyle("levelItemActive"); const levelItemInactiveStyle = useTemplateStyle("levelItemInactive"); + const iconRuleStyle = useSectionStyleRule("icon"); const levelRuleStyle = useSectionStyleRule("level"); + const { decorationSize, levelIconExplicitSize } = resolveLevelDisplaySizes({ + bodyFontSize: data.metadata.typography.body.fontSize, + iconFontSize: resolveStyleFontSize(iconRuleStyle), + levelFontSize: resolveStyleFontSize(levelRuleStyle), + }); const color = typeof iconProps.color === "string" ? iconProps.color : "#000000"; if (level === 0) return null; @@ -55,7 +62,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => { return ( @@ -69,7 +76,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => { style={composeStyles( { flex: 1, - height: iconSize, + height: decorationSize, borderWidth: 0.75, borderColor: color, backgroundColor: isActive ? color : "transparent", @@ -83,7 +90,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => { const itemStyle: Style = {}; let borderRadius = 0; - let width: string | number = iconSize; + let width: string | number = decorationSize; if (levelDesign.type === "rectangle") { width = 16; @@ -104,7 +111,7 @@ export const LevelDisplay = ({ level }: LevelDisplayProps) => { style={composeStyles( { width, - height: iconSize, + height: decorationSize, borderWidth: 0.75, borderColor: color, borderRadius, diff --git a/packages/pdf/src/templates/shared/primitives.tsx b/packages/pdf/src/templates/shared/primitives.tsx index b3ded8590..655049192 100644 --- a/packages/pdf/src/templates/shared/primitives.tsx +++ b/packages/pdf/src/templates/shared/primitives.tsx @@ -4,6 +4,7 @@ import type { StyleInput } from "./styles"; import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic"; import { Link as PdfLink, Text as PdfText, View } from "../../renderer"; import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context"; +import { resolveIconSize } from "./icon-size"; import { safeTextStyle } from "./safe-text-style"; import { composeLinkStyles, composeStyles } from "./styles"; @@ -64,9 +65,17 @@ export const Bold = ({ style, ...props }: ComponentProps) => { ); }; -export const Icon = ({ style, ...props }: ComponentProps) => { - const { style: iconStyle, ...iconProps } = useTemplateIconSlot("icon"); +export const Icon = ({ style, size: sizeProp, ...props }: ComponentProps) => { + const { style: iconStyle, size: templateSize, ...iconProps } = useTemplateIconSlot("icon"); const iconRuleStyle = useSectionStyleRule("icon"); + const composedStyle = composeStyles(asStyleInput(iconStyle), iconRuleStyle, asStyleInput(style)); + const templateIconSize = + typeof templateSize === "number" || typeof templateSize === "string" ? templateSize : undefined; + const resolvedSize = + resolveIconSize({ + size: sizeProp, + styles: [iconRuleStyle, asStyleInput(style)], + }) ?? templateIconSize; if (iconProps.display === "none") return null; @@ -74,7 +83,8 @@ export const Icon = ({ style, ...props }: ComponentProps) = ); }; diff --git a/packages/pdf/src/templates/shared/style-rules.ts b/packages/pdf/src/templates/shared/style-rules.ts index 1d946b65f..dd411c775 100644 --- a/packages/pdf/src/templates/shared/style-rules.ts +++ b/packages/pdf/src/templates/shared/style-rules.ts @@ -1,22 +1,17 @@ import type { Style } from "@react-pdf/types"; -import type { - CustomSectionType, - ResumeData, - SectionType, - StyleIntent, - StyleSlot, -} from "@reactive-resume/schema/resume/data"; +import type { ResumeData, StyleIntent, StyleSlot } from "@reactive-resume/schema/resume/data"; +import type { SectionStyleRuleContext } from "@reactive-resume/schema/resume/style-rules"; +import { getSectionStyleRuleContext, resolveStyleIntentForSlot } from "@reactive-resume/schema/resume/style-rules"; import { rgbaStringToHex } from "@reactive-resume/utils/color"; -export type SectionStyleRuleContext = { - sectionId: string; - sectionType?: CustomSectionType | undefined; -}; +export type { SectionStyleRuleContext }; export type ResolveStyleRuleSlotOptions = SectionStyleRuleContext & { slot: StyleSlot; }; +export { getSectionStyleRuleContext }; + const spacingProperties = [ "padding", "paddingTop", @@ -42,21 +37,6 @@ const spacingPropertyRange = (property: (typeof spacingProperties)[number]) => { return { min: -72, max: 72 }; }; -const builtInSectionTypes = new Set([ - "profiles", - "experience", - "education", - "projects", - "skills", - "languages", - "interests", - "awards", - "certifications", - "publications", - "volunteer", - "references", -]); - const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const toPdfColor = (value: string) => { @@ -98,37 +78,6 @@ const toStyle = (intent: StyleIntent | undefined): Style => { return style; }; -export const getSectionStyleRuleContext = (data: ResumeData, sectionId: string): SectionStyleRuleContext => { - if (sectionId === "summary") return { sectionId, sectionType: "summary" }; - if (builtInSectionTypes.has(sectionId as SectionType)) { - return { sectionId, sectionType: sectionId as SectionType }; - } - - const customSection = data.customSections.find((section) => section.id === sectionId); - - return { sectionId, sectionType: customSection?.type }; -}; - export const resolveStyleRuleSlot = (data: ResumeData, options: ResolveStyleRuleSlotOptions): Style => { - const matchingRules = (data.metadata.styleRules ?? []).filter((rule) => { - if (!rule.enabled) return false; - if (!rule.slots[options.slot]) return false; - - if (rule.target.scope === "global") return true; - if (rule.target.scope === "sectionType") return rule.target.sectionType === options.sectionType; - if (rule.target.scope === "sectionId") return rule.target.sectionId === options.sectionId; - - return false; - }); - - const specificity = { global: 0, sectionType: 1, sectionId: 2 } satisfies Record< - "global" | "sectionType" | "sectionId", - number - >; - - const bySpecificity = [...matchingRules].sort((a, b) => { - return specificity[a.target.scope] - specificity[b.target.scope]; - }); - - return Object.assign({}, ...bySpecificity.map((rule) => toStyle(rule.slots[options.slot]))); + return toStyle(resolveStyleIntentForSlot(data, options)); }; diff --git a/packages/schema/src/resume/level-display-sizes.test.ts b/packages/schema/src/resume/level-display-sizes.test.ts new file mode 100644 index 000000000..148f36ca0 --- /dev/null +++ b/packages/schema/src/resume/level-display-sizes.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { resolveLevelDisplaySizes } from "./level-display-sizes"; + +describe("resolveLevelDisplaySizes", () => { + it("uses typography defaults when no custom font sizes are set", () => { + expect(resolveLevelDisplaySizes({ bodyFontSize: 12 })).toEqual({ + decorationSize: 10, + levelIconExplicitSize: 14, + }); + }); + + it("uses the global icon font size for decorations and defers icon sizing to icon rules", () => { + expect(resolveLevelDisplaySizes({ bodyFontSize: 12, iconFontSize: 20 })).toEqual({ + decorationSize: 20, + }); + }); + + it("uses the level font size for all level display visuals", () => { + expect(resolveLevelDisplaySizes({ bodyFontSize: 12, iconFontSize: 20, levelFontSize: 16 })).toEqual({ + decorationSize: 16, + levelIconExplicitSize: 16, + }); + }); +}); diff --git a/packages/schema/src/resume/level-display-sizes.ts b/packages/schema/src/resume/level-display-sizes.ts new file mode 100644 index 000000000..fc1ddba04 --- /dev/null +++ b/packages/schema/src/resume/level-display-sizes.ts @@ -0,0 +1,27 @@ +export type ResolveLevelDisplaySizesOptions = { + bodyFontSize: number; + iconFontSize?: number | undefined; + levelFontSize?: number | undefined; +}; + +export type LevelDisplaySizes = { + decorationSize: number; + levelIconExplicitSize?: number | undefined; +}; + +export const resolveLevelDisplaySizes = (options: ResolveLevelDisplaySizesOptions): LevelDisplaySizes => { + const defaultDecorationSize = options.bodyFontSize - 2; + const legacyLevelIconSize = defaultDecorationSize + 4; + + const decorationSize = options.levelFontSize ?? options.iconFontSize ?? defaultDecorationSize; + + if (options.levelFontSize !== undefined) { + return { decorationSize, levelIconExplicitSize: options.levelFontSize }; + } + + if (options.iconFontSize !== undefined) { + return { decorationSize }; + } + + return { decorationSize, levelIconExplicitSize: legacyLevelIconSize }; +}; diff --git a/packages/schema/src/resume/style-rules.test.ts b/packages/schema/src/resume/style-rules.test.ts new file mode 100644 index 000000000..65df3e083 --- /dev/null +++ b/packages/schema/src/resume/style-rules.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { defaultResumeData } from "./default"; +import { resolveStyleRuleFontSize } from "./style-rules"; + +describe("resolveStyleRuleFontSize", () => { + it("returns global icon font sizes without section context", () => { + const data = { + ...defaultResumeData, + metadata: { + ...defaultResumeData.metadata, + styleRules: [ + { + id: "icon-global", + label: "", + enabled: true, + target: { scope: "global" as const }, + slots: { icon: { fontSize: 22 } }, + }, + ], + }, + }; + + expect(resolveStyleRuleFontSize(data, { slot: "icon" })).toBe(22); + }); + + it("does not apply level font sizes to the icon slot", () => { + const data = { + ...defaultResumeData, + metadata: { + ...defaultResumeData.metadata, + styleRules: [ + { + id: "level-global", + label: "", + enabled: true, + target: { scope: "global" as const }, + slots: { level: { fontSize: 18 } }, + }, + ], + }, + }; + + expect(resolveStyleRuleFontSize(data, { slot: "icon" })).toBeUndefined(); + }); +}); diff --git a/packages/schema/src/resume/style-rules.ts b/packages/schema/src/resume/style-rules.ts new file mode 100644 index 000000000..0cdb5c84e --- /dev/null +++ b/packages/schema/src/resume/style-rules.ts @@ -0,0 +1,74 @@ +import type { CustomSectionType, ResumeData, SectionType, StyleIntent, StyleSlot } from "./data"; + +export type SectionStyleRuleContext = { + sectionId: string; + sectionType?: CustomSectionType | undefined; +}; + +export type ResolveStyleRuleSlotOptions = { + slot: StyleSlot; + sectionId?: string | undefined; + sectionType?: CustomSectionType | undefined; +}; + +const builtInSectionTypes = new Set([ + "profiles", + "experience", + "education", + "projects", + "skills", + "languages", + "interests", + "awards", + "certifications", + "publications", + "volunteer", + "references", +]); + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +export const getSectionStyleRuleContext = (data: ResumeData, sectionId: string): SectionStyleRuleContext => { + if (sectionId === "summary") return { sectionId, sectionType: "summary" }; + if (builtInSectionTypes.has(sectionId as SectionType)) { + return { sectionId, sectionType: sectionId as SectionType }; + } + + const customSection = data.customSections.find((section) => section.id === sectionId); + + return { sectionId, sectionType: customSection?.type }; +}; + +export const resolveStyleIntentForSlot = (data: ResumeData, options: ResolveStyleRuleSlotOptions): StyleIntent => { + const matchingRules = (data.metadata.styleRules ?? []).filter((rule) => { + if (!rule.enabled) return false; + if (!rule.slots[options.slot]) return false; + + if (rule.target.scope === "global") return true; + if (rule.target.scope === "sectionType") return rule.target.sectionType === options.sectionType; + if (rule.target.scope === "sectionId") return rule.target.sectionId === options.sectionId; + + return false; + }); + + const specificity = { global: 0, sectionType: 1, sectionId: 2 } satisfies Record< + "global" | "sectionType" | "sectionId", + number + >; + + const bySpecificity = [...matchingRules].sort((a, b) => { + return specificity[a.target.scope] - specificity[b.target.scope]; + }); + + return Object.assign({}, ...bySpecificity.map((rule) => rule.slots[options.slot])); +}; + +export const resolveStyleRuleFontSize = ( + data: ResumeData, + options: ResolveStyleRuleSlotOptions, +): number | undefined => { + const fontSize = resolveStyleIntentForSlot(data, options).fontSize; + if (fontSize === undefined) return undefined; + + return clamp(fontSize, 6, 48); +};