diff --git a/apps/web/src/components/typography/combobox.test.tsx b/apps/web/src/components/typography/combobox.test.tsx new file mode 100644 index 000000000..76dbf1f66 --- /dev/null +++ b/apps/web/src/components/typography/combobox.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FontWeightCombobox } from "./combobox"; + +const comboboxMock = vi.hoisted(() => ({ + props: undefined as { onValueChange?: (value: string[] | null) => void } | undefined, +})); + +vi.mock("@/components/ui/combobox", () => ({ + Combobox: (props: { onValueChange?: (value: string[] | null) => void }) => { + comboboxMock.props = props; + return null; + }, +})); + +describe("FontWeightCombobox", () => { + beforeEach(() => { + comboboxMock.props = undefined; + }); + + it("emits selected font weights in ascending order", () => { + const onValueChange = vi.fn(); + + render(); + + comboboxMock.props?.onValueChange?.(["800", "600", "400"]); + + expect(onValueChange).toHaveBeenCalledWith(["400", "600", "800"]); + }); +}); diff --git a/apps/web/src/components/typography/combobox.tsx b/apps/web/src/components/typography/combobox.tsx index f1b72e426..5f351132d 100644 --- a/apps/web/src/components/typography/combobox.tsx +++ b/apps/web/src/components/typography/combobox.tsx @@ -1,6 +1,6 @@ import type { MultiComboboxProps, SingleComboboxProps } from "@/components/ui/combobox"; -import { useMemo } from "react"; -import { fontList, getFont, getFontDisplayName, getFontSearchKeywords } from "@reactive-resume/fonts"; +import { useCallback, useMemo } from "react"; +import { fontList, getFont, getFontDisplayName, getFontSearchKeywords, sortFontWeights } from "@reactive-resume/fonts"; import { cn } from "@reactive-resume/utils/style"; import { Combobox } from "@/components/ui/combobox"; import { FontDisplay } from "./font-display"; @@ -54,14 +54,20 @@ export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxPr type FontWeightComboboxProps = Omit & { fontFamily: string }; -export function FontWeightCombobox({ fontFamily, ...props }: FontWeightComboboxProps) { +export function FontWeightCombobox({ + fontFamily, + onValueChange, + value, + defaultValue, + ...props +}: FontWeightComboboxProps) { const options = useMemo(() => { const fontData = getFont(fontFamily); let weights: string[] = []; if (fontData && Array.isArray(fontData.weights) && fontData.weights.length > 0) { - weights = fontData.weights as string[]; + weights = sortFontWeights(fontData.weights); } else { // Fallback to all possible weights weights = ["100", "200", "300", "400", "500", "600", "700", "800", "900"]; @@ -74,5 +80,27 @@ export function FontWeightCombobox({ fontFamily, ...props }: FontWeightComboboxP })); }, [fontFamily]); - return ; + const sortedValue = useMemo(() => (value ? sortFontWeights(value) : value), [value]); + const sortedDefaultValue = useMemo( + () => (defaultValue ? sortFontWeights(defaultValue) : defaultValue), + [defaultValue], + ); + + const handleValueChange = useCallback( + (nextValue: string[] | null) => { + onValueChange?.(nextValue ? sortFontWeights(nextValue) : nextValue); + }, + [onValueChange], + ); + + return ( + + ); } diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts index fd958bf80..8fc877102 100644 --- a/packages/fonts/src/index.ts +++ b/packages/fonts/src/index.ts @@ -171,6 +171,10 @@ export function getWebFontSource(family: string, weight: FontWeight = "400", ita return webFont.files[key] ?? (italic ? webFont.files[weight] : undefined) ?? webFont.preview; } +export function sortFontWeights(fontWeights: T[]): T[] { + return [...fontWeights].sort((a, b) => Number(a) - Number(b)); +} + export function getFallbackWebFontFamilies(family: string) { if (isStandardPdfFontFamily(family)) return []; diff --git a/packages/pdf/src/document.tsx b/packages/pdf/src/document.tsx index ef10c108e..99697e72d 100644 --- a/packages/pdf/src/document.tsx +++ b/packages/pdf/src/document.tsx @@ -3,6 +3,7 @@ import type { Template } from "@reactive-resume/schema/templates"; import type { ComponentType } from "react"; import type { SectionTitleResolver } from "./section-title"; import { Document } from "@react-pdf/renderer"; +import { useMemo } from "react"; import { RenderProvider } from "./context"; import { registerFonts } from "./hooks/use-register-fonts"; import { getTemplatePage } from "./templates"; @@ -22,14 +23,12 @@ export type ResumeDocumentProps = { export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDocumentProps) => { const TemplatePageComponent = getTemplatePage(template); - const typography = registerFonts(data.metadata.typography); + const typography = registerFonts(data.metadata.typography) as Typography; + // `registerFonts` widens `fontFamily` to `string | string[]` for CJK // fallback (#2986); the cast carries that wider runtime value through // `ResumeData` without changing the public schema. - const resumeData = - typography === data.metadata.typography - ? data - : { ...data, metadata: { ...data.metadata, typography: typography as unknown as Typography } }; + const resumeData = useMemo(() => ({ ...data, metadata: { ...data.metadata, typography } }), [data, typography]); return ( diff --git a/packages/pdf/src/hooks/use-register-fonts.test.ts b/packages/pdf/src/hooks/use-register-fonts.test.ts index 4f1ce1959..b6df0d740 100644 --- a/packages/pdf/src/hooks/use-register-fonts.test.ts +++ b/packages/pdf/src/hooks/use-register-fonts.test.ts @@ -85,4 +85,18 @@ describe("registerFonts", () => { }), ); }); + + it("returns typography with font weights sorted ascending", async () => { + vi.spyOn(Font, "register").mockImplementation(() => {}); + const { registerFonts } = await import("./use-register-fonts"); + + const pdfTypography = registerFonts({ + ...typography, + body: { ...typography.body, fontFamily: "Source Sans 3", fontWeights: ["800", "600", "400"] }, + heading: { ...typography.heading, fontFamily: "Source Sans 3", fontWeights: ["900", "500"] }, + }); + + expect(pdfTypography.body.fontWeights).toEqual(["400", "600", "800"]); + expect(pdfTypography.heading.fontWeights).toEqual(["500", "900"]); + }); }); diff --git a/packages/pdf/src/hooks/use-register-fonts.ts b/packages/pdf/src/hooks/use-register-fonts.ts index 565c3831f..68f294253 100644 --- a/packages/pdf/src/hooks/use-register-fonts.ts +++ b/packages/pdf/src/hooks/use-register-fonts.ts @@ -6,6 +6,7 @@ import { getPdfCjkFallbackFontFamily, getWebFontSource, isStandardPdfFontFamily, + sortFontWeights, } from "@reactive-resume/fonts"; type FontWeightRange = { @@ -53,15 +54,13 @@ const resolvePdfFontFamily = (family: string) => { const resolvePdfTypography = (typography: Typography): Typography => { const bodyFontFamily = resolvePdfFontFamily(typography.body.fontFamily); const headingFontFamily = resolvePdfFontFamily(typography.heading.fontFamily); - - if (bodyFontFamily === typography.body.fontFamily && headingFontFamily === typography.heading.fontFamily) { - return typography; - } + const bodyFontWeights = sortFontWeights(typography.body.fontWeights); + const headingFontWeights = sortFontWeights(typography.heading.fontWeights); return { ...typography, - body: { ...typography.body, fontFamily: bodyFontFamily }, - heading: { ...typography.heading, fontFamily: headingFontFamily }, + body: { ...typography.body, fontFamily: bodyFontFamily, fontWeights: bodyFontWeights }, + heading: { ...typography.heading, fontFamily: headingFontFamily, fontWeights: headingFontWeights }, }; }; diff --git a/packages/ui/package.json b/packages/ui/package.json index 5ebef951a..f26ccf56c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,6 +39,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript/native-preview": "7.0.0-dev.20260510.1", + "postcss": "^8.5.14", "tailwindcss": "^4.3.0", "typescript": "^6.0.3" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b77e264c..84e0af9f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + postcss@<8.5.10: ^8.5.14 + importers: .: @@ -813,6 +816,9 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20260510.1 version: 7.0.0-dev.20260510.1 + postcss: + specifier: ^8.5.14 + version: 8.5.14 tailwindcss: specifier: ^4.3.0 version: 4.3.0 @@ -7558,10 +7564,6 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -15419,7 +15421,7 @@ snapshots: '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.29 caniuse-lite: 1.0.30001792 - postcss: 8.4.31 + postcss: 8.5.14 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) styled-jsx: 5.1.6(@babel/core@7.29.0)(babel-plugin-macros@3.1.0)(react@19.2.6) @@ -15836,12 +15838,6 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.4.31: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.14: dependencies: nanoid: 3.3.12 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0e07d0c77..af666788c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,5 +8,6 @@ allowBuilds: msw: true sharp: true minimumReleaseAgeExclude: - - postcss@8.5.10 - - ip-address@10.1.1 \ No newline at end of file + - postcss@8.5.14 +overrides: + postcss@<8.5.10: ^8.5.14