diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 2dee9c999..4c283ef7b 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -255,6 +255,42 @@ describe('Dashboard Module', () => { expect(summary?.innerHTML).toContain('Failed to load dashboard'); }); + // Issue #344 T3: skeleton lifecycle — skeleton renders synchronously + // at fetch start, then is replaced by the success render (clean + // handoff) or torn down on error so it never sits next to a stale + // error message. + test('error path tears down the loading skeleton', async () => { + (api.getDashboardSummary as jest.Mock).mockRejectedValue(new Error('API Error')); + console.error = jest.fn(); + + await loadDashboard(); + + const summary = document.getElementById('summary'); + expect(summary?.querySelector('.skeleton-tile')).toBeNull(); + expect(summary?.dataset['skeletonActive']).toBeUndefined(); + }); + + test('success path replaces the loading skeleton with KPI tiles', async () => { + (api.getDashboardSummary as jest.Mock).mockResolvedValue({ + potential_monthly_savings: 1000, + total_recommendations: 5, + active_commitments: 3, + committed_monthly: 500, + current_coverage: 70, + target_coverage: 80, + ytd_savings: 5000, + by_service: {} + }); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + await loadDashboard(); + + const summary = document.getElementById('summary'); + // Real KPI tiles render — skeleton placeholders are gone. + expect(summary?.querySelectorAll('.kpi-tile').length).toBeGreaterThan(0); + expect(summary?.querySelector('.skeleton-tile')).toBeNull(); + }); + test('uses current provider filter', async () => { (state.getCurrentProvider as jest.Mock).mockReturnValue('aws'); (api.getDashboardSummary as jest.Mock).mockResolvedValue({ diff --git a/frontend/src/__tests__/skeleton.test.ts b/frontend/src/__tests__/skeleton.test.ts new file mode 100644 index 000000000..feee3784c --- /dev/null +++ b/frontend/src/__tests__/skeleton.test.ts @@ -0,0 +1,175 @@ +/** + * Skeleton helper tests (issue #344 T3). + * + * Verifies the show / teardown lifecycle and that the helpers produce + * the DOM shape each call site expects (tile / row / box). + */ + +import { + skeletonBox, + skeletonText, + skeletonTile, + skeletonRow, + showSkeletonTiles, + showSkeletonRows, + showSkeletonBlock, + teardownSkeleton, + isSkeletonActive, +} from '../lib/skeleton'; + +describe('skeleton primitives', () => { + test('skeletonBox renders a div with .skeleton + width/height styles', () => { + const el = skeletonBox('80%', '1rem'); + expect(el.tagName).toBe('DIV'); + expect(el.classList.contains('skeleton')).toBe(true); + expect(el.getAttribute('aria-hidden')).toBe('true'); + expect(el.style.width).toBe('80%'); + expect(el.style.height).toBe('1rem'); + }); + + test('skeletonText emits N lines with a shorter final line by default', () => { + const group = skeletonText(3); + expect(group.children).toHaveLength(3); + const last = group.children[2] as HTMLElement; + expect(last.style.width).toBe('60%'); + const first = group.children[0] as HTMLElement; + expect(first.style.width).toBe('100%'); + }); + + test('skeletonText with widthVaries=false makes every line full-width', () => { + const group = skeletonText(2, false); + expect(group.children).toHaveLength(2); + Array.from(group.children).forEach((child) => { + expect((child as HTMLElement).style.width).toBe('100%'); + }); + }); + + test('skeletonText clamps non-positive line counts to 1', () => { + expect(skeletonText(0).children).toHaveLength(1); + expect(skeletonText(-3).children).toHaveLength(1); + }); + + test('skeletonTile has title + value + detail rows in tile shape', () => { + const tile = skeletonTile(); + expect(tile.classList.contains('kpi-tile')).toBe(true); + expect(tile.classList.contains('skeleton-tile')).toBe(true); + expect(tile.children).toHaveLength(3); + }); + + test('skeletonRow renders a with N s, each containing a skeleton', () => { + const tr = skeletonRow(5); + expect(tr.tagName).toBe('TR'); + expect(tr.classList.contains('skeleton-row')).toBe(true); + expect(tr.children).toHaveLength(5); + Array.from(tr.children).forEach((td) => { + expect(td.tagName).toBe('TD'); + expect(td.querySelector('.skeleton')).not.toBeNull(); + }); + }); + + test('skeletonRow clamps non-positive col counts to 1', () => { + expect(skeletonRow(0).children).toHaveLength(1); + expect(skeletonRow(-2).children).toHaveLength(1); + }); +}); + +describe('skeleton show / teardown lifecycle', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + test('showSkeletonTiles fills container with N tile skeletons + marks active', () => { + showSkeletonTiles(container, 4); + expect(container.querySelectorAll('.skeleton-tile')).toHaveLength(4); + expect(isSkeletonActive(container)).toBe(true); + }); + + test('showSkeletonRows fills container with N row skeletons + table shell', () => { + showSkeletonRows(container, 8, 6); + expect(container.querySelector('table.skeleton-table')).not.toBeNull(); + expect(container.querySelectorAll('tr.skeleton-row')).toHaveLength(8); + expect(container.querySelectorAll('tr.skeleton-row td')).toHaveLength(8 * 6); + expect(isSkeletonActive(container)).toBe(true); + }); + + test('showSkeletonBlock fills container with one sized shimmer block', () => { + showSkeletonBlock(container, '100%', '12rem'); + const skel = container.querySelector('.skeleton') as HTMLElement; + expect(skel).not.toBeNull(); + expect(skel.style.width).toBe('100%'); + expect(skel.style.height).toBe('12rem'); + expect(isSkeletonActive(container)).toBe(true); + }); + + test('teardownSkeleton wipes children when active + clears the marker', () => { + showSkeletonTiles(container, 3); + teardownSkeleton(container); + expect(container.children).toHaveLength(0); + expect(isSkeletonActive(container)).toBe(false); + }); + + test('teardownSkeleton is a no-op when no skeleton is active', () => { + // Container has real content (post-render); teardown must NOT wipe it. + const real = document.createElement('p'); + real.textContent = 'rendered'; + container.appendChild(real); + teardownSkeleton(container); + expect(container.children).toHaveLength(1); + expect(container.firstChild).toBe(real); + }); + + test('successive show* calls swap the placeholder without leaking prior children', () => { + showSkeletonTiles(container, 4); + showSkeletonRows(container, 5, 3); + expect(container.querySelectorAll('.skeleton-tile')).toHaveLength(0); + expect(container.querySelectorAll('tr.skeleton-row')).toHaveLength(5); + }); + + // PR #346 CR follow-up: the previous implementation called + // teardownSkeleton (which is conditional on the marker) so a first + // render where the container already held real content would APPEND + // skeletons below it instead of replacing them. The show* helpers + // must always clear children unconditionally. + test('show* helpers replace existing real content unconditionally on first render', () => { + const stale = document.createElement('p'); + stale.textContent = 'previous content (no skeleton marker)'; + container.appendChild(stale); + expect(container.children).toHaveLength(1); + + showSkeletonTiles(container, 3); + + // The pre-existing

must be gone, not still sitting at the top. + expect(container.querySelector('p')).toBeNull(); + expect(container.querySelectorAll('.skeleton-tile')).toHaveLength(3); + expect(isSkeletonActive(container)).toBe(true); + }); + + test('showSkeletonRows also clears unmarked prior content', () => { + const stale = document.createElement('div'); + stale.classList.add('error'); + stale.textContent = 'old error'; + container.appendChild(stale); + + showSkeletonRows(container, 4, 5); + + expect(container.querySelector('.error')).toBeNull(); + expect(container.querySelectorAll('tr.skeleton-row')).toHaveLength(4); + }); + + test('showSkeletonBlock also clears unmarked prior content', () => { + const stale = document.createElement('canvas'); + container.appendChild(stale); + + showSkeletonBlock(container, '100%', '6rem'); + + expect(container.querySelector('canvas')).toBeNull(); + expect(container.querySelectorAll('.skeleton')).toHaveLength(1); + }); +}); diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index 3cf406e7f..fd5aee6e7 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -5,13 +5,14 @@ import { Chart, registerables } from 'chart.js'; import * as api from './api'; import * as state from './state'; -import { formatCurrency, getDateParts, escapeHtml } from './utils'; +import { formatCurrency, getDateParts } from './utils'; import { renderFreshness } from './freshness'; import type { DashboardSummary, UpcomingPurchase, ServiceSavings, LocalRecommendation } from './types'; import type { SavingsDataPoint } from './api'; import { showToast } from './toast'; import { confirmDialog } from './confirmDialog'; import { groupRecsByCell, pageLevelRange, formatSavingsRange } from './recommendations'; +import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; // Register Chart.js components Chart.register(...registerables); @@ -51,6 +52,15 @@ export function setupDashboardHandlers(): void { * Load dashboard data */ export async function loadDashboard(): Promise { + // Issue #344 T3: render skeletons synchronously before kicking off the + // fetch so the panels show "loading" intent instead of staying blank. + // The success render replaces children for a clean handoff; the catch + // block calls teardownSkeleton before rendering the error. + const summaryEl = document.getElementById('summary'); + if (summaryEl) showSkeletonTiles(summaryEl, 4); + const upcomingEl = document.getElementById('upcoming-list'); + if (upcomingEl) showSkeletonBlock(upcomingEl, '100%', '6rem'); + try { const currentProvider = state.getCurrentProvider(); const currentAccountIDs = state.getCurrentAccountIDs(); @@ -102,9 +112,18 @@ export async function loadDashboard(): Promise { console.error('Failed to load dashboard:', error); const summary = document.getElementById('summary'); if (summary) { + teardownSkeleton(summary); const err = error as Error; - summary.innerHTML = `

Failed to load dashboard: ${escapeHtml(err.message)}

`; + while (summary.firstChild) summary.removeChild(summary.firstChild); + const p = document.createElement('p'); + p.classList.add('error'); + p.textContent = `Failed to load dashboard: ${err.message}`; + summary.appendChild(p); } + // Clear the upcoming-list skeleton too — shimmer next to a dashboard + // error reads as a fresh fetch in-flight, which is misleading. + const upcoming = document.getElementById('upcoming-list'); + if (upcoming) teardownSkeleton(upcoming); } } diff --git a/frontend/src/history.ts b/frontend/src/history.ts index f57752567..069dd6ce9 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -10,6 +10,7 @@ import { switchTab } from './navigation'; import { confirmDialog } from './confirmDialog'; import { showToast } from './toast'; import { getCurrentUser } from './state'; +import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; const VALID_PROVIDERS: api.Provider[] = ['aws', 'azure', 'gcp']; @@ -157,6 +158,15 @@ function snapDateInputsToPurchases(purchases: HistoryPurchase[]): void { * Load history with filters */ export async function loadHistory(): Promise { + // Issue #344 T3: skeleton rows for the purchase-history table. 8 + // rows matches the typical first-page row count so the skeleton + // doesn't shrink dramatically when real data arrives. Column count + // (11) mirrors the rendered table headers in renderHistoryList: + // Status / Date / Provider / Service / Type / Region / Count / + // Term / Upfront Cost / Monthly Savings / Plan. + const listEl = document.getElementById('history-list'); + if (listEl) showSkeletonRows(listEl, 8, 11); + try { // Provider/account filters live in state.ts now (mutated by topbar chips). const rawProvider = state.getCurrentProvider(); @@ -180,6 +190,7 @@ export async function loadHistory(): Promise { console.error('Failed to load history:', error); const list = document.getElementById('history-list'); if (list) { + teardownSkeleton(list); const err = error as Error; list.innerHTML = `

Failed to load history: ${escapeHtml(err.message)}

`; } diff --git a/frontend/src/lib/skeleton.ts b/frontend/src/lib/skeleton.ts new file mode 100644 index 000000000..4229e9e8b --- /dev/null +++ b/frontend/src/lib/skeleton.ts @@ -0,0 +1,163 @@ +/** + * Loading skeleton helpers (issue #344 T3). + * + * Small DOM-construction primitives for shimmer skeleton placeholders. + * Each helper returns a detached element you can append into the target + * container before kicking off the fetch. The render-success path then + * replaces the container's children with the real content for a clean + * handoff. The render-error path calls `teardownSkeleton(container)` + * before showing the error so a skeleton never sits beside a stale + * error message. + * + * Lifecycle contract (see plan.md §T3): + * - Show: synchronously at fetch start (no debounce). + * - Hide: the success render replaces children — implicit teardown. + * - Hide-error: call `teardownSkeleton(container)` before `renderError`. + * + * All helpers use `createElement` only — no `innerHTML` — to match the + * codebase's XSS posture from issue #340. + */ + +/** + * A single shimmer block. Width/height are CSS sizes (e.g. "60%", "1rem", + * "12px"). Useful as a primitive inside higher-level helpers. + */ +export function skeletonBox(width: string, height: string): HTMLElement { + const el = document.createElement('div'); + el.classList.add('skeleton'); + el.setAttribute('aria-hidden', 'true'); + el.style.width = width; + el.style.height = height; + return el; +} + +/** + * N stacked text-line skeletons. Each line is 1rem tall. When + * `widthVaries` is true the last line is shorter (60%) so the + * placeholder reads like prose; when false every line is full-width. + */ +export function skeletonText(lines: number, widthVaries = true): HTMLElement { + const group = document.createElement('div'); + group.classList.add('skeleton-group'); + group.setAttribute('aria-hidden', 'true'); + const safeLines = Math.max(1, Math.floor(lines)); + for (let i = 0; i < safeLines; i += 1) { + const isLast = i === safeLines - 1; + const width = widthVaries && isLast ? '60%' : '100%'; + group.appendChild(skeletonBox(width, '1rem')); + } + return group; +} + +/** + * KPI tile placeholder — matches the .kpi-tile layout (title line + + * value line + detail line) so post-render swap doesn't shift the page. + */ +export function skeletonTile(): HTMLElement { + const tile = document.createElement('div'); + tile.classList.add('card', 'kpi-tile', 'skeleton-tile'); + tile.setAttribute('aria-hidden', 'true'); + tile.appendChild(skeletonBox('50%', '0.875rem')); // title + tile.appendChild(skeletonBox('70%', '1.75rem')); // value (--cudly-fs-2xl) + tile.appendChild(skeletonBox('40%', '0.75rem')); // detail + return tile; +} + +/** + * Table row placeholder with `cols` cells. Renders as a real `` + * so it can be appended to an existing `` and laid out by the + * surrounding table's column widths. + */ +export function skeletonRow(cols: number): HTMLTableRowElement { + const tr = document.createElement('tr'); + tr.classList.add('skeleton-row'); + tr.setAttribute('aria-hidden', 'true'); + const safeCols = Math.max(1, Math.floor(cols)); + for (let i = 0; i < safeCols; i += 1) { + const td = document.createElement('td'); + td.appendChild(skeletonBox('80%', '1rem')); + tr.appendChild(td); + } + return tr; +} + +/** + * Wipe whatever is currently in `container` (skeleton, real content, + * stale error message — anything) and mark the container as actively + * holding a skeleton. The `show*` helpers all start with this so the + * placeholder *replaces* prior content, never appends to it. The + * previous implementation called `teardownSkeleton`, which is + * conditional on the marker being present — that meant a first render + * (no marker yet) where the container already had real content would + * end up with skeletons appended below the real content instead of + * replacing it (CR review on PR #346). + */ +function activateSkeleton(container: HTMLElement): void { + while (container.firstChild) container.removeChild(container.firstChild); + container.dataset['skeletonActive'] = '1'; +} + +/** + * Replace the children of `container` with a stack of `count` tile + * skeletons inside a grid wrapper — used for the dashboard KPI grid and + * Plans cards row. + */ +export function showSkeletonTiles(container: HTMLElement, count: number): void { + activateSkeleton(container); + const safeCount = Math.max(1, Math.floor(count)); + for (let i = 0; i < safeCount; i += 1) { + container.appendChild(skeletonTile()); + } +} + +/** + * Replace the children of `container` with `count` skeleton rows inside + * a minimal `` shell. The cols argument controls the + * number of cells per row so the placeholder matches the eventual table + * shape (11 cols for history, 8 for RI tables, etc.). + */ +export function showSkeletonRows(container: HTMLElement, count: number, cols: number): void { + activateSkeleton(container); + const table = document.createElement('table'); + table.classList.add('skeleton-table'); + const tbody = document.createElement('tbody'); + const safeCount = Math.max(1, Math.floor(count)); + for (let i = 0; i < safeCount; i += 1) { + tbody.appendChild(skeletonRow(cols)); + } + table.appendChild(tbody); + container.appendChild(table); +} + +/** + * Replace the children of `container` with a single shimmer block sized + * to `width` x `height`. Useful for chart placeholders. + */ +export function showSkeletonBlock(container: HTMLElement, width: string, height: string): void { + activateSkeleton(container); + container.appendChild(skeletonBox(width, height)); +} + +/** + * Remove any skeleton placeholders inside `container`. Idempotent — + * safe to call from both the success render path (where the render + * function already wipes children) and the error path. The + * `data-skeleton-active` marker is what lets the error path detect a + * pending skeleton and clear it without disturbing already-rendered + * content. + */ +export function teardownSkeleton(container: HTMLElement): void { + if (container.dataset['skeletonActive']) { + while (container.firstChild) container.removeChild(container.firstChild); + delete container.dataset['skeletonActive']; + } +} + +/** + * Returns true when `container` currently has a skeleton active. Used + * by tests + per-module render functions that need to know whether + * they're replacing a skeleton or merging into existing content. + */ +export function isSkeletonActive(container: HTMLElement): boolean { + return container.dataset['skeletonActive'] === '1'; +} diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 8b273d8df..570689b64 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -12,6 +12,7 @@ import { viewPlanHistory } from './history'; import type { PlannedPurchase } from './api'; import { populateTermSelect, populatePaymentSelect, isValidCombination, normalizePaymentValue } from './commitmentOptions'; import { openModal, closeModal } from './modal'; +import { showSkeletonTiles, showSkeletonRows, teardownSkeleton } from './lib/skeleton'; // pendingPlanRecommendations holds the resolved plan target captured at // "Plan from N selected" button-click time. The Plan flow used to re-derive @@ -28,6 +29,14 @@ let pendingPlanRecommendations: api.Recommendation[] = []; * Load plans and planned purchases */ export async function loadPlans(): Promise { + // Issue #344 T3: skeleton tiles for the plans list. Synchronous + // render before fetch so the page doesn't sit blank during the + // round-trip. The planned-purchases skeleton lives in + // loadPlannedPurchases so direct callers of that fetch (not via + // loadPlans) get the same loading affordance. + const plansList = document.getElementById('plans-list'); + if (plansList) showSkeletonTiles(plansList, 3); + try { const data = await api.getPlans() as unknown as PlansResponse; let plans = data.plans || []; @@ -50,6 +59,7 @@ export async function loadPlans(): Promise { console.error('Failed to load plans:', error); const list = document.getElementById('plans-list'); if (list) { + teardownSkeleton(list); const err = error as Error; list.innerHTML = `

Failed to load plans: ${escapeHtml(err.message)}

`; } @@ -66,11 +76,18 @@ async function loadPlannedPurchases(): Promise { const container = document.getElementById('planned-purchases-list'); if (!container) return; + // Issue #344 T3 (CR follow-up on PR #346): skeleton lives here, not + // in loadPlans, so direct callers (e.g. follow-up refresh paths after + // a single purchase action) also get the loading affordance. 5 rows + // × 11 cols matches the rendered table — see renderPlannedPurchases. + showSkeletonRows(container, 5, 11); + try { const data = await api.getPlannedPurchases(); renderPlannedPurchases(data.purchases || []); } catch (error) { console.error('Failed to load planned purchases:', error); + teardownSkeleton(container); const err = error as Error; container.innerHTML = `

Failed to load planned purchases: ${escapeHtml(err.message)}

`; } diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 424926ee1..9eada2604 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -20,6 +20,7 @@ import { import type { AccountServiceOverride } from './api/accounts'; import type { RecommendationsResponse, LocalRecommendation, RecommendationsSummary } from './types'; import { openModal } from './modal'; +import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; // Module state for current purchase modal recommendations let currentPurchaseRecommendations: LocalRecommendation[] = []; @@ -236,6 +237,19 @@ async function triggerAutoRefreshIfStale(): Promise { * Load recommendations */ export async function loadRecommendations(): Promise { + // Issue #344 T3: skeleton rows for the recommendations table so the + // panel reads as "loading" instead of staying blank while the + // (potentially multi-second) Promise.all resolves. 5 rows ≈ above- + // the-fold count for the typical viewport; the column count is + // derived from the live visible-columns set + 1 (the leading checkbox + // column the table renders), so toggling Columns ▾ keeps the + // skeleton row shape aligned with the eventual table. + const listEl = document.getElementById('recommendations-list'); + if (listEl) { + const skeletonCols = 1 + visibleColumns().length; + showSkeletonRows(listEl, 5, skeletonCols); + } + try { // Provider + account_ids are still sent to the API as hints so the // backend stays bounded for big multi-cloud tenants. Service / Region / @@ -289,6 +303,7 @@ export async function loadRecommendations(): Promise { console.error('Failed to load recommendations:', error); const list = document.getElementById('recommendations-list'); if (list) { + teardownSkeleton(list); const err = error as Error; list.innerHTML = `

Failed to load recommendations: ${escapeHtml(err.message)}

`; } diff --git a/frontend/src/riexchange.ts b/frontend/src/riexchange.ts index 95931d956..3e258ffb4 100644 --- a/frontend/src/riexchange.ts +++ b/frontend/src/riexchange.ts @@ -17,6 +17,7 @@ import type { OfferingOption, } from './api'; import { openModal, closeModal } from './modal'; +import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; // Module state let currentRIs: ConvertibleRI[] = []; @@ -80,7 +81,12 @@ async function loadConvertibleRIs(): Promise { const container = document.getElementById('ri-exchange-instances-list'); if (!container) return; - container.innerHTML = '

Loading convertible RIs...

'; + // Issue #344 T3: shimmer skeleton replaces the static "Loading…" text. + // 3 rows × 8 cols matches the convertible-RI table's column shape + // (RI ID / Instance Type / AZ / Count / Offering / Expiry / + // Utilization / Actions — see renderRIsTable); renderRIsTable swaps + // the children on success for a clean handoff. + showSkeletonRows(container, 3, 8); try { currentRIs = await api.listConvertibleRIs(); @@ -89,6 +95,7 @@ async function loadConvertibleRIs(): Promise { utilizationGeneration++; void loadUtilization(utilizationGeneration); } catch (error) { + teardownSkeleton(container); const err = error as Error; container.innerHTML = `

Failed to load convertible RIs: ${escapeHtml(err.message)}

`; } @@ -167,12 +174,17 @@ export async function loadReshapeRecommendations(): Promise { const container = document.getElementById('ri-exchange-recommendations-list'); if (!container) return; - container.innerHTML = '

Analyzing reshape opportunities...

'; + // Issue #344 T3: skeleton rows for the reshape-recommendations table. + // 3 rows × 8 cols matches the rendered table shape — see + // renderRecommendations: Source RI / Current / Suggested / + // Alternatives / Utilization / Normalized Units / Reason / Actions. + showSkeletonRows(container, 3, 8); try { currentRecommendations = await api.getReshapeRecommendations(); renderRecommendations(container); } catch (error) { + teardownSkeleton(container); const err = error as Error; container.innerHTML = `

Failed to load recommendations: ${escapeHtml(err.message)}

`; } diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 8296c2ced..3c868a922 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -219,6 +219,67 @@ body { display: none; } +/* Loading skeletons (issue #344 T3). + * + * Shimmer-animated placeholder blocks rendered synchronously at the start + * of every fetch path so panels show "loading" intent instead of staying + * empty. The render-success path replaces the skeleton container's + * children for a clean handoff; error paths must call the same teardown + * + renderError sequence so a skeleton never sits next to an error toast. + * + * Container helper `.skeleton-group` lays out child skeletons in a column + * with consistent spacing; `.skeleton-row-grid` does the same with the + * recommendations / history grid so each skeleton row reads as a table + * row. */ +.skeleton { + display: block; + background: linear-gradient( + 90deg, + var(--cudly-surface-muted) 0%, + var(--cudly-border) 50%, + var(--cudly-surface-muted) 100% + ); + background-size: 200% 100%; + border-radius: var(--cudly-r-sm); + animation: cudly-shimmer 1.6s ease-in-out infinite; +} + +.skeleton-group { + display: flex; + flex-direction: column; + gap: var(--cudly-sp-3); +} + +.skeleton-tile { + /* KPI tile shape — matches .kpi-tile's padding + min height so the + * post-render swap doesn't trigger layout shift. */ + padding: var(--cudly-sp-4); + min-height: 6rem; +} + +.skeleton-tile .skeleton { + margin-bottom: var(--cudly-sp-2); +} + +.skeleton-tile .skeleton:last-child { + margin-bottom: 0; +} + +@keyframes cudly-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Respect users who prefer reduced motion — drop the shimmer animation + * but keep a static muted background so the "loading" affordance still + * reads visually. */ +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + background: var(--cudly-surface-muted); + } +} + /* Error message */ .error-message { padding: 1rem;