From 28eea458d9e032c87f0efacdfffea408a12aaede Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 13:01:49 +0200 Subject: [PATCH] feat(web): let content arrive instead of popping in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../applications/components/insights-view.tsx | 43 ++++++++++---- .../features/assistant/assistant-panel.tsx | 27 ++++++--- .../src/features/assistant/conversation.tsx | 33 ++++++++--- apps/web/src/features/ats-checker/checker.tsx | 9 ++- .../ats-checker/report/score-header.tsx | 3 +- .../src/features/documents/document-card.tsx | 23 ++++++-- .../src/features/documents/documents-page.tsx | 57 ++++++++++++++----- .../documents/new-document-dialog.tsx | 6 +- .../features/documents/resume-thumbnail.tsx | 56 ++++++++++++------ .../resume/preview/preview.browser.tsx | 53 +++++++---------- .../src/features/resume/public/pdf-viewer.tsx | 26 ++++++--- .../web/src/features/settings/ai/api-keys.tsx | 9 ++- apps/web/src/index.css | 38 ++++++++++++- apps/web/src/libs/motion.ts | 12 ++++ .../-sidebar/right/sections/custom-styles.tsx | 5 +- .../routes/dashboard/applications/index.tsx | 29 ++++++++-- 16 files changed, 316 insertions(+), 113 deletions(-) diff --git a/apps/web/src/features/applications/components/insights-view.tsx b/apps/web/src/features/applications/components/insights-view.tsx index fcce989ba..ce98d658f 100644 --- a/apps/web/src/features/applications/components/insights-view.tsx +++ b/apps/web/src/features/applications/components/insights-view.tsx @@ -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 ( +
+ +
+ + +
+ +
+ ); +} + 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
; + if (!data) return ; 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 How far applications get
    - {outcomes.funnel.map((row) => ( + {outcomes.funnel.map((row, index) => (
  1. {row.reached} @@ -132,13 +152,16 @@ export function ApplicationInsights({ applications }: { applications: Applicatio Applications sent per week (last 8 weeks)

    - {timeline.map((bucket) => ( + {timeline.map((bucket, index) => (
    {bucket.count || ""}
    {bucket.label} @@ -161,13 +184,13 @@ export function ApplicationInsights({ applications }: { applications: Applicatio No source data yet.

    ) : ( - data.bySource.map((row) => ( + data.bySource.map((row, index) => (
    {row.source}
    {row.count} diff --git a/apps/web/src/features/assistant/assistant-panel.tsx b/apps/web/src/features/assistant/assistant-panel.tsx index 7cbef0636..ef1d93c89 100644 --- a/apps/web/src/features/assistant/assistant-panel.tsx +++ b/apps/web/src/features/assistant/assistant-panel.tsx @@ -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 ( -
    + // Hidden for the first 150ms, so a quick refetch never flashes a spinner. +
    ); @@ -352,8 +354,8 @@ function EmptyState({ document, disabled, onPick }: EmptyStateProps) { )}
      - {suggestionsFor(document, prepare).map((suggestion) => ( -
    • + {suggestionsFor(document, prepare).map((suggestion, index) => ( +
    + {/* 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 ; +}