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;
|