refactor(web): delete code only tests used and reuse shared locale and colour helpers

This commit is contained in:
Amruth Pillai
2026-09-29 10:25:13 +02:00
parent e368e5955d
commit c302faa70b
32 changed files with 44 additions and 969 deletions
@@ -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(
<CometCard>
<span>card body</span>
</CometCard>,
);
expect(getByText("card body")).toBeInTheDocument();
});
it("merges custom className into the wrapper", () => {
const { container } = render(
<CometCard className="extra-class">
<span>x</span>
</CometCard>,
);
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(
<CometCard>
<span>x</span>
</CometCard>,
);
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(
<CometCard>
<span>x</span>
</CometCard>,
);
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();
});
});
@@ -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<HTMLDivElement>) => {
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 (
<div className={cn("perspective-distant transform-3d", className)}>
<m.div
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
className="relative rounded-md"
whileHover={{ z: 20, scale: 1.02, transition: { duration: 0.2, ease: EASE_OUT_STRONG } }}
style={{ rotateX, rotateY, translateX, translateY }}
>
{children}
{glareOpacity > 0 && (
<m.div
style={{ background: glareBackground, opacity: glareOpacity }}
className="pointer-events-none absolute inset-0 z-50 size-full rounded-md mix-blend-overlay"
/>
)}
</m.div>
</div>
);
};
@@ -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(<Spotlight />);
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(<Spotlight />);
// Outer wrapper > two animated beam containers
const beamGroups = container.firstChild?.childNodes;
expect(beamGroups?.length).toBe(2);
});
});
@@ -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 = () => (
<div className="fade-in pointer-events-none absolute inset-0 size-full animate-in duration-1500 ease-out">
<div className={`${drift} inset-s-0 [--spotlight-drift:100px]`}>
<div
className="absolute inset-s-0 top-0 h-[1380px] w-[560px]"
style={{ background: FIRST, transform: "translateY(-350px) rotate(-45deg)" }}
/>
<div
className="absolute inset-s-0 top-0 h-[1380px] w-[240px] origin-top-left"
style={{ background: SECOND, transform: "rotate(-45deg) translate(5%, -50%)" }}
/>
<div
className="absolute inset-s-0 top-0 h-[1380px] w-[240px] origin-top-left"
style={{ background: THIRD, transform: "rotate(-45deg) translate(-180%, -70%)" }}
/>
</div>
<div className={`${drift} inset-e-0 [--spotlight-drift:-100px]`}>
<div
className="absolute inset-e-0 top-0 h-[1380px] w-[560px]"
style={{ background: FIRST, transform: "translateY(-350px) rotate(45deg)" }}
/>
<div
className="absolute inset-e-0 top-0 h-[1380px] w-[240px] origin-top-right"
style={{ background: SECOND, transform: "rotate(45deg) translate(-5%, -50%)" }}
/>
<div
className="absolute inset-e-0 top-0 h-[1380px] w-[240px] origin-top-right"
style={{ background: THIRD, transform: "rotate(45deg) translate(180%, -70%)" }}
/>
</div>
</div>
);
+1 -1
View File
@@ -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";
@@ -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<typeof Copyright>) =>
render(
<I18nProvider i18n={i18n}>
<Copyright {...props} />
</I18nProvider>,
);
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 <bdi> 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");
}
});
});
-50
View File
@@ -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 (
<div className={cn("text-ink-3/80 text-xs leading-relaxed", className)} {...props}>
<p>
<Trans>
Licensed under{" "}
<a
href="https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-2"
>
MIT
</a>
.
</Trans>
</p>
<p>
<Trans comment="Tagline shown in app footer/about area">By the community, for the community.</Trans>
</p>
<p>
<Trans>
A passion project by{" "}
<a
target="_blank"
rel="noopener noreferrer"
href="https://amruthpillai.com"
className="font-medium underline underline-offset-2"
>
Amruth Pillai
</a>
.
</Trans>
</p>
<p className="mt-4">
<Trans comment="App version label in footer; includes semantic version variable">
Reactive Resume v<bdi>{__APP_VERSION__}</bdi>
</Trans>
</p>
</div>
);
}
-1
View File
@@ -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) => {
+1 -1
View File
@@ -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"];
@@ -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", () => {
@@ -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) =>
@@ -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";
+4 -12
View File
@@ -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<boolean> {
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<boolean>((resolve) => {
@@ -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,
@@ -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<ResumeData>;
/** 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). */
@@ -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,
@@ -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(/&nbsp;/g, " ")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&amp;/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[]) {
@@ -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([
@@ -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,
-6
View File
@@ -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%);
+4 -3
View File
@@ -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");
});
});
+3 -10
View File
@@ -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<Locale, MessageDescriptor>;
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);
@@ -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();
});
});
@@ -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;
};
+7 -40
View File
@@ -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<typeof getSectionTitle>[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([]);
});
});
-108
View File
@@ -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", () => <ImageIcon {...iconProps} />)
.with("basics", () => <UserIcon {...iconProps} />)
.with("summary", () => <ArticleIcon {...iconProps} />)
.with("profiles", () => <MessengerLogoIcon {...iconProps} />)
.with("experience", () => <BriefcaseIcon {...iconProps} />)
.with("education", () => <GraduationCapIcon {...iconProps} />)
.with("projects", () => <CodeSimpleIcon {...iconProps} />)
.with("skills", () => <CompassToolIcon {...iconProps} />)
.with("languages", () => <TranslateIcon {...iconProps} />)
.with("interests", () => <FootballIcon {...iconProps} />)
.with("awards", () => <TrophyIcon {...iconProps} />)
.with("certifications", () => <CertificateIcon {...iconProps} />)
.with("publications", () => <BooksIcon {...iconProps} />)
.with("volunteer", () => <HandHeartIcon {...iconProps} />)
.with("references", () => <PhoneIcon {...iconProps} />)
.with("custom", () => <StarIcon {...iconProps} />)
// Custom Section Types (not in main sidebar)
.with("cover-letter", () => <EnvelopeSimpleIcon {...iconProps} />)
// Right Sidebar Sections
.with("template", () => <DiamondsFourIcon {...iconProps} />)
.with("layout", () => <LayoutIcon {...iconProps} />)
.with("typography", () => <TextTIcon {...iconProps} />)
.with("design", () => <PaletteIcon {...iconProps} />)
.with("styles", () => <PaintBrushBroadIcon {...iconProps} />)
.with("page", () => <ReadCvLogoIcon {...iconProps} />)
.with("notes", () => <NotepadIcon {...iconProps} />)
.with("sharing", () => <ShareFatIcon {...iconProps} />)
.with("statistics", () => <ChartLineIcon {...iconProps} />)
.with("export", () => <DownloadIcon {...iconProps} />)
.with("information", () => <InfoIcon {...iconProps} />)
.exhaustive()
);
};
-51
View File
@@ -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 (
<I18nProvider i18n={i18n}>
<form.AppField
name="website"
validators={{ onChange: ({ value }) => (value.url ? undefined : "Website is required") }}
>
{(field) => <field.WebsiteField label="Website" hideLabelButton formItemClassName="website-field" />}
</form.AppField>
</I18nProvider>
);
}
describe("registered resume fields", () => {
it("preserves labels, layout classes, attributes, values, and errors", async () => {
const user = userEvent.setup();
const { container } = render(<TestForm />);
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();
});
});
+3 -132
View File
@@ -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<React.ComponentProps<typeof Input>, "children" | "defaultValue" | "name" | "onBlur" | "onChange" | "value">;
type InputGroupTextFieldProps = FieldFrameProps &
Omit<
React.ComponentProps<typeof InputGroupInput>,
"children" | "defaultValue" | "name" | "onBlur" | "onChange" | "value"
>;
type NumberFieldProps = FieldFrameProps &
Omit<
React.ComponentProps<typeof Input>,
"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<string>();
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
return (
<FormItem hasError={hasError} className={formItemClassName}>
{label ? <FormLabel>{label}</FormLabel> : null}
<FormControl
render={
<Input
{...props}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
{description ? <FormDescription>{description}</FormDescription> : null}
</FormItem>
);
}
function InputGroupTextField({ label, description, formItemClassName, ...props }: InputGroupTextFieldProps) {
const field = useFieldContext<string>();
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
return (
<FormItem hasError={hasError} className={formItemClassName}>
{label ? <FormLabel>{label}</FormLabel> : null}
<FormControl
render={
<InputGroupInput
{...props}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
{description ? <FormDescription>{description}</FormDescription> : null}
</FormItem>
);
}
function NumberField({ label, description, formItemClassName, ...props }: NumberFieldProps) {
const field = useFieldContext<number>();
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
const value = Number.isFinite(field.state.value) ? field.state.value : "";
return (
<FormItem hasError={hasError} className={formItemClassName}>
{label ? <FormLabel>{label}</FormLabel> : null}
<FormControl
render={
<Input
{...props}
type="number"
name={field.name}
value={value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.valueAsNumber)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
{description ? <FormDescription>{description}</FormDescription> : null}
</FormItem>
);
}
function WebsiteField({ label, formItemClassName, hideLabelButton }: WebsiteFieldProps) {
const field = useFieldContext<Website>();
const hasError = field.state.meta.isTouched && field.state.meta.errors.length > 0;
return (
<FormItem hasError={hasError} className={formItemClassName}>
<FormLabel>{label}</FormLabel>
<FormControl
render={
<URLInput
value={field.state.value}
onChange={(value) => field.handleChange(value)}
hideLabelButton={hideLabelButton}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
);
}
// 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,
+1 -1
View File
@@ -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 = {
@@ -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(
<I18nProvider i18n={i18n}>
<Footer />
</I18nProvider>,
);
describe("Footer", () => {
it("renders Resources and Community link group headings", () => {
renderFooter();
expect(screen.getByText("Resources")).toBeInTheDocument();
expect(screen.getByText("Community")).toBeInTheDocument();
});
it("renders the documented resource links", () => {
const { container } = renderFooter();
const text = container.textContent ?? "";
for (const label of ["Documentation", "Sponsorships", "Source Code", "Changelog"]) {
expect(text, label).toContain(label);
}
});
it("renders the documented community links", () => {
const { container } = renderFooter();
const text = container.textContent ?? "";
for (const label of ["Report an issue", "Translations", "Subreddit", "Discord"]) {
expect(text, label).toContain(label);
}
});
it("renders social media icon links to GitHub, LinkedIn, and X", () => {
const { container } = renderFooter();
const hrefs = Array.from(container.querySelectorAll<HTMLAnchorElement>("a")).map((a) => a.href);
expect(hrefs.some((h) => h.includes("github.com/reactive-resume/reactive-resume"))).toBe(true);
expect(hrefs.some((h) => h.includes("linkedin.com/in/amruthpillai"))).toBe(true);
expect(hrefs.some((h) => h.includes("x.com/KingOKings"))).toBe(true);
});
it("includes Reactive Resume version copy via Copyright", () => {
renderFooter();
// The version is wrapped in <bdi> for RTL isolation, so it is its own text node.
expect(screen.getByText("9.9.9")).toBeInTheDocument();
});
});
@@ -1,140 +0,0 @@
import type { Icon } from "@phosphor-icons/react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { GithubLogoIcon, LinkedinLogoIcon, XLogoIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
import { Copyright } from "@/components/ui/copyright";
import { EASE_OUT_STRONG } from "@/libs/motion";
type FooterLinkItem = {
url: string;
label: string;
};
type FooterLinkGroupProps = {
title: string;
links: FooterLinkItem[];
};
type SocialLink = {
url: string;
label: string;
icon: Icon;
};
const getResourceLinks = (): FooterLinkItem[] => [
{ url: "https://docs.rxresu.me", label: t`Documentation` },
{ url: "https://opencollective.com/reactive-resume/donate", label: t`Sponsorships` },
{ url: "https://github.com/reactive-resume/reactive-resume", label: t`Source Code` },
{ url: "https://docs.rxresu.me/changelog", label: t`Changelog` },
];
const getCommunityLinks = (): FooterLinkItem[] => [
{ url: "https://github.com/reactive-resume/reactive-resume/issues", label: t`Report an issue` },
{ url: "https://crowdin.com/project/reactive-resume", label: t`Translations` },
{ url: "https://reddit.com/r/reactiveresume", label: t`Subreddit` },
{ url: "https://discord.gg/aSyA5ZSxpb", label: t`Discord` },
];
const socialLinks: SocialLink[] = [
{ url: "https://github.com/reactive-resume/reactive-resume", label: t`GitHub`, icon: GithubLogoIcon },
{ url: "https://linkedin.com/in/amruthpillai", label: t`LinkedIn`, icon: LinkedinLogoIcon },
{ url: "https://x.com/KingOKings", label: t`X (Twitter)`, icon: XLogoIcon },
];
export function Footer() {
return (
<m.footer
id="footer"
className="p-4 pb-8 md:p-8 md:pb-12"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.45, ease: EASE_OUT_STRONG }}
>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
{/* Brand Column */}
<div className="space-y-4 sm:col-span-2 lg:col-span-1">
<BrandIcon variant="logo" className="size-10" />
<div className="space-y-2">
<h2 className="font-semibold text-lg tracking-tight">Reactive Resume</h2>
<p className="max-w-xs text-ink-3 text-sm leading-relaxed">
<Trans>
A free and open-source resume builder that makes it easy to create, update, and share your resume.
</Trans>
</p>
</div>
{/* Social Links */}
<div className="flex items-center gap-2 pt-2">
{socialLinks.map((social) => (
<Button
key={social.label}
size="icon-sm"
variant="ghost"
nativeButton={false}
render={
<a
href={social.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${social.label} (${t`opens in new tab`})`}
>
<social.icon aria-hidden="true" size={18} />
</a>
}
/>
))}
</div>
</div>
{/* Resources Column */}
<FooterLinkGroup title={t`Resources`} links={getResourceLinks()} />
{/* Community Column */}
<FooterLinkGroup title={t`Community`} links={getCommunityLinks()} />
{/* Copyright Column */}
<div className="space-y-4 sm:col-span-2 lg:col-span-1">
<Copyright />
</div>
</div>
</m.footer>
);
}
function FooterLinkGroup({ title, links }: FooterLinkGroupProps) {
return (
<div className="space-y-4">
<h2 className="font-medium text-ink-3 text-sm tracking-tight">{title}</h2>
<ul className="space-y-3">
{links.map((link) => (
<FooterLink key={link.url} url={link.url} label={link.label} />
))}
</ul>
</div>
);
}
function FooterLink({ url, label }: FooterLinkItem) {
return (
<li className="relative">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="relative inline-block text-sm transition-colors after:pointer-events-none after:absolute after:inset-x-0 after:-bottom-0.5 after:h-px after:origin-left after:scale-x-0 after:rounded-md after:bg-accent after:content-[''] hover:text-ink hover:after:scale-x-100 rtl:after:origin-right after:[transition:scale_200ms_var(--ease-out-strong)]"
>
{label}
<span className="sr-only">
<Trans>(opens in new tab)</Trans>
</span>
</a>
</li>
);
}
@@ -145,7 +145,7 @@ function DocumentMenuItems({ onOpenDialog }: DocumentMenuItemsProps) {
<Icon name="edit" />
<Trans>Rename…</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openDialog("resume.duplicate", { id, name, slug, tags, shouldRedirect: true })}>
<DropdownMenuItem onClick={() => openDialog("resume.duplicate", { id, name, slug, tags })}>
<Icon name="content_copy" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>