(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" && (
-
+
Thinking…
)}
{stopped && !streaming && !proposedInLast && (
-
+
Stopped. No edits were proposed.
+
@@ -209,7 +216,7 @@ export function Conversation(props: ConversationProps) {
)}
{!streaming && messages.length > 0 && (
-
+
void copyTranscript(messages)}>
Copy transcript
@@ -253,6 +260,7 @@ function toProposals(
type MessageViewProps = {
message: UIMessage;
streaming: boolean;
+ enter: boolean;
readOnly: boolean;
statuses: ReadonlyMap;
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 (
-
+
{text &&
{text}
}
{files.map((file, index) => (
part.type === "text");
return (
-
+
{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
& { part: ToolPart };
+type ToolPartViewProps = Omit & { part: ToolPart };
function ToolPartView({ part, message, readOnly, statuses, document, onAnswer, onRecord }: ToolPartViewProps) {
const working = part.state === "input-streaming" || part.state === "input-available";
diff --git a/apps/web/src/features/ats-checker/checker.tsx b/apps/web/src/features/ats-checker/checker.tsx
index 9a6a2e208..6d754f0ac 100644
--- a/apps/web/src/features/ats-checker/checker.tsx
+++ b/apps/web/src/features/ats-checker/checker.tsx
@@ -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) {
{
const expanded = open === row.key;
return (
-
0 && "border-line border-t")}>
+
0 && "border-line border-t")}
+ >
+ {/* 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. */}
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 (
}
+ render={
+
+ }
>
@@ -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) {
diff --git a/apps/web/src/features/documents/documents-page.tsx b/apps/web/src/features/documents/documents-page.tsx
index 8b939876f..dd72f8624 100644
--- a/apps/web/src/features/documents/documents-page.tsx
+++ b/apps/web/src/features/documents/documents-page.tsx
@@ -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 ? (
-
- {Array.from({ length: 6 }, (_, index) => (
-
- ))}
-
+ view === "list" ? (
+
+
+ {Array.from({ length: 6 }, (_, index) => (
+
+
+
+ ))}
+
+ ) : (
+
+ {Array.from({ length: 6 }, (_, index) => (
+
+
+
+
+ ))}
+
+ )
) : shown.length === 0 ? (
@@ -229,8 +248,13 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
- {shown.map((document) => (
-
+ {shown.map((document, index) => (
+
))}
@@ -241,8 +265,13 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
viewEnter,
)}
>
- {shown.map((document) => (
-
+ {shown.map((document, index) => (
+
))}
)}
@@ -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 (
-
+
Let's start with what you have
diff --git a/apps/web/src/features/documents/new-document-dialog.tsx b/apps/web/src/features/documents/new-document-dialog.tsx
index 59beccfbb..c40deb74d 100644
--- a/apps/web/src/features/documents/new-document-dialog.tsx
+++ b/apps/web/src/features/documents/new-document-dialog.tsx
@@ -239,7 +239,11 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und
notes={[t`file read`, t`${step.sections} sections`, t`${step.entries} entries`]}
/>
-
+
{step.sections} sections and {step.entries} entries found.
diff --git a/apps/web/src/features/documents/resume-thumbnail.tsx b/apps/web/src/features/documents/resume-thumbnail.tsx
index a2687c77d..f0d7e066c 100644
--- a/apps/web/src/features/documents/resume-thumbnail.tsx
+++ b/apps/web/src/features/documents/resume-thumbnail.tsx
@@ -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, 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, 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(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" ? (
) : hasFailed ? (
@@ -174,7 +196,7 @@ export function ResumeThumbnail({ resume }: ResumeThumbnailProps) {
) : (
// Loading: a sunken placeholder at the page's real size, so nothing jumps.
-
+
)}
);
diff --git a/apps/web/src/features/resume/preview/preview.browser.tsx b/apps/web/src/features/resume/preview/preview.browser.tsx
index 7cd09aac3..15f48745d 100644
--- a/apps/web/src/features/resume/preview/preview.browser.tsx
+++ b/apps/web/src/features/resume/preview/preview.browser.tsx
@@ -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 (
- <>
-
-
- >
- );
- }
-
return (
+ {!visiblePdf && (
+ // Holds under the first render until it's opaque, then drops out, like any replaced layer.
+
+
+
+ )}
{previewLayers.map((visiblePdf) => (
{
+ 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 (
@@ -248,8 +254,14 @@ export function PdfViewer({
Unable to display PDF preview.
- ) : isReady ? null : (
-
+ ) : (
+
)}
diff --git a/apps/web/src/features/settings/ai/api-keys.tsx b/apps/web/src/features/settings/ai/api-keys.tsx
index ec9451a7e..bb06016e2 100644
--- a/apps/web/src/features/settings/ai/api-keys.tsx
+++ b/apps/web/src/features/settings/ai/api-keys.tsx
@@ -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() {
}
>
- {isLoading ? null : keys.length === 0 ? (
-
+ {isLoading ? (
+
+ ) : keys.length === 0 ? (
+
No keys yet.
) : (
-
+
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 50fe4f087..f22e52ee3 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -7,9 +7,45 @@
initial-value: 0%;
}
+/* Registered so crossing 80 blends accent and warn instead of snapping. */
+@property --ring-color {
+ syntax: "";
+ 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. */
diff --git a/apps/web/src/libs/motion.ts b/apps/web/src/libs/motion.ts
index 2a284c715..1ededfdb8 100644
--- a/apps/web/src/libs/motion.ts
+++ b/apps/web/src/libs/motion.ts
@@ -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;
diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx
index e0a619147..8cb4d18c1 100644
--- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx
@@ -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 (
- }
- >
+ }>
diff --git a/apps/web/src/routes/dashboard/applications/index.tsx b/apps/web/src/routes/dashboard/applications/index.tsx
index 5628de408..4e24c3957 100644
--- a/apps/web/src/routes/dashboard/applications/index.tsx
+++ b/apps/web/src/routes/dashboard/applications/index.tsx
@@ -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() {
)}
- {noMatches ? (
+ {isPending ? (
+
+ ) : noMatches ? (
No applications match “{query.trim()}”.
@@ -210,8 +213,10 @@ function RouteComponent() {
{shown === "list" && (
@@ -331,7 +336,7 @@ function FollowUpNudge({ applications, onOpen }: { applications: Application[];
function EmptyState({ onAdd, onImport }: { onAdd: () => void; onImport: () => void }) {
return (
-
+
@@ -353,3 +358,17 @@ function EmptyState({ onAdd, onImport }: { onAdd: () => void; onImport: () => vo
);
}
+
+/** 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
;
+ if (view === "list")
+ return (
+
+ {Array.from({ length: 6 }, (_, index) => (
+
+ ))}
+
+ );
+ return
;
+}