feat(web): let content arrive instead of popping in

Loaded content now settles in: library cards and rows, checker issue rows,
assistant suggestions and past conversations rise in with a short stagger on
first appearance only; new assistant messages and "Thinking…" rise in while
history stays put; a newly made document fades up from 96% with its accent
ring fading in.

Resume thumbnails keep the last drawn page of the same resume up while a new
one renders, so edits and return visits no longer drop to a placeholder. The
builder's first preview render paints hidden under the placeholder pages and
fades in; the PDF viewer reserves an A4 slot and holds its overlay until a
page has painted. The ATS checker ring fills on first reveal, ring colour
blends across the threshold, and report and insights bars grow from their
baseline.

Hand-rolled pulsing placeholders become the static Skeleton, shaped like the
view that is loading (documents grid or list, applications views, insights,
API keys). ENTER_CLASS and stagger() live in libs/motion.ts.
This commit is contained in:
Amruth Pillai
2026-09-29 13:02:24 +02:00
parent 607c561792
commit 28eea458d9
16 changed files with 316 additions and 113 deletions
@@ -6,7 +6,9 @@ import { useQuery } from "@tanstack/react-query";
import { useRef } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
import { toast } from "@reactive-resume/ui/components/toast";
import { stagger } from "@/libs/motion";
import { orpc } from "@/libs/orpc/client";
import { computeInsights, computeOutcomes, computeTimeline } from "../insights";
import { getStageColor, getStageLabel } from "../stages";
@@ -18,6 +20,20 @@ const appliedDate = (app: Application) =>
[...app.activity].sort(byNewest).find((entry) => entry.type === "stage" && entry.stage === "applied")?.at ??
app.appliedAt;
/** The page's shape while stats load: the funnel, the two figures, then the charts. */
export function InsightsSkeleton() {
return (
<div className="flex max-w-4xl flex-col gap-4">
<Skeleton className="h-56 rounded-xl" />
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton className="h-[102px] rounded-xl" />
<Skeleton className="h-[102px] rounded-xl" />
</div>
<Skeleton className="h-80 rounded-xl" />
</div>
);
}
export function ApplicationInsights({ applications }: { applications: Application[] }) {
const { data } = useQuery(orpc.applications.stats.queryOptions({}));
const { data: documents } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: false } }));
@@ -36,7 +52,7 @@ export function ApplicationInsights({ applications }: { applications: Applicatio
);
const maxWeek = Math.max(1, ...timeline.map((bucket) => bucket.count));
if (!data) return <div className="h-40 animate-pulse rounded-xl bg-sunken" />;
if (!data) return <InsightsSkeleton />;
const insights = computeInsights(data.byStage);
const maxSource = Math.max(1, ...data.bySource.map((s) => s.count));
@@ -57,7 +73,7 @@ export function ApplicationInsights({ applications }: { applications: Applicatio
<Trans>How far applications get</Trans>
</h2>
<ol className="grid gap-2">
{outcomes.funnel.map((row) => (
{outcomes.funnel.map((row, index) => (
<li key={row.status} className="grid grid-cols-[92px_minmax(0,1fr)_32px] items-center gap-3 text-sm">
<span className="flex items-center gap-1.5 text-ink-2">
<span
@@ -69,8 +85,12 @@ export function ApplicationInsights({ applications }: { applications: Applicatio
</span>
<span className="h-5 overflow-hidden rounded bg-sunken">
<span
className="block h-full rounded"
style={{ width: `${(row.reached / widest) * 100}%`, background: getStageColor(row.status) }}
className="block h-full origin-left starting:scale-x-0 rounded transition-[scale] delay-(--stagger) duration-emphasized ease-enter motion-reduce:delay-0 rtl:origin-right"
style={{
...stagger(index),
width: `${(row.reached / widest) * 100}%`,
background: getStageColor(row.status),
}}
/>
</span>
<span className="text-end font-display text-base tabular-nums">{row.reached}</span>
@@ -132,13 +152,16 @@ export function ApplicationInsights({ applications }: { applications: Applicatio
<Trans>Applications sent per week (last 8 weeks)</Trans>
</p>
<div className="mt-4 flex items-end gap-2">
{timeline.map((bucket) => (
{timeline.map((bucket, index) => (
<div key={bucket.label} className="flex flex-1 flex-col items-center gap-1">
<div className="flex h-28 w-full flex-col justify-end">
<span className="mb-1 text-center text-[10px] text-ink-3 tabular-nums">{bucket.count || ""}</span>
<div
className="w-full rounded-t bg-accent"
style={{ height: `${bucket.count ? Math.max((bucket.count / maxWeek) * 100, 8) : 0}%` }}
className="w-full origin-bottom starting:scale-y-0 rounded-t bg-accent transition-[scale] delay-(--stagger) duration-emphasized ease-enter motion-reduce:delay-0"
style={{
...stagger(index),
height: `${bucket.count ? Math.max((bucket.count / maxWeek) * 100, 8) : 0}%`,
}}
/>
</div>
<span className="text-[10px] text-ink-3 tabular-nums">{bucket.label}</span>
@@ -161,13 +184,13 @@ export function ApplicationInsights({ applications }: { applications: Applicatio
<Trans>No source data yet.</Trans>
</p>
) : (
data.bySource.map((row) => (
data.bySource.map((row, index) => (
<div key={row.source} className="flex items-center gap-3 text-xs">
<span className="w-28 shrink-0 truncate font-medium">{row.source}</span>
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-sunken">
<div
className="h-full rounded-full bg-ink-2"
style={{ width: `${Math.max((row.count / maxSource) * 100, 3)}%` }}
className="h-full origin-left starting:scale-x-0 rounded-full bg-ink-2 transition-[scale] delay-(--stagger) duration-emphasized ease-enter motion-reduce:delay-0 rtl:origin-right"
style={{ ...stagger(index), width: `${Math.max((row.count / maxSource) * 100, 3)}%` }}
/>
</div>
<span className="w-6 text-right text-ink-3">{row.count}</span>
@@ -26,6 +26,7 @@ import { formatVersionTime } from "@/features/resume/share/format";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { useConfirm } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { ENTER_CLASS, stagger } from "@/libs/motion";
import { client, orpc } from "@/libs/orpc/client";
import { Composer, Conversation } from "./conversation";
import { ProviderSetup } from "./provider-setup";
@@ -260,7 +261,8 @@ function ConversationLoader({ threadId, document, ...props }: ConversationLoader
);
if (!thread.data || !thread.isFetchedAfterMount)
return (
<div className="grid flex-1 place-items-center">
// Hidden for the first 150ms, so a quick refetch never flashes a spinner.
<div className="grid flex-1 place-items-center starting:opacity-0 transition-opacity delay-150 duration-standard ease-enter">
<Spinner />
</div>
);
@@ -352,8 +354,8 @@ function EmptyState({ document, disabled, onPick }: EmptyStateProps) {
)}
</div>
<ul className="grid gap-1">
{suggestionsFor(document, prepare).map((suggestion) => (
<li key={suggestion.label}>
{suggestionsFor(document, prepare).map((suggestion, index) => (
<li key={suggestion.label} style={stagger(index)} className={ENTER_CLASS}>
<button
type="button"
disabled={disabled}
@@ -434,9 +436,10 @@ function PastConversations({ threads, document, currentId, onOpen }: PastConvers
</h3>
{mine.length > 0 ? (
<ul className="grid">
{mine.map((thread) => (
{mine.map((thread, index) => (
<ConversationRow
key={thread.id}
index={index}
thread={thread}
current={thread.id === currentId}
detail={`${formatVersionTime(new Date(thread.lastMessageAt), i18n.locale)} · ${outcome(thread)}`}
@@ -457,9 +460,10 @@ function PastConversations({ threads, document, currentId, onOpen }: PastConvers
<Trans>Other documents · {others.length}</Trans>
</h3>
<ul className="grid">
{others.map((thread) => (
{others.map((thread, index) => (
<ConversationRow
key={thread.id}
index={mine.length + index}
thread={thread}
current={false}
detail={`${formatVersionTime(new Date(thread.lastMessageAt), i18n.locale)} · ${outcome(thread)} · ${thread.coverLetterName ?? thread.resumeName ?? ""}`}
@@ -478,13 +482,22 @@ type ConversationRowProps = {
thread: ThreadSummary;
current: boolean;
detail: string;
index: number;
onOpen: () => void;
onDelete: () => void;
};
function ConversationRow({ thread, current, detail, onOpen, onDelete }: ConversationRowProps) {
function ConversationRow({ thread, current, detail, index, onOpen, onDelete }: ConversationRowProps) {
return (
<li className="group/row flex items-center gap-1 rounded-lg transition-colors hover:bg-hover">
<li
style={stagger(index)}
// cn keeps the last transition list, so the hover colour is named again after ENTER_CLASS's.
className={cn(
"group/row flex items-center gap-1 rounded-lg hover:bg-hover",
ENTER_CLASS,
"transition-[opacity,translate,background-color]",
)}
>
<button
type="button"
aria-current={current ? "true" : undefined}
@@ -18,6 +18,7 @@ import { cn } from "@reactive-resume/utils/style";
import { ChangeSet } from "@/features/resume/editor/proposals/proposal-list";
import { useEditorStore } from "@/features/resume/editor/store";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { ENTER_CLASS } from "@/libs/motion";
import { client, orpc } from "@/libs/orpc/client";
import { attachmentPart, fileToBase64, transcriptOf, useAssistantChat } from "./chat";
import { AssistantMarkdown } from "./markdown";
@@ -49,6 +50,8 @@ export function Conversation(props: ConversationProps) {
const [statuses, setStatuses] = useState<ReadonlyMap<string, EditStatus>>(new Map());
const [stopped, setStopped] = useState(false);
const scroller = useRef<HTMLDivElement>(null);
// Messages already in the thread when it opens are history; only new ones rise in.
const [initialIds] = useState(() => new Set(props.initialMessages.map((message) => message.id)));
const refresh = () => void queryClient.invalidateQueries({ queryKey: orpc.agent.threads.list.key() });
const { messages, sendMessage, status, error, clearError, regenerate, stop, addToolOutput } = useAssistantChat({
@@ -158,6 +161,7 @@ export function Conversation(props: ConversationProps) {
key={message.id}
message={message}
streaming={streaming && index === messages.length - 1}
enter={!initialIds.has(message.id)}
readOnly={readOnly}
statuses={statuses}
document={document}
@@ -167,14 +171,14 @@ export function Conversation(props: ConversationProps) {
))}
{status === "submitted" && (
<p className="flex items-center gap-2 text-ink-3 text-sm">
<p className={cn("flex items-center gap-2 text-ink-3 text-sm", ENTER_CLASS)}>
<Spinner decorative className="size-3.5" />
<Trans>Thinking…</Trans>
</p>
)}
{stopped && !streaming && !proposedInLast && (
<p className="flex items-center gap-2 text-ink-2 text-sm">
<p className="flex items-center gap-2 text-ink-2 text-sm starting:opacity-0 transition-opacity duration-standard ease-enter">
<Icon name="stop_circle" size={18} className="text-ink-3" />
<Trans>Stopped. No edits were proposed.</Trans>
<button
@@ -188,7 +192,10 @@ export function Conversation(props: ConversationProps) {
)}
{error && !streaming && (
<div role="alert" className="grid gap-2 rounded-xl bg-danger-soft p-3 text-[13px] text-danger-text">
<div
role="alert"
className="grid gap-2 rounded-xl bg-danger-soft p-3 text-[13px] text-danger-text starting:opacity-0 transition-opacity duration-standard ease-enter"
>
<span className="flex gap-2">
<Icon name="error" size={18} className="shrink-0" />
<Trans>
@@ -209,7 +216,7 @@ export function Conversation(props: ConversationProps) {
)}
{!streaming && messages.length > 0 && (
<div className="flex justify-end">
<div className="flex justify-end starting:opacity-0 transition-opacity duration-standard ease-enter">
<Button size="sm" variant="ghost" className="text-ink-3" onClick={() => void copyTranscript(messages)}>
<Icon name="content_copy" size={16} />
<Trans>Copy transcript</Trans>
@@ -253,6 +260,7 @@ function toProposals(
type MessageViewProps = {
message: UIMessage;
streaming: boolean;
enter: boolean;
readOnly: boolean;
statuses: ReadonlyMap<string, EditStatus>;
document: AssistantDocument;
@@ -260,12 +268,21 @@ type MessageViewProps = {
onRecord: (message: UIMessage, part: ToolPart, proposals: readonly Proposal[], status: EditStatus) => void;
};
function MessageView({ message, streaming, readOnly, statuses, document, onAnswer, onRecord }: MessageViewProps) {
function MessageView({
message,
streaming,
enter,
readOnly,
statuses,
document,
onAnswer,
onRecord,
}: MessageViewProps) {
if (message.role === "user") {
const text = message.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n");
const files = message.parts.filter((part) => part.type === "file");
return (
<div className="ms-8 grid justify-items-end gap-1">
<div className={cn("ms-8 grid justify-items-end gap-1", enter && ENTER_CLASS)}>
{text && <p className="whitespace-pre-wrap rounded-[12px_12px_4px_12px] bg-sunken px-3 py-2 text-sm">{text}</p>}
{files.map((file, index) => (
<span
@@ -286,7 +303,7 @@ function MessageView({ message, streaming, readOnly, statuses, document, onAnswe
const lastTextIndex = message.parts.findLastIndex((part) => part.type === "text");
return (
<div className="grid gap-3 text-sm">
<div className={cn("grid gap-3 text-sm", enter && ENTER_CLASS)}>
{message.parts.map((part, index) => {
const key = `${message.id}-${index}`;
if (part.type === "text")
@@ -333,7 +350,7 @@ function MessageView({ message, streaming, readOnly, statuses, document, onAnswe
);
}
type ToolPartViewProps = Omit<MessageViewProps, "streaming"> & { part: ToolPart };
type ToolPartViewProps = Omit<MessageViewProps, "streaming" | "enter"> & { part: ToolPart };
function ToolPartView({ part, message, readOnly, statuses, document, onAnswer, onRecord }: ToolPartViewProps) {
const working = part.state === "input-streaming" || part.state === "input-available";
@@ -16,6 +16,7 @@ import { cn } from "@reactive-resume/utils/style";
import { ImportError, readResumeFile } from "@/features/resume/import/read-file";
import { PdfViewer } from "@/features/resume/public/pdf-viewer";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { ENTER_CLASS, stagger } from "@/libs/motion";
import { client } from "@/libs/orpc/client";
import { PdfPasswordRequiredError, PdfTooLargeError, PdfUnreadableError } from "./extract-client";
import { getPdfCategoryLabel, getPdfFindingMessage } from "./messages";
@@ -334,7 +335,7 @@ function Result({ result, file, onFix, onReset }: ResultProps) {
<div
role="img"
aria-label={t`Readability score: ${report.score} out of 100`}
className="score-ring grid size-[84px] shrink-0 place-items-center rounded-full"
className="score-ring score-ring-reveal grid size-[84px] shrink-0 place-items-center rounded-full"
style={
{
"--ring-value": `${report.score}%`,
@@ -370,7 +371,11 @@ function Result({ result, file, onFix, onReset }: ResultProps) {
{rows.map((row, index) => {
const expanded = open === row.key;
return (
<div key={row.key} className={cn(index > 0 && "border-line border-t")}>
<div
key={row.key}
style={stagger(index)}
className={cn(ENTER_CLASS, index > 0 && "border-line border-t")}
>
<button
type="button"
aria-expanded={expanded}
@@ -29,10 +29,11 @@ export function ScoreHeader({ report }: ScoreHeaderProps) {
</span>
</div>
{/* Fills from empty when the report first shows. The starting --fill needs !important to beat the inline one; it's derived through --fill so RTL still fills from the right. */}
<div className="h-1.5 overflow-hidden rounded-full bg-sunken">
<div
className={cn(
"h-full translate-x-(--fill) rounded-full transition-[translate] duration-standard ease-enter rtl:-translate-x-(--fill)",
"h-full translate-x-(--fill) rounded-full transition-[translate,background-color] duration-emphasized ease-enter starting:[--fill:-100%]! rtl:-translate-x-(--fill)",
tone.bar,
)}
style={{ "--fill": `${report.score - 100}%` } as CSSProperties}
@@ -10,6 +10,7 @@ import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { cn } from "@reactive-resume/utils/style";
import { formatRelativeTime } from "@/libs/locale";
import { ENTER_CLASS, stagger } from "@/libs/motion";
import { DocumentMenuContent, useDocumentActions } from "./document-actions";
import { daysLeftInTrash } from "./filter";
import { useNewDocumentsStore } from "./new-documents";
@@ -19,6 +20,8 @@ export type DocumentItemProps = {
document: DocumentSummary;
onTags: (document: DocumentSummary) => void;
onLink: (document: DocumentSummary) => void;
/** Position in the library's first appearance, which staggers in; undefined afterwards and in Trash. */
introIndex?: number | undefined;
};
/** "Resume · Edited 2h ago", or the days left for a document in Trash. */
@@ -103,7 +106,7 @@ function LetterThumbnail({ name }: { name: string }) {
}
/** A 204px card: the real first page, title with ⋯, "Resume · Edited 2h ago" and the linked application. */
export function DocumentCard({ document, onTags, onLink }: DocumentItemProps) {
export function DocumentCard({ document, onTags, onLink, introIndex }: DocumentItemProps) {
const openDocument = useOpenDocument();
const [renaming, setRenaming] = useState(false);
const isNew = useNewDocumentsStore((state) => state.ids.includes(document.id)) && !document.trashedAt;
@@ -119,7 +122,17 @@ export function DocumentCard({ document, onTags, onLink }: DocumentItemProps) {
return (
<ContextMenu>
<ContextMenuTrigger
render={<article className={cn("group/card grid gap-2", document.trashedAt && "opacity-70")} />}
render={
<article
style={introIndex === undefined ? undefined : stagger(introIndex)}
className={cn(
"group/card grid gap-2",
introIndex !== undefined && ENTER_CLASS,
isNew && "animate-arrive",
document.trashedAt && "opacity-70",
)}
/>
}
>
<OpenLink
document={document}
@@ -127,7 +140,7 @@ export function DocumentCard({ document, onTags, onLink }: DocumentItemProps) {
className={cn(
"relative block aspect-page overflow-hidden rounded-[6px] shadow-[0_0_0_1px_var(--line),var(--shadow-1)] transition-[translate,scale,box-shadow] duration-quick ease-enter hover:-translate-y-0.5 hover:shadow-[0_0_0_1px_var(--line),var(--shadow-2)] active:scale-[0.98]",
isNew &&
"shadow-[0_0_0_2px_var(--accent),var(--shadow-1)] hover:shadow-[0_0_0_2px_var(--accent),var(--shadow-2)]",
"shadow-[0_0_0_2px_var(--accent),var(--shadow-1)] starting:shadow-[0_0_0_2px_transparent,var(--shadow-1)] hover:shadow-[0_0_0_2px_var(--accent),var(--shadow-2)]",
document.trashedAt && "pointer-events-none",
)}
>
@@ -189,7 +202,7 @@ export function DocumentCard({ document, onTags, onLink }: DocumentItemProps) {
}
/** The list view's row: Name, Type, Application, Edited, ⋯. */
export function DocumentRow({ document, onTags, onLink }: DocumentItemProps) {
export function DocumentRow({ document, onTags, onLink, introIndex }: DocumentItemProps) {
const { i18n } = useLingui();
const openDocument = useOpenDocument();
const [renaming, setRenaming] = useState(false);
@@ -208,8 +221,10 @@ export function DocumentRow({ document, onTags, onLink }: DocumentItemProps) {
<ContextMenuTrigger
render={
<tr
style={introIndex === undefined ? undefined : stagger(introIndex)}
className={cn(
"border-line border-b transition-colors duration-quick hover:bg-hover",
introIndex !== undefined && ENTER_CLASS,
document.trashedAt && "opacity-70",
)}
/>
@@ -9,6 +9,7 @@ import { Icon } from "@reactive-resume/ui/components/icon";
import { Kbd } from "@reactive-resume/ui/components/kbd";
import { NativeSelect } from "@reactive-resume/ui/components/native-select";
import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control";
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
import { Tabs, TabsCount, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
@@ -44,6 +45,13 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
// Grid and list animate in only after a switch, never on the page's first render.
const [viewSwitched, setViewSwitched] = useState(false);
const { data: documents, isPending } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: false } }));
// The library staggers in on its first appearance only; cards that appear later (filters, a new document) don't.
const [intro, setIntro] = useState(true);
useEffect(() => {
if (isPending) return;
const timeout = setTimeout(() => setIntro(false), INTRO_MS);
return () => clearTimeout(timeout);
}, [isPending]);
const view = search.view ?? readStoredView();
const setView = (next: "grid" | "list") => {
setViewSwitched(true);
@@ -174,14 +182,25 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
)}
{isPending ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="grid gap-2">
<div className="aspect-page animate-pulse rounded-[6px] bg-sunken" />
<div className="h-4 w-3/4 animate-pulse rounded bg-sunken" />
</div>
))}
</div>
view === "list" ? (
<div className="grid">
<div className="h-10 border-line border-b" />
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex h-[45px] items-center border-line border-b ps-3">
<Skeleton className="h-4 w-1/3" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="grid gap-2">
<Skeleton className="aspect-page rounded-[6px]" />
<Skeleton className="h-4 w-3/4 rounded" />
</div>
))}
</div>
)
) : shown.length === 0 ? (
<div className="grid justify-items-center gap-2 py-16 text-center">
<Icon name="search_off" size={28} className="text-ink-3" />
@@ -229,8 +248,13 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
</tr>
</thead>
<tbody>
{shown.map((document) => (
<DocumentRow key={`${document.type}:${document.id}`} document={document} {...itemProps} />
{shown.map((document, index) => (
<DocumentRow
key={`${document.type}:${document.id}`}
document={document}
introIndex={intro ? index : undefined}
{...itemProps}
/>
))}
</tbody>
</table>
@@ -241,8 +265,13 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
viewEnter,
)}
>
{shown.map((document) => (
<DocumentCard key={`${document.type}:${document.id}`} document={document} {...itemProps} />
{shown.map((document, index) => (
<DocumentCard
key={`${document.type}:${document.id}`}
document={document}
introIndex={intro ? index : undefined}
{...itemProps}
/>
))}
</div>
)}
@@ -257,6 +286,8 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
}
const VIEW_KEY = "documents-view";
// Longest stagger (150ms) plus the D2 entrance (200ms), with room to spare: then the intro classes come off.
const INTRO_MS = 400;
// The last view picked on this device; storage can be unavailable, and then it's the grid.
function readStoredView(): "grid" | "list" {
@@ -280,7 +311,7 @@ function FirstRun({ onChooseFile }: { onChooseFile: () => void }) {
const { startBlank, trySample, creating } = useStartDocument();
return (
<section className="grid max-w-xl gap-4 pt-4">
<section className="grid max-w-xl starting:translate-y-2 gap-4 pt-4 starting:opacity-0 transition-[opacity,translate] duration-emphasized ease-enter">
<h2 className="font-display font-medium text-[26px] leading-8">
<Trans>Let's start with what you have</Trans>
</h2>
@@ -239,7 +239,11 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und
notes={[t`file read`, t`${step.sections} sections`, t`${step.entries} entries`]}
/>
<p role="status" className="flex gap-2 text-[13px] leading-[19px]">
<Icon name="check_circle" size={20} className="text-accent-text" />
<Icon
name="check_circle"
size={20}
className="starting:scale-80 text-accent-text starting:opacity-0 transition-[opacity,scale] duration-standard ease-enter"
/>
<span>
<Trans>
{step.sections} sections and {step.entries} entries found.
@@ -1,10 +1,12 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { QueryClient } from "@tanstack/react-query";
import type { RefObject } from "react";
import type { ResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useInView } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { createPdfFirstPageImageUrl, releaseThumbnailUrls } from "@/features/resume/preview/pdf-thumbnail";
import { getResumeThumbnailCacheKey, getResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
@@ -46,7 +48,7 @@ function useThumbnailSize(containerRef: RefObject<HTMLDivElement | null>, enable
let media: MediaQueryList;
const syncSize = () => {
clearTimeout(timeout);
// Coalesce sidebar animations and continuous viewport resizing.
// Coalesce continuous resizing (window drags, zoom, the grid reflowing).
timeout = setTimeout(() => {
if (measured.width <= 0 || measured.height <= 0) return;
const next = getResumeThumbnailSize(measured, window.devicePixelRatio);
@@ -79,6 +81,19 @@ function useThumbnailSize(containerRef: RefObject<HTMLDivElement | null>, enable
return size;
}
// The newest thumbnail already drawn for this resume (an earlier edit, or another size), shown until the current
// one is ready, so an edit or a return visit never drops the card back to a placeholder.
const findLatestThumbnail = (queryClient: QueryClient, resumePrefix: string) => {
let latest: { url: string; at: number } | undefined;
for (const query of queryClient.getQueryCache().findAll({ queryKey: ["resume-thumbnail"] })) {
const key = query.queryKey[1];
const url = query.state.data;
if (typeof key !== "string" || !key.startsWith(resumePrefix) || typeof url !== "string") continue;
if (!latest || query.state.dataUpdatedAt > latest.at) latest = { url, at: query.state.dataUpdatedAt };
}
return latest?.url;
};
function useResumeThumbnail(
data: ResumeData | undefined,
cacheKey: string,
@@ -86,23 +101,12 @@ function useResumeThumbnail(
enabled: boolean,
): ThumbnailState {
const queryClient = useQueryClient();
const resumePrefix = `${cacheKey.slice(0, cacheKey.lastIndexOf(":"))}:`;
useEffect(() => {
releaseThumbnailUrls(queryClient, "resume-thumbnail");
}, [queryClient]);
useEffect(() => {
const queryCache = queryClient.getQueryCache();
const resumePrefix = `${cacheKey.slice(0, cacheKey.lastIndexOf(":"))}:`;
for (const query of queryCache.findAll({ queryKey: ["resume-thumbnail"] })) {
const queryCacheKey = query.queryKey[1];
if (typeof queryCacheKey === "string" && queryCacheKey.startsWith(resumePrefix) && queryCacheKey !== cacheKey) {
queryCache.remove(query);
}
}
}, [cacheKey, queryClient]);
const {
data: thumbnailData,
error: thumbnailError,
@@ -114,12 +118,30 @@ function useResumeThumbnail(
return createResumeThumbnailUrl(data, size, signal);
},
enabled: Boolean(enabled && data && size.width && size.height),
placeholderData: (previous, query) => (query?.queryKey[1] === cacheKey ? previous : undefined),
placeholderData: (previous) => previous ?? findLatestThumbnail(queryClient, resumePrefix),
staleTime: Number.POSITIVE_INFINITY,
gcTime: THUMBNAIL_CACHE_TIME,
});
const previousThumbnailData = useRef<string | undefined>(undefined);
// Drops this resume's thumbnails from earlier edits, except the one still on screen: it goes (and its URL is
// revoked) once its replacement is what's shown.
useEffect(() => {
const queryCache = queryClient.getQueryCache();
for (const query of queryCache.findAll({ queryKey: ["resume-thumbnail"] })) {
const queryCacheKey = query.queryKey[1];
if (
typeof queryCacheKey === "string" &&
queryCacheKey.startsWith(resumePrefix) &&
queryCacheKey !== cacheKey &&
query.state.data !== thumbnailData
) {
queryCache.remove(query);
}
}
}, [cacheKey, queryClient, resumePrefix, thumbnailData]);
useEffect(() => {
if (thumbnailError) console.error("Failed to generate resume thumbnail", thumbnailError);
}, [thumbnailError]);
@@ -136,9 +158,9 @@ function useResumeThumbnail(
previousThumbnailData.current = thumbnailData;
}, [queryClient, thumbnailData]);
if (!data || !cacheKey) return { status: "idle" };
if (thumbnailIsError) return { status: "error" };
if (thumbnailData) return { status: "ready", url: thumbnailData };
if (!data || !cacheKey) return { status: "idle" };
return { status: "loading" };
}
@@ -165,7 +187,7 @@ export function ResumeThumbnail({ resume }: ResumeThumbnailProps) {
{thumbnail.status === "ready" ? (
<div
aria-hidden
className="absolute inset-0 bg-center bg-contain bg-white bg-no-repeat"
className="absolute inset-0 bg-center bg-contain bg-white bg-no-repeat starting:opacity-0 transition-opacity duration-standard ease-enter"
style={{ backgroundImage: `url(${thumbnail.url})` }}
/>
) : hasFailed ? (
@@ -174,7 +196,7 @@ export function ResumeThumbnail({ resume }: ResumeThumbnailProps) {
</div>
) : (
// Loading: a sunken placeholder at the page's real size, so nothing jumps.
<div className="absolute inset-0 animate-pulse bg-sunken" />
<Skeleton className="absolute inset-0 rounded-none" />
)}
</div>
);
@@ -40,19 +40,14 @@ const EXITING_TRANSITION = { duration: 0.1, delay: 0.18 };
// the old one at once, and no half-faded page is ever on screen.
const INSTANT_TRANSITION = { duration: 0 };
const createPreviewPdf = (
file: Blob,
id: number,
hasExistingPreview: boolean,
template: Template,
pageMap: PageMap | undefined,
): PreviewPdf => ({
const createPreviewPdf = (file: Blob, id: number, template: Template, pageMap: PageMap | undefined): PreviewPdf => ({
file,
id,
pageMap,
numPages: 0,
pageSizes: {},
phase: hasExistingPreview ? "staged" : "active",
// Every render, the first one included, paints hidden and fades in once all its pages are drawn.
phase: "staged",
renderedPages: [],
template,
});
@@ -158,13 +153,7 @@ export function ResumePreviewClient({
);
if (!cancelled && requestId === requestIdRef.current) {
const nextPdf = createPreviewPdf(
blob,
pdfIdRef.current++,
hasPreviewRef.current,
resumeData.metadata.template,
pageMap,
);
const nextPdf = createPreviewPdf(blob, pdfIdRef.current++, resumeData.metadata.template, pageMap);
hasPreviewRef.current = true;
setPreviewLayers((current) => addPreviewLayer(current, nextPdf));
@@ -203,26 +192,28 @@ export function ResumePreviewClient({
const visiblePdf = getActivePreviewLayer(previewLayers);
const resolvedPageGap = getResumePreviewGapValue(pageGap);
if (!visiblePdf) {
return (
<>
<ResumeAccessibleText data={resumeData} />
<ResumePreviewLoader
pageCount={getResumePreviewPageCount(resumeData)}
pageClassName={pageClassName}
pageGap={pageGap}
pageLayout={pageLayout}
pageScale={pageScale}
showPageNumbers={showPageNumbers}
/>
</>
);
}
return (
<div className={cn("grid", className)}>
<ResumeAccessibleText data={resumeData} />
<AnimatePresence initial={false}>
{!visiblePdf && (
// Holds under the first render until it's opaque, then drops out, like any replaced layer.
<m.div
key="loader"
className="col-start-1 row-start-1"
exit={{ opacity: 0 }}
transition={reducedMotion ? INSTANT_TRANSITION : EXITING_TRANSITION}
>
<ResumePreviewLoader
pageCount={getResumePreviewPageCount(resumeData)}
pageClassName={pageClassName}
pageGap={pageGap}
pageLayout={pageLayout}
pageScale={pageScale}
showPageNumbers={showPageNumbers}
/>
</m.div>
)}
{previewLayers.map((visiblePdf) => (
<m.div
key={visiblePdf.id}
@@ -66,7 +66,7 @@ function pdfViewerReducer(state: PdfViewerState, action: PdfViewerAction): PdfVi
? { ...state, viewerHeight: action.height }
: state;
case "ready":
return { ...state, isReady: true };
return state.isReady ? state : { ...state, isReady: true };
case "error":
return { ...state, error: true, isReady: false };
}
@@ -161,6 +161,12 @@ export function PdfViewer({
});
};
// The overlay stays until a page has actually painted, so it never lifts onto a blank viewer.
const reveal = () => {
syncViewerHeight();
dispatch({ type: "ready" });
};
const setInitialScale = () => {
if (!isCancelled && pdfViewer) {
pdfViewer.currentScaleValue = "page-width";
@@ -170,7 +176,7 @@ export function PdfViewer({
eventBus.on("pagesinit", setInitialScale);
eventBus.on("pagesloaded", syncViewerHeight);
eventBus.on("pagerendered", syncViewerHeight);
eventBus.on("pagerendered", reveal);
viewer.replaceChildren();
dispatch({ type: "viewerLoading" });
resizeObserver = new ResizeObserver(syncViewerHeight);
@@ -208,7 +214,6 @@ export function PdfViewer({
pdfViewer.setDocument(pdfDocument);
linkService.setDocument(pdfDocument);
syncViewerHeight();
dispatch({ type: "ready" });
}
}
};
@@ -224,7 +229,7 @@ export function PdfViewer({
isCancelled = true;
eventBus.off("pagesinit", setInitialScale);
eventBus.off("pagesloaded", syncViewerHeight);
eventBus.off("pagerendered", syncViewerHeight);
eventBus.off("pagerendered", reveal);
abortController.abort();
window.cancelAnimationFrame(animationFrameId);
resizeObserver?.disconnect();
@@ -237,7 +242,8 @@ export function PdfViewer({
return (
<div
ref={rootRef}
className={cn("pdf-viewer relative bg-sunken", viewerHeight ? "min-h-0" : "min-h-48", className)}
// Until the real height is known, reserve an A4 page so the viewer doesn't grow from 192px.
className={cn("pdf-viewer relative bg-sunken", viewerHeight ? "min-h-0" : "aspect-[210/297]", className)}
style={viewerHeight ? { height: viewerHeight } : undefined}
>
<div ref={containerRef} className="absolute inset-0 overflow-visible">
@@ -248,8 +254,14 @@ export function PdfViewer({
<div className="absolute inset-0 flex items-center justify-center bg-bg px-6 text-center text-ink-3 text-sm">
Unable to display PDF preview.
</div>
) : isReady ? null : (
<div className="absolute inset-0 flex items-center justify-center bg-bg">
) : (
<div
aria-hidden={isReady}
className={cn(
"absolute inset-0 flex items-center justify-center bg-bg transition-[opacity,visibility] ease-enter",
isReady ? "invisible opacity-0 duration-[calc(var(--d2)*0.7)]" : "duration-standard",
)}
>
<Spinner className="size-6" />
</div>
)}
@@ -15,6 +15,7 @@ import {
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { authClient } from "@/libs/auth/client";
@@ -89,12 +90,14 @@ export function ApiKeysSection() {
</Button>
}
>
{isLoading ? null : keys.length === 0 ? (
<p className="rounded-xl border border-line-2 border-dashed p-4 text-ink-2 text-sm">
{isLoading ? (
<Skeleton className="h-14 rounded-xl" />
) : keys.length === 0 ? (
<p className="rounded-xl border border-line-2 border-dashed p-4 text-ink-2 text-sm starting:opacity-0 transition-opacity duration-standard ease-enter">
<Trans>No keys yet.</Trans>
</p>
) : (
<table className="w-full overflow-hidden rounded-xl text-[13px] outline outline-line max-sm:block">
<table className="w-full overflow-hidden rounded-xl text-[13px] starting:opacity-0 outline outline-line transition-opacity duration-standard ease-enter max-sm:block">
<thead className="bg-bg text-ink-3 text-xs max-sm:hidden">
<tr className="h-9 text-start">
<th scope="col" className="ps-3.5 text-start font-medium">
+37 -1
View File
@@ -7,9 +7,45 @@
initial-value: 0%;
}
/* Registered so crossing 80 blends accent and warn instead of snapping. */
@property --ring-color {
syntax: "<color>";
inherits: false;
initial-value: transparent;
}
@utility score-ring {
background: conic-gradient(var(--ring-color) 0 var(--ring-value), var(--sunken) 0);
transition: --ring-value var(--d3) var(--ease);
transition:
--ring-value var(--d3) var(--ease),
--ring-color var(--d3) var(--ease);
}
/*
* The free ATS checker's first reveal: the arc fills from 0 once per checked file. 480ms is over the UI budget on
* purpose: it happens once per file and the fill is what explains the number. A keyframe (not @starting-style)
* because it must run on the element's first style; the reduced-motion rule in globals.css makes it instant.
*/
@utility score-ring-reveal {
animation: score-ring-reveal 480ms var(--ease) backwards;
}
@keyframes score-ring-reveal {
from {
--ring-value: 0%;
}
}
@theme {
/* A document made on this device lands on the grid: a fade up from 96%, once per mount. */
--animate-arrive: arrive var(--d2) var(--ease) backwards;
@keyframes arrive {
from {
opacity: 0;
scale: 0.96;
}
}
}
/* A wavy warn underline under a line of the page that has a Check issue. */
+12
View File
@@ -1,3 +1,5 @@
import type { CSSProperties } from "react";
// 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).
@@ -30,3 +32,13 @@ export function switchModeInstantly(change: () => void) {
change();
requestAnimationFrame(() => requestAnimationFrame(() => list?.removeAttribute("data-instant")));
}
/**
* Something entering from nothing on its first appearance: a 4px rise and fade over D2. In a list, pair it with
* `stagger(index)` on the same element; without it the delay is 0.
*/
export const ENTER_CLASS =
"starting:translate-y-1 starting:opacity-0 transition-[opacity,translate] delay-(--stagger) duration-standard ease-enter motion-reduce:delay-0";
/** The index-th item of a list's first appearance: 30ms apart, capped at 150ms (the first six are staggered). */
export const stagger = (index: number) => ({ "--stagger": `${Math.min(index, 5) * 30}ms` }) as CSSProperties;
@@ -1,4 +1,5 @@
import { lazy, Suspense } from "react";
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
import { SectionBase } from "../shared/section-base";
const StylesheetEditorShell = lazy(() => import("@/features/resume/stylesheet/editor"));
@@ -6,9 +7,7 @@ const StylesheetEditorShell = lazy(() => import("@/features/resume/stylesheet/ed
export function CustomStylesSectionBuilder() {
return (
<SectionBase type="styles" className="space-y-4">
<Suspense
fallback={<div role="status" className="h-72 animate-pulse rounded-md bg-sunken" aria-label="Loading editor" />}
>
<Suspense fallback={<Skeleton role="status" aria-label="Loading editor" className="h-72" />}>
<StylesheetEditorShell />
</Suspense>
</SectionBase>
@@ -14,6 +14,7 @@ import {
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
@@ -24,7 +25,7 @@ import { ApplicationBoard } from "@/features/applications/components/board";
import { ApplicationCalendar } from "@/features/applications/components/calendar-view";
import { ExportApplicationsSheet } from "@/features/applications/components/export-applications-sheet";
import { ImportApplicationsSheet } from "@/features/applications/components/import-applications-sheet";
import { ApplicationInsights } from "@/features/applications/components/insights-view";
import { ApplicationInsights, InsightsSkeleton } from "@/features/applications/components/insights-view";
import { ApplicationList } from "@/features/applications/components/list-view";
import { getNextStep } from "@/features/applications/next-step";
import { applicationsListQueryOptions } from "@/features/applications/queries";
@@ -197,7 +198,9 @@ function RouteComponent() {
)}
</div>
{noMatches ? (
{isPending ? (
<ViewSkeleton view={shown} />
) : noMatches ? (
<div className="grid justify-items-center gap-2 py-16 text-center">
<p className="font-medium text-sm">
<Trans>No applications match “{query.trim()}”.</Trans>
@@ -210,8 +213,10 @@ function RouteComponent() {
<div
key={shown}
className={cn(
viewSwitched &&
"starting:translate-y-1 starting:opacity-0 transition-[opacity,translate] duration-standard ease-enter",
viewSwitched
? "starting:translate-y-1 starting:opacity-0 transition-[opacity,translate] duration-standard ease-enter"
: // The list fades in on its first appearance, after its skeleton.
shown === "list" && "starting:opacity-0 transition-opacity duration-standard ease-enter",
)}
>
{shown === "list" && (
@@ -331,7 +336,7 @@ function FollowUpNudge({ applications, onOpen }: { applications: Application[];
function EmptyState({ onAdd, onImport }: { onAdd: () => void; onImport: () => void }) {
return (
<div className="grid justify-items-center gap-3 py-20 text-center">
<div className="grid starting:translate-y-2 justify-items-center gap-3 py-20 text-center starting:opacity-0 transition-[opacity,translate] duration-emphasized ease-enter">
<span className="grid size-12 place-items-center rounded-xl bg-sunken text-ink-2">
<Icon name="work" size={26} />
</span>
@@ -353,3 +358,17 @@ function EmptyState({ onAdd, onImport }: { onAdd: () => void; onImport: () => vo
</div>
);
}
/** Each view's shape while applications load, so the first rows land where the placeholders were. */
function ViewSkeleton({ view }: { view: View }) {
if (view === "insights") return <InsightsSkeleton />;
if (view === "list")
return (
<div className="grid gap-2 pt-10">
{Array.from({ length: 6 }, (_, index) => (
<Skeleton key={index} className="h-[52px]" />
))}
</div>
);
return <Skeleton className="h-[calc(100svh-230px)] min-h-96 rounded-xl" />;
}