From 0c287f15454b7d0cf0ca611d40a5c94b7982cfb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 02:02:46 +0200 Subject: [PATCH] feat(insights): polished loading + empty + error states Loading state replaces the spinner-only placeholder with a 3-card skeleton grid that mirrors the final layout, so the page doesn't visually jump when content lands. Each placeholder pulses with a 100ms staggered delay. Empty state gains an illustrated sparkle glyph + a centred copy block (max-w-prose) so the affordance reads as an invitation rather than a muted dead-end. Error state moves from a flat banner to a card with a discoverable retry button (data-slot=insight-retry-button) when an onRegenerate handler is supplied. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/insight-advisor-card.test.tsx | 60 +++++++++++++++ .../insights/insight-advisor-card.tsx | 75 ++++++++++++++++--- 2 files changed, 124 insertions(+), 11 deletions(-) diff --git a/src/components/insights/__tests__/insight-advisor-card.test.tsx b/src/components/insights/__tests__/insight-advisor-card.test.tsx index b7ecdd98e..8b3ebaa8a 100644 --- a/src/components/insights/__tests__/insight-advisor-card.test.tsx +++ b/src/components/insights/__tests__/insight-advisor-card.test.tsx @@ -221,3 +221,63 @@ describe(" — summary typography polish (B1b)", () => { ); }); }); + +describe(" — polished loading / empty / error states (B1b)", () => { + it("loading state shows a skeleton that mirrors the final layout", () => { + const html = renderToStaticMarkup( + + + , + ); + // A discoverable skeleton slot replaces the spinner-only loading state. + expect(html).toMatch(/data-slot="insight-skeleton"/); + // 3 placeholder rec rows match the final card grid (so the page + // doesn't visually jump when content lands). + expect(html).toMatch(/data-slot="insight-skeleton-card"/); + }); + + it("empty state shows a friendly illustration + CTA", () => { + const html = renderToStaticMarkup( + + {}} + /> + , + ); + expect(html).toMatch(/data-slot="insight-empty-state"/); + // The Start-Analysis button stays — that's the CTA. + expect(html).toContain("Start analysis"); + }); + + it("error state surfaces a retry button when onRegenerate is provided", () => { + const html = renderToStaticMarkup( + + {}} + /> + , + ); + expect(html).toMatch(/data-slot="insight-error-state"/); + expect(html).toContain("Something went wrong"); + expect(html).toMatch(/data-slot="insight-retry-button"/); + }); + + it("error state without onRegenerate hides the retry button", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain("No retry available"); + expect(html).not.toContain('data-slot="insight-retry-button"'); + }); +}); diff --git a/src/components/insights/insight-advisor-card.tsx b/src/components/insights/insight-advisor-card.tsx index 36753b40e..ca19b655c 100644 --- a/src/components/insights/insight-advisor-card.tsx +++ b/src/components/insights/insight-advisor-card.tsx @@ -317,14 +317,36 @@ export function InsightAdvisorCard({ : CLASSIFICATION_STYLES.gut; // ── Loading State ───────────────────────────────────── + // v1.4.16 phase B1b — skeleton mirrors the final card layout (header + // pulse + 3 placeholder rec rows) so the page doesn't visually jump + // when content lands. Animation keys off Tailwind `animate-pulse`, + // which `prefers-reduced-motion: reduce` already silences via the + // `motion-reduce:animate-none` utility on the same elements. if (loading && !insight) { return ( - - - - - {t("insights.generating")} - + + +
+ {icon ?? } + + {t("insights.aiAnalysisTitle")} + +
+

{title}

+
+ +
+
+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
); @@ -347,14 +369,45 @@ export function InsightAdvisorCard({ {error && ( -
- - {error} +
+
+ + {error} +
+ {onRegenerate && ( + + )}
)} {!error && ( -
-

+

+
+
+

{t("insights.noAnalysisYet")}

{onRegenerate && (