From 982e688e65bb543cb0a4f6aee83facef07147c1d Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 12:32:12 +0200 Subject: [PATCH] refactor(web): put app transitions on the house motion tokens Set Tailwind's default transition duration and curve to --d1/--ease so bare transition-* utilities run quick on the house curve. Mirror the motion tokens in libs/motion.ts (EASE, EASE_MOVE, D1-D3, EXIT) and move app UI off ease-out-strong and hand-typed 150/300ms durations; the landing page keeps --ease-out-strong. Give snapping hover and focus states a transition, slide progress fills with translate instead of animating width, keep selection outlines' width and style constant so they fade from transparent instead of flashing dark, and drop the unused agent-prompt-marquee keyframes. --- apps/web/src/components/input/chip-input.tsx | 2 +- .../web/src/components/input/color-picker.tsx | 4 ++-- .../components/typography/font-display.tsx | 2 +- .../applications/components/board.tsx | 2 +- .../applications/components/calendar-view.tsx | 4 ++-- .../components/detail/close-dialog.tsx | 5 ++++- .../components/file-attachment-field.tsx | 2 +- .../applications/components/list-view.tsx | 4 ++-- .../features/assistant/assistant-panel.tsx | 6 +++--- .../src/features/assistant/conversation.tsx | 8 ++++---- .../ats-checker/report/score-header.tsx | 2 +- apps/web/src/features/auth/layout.tsx | 2 +- .../documents/new-document-dialog.tsx | 5 +++-- .../resume/editor/check/job-match.tsx | 5 +++-- .../features/resume/editor/page-overlay.tsx | 4 ++-- .../resume/preview/preview.browser.tsx | 4 ++-- apps/web/src/index.css | 6 ------ apps/web/src/libs/motion.ts | 19 +++++++++++++++++-- .../-sidebar/right/sections/design.tsx | 6 +++--- .../-sidebar/right/sections/layout/pages.tsx | 6 +++--- .../letter/-components/letter-page.tsx | 6 +++--- .../letter/-components/write-panel.tsx | 17 ++++++++++++----- .../dashboard/-components/app-shell.tsx | 4 ++-- packages/ui/src/components/toast.tsx | 4 ++-- packages/ui/src/styles/globals.css | 9 +++++++-- 25 files changed, 82 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/input/chip-input.tsx b/apps/web/src/components/input/chip-input.tsx index 45a59da70..5f88b8280 100644 --- a/apps/web/src/components/input/chip-input.tsx +++ b/apps/web/src/components/input/chip-input.tsx @@ -90,7 +90,7 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp {chip}
diff --git a/apps/web/src/components/input/color-picker.tsx b/apps/web/src/components/input/color-picker.tsx index ae63c31a4..511ffd34e 100644 --- a/apps/web/src/components/input/color-picker.tsx +++ b/apps/web/src/components/input/color-picker.tsx @@ -64,7 +64,7 @@ export function ColorPicker({ {trigger ?? (
@@ -94,7 +94,7 @@ export function ColorPicker({ aria-pressed={currentValue === color} onClick={() => setCurrentValue(color)} className={cn( - "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-accent active:scale-[0.97]", + "size-5 shrink-0 cursor-pointer rounded-full transition-transform duration-quick ease-enter focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-accent active:scale-[0.97]", currentValue === color && "border border-ink/60", )} /> diff --git a/apps/web/src/components/typography/font-display.tsx b/apps/web/src/components/typography/font-display.tsx index cb482dbc7..7de228721 100644 --- a/apps/web/src/components/typography/font-display.tsx +++ b/apps/web/src/components/typography/font-display.tsx @@ -34,7 +34,7 @@ export function FontDisplay({ family, label, type, url }: FontDisplayProps) {
{label} diff --git a/apps/web/src/features/applications/components/board.tsx b/apps/web/src/features/applications/components/board.tsx index 3fe72cc6f..79be55eb9 100644 --- a/apps/web/src/features/applications/components/board.tsx +++ b/apps/web/src/features/applications/components/board.tsx @@ -104,7 +104,7 @@ function Column({ stage, applications, onOpen }: ColumnProps) { diff --git a/apps/web/src/features/applications/components/calendar-view.tsx b/apps/web/src/features/applications/components/calendar-view.tsx index e72e344d1..3e29bd22d 100644 --- a/apps/web/src/features/applications/components/calendar-view.tsx +++ b/apps/web/src/features/applications/components/calendar-view.tsx @@ -177,7 +177,7 @@ export function ApplicationCalendar({ applications, allApplications, onOpen }: A render={ @@ -612,7 +612,7 @@ export function Composer(props: ComposerProps) { type="button" aria-label={t`Remove ${attachment.filename}`} onClick={() => setAttachments((current) => current.filter((item) => item.id !== attachment.id))} - className="grid size-5 place-items-center rounded text-ink-3 hover:bg-hover hover:text-ink" + className="grid size-5 place-items-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink" > @@ -621,7 +621,7 @@ export function Composer(props: ComposerProps) {
)} -
+
diff --git a/apps/web/src/features/ats-checker/report/score-header.tsx b/apps/web/src/features/ats-checker/report/score-header.tsx index e7e29f0ba..c2ce168e5 100644 --- a/apps/web/src/features/ats-checker/report/score-header.tsx +++ b/apps/web/src/features/ats-checker/report/score-header.tsx @@ -32,7 +32,7 @@ export function ScoreHeader({ report }: ScoreHeaderProps) {
+
diff --git a/apps/web/src/features/documents/new-document-dialog.tsx b/apps/web/src/features/documents/new-document-dialog.tsx index 0e1e08d3e..2a02883cd 100644 --- a/apps/web/src/features/documents/new-document-dialog.tsx +++ b/apps/web/src/features/documents/new-document-dialog.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties } from "react"; import type { ImportKind } from "@/features/resume/import/read-file"; import { t } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; @@ -467,8 +468,8 @@ function ImportProgress({ stage, notes }: { stage: number; notes: string[] }) { className="h-1 overflow-hidden rounded-full bg-sunken" >
diff --git a/apps/web/src/features/resume/editor/check/job-match.tsx b/apps/web/src/features/resume/editor/check/job-match.tsx index f5f64aaa3..bc01d7d1f 100644 --- a/apps/web/src/features/resume/editor/check/job-match.tsx +++ b/apps/web/src/features/resume/editor/check/job-match.tsx @@ -1,5 +1,6 @@ import type { JdTermMatch } from "@reactive-resume/resume/ats-pdf"; import type { ResumeData, SkillItem } from "@reactive-resume/schema/resume/data"; +import type { CSSProperties } from "react"; import { t } from "@lingui/core/macro"; import { Plural, Trans } from "@lingui/react/macro"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -155,8 +156,8 @@ export function JobMatchTab({ match, data }: JobMatchTabProps) { className="h-1.5 overflow-hidden rounded-full bg-sunken" >
diff --git a/apps/web/src/features/resume/editor/page-overlay.tsx b/apps/web/src/features/resume/editor/page-overlay.tsx index e4225a35e..b6347cf3b 100644 --- a/apps/web/src/features/resume/editor/page-overlay.tsx +++ b/apps/web/src/features/resume/editor/page-overlay.tsx @@ -59,8 +59,8 @@ export function PageOverlay({ pageIndex, pageMap, onSelect }: PageOverlayProps) data-section-id={node.kind === "header" ? undefined : node.sectionId} data-item-id={node.kind === "item" ? node.itemId : undefined} className={cn( - "absolute cursor-pointer rounded-[4px] transition-[background-color,outline-color] duration-quick hover:bg-[oklch(0.5_0.1_150/0.06)]", - selected && "outline-[1.5px] outline-accent outline-solid", + "absolute cursor-pointer rounded-[4px] outline-[1.5px] outline-solid transition-[background-color,outline-color] duration-quick hover:bg-[oklch(0.5_0.1_150/0.06)]", + selected ? "outline-accent" : "outline-transparent", )} style={{ left: `calc(${(node.x / page.width) * 100}% - 4px)`, diff --git a/apps/web/src/features/resume/preview/preview.browser.tsx b/apps/web/src/features/resume/preview/preview.browser.tsx index f5c41aef5..7cd09aac3 100644 --- a/apps/web/src/features/resume/preview/preview.browser.tsx +++ b/apps/web/src/features/resume/preview/preview.browser.tsx @@ -10,7 +10,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 { EASE } from "@/libs/motion"; import { isEditableElementFocused, usePreviewPausedStore, useResumeData } from "../builder/draft"; import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas"; import { ResumePreviewLoader } from "./preview.shared"; @@ -34,7 +34,7 @@ const UPDATE_DEBOUNCE_MS = 100; const TYPING_DEBOUNCE_MS = 250; // 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 INCOMING_TRANSITION = { duration: 0.15, ease: EASE }; const EXITING_TRANSITION = { duration: 0.1, delay: 0.18 }; // Motion animates in JS, so the CSS reduced-motion rule doesn't reach it: with reduced motion a new render replaces // the old one at once, and no half-faded page is ever on screen. diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6d439dc16..85eda7154 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,11 +1,5 @@ @import "@reactive-resume/ui/globals.css"; -@keyframes agent-prompt-marquee { - to { - transform: translateX(-33.333%); - } -} - /* Check's score ring: the arc eases to a new score, so a fix visibly counts. */ @property --ring-value { syntax: ""; diff --git a/apps/web/src/libs/motion.ts b/apps/web/src/libs/motion.ts index fbabf0e4a..a89a280c5 100644 --- a/apps/web/src/libs/motion.ts +++ b/apps/web/src/libs/motion.ts @@ -1,3 +1,18 @@ -// Mirrors `--ease-out-strong` in packages/ui/src/styles/globals.css for Motion (JS) animations. -// Mirror the Desk & Paper motion tokens (--d1/--d2/--d3, --ease) here as Motion animations start using them. +// Motion (JS) mirrors of the CSS motion tokens in packages/ui/src/styles/globals.css (--d1/--d2/--d3, --ease, +// --ease-in-out-strong). Durations are in seconds, Motion's unit; multiply by 1000 for APIs that take ms (dnd-kit). + +/** --ease / `ease-enter`: everything entering, exiting or changing state. */ +export const EASE = [0.2, 0.8, 0.2, 1] as const; +/** --ease-in-out-strong / `ease-in-out-strong`: things moving across the screen (indicators, reorder, settling). */ +export const EASE_MOVE = [0.77, 0, 0.175, 1] as const; +/** --d1 / `duration-quick`: hover, press, toggle, checkbox, focus ring. */ +export const D1 = 0.12; +/** --d2 / `duration-standard`: menus, popovers, expand/collapse, content swaps, dialogs. */ +export const D2 = 0.2; +/** --d3 / `duration-emphasized`: side and bottom sheets, toasts, the assistant. */ +export const D3 = 0.32; +/** Exits run at 70% of the enter duration, e.g. `D2 * EXIT`. */ +export const EXIT = 0.7; + +/** --ease-out-strong. Landing page only (features/homepage/site-footer.tsx); app code uses EASE. */ export const EASE_OUT_STRONG = [0.23, 1, 0.32, 1] as const; diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx index 86969425f..1c7c7270e 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx @@ -17,7 +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 { D1, EASE } from "@/libs/motion"; import { useAppForm } from "@/libs/tanstack-form"; import { SectionBase } from "../shared/section-base"; @@ -180,7 +180,7 @@ 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", - "transition-[scale,background-color] duration-150 ease-out-strong hover:bg-sunken/80 active:scale-[0.97]", + "transition-[scale,background-color] duration-quick ease-enter hover:bg-sunken/80 active:scale-[0.97]", className, )} {...props} @@ -193,7 +193,7 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} - transition={{ duration: 0.16, ease: EASE_OUT_STRONG }} + transition={{ duration: D1, ease: EASE }} className="absolute inset-0 flex size-8 items-center justify-center" >
diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx index 3763e3d62..47b5298cd 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx @@ -33,7 +33,7 @@ import { Icon } from "@reactive-resume/ui/components/icon"; import { Switch } from "@reactive-resume/ui/components/switch"; import { cn } from "@reactive-resume/utils/style"; import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft"; -import { EASE_OUT_STRONG } from "@/libs/motion"; +import { EASE } from "@/libs/motion"; import { resolveLayoutSectionTitle } from "./title"; import { filterVisibleLayoutSectionIds } from "./visibility"; @@ -263,7 +263,7 @@ export function LayoutPages() {
- + {activeId ? : null} @@ -612,7 +612,7 @@ 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-line bg-bg px-2 py-1.5 font-medium text-sm transition-[background-color,border-color] duration-150", + "group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-line bg-bg px-2 py-1.5 font-medium text-sm transition-[background-color,border-color] duration-quick", "hover:bg-sunken/40 active:cursor-grabbing active:border-accent/60 active:bg-sunken/40", "data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-accent/60 data-[overlay=true]:bg-bg data-[overlay=true]:shadow-md", "data-[dragging=true]:cursor-grabbing data-[dragging=true]:opacity-40", diff --git a/apps/web/src/routes/builder/letter/-components/letter-page.tsx b/apps/web/src/routes/builder/letter/-components/letter-page.tsx index e8bc0f966..25a8e0a8b 100644 --- a/apps/web/src/routes/builder/letter/-components/letter-page.tsx +++ b/apps/web/src/routes/builder/letter/-components/letter-page.tsx @@ -215,10 +215,10 @@ function LetterOverlay({ pageIndex, pageMap, itemId, washed }: LetterOverlayProp key={`${node.key}:${node.y}`} onClick={() => reveal(node.kind === "header" ? "from" : "body")} className={cn( - "absolute cursor-pointer rounded-[4px] transition-[background-color,outline-color] duration-standard", + "absolute cursor-pointer rounded-[4px] outline-[1.5px] outline-solid transition-[background-color,outline-color] duration-standard", washed && node.kind === "item" - ? "bg-[oklch(0.93_0.05_150/0.55)] mix-blend-multiply outline-[1.5px] outline-[oklch(0.5_0.1_150)] outline-solid" - : "hover:bg-[oklch(0.5_0.1_150/0.06)]", + ? "bg-[oklch(0.93_0.05_150/0.55)] mix-blend-multiply outline-[oklch(0.5_0.1_150)]" + : "outline-transparent hover:bg-[oklch(0.5_0.1_150/0.06)]", )} style={{ left: `calc(${(node.x / page.width) * 100}% - 4px)`, diff --git a/apps/web/src/routes/builder/letter/-components/write-panel.tsx b/apps/web/src/routes/builder/letter/-components/write-panel.tsx index 46df972e1..a5fcdaf7a 100644 --- a/apps/web/src/routes/builder/letter/-components/write-panel.tsx +++ b/apps/web/src/routes/builder/letter/-components/write-panel.tsx @@ -1,5 +1,5 @@ import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data"; -import type { ReactElement, ReactNode } from "react"; +import type { CSSProperties, ReactElement, ReactNode } from "react"; import type { Application } from "@/features/applications/types"; import type { LetterDraft } from "@/features/letters/store"; import { t } from "@lingui/core/macro"; @@ -553,8 +553,8 @@ function DraftBox({ draft, letter, application, onKeep }: DraftBoxProps) { aria-live="polite" aria-busy={streaming} className={cn( - "min-h-[120px] whitespace-pre-wrap rounded-lg bg-accent-soft px-3 py-2.5 text-sm leading-relaxed transition-[outline-color] duration-standard", - draft.phase === "ready" ? "outline-[1.5px] outline-accent outline-solid" : "outline-transparent", + "min-h-[120px] whitespace-pre-wrap rounded-lg bg-accent-soft px-3 py-2.5 text-sm leading-relaxed outline-[1.5px] outline-solid transition-[outline-color] duration-standard", + draft.phase === "ready" ? "outline-accent" : "outline-transparent", )} > {text} @@ -600,8 +600,15 @@ function LengthSection({ content }: { content: string }) { }} />

diff --git a/apps/web/src/routes/dashboard/-components/app-shell.tsx b/apps/web/src/routes/dashboard/-components/app-shell.tsx index ad47ce79b..2c5a566c9 100644 --- a/apps/web/src/routes/dashboard/-components/app-shell.tsx +++ b/apps/web/src/routes/dashboard/-components/app-shell.tsx @@ -214,7 +214,7 @@ function Rail() { type="button" aria-label={t`Search or run…`} onClick={() => openPalette(true)} - className="grid size-10 place-items-center rounded-lg text-ink-2 hover:bg-hover" + className="grid size-10 place-items-center rounded-lg text-ink-2 transition-colors hover:bg-hover" />