feat(design): skeleton first loads by shape on the shared reveal clock (E28 S3d2, B8)
This commit is contained in:
+2
-1
@@ -191,7 +191,8 @@ Also in force:
|
||||
3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add
|
||||
height, never width, so each module has a short rail label that fits, and the full name stays in the
|
||||
tooltip. No wide padding around the icons.
|
||||
- **Loading (B8):** a first load of a grid, list or table shows its skeleton (a `Skeleton` primitive), so
|
||||
- **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" |
|
||||
"poster" | "list" | "table"`, built from the `Skeleton` primitive), so
|
||||
nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears
|
||||
before ~300ms. [Nielsen #1]
|
||||
- **The public watch page (B6)** follows these same rules (glass, `Button`, the pressed-pill toggle, the
|
||||
|
||||
@@ -369,7 +369,7 @@ export default function AdminLogs() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : logs.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="table" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
|
||||
{t("adminLogs.empty")}
|
||||
|
||||
@@ -224,7 +224,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading && !q.data ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
|
||||
) : (
|
||||
@@ -395,7 +395,7 @@ function PendingAccess() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : rows.isLoading && !rows.data ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{list.map((u) => (
|
||||
@@ -664,7 +664,7 @@ function AdminInvites() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : invites.isLoading && !invites.data ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : pending.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
||||
) : (
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function AuditLog() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="table" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("audit.empty")}</p>
|
||||
) : (
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function ChannelDiscovery({
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : query.isLoading ? (
|
||||
<LoadingState label={t("channels.discovery.loading")} />
|
||||
<LoadingState shape="list" label={t("channels.discovery.loading")} />
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
|
||||
@@ -619,7 +619,7 @@ export default function Channels() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : channelsQuery.isLoading ? (
|
||||
<LoadingState label={t("channels.loading")} />
|
||||
<LoadingState shape="table" label={t("channels.loading")} />
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={syncedChannels}
|
||||
|
||||
@@ -267,7 +267,7 @@ export default function DownloadCenter() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : jobsQ.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.queue")}
|
||||
@@ -356,7 +356,7 @@ export default function DownloadCenter() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : jobsQ.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.done")}
|
||||
@@ -411,7 +411,7 @@ export default function DownloadCenter() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : sharedQ.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.shared")}
|
||||
|
||||
@@ -563,7 +563,7 @@ export default function Feed({
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isLoading) return <LoadingState label={t("feed.loading")} />;
|
||||
if (query.isLoading) return <LoadingState shape="grid" label={t("feed.loading")} />;
|
||||
if (query.isError && !query.data)
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
||||
|
||||
@@ -699,7 +699,7 @@ export default function Home() {
|
||||
if (surpriseRequest !== null && takeSurprise()) reroll("surprise");
|
||||
}, [surpriseRequest, reroll]);
|
||||
|
||||
if (q.isPending) return <LoadingState />;
|
||||
if (q.isPending) return <LoadingState shape="grid" />;
|
||||
// `&& !data`, the codebase-wide convention (and the one `ui/QueryState` documents): react-query
|
||||
// flips `status` to error on a FAILED REFETCH while keeping the cached payload, and every mutation
|
||||
// on this page invalidates `qk.discover()` — so without the guard, marking one card watched over a
|
||||
|
||||
@@ -145,7 +145,7 @@ function ConversationList({
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading && !q.data ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : items.length === 0 ? (
|
||||
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>{t("messages.empty")}</div>
|
||||
) : (
|
||||
@@ -247,7 +247,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : users.length === 0 ? (
|
||||
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
|
||||
{t("messages.noPeople")}
|
||||
|
||||
@@ -181,7 +181,7 @@ export default function NotificationsPanel() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading && !q.data && clientItems.length === 0 ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : !hasAny ? (
|
||||
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
|
||||
<Bell className="w-8 h-8 mx-auto mb-3 opacity-40" />
|
||||
|
||||
@@ -844,7 +844,7 @@ export default function Playlists() {
|
||||
if (!detail) {
|
||||
return (
|
||||
<>
|
||||
<LoadingState label={t("playlists.loading")} />
|
||||
<LoadingState shape="list" label={t("playlists.loading")} />
|
||||
{player}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -261,7 +261,7 @@ export default function PlexCollectionView({
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading || !d ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
<LoadingState shape="poster" label={t("plex.loading")} />
|
||||
) : (
|
||||
<>
|
||||
<DetailHero
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function PlexDiscover({
|
||||
);
|
||||
}
|
||||
if (q.isLoading || !q.data) {
|
||||
return <LoadingState label={t("plex.loading")} />;
|
||||
return <LoadingState shape="poster" label={t("plex.loading")} />;
|
||||
}
|
||||
|
||||
const d = q.data;
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function PlexLibraryPicker({
|
||||
|
||||
if (!enabled) return null;
|
||||
if (debounced.length === 0) return <>{emptyHint}</>;
|
||||
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
|
||||
if (q.isLoading) return <LoadingState shape="list" label={t("plex.loading")} />;
|
||||
if (q.isError) return <p className="text-muted text-sm">{t("common.loadError")}</p>;
|
||||
if (results.length === 0)
|
||||
return <p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>;
|
||||
|
||||
@@ -592,7 +592,7 @@ export default function PlexPlaylistView({
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
<LoadingState shape="list" label={t("plex.loading")} />
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p>
|
||||
) : (
|
||||
|
||||
@@ -49,7 +49,7 @@ export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }
|
||||
</StateMessage>
|
||||
);
|
||||
}
|
||||
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
|
||||
if (q.isLoading) return <LoadingState shape="poster" label={t("plex.loading")} />;
|
||||
if (playlists.length === 0)
|
||||
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
|
||||
|
||||
@@ -212,7 +212,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
<LoadingState shape="poster" label={t("plex.loading")} />
|
||||
) : collections.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
|
||||
) : (
|
||||
|
||||
@@ -75,3 +75,15 @@ describe("LoadingState reveal", () => {
|
||||
expect(revealDelay(container)).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LoadingState shape (B8)", () => {
|
||||
it("shows the content's skeleton on the same clock, the label for a screen reader only", () => {
|
||||
now = 90_000;
|
||||
const { container } = render(<LoadingState shape="list" label="Loading playlists" />);
|
||||
const status = container.querySelector<HTMLElement>("[role=status]")!;
|
||||
expect(revealDelay(container)).toBe(300);
|
||||
expect(status.querySelector(".animate-spin")).toBeNull();
|
||||
expect(status.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
||||
expect(status.querySelector(".sr-only")?.textContent).toBe("Loading playlists");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
import { SkeletonShapes, type SkeletonShape } from "./Skeleton";
|
||||
|
||||
// A loading state fades in only after a delay: most loads here answer in tens of milliseconds, and a
|
||||
// spinner shown for two frames before the content replaces it reads as a flicker, not as feedback. A
|
||||
@@ -22,15 +23,19 @@ function revealWait(since: number | undefined, delayMs: number): number {
|
||||
}
|
||||
|
||||
// Centered loading state: a spinning accent ring + an optional label. The single shared loading
|
||||
// visual for a page/section's first load, so all of them read the same (and can be swapped for
|
||||
// skeletons later in one place). `role=status`/`aria-live` announces it to screen readers.
|
||||
// visual for a page/section's first load, so all of them read the same. A grid, a list or a table
|
||||
// passes its `shape` instead (B8): the skeleton of what is coming, on the same reveal clock, with the
|
||||
// label kept for a screen reader only. `role=status`/`aria-live` announces it either way.
|
||||
export function LoadingState({
|
||||
label,
|
||||
className = "",
|
||||
since,
|
||||
delayMs = LOADING_REVEAL_MS,
|
||||
shape,
|
||||
}: {
|
||||
label?: ReactNode;
|
||||
/** The content's shape: its skeleton replaces the spinner (B8). */
|
||||
shape?: SkeletonShape;
|
||||
className?: string;
|
||||
/** When the wait began (a `performance.now()` time), if earlier than this mount — the boot screen
|
||||
* passes 0, navigation start. Only used when this state starts a new clock. */
|
||||
@@ -46,10 +51,18 @@ export function LoadingState({
|
||||
reveal.lastGone = performance.now();
|
||||
};
|
||||
}, []);
|
||||
const style = wait > 0 ? { animation: `fadeIn 0.2s ease ${Math.round(wait)}ms both` } : undefined;
|
||||
if (shape)
|
||||
return (
|
||||
<div className={`w-full ${className}`} style={style} role="status" aria-live="polite">
|
||||
<span className="sr-only">{label ?? t("common.loading")}</span>
|
||||
<SkeletonShapes shape={shape} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
|
||||
style={wait > 0 ? { animation: `fadeIn 0.2s ease ${Math.round(wait)}ms both` } : undefined}
|
||||
style={style}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import clsx from "clsx";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// B8 (docs/DESIGN.md): a first load of a grid, list or table shows the SHAPE of what is coming, so
|
||||
// nothing jumps when the data lands. One block primitive (a tonal fill, the Badge's 8% of the text
|
||||
// colour, pulsing unless the user asked for less motion) and the four page shapes built from it. The
|
||||
// shapes are shown through `LoadingState shape=…`, which owns the 300 ms reveal and the status role.
|
||||
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={clsx("rounded-lg bg-fg/8 animate-pulse motion-reduce:animate-none", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export type SkeletonShape = "grid" | "poster" | "list" | "table";
|
||||
|
||||
const COUNT: Record<SkeletonShape, number> = { grid: 8, poster: 12, list: 6, table: 8 };
|
||||
|
||||
/** The placeholder for a page's first load, by the shape of its content. */
|
||||
export function SkeletonShapes({ shape }: { shape: SkeletonShape }) {
|
||||
const n = Array.from({ length: COUNT[shape] }, (_, i) => i);
|
||||
if (shape === "grid")
|
||||
return (
|
||||
<div className="grid w-full grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-4">
|
||||
{n.map((i) => (
|
||||
// The video card's own surface, bones inside it, as the card will stand.
|
||||
<div key={i} className={`${SURFACE.card} flex flex-col gap-2 p-1.5`}>
|
||||
<Skeleton className="aspect-video w-full" />
|
||||
<Skeleton className="h-3.5 w-4/5" />
|
||||
<Skeleton className="mb-1 h-3 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (shape === "poster")
|
||||
return (
|
||||
<div className="grid w-full grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-4">
|
||||
{n.map((i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-2/3 w-full rounded-xl" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (shape === "list")
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{n.map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-2">
|
||||
<Skeleton className="h-10 w-16 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<Skeleton className="h-3.5 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<Skeleton className="mb-1 h-8 w-full" />
|
||||
{n.map((i) => (
|
||||
<Skeleton key={i} className="h-9 w-full opacity-70" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Download,
|
||||
Home,
|
||||
ListVideo,
|
||||
Loader2,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
PlayCircle,
|
||||
@@ -22,10 +21,12 @@ import {
|
||||
import { Btn } from "./batchA";
|
||||
import { hexContrast, hueGap } from "../lib/colorMath";
|
||||
import { Reference, Sample } from "./labKit";
|
||||
import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, FILLED, H, LAYER } from "./labTokens";
|
||||
import { FADE_ABOVE_PANEL, FADE_BELOW, FADE_BELOW_PANEL, LAYER } from "./labTokens";
|
||||
import { Chip } from "../components/ui/Chip";
|
||||
import { SURFACE } from "../components/ui/surfaces";
|
||||
import { SelectTrigger } from "../components/ui/SelectTrigger";
|
||||
import { SkeletonShapes } from "../components/ui/Skeleton";
|
||||
import { Button } from "../components/ui/Button";
|
||||
|
||||
// E28 S2 · Batch B — surfaces, chrome and colour, DECIDED (owner, 2026-09-24): every recommendation,
|
||||
// except B7, where the owner combined the soft tint + edge bar with optional labels. B1 (density) and
|
||||
@@ -358,32 +359,18 @@ function Rail({ lang }: { lang?: "en" | "hu" }) {
|
||||
}
|
||||
|
||||
// ── B8 · loading ─────────────────────────────────────────────────────────────────────────────────
|
||||
const bone = "animate-pulse rounded bg-[color-mix(in_srgb,var(--fg)_10%,transparent)]";
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<>
|
||||
<Sample label="A grid, first load: its skeleton (after ~300ms)">
|
||||
<div className="grid max-w-md grid-cols-3 gap-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className={`${SURFACE.card} flex flex-col gap-1.5 p-1.5`}>
|
||||
<div className={clsx("aspect-video rounded-lg", bone)} />
|
||||
<div className={clsx("h-2 w-4/5", bone)} />
|
||||
<div className={clsx("h-2 w-1/2", bone)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Sample>
|
||||
{(["grid", "poster", "list", "table"] as const).map((shape) => (
|
||||
<Sample key={shape} label={`First load, ${shape}: its skeleton (after ~300ms)`}>
|
||||
<div className="max-w-xl">
|
||||
<SkeletonShapes shape={shape} />
|
||||
</div>
|
||||
</Sample>
|
||||
))}
|
||||
<Sample label="A control's own work: its spinner">
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex w-fit items-center rounded-lg font-medium opacity-80",
|
||||
FILLED,
|
||||
H.md,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Saving
|
||||
</span>
|
||||
<Button loading>Saving</Button>
|
||||
</Sample>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user