feat: add error boundaries, loading skeletons, page transitions, and empty states

- ErrorBoundary: global error boundary with ShieldAI branding, retry/report
- Skeleton: SkeletonText, SkeletonCard, SkeletonAvatar, SkeletonTable
- PageTransition: fade-in + translate-y on route change, respects reduced motion
- EmptyState: reusable component with icon, title, description, action
- Button: add ariaLabel prop support
- Toast: add aria-live=polite region
- Dashboard: replace pulse divs with SkeletonCard fallbacks
- Service pages: add skeleton layouts, empty states, mutation loading states
- 404 page: polished with ShieldAI branding and home navigation
- app.tsx: add ErrorBoundary, PageTransition, skip-to-content link
- app.css: add page-enter animation with prefers-reduced-motion support
This commit is contained in:
2026-05-25 18:05:29 -04:00
parent c02457c66a
commit 20dc5bf785
18 changed files with 771 additions and 179 deletions

View File

@@ -13,6 +13,7 @@ interface ButtonProps {
children: JSX.Element;
onClick?: (e: MouseEvent) => void;
type?: "button" | "submit" | "reset";
ariaLabel?: string;
}
const variantClasses: Record<ButtonVariant, string> = {
@@ -67,6 +68,7 @@ export default function Button(props: ButtonProps) {
type={props.type ?? "button"}
disabled={isDisabled()}
onClick={props.onClick}
aria-label={props.ariaLabel}
class={cn(
"inline-flex items-center justify-center font-medium transition-all duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer",
variantClasses[variant()],

View File

@@ -0,0 +1,44 @@
import type { JSX } from "solid-js";
import { cn } from "~/lib/utils";
import Button from "./Button";
interface EmptyStateProps {
icon?: JSX.Element;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
class?: string;
}
export default function EmptyState(props: EmptyStateProps) {
return (
<div
class={cn(
"flex flex-col items-center justify-center text-center py-12 px-6",
props.class,
)}
>
{props.icon && (
<div class="mb-4 text-[var(--color-text-tertiary)]">
{props.icon}
</div>
)}
<h3 class="text-lg font-semibold text-[var(--color-text-primary)] mb-1">
{props.title}
</h3>
{props.description && (
<p class="text-sm text-[var(--color-text-secondary)] max-w-sm mb-6">
{props.description}
</p>
)}
{props.action && (
<Button onClick={props.action.onClick} size="sm">
{props.action.label}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { createSignal, type JSX } from "solid-js";
import { ErrorBoundary as SolidErrorBoundary } from "solid-js";
import Button from "./Button";
interface ErrorFallbackProps {
error: Error;
reset: () => void;
}
function ShieldLogo() {
return (
<svg
width="48"
height="56"
viewBox="0 0 28 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient
id="error-shield-grad"
x1="0"
y1="0"
x2="28"
y2="32"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--color-brand-primary)" />
<stop offset="1" stop-color="var(--color-brand-accent)" />
</linearGradient>
</defs>
<path
d="M14 0L26 6V16C26 24 14 32 14 32S2 24 2 16V6L14 0Z"
fill="url(#error-shield-grad)"
/>
<path
d="M10 16L13 19L19 13"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}
function ErrorFallback(props: ErrorFallbackProps) {
const [expanded, setExpanded] = createSignal(false);
function handleReport() {
console.error("[ErrorBoundary] Error details:", props.error);
const text = `Error: ${props.error.message}\nStack: ${props.error.stack}`;
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "error-report.txt";
a.click();
URL.revokeObjectURL(url);
}
return (
<div class="flex items-center justify-center min-h-[400px] p-8">
<div class="flex flex-col items-center text-center max-w-md gap-6">
<ShieldLogo />
<div class="space-y-2">
<h2 class="text-xl font-bold text-[var(--color-text-primary)]">
Something went wrong
</h2>
<p class="text-sm text-[var(--color-text-secondary)]">
An unexpected error occurred. Please try again or report the issue.
</p>
</div>
<div class="flex gap-3">
<Button onClick={props.reset}>Try again</Button>
<Button variant="secondary" onClick={handleReport}>
Report issue
</Button>
</div>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
class="text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors cursor-pointer"
aria-label="Toggle error details"
>
{expanded() ? "Hide" : "Show"} error details
</button>
{expanded() && (
<pre class="text-xs text-left text-[var(--color-text-tertiary)] bg-[var(--color-bg-secondary)] p-3 rounded-lg overflow-auto max-w-full">
{props.error.message}
{"\n\n"}
{props.error.stack}
</pre>
)}
</div>
</div>
);
}
interface ErrorBoundaryProps {
children: JSX.Element;
}
export default function ErrorBoundary(props: ErrorBoundaryProps) {
return (
<SolidErrorBoundary
fallback={(error, reset) => (
<ErrorFallback error={error} reset={reset} />
)}
>
{props.children}
</SolidErrorBoundary>
);
}

View File

@@ -0,0 +1,34 @@
import { createEffect, createSignal, type JSX } from "solid-js";
import { useLocation } from "@solidjs/router";
import { cn } from "~/lib/utils";
interface PageTransitionProps {
children: JSX.Element;
class?: string;
}
export default function PageTransition(props: PageTransitionProps) {
const location = useLocation();
const [entering, setEntering] = createSignal(true);
createEffect(() => {
location.pathname;
setEntering(false);
requestAnimationFrame(() => {
requestAnimationFrame(() => setEntering(true));
});
});
return (
<div
class={cn(
!entering() && "opacity-0",
entering() && "opacity-100 translate-y-0 animate-page-enter",
"motion-reduce:opacity-100 motion-reduce:translate-y-0 motion-reduce:animate-none",
props.class,
)}
>
{props.children}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { For } from "solid-js";
import { cn } from "~/lib/utils";
interface SkeletonTextProps {
lines?: number;
width?: string;
class?: string;
}
export function SkeletonText(props: SkeletonTextProps) {
const lines = () => props.lines ?? 3;
return (
<div class={cn("space-y-2", props.class)} role="status" aria-label="Loading">
<For each={Array.from({ length: lines() })}>
{(_, i) => (
<div
class="h-3 rounded bg-[var(--color-bg-tertiary)] animate-pulse"
style={{ width: i() === lines() - 1 ? "60%" : "100%" }}
/>
)}
</For>
</div>
);
}
interface SkeletonCardProps {
class?: string;
}
export function SkeletonCard(props: SkeletonCardProps) {
return (
<div
class={cn(
"rounded-xl border border-[var(--color-border)]/50 overflow-hidden",
props.class,
)}
role="status"
aria-label="Loading"
>
<div class="p-4 space-y-3">
<div class="h-4 w-2/3 rounded bg-[var(--color-bg-tertiary)] animate-pulse" />
<div class="h-3 w-full rounded bg-[var(--color-bg-tertiary)] animate-pulse" />
<div class="h-3 w-4/5 rounded bg-[var(--color-bg-tertiary)] animate-pulse" />
</div>
<div class="border-t border-[var(--color-border)]/50 p-4">
<div class="h-3 w-1/3 rounded bg-[var(--color-bg-tertiary)] animate-pulse" />
</div>
</div>
);
}
interface SkeletonAvatarProps {
size?: number;
class?: string;
}
export function SkeletonAvatar(props: SkeletonAvatarProps) {
const size = () => props.size ?? 40;
return (
<div
class={cn("rounded-full bg-[var(--color-bg-tertiary)] animate-pulse shrink-0", props.class)}
style={{ width: `${size()}px`, height: `${size()}px` }}
role="status"
aria-label="Loading"
/>
);
}
interface SkeletonTableProps {
rows?: number;
columns?: number;
class?: string;
}
export function SkeletonTable(props: SkeletonTableProps) {
const rows = () => props.rows ?? 5;
const columns = () => props.columns ?? 4;
return (
<div class={cn("space-y-2", props.class)} role="status" aria-label="Loading">
<For each={Array.from({ length: rows() })}>
{() => (
<div class="flex gap-4 px-4 py-3">
<For each={Array.from({ length: columns() })}>
{(_, i) => (
<div
class="h-3 rounded bg-[var(--color-bg-tertiary)] animate-pulse"
style={{ width: i() === 0 ? "30%" : `${15 + Math.random() * 20}%` }}
/>
)}
</For>
</div>
)}
</For>
</div>
);
}

View File

@@ -106,6 +106,7 @@ function ToastContainer() {
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm"
role="region"
aria-label="Notifications"
aria-live="polite"
>
<For each={toasts()}>
{(toast) => (

View File

@@ -3,4 +3,13 @@ export { default as Card } from "./Card";
export { default as Input } from "./Input";
export { default as Badge } from "./Badge";
export { default as Modal } from "./Modal";
export { default as ErrorBoundary } from "./ErrorBoundary";
export { default as PageTransition } from "./PageTransition";
export { default as EmptyState } from "./EmptyState";
export {
SkeletonText,
SkeletonCard,
SkeletonAvatar,
SkeletonTable,
} from "./Skeleton";
export { ToastProvider, useToast } from "./Toast";

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render } from "solid-js/web";
import { ErrorBoundary as SolidErrorBoundary } from "solid-js";
import type { JSX } from "solid-js";
import Button from "./Button";
@@ -8,6 +9,14 @@ import Input from "./Input";
import Badge from "./Badge";
import Modal from "./Modal";
import { ToastProvider, useToast } from "./Toast";
import ErrorBoundary from "./ErrorBoundary";
import EmptyState from "./EmptyState";
import {
SkeletonText,
SkeletonCard,
SkeletonAvatar,
SkeletonTable,
} from "./Skeleton";
function mount(comp: () => JSX.Element): HTMLDivElement {
const container = document.createElement("div");
@@ -502,3 +511,101 @@ describe("Toast", () => {
expect(toastEl.className).toContain("bg-[var(--color-error-bg)]");
});
});
describe("ErrorBoundary", () => {
it("renders children when no error", () => {
mount(() => (
<ErrorBoundary>
<p>All good</p>
</ErrorBoundary>
));
expect(document.body.textContent).toContain("All good");
});
it("catches errors and shows fallback UI", () => {
const Throwing = () => {
throw new Error("Test error");
};
mount(() => (
<ErrorBoundary>
<Throwing />
</ErrorBoundary>
));
expect(document.body.textContent).toContain("Something went wrong");
expect(document.body.textContent).toContain("Try again");
expect(document.body.textContent).toContain("Report issue");
});
});
describe("Skeleton", () => {
it("SkeletonText renders with aria-label", () => {
mount(() => <SkeletonText lines={3} />);
const el = document.querySelector("[role='status']")!;
expect(el).toBeTruthy();
expect(el.getAttribute("aria-label")).toBe("Loading");
});
it("SkeletonText renders correct number of lines", () => {
mount(() => <SkeletonText lines={5} />);
const bars = document.querySelectorAll("[role='status'] > div");
expect(bars.length).toBe(5);
});
it("SkeletonCard renders with aria-label", () => {
mount(() => <SkeletonCard />);
const el = document.querySelector("[role='status']")!;
expect(el).toBeTruthy();
expect(el.getAttribute("aria-label")).toBe("Loading");
});
it("SkeletonAvatar renders with correct size", () => {
mount(() => <SkeletonAvatar size={60} />);
const el = document.querySelector("[role='status']")! as HTMLElement;
expect(el.style.width).toBe("60px");
expect(el.style.height).toBe("60px");
});
it("SkeletonTable renders correct number of rows", () => {
mount(() => <SkeletonTable rows={3} />);
const rows = document.querySelectorAll("[role='status'] > div");
expect(rows.length).toBe(3);
});
});
describe("EmptyState", () => {
it("renders title and description", () => {
mount(() => (
<EmptyState
title="No items"
description="There are no items to display."
/>
));
expect(document.body.textContent).toContain("No items");
expect(document.body.textContent).toContain("There are no items to display.");
});
it("renders action button when provided", () => {
const onClick = vi.fn();
mount(() => (
<EmptyState
title="Empty"
action={{ label: "Add item", onClick }}
/>
));
const btn = document.querySelector("button")!;
expect(btn).toBeTruthy();
expect(btn.textContent).toContain("Add item");
btn.click();
expect(onClick).toHaveBeenCalledTimes(1);
});
it("renders icon when provided", () => {
mount(() => (
<EmptyState
title="Empty"
icon={<svg data-testid="test-icon" width="24" height="24" />}
/>
));
expect(document.querySelector("[data-testid='test-icon']")).toBeTruthy();
});
});