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
104 changes: 75 additions & 29 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1007,10 +1007,10 @@ body {
}

.skeleton-bone--chart-donut {
width: min(100%, 240px);
height: min(100vw, 240px);
max-width: 240px;
max-height: 240px;
width: min(100%, 260px);
height: min(100vw, 260px);
max-width: 260px;
max-height: 260px;
}

.legend-item {
Expand Down Expand Up @@ -1920,9 +1920,20 @@ h2 {
}

/* ── Skeleton loaders ────────────────────────────────────────── */
/*
* Design rules:
* - Bones sit on top of the same card backgrounds as real content
* (stat-card: rgba(255,255,255,0.03), goal-section: rgba(255,255,255,0.02))
* so the bone fill must be brighter than the card to be visible.
* - Shimmer peak matches the MetricTile inline skeleton for consistency.
* - border-radius per bone type: text lines → 4px, pill/progress → 999px,
* circular → 50%, swatch/icon → 4–8px, card-level → matches real card.
* - Dimensions are locked to the real component measurements to prevent
* layout shift on swap.
*/
.skeleton-bone {
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
background: rgba(255, 255, 255, 0.07);
border-radius: 4px;
position: relative;
overflow: hidden;
}
Expand All @@ -1931,13 +1942,18 @@ h2 {
margin-bottom: 2.5rem;
}

/* Stat card skeleton — matches .stat-card padding so bones don't sit flush */
.skeleton-stat-card {
gap: 0;
gap: 0.5rem;
padding: 1.5rem;
}

.skeleton-goal-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}

.skeleton-progress-row {
Expand All @@ -1958,89 +1974,119 @@ h2 {
.skeleton-legend-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}

/* ── Bone dimensions — locked to real component measurements ── */

/* stat-label: matches .metric-tile__label font-size ~0.75rem → ~12px rendered */
.skeleton-bone--stat-label {
width: 60%;
height: 14px;
margin-bottom: 12px;
width: 55%;
height: 12px;
border-radius: 4px;
}

/* stat-value: matches .metric-tile__value font-size 1.4rem → ~22px rendered */
.skeleton-bone--stat-value {
width: 80%;
height: 36px;
width: 75%;
height: 22px;
border-radius: 4px;
}

/* stat-trend: matches .metric-tile__trend font-size 0.72rem → ~11px */
.skeleton-bone--stat-trend {
width: 35%;
height: 11px;
border-radius: 4px;
}

/* goal-title: matches .goal-title font-size 1.25rem → ~20px */
.skeleton-bone--goal-title {
width: 55%;
height: 18px;
margin-bottom: 8px;
height: 20px;
border-radius: 4px;
}

/* goal-line: matches .text-muted .goal-subtitle font-size 0.9rem → ~14px */
.skeleton-bone--goal-line {
width: 70%;
height: 13px;
margin-bottom: 6px;
height: 14px;
border-radius: 4px;
}

/* goal-line-short: status indicator ~14px tall */
.skeleton-bone--goal-line-short {
width: 40%;
height: 13px;
height: 14px;
border-radius: 4px;
}

/* goal-icon: matches Target size={32} */
.skeleton-bone--goal-icon {
width: 32px;
height: 32px;
border-radius: 50%;
flex-shrink: 0;
}

/* progress bar: matches .progress-bar-container height 12px, border-radius 999px */
.skeleton-bone--progress {
width: 100%;
height: 12px;
border-radius: 999px;
margin: 1rem 0;
}

/* progress stats: matches .progress-stats font-size 0.85rem → ~13px */
.skeleton-bone--progress-stat {
width: 30%;
height: 13px;
border-radius: 4px;
}

/* chart donut: matches PortfolioChart default width/height={320} minus 18px padding
outerRadius = 320/2 - 18 = 142 → full SVG viewBox is 320×320 */
.skeleton-bone--chart-donut {
width: min(100%, 280px);
height: min(100%, 280px);
max-width: 280px;
max-height: 280px;
width: min(100%, 320px);
height: min(100%, 320px);
max-width: 320px;
max-height: 320px;
border-radius: 50%;
margin: 0 auto;
}

/* legend swatch: matches .legend-color 16×16 */
.skeleton-bone--legend-swatch {
width: 16px;
height: 16px;
border-radius: 4px;
flex-shrink: 0;
}

/* legend name: matches .legend-name font-size 0.9rem → ~14px */
.skeleton-bone--legend-name {
width: 60%;
height: 13px;
margin-bottom: 6px;
height: 14px;
border-radius: 4px;
}

/* legend percentage: matches .legend-percentage font-size 0.8rem → ~12px */
.skeleton-bone--legend-pct {
width: 30%;
height: 11px;
height: 12px;
border-radius: 4px;
}

/* ── Shimmer sweep ── */
.skeleton-shimmer {
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.08) 50%,
rgba(255, 255, 255, 0.1) 50%,
transparent 100%
);
animation: skeleton-sweep 1.6s infinite linear;
Expand Down Expand Up @@ -2559,16 +2605,16 @@ h2 {
.metric-tile__trend--flat { color: var(--text-muted, #888); }
.metric-tile__trend-label { font-family: 'JetBrains Mono', monospace; }

/* Loading skeleton for MetricTile */
/* Loading skeleton for MetricTile — uses same bone brightness as SkeletonLoader */
.metric-tile--loading .metric-tile__label-skeleton,
.metric-tile--loading .metric-tile__value-skeleton {
border-radius: 4px;
background: linear-gradient(90deg, rgba(255,255,255,0.05) 25%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 75%);
background: linear-gradient(90deg, rgba(255,255,255,0.07) 25%, rgba(255,255,255,0.12) 50%, rgba(255,255,255,0.07) 75%);
background-size: 200% 100%;
animation: shimmer 1.6s ease-in-out infinite;
}
.metric-tile--loading .metric-tile__label-skeleton { height: 0.75rem; width: 60%; margin-bottom: 0.25rem; }
.metric-tile--loading .metric-tile__value-skeleton { height: 1.6rem; width: 80%; }
.metric-tile--loading .metric-tile__label-skeleton { height: 0.75rem; width: 55%; margin-bottom: 0.25rem; }
.metric-tile--loading .metric-tile__value-skeleton { height: 1.4rem; width: 75%; }

/* ── EmptyState (#105) ──────────────────────────────────────────── */
.empty-state {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/features/portfolio/GoalTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function GoalTracker({
const clampedProgress = Math.max(0, Math.min(100, progressPercentage));

return (
<div className="goal-section skeleton-fade-in">
<div className="goal-section">
<div className="goal-header">
<div>
<h3 className="goal-title">{goalName}</h3>
Expand Down
84 changes: 70 additions & 14 deletions frontend/src/components/feedback/SkeletonLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,79 @@

import React from 'react';

// ── Bone ─────────────────────────────────────────────────────────────────────
// A single skeleton bone. className is appended to the base `skeleton-bone`
// class so per-bone dimension/shape overrides work without specificity fights.

interface BoneProps {
className?: string;
}

function Bone({ className }: BoneProps) {
return (
<div className={className ? `skeleton-bone ${className}` : 'skeleton-bone'}>
<div className="skeleton-shimmer" />
<div className={['skeleton-bone', className].filter(Boolean).join(' ')}>
<div className="skeleton-shimmer" aria-hidden="true" />
</div>
);
}

// ── PortfolioStatsSkeleton ────────────────────────────────────────────────────
// Mirrors the two-column .stats-grid → two .stat-card wrappers, each holding
// a MetricTile-shaped set of bones (label + value + optional trend).
// Dimensions are locked to MetricTile's rendered sizes to prevent layout shift.

export function PortfolioStatsSkeleton() {
return (
<div className="stats-grid skeleton-stats-grid">
{[0, 1].map((i) => (
<div key={i} className="stat-card skeleton-stat-card">
<Bone className="skeleton-bone--stat-label" />
<Bone className="skeleton-bone--stat-value" />
</div>
))}
<div
className="stats-grid skeleton-stats-grid"
aria-busy="true"
aria-label="Loading portfolio statistics"
>
{/* Card 1 — Total Value (has trend row) */}
<div className="stat-card skeleton-stat-card">
<Bone className="skeleton-bone--stat-label" />
<Bone className="skeleton-bone--stat-value" />
<Bone className="skeleton-bone--stat-trend" />
</div>

{/* Card 2 — APY (no trend, has status pill placeholder) */}
<div className="stat-card secondary skeleton-stat-card">
<Bone className="skeleton-bone--stat-label" />
<Bone className="skeleton-bone--stat-value" />
</div>
</div>
);
}

// ── GoalTrackerSkeleton ───────────────────────────────────────────────────────
// Mirrors .goal-section → .goal-header (copy + icon) → progress bar → stats row.
// .skeleton-goal-copy uses flex-column + gap so bones space themselves without
// margin hacks that would differ from the real component.

export function GoalTrackerSkeleton() {
return (
<div className="goal-section">
<div
className="goal-section"
aria-busy="true"
aria-label="Loading goal tracker"
>
<div className="goal-header">
<div className="skeleton-goal-copy">
{/* goal-title: h3.goal-title */}
<Bone className="skeleton-bone--goal-title" />
{/* goal-subtitle: p.text-muted */}
<Bone className="skeleton-bone--goal-line" />
{/* status-indicator badge */}
<Bone className="skeleton-bone--goal-line-short" />
</div>
{/* Target icon — size={32} */}
<Bone className="skeleton-bone--goal-icon" />
</div>

{/* .progress-bar-container height=12px, border-radius=999px */}
<Bone className="skeleton-bone--progress" />

{/* .progress-stats row — two short text spans */}
<div className="skeleton-progress-row">
<Bone className="skeleton-bone--progress-stat" />
<Bone className="skeleton-bone--progress-stat" />
Expand All @@ -47,13 +83,33 @@ export function GoalTrackerSkeleton() {
);
}

// ── PortfolioChartSkeleton ────────────────────────────────────────────────────
// Mirrors .portfolio-chart-container → donut SVG placeholder + legend list.
// Donut is 320×320 (matching PortfolioChart default props) to prevent the
// 40px height jump that occurred with the old 280px value.
// Legend items use .legend-item so they inherit the same min-height: 54px and
// padding as the real buttons — no height shift on swap.

export function PortfolioChartSkeleton() {
return (
<div className="portfolio-chart-container">
<Bone className="skeleton-bone--chart-donut" />
<div
className="portfolio-chart-container"
aria-busy="true"
aria-label="Loading portfolio chart"
>
{/* Donut placeholder — 320×320 matches PortfolioChart default */}
<div className="chart-wrapper">
<Bone className="skeleton-bone--chart-donut" />
</div>

{/* Legend — 3 rows matching the default 3-asset allocation */}
<div className="chart-legend skeleton-chart-legend">
{[0, 1, 2].map((i) => (
<div key={i} className="legend-item skeleton-legend-item">
{([0, 1, 2] as const).map((i) => (
<div
key={i}
className="legend-item skeleton-legend-item"
aria-hidden="true"
>
<Bone className="skeleton-bone--legend-swatch" />
<div className="skeleton-legend-text">
<Bone className="skeleton-bone--legend-name" />
Expand Down