diff --git a/src/components/ClaimDrawer/ClaimDrawerError.test.tsx b/src/components/ClaimDrawer/ClaimDrawerError.test.tsx
new file mode 100644
index 0000000..34d509a
--- /dev/null
+++ b/src/components/ClaimDrawer/ClaimDrawerError.test.tsx
@@ -0,0 +1,157 @@
+// @vitest-environment happy-dom
+// React Query's internal state updates need an `act`-aware environment or
+// it logs noisy warnings. Match the convention from useClaimDetail.test.ts.
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { describe, expect, it, vi } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ClaimDrawerError } from "./ClaimDrawerError";
+
+function renderIntoContainer(element: React.ReactElement): {
+ container: HTMLDivElement;
+ unmount: () => void;
+} {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const root: Root = createRoot(container);
+ act(() => {
+ root.render(
+ React.createElement(QueryClientProvider, { client: qc }, element)
+ );
+ });
+ return {
+ container,
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+describe("ClaimDrawerError", () => {
+ it("renders_not_found_message_and_close_button", () => {
+ // The container surface marks itself with role=alert; that gives AT
+ // a single announcement region to listen for.
+ const onClose = vi.fn();
+ const { container, unmount } = renderIntoContainer(
+
+ );
+
+ const root = container.firstElementChild as HTMLElement;
+ expect(root.getAttribute("role")).toBe("alert");
+ expect(
+ root.getAttribute("data-testid")?.includes("not_found")
+ ).toBe(true);
+
+ // Message body — case-insensitive substring match against "not found".
+ const text = (container.textContent ?? "").toLowerCase();
+ expect(text).toContain("not found");
+
+ // Close button is wired up.
+ const closeBtn = container.querySelector(
+ '[data-testid="error-close"]'
+ ) as HTMLButtonElement | null;
+ expect(closeBtn).not.toBeNull();
+ expect(closeBtn?.textContent?.toLowerCase()).toContain("close");
+ expect(onClose).not.toHaveBeenCalled();
+ act(() => {
+ closeBtn?.click();
+ });
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ unmount();
+ });
+
+ it("renders_network_message_and_retry_button", () => {
+ const onRetry = vi.fn();
+ const onClose = vi.fn();
+ const { container, unmount } = renderIntoContainer(
+
+ );
+
+ // Message body — at least one of the three hints ("network",
+ // "connection", "try again") should appear, case-insensitive.
+ const text = (container.textContent ?? "").toLowerCase();
+ expect(
+ text.includes("network") ||
+ text.includes("connection") ||
+ text.includes("try again")
+ ).toBe(true);
+
+ // Retry button fires onRetry.
+ const retryBtn = container.querySelector(
+ '[data-testid="error-retry"]'
+ ) as HTMLButtonElement | null;
+ expect(retryBtn).not.toBeNull();
+ act(() => {
+ retryBtn?.click();
+ });
+ expect(onRetry).toHaveBeenCalledTimes(1);
+
+ // Close still fires onClose.
+ const closeBtn = container.querySelector(
+ '[data-testid="error-close"]'
+ ) as HTMLButtonElement | null;
+ expect(closeBtn).not.toBeNull();
+ act(() => {
+ closeBtn?.click();
+ });
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ unmount();
+ });
+
+ it("omits_retry_button_when_onRetry_not_provided", () => {
+ const onClose = vi.fn();
+ const { container, unmount } = renderIntoContainer(
+
+ );
+
+ // No retry button when handler not supplied — no point promising
+ // a retry the caller can't fulfill.
+ const retryBtn = container.querySelector('[data-testid="error-retry"]');
+ expect(retryBtn).toBeNull();
+
+ unmount();
+ });
+
+ it("close_button_always_present", () => {
+ // not_found branch.
+ const onCloseNF = vi.fn();
+ const { container: c1, unmount: u1 } = renderIntoContainer(
+
+ );
+ expect(
+ c1.querySelector('[data-testid="error-close"]')
+ ).not.toBeNull();
+ u1();
+
+ // network branch (with retry).
+ const onRetry = vi.fn();
+ const onCloseNet = vi.fn();
+ const { container: c2, unmount: u2 } = renderIntoContainer(
+
+ );
+ expect(
+ c2.querySelector('[data-testid="error-close"]')
+ ).not.toBeNull();
+ u2();
+
+ // network branch (no retry) — close still required.
+ const onCloseNet2 = vi.fn();
+ const { container: c3, unmount: u3 } = renderIntoContainer(
+
+ );
+ expect(
+ c3.querySelector('[data-testid="error-close"]')
+ ).not.toBeNull();
+ u3();
+ });
+});
\ No newline at end of file
diff --git a/src/components/ClaimDrawer/ClaimDrawerError.tsx b/src/components/ClaimDrawer/ClaimDrawerError.tsx
new file mode 100644
index 0000000..7e33fab
--- /dev/null
+++ b/src/components/ClaimDrawer/ClaimDrawerError.tsx
@@ -0,0 +1,62 @@
+import { AlertCircle, WifiOff } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+type ClaimDrawerErrorProps = {
+ kind: "not_found" | "network";
+ onRetry?: () => void;
+ onClose: () => void;
+};
+
+const COPY = {
+ not_found: {
+ eyebrow: "NOT FOUND",
+ message: "This claim doesn't exist or has been removed.",
+ },
+ network: {
+ eyebrow: "CONNECTION",
+ message: "Couldn't reach the server. Check your connection and try again.",
+ },
+} as const;
+
+/**
+ * Error state for the claim detail drawer (SP4).
+ *
+ * Two shapes — `not_found` (the claim id in the URL doesn't resolve on
+ * the server, e.g. a stale deep link) and `network` (the request
+ * failed). The not_found variant has no retry affordance (retrying won't
+ * help), the network variant does when `onRetry` is supplied.
+ */
+export function ClaimDrawerError({ kind, onRetry, onClose }: ClaimDrawerErrorProps) {
+ const { eyebrow, message } = COPY[kind];
+ const Icon = kind === "network" ? WifiOff : AlertCircle;
+
+ return (
+
+
+
+
+ {eyebrow}
+
+
+
{message}
+
+ {kind === "network" && onRetry ? (
+
+ ) : null}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ClaimDrawer/ClaimDrawerSkeleton.test.tsx b/src/components/ClaimDrawer/ClaimDrawerSkeleton.test.tsx
new file mode 100644
index 0000000..6e95aa1
--- /dev/null
+++ b/src/components/ClaimDrawer/ClaimDrawerSkeleton.test.tsx
@@ -0,0 +1,120 @@
+// @vitest-environment happy-dom
+// React Query's internal state updates need an `act`-aware environment or
+// it logs noisy warnings. Match the convention from useClaimDetail.test.ts.
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { describe, expect, it } from "vitest";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
+
+function renderIntoContainer(element: React.ReactElement): {
+ container: HTMLDivElement;
+ unmount: () => void;
+} {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const root: Root = createRoot(container);
+ act(() => {
+ root.render(
+ React.createElement(QueryClientProvider, { client: qc }, element)
+ );
+ });
+ return {
+ container,
+ unmount: () => {
+ act(() => root.unmount());
+ container.remove();
+ },
+ };
+}
+
+describe("ClaimDrawerSkeleton", () => {
+ it("renders_eight_to_twelve_skeleton_primitives", () => {
+ // The Skeleton primitive marks itself aria-busy="true" for AT.
+ // Counting aria-busy elements tells us how many primitives rendered,
+ // independent of layout or copy.
+ const { unmount } = renderIntoContainer();
+
+ const busyCount = document.querySelectorAll('[aria-busy="true"]').length;
+ expect(busyCount).toBeGreaterThanOrEqual(8);
+ expect(busyCount).toBeLessThanOrEqual(12);
+
+ unmount();
+ });
+
+ it("renders_one_primitives_per_drawer_section", () => {
+ // The skeleton must mirror the drawer's section structure so the
+ // layout doesn't jump when real data arrives. Each section gets a
+ // labelled testid primitive.
+ const { container, unmount } = renderIntoContainer();
+
+ const header = container.querySelector('[data-testid="skeleton-header"]');
+ const validation = container.querySelector(
+ '[data-testid="skeleton-validation"]'
+ );
+ const lines1 = container.querySelector('[data-testid="skeleton-lines-1"]');
+ const lines2 = container.querySelector('[data-testid="skeleton-lines-2"]');
+ const lines3 = container.querySelector('[data-testid="skeleton-lines-3"]');
+ const dx1 = container.querySelector('[data-testid="skeleton-dx-1"]');
+ const dx2 = container.querySelector('[data-testid="skeleton-dx-2"]');
+ const parties1 = container.querySelector(
+ '[data-testid="skeleton-parties-1"]'
+ );
+ const parties2 = container.querySelector(
+ '[data-testid="skeleton-parties-2"]'
+ );
+ const history = container.querySelector('[data-testid="skeleton-history"]');
+
+ // Every section primitive is present.
+ expect(header).not.toBeNull();
+ expect(validation).not.toBeNull();
+ expect(lines1).not.toBeNull();
+ expect(lines2).not.toBeNull();
+ expect(lines3).not.toBeNull();
+ expect(dx1).not.toBeNull();
+ expect(dx2).not.toBeNull();
+ expect(parties1).not.toBeNull();
+ expect(parties2).not.toBeNull();
+ expect(history).not.toBeNull();
+
+ // Header band should be visibly taller than the validation row — it
+ // hosts the claim id, status, billed amount etc.
+ // happy-dom doesn't do layout, so we read the inline styles the
+ // Skeleton primitive writes from `height`/`width` props.
+ const headerHeight = Number(
+ (header as HTMLElement).style.height.replace("px", "")
+ );
+ const validationHeight = Number(
+ (validation as HTMLElement).style.height.replace("px", "") || "36"
+ );
+ expect(headerHeight).toBeGreaterThan(validationHeight);
+
+ // Header should be wider than the dx rows (60% / 45%).
+ const headerWidth = (header as HTMLElement).style.width || "100%";
+ const dx1Width = (dx1 as HTMLElement).style.width;
+ // "100%" vs "60%" — compare as percentage strings.
+ const headerPct = Number(headerWidth.replace("%", ""));
+ const dx1Pct = Number(dx1Width.replace("%", ""));
+ expect(headerPct).toBeGreaterThan(dx1Pct);
+
+ // History block is taller than the validation row.
+ const historyHeight = Number(
+ (history as HTMLElement).style.height.replace("px", "")
+ );
+ expect(historyHeight).toBeGreaterThan(validationHeight);
+
+ unmount();
+ });
+
+ it("no_props_required", () => {
+ // The contract: "No props." Calling the component with no props
+ // must not throw and must render something.
+ expect(() => {
+ const { unmount } = renderIntoContainer();
+ unmount();
+ }).not.toThrow();
+ });
+});
\ No newline at end of file
diff --git a/src/components/ClaimDrawer/ClaimDrawerSkeleton.tsx b/src/components/ClaimDrawer/ClaimDrawerSkeleton.tsx
new file mode 100644
index 0000000..6a72432
--- /dev/null
+++ b/src/components/ClaimDrawer/ClaimDrawerSkeleton.tsx
@@ -0,0 +1,46 @@
+import { Skeleton } from "@/components/ui/skeleton";
+
+/**
+ * Loading state for the claim detail drawer (SP4).
+ *
+ * Mirrors the drawer's section heights so the layout doesn't jump when
+ * data arrives. 10 primitives across 6 sections: header (1), validation
+ * (1), service lines (3 rows), diagnoses (2), parties (2), history (1).
+ */
+export function ClaimDrawerSkeleton() {
+ return (
+
+ {/* Header band — taller, prominent */}
+
+
+ {/* Validation panel — short row */}
+
+
+ {/* Service lines — three stacked rows */}
+
+
+
+
+
+
+ {/* Diagnoses — two short text rows */}
+
+
+
+
+
+ {/* Parties — two rows */}
+
+
+
+
+
+ {/* History — tall block */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
index 37cf573..7ef9ba7 100644
--- a/src/components/ui/skeleton.tsx
+++ b/src/components/ui/skeleton.tsx
@@ -3,8 +3,7 @@ import { cn } from "@/lib/utils";
type SkeletonVariant = "default" | "text" | "circle" | "row";
-type SkeletonProps = {
- className?: string;
+type SkeletonProps = React.HTMLAttributes & {
variant?: SkeletonVariant;
width?: string | number;
height?: string | number;
@@ -28,6 +27,7 @@ export function Skeleton({
variant = "default",
width,
height,
+ ...props
}: SkeletonProps) {
const style: React.CSSProperties = {
...(width !== undefined ? { width } : null),
@@ -45,6 +45,7 @@ export function Skeleton({
style={style}
aria-busy="true"
aria-live="polite"
+ {...props}
>