Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/governance-app-frontend/src/app/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@
--sidebar-ring: var(--icp-accent-strong);
--main-content: var(--icp-bg);
--staking-ratio: var(--icp-section-teal);
/* Skeleton bars pulse between these two. The pulse animates a color, which
leaves `opacity` free for the delayed reveal. An ink wash on the parchment,
not a tint of the rust accent: a placeholder must not read as content. */
--skeleton-base: rgba(26, 26, 26, 0.07);
--skeleton-peak: rgba(26, 26, 26, 0.035);
}

.dark {
Expand Down Expand Up @@ -111,6 +116,8 @@
--sidebar-ring: var(--icp-accent-strong);
--main-content: var(--icp-bg);
--staking-ratio: #3fafaa;
--skeleton-base: rgba(162, 154, 141, 0.2);
--skeleton-peak: rgba(162, 154, 141, 0.1);
}

@theme inline {
Expand Down Expand Up @@ -204,6 +211,58 @@
}

@layer utilities {
/*
* Skeleton bars.
*
* A bar claims its space on the first frame, so the content lands without a
* jump. It stays invisible for `--skeleton-delay` first, so a fast query
* resolves into content with no grey flash on the way.
*
* The reveal animates `opacity` and the pulse animates `background-color`.
* Two animations on one property fight, and the pulse is declared last, so
* it would win and cancel the delay.
*/
@keyframes skeleton-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

@keyframes skeleton-pulse {
0%,
100% {
background-color: var(--skeleton-base);
}
50% {
background-color: var(--skeleton-peak);
}
}

.skeleton {
--skeleton-delay: 120ms;
background-color: var(--skeleton-base);
animation:
skeleton-reveal 150ms ease-out var(--skeleton-delay) both,
skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) var(--skeleton-delay) infinite;
}

/*
* Keep the delayed reveal, drop the pulse. Spinners keep turning: they are
* the only sign that a mutation is still running.
*/
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: skeleton-reveal 0s linear var(--skeleton-delay) both;
}

.animate-pulse {
animation: none;
}
}

.text-hero-responsive {
font-size: clamp(2rem, 5vh, 4rem);
line-height: 1.1;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { AlertTriangle, Loader } from 'lucide-react';
import { AlertTriangle } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { Button } from '@components/button';
import { NavigationBlockerDialog } from '@components/NavigationBlockerDialog';
import { ProcessingSpinner } from '@components/ProcessingSpinner';
import {
ResponsiveDialog,
ResponsiveDialogContent,
Expand Down Expand Up @@ -131,7 +132,7 @@ export function MutationDialog({
<ResponsiveDialogTitle className="sr-only">
{processingMessage}
</ResponsiveDialogTitle>
<AnimatedSpinner />
<ProcessingSpinner />
<FadeInText delay={0.2}>{processingMessage}</FadeInText>
</PhaseContainer>
)}
Expand Down Expand Up @@ -232,19 +233,6 @@ function PhaseContainer({
);
}

function AnimatedSpinner() {
return (
<motion.div
className="flex size-16 items-center justify-center rounded-full bg-primary/10"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15 }}
>
<Loader className="size-8 animate-spin text-primary" />
</motion.div>
);
}

function AnimatedSuccessIcon() {
return (
<motion.div
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { motion } from 'motion/react';

import { Spinner } from '@components/Spinner';

/**
* The spinner shown while a mutation runs.
*
* Every processing screen uses this one, so a transfer, a stake, and a vote all
* look the same while they wait.
*/
export const ProcessingSpinner = () => (
<motion.div
className="flex size-16 items-center justify-center rounded-full bg-primary/10"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15 }}
>
<Spinner className="size-8 text-primary" />
</motion.div>
);
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';

import { useDelayedFlag } from '@hooks/useDelayedFlag';

import { EmptyMessage } from './EmptyMessage';
import { MultipleSkeletons } from './MultipleSkeletons';
import { SkeletonText } from './skeletons/SkeletonText';
import { WarningMessage } from './WarningMessage';

type InfiniteQueryData<TData = unknown> = Partial<InfiniteData<TData, unknown>>;
Expand Down Expand Up @@ -42,11 +40,12 @@ export const QueryStates = <TData,>({
}: Props<TData>) => {
const { t } = useTranslation();
const q = query || infiniteQuery;
const showLoading = useDelayedFlag(q.isLoading);

if (q.isLoading) {
// Hold the frame empty until the delay elapses rather than flashing a skeleton.
return showLoading ? loadingComponent || <MultipleSkeletons count={3} /> : null;
// The skeleton holds the space at once and reveals itself after a short
// delay, so a fast query needs no hold-back here. See `.skeleton` in
// `main.css`. Pass a `loadingComponent` built from `Skeleton` to keep that.
return loadingComponent || <SkeletonText lines={3} />;
}
Comment on lines 44 to 49

if (q.error) {
Expand Down
14 changes: 13 additions & 1 deletion src/governance-app-frontend/src/common/components/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { cn } from '@common/utils/shadcn';

/**
* A placeholder bar.
*
* The look and the timing live in the `.skeleton` class in `main.css`: the bar
* holds its space from the first frame and stays invisible for a short delay,
* so a fast query resolves into content without a grey flash and without a
* jump. Pass size classes only; the colour comes from the theme.
*
* The bar is decorative. Wrap a group of them in `SkeletonScreen` so screen
* readers hear one "loading" for the region instead of one per bar.
*/
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('animate-pulse rounded-md bg-accent dark:bg-muted-foreground/20', className)}
aria-hidden={true}
className={cn('skeleton rounded-md', className)}
{...props}
/>
Comment on lines 16 to 21
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Card, CardContent, CardHeader } from '@components/Card';
import { Skeleton } from '@components/Skeleton';

import { SkeletonAccountCard } from './SkeletonAccountCard';
import { SkeletonPageHeader } from './SkeletonPageHeader';
import { SkeletonTransactionRows } from './SkeletonTransactionList';

/**
* Mirrors the `/accounts` layout: total card, account list, recent activity.
*
* `SkeletonTransactionRows` inside announces the load, so this wrapper stays a
* plain `div`. Nested status regions read twice.
*/
export const AccountsSkeleton = () => (
<div className="flex flex-col gap-6">
<SkeletonPageHeader action={true} />

<Card>
<CardHeader className="flex flex-col gap-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-8 w-32" />
<Skeleton className="h-5 w-20" />
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Skeleton className="h-3 w-full rounded-full" />
<div className="flex gap-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-24" />
</div>
</CardContent>
</Card>

<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-2">
<SkeletonAccountCard />
<SkeletonAccountCard />
</div>

<Card className="h-fit">
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<SkeletonTransactionRows />
</CardContent>
</Card>
</div>
</div>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Card, CardContent, CardHeader } from '@components/Card';
import { Skeleton } from '@components/Skeleton';

import { SkeletonScreen } from './SkeletonScreen';
import { SkeletonStatCard } from './SkeletonStatCard';
import { SkeletonText } from './SkeletonText';

/** Mirrors the `/dashboard` layout, so the real page lands in the same frame. */
export const DashboardSkeleton = () => (
<SkeletonScreen className="flex flex-col gap-8">
{/* SmartTitle */}
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-full max-w-xl" />
<Skeleton className="h-10 w-full max-w-md" />
</div>

<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{/* TotalAssetsCard, with its radial chart. */}
<Card className="items-center pt-4 pb-6">
<CardHeader className="flex flex-col items-center gap-2">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-8 w-40" />
</CardHeader>
<CardContent className="flex w-full justify-center">
<Skeleton className="aspect-square w-full max-w-48 rounded-full" />
</CardContent>
</Card>

{/* AccountCard or AccountsCard. */}
<Card className="pt-4 pb-6">
<CardHeader className="flex flex-col gap-2">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-32" />
<Skeleton className="h-5 w-20" />
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>

{/* StakedCard. */}
<Card className="pt-4 pb-6 md:col-span-2">
<CardHeader className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-16" />
</div>
<Skeleton className="h-8 w-32" />
<Skeleton className="h-5 w-20" />
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="grid grid-cols-2 gap-6 border-t pt-4">
<Skeleton className="h-7 w-20 justify-self-end" />
<Skeleton className="h-7 w-20 justify-self-end" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
</CardContent>
</Card>
</div>

<div className="mt-4 flex flex-col gap-3">
<Skeleton className="h-9 w-64" />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<SkeletonStatCard />
<SkeletonStatCard />
<SkeletonStatCard />
<SkeletonStatCard caption={false} />
</div>

{/* ExecutiveSummaryCard. */}
<Card>
<CardHeader className="flex flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-9 w-48" />
</CardHeader>
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
<SkeletonText lines={4} />
<SkeletonText lines={4} />
</CardContent>
</Card>
</div>
</SkeletonScreen>
);
Loading
Loading