diff --git a/frontend/src/__tests__/error-callout.test.ts b/frontend/src/__tests__/error-callout.test.ts new file mode 100644 index 000000000..b5b5e70bc --- /dev/null +++ b/frontend/src/__tests__/error-callout.test.ts @@ -0,0 +1,36 @@ +/** + * Section load-failure callout: keeps the failure line as p.error, is + * announced, and offers "Try again" that re-runs the section's load. + */ +import { buildErrorCallout, renderErrorCallout, ERROR_CALLOUT_HINT } from '../lib/error-callout'; + +describe('error callout', () => { + test('keeps the exact failure line in p.error and is announced', () => { + const el = buildErrorCallout({ message: 'Failed to load plans: boom' }); + expect(el.getAttribute('role')).toBe('alert'); + const line = el.querySelector('p.error'); + // textContent, not innerHTML: markup in the reason stays text. + expect(line?.textContent).toBe('Failed to load plans: boom'); + expect(line?.querySelector('b')).toBeNull(); + expect(el.querySelector('.error-callout-hint')?.textContent).toBe(ERROR_CALLOUT_HINT); + expect(el.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true'); + }); + + test('has no button without a retry handler', () => { + const el = buildErrorCallout({ message: 'Failed to load plans: x' }); + expect(el.querySelector('button')).toBeNull(); + }); + + test('"Try again" re-runs the load', () => { + const onRetry = jest.fn(); + const host = document.createElement('div'); + host.innerHTML = '
stale
'; + renderErrorCallout(host, { message: 'Failed to load recommendations: internal error', onRetry }); + expect(host.children.length).toBe(1); + const btn = host.querySelector('button')!; + expect(btn.textContent).toBe('Try again'); + expect(btn.type).toBe('button'); + btn.click(); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index a00f9de0d..04fc49386 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -16,6 +16,7 @@ import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefresh import { getRecommendationsFreshness } from './api/recommendations'; import { buildFirstRunEmptyState, isScopeFiltered } from './first-run-empty-state'; import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; +import { buildErrorCallout } from './lib/error-callout'; import { filterByEnabledProviders } from './lib/enabled-providers'; import { buildKpiTile } from './lib/kpi-tile'; import { scopeUpcomingToProvider, upcomingEmptyMessage, UPCOMING_ACCOUNT_SCOPE_NOTE } from './lib/upcoming-scope'; @@ -291,10 +292,10 @@ export async function loadDashboard(): PromiseFailed to load plans: ${escapeHtml(err.message)}
`; + renderErrorCallout(list, { + message: `Failed to load plans: ${err.message}`, + onRetry: () => void loadPlans(), + }); } } @@ -124,7 +128,10 @@ async function loadPlannedPurchases(): PromiseFailed to load planned purchases: ${escapeHtml(err.message)}
`; + renderErrorCallout(container, { + message: `Failed to load planned purchases: ${err.message}`, + onRetry: () => void loadPlannedPurchases(), + }); } } diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 53f6a16d8..6452527f3 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -26,6 +26,7 @@ import type { AccountServiceOverride } from './api/accounts'; import type { RecommendationsResponse, LocalRecommendation, RecommendationsSummary, GlobalConfig } from './types'; import { openModal } from './modal'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; +import { renderErrorCallout } from './lib/error-callout'; import { renderKpiTiles } from './lib/kpi-tile'; import { captureFocus, restoreFocus } from './lib/focus-restore'; import { canAccess } from './permissions'; @@ -716,7 +717,10 @@ export async function loadRecommendations(): PromiseFailed to load recommendations: ${escapeHtml(err.message)}
`; + renderErrorCallout(list, { + message: `Failed to load recommendations: ${err.message}`, + onRetry: () => void loadRecommendations(), + }); } } } diff --git a/frontend/src/styles/shell.css b/frontend/src/styles/shell.css index 5b86fa02b..3f5be991b 100644 --- a/frontend/src/styles/shell.css +++ b/frontend/src/styles/shell.css @@ -681,6 +681,122 @@ body.auth-pending #app { margin-top: var(--cudly-sp-4); } +/* --------------------------------------------------------------------------- + * Error, empty and loading blocks. + * + * Inline error lines (p.error / div.error) read as one alert: tinted box, + * hairline border, alert icon. A section whose load failed gets the + * .error-callout (lib/error-callout.ts): the same icon, the failure line, a + * short hint and a "Try again" that re-runs the section's load, on a card + * surface like the start-up error card (init-error.ts). + * ------------------------------------------------------------------------- */ +:root { + --cudly-error-border: color-mix(in srgb, var(--cudly-error) 28%, transparent); + --cudly-icon-alert: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='8' x2='12' y2='12'/%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'/%3E%3C/svg%3E"); +} + +p.error, +div.error { + position: relative; + padding: var(--cudly-sp-3) var(--cudly-sp-4) var(--cudly-sp-3) calc(var(--cudly-sp-4) + 1.125rem + var(--cudly-sp-2)); + border: 1px solid var(--cudly-error-border); + border-radius: var(--cudly-r-md); + background: var(--cudly-error-bg); + color: var(--cudly-error-fg); + font-size: var(--cudly-fs-sm); + line-height: var(--cudly-lh-normal); +} + +p.error::before, +div.error::before { + content: ""; + position: absolute; + left: var(--cudly-sp-4); + top: calc(var(--cudly-sp-3) + 0.125rem); + width: 1.125rem; + height: 1.125rem; + background-color: currentColor; + -webkit-mask: var(--cudly-icon-alert) center / contain no-repeat; + mask: var(--cudly-icon-alert) center / contain no-repeat; +} + +.error-callout { + display: flex; + align-items: flex-start; + gap: var(--cudly-sp-3); + padding: var(--cudly-sp-4) var(--cudly-sp-5); + background: var(--cudly-surface); + border: 1px solid var(--cudly-error-border); + border-radius: var(--cudly-r-md); + box-shadow: var(--cudly-shadow-sm); +} + +.error-callout-icon { + flex-shrink: 0; + margin-top: 0.0625rem; + color: var(--cudly-error-fg); +} + +.error-callout-body { + min-width: 0; +} + +.error-callout p.error { + padding: 0; + border: 0; + background: none; + color: var(--cudly-text); + font-weight: var(--cudly-fw-semibold); + overflow-wrap: anywhere; +} + +.error-callout p.error::before { + content: none; +} + +.error-callout-hint { + margin-top: var(--cudly-sp-1); + color: var(--cudly-text-muted); + font-size: var(--cudly-fs-sm); +} + +.error-callout .error-callout-retry { + margin: var(--cudly-sp-3) 0 0; +} + +/* Start-up error card: the same icon, centred above the title. */ +.init-error-card .init-error-icon { + display: block; + width: 2.5rem; + height: 2.5rem; + margin: 0 auto var(--cudly-sp-3); + padding: var(--cudly-sp-2); + border-radius: var(--cudly-r-full); + background: var(--cudly-error-bg); + color: var(--cudly-error-fg); +} + +.init-error-card .btn-primary { + margin-top: var(--cudly-sp-4); +} + +/* Section/page empty cards: a little more air and a calmer heading. */ +.empty-state { + padding: var(--cudly-sp-7) var(--cudly-sp-5); +} + +.empty-state h3 { + font-size: var(--cudly-fs-md); +} + +.empty-state p { + font-size: var(--cudly-fs-sm); +} + +.card .error-callout { + box-shadow: none; +} + /* Mid-width desktops (tablet landscape, small laptops): the top bar drops * the email text (the avatar stays, and the button keeps the address as its * accessible name and tooltip) and the brand stops reserving the sidebar