Converge error fallback chrome on shared primitives

This commit is contained in:
rcourtman
2026-06-13 19:26:56 +01:00
parent 761127018e
commit 9418dbb330
5 changed files with 148 additions and 73 deletions
@@ -520,6 +520,9 @@ not a replacement status card, CTA band, or page-local nested card.
and reporting exports use the shared `success`, `successOutline`, and
`successGhost` Button variants instead of carrying page-local emerald action
shells.
Shared error-boundary fallback actions are also command buttons: reset,
reload, and retry controls must compose `Button` so emergency UI does not
become a separate local button vocabulary.
Compact icon-only row, inline, and floating action controls belong to
`ActionIconButton`. Feature surfaces may own the icon choice, click handler,
label text, and layout slot, but icon-button size, tone, focus ring,
@@ -3391,6 +3394,10 @@ credential slots own the source-specific lifecycle or API meaning, while
The `settings-connection-editor-local-*-callout-shell` pattern guards block
future connection-editor files from reintroducing amber, red, or rose local
notice shells.
Shared error-boundary fallbacks use the same boundary: the fallback owns error
copy and reset/reload handlers, while `CalloutCard` owns danger tone, spacing,
dark-mode styling, and alert layout instead of inline red panels or raw SVG
alert glyphs.
Settings loading placeholders must route through the shared
`SettingsLoadingSkeleton` primitive instead of local `animate-pulse` blocks.
@@ -389,6 +389,32 @@
"scripts/shared-template-audit.mjs"
]
},
{
"id": "error-boundary-callout-shell",
"category": "callout",
"summary": "Shared error-boundary fallback notices must compose CalloutCard and library icons for tone, spacing, and dark-mode styling instead of recreating local red panels or inline SVG alert glyphs.",
"canonical": {
"path": "src/components/shared/CalloutCard.tsx",
"export": "CalloutCard"
},
"requiredConsumers": [{ "path": "src/components/ErrorBoundary.tsx" }],
"forbiddenPatterns": [
{
"path": "src/components/ErrorBoundary.tsx",
"patterns": [
"<svg",
"bg-red-50 dark:bg-red-900 border border-red-200",
"p-4 bg-red-50 dark:bg-red-900 border border-red-200"
]
}
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/__tests__/ErrorBoundary.test.tsx",
"src/components/shared/CalloutCard.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "platform-inline-notice-shell",
"category": "callout",
@@ -513,15 +539,11 @@
},
{
"path": "src/components/Settings/AgentProfilesPanel.tsx",
"patterns": [
"animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"
]
"patterns": ["animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"]
},
{
"path": "src/components/Settings/RolesPanel.tsx",
"patterns": [
"animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"
]
"patterns": ["animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"]
},
{
"path": "src/components/Settings/SSOProvidersPanel.tsx",
@@ -543,21 +565,15 @@
},
{
"path": "src/components/Settings/UserAssignmentsDialog.tsx",
"patterns": [
"animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"
]
"patterns": ["animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"]
},
{
"path": "src/components/Settings/UserAssignmentsPanel.tsx",
"patterns": [
"animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"
]
"patterns": ["animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"]
},
{
"path": "src/components/shared/Button.tsx",
"patterns": [
"class=\"animate-spin -ml-1 mr-2 h-4 w-4 text-current\""
]
"patterns": ["class=\"animate-spin -ml-1 mr-2 h-4 w-4 text-current\""]
},
{
"path": "src/components/shared/HistoryChartOverlay.tsx",
@@ -735,6 +751,7 @@
},
"requiredConsumers": [
{ "path": "src/components/AI/Chat/ChatMessages.tsx" },
{ "path": "src/components/ErrorBoundary.tsx" },
{ "path": "src/components/Infrastructure/ResourceDetailDrawer.tsx" },
{ "path": "src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx" },
{ "path": "src/components/Settings/AgentProfilesPanel.tsx" },
@@ -809,10 +826,19 @@
"inline-flex items-center justify-center rounded-md border border-emerald-300 bg-white px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100",
"inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100"
]
},
{
"path": "src/components/ErrorBoundary.tsx",
"patterns": [
"px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700",
"px-4 py-2 bg-slate-600 text-white rounded hover:bg-slate-700",
"text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700"
]
}
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/__tests__/ErrorBoundary.test.tsx",
"src/components/shared/Button.test.tsx",
"scripts/shared-template-audit.mjs"
]
@@ -1,4 +1,8 @@
import { Component, JSX, ErrorBoundary as SolidErrorBoundary } from 'solid-js';
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import { Button } from '@/components/shared/Button';
import { CalloutCard } from '@/components/shared/CalloutCard';
import { logError } from '@/utils/logger';
import { SectionHeader } from '@/components/shared/SectionHeader';
@@ -13,19 +17,7 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
<div class="min-h-screen flex items-center justify-center bg-base p-4">
<div class="max-w-md w-full bg-surface rounded-md shadow-sm p-6">
<div class="flex items-center mb-4">
<svg
class="w-12 h-12 text-red-500 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<AlertTriangleIcon class="mr-3 h-12 w-12 text-red-500" aria-hidden="true" />
<div>
<SectionHeader
title="Something went wrong"
@@ -37,28 +29,26 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
</div>
</div>
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded p-3 mb-4">
<p class="text-sm text-red-800 dark:text-red-200">
Please try again or reload the page. If the problem persists, contact your
administrator.
</p>
</div>
<CalloutCard
tone="danger"
scale="compact"
padding="sm"
class="mb-4"
description="Please try again or reload the page. If the problem persists, contact your administrator."
/>
<div class="flex gap-2">
<button
type="button"
onClick={props.reset}
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
<Button onClick={props.reset} variant="primary" size="md" class="flex-1">
Try Again
</button>
<button
type="button"
</Button>
<Button
onClick={() => window.location.reload()}
class="flex-1 px-4 py-2 bg-slate-600 text-white rounded hover:bg-slate-700 transition-colors"
variant="secondary"
size="md"
class="flex-1"
>
Reload Page
</button>
</Button>
</div>
<div class="mt-4 text-xs text-muted leading-relaxed">
@@ -102,36 +92,18 @@ export const ComponentErrorBoundary: Component<{
return (
<ErrorBoundary
fallback={(error, reset) => (
<div class="p-4 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded">
<div class="flex items-center mb-2">
<svg
class="w-5 h-5 text-red-500 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<SectionHeader
title={`Error in ${props.name}`}
size="sm"
titleClass="text-red-800 dark:text-red-200"
/>
</div>
<p class="text-xs text-red-700 dark:text-red-300 mb-2">{error.message}</p>
<button
type="button"
onClick={reset}
class="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
<CalloutCard
tone="danger"
scale="compact"
padding="md"
icon={<AlertTriangleIcon class="h-5 w-5" aria-hidden="true" />}
title={`Error in ${props.name}`}
description={error.message}
>
<Button onClick={reset} variant="danger" size="xs" class="mt-1">
Retry
</button>
</div>
</Button>
</CalloutCard>
)}
onError={(error) => {
logError(`Error in component ${props.name}`, error);
@@ -1,6 +1,7 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import type { JSX } from 'solid-js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import errorBoundarySource from '@/components/ErrorBoundary.tsx?raw';
/* ------------------------------------------------------------------ */
/* Mocks */
@@ -90,6 +91,17 @@ describe('ErrorBoundary', () => {
expect(screen.getByText(/Technical details are suppressed/)).toBeInTheDocument();
});
it('keeps fallback chrome on shared primitives', () => {
expect(errorBoundarySource).toContain('CalloutCard');
expect(errorBoundarySource).toContain('Button');
expect(errorBoundarySource).toContain('lucide-solid/icons/alert-triangle');
expect(errorBoundarySource).not.toContain('<svg');
expect(errorBoundarySource).not.toContain(
'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700',
);
expect(errorBoundarySource).not.toContain('bg-red-50 dark:bg-red-900 border border-red-200');
});
/* ---------- Logs error via logError ---------- */
it('calls logError exactly once when a child throws', async () => {
@@ -115,6 +115,7 @@ import inlineDetailTableRowSource from '@/components/shared/InlineDetailTableRow
import sharedTemplateRegistrySource from '../../../scripts/shared-template-registry.json?raw';
import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw';
import emailProviderSelectSource from '@/components/Alerts/EmailProviderSelect.tsx?raw';
import errorBoundarySource from '@/components/ErrorBoundary.tsx?raw';
import incidentTimelinePanelSource from '@/components/Alerts/IncidentTimelinePanel.tsx?raw';
import alertDetailPresentationSource from '@/utils/alertDetailPresentation.ts?raw';
import alertSeverityPresentationSource from '@/utils/alertSeverityPresentation.ts?raw';
@@ -2881,6 +2882,7 @@ describe('shared primitive guardrails', () => {
expect(registeredRule?.canonical?.export).toBe('Button');
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
'src/components/AI/Chat/ChatMessages.tsx',
'src/components/ErrorBoundary.tsx',
'src/components/Infrastructure/ResourceDetailDrawer.tsx',
'src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx',
'src/components/Settings/AgentProfilesPanel.tsx',
@@ -2951,8 +2953,26 @@ describe('shared primitive guardrails', () => {
'inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100',
]),
}),
expect.objectContaining({
path: 'src/components/ErrorBoundary.tsx',
patterns: expect.arrayContaining([
'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700',
'px-4 py-2 bg-slate-600 text-white rounded hover:bg-slate-700',
'text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700',
]),
}),
]),
);
expect(errorBoundarySource).toContain('Button');
expect(errorBoundarySource).not.toContain(
'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700',
);
expect(errorBoundarySource).not.toContain(
'px-4 py-2 bg-slate-600 text-white rounded hover:bg-slate-700',
);
expect(errorBoundarySource).not.toContain(
'text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700',
);
expect(registeredGuard?.canonical?.path).toBe('src/components/shared/buttonModel.ts');
expect(registeredGuard?.canonical?.export).toBe('getButtonClass');
expect(registeredGuard?.allPatterns).toEqual([
@@ -3677,6 +3697,44 @@ describe('shared primitive guardrails', () => {
);
});
it('routes shared error-boundary fallbacks through shared callout and button primitives', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
rules?: Array<{
id: string;
canonical?: { path?: string; export?: string };
requiredConsumers?: Array<{ path?: string }>;
forbiddenPatterns?: Array<{ path?: string; patterns?: string[] }>;
}>;
};
const registeredRule = registry.rules?.find(
(rule) => rule.id === 'error-boundary-callout-shell',
);
expect(registeredRule?.canonical?.path).toBe('src/components/shared/CalloutCard.tsx');
expect(registeredRule?.canonical?.export).toBe('CalloutCard');
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
'src/components/ErrorBoundary.tsx',
]);
expect(registeredRule?.forbiddenPatterns).toEqual([
{
path: 'src/components/ErrorBoundary.tsx',
patterns: [
'<svg',
'bg-red-50 dark:bg-red-900 border border-red-200',
'p-4 bg-red-50 dark:bg-red-900 border border-red-200',
],
},
]);
expect(errorBoundarySource).toContain('CalloutCard');
expect(errorBoundarySource).toContain('Button');
expect(errorBoundarySource).toContain('lucide-solid/icons/alert-triangle');
expect(errorBoundarySource).not.toContain('<svg');
expect(errorBoundarySource).not.toContain('bg-red-50 dark:bg-red-900 border border-red-200');
expect(errorBoundarySource).not.toContain(
'p-4 bg-red-50 dark:bg-red-900 border border-red-200',
);
});
it('routes platform inline notices through InlineNotice', () => {
const registry = JSON.parse(sharedTemplateRegistrySource) as {
rules?: Array<{