diff --git a/apps/web/src/components/animation/comet-card.test.tsx b/apps/web/src/components/animation/comet-card.test.tsx deleted file mode 100644 index 20ca126a2..000000000 --- a/apps/web/src/components/animation/comet-card.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -// @vitest-environment happy-dom - -import { fireEvent, render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { CometCard } from "./comet-card"; - -describe("CometCard", () => { - it("renders its children inside the perspective wrapper", () => { - const { getByText } = render( - - card body - , - ); - expect(getByText("card body")).toBeInTheDocument(); - }); - - it("merges custom className into the wrapper", () => { - const { container } = render( - - x - , - ); - const wrapper = container.firstChild as HTMLElement; - expect(wrapper.className).toContain("extra-class"); - expect(wrapper.className).toContain("perspective-distant"); - expect(wrapper.className).toContain("transform-3d"); - }); - - it("renders a glare overlay positioned absolutely with mix-blend-overlay", () => { - const { container } = render( - - x - , - ); - const glare = container.querySelector("[class*='mix-blend-overlay']") as HTMLElement | null; - expect(glare).not.toBeNull(); - expect(glare?.className).toContain("pointer-events-none"); - }); - - it("does not throw when pointer moves over / leaves the card", () => { - const { container } = render( - - x - , - ); - - const tiltable = container.querySelector("[class*='rounded-md']") as HTMLElement; - expect(tiltable).toBeTruthy(); - - expect(() => { - fireEvent.pointerMove(tiltable, { clientX: 100, clientY: 50, pointerType: "mouse" }); - fireEvent.pointerMove(tiltable, { clientX: 0, clientY: 0, pointerType: "mouse" }); - fireEvent.pointerLeave(tiltable); - }).not.toThrow(); - }); -}); diff --git a/apps/web/src/components/animation/comet-card.tsx b/apps/web/src/components/animation/comet-card.tsx deleted file mode 100644 index 4b70b497c..000000000 --- a/apps/web/src/components/animation/comet-card.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type React from "react"; -import { m, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; -import { cn } from "@reactive-resume/utils/style"; -import { EASE_OUT_STRONG } from "@/libs/motion"; - -type Props = { - glareOpacity?: number; - className?: string; - children: React.ReactNode; -}; - -// Critically damped: the tilt follows the pointer without wobbling past it. -const tiltSpring = { stiffness: 300, damping: 30 }; - -export const CometCard = ({ glareOpacity = 0.4, className, children }: Props) => { - const reduceMotion = useReducedMotion(); - - const x = useSpring(useMotionValue(0), tiltSpring); - const y = useSpring(useMotionValue(0), tiltSpring); - - const rotateX = useTransform(y, [-0.5, 0.5], ["-6deg", "6deg"]); - const rotateY = useTransform(x, [-0.5, 0.5], ["6deg", "-6deg"]); - const translateX = useTransform(x, [-0.5, 0.5], ["-3px", "3px"]); - const translateY = useTransform(y, [-0.5, 0.5], ["3px", "-3px"]); - - const glareX = useTransform(x, [-0.5, 0.5], [0, 100]); - const glareY = useTransform(y, [-0.5, 0.5], [0, 100]); - const glareBackground = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, rgba(255, 255, 255, 0.9) 10%, rgba(255, 255, 255, 0.75) 20%, rgba(255, 255, 255, 0) 80%)`; - - // Touch taps emit synthetic pointer moves that would leave the card stuck mid-tilt, so only follow a real mouse. - const handlePointerMove = (e: React.PointerEvent) => { - if (e.pointerType !== "mouse" || reduceMotion) return; - const rect = e.currentTarget.getBoundingClientRect(); - x.set((e.clientX - rect.left) / rect.width - 0.5); - y.set((e.clientY - rect.top) / rect.height - 0.5); - }; - - const handlePointerLeave = () => { - x.set(0); - y.set(0); - }; - - return ( -
- - {children} - - {glareOpacity > 0 && ( - - )} - -
- ); -}; diff --git a/apps/web/src/components/animation/spotlight.test.tsx b/apps/web/src/components/animation/spotlight.test.tsx deleted file mode 100644 index e9b1d6991..000000000 --- a/apps/web/src/components/animation/spotlight.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// @vitest-environment happy-dom - -import { render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { Spotlight } from "./spotlight"; - -describe("Spotlight", () => { - it("renders a non-pointer-events overlay container", () => { - const { container } = render(); - const wrapper = container.firstChild as HTMLElement; - expect(wrapper.className).toContain("pointer-events-none"); - expect(wrapper.className).toContain("absolute"); - }); - - it("renders both left and right beam groups by default", () => { - const { container } = render(); - // Outer wrapper > two animated beam containers - const beamGroups = container.firstChild?.childNodes; - expect(beamGroups?.length).toBe(2); - }); -}); diff --git a/apps/web/src/components/animation/spotlight.tsx b/apps/web/src/components/animation/spotlight.tsx deleted file mode 100644 index 23edcafb1..000000000 --- a/apps/web/src/components/animation/spotlight.tsx +++ /dev/null @@ -1,44 +0,0 @@ -const FIRST = - "radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(210, 100%, 85%, .08) 0, hsla(210, 100%, 55%, .02) 50%, hsla(210, 100%, 45%, 0) 80%)"; -const SECOND = - "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .06) 0, hsla(210, 100%, 55%, .02) 80%, transparent 100%)"; -const THIRD = - "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .04) 0, hsla(210, 100%, 45%, .02) 80%, transparent 100%)"; - -// CSS keyframes run on the compositor, so the infinite drift never competes with the page's main thread. -const drift = - "pointer-events-none absolute top-0 z-40 h-svh w-svw animate-[spotlight-drift_7s_ease-in-out_infinite_alternate]"; - -export const Spotlight = () => ( -
-
-
-
-
-
- -
-
-
-
-
-
-); diff --git a/apps/web/src/components/input/rich-input.tsx b/apps/web/src/components/input/rich-input.tsx index df209a0be..8149d5a83 100644 --- a/apps/web/src/components/input/rich-input.tsx +++ b/apps/web/src/components/input/rich-input.tsx @@ -26,9 +26,9 @@ import { PopoverHeader, PopoverTitle, PopoverTrigger } from "@reactive-resume/ui import { toast } from "@reactive-resume/ui/components/toast"; import { Toggle } from "@reactive-resume/ui/components/toggle"; import { isDarkColor } from "@reactive-resume/utils/color"; +import { isRTL } from "@reactive-resume/utils/locale"; import { cn } from "@reactive-resume/utils/style"; import { usePrompt } from "@/hooks/use-prompt"; -import { isRTL } from "@/libs/locale"; import { ColorPicker } from "./color-picker"; import { ParagraphIndent } from "./paragraph-indent"; import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-input.utils"; diff --git a/apps/web/src/components/ui/copyright.test.tsx b/apps/web/src/components/ui/copyright.test.tsx deleted file mode 100644 index 2f456e14a..000000000 --- a/apps/web/src/components/ui/copyright.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -// @vitest-environment happy-dom - -import { render, screen } from "@testing-library/react"; -import { beforeAll, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; - -vi.stubGlobal("__APP_VERSION__", "9.9.9"); - -const { Copyright } = await import("./copyright"); - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -const renderCopyright = (props?: React.ComponentProps) => - render( - - - , - ); - -describe("Copyright", () => { - it("renders the MIT license link", () => { - renderCopyright(); - const link = screen.getByRole("link", { name: "MIT" }); - expect(link.getAttribute("href")).toBe("https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE"); - expect(link.getAttribute("rel")).toBe("noopener noreferrer"); - }); - - it("renders the Amruth Pillai attribution link", () => { - renderCopyright(); - const link = screen.getByRole("link", { name: "Amruth Pillai" }); - expect(link.getAttribute("href")).toBe("https://amruthpillai.com"); - }); - - it("includes the app version string", () => { - renderCopyright(); - // The version is wrapped in for RTL isolation, so it is its own text node. - expect(screen.getByText("9.9.9")).toBeInTheDocument(); - }); - - it("merges custom className into the wrapper", () => { - const { container } = renderCopyright({ className: "extra-class" }); - const wrapper = container.firstChild as HTMLElement; - expect(wrapper.className).toContain("extra-class"); - expect(wrapper.className).toContain("text-ink-3"); - }); - - it("opens external links in a new tab", () => { - renderCopyright(); - for (const link of screen.getAllByRole("link")) { - expect(link.getAttribute("target")).toBe("_blank"); - } - }); -}); diff --git a/apps/web/src/components/ui/copyright.tsx b/apps/web/src/components/ui/copyright.tsx deleted file mode 100644 index ff585ba08..000000000 --- a/apps/web/src/components/ui/copyright.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Trans } from "@lingui/react/macro"; -import { cn } from "@reactive-resume/utils/style"; - -type Props = React.ComponentProps<"div">; - -export function Copyright({ className, ...props }: Props) { - return ( -
-

- - Licensed under{" "} - - MIT - - . - -

- -

- By the community, for the community. -

- -

- - A passion project by{" "} - - Amruth Pillai - - . - -

- -

- - Reactive Resume v{__APP_VERSION__} - -

-
- ); -} diff --git a/apps/web/src/dialogs/resume/index.tsx b/apps/web/src/dialogs/resume/index.tsx index ec402dfa2..7b8785b16 100644 --- a/apps/web/src/dialogs/resume/index.tsx +++ b/apps/web/src/dialogs/resume/index.tsx @@ -134,7 +134,6 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">) toast.add({ type: "success", description: t`Your resume has been duplicated.`, id: toastId }); closeDialog(); - if (!data.shouldRedirect) return; void navigate({ to: "/builder/$resumeId", params: { resumeId: id } }); }, onError: (error) => { diff --git a/apps/web/src/dialogs/schemas.ts b/apps/web/src/dialogs/schemas.ts index 33e617b90..639192be6 100644 --- a/apps/web/src/dialogs/schemas.ts +++ b/apps/web/src/dialogs/schemas.ts @@ -15,7 +15,7 @@ export type DialogSchema = } | { type: "resume.duplicate"; - data: { id: string; name: string; slug: string; tags: string[]; shouldRedirect?: boolean }; + data: { id: string; name: string; slug: string; tags: string[] }; }; export type DialogType = DialogSchema["type"]; diff --git a/apps/web/src/features/applications/next-step.test.ts b/apps/web/src/features/applications/next-step.test.ts index a4596676e..ab4b8cb28 100644 --- a/apps/web/src/features/applications/next-step.test.ts +++ b/apps/web/src/features/applications/next-step.test.ts @@ -1,6 +1,6 @@ import type { Application } from "./types"; import { describe, expect, it } from "vitest"; -import { getNextStep, isOverdue } from "./next-step"; +import { getNextStep } from "./next-step"; const NOW = new Date("2026-09-28T12:00:00Z"); const day = (offset: number) => new Date(NOW.getTime() + offset * 86_400_000); @@ -47,7 +47,6 @@ describe("getNextStep", () => { }); const late = getNextStep(application({ followUpAt: day(-1) }), NOW); expect(late).toMatchObject({ kind: "follow-up", overdue: true }); - expect(isOverdue(late)).toBe(true); }); it("suggests a follow-up after ten days without a reply, and counts the wait before that", () => { diff --git a/apps/web/src/features/applications/next-step.ts b/apps/web/src/features/applications/next-step.ts index e0830cc0b..feba131e4 100644 --- a/apps/web/src/features/applications/next-step.ts +++ b/apps/web/src/features/applications/next-step.ts @@ -56,9 +56,6 @@ export function getNextStep(application: NextStepSource, now = new Date()): Next return { kind: "waiting", days }; } -/** Whether a next step needs attention now: an overdue follow-up, or no reply for too long. */ -export const isOverdue = (step: NextStep) => step.kind === "no-reply" || (step.kind === "follow-up" && step.overdue); - export type NextStepText = { icon: IconName; title: string; sub: string; tone: "normal" | "warn" | "muted" }; const formatWhen = (date: Date, locale: string, withTime: boolean) => diff --git a/apps/web/src/features/homepage/languages-showcase.tsx b/apps/web/src/features/homepage/languages-showcase.tsx index 3f5463cbf..7e45faea2 100644 --- a/apps/web/src/features/homepage/languages-showcase.tsx +++ b/apps/web/src/features/homepage/languages-showcase.tsx @@ -4,8 +4,9 @@ import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; import { ArrowUpRightIcon, CheckIcon, DownloadSimpleIcon, PlusIcon } from "@phosphor-icons/react"; import { useState } from "react"; +import { isLocale, isRTL } from "@reactive-resume/utils/locale"; import { cn } from "@reactive-resume/utils/style"; -import { changeLocale, isLocale, isRTL, localeMap, resolveLocale } from "@/libs/locale"; +import { changeLocale, localeMap, resolveLocale } from "@/libs/locale"; import { section, sectionHeading, sectionText, sectionTitle, textLink, wrap } from "./classes"; import "./languages-showcase.css"; diff --git a/apps/web/src/features/resume/builder/draft.ts b/apps/web/src/features/resume/builder/draft.ts index 08f30beb1..32b3c9625 100644 --- a/apps/web/src/features/resume/builder/draft.ts +++ b/apps/web/src/features/resume/builder/draft.ts @@ -181,14 +181,6 @@ function getResumeQueryKey(id: string): QueryKey { return orpc.resume.getById.queryOptions({ input: { id } }).queryKey as QueryKey; } -function cloneResumeData(data: ResumeData): ResumeData { - return structuredClone(data); -} - -function cloneResume(resume: Resume): Resume { - return { ...resume, data: cloneResumeData(resume.data) }; -} - export function isEditableElementFocused(): boolean { if (typeof document === "undefined") return false; const element = document.activeElement as HTMLElement | null; @@ -261,7 +253,7 @@ async function flushResumeSave(id: string) { if (!runtime || runtime.isSaving || !runtime.pendingResume) return; const submitted = runtime.pendingResume; - const submittedData = cloneResumeData(submitted.data); + const submittedData = structuredClone(submitted.data); runtime.pendingResume = undefined; runtime.isSaving = true; @@ -292,7 +284,7 @@ async function flushResumeSave(id: string) { if (!runtime.pendingResume && currentResume?.id === submitted.id && !isEqual(currentResume.data, submittedData)) { runtime.syncResume.cancel(); - runtime.pendingResume = cloneResume(currentResume); + runtime.pendingResume = structuredClone(currentResume); } } @@ -319,7 +311,7 @@ async function flushResumeSave(id: string) { function queueResumeSave(resume: Resume) { const runtime = getRuntime(resume.id); - runtime.pendingResume = cloneResume(resume); + runtime.pendingResume = structuredClone(resume); runtime.hasPendingLocalChanges = true; runtime.saveFailed = false; void flushResumeSave(resume.id); @@ -804,7 +796,7 @@ export function savePendingChanges(id: string): boolean | Promise { if (!runtime?.hasPendingLocalChanges || current?.id !== id) return true; runtime.syncResume.cancel(); - runtime.pendingResume = cloneResume(current); + runtime.pendingResume = structuredClone(current); useResumeStore.getState().setSaveStatus("saving"); return new Promise((resolve) => { diff --git a/apps/web/src/features/resume/editor/design/presets.test.ts b/apps/web/src/features/resume/editor/design/presets.test.ts index 92d8d4694..05b77eefd 100644 --- a/apps/web/src/features/resume/editor/design/presets.test.ts +++ b/apps/web/src/features/resume/editor/design/presets.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { produce } from "immer"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; +import { contrastOnWhite } from "@reactive-resume/utils/color"; import { ACCENTS, applyFontPairing, applyTextSize, - contrastOnWhite, darkenForWhite, fitToPages, hexToRgba, diff --git a/apps/web/src/features/resume/editor/design/presets.ts b/apps/web/src/features/resume/editor/design/presets.ts index 5dce69f01..f5825147a 100644 --- a/apps/web/src/features/resume/editor/design/presets.ts +++ b/apps/web/src/features/resume/editor/design/presets.ts @@ -1,5 +1,6 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; import type { WritableDraft } from "immer"; +import { contrastOnWhite } from "@reactive-resume/utils/color"; type Data = ResumeData | WritableDraft; /** The design a resume and a letter share. */ @@ -117,18 +118,6 @@ export function rgbaToHex(color: string): string | null { .toUpperCase()}`; } -function luminance(hex: string) { - const value = Number.parseInt(HEX.exec(hex)?.[1] ?? "000000", 16); - const [r, g, b] = [(value >> 16) & 255, (value >> 8) & 255, value & 255].map((channel) => { - const c = channel / 255; - return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; - }); - return 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0); -} - -/** WCAG contrast ratio of a colour against white. */ -export const contrastOnWhite = (hex: string) => 1.05 / (luminance(hex) + 0.05); - export const isValidHex = (hex: string) => HEX.test(hex.trim()); /** The same hue, darkened step by step until it reads on white (at least 4.6:1). */ diff --git a/apps/web/src/features/resume/editor/design/style-groups.tsx b/apps/web/src/features/resume/editor/design/style-groups.tsx index 1a5f8771b..b8405313c 100644 --- a/apps/web/src/features/resume/editor/design/style-groups.tsx +++ b/apps/web/src/features/resume/editor/design/style-groups.tsx @@ -13,6 +13,7 @@ import { Input } from "@reactive-resume/ui/components/input"; import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control"; import { Slider } from "@reactive-resume/ui/components/slider"; import { SwitchRow } from "@reactive-resume/ui/components/switch"; +import { contrastOnWhite } from "@reactive-resume/utils/color"; import { cn } from "@reactive-resume/utils/style"; import { Combobox } from "@/components/ui/combobox"; import { getLocaleOptions } from "@/features/locale/locale-options"; @@ -23,7 +24,6 @@ import { applyFontPairing, applyMargins, applyTextSize, - contrastOnWhite, darkenForWhite, FONT_PAIRINGS, hexToRgba, diff --git a/apps/web/src/features/resume/editor/proposals/proposal-list.tsx b/apps/web/src/features/resume/editor/proposals/proposal-list.tsx index c5c71aa1c..35fb53127 100644 --- a/apps/web/src/features/resume/editor/proposals/proposal-list.tsx +++ b/apps/web/src/features/resume/editor/proposals/proposal-list.tsx @@ -13,14 +13,7 @@ import { useEditorStore } from "../store"; /** The visible text of a passage's HTML, for the card. */ const passageText = (html: string) => - html - .replace(/<[^>]*>/g, "") - .replace(/ /g, " ") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/&/g, "&") - .trim(); + new DOMParser().parseFromString(html, "text/html").body.textContent?.replaceAll("\u00a0", " ").trim() ?? ""; /** Applies proposals to the resume as one undo step; the toast's Undo takes them back, and they show as pending again. */ export function acceptResumeProposals(proposals: readonly Proposal[]) { diff --git a/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts b/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts index 6630991b3..7f30bc9bc 100644 --- a/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts +++ b/apps/web/src/features/resume/stylesheet/editor-extensions.test.ts @@ -7,10 +7,10 @@ import { EditorView } from "@codemirror/view"; import { compileStylesheet } from "@reactive-resume/resume/stylesheet"; import { collectCompiledColorTokens } from "./color-tokens"; import { + completionLabels, compositionAwareDocumentListener, copySourceToClipboard, createSemanticCssEditorExtensions, - getSemanticCssCompletionLabels, getSemanticCssHoverDocumentation, mapCompilerDiagnostics, } from "./editor-extensions"; @@ -63,14 +63,14 @@ afterEach(() => { describe("Semantic CSS editor extensions", () => { it("uses only Semantic CSS registries and the current resume for completion", async () => { - const selectorLabels = await getSemanticCssCompletionLabels("", 0, metadata); - const propertyLabels = await getSemanticCssCompletionLabels("section {\n\tco", 13, metadata); + const selectorLabels = await completionLabels("", 0, metadata); + const propertyLabels = await completionLabels("section {\n\tco", 13, metadata); const variableSource = "resume { --brand-accent: #f00; color: var(--br"; - const variableLabels = await getSemanticCssCompletionLabels(variableSource, variableSource.length, metadata); + const variableLabels = await completionLabels(variableSource, variableSource.length, metadata); // The cursor has to sit at the end of the typed prefix; mid-token it reads as a selector context. const systemSource = "--resume-"; - const systemLabels = await getSemanticCssCompletionLabels(systemSource, systemSource.length, metadata); - const directiveLabels = await getSemanticCssCompletionLabels("@", 1, metadata); + const systemLabels = await completionLabels(systemSource, systemSource.length, metadata); + const directiveLabels = await completionLabels("@", 1, metadata); expect(selectorLabels).toEqual( expect.arrayContaining([ @@ -97,9 +97,9 @@ describe("Semantic CSS editor extensions", () => { const borderStyleSource = "section { border-style: d"; const fontSizeSource = "section { font-size: 1"; - const displayLabels = getSemanticCssCompletionLabels(displaySource, displaySource.length, metadata); - const borderStyleLabels = getSemanticCssCompletionLabels(borderStyleSource, borderStyleSource.length, metadata); - const fontSizeLabels = getSemanticCssCompletionLabels(fontSizeSource, fontSizeSource.length, metadata); + const displayLabels = completionLabels(displaySource, displaySource.length, metadata); + const borderStyleLabels = completionLabels(borderStyleSource, borderStyleSource.length, metadata); + const fontSizeLabels = completionLabels(fontSizeSource, fontSizeSource.length, metadata); expect(displayLabels).toEqual(expect.arrayContaining(["flex", "none", "inherit"])); expect(displayLabels).not.toEqual(expect.arrayContaining(["portrait", "dashed", "pt"])); @@ -111,7 +111,7 @@ describe("Semantic CSS editor extensions", () => { it.each(borderShorthands)("offers complete %s shorthand values instead of bare units", (property) => { const source = `section { ${property}: `; - const labels = getSemanticCssCompletionLabels(source, source.length, metadata); + const labels = completionLabels(source, source.length, metadata); expect(labels).toEqual(expect.arrayContaining(["1pt dotted", "1pt dashed", "1pt solid"])); expect(labels).not.toEqual(expect.arrayContaining(["pt", "px", "in", "mm", "cm", "%", "vw", "vh", "em", "rem"])); @@ -135,7 +135,7 @@ describe("Semantic CSS editor extensions", () => { templateParts: ['timeline"marker\n'], } as const; - const labels = getSemanticCssCompletionLabels("", 0, unsafeMetadata); + const labels = completionLabels("", 0, unsafeMetadata); expect(labels).toEqual( expect.arrayContaining([ diff --git a/apps/web/src/features/resume/stylesheet/editor-extensions.ts b/apps/web/src/features/resume/stylesheet/editor-extensions.ts index accb53b90..ce3a1839f 100644 --- a/apps/web/src/features/resume/stylesheet/editor-extensions.ts +++ b/apps/web/src/features/resume/stylesheet/editor-extensions.ts @@ -93,7 +93,7 @@ function declarationProperty(source: string, position: number): string | undefin return property || undefined; } -function completionLabels(source: string, position: number, metadata: SemanticCssEditorMetadata): string[] { +export function completionLabels(source: string, position: number, metadata: SemanticCssEditorMetadata): string[] { switch (completionKind(source, position)) { case "directive": return [...directives]; @@ -118,14 +118,6 @@ function completionLabels(source: string, position: number, metadata: SemanticCs } } -export function getSemanticCssCompletionLabels( - source: string, - position: number, - metadata: SemanticCssEditorMetadata, -): readonly string[] { - return completionLabels(source, position, metadata); -} - export function getSemanticCssHoverDocumentation( label: string, metadata: SemanticCssEditorMetadata, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b812bd6bc..6d439dc16 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,11 +1,5 @@ @import "@reactive-resume/ui/globals.css"; -@keyframes spotlight-drift { - to { - translate: var(--spotlight-drift); - } -} - @keyframes agent-prompt-marquee { to { transform: translateX(-33.333%); diff --git a/apps/web/src/libs/locale.test.ts b/apps/web/src/libs/locale.test.ts index ff31f3b9a..1c57124fd 100644 --- a/apps/web/src/libs/locale.test.ts +++ b/apps/web/src/libs/locale.test.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import Cookies from "js-cookie"; -import { changeLocale, formatRelativeTime, isLocale, resolveLocale } from "./locale"; +import { isLocale } from "@reactive-resume/utils/locale"; +import { changeLocale, formatRelativeTime, resolveLocale } from "./locale"; afterEach(() => { vi.restoreAllMocks(); @@ -64,10 +65,10 @@ describe("formatRelativeTime", () => { expect(formatRelativeTime("2026-01-02T11:59:45Z", formatter)).toBe("now"); }); - it("uses the requested fallback for an invalid date", () => { + it("reads an invalid date as now", () => { const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); - expect(formatRelativeTime("invalid", formatter, "")).toBe(""); + expect(formatRelativeTime("invalid", formatter)).toBe("now"); }); }); diff --git a/apps/web/src/libs/locale.ts b/apps/web/src/libs/locale.ts index 1261cfefe..bf2257f3b 100644 --- a/apps/web/src/libs/locale.ts +++ b/apps/web/src/libs/locale.ts @@ -3,12 +3,9 @@ import type { Locale } from "@reactive-resume/utils/locale"; import { i18n } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import Cookies from "js-cookie"; -import { isRTL, localeSchema } from "@reactive-resume/utils/locale"; - -export { isRTL }; +import { defaultLocale, isLocale } from "@reactive-resume/utils/locale"; const storageKey = "locale"; -const defaultLocale: Locale = "en-US"; const messageLoaders = import.meta.glob<{ messages: Messages }>("../../locales/*.po"); const relativeTimeDivisions: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [ { amount: 31_536_000_000, unit: "year" }, @@ -77,18 +74,14 @@ export const localeMap = { "zu-ZA": msg`Zulu`, } satisfies Record; -export function isLocale(locale: string): locale is Locale { - return localeSchema.safeParse(locale).success; -} - export const resolveLocale = (locale: string): Locale => { return isLocale(locale) ? locale : defaultLocale; }; -export function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat, invalidFallback?: string) { +export function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) { const date = value instanceof Date ? value : new Date(value); const diffMs = date.getTime() - Date.now(); - if (Number.isNaN(diffMs)) return invalidFallback ?? formatter.format(0, "second"); + if (Number.isNaN(diffMs)) return formatter.format(0, "second"); const division = relativeTimeDivisions.find((candidate) => Math.abs(diffMs) >= candidate.amount); diff --git a/apps/web/src/libs/resume/section-title-locale.test.tsx b/apps/web/src/libs/resume/section-title-locale.test.tsx index 36079e58c..aaf5f1337 100644 --- a/apps/web/src/libs/resume/section-title-locale.test.tsx +++ b/apps/web/src/libs/resume/section-title-locale.test.tsx @@ -1,6 +1,5 @@ // @vitest-environment happy-dom -import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock locale module so getLocaleMessages returns a known mapping @@ -54,38 +53,3 @@ describe("createSectionTitleResolverForLocale", () => { expect(title.length).toBeGreaterThan(0); }); }); - -describe("useSectionTitleResolver", () => { - it("returns null when no locale is provided", async () => { - const { useSectionTitleResolver } = await import("./section-title-locale"); - - const { result } = renderHook(() => useSectionTitleResolver(undefined)); - expect(result.current).toBeNull(); - }); - - it("loads a resolver when a locale is provided", async () => { - const { useSectionTitleResolver } = await import("./section-title-locale"); - - const { result, rerender } = renderHook( - ({ locale }: { locale: string | undefined }) => useSectionTitleResolver(locale), - { - initialProps: { locale: "en-US" as string | undefined }, - }, - ); - - // Initially null while the resolver is loading async. - expect(result.current).toBeNull(); - - await act(async () => { - // Flush microtasks so the async resolver settles. - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(typeof result.current).toBe("function"); - - // Switching to undefined clears the resolver. - rerender({ locale: undefined }); - expect(result.current).toBeNull(); - }); -}); diff --git a/apps/web/src/libs/resume/section-title-locale.ts b/apps/web/src/libs/resume/section-title-locale.ts index 0c999b1dc..75322c416 100644 --- a/apps/web/src/libs/resume/section-title-locale.ts +++ b/apps/web/src/libs/resume/section-title-locale.ts @@ -1,6 +1,5 @@ import type { SectionTitleResolver } from "@reactive-resume/pdf/section-title"; import { setupI18n } from "@lingui/core"; -import { useEffect, useReducer } from "react"; import { getLocaleMessages, resolveLocale } from "@/libs/locale"; import { createSectionTitleResolver } from "./section-title"; @@ -23,30 +22,3 @@ export const createSectionTitleResolverForLocale = (localeParam: string) => { return resolver; }; - -export const useSectionTitleResolver = (locale?: string) => { - const [resolver, dispatchResolver] = useReducer( - (_state: SectionTitleResolver | null, nextResolver: SectionTitleResolver | null) => nextResolver, - null, - ); - - useEffect(() => { - if (!locale) { - dispatchResolver(null); - return; - } - - let cancelled = false; - - dispatchResolver(null); - void createSectionTitleResolverForLocale(locale).then((nextResolver) => { - if (!cancelled) dispatchResolver(nextResolver); - }); - - return () => { - cancelled = true; - }; - }, [locale]); - - return resolver; -}; diff --git a/apps/web/src/libs/resume/section.test.tsx b/apps/web/src/libs/resume/section.test.tsx index 1c0f54481..c053c574c 100644 --- a/apps/web/src/libs/resume/section.test.tsx +++ b/apps/web/src/libs/resume/section.test.tsx @@ -2,14 +2,18 @@ import { beforeAll, describe, expect, it } from "vitest"; import { i18n } from "@lingui/core"; -import { isValidElement } from "react"; -import { getSectionIcon, getSectionTitle, leftSidebarSections, rightSidebarSections } from "./section"; +import { getSectionTitle } from "./section"; beforeAll(() => { i18n.loadAndActivate({ locale: "en", messages: {} }); }); -const ALL_SECTIONS = [...leftSidebarSections, ...rightSidebarSections, "cover-letter"] as const; +const ALL_SECTIONS = [ + ...["picture", "basics", "summary", "profiles", "experience", "education", "projects", "skills", "languages"], + ...["interests", "awards", "certifications", "publications", "volunteer", "references", "custom", "template"], + ...["layout", "sharing", "statistics", "typography", "design", "styles", "page", "notes", "export", "information"], + "cover-letter", +] as Parameters[0][]; describe("getSectionTitle", () => { it("returns a non-empty string for every known sidebar section", () => { @@ -25,40 +29,3 @@ describe("getSectionTitle", () => { expect(new Set(titles).size).toBe(titles.length); }); }); - -describe("getSectionIcon", () => { - it("returns a React element for every known sidebar section", () => { - for (const section of ALL_SECTIONS) { - const icon = getSectionIcon(section); - expect(isValidElement(icon), section).toBe(true); - } - }); - - it("forwards icon props such as className", () => { - const icon = getSectionIcon("skills", { className: "custom-class" }); - const props = (icon as React.ReactElement).props as { className?: string }; - expect(props.className).toContain("custom-class"); - // Always merges the shrink-0 baseline class. - expect(props.className).toContain("shrink-0"); - }); -}); - -describe("sidebar section collections", () => { - it("expose the documented left-sidebar set", () => { - expect(leftSidebarSections).toContain("picture"); - expect(leftSidebarSections).toContain("basics"); - expect(leftSidebarSections).toContain("experience"); - expect(leftSidebarSections).toContain("custom"); - }); - - it("expose the documented right-sidebar set", () => { - expect(rightSidebarSections).toContain("template"); - expect(rightSidebarSections).toContain("design"); - expect(rightSidebarSections).toContain("export"); - }); - - it("do not overlap (every section belongs to exactly one sidebar)", () => { - const overlap = leftSidebarSections.filter((s) => (rightSidebarSections as readonly string[]).includes(s)); - expect(overlap).toEqual([]); - }); -}); diff --git a/apps/web/src/libs/resume/section.tsx b/apps/web/src/libs/resume/section.tsx index c2b09f9fb..e46b76f39 100644 --- a/apps/web/src/libs/resume/section.tsx +++ b/apps/web/src/libs/resume/section.tsx @@ -1,38 +1,6 @@ -import type { IconProps } from "@phosphor-icons/react"; import type { SectionType } from "@reactive-resume/schema/resume/data"; import { t } from "@lingui/core/macro"; -import { - ArticleIcon, - BooksIcon, - BriefcaseIcon, - CertificateIcon, - ChartLineIcon, - CodeSimpleIcon, - CompassToolIcon, - DiamondsFourIcon, - DownloadIcon, - EnvelopeSimpleIcon, - FootballIcon, - GraduationCapIcon, - HandHeartIcon, - ImageIcon, - InfoIcon, - LayoutIcon, - MessengerLogoIcon, - NotepadIcon, - PaintBrushBroadIcon, - PaletteIcon, - PhoneIcon, - ReadCvLogoIcon, - ShareFatIcon, - StarIcon, - TextTIcon, - TranslateIcon, - TrophyIcon, - UserIcon, -} from "@phosphor-icons/react"; import { match } from "ts-pattern"; -import { cn } from "@reactive-resume/utils/style"; export type LeftSidebarSection = "picture" | "basics" | "summary" | SectionType | "custom"; @@ -54,39 +22,6 @@ export type RightSidebarSection = export type SidebarSection = LeftSidebarSection | RightSidebarSection; -export const leftSidebarSections: LeftSidebarSection[] = [ - "picture", - "basics", - "summary", - "profiles", - "experience", - "education", - "projects", - "skills", - "languages", - "interests", - "awards", - "certifications", - "publications", - "volunteer", - "references", - "custom", -] as const; - -export const rightSidebarSections: RightSidebarSection[] = [ - "template", - "layout", - "sharing", - "statistics", - "typography", - "design", - "styles", - "page", - "notes", - "export", - "information", -] as const; - export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string => { return ( match(type) @@ -127,46 +62,3 @@ export const getSectionTitle = (type: SidebarSection | CustomOnlyType): string = .exhaustive() ); }; - -export const getSectionIcon = (type: SidebarSection | CustomOnlyType, props?: IconProps): React.ReactNode => { - const iconProps = { ...props, className: cn("shrink-0", props?.className) }; - - return ( - match(type) - // Left Sidebar Sections - .with("picture", () => ) - .with("basics", () => ) - .with("summary", () => ) - .with("profiles", () => ) - .with("experience", () => ) - .with("education", () => ) - .with("projects", () => ) - .with("skills", () => ) - .with("languages", () => ) - .with("interests", () => ) - .with("awards", () => ) - .with("certifications", () => ) - .with("publications", () => ) - .with("volunteer", () => ) - .with("references", () => ) - .with("custom", () => ) - - // Custom Section Types (not in main sidebar) - .with("cover-letter", () => ) - - // Right Sidebar Sections - .with("template", () => ) - .with("layout", () => ) - .with("typography", () => ) - .with("design", () => ) - .with("styles", () => ) - .with("page", () => ) - .with("notes", () => ) - .with("sharing", () => ) - .with("statistics", () => ) - .with("export", () => ) - .with("information", () => ) - - .exhaustive() - ); -}; diff --git a/apps/web/src/libs/tanstack-form.test.tsx b/apps/web/src/libs/tanstack-form.test.tsx deleted file mode 100644 index de6faa13e..000000000 --- a/apps/web/src/libs/tanstack-form.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -// @vitest-environment happy-dom - -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeAll, describe, expect, it } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; -import { useAppForm } from "./tanstack-form"; - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -function TestForm() { - const form = useAppForm({ - defaultValues: { - website: { url: "https://example.com", label: "Example", inlineLink: false }, - }, - }); - - return ( - - (value.url ? undefined : "Website is required") }} - > - {(field) => } - - - ); -} - -describe("registered resume fields", () => { - it("preserves labels, layout classes, attributes, values, and errors", async () => { - const user = userEvent.setup(); - const { container } = render(); - - expect(screen.getByText("Website")).toBeInTheDocument(); - expect(container.querySelector(".website-field")).toBeInTheDocument(); - const website = screen.getByRole("textbox", { name: "Website" }); - expect(website).toHaveValue("example.com"); - expect(screen.getByLabelText("Website")).toBe(website); - expect(container.querySelector(".website-field button")).not.toBeInTheDocument(); - await user.click(screen.getByText("Website", { selector: "label" })); - expect(website).toHaveFocus(); - - await user.clear(website); - - expect(screen.getByText("Website is required")).toBeInTheDocument(); - }); -}); diff --git a/apps/web/src/libs/tanstack-form.tsx b/apps/web/src/libs/tanstack-form.tsx index 81603f0c1..fb0596b08 100644 --- a/apps/web/src/libs/tanstack-form.tsx +++ b/apps/web/src/libs/tanstack-form.tsx @@ -1,139 +1,10 @@ -import type { Website } from "@reactive-resume/schema/resume/data"; -import type * as React from "react"; import { createFormHook, createFormHookContexts } from "@tanstack/react-form"; -import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; -import { Input } from "@reactive-resume/ui/components/input"; -import { InputGroupInput } from "@reactive-resume/ui/components/input-group"; -import { URLInput } from "@/components/input/url-input"; -type FieldFrameProps = { - label?: React.ReactNode; - description?: React.ReactNode; - formItemClassName?: string; -}; - -type TextFieldProps = FieldFrameProps & - Omit, "children" | "defaultValue" | "name" | "onBlur" | "onChange" | "value">; - -type InputGroupTextFieldProps = FieldFrameProps & - Omit< - React.ComponentProps, - "children" | "defaultValue" | "name" | "onBlur" | "onChange" | "value" - >; - -type NumberFieldProps = FieldFrameProps & - Omit< - React.ComponentProps, - "children" | "defaultValue" | "name" | "onBlur" | "onChange" | "type" | "value" - >; - -type WebsiteFieldProps = { - label: React.ReactNode; - formItemClassName?: string; - hideLabelButton: boolean; -}; - -const { fieldContext, formContext, useFieldContext } = createFormHookContexts(); - -function TextField({ label, description, formItemClassName, ...props }: TextFieldProps) { - const field = useFieldContext(); - - const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0; - - return ( - - {label ? {label} : null} - field.handleChange(event.target.value)} - /> - } - /> - - {description ? {description} : null} - - ); -} - -function InputGroupTextField({ label, description, formItemClassName, ...props }: InputGroupTextFieldProps) { - const field = useFieldContext(); - - const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0; - - return ( - - {label ? {label} : null} - field.handleChange(event.target.value)} - /> - } - /> - - {description ? {description} : null} - - ); -} - -function NumberField({ label, description, formItemClassName, ...props }: NumberFieldProps) { - const field = useFieldContext(); - const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0; - const value = Number.isFinite(field.state.value) ? field.state.value : ""; - - return ( - - {label ? {label} : null} - field.handleChange(event.target.valueAsNumber)} - /> - } - /> - - {description ? {description} : null} - - ); -} - -function WebsiteField({ label, formItemClassName, hideLabelButton }: WebsiteFieldProps) { - const field = useFieldContext(); - const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0; - - return ( - - {label} - field.handleChange(value)} - hideLabelButton={hideLabelButton} - /> - } - /> - - - ); -} +// Forms render their fields with `form.Field`; no shared field components are registered. +const { fieldContext, formContext } = createFormHookContexts(); export const { useAppForm, withForm } = createFormHook({ - fieldComponents: { InputGroupTextField, NumberField, TextField, WebsiteField }, + fieldComponents: {}, fieldContext, formComponents: {}, formContext, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index c7bea6897..ae629d217 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -19,6 +19,7 @@ import { domMax, LazyMotion, MotionConfig } from "motion/react"; import { useEffect } from "react"; import { Toaster } from "@reactive-resume/ui/components/toast"; import { TooltipProvider } from "@reactive-resume/ui/components/tooltip"; +import { isRTL } from "@reactive-resume/utils/locale"; import { BreakpointIndicator } from "@/components/layout/breakpoint-indicator"; import { DonationToast } from "@/components/ui/donation-toast"; import { DialogManager } from "@/dialogs/manager"; @@ -26,7 +27,6 @@ import { CommandPalette } from "@/features/command-palette"; import { ThemeProvider } from "@/features/theme/provider"; import { ConfirmDialogProvider } from "@/hooks/use-confirm"; import { PromptDialogProvider } from "@/hooks/use-prompt"; -import { isRTL } from "@/libs/locale"; import { loadRootContext } from "@/libs/root-context"; type RouterContext = { diff --git a/apps/web/src/routes/_home/-sections/footer.test.tsx b/apps/web/src/routes/_home/-sections/footer.test.tsx deleted file mode 100644 index fbe35f8b3..000000000 --- a/apps/web/src/routes/_home/-sections/footer.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -// @vitest-environment happy-dom - -import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; - -vi.stubGlobal("__APP_VERSION__", "9.9.9"); - -// The footer module evaluates `socialLinks = [{ label: t`...`, ... }]` at module -// scope. That `t` call needs an activated locale BEFORE the import, so do that -// here instead of in beforeAll. -i18n.loadAndActivate({ locale: "en", messages: {} }); - -const { Footer } = await import("./footer"); - -const renderFooter = () => - render( - -