fix(web): replace generic spinners with content-shaped loading skeletons
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

The six loading states across the Knowledge wiki (initial app load, the
reader's note/history fetches, and the four Cleanup tabs) all showed a
centered spinner with no relation to what was about to render — costing
a full reflow the instant real content landed. Replaces each with a
skeleton shaped like its actual content (tree rows, reader header +
prose, revision list + diff, cluster cards, table rows, flat lists)
using the existing shadcn Skeleton primitive already used elsewhere.

Verified each of the six by temporarily injecting a delay into
fetchWithAuth and screenshotting the transient state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 08:44:52 +02:00
parent 89312a9ce4
commit 7e1ccad5f4
3 changed files with 151 additions and 12 deletions

View File

@@ -28,7 +28,7 @@
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Badge } from '$lib/components/ui/badge'
import Spinner from '$lib/components/Spinner.svelte'
import { Skeleton } from '$lib/components/ui/skeleton'
import { toast } from 'svelte-sonner'
import { kindMeta } from './kinds'
import { relativeTime } from '$lib/utils'
@@ -89,7 +89,7 @@
mergeSources = sources
} catch (e) {
duplicatesError = errMsg(e)
clusters = [] // stop the spinner — the error message above explains the empty state
clusters = [] // clear the loading skeleton — the error message above explains the empty state
}
}
@@ -289,7 +289,23 @@
</p>
{/if}
{#if clusters === null}
<div class="flex justify-center py-8"><Spinner /></div>
<div class="flex flex-col gap-3">
{#each Array(2) as _, ci (ci)}
<div class="overflow-hidden rounded-lg border">
<div
class="flex items-center justify-between gap-2 border-b bg-muted/30 px-2.5 py-1.5"
>
<Skeleton class="h-3 w-28" />
<Skeleton class="h-6 w-28" />
</div>
<div class="flex flex-col gap-2 p-2.5">
{#each Array(ci === 0 ? 3 : 2) as _, ri (ri)}
<Skeleton class="h-3.5" style="width: {70 - ri * 10}%" />
{/each}
</div>
</div>
{/each}
</div>
{:else if clusters.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">No likely duplicates found.</p>
{:else}
@@ -412,7 +428,31 @@
</p>
{/if}
{#if tags === null}
<div class="flex justify-center py-8"><Spinner /></div>
<table class="w-full text-xs">
<thead>
<tr class="border-b text-left text-muted-foreground">
<th class="py-1 font-normal">Tag</th>
<th class="py-1 font-normal" colspan="2">Uses</th>
<th class="py-1 font-normal">Variants</th>
<th class="py-1"></th>
</tr>
</thead>
<tbody>
{#each Array(10) as _, i (i)}
<tr class="border-b border-border/50">
<td class="w-32 py-1.5 pr-2"
><Skeleton class="h-3" style="width: {60 - i * 3}%" /></td
>
<td class="w-8 py-1.5 pr-1"><Skeleton class="ml-auto h-3 w-4" /></td>
<td class="w-24 py-1.5 pr-3">
<Skeleton class="h-1 rounded-full" style="width: {90 - i * 8}%" />
</td>
<td class="py-1.5 pr-2"><Skeleton class="h-3 w-6" /></td>
<td class="py-1.5"></td>
</tr>
{/each}
</tbody>
</table>
{:else}
{@const maxUses = Math.max(1, ...tags.map((t) => t.uses))}
<table class="w-full text-xs">
@@ -509,7 +549,15 @@
</p>
{/if}
{#if orphans === null}
<div class="flex justify-center py-8"><Spinner /></div>
<div class="flex flex-col gap-0.5">
{#each Array(7) as _, i (i)}
<div class="flex items-center gap-2 px-1.5 py-1.5">
<Skeleton class="h-3.5 flex-1" style="max-width: {60 - (i % 4) * 8}%" />
<Skeleton class="h-4 w-14 shrink-0 rounded-full" />
<Skeleton class="h-3 w-10 shrink-0" />
</div>
{/each}
</div>
{:else if orphans.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Nothing orphaned.</p>
{:else}
@@ -541,7 +589,15 @@
</p>
{/if}
{#if trash === null}
<div class="flex justify-center py-8"><Spinner /></div>
<div class="flex flex-col gap-0.5">
{#each Array(4) as _, i (i)}
<div class="flex items-center gap-2 px-1.5 py-1.5">
<Skeleton class="h-3.5 flex-1" style="max-width: {55 - i * 6}%" />
<Skeleton class="h-3 w-32 shrink-0" />
<Skeleton class="h-6 w-16 shrink-0" />
</div>
{/each}
</div>
{:else if trash.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">Trash is empty.</p>
{:else}

View File

@@ -27,7 +27,7 @@
import { Button } from '$lib/components/ui/button'
import { Input } from '$lib/components/ui/input'
import { Textarea } from '$lib/components/ui/textarea'
import Spinner from '$lib/components/Spinner.svelte'
import { Skeleton } from '$lib/components/ui/skeleton'
import PencilIcon from '@lucide/svelte/icons/pencil'
import TrashIcon from '@lucide/svelte/icons/trash-2'
import HistoryIcon from '@lucide/svelte/icons/history'
@@ -224,7 +224,33 @@
{#if !item}
<WikiOverview items={allItems} onSelect={onNavigate} {onNew} />
{:else if loading}
<div class="flex h-full items-center justify-center"><Spinner /></div>
<!-- Shaped like the loaded header/tags/body below rather than a
centered spinner, so the switch from "loading" to "loaded" is a
content swap, not a layout jump — the title, meta line, tag row,
and first few lines of body all keep their real position. -->
<div class="flex items-start justify-between gap-2 border-b pb-2.5">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<Skeleton class="size-4 shrink-0 rounded" />
<Skeleton class="h-5 w-56" />
</div>
<div class="mt-2 flex items-center gap-2">
<Skeleton class="h-3 w-16" />
<Skeleton class="h-3 w-20" />
<Skeleton class="h-3 w-24" />
</div>
</div>
<Skeleton class="h-7 w-16 shrink-0" />
</div>
<div class="flex flex-wrap gap-1.5 pt-3">
<Skeleton class="h-5 w-14 rounded-full" />
<Skeleton class="h-5 w-16 rounded-full" />
</div>
<div class="flex flex-col gap-2.5 pt-2">
{#each Array(6) as _, i (i)}
<Skeleton class="h-4" style="width: {i === 5 ? 45 : 96 - i * 4}%" />
{/each}
</div>
{:else if !content}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
Couldn't load this note.
@@ -374,7 +400,21 @@
<Tabs.Content value="history" class="min-h-0 flex-1 overflow-y-auto pt-2">
{#if revisionsLoading}
<div class="flex justify-center py-8"><Spinner /></div>
<div class="flex gap-3">
<div class="flex w-40 shrink-0 flex-col gap-2 px-2 py-1">
{#each Array(4) as _, i (i)}
<div class="flex flex-col gap-1">
<Skeleton class="h-3 w-16" />
<Skeleton class="h-2.5 w-20" />
</div>
{/each}
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1.5 rounded border p-2">
{#each Array(8) as _, i (i)}
<Skeleton class="h-3" style="width: {90 - (i % 4) * 15}%" />
{/each}
</div>
</div>
{:else if !revisions || revisions.length === 0}
<p class="py-8 text-center text-xs text-muted-foreground">
No prior revisions — this is the first version.

View File

@@ -20,6 +20,7 @@
import WikiQuickOpen from '$lib/components/knowledge/WikiQuickOpen.svelte'
import * as Dialog from '$lib/components/ui/dialog'
import { Button } from '$lib/components/ui/button'
import { Skeleton } from '$lib/components/ui/skeleton'
import WrenchIcon from '@lucide/svelte/icons/wrench'
import BookOpenIcon from '@lucide/svelte/icons/book-open'
@@ -149,9 +150,51 @@
<div class="min-h-0 flex-1">
{#if itemsLoading}
<div class="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading…
</div>
<!-- Shaped like the real three-pane layout below (same Splitpanes
proportions) rather than a centered spinner: a spinner tells the
operator nothing about what's about to render and costs a full
reflow the instant real content lands. This costs none — the
panes are already the right size. -->
<Splitpanes theme="oikos-theme" dblClickSplitter={false} class="h-full">
<Pane size={22} minSize={15} maxSize={40}>
<div class="flex h-full flex-col gap-3 overflow-hidden p-1.5">
<Skeleton class="h-7 w-full" />
<Skeleton class="h-3 w-16" />
<div class="flex flex-col gap-3">
{#each Array(4) as _, gi (gi)}
<div class="flex flex-col gap-1.5">
<Skeleton class="h-3 w-20" />
<div class="ml-3 flex flex-col gap-2 border-l pl-2">
{#each Array(3) as _, ri (ri)}
<Skeleton class="h-3.5" style="width: {75 - ri * 15}%" />
{/each}
</div>
</div>
{/each}
</div>
</div>
</Pane>
<Pane size={56} minSize={30}>
<div class="flex h-full flex-col gap-3 overflow-hidden p-2">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-9 w-40" />
<div class="mt-1 grid grid-cols-4 gap-px overflow-hidden rounded-lg bg-border/60">
{#each Array(4) as _, i (i)}
<div class="flex flex-col gap-1.5 bg-card px-3 py-2.5">
<Skeleton class="h-3 w-14" />
<Skeleton class="h-5 w-8" />
</div>
{/each}
</div>
<Skeleton class="mt-2 h-3 w-full" />
<div class="mt-2 flex flex-col gap-2.5">
{#each Array(5) as _, i (i)}
<Skeleton class="h-4" style="width: {85 - i * 8}%" />
{/each}
</div>
</div>
</Pane>
</Splitpanes>
{:else if loadError}
<div class="flex h-full flex-col items-center justify-center gap-2 text-sm">
<p class="text-destructive">{loadError}</p>