mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-13 06:24:54 +10:00
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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(
|
||||
<LevelDisplay type="circle" icon="star" level={2} decorationSizePx={18} iconSizePx={20} />,
|
||||
);
|
||||
|
||||
const shapes = container.querySelectorAll("div[data-active]");
|
||||
expect(shapes[0]).toHaveStyle({ width: "18px", height: "18px" });
|
||||
|
||||
const { container: iconContainer } = render(
|
||||
<LevelDisplay type="icon" icon="star" level={2} decorationSizePx={16} />,
|
||||
);
|
||||
const icon = iconContainer.querySelector("i");
|
||||
expect(icon).toHaveStyle({ fontSize: "16px", width: "16px", height: "16px" });
|
||||
});
|
||||
|
||||
it("merges extra className into the wrapper", () => {
|
||||
const { container } = render(<LevelDisplay type="circle" icon="star" level={1} className="extra" />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
|
||||
@@ -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<typeof levelDesignSchema> & React.ComponentProps<"div"> & { level: number };
|
||||
type Props = z.infer<typeof levelDesignSchema> &
|
||||
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 (
|
||||
<div
|
||||
role="img"
|
||||
@@ -29,8 +50,10 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
|
||||
<div
|
||||
key={itemKey}
|
||||
data-active={isActive}
|
||||
style={decorationStyle}
|
||||
className={cn(
|
||||
"h-2.5 flex-1 border border-(--page-primary-color) border-x-0 first:border-l last:border-r",
|
||||
"flex-1 border border-(--page-primary-color) border-x-0 first:border-l last:border-r",
|
||||
decorationSizePx === undefined && "h-2.5",
|
||||
isActive && "bg-(--page-primary-color)",
|
||||
)}
|
||||
/>
|
||||
@@ -41,7 +64,13 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
|
||||
return (
|
||||
<i
|
||||
key={itemKey}
|
||||
className={cn("ph size-2.5 text-(--page-primary-color)", `ph-${icon}`, !isActive && "opacity-40")}
|
||||
style={iconStyle}
|
||||
className={cn(
|
||||
"ph text-(--page-primary-color)",
|
||||
resolvedIconSizePx === undefined && defaultDecorationClassName,
|
||||
`ph-${icon}`,
|
||||
!isActive && "opacity-40",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -50,8 +79,10 @@ export function LevelDisplay({ icon, type, level, className, ...props }: Props)
|
||||
<div
|
||||
key={itemKey}
|
||||
data-active={isActive}
|
||||
style={decorationStyle}
|
||||
className={cn(
|
||||
"size-2.5 border border-(--page-primary-color)",
|
||||
"border border-(--page-primary-color)",
|
||||
decorationSizePx === undefined && defaultDecorationClassName,
|
||||
isActive && "bg-(--page-primary-color)",
|
||||
type === "circle" && "rounded-full",
|
||||
type === "rectangle" && "w-7",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
import { colorDesignSchema, levelDesignSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { resolveLevelDisplaySizes } from "@reactive-resume/schema/resume/level-display-sizes";
|
||||
import { resolveStyleRuleFontSize } from "@reactive-resume/schema/resume/style-rules";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
@@ -276,6 +278,13 @@ function LevelSectionForm() {
|
||||
|
||||
const previewType = useStore(form.store, (s) => 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 (
|
||||
<form
|
||||
@@ -294,7 +303,14 @@ function LevelSectionForm() {
|
||||
style={{ "--page-primary-color": colors.primary, backgroundColor: colors.background } as React.CSSProperties}
|
||||
className="flex items-center justify-center rounded-md p-6"
|
||||
>
|
||||
<LevelDisplay level={3} type={previewType} icon={previewIcon} className="w-full max-w-[220px] justify-center" />
|
||||
<LevelDisplay
|
||||
level={3}
|
||||
type={previewType}
|
||||
icon={previewIcon}
|
||||
decorationSizePx={decorationSize}
|
||||
iconSizePx={levelIconExplicitSize}
|
||||
className="w-full max-w-[220px] justify-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? []));
|
||||
};
|
||||
@@ -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 (
|
||||
<Icon
|
||||
key={itemKey}
|
||||
size={iconSize + 4}
|
||||
{...(levelIconExplicitSize === undefined ? {} : { size: levelIconExplicitSize })}
|
||||
name={levelDesign.icon as IconName}
|
||||
style={{ opacity: isActive ? 1 : 0.35 }}
|
||||
/>
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof PdfText>) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Icon = ({ style, ...props }: ComponentProps<typeof PhosphorIcon>) => {
|
||||
const { style: iconStyle, ...iconProps } = useTemplateIconSlot("icon");
|
||||
export const Icon = ({ style, size: sizeProp, ...props }: ComponentProps<typeof PhosphorIcon>) => {
|
||||
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<typeof PhosphorIcon>) =
|
||||
<PhosphorIcon
|
||||
{...iconProps}
|
||||
{...props}
|
||||
style={composeStyles(asStyleInput(iconStyle), iconRuleStyle, asStyleInput(style))}
|
||||
{...(resolvedSize === undefined ? {} : { size: resolvedSize })}
|
||||
style={composedStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<SectionType>([
|
||||
"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));
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<SectionType>([
|
||||
"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);
|
||||
};
|
||||
Reference in New Issue
Block a user