feat(web): refine motion system and simplify animated UI (#3546)

Audit every animation in the app and shared UI primitives against a
frequency-first motion bar: keyboard and high-frequency actions no longer
animate, remaining motion uses interruptible CSS transitions with shared
easing tokens, and redundant animation code is removed.

UI primitives (@reactive-resume/ui)
- Dialog, alert dialog, popover and tooltip move from tw-animate keyframes
  to Base UI data-starting/ending-style transitions; menus, popovers and
  tooltips skip motion when opened from the keyboard (data-instant).
- Dialog gains an `instant` prop; the command palette uses it.
- Accordion animates its real panel height; caret rotates instead of
  swapping icons.
- Menu backdrop blur moves onto the popup so it no longer snaps in after
  the fade; context menus and comboboxes fade only.
- Sidebar collapse uses the strong ease-out curve and snaps on Cmd+B.
- Toast, sheet, checkbox, tabs, toggle, inputs and message scroller get
  tokenised easing, correct transition properties and press feedback.
- Tabs no longer squeeze a trigger narrower than its label.
- Spinners keep spinning under prefers-reduced-motion.

Web app
- Remove the default route view transition and page-entrance slides.
- Add EASE_OUT_STRONG for Motion; replace built-in "easeOut" everywhere.
- Switch LazyMotion to domMax so layout and Reorder animations run.
- Builder: consolidate 12 section list files into one ItemsSection,
  opacity-only list rows with popLayout, instant Cmd+0, faster dock zoom,
  crossfade that no longer dips, transform-based progress bars.
- Dashboard: keep previous results while sorting/filtering, no empty-state
  flash, uncontrolled sidebar (no network round trip on collapse), calmer
  resume card tilt, no stacked hover wrappers.
- Settings: drop entrance/stagger wrappers and ActionButton.
- Agent: CSS marquee paused on hover; thread switches keep the layout.
- Homepage: fix invalid transition declarations, CSS spotlight drift,
  scroll-hiding header without a JS spring, tokenised curves.
- Theme switches change every color at once.
- Remove SSR-only useIsClient guards from the SPA.

Docs: rewrite the DESIGN.md animation section around the new tokens.
This commit is contained in:
Amruth Pillai
2026-09-27 17:10:42 +02:00
committed by GitHub
parent 0cb83602f5
commit b48a9c2142
131 changed files with 915 additions and 1611 deletions
+13 -10
View File
@@ -226,21 +226,24 @@ A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions
## Animation
Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern:
Motion exists to explain a change, confirm an action, or soften a jump. This is a tool people use for hours, so it stays crisp: short, precise, rarely decorative.
**Entrance animations** use a fade-up reveal: elements start at `opacity: 0, y: 20-100` and animate to `opacity: 1, y: 0`. The hero section uses a larger y-offset (100px) for dramatic effect; subsequent sections use 20px for subtlety.
**Frequency decides first.** Keyboard-initiated actions (the command palette, ⌘B sidebar toggle, zoom shortcuts, keyboard-opened menus via Base UI's `data-instant`) do not animate. Things hit tens of times a day (list rows, tooltips after the first, context menus) get opacity-only or no motion. Dialogs, sheets and toasts get a standard transition. Only rare moments (marketing pages, first load) get more.
**Timing principles:**
- **Base duration:** 0.35s–0.6s for standard section reveals, 0.45s for hero elements, up to 1.1s for the hero video entrance.
- **Stagger pattern:** Sequential delays within a group, typically 0.1s–0.15s apart (hero: 0.55s, 0.7s, 0.82s, 0.95s). For grids, use `index * 0.03`–`0.1` for per-item stagger.
- **Easing:** `easeOut` for entrances (elements decelerate into position). `easeInOut` for looping/ambient animations.
- **Performance:** Apply `will-change-[transform,opacity]` on animated elements and `will-change-transform` on continuously animated elements.
**Tokens.** Never hand-type a curve.
- CSS: `ease-out-strong` / `var(--ease-out-strong)` (`cubic-bezier(0.23, 1, 0.32, 1)`) for anything entering, exiting or responding; `ease-in-out-strong` for on-screen movement nobody is waiting on (ambient loops, carousels); `ease-drawer` for sheets. Never `ease-in`.
- Motion (JS): `EASE_OUT_STRONG` from `apps/web/src/libs/motion.ts`.
**Hover/interaction animations** are quick (0.2s) and subtle — small scale bumps (`scale: 1.01`), slight y-offsets (`y: -2`), and `active:translate-y-px` for button press.
**Durations.** Press feedback 100–160ms, tooltips/popovers/menus 150ms in and 100ms out, dialogs 200ms in and 150ms out, sheets 300ms. App UI stays under 300ms; marketing reveals may run 0.5–0.9s. Exits are faster than entrances.
**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s).
**Mechanics.**
- Popups use interruptible CSS transitions on Base UI's `data-starting-style` / `data-ending-style`, scale from `0.95` (never `0`) and grow from `origin-(--transform-origin)`. Modals stay centred.
- Animate `transform`/`translate`/`scale` and `opacity` only. No `transition-all`, no permanent `will-change` (Motion promotes layers while it animates).
- Presses use `active:scale-[0.97]`. `Button` already has it; don't wrap it in Motion hover/tap wrappers.
- Lists use `AnimatePresence initial={false}` so items animate when added or removed, not every time the list mounts.
- Continuous loops (marquees, drifting spotlights) are CSS keyframes, so they run off the main thread.
**Reduced motion:** All CSS transitions and animations collapse to `0.01ms` duration and single iteration when `prefers-reduced-motion: reduce` is active. Motion library animations should also respect this preference.
**Reduced motion:** `MotionConfig reducedMotion="user"` disables Motion transforms, and CSS transitions and animations collapse to `0.01ms` — except `animate-spin`, which keeps spinning so loading never looks frozen. Values driven by `useSpring`/`useMotionValue` bypass `MotionConfig`, so check `useReducedMotion()` there.
## Elevation & Depth
@@ -37,20 +37,20 @@ describe("CometCard", () => {
expect(glare?.className).toContain("pointer-events-none");
});
it("does not throw when mouse enters / moves over / leaves the card", () => {
it("does not throw when pointer moves over / leaves the card", () => {
const { container } = render(
<CometCard>
<span>x</span>
</CometCard>,
);
const tiltable = container.querySelector("[class*='will-change-transform']") as HTMLElement;
const tiltable = container.querySelector("[class*='rounded-md']") as HTMLElement;
expect(tiltable).toBeTruthy();
expect(() => {
fireEvent.mouseMove(tiltable, { clientX: 100, clientY: 50 });
fireEvent.mouseMove(tiltable, { clientX: 0, clientY: 0 });
fireEvent.mouseLeave(tiltable);
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 +1,41 @@
import type React from "react";
import { m, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react";
import { useRef } 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 = {
rotateDepth?: number;
translateDepth?: number;
glareOpacity?: number;
scaleFactor?: number;
className?: string;
children: React.ReactNode;
};
export const CometCard = ({
rotateDepth = 17.5,
translateDepth = 20,
glareOpacity = 0.4,
scaleFactor = 1.05,
className,
children,
}: Props) => {
const ref = useRef<HTMLDivElement>(null);
// Critically damped: the tilt follows the pointer without wobbling past it.
const tiltSpring = { stiffness: 300, damping: 30 };
const x = useMotionValue(0);
const y = useMotionValue(0);
export const CometCard = ({ glareOpacity = 0.4, className, children }: Props) => {
const reduceMotion = useReducedMotion();
const mouseXSpring = useSpring(x);
const mouseYSpring = useSpring(y);
const x = useSpring(useMotionValue(0), tiltSpring);
const y = useSpring(useMotionValue(0), tiltSpring);
const rotateX = useTransform(mouseYSpring, [-0.5, 0.5], [`-${rotateDepth}deg`, `${rotateDepth}deg`]);
const rotateY = useTransform(mouseXSpring, [-0.5, 0.5], [`${rotateDepth}deg`, `-${rotateDepth}deg`]);
const translateX = useTransform(mouseXSpring, [-0.5, 0.5], [`-${translateDepth}px`, `${translateDepth}px`]);
const translateY = useTransform(mouseYSpring, [-0.5, 0.5], [`${translateDepth}px`, `-${translateDepth}px`]);
const glareX = useTransform(mouseXSpring, [-0.5, 0.5], [0, 100]);
const glareY = useTransform(mouseYSpring, [-0.5, 0.5], [0, 100]);
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%)`;
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const xPct = mouseX / width - 0.5;
const yPct = mouseY / height - 0.5;
x.set(xPct);
y.set(yPct);
// 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 handleMouseLeave = () => {
const handlePointerLeave = () => {
x.set(0);
y.set(0);
};
@@ -65,21 +43,20 @@ export const CometCard = ({
return (
<div className={cn("perspective-distant transform-3d", className)}>
<m.div
ref={ref}
initial={{ scale: 1, z: 0 }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="relative rounded-md will-change-transform"
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
style={{ rotateX: rotateX, rotateY: rotateY, translateX: translateX, translateY: translateY }}
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}
<m.div
transition={{ duration: 0.2 }}
style={{ background: glareBackground, opacity: glareOpacity }}
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay will-change-[opacity]"
/>
{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,46 +1,40 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CountUp } from "./count-up";
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { domAnimation, LazyMotion } from "motion/react";
const reducedMotion = vi.hoisted(() => ({ value: false }));
vi.mock("motion/react", async (importOriginal) => ({
...(await importOriginal<typeof import("motion/react")>()),
useReducedMotion: () => reducedMotion.value,
}));
const { CountUp } = await import("./count-up");
describe("CountUp", () => {
it("renders an aria-live=polite span by default (announced to screen readers)", () => {
const { container } = render(<CountUp to={1000} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.getAttribute("aria-live")).toBe("polite");
expect(span.getAttribute("aria-atomic")).toBe("true");
it("starts at 0 before animating", () => {
reducedMotion.value = false;
const { container } = render(<CountUp to={1234} />);
expect(container.querySelector("span")?.textContent).toBe("0");
});
it("seeds textContent to 0 on initial render", () => {
const { container } = render(<CountUp to={100} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("0");
it("jumps to the final grouped value under reduced motion", async () => {
reducedMotion.value = true;
const { container } = render(
<LazyMotion features={domAnimation}>
<CountUp to={1234} />
</LazyMotion>,
);
await waitFor(() => expect(container.querySelector("span")?.textContent).toBe("1,234"));
});
it("formats with the separator when one is supplied", () => {
const { container } = render(<CountUp to={1234} separator="," />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("0");
});
it("preserves decimal places when to is fractional", () => {
const { container } = render(<CountUp to={3.75} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("0.00");
});
it("strips aria-live and aria-atomic when aria-hidden is set", () => {
const { container } = render(<CountUp to={100} aria-hidden="true" />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.getAttribute("aria-hidden")).toBe("true");
expect(span.getAttribute("aria-live")).toBeNull();
expect(span.getAttribute("aria-atomic")).toBeNull();
});
it("accepts a custom className", () => {
const { container } = render(<CountUp to={100} className="custom-class" />);
it("forwards className and aria-hidden", () => {
reducedMotion.value = false;
const { container } = render(<CountUp to={100} className="custom-class" aria-hidden="true" />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.className).toContain("custom-class");
expect(span.getAttribute("aria-hidden")).toBe("true");
});
});
+14 -70
View File
@@ -1,84 +1,28 @@
import { useInView, useMotionValue, useSpring } from "motion/react";
import { useCallback, useEffect, useEffectEvent, useRef } from "react";
import { m, useInView, useReducedMotion, useSpring, useTransform } from "motion/react";
import { useEffect, useRef } from "react";
type CountUpProps = {
to: number;
duration?: number;
className?: string;
separator?: string;
"aria-hidden"?: boolean | "true" | "false";
"aria-live"?: "off" | "polite" | "assertive";
"aria-atomic"?: boolean | "true" | "false";
};
const getDecimalPlaces = (num: number): number => {
const str = num.toString();
if (str.includes(".")) {
const decimals = str.split(".")[1];
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
}
return 0;
};
// ponytail: from/direction/delay/startWhen/onStart/onEnd removed — no production caller passes them
export function CountUp({
to,
duration = 2,
className = "",
separator = "",
"aria-hidden": ariaHidden,
"aria-live": ariaLive = "polite",
"aria-atomic": ariaAtomic = "true",
}: CountUpProps) {
// Integer count-up with en-US grouping. Starts once when scrolled into view; reduced motion shows the final value.
export function CountUp({ to, className, "aria-hidden": ariaHidden }: CountUpProps) {
const ref = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(0);
const damping = 20 + 40 * (1 / duration);
const stiffness = 100 * (1 / duration);
const springValue = useSpring(motionValue, { damping, stiffness });
const isInView = useInView(ref, { once: true, margin: "0px" });
const maxDecimals = getDecimalPlaces(to);
const formatValue = useCallback(
(latest: number) => {
const options: Intl.NumberFormatOptions = {
useGrouping: !!separator,
minimumFractionDigits: maxDecimals,
maximumFractionDigits: maxDecimals,
};
const formattedNumber = Intl.NumberFormat("en-US", options).format(latest);
return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
},
[maxDecimals, separator],
);
const formatCurrentValue = useEffectEvent((latest: number) => formatValue(latest));
const isInView = useInView(ref, { once: true });
const reducedMotion = useReducedMotion();
const spring = useSpring(0, { visualDuration: 0.8, bounce: 0 });
const text = useTransform(spring, (value) => Math.round(value).toLocaleString("en-US"));
useEffect(() => {
if (ref.current) ref.current.textContent = formatCurrentValue(0);
}, []);
useEffect(() => {
if (isInView) motionValue.set(to);
}, [isInView, motionValue, to]);
useEffect(() => {
const unsubscribe = springValue.on("change", (latest: number) => {
if (ref.current) ref.current.textContent = formatCurrentValue(latest);
});
return () => unsubscribe();
}, [springValue]);
if (reducedMotion) spring.jump(to);
else if (isInView) spring.set(to);
}, [isInView, reducedMotion, spring, to]);
return (
<span
ref={ref}
className={className}
aria-hidden={ariaHidden}
aria-live={ariaHidden ? undefined : ariaLive}
aria-atomic={ariaHidden ? undefined : ariaAtomic}
/>
<m.span ref={ref} className={className} aria-hidden={ariaHidden}>
{text}
</m.span>
);
}
@@ -18,28 +18,4 @@ describe("Spotlight", () => {
const beamGroups = container.firstChild?.childNodes;
expect(beamGroups?.length).toBe(2);
});
it("applies the provided width / height / smallWidth to inline styles", () => {
const { container } = render(<Spotlight width={500} height={800} smallWidth={120} translateY={-100} />);
const inlineStyles = Array.from(container.querySelectorAll<HTMLDivElement>("[style]")).map(
(el) => el.getAttribute("style") ?? "",
);
const allStyles = inlineStyles.join("|");
expect(allStyles).toContain("width: 500px");
expect(allStyles).toContain("height: 800px");
expect(allStyles).toContain("width: 120px");
expect(allStyles).toContain("translateY(-100px)");
});
it("uses the supplied gradient strings as background values", () => {
const customFirst = "radial-gradient(red, blue)";
const { container } = render(<Spotlight gradientFirst={customFirst} />);
const matched = Array.from(container.querySelectorAll<HTMLDivElement>("[style]")).filter(
(el) => el.style.background.includes("red") && el.style.background.includes("blue"),
);
expect(matched.length).toBeGreaterThan(0);
});
});
+41 -103
View File
@@ -1,106 +1,44 @@
import { m } from "motion/react";
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%)";
type SpotlightProps = {
duration?: number;
gradientFirst?: string;
gradientSecond?: string;
gradientThird?: string;
width?: number;
height?: number;
smallWidth?: number;
translateY?: number;
xOffset?: number;
};
// 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 = ({
duration = 7,
gradientFirst = "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%)",
gradientSecond = "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .06) 0, hsla(210, 100%, 55%, .02) 80%, transparent 100%)",
gradientThird = "radial-gradient(50% 50% at 50% 50%, hsla(210, 100%, 85%, .04) 0, hsla(210, 100%, 45%, .02) 80%, transparent 100%)",
width = 560,
height = 1380,
smallWidth = 240,
translateY = -350,
xOffset = 100,
}: SpotlightProps) => {
return (
<m.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1.5 }}
className="pointer-events-none absolute inset-0 h-full w-full"
>
<m.div
animate={{ x: [0, xOffset, 0] }}
transition={{ duration, repeat: Number.POSITIVE_INFINITY, repeatType: "reverse", ease: "easeInOut" }}
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw will-change-transform"
>
<div
className="absolute inset-s-0 top-0"
style={{
width: `${width}px`,
height: `${height}px`,
background: gradientFirst,
transform: `translateY(${translateY}px) rotate(-45deg)`,
}}
/>
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="absolute inset-s-0 top-0 origin-top-left"
style={{
height: `${height}px`,
width: `${smallWidth}px`,
background: gradientSecond,
transform: "rotate(-45deg) translate(5%, -50%)",
}}
/>
<div
className="absolute inset-s-0 top-0 origin-top-left"
style={{
height: `${height}px`,
width: `${smallWidth}px`,
background: gradientThird,
transform: "rotate(-45deg) translate(-180%, -70%)",
}}
/>
</m.div>
<m.div
animate={{ x: [0, -xOffset, 0] }}
transition={{ duration, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut", repeatType: "reverse" }}
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw will-change-transform"
>
<div
className="absolute inset-e-0 top-0"
style={{
width: `${width}px`,
height: `${height}px`,
background: gradientFirst,
transform: `translateY(${translateY}px) rotate(45deg)`,
}}
/>
<div
className="absolute inset-e-0 top-0 origin-top-right"
style={{
height: `${height}px`,
width: `${smallWidth}px`,
background: gradientSecond,
transform: "rotate(45deg) translate(-5%, -50%)",
}}
/>
<div
className="absolute inset-e-0 top-0 origin-top-right"
style={{
height: `${height}px`,
width: `${smallWidth}px`,
background: gradientThird,
transform: "rotate(45deg) translate(180%, -70%)",
}}
/>
</m.div>
</m.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>
);
+34 -52
View File
@@ -13,7 +13,6 @@ import { CSS } from "@dnd-kit/utilities";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleIcon, XIcon } from "@phosphor-icons/react";
import { AnimatePresence, m } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import { Badge } from "@reactive-resume/ui/components/badge";
@@ -75,15 +74,10 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
};
return (
<m.div
layout
initial={{ opacity: 0, scale: 0.92, y: -4 }}
animate={{ opacity: isDragging ? 0.62 : 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: -4 }}
transition={{ duration: 0.1, ease: "easeOut" }}
<div
style={style}
ref={setNodeRef}
className="group/chip relative touch-none"
className={cn("group/chip relative touch-none", isDragging && "opacity-60")}
{...attributes}
{...listeners}
>
@@ -96,11 +90,11 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
)}
>
<span className="max-w-32 truncate sm:max-w-44">{chip}</span>
<m.div
initial={false}
animate={isEditing ? { opacity: 1 } : { opacity: 0.66 }}
transition={{ duration: 0.12, ease: "easeOut" }}
className="ms-1.5 flex shrink-0 items-center gap-x-0.5 will-change-[opacity] group-focus-within/chip:opacity-100 group-hover/chip:opacity-100"
<div
className={cn(
"ms-1.5 flex shrink-0 items-center gap-x-0.5 transition-opacity duration-150 group-focus-within/chip:opacity-100 group-hover/chip:opacity-100",
isEditing ? "opacity-100" : "opacity-65",
)}
>
<button
type="button"
@@ -134,9 +128,9 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
>
<XIcon className="size-3.5" />
</button>
</m.div>
</div>
</Badge>
</m.div>
</div>
);
}
@@ -357,21 +351,19 @@ export function ChipInput({
className={cn("max-h-24 overflow-y-auto px-2 py-1.5", hasChips ? "border-border/70 border-b" : "hidden")}
>
<SortableContext items={chips} strategy={rectSortingStrategy}>
<m.div layout className="flex flex-wrap gap-1">
<AnimatePresence initial={false} mode="popLayout">
{chips.map((chip, idx) => (
<ChipItem
key={chip}
id={chip}
chip={chip}
index={idx}
isEditing={editingIndex === idx}
onEdit={handleEdit}
onRemove={removeChip}
/>
))}
</AnimatePresence>
</m.div>
<div className="flex flex-wrap gap-1">
{chips.map((chip, idx) => (
<ChipItem
key={chip}
id={chip}
chip={chip}
index={idx}
isEditing={editingIndex === idx}
onEdit={handleEdit}
onRemove={removeChip}
/>
))}
</div>
</SortableContext>
</div>
<div className={cn("flex items-center gap-1.5 px-2", hasChips ? "py-1.5" : "py-0")}>
@@ -393,28 +385,18 @@ export function ChipInput({
onChange={handleInputChange}
className="h-9 flex-1 border-none p-0 focus-visible:border-none focus-visible:ring-0 dark:bg-transparent"
/>
<AnimatePresence>
{chips.length > 0 && (
<m.span
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{
opacity: isEditingKeyword ? 1 : 0.8,
scale: 1,
}}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.12, ease: "easeOut" }}
className={cn(
"flex h-6 min-w-6 shrink-0 items-center justify-center rounded-md border px-1.5 font-medium text-[0.7rem] tabular-nums",
isEditingKeyword
? "border-primary/30 bg-primary/10 text-primary"
: "border-border bg-muted/50 text-foreground/80",
)}
>
{isEditingKeyword ? <Trans>Edit</Trans> : chips.length}
</m.span>
)}
</AnimatePresence>
{chips.length > 0 && (
<span
className={cn(
"flex h-6 min-w-6 shrink-0 items-center justify-center rounded-md border px-1.5 font-medium text-[0.7rem] tabular-nums",
isEditingKeyword
? "border-primary/30 bg-primary/10 text-primary"
: "border-border bg-muted/50 text-foreground/80 opacity-80",
)}
>
{isEditingKeyword ? <Trans>Edit</Trans> : chips.length}
</span>
)}
</div>
</div>
</div>
@@ -65,7 +65,7 @@ export function ColorPicker({
{trigger ?? (
<PopoverTrigger>
<div
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-transform duration-150 ease-out-strong focus-visible:outline-hidden active:scale-[0.97]"
style={{ backgroundColor: currentValue }}
/>
</PopoverTrigger>
@@ -95,7 +95,7 @@ export function ColorPicker({
aria-pressed={currentValue === color}
onClick={() => setCurrentValue(color)}
className={cn(
"size-5 shrink-0 cursor-pointer rounded-full transition-all hover:scale-105 focus-visible:outline-hidden",
"size-5 shrink-0 cursor-pointer rounded-full transition-transform duration-150 ease-out-strong focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]",
currentValue === color && "border border-foreground/60",
)}
/>
@@ -25,9 +25,7 @@ export function GithubStarsButton() {
rel="noopener noreferrer"
>
<GithubLogoIcon aria-hidden="true" />
{starCount != null ? (
<CountUp to={starCount} duration={0.5} separator="," className="font-bold" aria-hidden="true" />
) : null}
{starCount != null ? <CountUp to={starCount} className="font-bold" aria-hidden="true" /> : null}
<StarIcon aria-hidden="true" />
</a>
}
@@ -34,7 +34,7 @@ export function FontDisplay({ family, label, type, url }: FontDisplayProps) {
<div ref={containerRef} className="inline-flex items-baseline gap-2">
<span
style={{ fontFamily: isLoaded ? `'${previewName}', sans-serif` : "sans-serif" }}
className={cn(isLoaded ? "opacity-100" : "opacity-50", "transition-opacity duration-200 ease-in")}
className={cn(isLoaded ? "opacity-100" : "opacity-50", "transition-opacity duration-150 ease-out-strong")}
>
{label}
</span>
@@ -15,6 +15,7 @@ import { RichInput } from "@/components/input/rich-input";
import { useDialogStore } from "@/dialogs/store";
import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
@@ -189,7 +190,7 @@ const ExperienceForm = withForm({
onReorder={handleReorderRoles}
className="flex flex-col gap-4 sm:col-span-full"
>
<AnimatePresence>
<AnimatePresence initial={false}>
{rolesField.state.value.map((role: RoleItem, index: number) => (
<RoleFields
key={role.id}
@@ -238,10 +239,11 @@ const RoleFields = withForm({
value={role}
dragListener={false}
dragControls={controls}
initial={{ opacity: 1, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="relative grid rounded-md border sm:col-span-full sm:grid-cols-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: EASE_OUT_STRONG }}
className="relative grid rounded-md border bg-popover sm:col-span-full sm:grid-cols-2"
>
<div className="col-span-full flex items-center justify-between rounded-t bg-border/30 px-2 py-1.5">
<Button
@@ -160,7 +160,7 @@ const InterestForm = withForm({
trigger={
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
<div
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-transform duration-150 ease-out-strong focus-visible:outline-hidden active:scale-[0.97]"
style={{ backgroundColor: field.state.value ?? "currentColor" }}
/>
</PopoverTrigger>
@@ -168,7 +168,7 @@ const ProfileForm = withForm({
trigger={
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
<div
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-transform duration-150 ease-out-strong focus-visible:outline-hidden active:scale-[0.97]"
style={{ backgroundColor: field.state.value ?? "currentColor" }}
/>
</PopoverTrigger>
@@ -164,7 +164,7 @@ const SkillForm = withForm({
trigger={
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
<div
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-transform duration-150 ease-out-strong focus-visible:outline-hidden active:scale-[0.97]"
style={{ backgroundColor: field.state.value ?? "currentColor" }}
/>
</PopoverTrigger>
@@ -84,7 +84,7 @@ type TemplateCardProps = {
function TemplateCard({ id, metadata, isActive, onSelect }: TemplateCardProps) {
return (
<CometCard translateDepth={3} rotateDepth={6} glareOpacity={0}>
<CometCard glareOpacity={0}>
<button
type="button"
onClick={() => onSelect(id)}
@@ -104,7 +104,7 @@ function TemplateCard({ id, metadata, isActive, onSelect }: TemplateCardProps) {
{metadata.tags.length > 0 && (
<div className="flex flex-wrap justify-center gap-1 px-1 pb-1">
{metadata.tags
.sort((a, b) => a.localeCompare(b))
.toSorted((a, b) => a.localeCompare(b))
.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
@@ -60,7 +60,7 @@ function FitRing({ score }: { score: number }) {
strokeLinecap="round"
strokeDasharray={c}
strokeDashoffset={c - (Math.max(0, Math.min(100, score)) / 100) * c}
className="transition-[stroke-dashoffset] duration-700 ease-out motion-reduce:transition-none"
className="transition-[stroke-dashoffset] duration-300 ease-out-strong motion-reduce:transition-none"
/>
</svg>
);
@@ -31,7 +31,7 @@ export function ApplicationCard({ application, onClick, onEdit, className, dragg
}}
className={cn(
"group relative w-full cursor-pointer rounded-xl border border-border bg-card p-3 text-left shadow-sm outline-none transition-colors hover:border-foreground/25 focus-visible:ring-2 focus-visible:ring-ring",
dragging && "opacity-60",
dragging && "scale-[1.02] shadow-lg",
className,
)}
>
@@ -88,7 +88,10 @@ export function ApplicationBoard({ applications, onOpen, onEdit }: Props) {
))}
</div>
<DragOverlay>{activeApp ? <ApplicationCard application={activeApp} dragging /> : null}</DragOverlay>
{/* No drop animation: the optimistic move lands after an await, so the default would fly the card back. */}
<DragOverlay dropAnimation={null}>
{activeApp ? <ApplicationCard application={activeApp} dragging /> : null}
</DragOverlay>
</DndContext>
);
}
@@ -1,4 +1,5 @@
import type { PdfAtsReport } from "@reactive-resume/resume/ats-pdf";
import type { CSSProperties } from "react";
import { Trans } from "@lingui/react/macro";
import { InfoIcon } from "@phosphor-icons/react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
@@ -30,8 +31,11 @@ export function ScoreHeader({ report }: ScoreHeaderProps) {
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full transition-[width] duration-300", tone.bar)}
style={{ width: `${report.score}%` }}
className={cn(
"h-full translate-x-(--fill) rounded-full transition-[translate] duration-300 ease-out-strong rtl:-translate-x-(--fill)",
tone.bar,
)}
style={{ "--fill": `${report.score - 100}%` } as CSSProperties}
/>
</div>
@@ -98,6 +98,7 @@ export function CommandPalette() {
</DialogHeader>
<DialogContent
instant
className="overflow-hidden p-0"
aria-label={
isFirstPage
@@ -134,7 +134,7 @@ export function ResumesCommandGroup() {
<ReadCvLogoIcon />
{resume.name}
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
<CommandShortcut className="opacity-0 group-data-[selected=true]/command-item:opacity-100">
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
Press <Kbd>Enter</Kbd> to open
</Trans>
@@ -1,6 +1,6 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { coverLetterDocumentSchema } from "@reactive-resume/schema/cover-letter/data";
import { templateSchema } from "@reactive-resume/schema/templates";
@@ -9,6 +9,7 @@ import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { templates } from "@/dialogs/resume/template/data";
import { getReadableErrorMessage } from "@/libs/error-message";
@@ -46,7 +47,10 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
useEffect(() => {
onEditingChange?.(selectedId !== null);
}, [selectedId, onEditingChange]);
const query = useQuery(orpc.coverLetters.list.queryOptions({ input: { search: querySearch, offset, limit: 20 } }));
const query = useQuery({
...orpc.coverLetters.list.queryOptions({ input: { search: querySearch, offset, limit: 20 } }),
placeholderData: keepPreviousData,
});
const resumes = useQuery(orpc.resume.list.queryOptions({ input: {} }));
const source = useQuery(
orpc.resume.getById.queryOptions({ input: { id: resumeId ?? "" }, enabled: creating && !!resumeId }),
@@ -250,7 +254,12 @@ export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEdit
<Trans>No cover letters found. Create one or import an existing letter.</Trans>
</p>
) : (
<ul className="divide-y rounded-lg border">
<ul
className={cn(
"divide-y rounded-lg border transition-opacity duration-150",
query.isPlaceholderData && "opacity-60",
)}
>
{query.data.items.map((letter) => (
<li key={letter.id}>
<button
@@ -1,6 +0,0 @@
/* Keyframes cannot be expressed as a utility; referenced via animate-[home-ats-spin_...] in ats-playground.tsx. */
@keyframes home-ats-spin {
to {
transform: rotate(360deg);
}
}
@@ -2,7 +2,6 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ArrowUpRightIcon, FilePdfIcon, SpinnerGapIcon } from "@phosphor-icons/react";
import { useEffect, useId, useRef, useState } from "react";
import "./ats-playground.css";
const focusRing = "focus-visible:outline-2 focus-visible:outline-(--home-accent) focus-visible:outline-offset-4";
const message = "max-w-[300px] text-[14px] leading-[1.8]";
@@ -97,17 +96,13 @@ export default function AtsPlayground() {
</div>
<div className="flex flex-col items-center gap-[15px] max-[900px]:col-[1] max-[600px]:row-auto max-[900px]:row-[2] max-[600px]:flex-row max-[600px]:flex-wrap max-[600px]:justify-center max-[600px]:gap-2.5">
<button
className={`motion-safe:active:not-disabled:not-focus-visible:transform-[scale(0.97)] flex min-w-[140px] flex-col items-center gap-[14px] rounded border border-(--home-line) bg-(--home-panel) px-4 py-5 text-(--home-ink) text-[13px] transition-[transform_140ms_cubic-bezier(0.23,1,0.32,1)] hover:not-disabled:border-(--home-accent) disabled:cursor-progress max-[900px]:flex-row max-[900px]:gap-2.5 max-[900px]:py-[13px] ${focusRing}`}
className={`motion-safe:active:not-disabled:not-focus-visible:transform-[scale(0.97)] flex min-w-[140px] flex-col items-center gap-[14px] rounded border border-(--home-line) bg-(--home-panel) px-4 py-5 text-(--home-ink) text-[13px] [transition:transform_140ms_var(--ease-out-strong),border-color_150ms_ease] hover:not-disabled:border-(--home-accent) disabled:cursor-progress max-[900px]:flex-row max-[900px]:gap-2.5 max-[900px]:py-[13px] ${focusRing}`}
type="button"
disabled={isReading}
onClick={() => void read(file)}
>
{isReading ? (
<SpinnerGapIcon
className="animate-[home-ats-spin_900ms_linear_infinite] text-(--home-accent)"
size={22}
aria-hidden="true"
/>
<SpinnerGapIcon className="animate-spin text-(--home-accent)" size={22} aria-hidden="true" />
) : (
<ArrowRightIcon className="text-(--home-accent)" size={22} aria-hidden="true" />
)}
@@ -184,7 +184,7 @@ export default function ExportPlayground({ name, accent, typeface, template }: E
className="perspective-[1000px] grid justify-items-center px-[30px] pt-[44px] pb-4 max-[700px]:pt-[35px]"
aria-hidden="true"
>
<div className="transform-3d transform-[rotate(-7deg)_rotateY(-12deg)] group-data-[format=docx]:transform-[rotate(-3deg)_rotateY(8deg)] group-data-[format=json]:transform-[rotate(7deg)_rotateY(9deg)] group-data-[format=md]:transform-[rotate(4deg)_rotateY(-7deg)] motion-reduce:transform-[rotate(-4deg)] relative aspect-[0.76] w-[min(100%,285px)] transition-[transform_280ms_cubic-bezier(0.23,1,0.32,1)] group-has-focus-visible:transition-none max-[700px]:w-[225px]">
<div className="transform-3d transform-[rotate(-7deg)_rotateY(-12deg)] group-data-[format=docx]:transform-[rotate(-3deg)_rotateY(8deg)] group-data-[format=json]:transform-[rotate(7deg)_rotateY(9deg)] group-data-[format=md]:transform-[rotate(4deg)_rotateY(-7deg)] motion-reduce:transform-[rotate(-4deg)] relative aspect-[0.76] w-[min(100%,285px)] [transition:transform_280ms_var(--ease-out-strong)] group-has-focus-visible:transition-none max-[700px]:w-[225px]">
<div className="transform-[translate(-26px,21px)_rotate(-10deg)] absolute inset-0 rounded border border-[#535154] bg-[#242427]" />
<div className="transform-[translate(-14px,10px)_rotate(-5deg)] absolute inset-0 rounded border border-[#535154] bg-[#383739]" />
<div className="absolute inset-0 flex flex-col overflow-hidden rounded bg-(--home-export-color) p-[25px] text-[#29272c] shadow-[1px_1px_0_#ffffff60_inset,0_24px_48px_#00000050] max-[700px]:p-5">
@@ -226,7 +226,7 @@ export default function ExportPlayground({ name, accent, typeface, template }: E
<button
key={item.id}
type="button"
className={`group flex min-h-16 w-full items-center gap-[17px] border-(--home-line) border-b py-3 pr-[15px] pl-2 text-left text-(--home-muted) text-[17px] [transition:color_160ms_ease,background-color_160ms_ease,transform_120ms_cubic-bezier(0.23,1,0.32,1)] hover:not-disabled:bg-[#ffffff08] hover:not-disabled:text-(--home-ink) aria-pressed:bg-[#ffffff04] aria-pressed:text-(--home-ink) aria-pressed:hover:not-disabled:bg-[#ffffff08] ${buttonState}`}
className={`group flex min-h-16 w-full items-center gap-[17px] border-(--home-line) border-b py-3 pr-[15px] pl-2 text-left text-(--home-muted) text-[17px] [transition:color_160ms_ease,background-color_160ms_ease,transform_120ms_var(--ease-out-strong)] hover:not-disabled:bg-[#ffffff08] hover:not-disabled:text-(--home-ink) aria-pressed:bg-[#ffffff04] aria-pressed:text-(--home-ink) aria-pressed:hover:not-disabled:bg-[#ffffff08] ${buttonState}`}
aria-pressed={format === item.id}
disabled={busy}
onClick={() => setFormat(item.id)}
@@ -253,7 +253,7 @@ export default function ExportPlayground({ name, accent, typeface, template }: E
</p>
<button
type="button"
className={`flex min-h-14 w-full items-center justify-between gap-5 rounded border border-transparent bg-(--home-export-color) px-5 py-4 font-[550] text-[#252329] text-[15px] [transition:transform_120ms_cubic-bezier(0.23,1,0.32,1),opacity_160ms_ease] hover:not-disabled:opacity-90 ${buttonState}`}
className={`flex min-h-14 w-full items-center justify-between gap-5 rounded border border-transparent bg-(--home-export-color) px-5 py-4 font-[550] text-[#252329] text-[15px] [transition:transform_120ms_var(--ease-out-strong),opacity_160ms_ease] hover:not-disabled:opacity-90 ${buttonState}`}
disabled={busy}
aria-describedby={descriptionId}
onClick={() => void download()}
@@ -19,11 +19,11 @@ import { cn } from "@reactive-resume/utils/style";
import { textLink } from "./classes";
const demoClass =
"min-h-[492px] animate-[home-feature-enter_220ms_cubic-bezier(0.23,1,0.32,1)_both] px-[30px] py-6 group-data-[instant=true]/explorer:animate-none max-[1100px]:p-[23px] max-[900px]:p-[27px] max-[540px]:px-4 max-[540px]:py-5";
"min-h-[492px] animate-[home-feature-enter_220ms_var(--ease-out-strong)_both] px-[30px] py-6 group-data-[instant=true]/explorer:animate-none max-[1100px]:p-[23px] max-[900px]:p-[27px] max-[540px]:px-4 max-[540px]:py-5";
const demoTopClass = "flex items-center justify-between gap-5 text-[12px] text-(--home-muted)";
const demoCaptionClass = "mt-[25px] text-[12px] leading-[1.7] text-(--home-muted) max-[540px]:text-[11px]";
const pressTransition =
"[transition:transform_140ms_cubic-bezier(0.23,1,0.32,1),background-color_180ms_ease,color_180ms_ease] active:transform-[scale(0.97)]";
"[transition:transform_140ms_var(--ease-out-strong),background-color_180ms_ease,color_180ms_ease] active:transform-[scale(0.97)]";
const jobCardClass =
"min-h-[191px] rounded-[4px] border px-3 py-[14px] max-[540px]:min-h-[183px] max-[540px]:px-2 max-[540px]:py-[13px]";
const toolBoxClass =
@@ -282,7 +282,7 @@ export function FeatureExplorer() {
/>
))}
<div
className={`${jobCardClass} transform-[translateX(calc(var(--stage)_*_(100%_+_var(--home-board-gap))))_rotate(calc(var(--stage)_*_3deg_-_2deg))] absolute top-0 left-0 w-[calc((100%_-_2_*_var(--home-board-gap))_/_3)] border-[#4b4b51] bg-[#2a2a2e] text-(--home-ink) shadow-[0_5px_12px_#0002] [transition:transform_460ms_cubic-bezier(0.23,1,0.32,1)] group-data-[instant=true]/explorer:transition-none`}
className={`${jobCardClass} transform-[translateX(calc(var(--stage)_*_(100%_+_var(--home-board-gap))))_rotate(calc(var(--stage)_*_3deg_-_2deg))] absolute top-0 left-0 w-[calc((100%_-_2_*_var(--home-board-gap))_/_3)] border-[#4b4b51] bg-[#2a2a2e] text-(--home-ink) shadow-[0_5px_12px_#0002] [transition:transform_460ms_var(--ease-out-strong)] group-data-[instant=true]/explorer:transition-none`}
>
<div className="mb-4 grid size-[30px] place-items-center rounded-[4px] bg-[#d8c4ac] font-[Georgia,serif] font-semibold text-[#34302a] text-[19px] leading-none">
n.
@@ -30,7 +30,7 @@ const headingClass = `${wordClass} mb-[15px] font-semibold text-[13px] leading-[
const paperClass =
"language-paper relative mx-auto w-full max-w-[440px] rotate-2 rounded border border-[#e0dcd5] bg-[#efede7] font-[Arial,Noto_Sans,sans-serif] text-[#29272b] text-start shadow-[4px_5px_0_#d5d0c8,5px_6px_0_#77716b,11px_16px_0_-4px_#b9b1a7,10px_28px_42px_#0005]";
const optionClass =
"group/option flex min-h-[62px] items-center justify-between gap-2 rounded border border-[#454345] bg-[#222224] px-[17px] py-[14px] text-[#e3dfda] text-[clamp(16px,1.6vw,21px)] shadow-[0_3px_0_#101011] transition-[background,color,transform,box-shadow] duration-[160ms] ease-[ease] hover:not-aria-pressed:-translate-y-[2px] hover:not-aria-pressed:bg-[#343031] hover:not-aria-pressed:shadow-[0_5px_0_#101011] focus-visible:outline-2 focus-visible:outline-offset-[5px] focus-visible:outline-(--home-accent) active:translate-y-[2px] active:shadow-[0_1px_0_#101011] aria-pressed:translate-y-[2px] aria-pressed:border-[#d0b9a5] aria-pressed:bg-[#d0b9a5] aria-pressed:text-[#24201d] aria-pressed:shadow-[0_1px_0_#101011] group-data-[instant=true]/languages:transition-none max-[420px]:min-h-[56px] max-[420px]:px-[11px] max-[420px]:py-[10px] max-[420px]:text-[16px]";
"group/option flex min-h-[62px] items-center justify-between gap-2 rounded border border-[#454345] bg-[#222224] px-[17px] py-[14px] text-[#e3dfda] text-[clamp(16px,1.6vw,21px)] shadow-[0_3px_0_#101011] transition-[background-color,color,translate,box-shadow] duration-[160ms] ease-[ease] hover:not-aria-pressed:-translate-y-[2px] hover:not-aria-pressed:bg-[#343031] hover:not-aria-pressed:shadow-[0_5px_0_#101011] focus-visible:outline-2 focus-visible:outline-offset-[5px] focus-visible:outline-(--home-accent) active:translate-y-[2px] active:shadow-[0_1px_0_#101011] aria-pressed:translate-y-[2px] aria-pressed:border-[#d0b9a5] aria-pressed:bg-[#d0b9a5] aria-pressed:text-[#24201d] aria-pressed:shadow-[0_1px_0_#101011] group-data-[instant=true]/languages:transition-none max-[420px]:min-h-[56px] max-[420px]:px-[11px] max-[420px]:py-[10px] max-[420px]:text-[16px]";
type LinesProps = { count: 2 | 3; className?: string };
+3 -3
View File
@@ -23,12 +23,12 @@ const ExportPlayground = lazy(() => import("./export-playground"));
const AtsPlayground = lazy(() => import("./ats-playground"));
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
const buttonClass =
"inline-flex min-h-[52px] items-center justify-center gap-3 rounded-[4px] border border-[#f1f0eb] bg-[#f1f0eb] px-[19px] py-[14px] text-[14px] font-[550] text-[#151516] [transition:background-color_150ms_ease,transform_150ms_cubic-bezier(0.23,1,0.32,1)] hover:bg-[#d9d8d2] active:transform-[scale(0.97)] max-[540px]:min-h-[49px] max-[540px]:gap-[18px] max-[540px]:px-[15px] max-[540px]:py-3 max-[540px]:text-[13px]";
"inline-flex min-h-[52px] items-center justify-center gap-3 rounded-[4px] border border-[#f1f0eb] bg-[#f1f0eb] px-[19px] py-[14px] text-[14px] font-[550] text-[#151516] [transition:background-color_150ms_ease,transform_150ms_var(--ease-out-strong)] hover:bg-[#d9d8d2] active:transform-[scale(0.97)] max-[540px]:min-h-[49px] max-[540px]:gap-[18px] max-[540px]:px-[15px] max-[540px]:py-3 max-[540px]:text-[13px]";
const brandClass = "inline-flex shrink-0 items-center gap-[11px] font-[550] tracking-[-0.04em] max-[540px]:gap-2";
const paperLinkClass =
"group/link flex min-h-[65px] items-center gap-[14px] border-t border-[#2d2e3038] text-[14px] [transition:background-color_160ms_ease] focus-visible:outline-[#37383a]";
const paperArrowClass =
"ml-auto [transition:transform_180ms_cubic-bezier(0.23,1,0.32,1)] group-hover/link:transform-[translate(2px,-2px)]";
"ml-auto [transition:transform_180ms_var(--ease-out-strong)] group-hover/link:transform-[translate(2px,-2px)]";
const contributeLinkClass =
"inline-flex min-h-11 items-center gap-2 text-(--home-ink) underline-offset-4 hover:underline";
type DeferredDemoProps = { children: ReactNode };
@@ -91,7 +91,7 @@ export function Homepage() {
>
<Trans>Docs</Trans>
</a>
<div className="[&>a]:hover:[&_svg:last-child]:transform-[rotate(20deg)_scale(1.15)] [&>a>span]:min-w-[6ch] [&>a>span]:border-s [&>a>span]:border-s-[#3d444d] [&>a>span]:ps-2 [&>a>span]:text-center [&>a>span]:font-semibold max-[540px]:[&>a>span]:min-w-0 max-[540px]:[&>a>span]:ps-[6px] [&>a]:inline-flex [&>a]:min-h-[38px] [&>a]:min-w-[128px] [&>a]:items-center [&>a]:gap-2 [&>a]:rounded-[4px] [&>a]:border [&>a]:border-[#3d444d] [&>a]:bg-[#161b22] [&>a]:px-[11px] [&>a]:py-0 [&>a]:text-[#f0f6fc] [&>a]:text-[13px] [&>a]:tabular-nums [&>a]:shadow-[inset_0_1px_0_#ffffff0d,0_3px_0_#09090966] [&>a]:[transition:background-color_180ms_ease,border-color_180ms_ease,box-shadow_180ms_ease] [&>a]:hover:border-[#6e7681] [&>a]:hover:bg-[#21262d] [&>a]:hover:text-(--home-ink) [&>a]:hover:shadow-[inset_0_1px_0_#ffffff14,0_3px_0_#09090966,0_0_18px_#ffffff08] [&>a]:focus-visible:border-[#3d444d] [&>a]:focus-visible:ring-0 max-[540px]:[&>a]:min-w-24 max-[540px]:[&>a]:gap-[5px] max-[540px]:[&>a]:px-[6px] max-[540px]:[&>a]:text-[11px] [&_svg:last-child]:text-[#f0f6fc] [&_svg:last-child]:[transition:transform_220ms_cubic-bezier(0.23,1,0.32,1)] max-[540px]:[&_svg:not([class*='size-'])]:size-[14px]">
<div className="[&>a]:hover:[&_svg:last-child]:transform-[rotate(20deg)_scale(1.15)] [&>a>span]:min-w-[6ch] [&>a>span]:border-s [&>a>span]:border-s-[#3d444d] [&>a>span]:ps-2 [&>a>span]:text-center [&>a>span]:font-semibold max-[540px]:[&>a>span]:min-w-0 max-[540px]:[&>a>span]:ps-[6px] [&>a]:inline-flex [&>a]:min-h-[38px] [&>a]:min-w-[128px] [&>a]:items-center [&>a]:gap-2 [&>a]:rounded-[4px] [&>a]:border [&>a]:border-[#3d444d] [&>a]:bg-[#161b22] [&>a]:px-[11px] [&>a]:py-0 [&>a]:text-[#f0f6fc] [&>a]:text-[13px] [&>a]:tabular-nums [&>a]:shadow-[inset_0_1px_0_#ffffff0d,0_3px_0_#09090966] [&>a]:[transition:background-color_180ms_ease,border-color_180ms_ease,box-shadow_180ms_ease] [&>a]:hover:border-[#6e7681] [&>a]:hover:bg-[#21262d] [&>a]:hover:text-(--home-ink) [&>a]:hover:shadow-[inset_0_1px_0_#ffffff14,0_3px_0_#09090966,0_0_18px_#ffffff08] [&>a]:focus-visible:border-[#3d444d] [&>a]:focus-visible:ring-0 max-[540px]:[&>a]:min-w-24 max-[540px]:[&>a]:gap-[5px] max-[540px]:[&>a]:px-[6px] max-[540px]:[&>a]:text-[11px] [&_svg:last-child]:text-[#f0f6fc] [&_svg:last-child]:[transition:transform_220ms_var(--ease-out-strong)] max-[540px]:[&_svg:not([class*='size-'])]:size-[14px]">
<GithubStarsButton />
</div>
</nav>
@@ -1,7 +1,7 @@
import type { CSSProperties, PointerEvent } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { motion, useMotionTemplate, useReducedMotion, useSpring } from "motion/react";
import { m, useMotionTemplate, useReducedMotion, useSpring } from "motion/react";
import { useId, useRef, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import "./resume-sculpture.css";
@@ -16,7 +16,7 @@ const focusRing =
const dockLabel = "mb-[7px] block text-[11px] leading-[1.4] text-[#a5a5ab]";
const dockButton =
"relative grid min-h-[44px] min-w-[44px] cursor-pointer place-items-center bg-transparent text-[12px] text-[#a5a5ab] [transition:transform_140ms_cubic-bezier(0.23,1,0.32,1),color_150ms_ease]";
"relative grid min-h-[44px] min-w-[44px] cursor-pointer place-items-center bg-transparent text-[12px] text-[#a5a5ab] [transition:transform_140ms_var(--ease-out-strong),color_150ms_ease]";
const templateIcon: Record<SculptureTemplate, string> = {
ditgar: "bg-[linear-gradient(to_right,#aa8ebe_34%,transparent_34%)]",
@@ -110,7 +110,7 @@ export function ResumeSculpture({
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
>
<motion.div
<m.div
className="sculpture-rig group/rig transform-3d absolute inset-0"
data-spread={spread}
data-instant={keyboardAction}
@@ -129,7 +129,7 @@ export function ResumeSculpture({
/>
);
})}
</motion.div>
</m.div>
</div>
</div>
<div className="flex items-center justify-between @max-[450px]:gap-1 gap-3 @max-[450px]:px-0 px-2 pt-[14px] pb-[18px] text-(--home-muted)">
@@ -351,7 +351,7 @@ function ResumePaper({ name, accent, typeface, template, position }: ResumePaper
return (
<article
className={cn(
"sculpture-paper transform-3d absolute top-[2%] left-[24%] aspect-[210/297] w-[56%] origin-[50%_80%] animate-[home-sculpture-assemble_900ms_cubic-bezier(0.23,1,0.32,1)_both] transition-[transform] duration-[650ms] ease-[cubic-bezier(0.23,1,0.32,1)] group-data-[instant=true]/rig:transition-none",
"sculpture-paper transform-3d absolute top-[2%] left-[24%] aspect-[210/297] w-[56%] origin-[50%_80%] animate-[home-sculpture-assemble_900ms_var(--ease-out-strong)_both] transition-[transform] duration-[650ms] ease-out-strong group-data-[instant=true]/rig:transition-none",
paperTransform[position],
)}
data-template={template}
@@ -12,6 +12,7 @@ import {
import { Link } from "@tanstack/react-router";
import { m } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { wrap } from "./classes";
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
@@ -73,7 +74,7 @@ const getSocialLinks = (): SocialLink[] => [
const linkClass =
"group/link inline-flex min-h-9 items-center gap-1 text-(--home-muted) text-[14px] [transition:color_150ms_ease] hover:text-(--home-ink)";
const arrowClass =
"opacity-0 [transition:opacity_180ms_ease,transform_180ms_cubic-bezier(0.23,1,0.32,1)] group-hover/link:transform-[translate(1px,-1px)] group-hover/link:opacity-60";
"opacity-0 [transition:opacity_180ms_ease,transform_180ms_var(--ease-out-strong)] group-hover/link:transform-[translate(1px,-1px)] group-hover/link:opacity-60";
const socialClass =
"inline-grid size-10 place-items-center text-(--home-muted) [transition:color_160ms_ease] hover:text-(--home-ink)";
const metaLinkClass = "text-(--home-ink) underline-offset-[3px] hover:underline";
@@ -201,7 +202,7 @@ export function SiteFooter() {
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.3 }}
transition={{ duration: 0.7, ease: [0.23, 1, 0.32, 1] }}
transition={{ duration: 0.7, ease: EASE_OUT_STRONG }}
>
Reactive Resume
</m.p>
-10
View File
@@ -49,13 +49,3 @@
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.homepage *,
.homepage *::before,
.homepage *::after {
animation: none;
transition: none;
scroll-behavior: auto;
}
}
@@ -11,7 +11,7 @@ type TemplateShelfProps = { template: Template; onChange: (template: Template) =
const templates = templateSchema.options;
const displayName = (name: string) => name[0].toUpperCase() + name.slice(1);
const roundLinkClass =
"inline-flex size-[46px] shrink-0 items-center justify-center rounded-full border border-[#444447] bg-transparent text-(--home-ink) [transition:background-color_150ms_ease,transform_150ms_cubic-bezier(0.23,1,0.32,1)] hover:bg-[#2c2c2f] active:transform-[scale(0.97)] max-[540px]:size-11";
"inline-flex size-[46px] shrink-0 items-center justify-center rounded-full border border-[#444447] bg-transparent text-(--home-ink) [transition:background-color_150ms_ease,transform_150ms_var(--ease-out-strong)] hover:bg-[#2c2c2f] active:transform-[scale(0.97)] max-[540px]:size-11";
export function TemplateShelf({ template, onChange }: TemplateShelfProps) {
const index = templates.indexOf(template);
@@ -35,7 +35,7 @@ export function TemplateShelf({ template, onChange }: TemplateShelfProps) {
<button
key={item}
type="button"
className="transform-[translateX(calc(var(--position)_*_var(--home-paper-step)))_translateY(calc(var(--distance)_*_15px))_translateZ(calc(var(--distance)_*_-80px))_rotateY(calc(var(--position)_*_-12deg))_rotateZ(calc(var(--position)_*_2deg))] absolute top-5 left-[calc(50%_-_var(--home-paper-width)_/_2)] z-[calc(10_-_var(--distance))] aspect-[510/720] w-(--home-paper-width) rounded-[2px] border border-[#dedbd3] bg-[#eee] shadow-[0_25px_28px_#0004,0_2px_1px_#0006] brightness-[calc(1_-_var(--distance)_*_0.18)] [transition:transform_550ms_cubic-bezier(0.23,1,0.32,1),filter_350ms_ease,opacity_220ms_ease] not-aria-pressed:hover:brightness-95 aria-hidden:invisible aria-hidden:opacity-0 group-data-[instant=true]/shelf:transition-none"
className="transform-[translateX(calc(var(--position)_*_var(--home-paper-step)))_translateY(calc(var(--distance)_*_15px))_translateZ(calc(var(--distance)_*_-80px))_rotateY(calc(var(--position)_*_-12deg))_rotateZ(calc(var(--position)_*_2deg))] absolute top-5 left-[calc(50%_-_var(--home-paper-width)_/_2)] z-[calc(10_-_var(--distance))] aspect-[510/720] w-(--home-paper-width) rounded-[2px] border border-[#dedbd3] bg-[#eee] shadow-[0_25px_28px_#0004,0_2px_1px_#0006] brightness-[calc(1_-_var(--distance)_*_0.18)] [transition:transform_550ms_var(--ease-out-strong),filter_350ms_ease,opacity_220ms_ease,visibility_220ms] not-aria-pressed:hover:brightness-95 aria-hidden:invisible aria-hidden:opacity-0 group-data-[instant=true]/shelf:transition-none"
aria-label={t`Choose ${displayName(item)}`}
aria-pressed={item === template}
aria-hidden={Math.abs(offset) > 2}
@@ -26,10 +26,15 @@ export function getVisibleLeftSidebarSections(data: ResumeData): LeftSidebarSect
);
}
/** Smooth scrolling unless the user asked for reduced motion. */
export function getScrollBehavior(): ScrollBehavior {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth";
}
function focusSidebarSection(sectionId: string): void {
const editorTarget = document.getElementById(`sidebar-${sectionId}`);
if (editorTarget) {
editorTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
editorTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
return;
}
@@ -39,7 +44,7 @@ function focusSidebarSection(sectionId: string): void {
if (!recoveryTarget) return;
recoveryTarget.focus({ preventScroll: true });
recoveryTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
recoveryTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
};
const trigger = document.getElementById("sidebar-hidden-sections-trigger");
@@ -9,6 +9,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { isRTL } from "@reactive-resume/utils/locale";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { usePreviewPausedStore, useResumeData } from "../builder/draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { ResumePreviewLoader } from "./preview.shared";
@@ -26,7 +27,10 @@ type PreviewPdf = {
};
const UPDATE_DEBOUNCE_MS = 100;
const CROSSFADE_DURATION_MS = 180;
// Incoming layer fades in over the old one; the old layer holds at full opacity until the incoming one is opaque,
// then drops out. Fading both at once dips the page towards the background mid-swap.
const INCOMING_TRANSITION = { duration: 0.15, ease: EASE_OUT_STRONG };
const EXITING_TRANSITION = { duration: 0.1, delay: 0.18 };
const createPreviewPdf = (file: Blob, id: number, hasExistingPreview: boolean, template: Template): PreviewPdf => ({
file,
@@ -189,7 +193,7 @@ export function ResumePreviewClient({
initial={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
animate={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
exit={{ opacity: 0 }}
transition={{ duration: CROSSFADE_DURATION_MS / 1000, ease: "easeOut" }}
transition={visiblePdf.phase === "exiting" ? EXITING_TRANSITION : INCOMING_TRANSITION}
onAnimationComplete={() => {
if (visiblePdf.phase !== "exiting") return;
setPreviewLayers((current) => removePreviewLayer(current, visiblePdf.id));
@@ -65,7 +65,10 @@ export function ResumePreviewLoader({
role="img"
aria-label={`Loading resume page ${pageNumber} of ${pageCount}`}
style={pageSize}
className={cn("aspect-page overflow-hidden rounded-md bg-white", pageClassName)}
className={cn(
"flex aspect-page items-center justify-center overflow-hidden rounded-md bg-white text-neutral-400",
pageClassName,
)}
>
<Spinner className="size-10" />
</div>
@@ -1,6 +1,5 @@
import type { ResumePreviewProps } from "./preview.shared";
import { lazy, Suspense } from "react";
import { useIsClient } from "usehooks-ts";
import { useResumeData } from "../builder/draft";
import { ResumePreviewLoader } from "./preview.shared";
import { getResumePreviewPageCount } from "./preview.shared.utils";
@@ -19,13 +18,10 @@ export function ResumePreview({
showPageNumbers = false,
...rest
}: ResumePreviewProps) {
const isClient = useIsClient();
const builderResumeData = useResumeData();
const resumeData = rest.data ?? builderResumeData;
const pageCount = getResumePreviewPageCount(resumeData);
if (!isClient) return null;
const resolvedProps = { ...rest, pageGap, pageLayout, pageScale, showPageNumbers };
return (
@@ -89,7 +89,7 @@ export function PublicResumePage({ resume, username, slug, flags, isRoot = false
onClick={() => void onDownloadPDF()}
aria-label={t`Download PDF`}
title={t`Download PDF`}
className="fixed right-6 bottom-6 z-50 rounded-full bg-background/95 opacity-70 shadow-lg backdrop-blur transition-opacity hover:opacity-100 print:hidden"
className="fixed right-6 bottom-6 z-50 rounded-full bg-background/95 opacity-70 shadow-lg backdrop-blur transition-[opacity,scale] duration-150 ease-out-strong hover:opacity-100 print:hidden"
>
{isExporting ? (
<CircleNotchIcon className="size-5 animate-spin" />
@@ -28,9 +28,8 @@ const builder = vi.hoisted(() => ({
redo: vi.fn(),
}));
vi.mock("usehooks-ts", async (importOriginal) => ({
...(await importOriginal<typeof import("usehooks-ts")>()),
useMediaQuery: () => media.mobile,
vi.mock("@reactive-resume/ui/hooks/use-mobile", () => ({
useIsMobile: () => media.mobile,
}));
vi.mock("@/features/theme/provider", () => ({
@@ -20,7 +20,6 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { BookOpenIcon } from "@phosphor-icons/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMediaQuery } from "usehooks-ts";
import { convertLegacyStyleRules } from "@reactive-resume/pdf/semantic-legacy";
import {
buildSemanticTree,
@@ -31,6 +30,7 @@ import {
import { isFatalStylesheetDiagnostic } from "@reactive-resume/resume/stylesheet";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { useIsMobile } from "@reactive-resume/ui/hooks/use-mobile";
import { ColorPicker } from "@/components/input/color-picker";
import { useIsResumeLocked, useResumeData, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useTheme } from "@/features/theme/provider";
@@ -382,7 +382,7 @@ const createEditorMetadata = (data: ResumeData): SemanticCssEditorMetadata => {
function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps) {
const { theme } = useTheme();
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
const isMobile = useIsMobile();
const [focusOpen, setFocusOpen] = useState(false);
const [diagnostics, setDiagnostics] = useState<readonly SemanticCssDiagnostic[]>([]);
const [colorTokens, setColorTokens] = useState<readonly SemanticCssColorToken[]>([]);
@@ -1,19 +0,0 @@
import type { ReactNode } from "react";
import { m } from "motion/react";
type ActionButtonProps = {
children: ReactNode;
};
export function ActionButton({ children }: ActionButtonProps) {
return (
<m.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
{children}
</m.div>
);
}
@@ -2,7 +2,6 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { KeyIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { m } from "motion/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { toast } from "@reactive-resume/ui/components/toast";
@@ -112,12 +111,7 @@ export function PasskeysSection() {
};
return (
<m.div
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.3, ease: "easeOut" }}
className="will-change-[transform,opacity]"
>
<div>
<Separator />
<div className="mt-4 grid gap-3">
@@ -166,6 +160,6 @@ export function PasskeysSection() {
</div>
)}
</div>
</m.div>
</div>
);
}
@@ -1,10 +1,8 @@
import { Trans } from "@lingui/react/macro";
import { PasswordIcon, PencilSimpleLineIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { m } from "motion/react";
import { Button } from "@reactive-resume/ui/components/button";
import { useDialogStore } from "@/dialogs/store";
import { ActionButton } from "./action-button";
import { useAuthAccounts } from "./hooks";
export function PasswordSection() {
@@ -14,35 +12,28 @@ export function PasswordSection() {
const hasPassword = hasAccount("credential");
return (
<m.div
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.1, ease: "easeOut" }}
className="flex items-center justify-between gap-x-4 will-change-[transform,opacity]"
>
<div className="flex items-center justify-between gap-x-4">
<h2 className="flex items-center gap-x-3 font-medium text-base">
<PasswordIcon />
<Trans>Password</Trans>
</h2>
<ActionButton>
{hasPassword ? (
<Button variant="outline" onClick={() => openDialog("auth.change-password", undefined)}>
<PencilSimpleLineIcon />
<Trans>Update Password</Trans>
</Button>
) : (
<Button
variant="outline"
nativeButton={false}
render={
<Link to="/auth/forgot-password">
<Trans>Set Password</Trans>
</Link>
}
/>
)}
</ActionButton>
</m.div>
{hasPassword ? (
<Button variant="outline" onClick={() => openDialog("auth.change-password", undefined)}>
<PencilSimpleLineIcon />
<Trans>Update Password</Trans>
</Button>
) : (
<Button
variant="outline"
nativeButton={false}
render={
<Link to="/auth/forgot-password">
<Trans>Set Password</Trans>
</Link>
}
/>
)}
</div>
);
}
@@ -1,19 +1,16 @@
import type { AuthProvider } from "@reactive-resume/auth/types";
import { Trans } from "@lingui/react/macro";
import { LinkBreakIcon, LinkIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { ActionButton } from "./action-button";
import { getProviderIcon, getProviderName, useAuthAccounts, useAuthProviderActions } from "./hooks";
type SocialProviderSectionProps = {
provider: AuthProvider;
name?: string;
animationDelay?: number;
};
export function SocialProviderSection({ provider, name, animationDelay = 0 }: SocialProviderSectionProps) {
export function SocialProviderSection({ provider, name }: SocialProviderSectionProps) {
const { link, unlink } = useAuthProviderActions();
const { hasAccount, getAccountByProviderId } = useAuthAccounts();
@@ -24,12 +21,7 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
const isConnected = hasAccount(provider);
return (
<m.div
className="will-change-[transform,opacity]"
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: animationDelay, ease: "easeOut" }}
>
<div>
<Separator />
<div className="mt-4 flex items-center justify-between gap-x-4">
@@ -38,27 +30,25 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
{providerName}
</h2>
<ActionButton>
{isConnected ? (
<Button
variant="outline"
onClick={() => {
if (account?.accountId) void unlink(provider, account.accountId);
}}
>
<LinkBreakIcon />
<Trans comment="Authentication settings action to unlink a connected social login provider">
Disconnect
</Trans>
</Button>
) : (
<Button variant="outline" onClick={() => void link(provider)}>
<LinkIcon />
<Trans comment="Authentication settings action to link a social login provider">Connect</Trans>
</Button>
)}
</ActionButton>
{isConnected ? (
<Button
variant="outline"
onClick={() => {
if (account?.accountId) void unlink(provider, account.accountId);
}}
>
<LinkBreakIcon />
<Trans comment="Authentication settings action to unlink a connected social login provider">
Disconnect
</Trans>
</Button>
) : (
<Button variant="outline" onClick={() => void link(provider)}>
<LinkIcon />
<Trans comment="Authentication settings action to link a social login provider">Connect</Trans>
</Button>
)}
</div>
</m.div>
</div>
);
}
@@ -1,11 +1,9 @@
import { Trans } from "@lingui/react/macro";
import { KeyIcon, LockOpenIcon, ToggleLeftIcon, ToggleRightIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { useDialogStore } from "@/dialogs/store";
import { authClient } from "@/libs/auth/client";
import { ActionButton } from "./action-button";
import { useAuthAccounts } from "./hooks";
export function TwoFactorSection() {
@@ -19,12 +17,7 @@ export function TwoFactorSection() {
if (!hasPassword) return null;
return (
<m.div
className="will-change-[transform,opacity]"
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }}
>
<div>
<Separator />
<div className="mt-4 flex items-center justify-between gap-x-4">
@@ -33,25 +26,23 @@ export function TwoFactorSection() {
<Trans>Two-Factor Authentication</Trans>
</h2>
<ActionButton>
<Button
variant="outline"
onClick={() => openDialog(hasTwoFactor ? "auth.two-factor.disable" : "auth.two-factor.enable", undefined)}
>
{hasTwoFactor ? (
<>
<ToggleLeftIcon />
<Trans>Disable 2FA</Trans>
</>
) : (
<>
<ToggleRightIcon />
<Trans>Enable 2FA</Trans>
</>
)}
</Button>
</ActionButton>
<Button
variant="outline"
onClick={() => openDialog(hasTwoFactor ? "auth.two-factor.disable" : "auth.two-factor.enable", undefined)}
>
{hasTwoFactor ? (
<>
<ToggleLeftIcon />
<Trans>Disable 2FA</Trans>
</>
) : (
<>
<ToggleRightIcon />
<Trans>Enable 2FA</Trans>
</>
)}
</Button>
</div>
</m.div>
</div>
);
}
@@ -1,4 +1,3 @@
import { m } from "motion/react";
import { useEnabledProviders } from "./components/hooks";
import { PasskeysSection } from "./components/passkeys";
import { PasswordSection } from "./components/password";
@@ -9,27 +8,20 @@ export function AuthenticationSettingsPage() {
const { enabledProviders } = useEnabledProviders();
return (
<m.div
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-4 will-change-[transform,opacity]"
>
<div className="grid max-w-xl gap-4">
<PasswordSection />
<TwoFactorSection />
<PasskeysSection />
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.04} />}
{"google" in enabledProviders && <SocialProviderSection provider="google" />}
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.08} />}
{"github" in enabledProviders && <SocialProviderSection provider="github" />}
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.12} />}
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" />}
{"custom" in enabledProviders && (
<SocialProviderSection provider="custom" animationDelay={0.16} name={enabledProviders.custom} />
)}
</m.div>
{"custom" in enabledProviders && <SocialProviderSection provider="custom" name={enabledProviders.custom} />}
</div>
);
}
@@ -1,20 +1,9 @@
import { m } from "motion/react";
import { useIsClient } from "usehooks-ts";
import { AISettingsSection } from "./components/ai-section";
export function IntegrationsSettingsPage() {
const isClient = useIsClient();
if (!isClient) return null;
return (
<m.div
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-4xl gap-8 will-change-[transform,opacity]"
>
<div className="grid max-w-4xl gap-8">
<AISettingsSection />
</m.div>
</div>
);
}
@@ -3,7 +3,6 @@ import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { m } from "motion/react";
import { useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
@@ -20,7 +19,6 @@ export function AccountSettingsPage() {
const confirm = useConfirm();
const navigate = useNavigate();
const [confirmationText, setConfirmationText] = useState("");
const isConfirmationValid = confirmationText === CONFIRMATION_TEXT;
const { mutate: deleteAccount } = useMutation(orpc.auth.deleteAccount.mutationOptions());
@@ -86,28 +84,21 @@ export function AccountSettingsPage() {
};
return (
<m.div
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<div className="grid max-w-xl gap-6">
<div className="grid gap-3">
<p className="leading-relaxed">
<Trans>Download a copy of all your data, including your profile and every resume, as a JSON file.</Trans>
</p>
<m.div
className="justify-self-start will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.14, ease: "easeOut" }}
<Button
variant="outline"
className="justify-self-start"
onClick={() => exportData(undefined)}
disabled={isExporting}
>
<Button variant="outline" onClick={() => exportData(undefined)} disabled={isExporting}>
<DownloadSimpleIcon />
<Trans>Export my data</Trans>
</Button>
</m.div>
<DownloadSimpleIcon />
<Trans>Export my data</Trans>
</Button>
</div>
<hr className="border-border" />
@@ -123,17 +114,15 @@ export function AccountSettingsPage() {
placeholder={t`Type "${CONFIRMATION_TEXT}" to confirm`}
/>
<m.div
className="justify-self-end will-change-transform"
whileHover={!isConfirmationValid ? undefined : { y: -1, scale: 1.01 }}
whileTap={!isConfirmationValid ? undefined : { scale: 0.98 }}
transition={{ duration: 0.14, ease: "easeOut" }}
<Button
variant="destructive"
className="justify-self-end"
onClick={handleDeleteAccount}
disabled={confirmationText !== CONFIRMATION_TEXT}
>
<Button variant="destructive" onClick={handleDeleteAccount} disabled={!isConfirmationValid}>
<TrashSimpleIcon />
<Trans>Delete Account</Trans>
</Button>
</m.div>
</m.div>
<TrashSimpleIcon />
<Trans>Delete Account</Trans>
</Button>
</div>
);
}
@@ -2,7 +2,6 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { BookOpenIcon, KeyIcon, LinkSimpleIcon, PlusIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, m } from "motion/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { toast } from "@reactive-resume/ui/components/toast";
@@ -67,12 +66,7 @@ export function ApiKeysSettingsPage() {
};
return (
<m.div
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<div className="grid max-w-xl gap-6">
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
<div className="rounded-md bg-primary/10 p-2.5">
<BookOpenIcon className="text-primary" size={24} />
@@ -115,43 +109,27 @@ export function ApiKeysSettingsPage() {
<Trans>Create a new API key</Trans>
</Button>
<AnimatePresence initial={false} mode="popLayout">
{apiKeys.map((key, index) => (
<m.div
key={key.id}
className="flex items-center gap-x-4 py-4 will-change-[transform,opacity]"
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.16, delay: Math.min(0.12, index * 0.04) }}
>
<KeyIcon />
{apiKeys.map((key) => (
<div key={key.id} className="flex items-center gap-x-4 py-4">
<KeyIcon />
<div className="flex-1 space-y-1">
<p className="font-mono text-xs">{key.start}...</p>
<div className="text-muted-foreground text-xs">
{key.expiresAt ? (
<Trans>Expires on {key.expiresAt.toLocaleDateString()}</Trans>
) : (
<Trans>Never expires</Trans>
)}
</div>
<div className="flex-1 space-y-1">
<p className="font-mono text-xs">{key.start}...</p>
<div className="text-muted-foreground text-xs">
{key.expiresAt ? (
<Trans>Expires on {key.expiresAt.toLocaleDateString()}</Trans>
) : (
<Trans>Never expires</Trans>
)}
</div>
</div>
<m.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.03 }}
whileTap={{ scale: 0.96 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
<TrashSimpleIcon />
</Button>
</m.div>
</m.div>
))}
</AnimatePresence>
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
<TrashSimpleIcon />
</Button>
</div>
))}
</div>
</m.div>
</div>
);
}
@@ -1,6 +1,5 @@
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon } from "@phosphor-icons/react";
import { m } from "motion/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
import { LocaleCombobox } from "@/features/locale/combobox";
@@ -8,12 +7,7 @@ import { ThemeCombobox } from "@/features/theme/combobox";
export function PreferencesSettingsPage() {
return (
<m.div
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<div className="grid max-w-xl gap-6">
<div className="grid gap-1.5">
<Label className="mb-0.5">
<Trans>Theme</Trans>
@@ -39,6 +33,6 @@ export function PreferencesSettingsPage() {
}
/>
</div>
</m.div>
</div>
);
}
@@ -12,6 +12,7 @@ import { Input } from "@reactive-resume/ui/components/input";
import { toast } from "@reactive-resume/ui/components/toast";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { useAppForm } from "@/libs/tanstack-form";
const formSchema = z.object({
@@ -136,11 +137,8 @@ export function ProfileSettingsPage({ session }: Props) {
};
return (
<m.form
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
<form
className="grid max-w-xl gap-6"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -258,8 +256,8 @@ export function ProfileSettingsPage({ session }: Props) {
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="flex items-center gap-x-4 justify-self-end will-change-[transform,opacity]"
transition={{ duration: 0.16, ease: EASE_OUT_STRONG }}
className="flex items-center gap-x-4 justify-self-end"
>
<Button type="reset" variant="ghost" onClick={onCancel}>
<Trans comment="Profile settings form action to discard unsaved edits">Cancel</Trans>
@@ -271,6 +269,6 @@ export function ProfileSettingsPage({ session }: Props) {
</m.div>
)}
</AnimatePresence>
</m.form>
</form>
);
}
+18 -2
View File
@@ -12,19 +12,35 @@ type ThemeContextValue = {
const ThemeContext = createContext<ThemeContextValue | null>(null);
// Swap the theme class with transitions suppressed for one frame, so every color changes at once
// instead of each element easing between palettes on its own schedule.
function applyThemeClass(theme: Theme) {
const root = document.documentElement;
const isDark = theme === "dark";
if (root.classList.contains("dark") === isDark) return;
const style = document.createElement("style");
style.textContent = "*,*::before,*::after{transition:none!important}";
document.head.append(style);
root.classList.toggle("dark", isDark);
void window.getComputedStyle(root).color; // force a style flush while transitions are off
setTimeout(() => style.remove(), 1); // rAF would never fire in a hidden tab, leaving transitions off
}
type Props = PropsWithChildren<{ theme: Theme }>;
export function ThemeProvider({ children, theme }: Props) {
const router = useRouter();
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
applyThemeClass(theme);
}, [theme]);
async function setTheme(value: Theme, options: { playSound?: boolean } = {}) {
const { playSound = true } = options;
document.documentElement.classList.toggle("dark", value === "dark");
applyThemeClass(value);
setThemeCookie(value);
void router.invalidate();
@@ -4,7 +4,6 @@ import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { PaletteIcon, SignOutIcon, TranslateIcon } from "@phosphor-icons/react";
import { useRouter } from "@tanstack/react-router";
import { useIsClient } from "usehooks-ts";
import {
DropdownMenu,
DropdownMenuContent,
@@ -30,7 +29,6 @@ type Props = {
};
export function UserDropdownMenu({ children }: Props) {
const isClient = useIsClient();
const router = useRouter();
const { i18n } = useLingui();
const { theme, setTheme } = useTheme();
@@ -67,7 +65,6 @@ export function UserDropdownMenu({ children }: Props) {
});
};
if (!isClient) return null;
if (!session?.user) return null;
return (
+12
View File
@@ -1 +1,13 @@
@import "@reactive-resume/ui/globals.css";
@keyframes spotlight-drift {
to {
translate: var(--spotlight-drift);
}
}
@keyframes agent-prompt-marquee {
to {
transform: translateX(-33.333%);
}
}
+2
View File
@@ -0,0 +1,2 @@
// Mirrors `--ease-out-strong` in packages/ui/src/styles/globals.css for Motion (JS) animations.
export const EASE_OUT_STRONG = [0.23, 1, 0.32, 1] as const;
-1
View File
@@ -15,7 +15,6 @@ export const getRouter = async () => {
const router = createRouter({
routeTree,
scrollRestoration: true,
defaultViewTransition: true,
defaultStructuralSharing: true,
defaultErrorComponent: ErrorScreen,
defaultPendingComponent: LoadingScreen,
+2 -2
View File
@@ -15,7 +15,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
import { createRootRouteWithContext, HeadContent, Outlet, useRouterState } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { domAnimation, LazyMotion, MotionConfig } from "motion/react";
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";
@@ -110,7 +110,7 @@ function RootComponent() {
<QueryClientProvider client={queryClient}>
<MotionConfig reducedMotion="user">
<LazyMotion features={domAnimation}>
<LazyMotion features={domMax}>
<I18nProvider i18n={i18n}>
<IconContext.Provider value={iconContextValue}>
<ThemeProvider theme={theme}>
+4 -16
View File
@@ -3,10 +3,10 @@ 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 { useState } from "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;
@@ -48,11 +48,11 @@ export function Footer() {
return (
<m.footer
id="footer"
className="p-4 pb-8 will-change-[opacity] md:p-8 md:pb-12"
className="p-4 pb-8 md:p-8 md:pb-12"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
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 */}
@@ -121,31 +121,19 @@ function FooterLinkGroup({ title, links }: FooterLinkGroupProps) {
}
function FooterLink({ url, label }: FooterLinkItem) {
const [isHovered, setIsHovered] = useState(false);
return (
<li className="relative">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="relative inline-block text-sm transition-colors hover:text-foreground"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
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-primary after:content-[''] hover:text-foreground 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>
<m.div
aria-hidden="true"
initial={{ width: 0, opacity: 0 }}
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary will-change-[width,opacity]"
/>
</a>
</li>
);
+11 -37
View File
@@ -1,8 +1,8 @@
import { t } from "@lingui/core/macro";
import { ArrowRightIcon, TranslateIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { m, useMotionValue, useSpring } from "motion/react";
import { useEffect, useRef } from "react";
import { useMotionValueEvent, useScroll } from "motion/react";
import { useState } from "react";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
import { GithubStarsButton } from "@/components/input/github-stars-button";
@@ -10,43 +10,17 @@ import { LocaleCombobox } from "@/features/locale/combobox";
import { ThemeToggleButton } from "@/features/theme/toggle-button";
export function Header() {
const y = useMotionValue(0);
const lastScroll = useRef(0);
const ticking = useRef(false);
const springY = useSpring(y, { stiffness: 300, damping: 40 });
const [hidden, setHidden] = useState(false);
const { scrollY } = useScroll();
useEffect(() => {
if (typeof window === "undefined") return;
function onScroll() {
const current = window.scrollY ?? 0;
if (!ticking.current) {
window.requestAnimationFrame(() => {
if (current > 32 && current > lastScroll.current) {
// Scrolling down, hide
y.set(-100);
} else {
// Scrolling up, show
y.set(0);
}
lastScroll.current = current;
ticking.current = false;
});
ticking.current = true;
}
}
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [y]);
useMotionValueEvent(scrollY, "change", (current) => {
setHidden(current > 32 && current > (scrollY.getPrevious() ?? 0));
});
return (
<m.header
style={{ y: springY }}
className="fixed inset-x-0 top-0 z-50 border-transparent border-b bg-background/80 backdrop-blur-lg transition-colors"
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.35, ease: "easeOut" }}
<header
data-hidden={hidden}
className="fade-in animation-duration-300 fixed inset-x-0 top-0 z-50 animate-in border-transparent border-b bg-background/80 backdrop-blur-lg ease-out-strong [transition:translate_250ms_var(--ease-out-strong)] data-[hidden=true]:-translate-y-full"
>
<nav aria-label={t`Main navigation`} className="container mx-auto flex items-center gap-x-4 p-3 lg:px-12">
<Link to="/" className="transition-opacity hover:opacity-80" aria-label={t`Reactive Resume - Go to homepage`}>
@@ -80,6 +54,6 @@ export function Header() {
</div>
</div>
</nav>
</m.header>
</header>
);
}
+4 -4
View File
@@ -8,6 +8,7 @@ import { Spotlight } from "@/components/animation/spotlight";
import { AiReviewCard } from "@/features/ats-checker/ai-review/ai-review-card";
import { LockedAiCard } from "@/features/ats-checker/ai-review/locked-card";
import { ParsePreview } from "@/features/ats-checker/parse-preview";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { Footer } from "./-sections/footer";
const PAGE_TITLE = "ATS Checker - Reactive Resume";
@@ -91,10 +92,10 @@ function Header() {
<div className="relative grid items-center gap-8 p-4 md:p-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,32rem)] lg:gap-12 xl:py-12">
<m.div
className="space-y-5 will-change-[transform,opacity]"
className="space-y-5"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
transition={{ duration: 0.5, ease: EASE_OUT_STRONG }}
>
<h1 className="font-semibold text-4xl tracking-tight md:text-5xl">
<Trans>ATS Checker</Trans>
@@ -109,10 +110,9 @@ function Header() {
</m.div>
<m.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, delay: 0.12, ease: "easeOut" }}
transition={{ duration: 0.55, delay: 0.12, ease: EASE_OUT_STRONG }}
>
<ParsePreview />
</m.div>
+42 -29
View File
@@ -7,9 +7,9 @@ import { useCallback, useRef, useState } from "react";
import { useMediaQuery } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { cn } from "@reactive-resume/utils/style";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { orpc } from "@/libs/orpc/client";
import { AgentChat } from "./-components/agent-chat";
import { ResumePane } from "./-components/resume-pane";
@@ -62,9 +62,8 @@ function RouteComponent() {
}
}, []);
if (isLoading) return <LoadingScreen />;
if (error || !data) {
// Loading stays local to the chat/resume panes so the thread sidebar remains mounted while switching threads.
if (!isLoading && (error || !data)) {
return (
<div className="grid h-svh place-items-center bg-background p-6 text-center">
<div className="space-y-4">
@@ -79,12 +78,18 @@ function RouteComponent() {
);
}
const readOnlyReason: "archived" | "missing" | null = data.isReadOnly
const readOnlyReason: "archived" | "missing" | null = data?.isReadOnly
? data.thread.status === "archived"
? "archived"
: "missing"
: null;
const loadingPane = (
<div className="grid h-full place-items-center">
<Spinner className="size-6 text-muted-foreground" />
</div>
);
return (
<div className="h-svh min-w-0 overflow-hidden bg-background">
{isDesktopLayout ? (
@@ -104,18 +109,22 @@ function RouteComponent() {
</ResizablePanel>
<ResizableSeparator withHandle />
<ResizablePanel id="chat" defaultSize="52%" minSize="280px">
<AgentChat
threadId={threadId}
initialMessages={data.messages}
isReadOnly={data.isReadOnly}
readOnlyReason={readOnlyReason}
threadStatus={data.thread.status}
reviewPatches={data.thread.reviewPatches}
activeRunId={data.thread.activeRunId}
actions={data.actions}
onToggleThreads={toggleThreadsPanel}
onToggleResume={toggleResumePanel}
/>
{data ? (
<AgentChat
threadId={threadId}
initialMessages={data.messages}
isReadOnly={data.isReadOnly}
readOnlyReason={readOnlyReason}
threadStatus={data.thread.status}
reviewPatches={data.thread.reviewPatches}
activeRunId={data.thread.activeRunId}
actions={data.actions}
onToggleThreads={toggleThreadsPanel}
onToggleResume={toggleResumePanel}
/>
) : (
loadingPane
)}
</ResizablePanel>
<ResizableSeparator withHandle />
<ResizablePanel
@@ -129,7 +138,7 @@ function RouteComponent() {
onResize={(size) => setIsResumeCollapsed(size.inPixels < 24)}
>
<div className={cn("h-full", isResumeCollapsed && "invisible")}>
<ResumePane resume={data.resume} />
{data && <ResumePane resume={data.resume} />}
</div>
</ResizablePanel>
</ResizableGroup>
@@ -159,19 +168,23 @@ function RouteComponent() {
<AgentThreadSidebar activeThreadId={threadId} className="border-e-0" />
</div>
<div className={cn("h-full min-w-0", mobileTab !== "chat" && "hidden")}>
<AgentChat
threadId={threadId}
initialMessages={data.messages}
isReadOnly={data.isReadOnly}
readOnlyReason={readOnlyReason}
threadStatus={data.thread.status}
reviewPatches={data.thread.reviewPatches}
activeRunId={data.thread.activeRunId}
actions={data.actions}
/>
{data ? (
<AgentChat
threadId={threadId}
initialMessages={data.messages}
isReadOnly={data.isReadOnly}
readOnlyReason={readOnlyReason}
threadStatus={data.thread.status}
reviewPatches={data.thread.reviewPatches}
activeRunId={data.thread.activeRunId}
actions={data.actions}
/>
) : (
loadingPane
)}
</div>
<div className={cn("h-full min-w-0", mobileTab !== "resume" && "hidden")}>
<ResumePane resume={data.resume} />
{data && <ResumePane resume={data.resume} />}
</div>
</div>
</div>
@@ -31,7 +31,6 @@ import {
parseJsonEventStream,
uiMessageChunkSchema,
} from "ai";
import { m } from "motion/react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -412,14 +411,12 @@ function StarterPromptMarquee({ onSelect }: StarterPromptMarqueeProps) {
{ id: `${prompt}-repeat-b`, prompt },
]);
const duration = 135 + rowIndex * 22;
const animate = rowIndex % 2 === 0 ? { x: ["0%", "-33.333%"] } : { x: ["-33.333%", "0%"] };
return (
<m.div
<div
key={`prompt-row-${row.join("|")}`}
className="flex w-max gap-3"
animate={animate}
transition={{ duration, ease: "linear", repeat: Number.POSITIVE_INFINITY }}
className="flex w-max animate-[agent-prompt-marquee_135s_linear_infinite] gap-3 focus-within:[animation-play-state:paused] hover:[animation-play-state:paused]"
style={{ animationDuration: `${duration}s`, animationDirection: rowIndex % 2 === 0 ? "normal" : "reverse" }}
>
{marqueePrompts.map(({ id, prompt }) => (
<Button
@@ -432,7 +429,7 @@ function StarterPromptMarquee({ onSelect }: StarterPromptMarqueeProps) {
{promptPreview(prompt)}
</Button>
))}
</m.div>
</div>
);
})}
</div>
@@ -4,7 +4,6 @@ import { ArrowRightIcon, ChatCircleDotsIcon, FilePlusIcon, GearSixIcon } from "@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useIsClient } from "usehooks-ts";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { Label } from "@reactive-resume/ui/components/label";
@@ -29,7 +28,6 @@ function isAgentConfigError(error: unknown) {
}
export function NewThreadSetup({ resumeId }: NewThreadSetupProps) {
const isClient = useIsClient();
const navigate = useNavigate();
const { usableProviders, isLoading: isLoadingProviders, error: providersError } = useHasUsableAiProvider();
const { data: resumes, isLoading: isLoadingResumes } = useQuery(
@@ -54,8 +52,6 @@ export function NewThreadSetup({ resumeId }: NewThreadSetupProps) {
const selectedResumeValue = sourceResumeId ?? "__scratch__";
const canCreate = !!aiProviderId && usableProviders.length > 0;
if (!isClient) return null;
return (
<div className="mx-auto grid w-full max-w-4xl gap-6 self-center p-4 lg:p-6">
<div className="flex items-start gap-4">
@@ -14,7 +14,7 @@ import {
} from "@phosphor-icons/react";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useNavigate } from "@tanstack/react-router";
import { m } from "motion/react";
import { m, useReducedMotion } from "motion/react";
import { useControls, useTransformComponent } from "react-zoom-pan-pinch";
import { useCopyToClipboard } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
@@ -26,13 +26,13 @@ import {
} from "@reactive-resume/ui/components/dropdown-menu";
import { toast } from "@reactive-resume/ui/components/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { cn } from "@reactive-resume/utils/style";
import {
isEditableElementFocused,
useCurrentBuilderResumeSelector,
useResumeStore,
} from "@/features/resume/builder/draft";
import { authClient } from "@/libs/auth/client";
import { EASE_OUT_STRONG } from "@/libs/motion";
type BuilderDockProps = {
pageLayout: BuilderPreviewPageLayout;
@@ -48,13 +48,15 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
const [_, copyToClipboard] = useCopyToClipboard();
const { zoomIn, zoomOut, resetTransform } = useControls();
const zoomDuration = useReducedMotion() ? 0 : 150;
const canUndo = useResumeStore((state) => state.canUndo);
const canRedo = useResumeStore((state) => state.canRedo);
const undo = useResumeStore((state) => state.undo);
const redo = useResumeStore((state) => state.redo);
useHotkey("Mod+0", () => resetTransform());
// Keyboard actions snap instantly; animating a shortcut makes it feel sluggish.
useHotkey("Mod+0", () => resetTransform(0));
// App-level undo/redo of resume state, scoped to the builder. Mod maps to Cmd (mac) / Ctrl (win/linux).
// Inside a focused text field, defer to the browser's native input undo; the dock buttons remain
// available for resume-level history while editing a field.
@@ -77,18 +79,25 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
return (
<div className="fixed inset-x-0 bottom-20 flex items-center justify-center md:bottom-4">
<m.div
initial={{ opacity: 0, y: -18 }}
animate={{ opacity: 0.6, y: 0 }}
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex items-center rounded-r-full rounded-l-full bg-popover px-2 shadow-xl will-change-[transform,opacity]"
initial={{ opacity: 0, transform: "translateY(8px)" }}
animate={{ opacity: 1, transform: "translateY(0px)" }}
transition={{ duration: 0.2, ease: EASE_OUT_STRONG }}
className="flex items-center rounded-r-full rounded-l-full bg-popover px-2 shadow-xl"
>
<DockIcon icon={ArrowUUpLeftIcon} title={t`Undo`} disabled={!canUndo} onClick={() => undo()} />
<DockIcon icon={ArrowUUpRightIcon} title={t`Redo`} disabled={!canRedo} onClick={() => redo()} />
<div className="mx-1 h-8 w-px bg-border" />
<DockIcon icon={MagnifyingGlassMinusIcon} title={t`Zoom out`} onClick={() => zoomOut(0.15)} />
<DockIcon
icon={MagnifyingGlassMinusIcon}
title={t`Zoom out`}
onClick={() => zoomOut(0.15, zoomDuration, "easeOutCubic")}
/>
<ZoomMenu />
<DockIcon icon={MagnifyingGlassPlusIcon} title={t`Zoom in`} onClick={() => zoomIn(0.15)} />
<DockIcon
icon={MagnifyingGlassPlusIcon}
title={t`Zoom in`}
onClick={() => zoomIn(0.15, zoomDuration, "easeOutCubic")}
/>
<DockIcon
icon={pageLayout === "horizontal" ? AlignTopIcon : AlignCenterHorizontalIcon}
title={t`Toggle page stacking`}
@@ -119,6 +128,7 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
function ZoomMenu() {
const scale = useTransformComponent((ctx) => ctx.state.scale);
const { centerView, resetTransform } = useControls();
const zoomDuration = useReducedMotion() ? 0 : 200;
return (
<DropdownMenu>
@@ -136,10 +146,10 @@ function ZoomMenu() {
/>
<DropdownMenuContent side="top" align="center">
<DropdownMenuItem onClick={() => centerView(1)}>
<DropdownMenuItem onClick={() => centerView(1, zoomDuration, "easeOutCubic")}>
<Trans>Actual size (100%)</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => resetTransform()}>
<DropdownMenuItem onClick={() => resetTransform(zoomDuration, "easeOutCubic")}>
<Trans>Fit to view</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -152,34 +162,17 @@ type DockIconProps = {
icon: Icon;
disabled?: boolean;
onClick: () => void;
iconClassName?: string;
active?: boolean;
};
function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName, active }: DockIconProps) {
function DockIcon({ icon: Icon, title, disabled, onClick }: DockIconProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<m.div
className="will-change-transform"
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
whileTap={disabled ? undefined : { scale: 0.97 }}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<Button
size="icon"
variant="ghost"
disabled={disabled}
className={cn(active && "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary")}
onClick={onClick}
aria-label={title}
>
<Icon className={cn("size-4", iconClassName)} />
</Button>
</m.div>
}
/>
{/* A disabled button ignores the pointer, so a wrapper keeps the tooltip working for Undo/Redo. */}
<TooltipTrigger render={<span className="inline-flex" />}>
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick} aria-label={title}>
<Icon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="center" className="font-medium">
{title}
@@ -26,6 +26,7 @@ import {
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
import {
useCurrentBuilderResumeSelector,
@@ -137,9 +138,9 @@ function ResumeDownloadButton() {
function SaveStatusIndicator() {
const status = useResumeStore((state) => state.saveStatus);
if (status === "idle") return null;
const { icon, label } = match(status)
.with("idle", () => ({ icon: null, label: "" }))
.with("saving", () => ({
icon: <CircleNotchIcon className="animate-spin" />,
label: t`Saving…`,
@@ -153,7 +154,11 @@ function SaveStatusIndicator() {
return (
<span
className="ms-1 flex shrink-0 items-center gap-x-1 text-muted-foreground text-xs"
// Always reserve the slot so the header buttons don't shift when the status appears or clears.
className={cn(
"ms-1 flex min-w-3 shrink-0 items-center gap-x-1 text-muted-foreground text-xs md:min-w-18",
status === "idle" && "invisible",
)}
aria-live="polite"
role="status"
>
@@ -53,7 +53,7 @@ function MobileBuilderTabBar({ activeTab, onTabChange }: MobileBuilderTabBarProp
aria-current={isActive ? "page" : undefined}
onClick={() => onTabChange(value)}
className={cn(
"flex min-h-16 flex-1 flex-col items-center justify-center gap-1 text-xs transition-colors",
"flex min-h-16 flex-1 flex-col items-center justify-center gap-1 text-xs transition-[color,scale] duration-150 ease-out-strong active:scale-[0.97]",
isActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
@@ -1,5 +1,6 @@
import { t } from "@lingui/core/macro";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useReducedMotion } from "motion/react";
import { Suspense, useState } from "react";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import { toast } from "@reactive-resume/ui/components/toast";
@@ -11,6 +12,7 @@ import { blurFocusedElementOnPan } from "./pan-focus";
export function PreviewPage() {
const [pageLayout, setPageLayout] = useState(DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT);
const reduceMotion = useReducedMotion();
useHotkey("Mod+S", () => {
toast.add({
@@ -30,6 +32,7 @@ export function PreviewPage() {
initialScale={0.75}
limitToBounds={false}
wheel={{ step: 0.001 }}
doubleClick={{ animationType: "easeOutCubic", animationTime: reduceMotion ? 0 : 200 }}
onPanningStart={blurFocusedElementOnPan}
>
{/* Zoom transforms use left-origin coordinates, regardless of the interface language. */}
@@ -28,40 +28,32 @@ import { orpc } from "@/libs/orpc/client";
import { getSectionIcon, getSectionTitle, leftSidebarSections } from "@/libs/resume/section";
import { BuilderSidebarEdge } from "../../-components/edge";
import { useBuilderSidebar } from "../../-store/sidebar";
import { AwardsSectionBuilder } from "./sections/awards";
import { BasicsSectionBuilder } from "./sections/basics";
import { CertificationsSectionBuilder } from "./sections/certifications";
import { CustomSectionBuilder } from "./sections/custom";
import { EducationSectionBuilder } from "./sections/education";
import { ExperienceSectionBuilder } from "./sections/experience";
import { InterestsSectionBuilder } from "./sections/interests";
import { LanguagesSectionBuilder } from "./sections/languages";
import { PictureSectionBuilder } from "./sections/picture";
import { ProfilesSectionBuilder } from "./sections/profiles";
import { ProjectsSectionBuilder } from "./sections/projects";
import { PublicationsSectionBuilder } from "./sections/publications";
import { ReferencesSectionBuilder } from "./sections/references";
import { SkillsSectionBuilder } from "./sections/skills";
import { SummarySectionBuilder } from "./sections/summary";
import { VolunteerSectionBuilder } from "./sections/volunteer";
import { ItemsSection } from "./shared/items-section";
function getSectionComponent(type: LeftSidebarSection) {
return match(type)
.with("picture", () => <PictureSectionBuilder />)
.with("basics", () => <BasicsSectionBuilder />)
.with("summary", () => <SummarySectionBuilder />)
.with("profiles", () => <ProfilesSectionBuilder />)
.with("experience", () => <ExperienceSectionBuilder />)
.with("education", () => <EducationSectionBuilder />)
.with("projects", () => <ProjectsSectionBuilder />)
.with("skills", () => <SkillsSectionBuilder />)
.with("languages", () => <LanguagesSectionBuilder />)
.with("interests", () => <InterestsSectionBuilder />)
.with("awards", () => <AwardsSectionBuilder />)
.with("certifications", () => <CertificationsSectionBuilder />)
.with("publications", () => <PublicationsSectionBuilder />)
.with("volunteer", () => <VolunteerSectionBuilder />)
.with("references", () => <ReferencesSectionBuilder />)
.with(
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
(type) => <ItemsSection type={type} />,
)
.with("custom", () => <CustomSectionBuilder />)
.exhaustive();
}
@@ -1,35 +0,0 @@
import type { awardItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function AwardsSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.awards);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof awardItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.awards.items = items;
});
};
return (
<SectionBase type="awards" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="awards" item={item} title={item.title} subtitle={item.awarder} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="awards">
<Trans>Add a new award</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -1,44 +0,0 @@
import type { certificationItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function CertificationsSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.certifications);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof certificationItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.certifications.items = items;
});
};
return (
<SectionBase
type="certifications"
className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}
>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem
key={item.id}
type="certifications"
item={item}
title={item.title}
subtitle={[item.issuer, item.date].filter(Boolean).join(" • ") || undefined}
/>
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="certifications">
<Trans>Add a new certification</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -33,7 +33,7 @@ export const CustomFieldsSection = withForm({
<form.Field name="customFields" mode="array">
{(customFieldsField) => (
<Reorder.Group
className="touch-none space-y-4"
className="space-y-4"
values={customFieldsField.state.value}
onReorder={(fields) => {
customFieldsField.setValue(fields);
@@ -152,15 +152,7 @@ function CustomFieldItem({ field, children }: CustomFieldItemProps) {
const controls = useDragControls();
return (
<Reorder.Item
key={field.id}
value={field}
dragListener={false}
dragControls={controls}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex touch-none items-center"
>
<Reorder.Item value={field} dragListener={false} dragControls={controls} className="flex items-center">
<Button
size="icon"
variant="ghost"
@@ -14,7 +14,6 @@ import {
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { AnimatePresence, Reorder } from "motion/react";
import { Badge } from "@reactive-resume/ui/components/badge";
import {
DropdownMenu,
@@ -35,39 +34,11 @@ import { useDialogStore } from "@/dialogs/store";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
import { getSectionTitle } from "@/libs/resume/section";
import { SECTIONS, SectionItemList } from "../shared/items-section";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
import { SectionAddItemButton } from "../shared/section-item";
import { SkillKeywordLayoutMenu } from "../shared/skill-keyword-layout-menu";
// ponytail: data maps replace ts-pattern exhaustive matchers — same coverage, no runtime dependency
const TITLE_FIELD: Partial<Record<CustomSectionType, string>> = {
profiles: "network",
experience: "company",
education: "school",
projects: "name",
skills: "name",
languages: "language",
interests: "name",
awards: "title",
certifications: "title",
publications: "title",
volunteer: "organization",
references: "name",
};
const SUBTITLE_FIELD: Partial<Record<CustomSectionType, string>> = {
profiles: "username",
experience: "position",
education: "degree",
projects: "period",
skills: "proficiency",
languages: "fluency",
awards: "awarder",
certifications: "issuer",
publications: "publisher",
volunteer: "period",
};
function truncateHtml(html: string, max = 50): string {
const stripped = stripHtml(html);
return stripped.length > max ? `${stripped.slice(0, max)}...` : stripped;
@@ -104,8 +75,7 @@ function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): str
message: "Cover Letter",
});
}
const field = TITLE_FIELD[type];
return field && field in item ? String((item as Record<string, unknown>)[field]) : "";
return SECTIONS[type].title(item as never);
}
function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType): string | undefined {
@@ -116,10 +86,8 @@ function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType):
}
return undefined;
}
const field = SUBTITLE_FIELD[type];
if (!field || !(field in item)) return undefined;
const value = (item as Record<string, unknown>)[field];
return typeof value === "string" ? value || undefined : undefined;
if (type === "summary") return undefined;
return SECTIONS[type].subtitle?.(item as never) || undefined;
}
export function CustomSectionBuilder() {
@@ -127,13 +95,11 @@ export function CustomSectionBuilder() {
return (
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
<AnimatePresence>
{customSections
.filter((section) => !section.hidden)
.map((section) => (
<CustomSectionContainer key={section.id} section={section} />
))}
</AnimatePresence>
{customSections
.filter((section) => !section.hidden)
.map((section) => (
<CustomSectionContainer key={section.id} section={section} />
))}
{/* Add Custom Section Button */}
<SectionAddItemButton type="custom" variant="outline" className="rounded-md">
@@ -190,20 +156,14 @@ function CustomSectionContainer({ section }: CustomSectionContainerProps) {
{/* Section Items */}
{section.items.length > 0 && (
<div className={cn("border-t", section.hidden && "opacity-50")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem
key={item.id}
type={section.type}
item={item}
customSectionId={section.id}
title={getItemTitle(section.type, item)}
subtitle={getItemSubtitle(section.type, item)}
/>
))}
</AnimatePresence>
</Reorder.Group>
<SectionItemList
type={section.type}
items={section.items}
onReorder={handleReorder}
customSectionId={section.id}
getTitle={(item) => getItemTitle(section.type, item)}
getSubtitle={(item) => getItemSubtitle(section.type, item)}
/>
</div>
)}
@@ -100,8 +100,9 @@ vi.mock("../shared/section-item", () => ({
),
}));
const { EducationSectionBuilder } = await import("./education");
const { ExperienceSectionBuilder } = await import("./experience");
const { ItemsSection } = await import("../shared/items-section");
const EducationSectionBuilder = () => <ItemsSection type="education" />;
const ExperienceSectionBuilder = () => <ItemsSection type="experience" />;
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -1,35 +0,0 @@
import type { educationItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function EducationSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.education);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof educationItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.education.items = items;
});
};
return (
<SectionBase type="education" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="education" item={item} title={item.school} subtitle={item.degree} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="education">
<Trans>Add a new education</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -1,44 +0,0 @@
import type { experienceItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { plural } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function ExperienceSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.experience);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof experienceItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.experience.items = items;
});
};
return (
<SectionBase type="experience" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence initial={false} mode="popLayout">
{section.items.map((item) => {
return (
<SectionItem
key={item.id}
type="experience"
item={item}
title={item.company}
subtitle={item.position || plural(item.roles.length, { one: "# role", other: "# roles" })}
/>
);
})}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="experience">
<Trans>Add a new experience</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -1,35 +0,0 @@
import type { interestItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function InterestsSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.interests);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof interestItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.interests.items = items;
});
};
return (
<SectionBase type="interests" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="interests" item={item} title={item.name} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="interests">
<Trans>Add a new interest</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -1,35 +0,0 @@
import type { languageItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function LanguagesSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.languages);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof languageItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.languages.items = items;
});
};
return (
<SectionBase type="languages" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="languages" item={item} title={item.language} subtitle={item.fluency} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="languages">
<Trans>Add a new language</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -125,13 +125,14 @@ vi.mock("../shared/section-item", () => ({
),
}));
const { AwardsSectionBuilder } = await import("./awards");
const { CertificationsSectionBuilder } = await import("./certifications");
const { InterestsSectionBuilder } = await import("./interests");
const { LanguagesSectionBuilder } = await import("./languages");
const { PublicationsSectionBuilder } = await import("./publications");
const { ReferencesSectionBuilder } = await import("./references");
const { VolunteerSectionBuilder } = await import("./volunteer");
const { ItemsSection } = await import("../shared/items-section");
const AwardsSectionBuilder = () => <ItemsSection type="awards" />;
const CertificationsSectionBuilder = () => <ItemsSection type="certifications" />;
const InterestsSectionBuilder = () => <ItemsSection type="interests" />;
const LanguagesSectionBuilder = () => <ItemsSection type="languages" />;
const PublicationsSectionBuilder = () => <ItemsSection type="publications" />;
const ReferencesSectionBuilder = () => <ItemsSection type="references" />;
const VolunteerSectionBuilder = () => <ItemsSection type="volunteer" />;
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -95,7 +95,7 @@ function PicturePreviewControls({
alt=""
src={normalizedPictureUrl}
style={{ objectFit: picture.fit }}
className="fade-in relative z-10 size-full animate-in rounded-md transition-opacity group-hover/picture:opacity-20"
className="relative z-10 size-full rounded-md transition-opacity group-hover/picture:opacity-20"
/>
)}
@@ -67,7 +67,8 @@ vi.mock("../shared/section-item", () => ({
),
}));
const { ProfilesSectionBuilder } = await import("./profiles");
const { ItemsSection } = await import("../shared/items-section");
const ProfilesSectionBuilder = () => <ItemsSection type="profiles" />;
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -1,35 +0,0 @@
import type { profileItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function ProfilesSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.profiles);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof profileItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.profiles.items = items;
});
};
return (
<SectionBase type="profiles" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="profiles" item={item} title={item.network} subtitle={item.username} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="profiles">
<Trans>Add a new profile</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -69,7 +69,8 @@ vi.mock("../shared/section-item", () => ({
),
}));
const { ProjectsSectionBuilder } = await import("./projects");
const { ItemsSection } = await import("../shared/items-section");
const ProjectsSectionBuilder = () => <ItemsSection type="projects" />;
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -1,40 +0,0 @@
import type { projectItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
const buildSubtitle = (item: z.infer<typeof projectItemSchema>) => {
const parts = [item.period, item.website.label].filter((part) => part && part.trim().length > 0);
return parts.length > 0 ? parts.join(" • ") : undefined;
};
export function ProjectsSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.projects);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof projectItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.projects.items = items;
});
};
return (
<SectionBase type="projects" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="projects" item={item} title={item.name} subtitle={buildSubtitle(item)} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="projects">
<Trans>Add a new project</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -1,35 +0,0 @@
import type { publicationItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function PublicationsSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.publications);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof publicationItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.publications.items = items;
});
};
return (
<SectionBase type="publications" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="publications" item={item} title={item.title} subtitle={item.publisher} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="publications">
<Trans>Add a new publication</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -1,35 +0,0 @@
import type { referenceItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function ReferencesSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.references);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof referenceItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.references.items = items;
});
};
return (
<SectionBase type="references" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="references" item={item} title={item.name} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="references">
<Trans>Add a new reference</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -49,7 +49,8 @@ vi.mock("../shared/section-item", () => ({
),
}));
const { SkillsSectionBuilder } = await import("./skills");
const { ItemsSection } = await import("../shared/items-section");
const SkillsSectionBuilder = () => <ItemsSection type="skills" />;
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -1,35 +0,0 @@
import type { skillItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function SkillsSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.skills);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof skillItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.skills.items = items;
});
};
return (
<SectionBase type="skills" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence initial={false} mode="popLayout">
{section.items.map((item) => (
<SectionItem key={item.id} type="skills" item={item} title={item.name} subtitle={item.proficiency} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="skills">
<Trans>Add a new skill</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -1,41 +0,0 @@
import type { volunteerItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function VolunteerSectionBuilder() {
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.volunteer);
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof volunteerItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.volunteer.items = items;
});
};
return (
<SectionBase type="volunteer" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem
key={item.id}
type="volunteer"
item={item}
title={item.organization}
subtitle={item.location}
/>
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="volunteer">
<Trans>Add a new volunteer experience</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,147 @@
import type {
CustomSectionItem,
CustomSectionType,
SectionItem as SectionItemType,
SectionType,
} from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import { plural } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SectionBase } from "./section-base";
import { SectionAddItemButton, SectionItem } from "./section-item";
type SectionItemListProps<T extends CustomSectionItem | SectionItemType> = {
type: CustomSectionType;
items: T[];
onReorder: (items: T[]) => void;
getTitle: (item: T) => string;
getSubtitle?: (item: T) => string | undefined;
customSectionId?: string;
};
export function SectionItemList<T extends CustomSectionItem | SectionItemType>({
type,
items,
onReorder,
getTitle,
getSubtitle,
customSectionId,
}: SectionItemListProps<T>) {
return (
// popLayout pops exiting rows out of flow, so the group must be the positioned ancestor.
<Reorder.Group axis="y" values={items} onReorder={onReorder} className="relative">
<AnimatePresence initial={false} mode="popLayout">
{items.map((item) => (
<SectionItem
key={item.id}
type={type}
item={item}
customSectionId={customSectionId}
title={getTitle(item)}
subtitle={getSubtitle?.(item)}
/>
))}
</AnimatePresence>
</Reorder.Group>
);
}
type ItemsSectionConfig<T extends SectionType> = {
title: (item: SectionItemType<T>) => string;
subtitle?: (item: SectionItemType<T>) => string | undefined;
addLabel: ReactNode;
};
export const SECTIONS: { [T in SectionType]: ItemsSectionConfig<T> } = {
profiles: {
title: (item) => item.network,
subtitle: (item) => item.username,
addLabel: <Trans>Add a new profile</Trans>,
},
experience: {
title: (item) => item.company,
subtitle: (item) => item.position || plural(item.roles.length, { one: "# role", other: "# roles" }),
addLabel: <Trans>Add a new experience</Trans>,
},
education: {
title: (item) => item.school,
subtitle: (item) => item.degree,
addLabel: <Trans>Add a new education</Trans>,
},
projects: {
title: (item) => item.name,
subtitle: (item) => [item.period, item.website.label].filter((part) => part?.trim()).join(" • ") || undefined,
addLabel: <Trans>Add a new project</Trans>,
},
skills: {
title: (item) => item.name,
subtitle: (item) => item.proficiency,
addLabel: <Trans>Add a new skill</Trans>,
},
languages: {
title: (item) => item.language,
subtitle: (item) => item.fluency,
addLabel: <Trans>Add a new language</Trans>,
},
interests: {
title: (item) => item.name,
addLabel: <Trans>Add a new interest</Trans>,
},
awards: {
title: (item) => item.title,
subtitle: (item) => item.awarder,
addLabel: <Trans>Add a new award</Trans>,
},
certifications: {
title: (item) => item.title,
subtitle: (item) => [item.issuer, item.date].filter(Boolean).join(" • ") || undefined,
addLabel: <Trans>Add a new certification</Trans>,
},
publications: {
title: (item) => item.title,
subtitle: (item) => item.publisher,
addLabel: <Trans>Add a new publication</Trans>,
},
volunteer: {
title: (item) => item.organization,
subtitle: (item) => item.location,
addLabel: <Trans>Add a new volunteer experience</Trans>,
},
references: {
title: (item) => item.name,
addLabel: <Trans>Add a new reference</Trans>,
},
};
type ItemsSectionProps<T extends SectionType> = {
type: T;
};
export function ItemsSection<T extends SectionType>({ type }: ItemsSectionProps<T>) {
const items = useCurrentBuilderResumeSelector((resume) => resume.data.sections[type].items) as SectionItemType<T>[];
const updateResumeData = useUpdateResumeData();
const config = SECTIONS[type];
const handleReorder = (nextItems: SectionItemType<T>[]) => {
updateResumeData((draft) => {
(draft.sections[type] as { items: SectionItemType<T>[] }).items = nextItems;
});
};
return (
<SectionBase type={type} className={cn("rounded-md border", items.length === 0 && "border-dashed")}>
<SectionItemList
type={type}
items={items}
onReorder={handleReorder}
getTitle={config.title}
getSubtitle={config.subtitle}
/>
<SectionAddItemButton type={type}>{config.addLabel}</SectionAddItemButton>
</SectionBase>
);
}
@@ -1,10 +1,12 @@
import type {
CustomSectionItem,
CustomSectionType,
ResumeData,
SectionItem as SectionItemType,
SectionType,
} from "@reactive-resume/schema/resume/data";
import type { ButtonProps } from "@reactive-resume/ui/components/button";
import type { Ref } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
@@ -39,6 +41,7 @@ import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { atsFindingItemElementId } from "@/libs/resume/ats";
import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item";
@@ -169,6 +172,8 @@ type Props<T extends CustomSectionItem | SectionItemType> = {
title: string;
subtitle?: string;
customSectionId?: string;
// Injected by `AnimatePresence mode="popLayout"` to pop an exiting row out of the flow.
ref?: Ref<HTMLLIElement>;
};
export function SectionItem<T extends CustomSectionItem | SectionItemType>({
@@ -177,28 +182,27 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
title,
subtitle,
customSectionId,
ref,
}: Props<T>) {
const confirm = useConfirm();
const controls = useDragControls();
const { openDialog } = useDialogStore();
const updateResumeData = useUpdateResumeData();
// Returns the items array (and index) this row lives in, from either a custom or a built-in section.
const findItem = (draft: ResumeData) => {
const items = customSectionId
? draft.customSections.find((section) => section.id === customSectionId)?.items
: // Type assertion: when customSectionId is not provided, type is always a built-in SectionType
draft.sections[type as SectionType].items;
const index = items?.findIndex((_item) => _item.id === item.id) ?? -1;
return index === -1 || !items ? null : { items, index };
};
const onToggleVisibility = () => {
updateResumeData((draft) => {
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items[index].hidden = !section.items[index].hidden;
} else {
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
const section = draft.sections[type as SectionType];
if (!("items" in section)) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items[index].hidden = !section.items[index].hidden;
}
const found = findItem(draft);
if (found) found.items[found.index].hidden = !found.items[found.index].hidden;
});
};
@@ -227,38 +231,27 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
if (!confirmed) return;
updateResumeData((draft) => {
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items.splice(index, 1);
} else {
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
const section = draft.sections[type as SectionType];
if (!("items" in section)) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items.splice(index, 1);
}
const found = findItem(draft);
if (found) found.items.splice(found.index, 1);
});
};
return (
<Reorder.Item
key={item.id}
ref={ref}
id={atsFindingItemElementId(item.id)}
value={item}
dragListener={false}
dragControls={controls}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="group relative flex h-18 select-none border-b will-change-[transform,opacity]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.12, ease: EASE_OUT_STRONG }}
whileDrag={{ boxShadow: "0 4px 12px rgb(0 0 0 / 0.08)" }}
className="group relative flex h-18 select-none border-b bg-background"
>
<div
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 group-hover:opacity-100"
className="flex cursor-grab touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 active:cursor-grabbing group-hover:opacity-100"
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
@@ -6,6 +6,7 @@ import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
import { Separator } from "@reactive-resume/ui/components/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { Copyright } from "@/components/ui/copyright";
import { getScrollBehavior } from "@/features/resume/builder/section-recovery";
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
import { BuilderSidebarEdge } from "../../-components/edge";
import { useBuilderSidebar } from "../../-store/sidebar";
@@ -74,7 +75,7 @@ function SidebarEdge() {
// Section ids are globally unique; document.getElementById reliably resolves the scroll target.
document
.getElementById(`sidebar-${section}`)
?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
},
[toggleSidebar],
);
@@ -1,4 +1,5 @@
import type { AtsFinding, AtsSeverity } from "@reactive-resume/resume/ats";
import type { CSSProperties } from "react";
import type { AtsCheckResult } from "@/features/ats-checker/run-ats-check";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
@@ -18,6 +19,7 @@ import { AiReviewCard } from "@/features/ats-checker/ai-review/ai-review-card";
import { AtsPdfReportView } from "@/features/ats-checker/report/report-view";
import { blobToPdfFile, runAtsCheck } from "@/features/ats-checker/run-ats-check";
import { useResumeData } from "@/features/resume/builder/draft";
import { getScrollBehavior } from "@/features/resume/builder/section-recovery";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import {
atsFindingItemElementId,
@@ -118,7 +120,7 @@ function LiveLintTier() {
const item = target.itemId ? document.getElementById(atsFindingItemElementId(target.itemId)) : null;
const destination = item ?? document.getElementById(`sidebar-${target.section}`);
destination?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
destination?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
},
[data, setCollapsed, toggleSidebar],
);
@@ -146,8 +148,8 @@ function LiveLintTier() {
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width] duration-300"
style={{ width: `${Math.round((passedRules / totalRules) * 100)}%` }}
className="h-full translate-x-(--fill) rounded-full bg-primary transition-[translate] duration-200 ease-out-strong rtl:-translate-x-(--fill)"
style={{ "--fill": `${(passedRules / totalRules) * 100 - 100}%` } as CSSProperties}
/>
</div>
</div>
@@ -17,6 +17,7 @@ import { LevelTypeCombobox } from "@/components/level/combobox";
import { LevelDisplay } from "@/components/level/display";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { useAppForm } from "@/libs/tanstack-form";
import { SectionBase } from "../shared/section-base";
@@ -179,21 +180,21 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic
onClick={() => onSelect(color)}
className={cn(
"relative flex size-8 items-center justify-center rounded-md bg-transparent",
"scale-100 transition-transform hover:scale-120 hover:bg-secondary/80 active:scale-95",
"transition-[scale,background-color] duration-150 ease-out-strong hover:bg-secondary/80 active:scale-[0.97]",
className,
)}
{...props}
>
<div style={{ backgroundColor: color }} className="size-6 shrink-0 rounded-md" />
<AnimatePresence>
<AnimatePresence initial={false}>
{active && (
<m.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="absolute inset-0 flex size-8 items-center justify-center will-change-transform"
transition={{ duration: 0.16, ease: EASE_OUT_STRONG }}
className="absolute inset-0 flex size-8 items-center justify-center"
>
<div className="size-4 rounded-md bg-foreground" />
</m.div>
@@ -41,6 +41,7 @@ import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { templates } from "@/dialogs/resume/template/data";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { resolveLayoutSectionTitle } from "./title";
import { filterVisibleLayoutSectionIds } from "./visibility";
@@ -270,7 +271,9 @@ export function LayoutPages() {
</Button>
</div>
<DragOverlay>{activeId ? <LayoutItemContent id={activeId} isDragging isOverlay /> : null}</DragOverlay>
<DragOverlay dropAnimation={{ duration: 150, easing: `cubic-bezier(${EASE_OUT_STRONG})` }}>
{activeId ? <LayoutItemContent id={activeId} isOverlay /> : null}
</DragOverlay>
</DndContext>
);
}
@@ -617,10 +620,10 @@ function LayoutItemContent({
data-overlay={isOverlay ? "true" : undefined}
data-dragging={isDragging ? "true" : undefined}
className={cn(
"group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-border bg-background px-2 py-1.5 font-medium text-sm transition-all duration-200 ease-out",
"group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-border bg-background px-2 py-1.5 font-medium text-sm transition-[background-color,border-color] duration-150",
"hover:bg-secondary/40 active:cursor-grabbing active:border-primary/60 active:bg-secondary/40",
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background",
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:border-primary/60 data-[dragging=true]:bg-background",
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background data-[overlay=true]:shadow-md",
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:opacity-40",
className,
)}
{...rest}
@@ -1,8 +1,9 @@
import type { Layout, usePanelRef } from "react-resizable-panels";
import Cookies from "js-cookie";
import { useCallback } from "react";
import { useMediaQuery, useWindowSize } from "usehooks-ts";
import { useWindowSize } from "usehooks-ts";
import { create } from "zustand/react";
import { useIsMobile } from "@reactive-resume/ui/hooks/use-mobile";
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
@@ -102,7 +103,7 @@ type UseBuilderSidebarReturn = {
};
export function useBuilderSidebar(): UseBuilderSidebarReturn {
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
const isMobile = useIsMobile();
const { width } = useWindowSize();
const { maxSidebarSize, minSidebarSize, collapsedSidebarSize, expandSize, groupResizeBehavior } =
@@ -2,7 +2,7 @@ import type { BuilderLayout } from "./-store/sidebar";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
import { useMediaQuery } from "usehooks-ts";
import { useIsMobile } from "@reactive-resume/ui/hooks/use-mobile";
import { useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore } from "@/features/resume/builder/draft";
import { orpc } from "@/libs/orpc/client";
import { createNoindexFollowMeta } from "@/libs/seo";
@@ -73,7 +73,7 @@ function RouteComponent() {
function BuilderLayoutShell({ initialLayout }: { initialLayout: BuilderLayout }) {
// Single breakpoint (below `md`) switches between the desktop resizable panels and the mobile tabbed shell.
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
const isMobile = useIsMobile();
if (isMobile) return <MobileBuilderShell />;
return <DesktopBuilderShell initialLayout={initialLayout} />;
@@ -1,13 +0,0 @@
import Cookies from "js-cookie";
import z from "zod";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
export function getDashboardSidebarState() {
return Cookies.get(SIDEBAR_COOKIE_NAME) !== "false";
}
export function setDashboardSidebarState(value: boolean) {
const parsed = z.boolean().parse(value);
Cookies.set(SIDEBAR_COOKIE_NAME, parsed.toString());
}
@@ -40,6 +40,7 @@ import { getInitials } from "@reactive-resume/utils/string";
import { Copyright } from "@/components/ui/copyright";
import { useCommandPaletteStore } from "@/features/command-palette/store";
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
import { EASE_OUT_STRONG } from "@/libs/motion";
type SidebarItem = {
icon: React.ReactNode;
@@ -120,11 +121,11 @@ function SidebarItemList({ items }: SidebarItemListProps) {
{items.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
title={i18n.t(item.label)}
tooltip={i18n.t(item.label)}
render={
<Link to={item.href} activeProps={{ className: "bg-sidebar-accent" }}>
{item.icon}
<span className="shrink-0 transition-[margin,opacity] duration-200 ease-in-out group-data-[collapsible=icon]:-ms-8 group-data-[collapsible=icon]:opacity-0">
<span className="shrink-0 transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
{i18n.t(item.label)}
</span>
</Link>
@@ -144,12 +145,14 @@ function SidebarSearchButton() {
return (
<SidebarMenuItem>
<SidebarMenuButton title={label} tooltip={label} onClick={() => setOpen(true)}>
<SidebarMenuButton tooltip={label} onClick={() => setOpen(true)}>
<MagnifyingGlassIcon />
<span className="flex-1 text-start transition-[margin,opacity] duration-200 ease-in-out group-data-[collapsible=icon]:-ms-8 group-data-[collapsible=icon]:opacity-0">
<span className="flex-1 text-start transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
{label}
</span>
<Kbd className="transition-opacity duration-200 ease-in-out group-data-[collapsible=icon]:opacity-0">⌘K</Kbd>
<Kbd className="transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
⌘K
</Kbd>
</SidebarMenuButton>
</SidebarMenuItem>
);
@@ -209,14 +212,14 @@ export function DashboardSidebar() {
<UserDropdownMenu>
{({ session }) => (
<SidebarMenuButton className="h-auto gap-x-3 group-data-[collapsible=icon]:p-1!">
<Avatar className="size-8 shrink-0 transition-all group-data-[collapsible=icon]:size-6">
<Avatar className="size-8 shrink-0 group-data-[collapsible=icon]:size-6">
<AvatarImage src={session.user.image ?? undefined} />
<AvatarFallback className="group-data-[collapsible=icon]:text-[0.5rem]">
{getInitials(session.user.name)}
</AvatarFallback>
</Avatar>
<div className="transition-[margin,opacity] duration-200 ease-in-out group-data-[collapsible=icon]:-ms-8 group-data-[collapsible=icon]:opacity-0">
<div className="transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
<p className="font-medium">{session.user.name}</p>
<p className="text-muted-foreground text-xs">{session.user.email}</p>
</div>
@@ -230,11 +233,9 @@ export function DashboardSidebar() {
{state === "expanded" && (
<m.div
key="copyright"
className="will-change-[transform,opacity]"
initial={{ y: 12, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 12, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.2, ease: EASE_OUT_STRONG } }}
exit={{ opacity: 0, transition: { duration: 0.08 } }}
>
<Copyright className="wrap-break-word shrink-0 whitespace-normal p-2" />
</m.div>
@@ -87,7 +87,7 @@ function RouteComponent() {
void navigate({ replace: true, search: (prev: Search) => ({ ...prev, create: false }) });
}, [create, navigate]);
const { data: applications } = useQuery(applicationsListQueryOptions());
const { data: applications, isPending } = useQuery(applicationsListQueryOptions());
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
useEffect(() => {
@@ -116,7 +116,8 @@ function RouteComponent() {
const archivedCount = (applications ?? []).filter((app) => app.archived).length;
const isEmpty = (applications?.length ?? 0) === 0;
// While loading render neither the empty state nor the board; a failed load falls back to the empty state.
const isEmpty = !applications?.length;
const setUrlSearch = (patch: Partial<Search>) => void navigate({ search: (prev: Search) => ({ ...prev, ...patch }) });
@@ -148,7 +149,7 @@ function RouteComponent() {
<Separator />
{isEmpty ? (
{isPending ? null : isEmpty ? (
<EmptyState onAdd={() => setAddOpen(true)} onImport={() => setImportOpen(true)} />
) : (
<>
@@ -12,7 +12,7 @@ type BaseCardProps = React.ComponentProps<"div"> & {
export function BaseCard({ title, description, tags, className, children, ...props }: BaseCardProps) {
return (
<CometCard translateDepth={3} rotateDepth={6}>
<CometCard>
<div
{...props}
className={cn(

Some files were not shown because too many files have changed in this diff Show More