From d10fa5103ef98cf67c0d2caed1f37354365b95cc Mon Sep 17 00:00:00 2001
From: lunabeckod-design
Date: Thu, 27 Aug 2026 04:33:43 +0000
Subject: [PATCH 1/4] fix(a11y): add aria-current='page' to last breadcrumb
item
The last breadcrumb item now includes aria-current='page' to properly indicate
the current page location to screen reader users, improving accessibility
per WCAG 2.3.3 standards.
---
src/components/Breadcrumb.tsx | 2 +-
src/components/__tests__/Breadcrumb.test.tsx | 21 ++++++++++++++++++++
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx
index 7f7cf42..fe4dfc8 100644
--- a/src/components/Breadcrumb.tsx
+++ b/src/components/Breadcrumb.tsx
@@ -51,7 +51,7 @@ export default function Breadcrumb({ items }: Props) {
)}
{isLast ? (
- {item.label}
+ {item.label}
) : (
<>
{i === 0 && !showEllipsis && (
diff --git a/src/components/__tests__/Breadcrumb.test.tsx b/src/components/__tests__/Breadcrumb.test.tsx
index d96fbe1..b28be74 100644
--- a/src/components/__tests__/Breadcrumb.test.tsx
+++ b/src/components/__tests__/Breadcrumb.test.tsx
@@ -48,4 +48,25 @@ describe("Breadcrumb", () => {
const list = container.querySelector("ol");
expect(list).toBeInTheDocument();
});
+
+ it("should mark the last item with aria-current='page'", () => {
+ const items: BreadcrumbItem[] = [
+ { label: "Invoices", href: "/dashboard" },
+ { label: "Invoice #123" },
+ ];
+ const { container } = render();
+ const lastItem = container.querySelector("span[aria-current='page']");
+ expect(lastItem).toBeInTheDocument();
+ expect(lastItem).toHaveTextContent("Invoice #123");
+ });
+
+ it("should not mark non-last items with aria-current", () => {
+ const items: BreadcrumbItem[] = [
+ { label: "Invoices", href: "/dashboard" },
+ { label: "Invoice #123" },
+ ];
+ const { container } = render();
+ const invoicesLink = screen.getByText("Invoices").closest("a");
+ expect(invoicesLink).not.toHaveAttribute("aria-current");
+ });
});
From 5df5883800b12471e06ef50de16cebe92cf39a8f Mon Sep 17 00:00:00 2001
From: lunabeckod-design
Date: Thu, 27 Aug 2026 04:34:19 +0000
Subject: [PATCH 2/4] fix(a11y): replace skeleton animation with static
background on prefers-reduced-motion
When prefers-reduced-motion is enabled, the skeleton shimmer animation is
replaced with a static muted background to comply with WCAG 2.3.3 and improve
accessibility for users sensitive to motion. The change is implemented via CSS
media queries for SSR compatibility.
---
src/__tests__/Skeleton.test.tsx | 36 +++++++++++++++++++++++++++++++++
src/app/globals.css | 20 ++++++++++++++++++
src/components/Skeleton.tsx | 2 +-
3 files changed, 57 insertions(+), 1 deletion(-)
create mode 100644 src/__tests__/Skeleton.test.tsx
diff --git a/src/__tests__/Skeleton.test.tsx b/src/__tests__/Skeleton.test.tsx
new file mode 100644
index 0000000..15fe317
--- /dev/null
+++ b/src/__tests__/Skeleton.test.tsx
@@ -0,0 +1,36 @@
+import { render } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import { Skeleton } from '@/components/Skeleton';
+
+describe('Skeleton', () => {
+ it('renders with skeleton-shimmer class', () => {
+ const { container } = render();
+ const skeletonDiv = container.querySelector('div');
+ expect(skeletonDiv).toHaveClass('skeleton-shimmer');
+ });
+
+ it('applies custom className', () => {
+ const { container } = render();
+ const skeletonDiv = container.querySelector('div');
+ expect(skeletonDiv).toHaveClass('h-4');
+ expect(skeletonDiv).toHaveClass('w-24');
+ expect(skeletonDiv).toHaveClass('skeleton-shimmer');
+ });
+
+ it('has proper base styles', () => {
+ const { container } = render();
+ const skeletonDiv = container.querySelector('div');
+ expect(skeletonDiv).toHaveClass('bg-gray-200');
+ expect(skeletonDiv).toHaveClass('dark:bg-gray-700');
+ expect(skeletonDiv).toHaveClass('rounded');
+ });
+
+ it('renders InvoiceCardSkeleton with proper ARIA attributes', () => {
+ const { render: customRender } = require('@testing-library/react');
+ const { InvoiceCardSkeleton } = require('@/components/Skeleton');
+ const { container } = customRender();
+ const skeleton = container.querySelector('[role="status"]');
+ expect(skeleton).toHaveAttribute('aria-busy', 'true');
+ expect(skeleton).toHaveAttribute('aria-label', 'Loading invoice data');
+ });
+});
diff --git a/src/app/globals.css b/src/app/globals.css
index c992527..cc6074e 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -126,6 +126,26 @@ html[data-contrast="high"] .text-indigo-500 {
}
/* ── Custom animations ─────────────────────────────────────────── */
+@keyframes shimmer {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+.skeleton-shimmer {
+ animation: shimmer 2s ease-in-out infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .skeleton-shimmer {
+ animation: none;
+ opacity: 0.6;
+ }
+}
+
@keyframes slide-up {
from {
opacity: 0;
diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx
index 1316ebb..98d1755 100644
--- a/src/components/Skeleton.tsx
+++ b/src/components/Skeleton.tsx
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
/** Shared animated shimmer base */
-const shimmer = "animate-pulse bg-gray-200 dark:bg-gray-700 rounded";
+const shimmer = "skeleton-shimmer bg-gray-200 dark:bg-gray-700 rounded";
/** Base pulsing rectangle primitive — every skeleton in the app builds on this. */
export function Skeleton({ className = "" }: { className?: string }) {
From 5216206e8917ba00cb0f8f0994ec988c560901fc Mon Sep 17 00:00:00 2001
From: lunabeckod-design
Date: Thu, 27 Aug 2026 04:34:53 +0000
Subject: [PATCH 3/4] feat: add copy error details button to ErrorBoundary
Add a 'Copy error details' button to the ErrorBoundary fallback UI that copies
the error message and stack trace to the clipboard. The button shows a 'Copied!'
confirmation for 2 seconds after clicking, providing developers and support
teams with an easy way to capture error diagnostics without opening DevTools.
---
src/__tests__/ErrorBoundary.test.tsx | 121 +++++++++++++++++++++++++++
src/components/ErrorBoundary.tsx | 28 ++++++-
2 files changed, 147 insertions(+), 2 deletions(-)
create mode 100644 src/__tests__/ErrorBoundary.test.tsx
diff --git a/src/__tests__/ErrorBoundary.test.tsx b/src/__tests__/ErrorBoundary.test.tsx
new file mode 100644
index 0000000..c05941a
--- /dev/null
+++ b/src/__tests__/ErrorBoundary.test.tsx
@@ -0,0 +1,121 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import ErrorBoundary from '@/components/ErrorBoundary';
+
+describe('ErrorBoundary', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders children when no error', () => {
+ render(
+
+ Test content
+
+ );
+ expect(screen.getByText('Test content')).toBeInTheDocument();
+ });
+
+ it('renders error UI when error is caught', () => {
+ const TestComponent = () => {
+ throw new Error('Test error');
+ };
+
+ const { container } = render(
+
+
+
+ );
+
+ expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+ });
+
+ it('displays copy button for error details', () => {
+ const TestComponent = () => {
+ throw new Error('Test error message');
+ };
+
+ render(
+
+
+
+ );
+
+ const copyButton = screen.getByText('Copy error details');
+ expect(copyButton).toBeInTheDocument();
+ });
+
+ it('copies error details to clipboard when button is clicked', async () => {
+ const mockClipboard = {
+ writeText: vi.fn().mockResolvedValue(undefined),
+ };
+ Object.assign(navigator, { clipboard: mockClipboard });
+
+ const TestComponent = () => {
+ throw new Error('Test error message');
+ };
+
+ render(
+
+
+
+ );
+
+ const copyButton = screen.getByText('Copy error details');
+ await userEvent.click(copyButton);
+
+ await waitFor(() => {
+ expect(mockClipboard.writeText).toHaveBeenCalled();
+ expect(mockClipboard.writeText.mock.calls[0][0]).toContain('Test error message');
+ });
+ });
+
+ it('shows copied confirmation for 2 seconds', async () => {
+ vi.useFakeTimers();
+ const mockClipboard = {
+ writeText: vi.fn().mockResolvedValue(undefined),
+ };
+ Object.assign(navigator, { clipboard: mockClipboard });
+
+ const TestComponent = () => {
+ throw new Error('Test error');
+ };
+
+ render(
+
+
+
+ );
+
+ const copyButton = screen.getByText('Copy error details');
+ await userEvent.click(copyButton);
+
+ await waitFor(() => {
+ expect(screen.getByText('Copied!')).toBeInTheDocument();
+ });
+
+ vi.advanceTimersByTime(2000);
+
+ await waitFor(() => {
+ expect(screen.getByText('Copy error details')).toBeInTheDocument();
+ });
+
+ vi.useRealTimers();
+ });
+
+ it('renders reload and dashboard buttons', () => {
+ const TestComponent = () => {
+ throw new Error('Test error');
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText('Reload Page')).toBeInTheDocument();
+ expect(screen.getByText('Go to Dashboard')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
index 640615e..31fac81 100644
--- a/src/components/ErrorBoundary.tsx
+++ b/src/components/ErrorBoundary.tsx
@@ -12,11 +12,12 @@ interface Props {
interface State {
error: Error | null;
sentryEventId: string | null;
+ copied: boolean;
}
/** Root error boundary — wraps the entire app. Captures exceptions to Sentry when configured. */
export default class ErrorBoundary extends Component {
- state: State = { error: null, sentryEventId: null };
+ state: State = { error: null, sentryEventId: null, copied: false };
static getDerivedStateFromError(error: Error): Partial {
return { error };
@@ -36,10 +37,26 @@ export default class ErrorBoundary extends Component {
}
private handleRetry = () => {
- this.setState({ error: null, sentryEventId: null });
+ this.setState({ error: null, sentryEventId: null, copied: false });
window.location.reload();
};
+ private handleCopyErrorDetails = async () => {
+ const { error } = this.state;
+ if (!error) return;
+
+ const errorText = `${error.message}\n${error.stack}`;
+ try {
+ await navigator.clipboard.writeText(errorText);
+ this.setState({ copied: true });
+ setTimeout(() => {
+ this.setState({ copied: false });
+ }, 2000);
+ } catch {
+ console.error('Failed to copy error details');
+ }
+ };
+
render() {
const { error, sentryEventId } = this.state;
if (!error) {
@@ -95,6 +112,13 @@ export default class ErrorBoundary extends Component {
Error ID: {sentryEventId}
)}
+