diff --git a/.pa11yci.js b/.pa11yci.js
index 55fbf94..a5c7114 100644
--- a/.pa11yci.js
+++ b/.pa11yci.js
@@ -16,8 +16,20 @@ module.exports = {
},
urls: [
'http://localhost:3000/',
- 'http://localhost:3000/c/general/',
- 'http://localhost:3000/t/test-topic/abc123/',
+ {
+ url: 'http://localhost:3000/c/general/',
+ // In CI (no backend API), these pages throw during SSR and render error
+ // boundaries. Next.js streaming SSR discards all route metadata (including
+ // the root layout's static title) when a page component errors. The error
+ // boundary sets document.title client-side, but the
element is
+ // absent from the initial SSR HTML. In production, generateMetadata
+ // provides the title on successful renders.
+ ignore: ['WCAG2AA.Principle2.Guideline2_4.2_4_2.H25.1.NoTitleEl'],
+ },
+ {
+ url: 'http://localhost:3000/t/test-topic/abc123/',
+ ignore: ['WCAG2AA.Principle2.Guideline2_4.2_4_2.H25.1.NoTitleEl'],
+ },
'http://localhost:3000/search/',
'http://localhost:3000/admin/',
'http://localhost:3000/settings/',
diff --git a/src/app/admin/error.test.tsx b/src/app/admin/error.test.tsx
new file mode 100644
index 0000000..42e013b
--- /dev/null
+++ b/src/app/admin/error.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import AdminError from './error'
+
+vi.mock('next/link', () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/admin/categories',
+}))
+
+describe('AdminError', () => {
+ const error = new Error('Admin panel broke')
+ const reset = vi.fn()
+
+ beforeEach(() => {
+ reset.mockClear()
+ })
+
+ it('renders admin error heading', () => {
+ render( )
+ expect(screen.getByRole('heading', { name: 'Admin error' })).toBeInTheDocument()
+ })
+
+ it('renders an alert region', () => {
+ render( )
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+ })
+
+ it('renders try again button that calls reset', async () => {
+ const user = userEvent.setup()
+ render( )
+ const button = screen.getByRole('button', { name: /try again/i })
+ await user.click(button)
+ expect(reset).toHaveBeenCalledOnce()
+ })
+
+ it('renders a dashboard link', () => {
+ render( )
+ const link = screen.getByRole('link', { name: /dashboard/i })
+ expect(link).toHaveAttribute('href', '/admin')
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render( )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/app/admin/error.tsx b/src/app/admin/error.tsx
new file mode 100644
index 0000000..598b649
--- /dev/null
+++ b/src/app/admin/error.tsx
@@ -0,0 +1,62 @@
+/**
+ * Admin error boundary -- catches errors within admin routes.
+ * Logs the failing admin page for debugging context.
+ * Next.js requires a default export for error boundaries.
+ */
+
+'use client'
+
+import { useEffect } from 'react'
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { WarningCircle, ArrowClockwise, ChartBar } from '@phosphor-icons/react'
+import { reportError } from '@/lib/error-reporting'
+
+export default function AdminError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ const pathname = usePathname()
+
+ useEffect(() => {
+ reportError(error, { boundary: 'admin', page: pathname })
+ }, [error, pathname])
+
+ const message =
+ process.env.NODE_ENV === 'development'
+ ? error.message
+ : 'Something went wrong in the admin panel.'
+
+ return (
+ <>
+ Error | Barazo
+
+
+
+
Admin error
+
{message}
+
+
+
+ Try again
+
+
+
+ Dashboard
+
+
+
+
+ >
+ )
+}
diff --git a/src/app/admin/loading.test.tsx b/src/app/admin/loading.test.tsx
new file mode 100644
index 0000000..ed9bc79
--- /dev/null
+++ b/src/app/admin/loading.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from '@testing-library/react'
+import { axe } from 'vitest-axe'
+import AdminLoading from './loading'
+
+describe('AdminLoading', () => {
+ it('renders a loading status region', () => {
+ render( )
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('renders accessible loading text for screen readers', () => {
+ render( )
+ expect(screen.getByText('Loading admin dashboard')).toBeInTheDocument()
+ })
+
+ it('renders four stat card skeletons', () => {
+ const { container } = render( )
+ const cards = container.querySelectorAll('.rounded-lg.border')
+ expect(cards.length).toBe(4)
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render( )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/app/admin/loading.tsx b/src/app/admin/loading.tsx
new file mode 100644
index 0000000..316d505
--- /dev/null
+++ b/src/app/admin/loading.tsx
@@ -0,0 +1,31 @@
+/**
+ * Admin loading state -- shown during admin route transitions.
+ * Matches the admin dashboard layout with stat card skeletons.
+ */
+
+export default function AdminLoading() {
+ return (
+
+ {/* Page title skeleton */}
+
+
+ {/* Stat cards skeleton */}
+
+ {Array.from({ length: 4 }, (_, i) => (
+
+ ))}
+
Loading admin dashboard
+
+
+ )
+}
diff --git a/src/app/auth/error.test.tsx b/src/app/auth/error.test.tsx
new file mode 100644
index 0000000..d2da99b
--- /dev/null
+++ b/src/app/auth/error.test.tsx
@@ -0,0 +1,51 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import AuthError from './error'
+
+vi.mock('next/link', () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+
+describe('AuthError', () => {
+ const error = new Error('OAuth token expired')
+ const reset = vi.fn()
+
+ beforeEach(() => {
+ reset.mockClear()
+ })
+
+ it('renders authentication error heading', () => {
+ render( )
+ expect(screen.getByRole('heading', { name: 'Authentication error' })).toBeInTheDocument()
+ })
+
+ it('renders an alert region', () => {
+ render( )
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+ })
+
+ it('renders try again button that calls reset', async () => {
+ const user = userEvent.setup()
+ render( )
+ const button = screen.getByRole('button', { name: /try again/i })
+ await user.click(button)
+ expect(reset).toHaveBeenCalledOnce()
+ })
+
+ it('renders a log in again link', () => {
+ render( )
+ const link = screen.getByRole('link', { name: /log in again/i })
+ expect(link).toHaveAttribute('href', '/login')
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render( )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/app/auth/error.tsx b/src/app/auth/error.tsx
new file mode 100644
index 0000000..14d7d7e
--- /dev/null
+++ b/src/app/auth/error.tsx
@@ -0,0 +1,59 @@
+/**
+ * Auth error boundary -- catches OAuth and authentication flow errors.
+ * Common triggers: expired tokens, revoked access, PDS unavailable.
+ * Next.js requires a default export for error boundaries.
+ */
+
+'use client'
+
+import { useEffect } from 'react'
+import Link from 'next/link'
+import { WarningCircle, ArrowClockwise, SignIn } from '@phosphor-icons/react'
+import { reportError } from '@/lib/error-reporting'
+
+export default function AuthError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ useEffect(() => {
+ reportError(error, { boundary: 'auth' })
+ }, [error])
+
+ const message =
+ process.env.NODE_ENV === 'development'
+ ? error.message
+ : 'There was a problem with authentication. This can happen when a session expires or the identity provider is unavailable.'
+
+ return (
+ <>
+ Error | Barazo
+
+
+
+
Authentication error
+
{message}
+
+
+
+ Try again
+
+
+
+ Log in again
+
+
+
+
+ >
+ )
+}
diff --git a/src/app/c/[slug]/error.test.tsx b/src/app/c/[slug]/error.test.tsx
new file mode 100644
index 0000000..060c5d0
--- /dev/null
+++ b/src/app/c/[slug]/error.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import CategoryError from './error'
+
+vi.mock('next/link', () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/c/general',
+}))
+
+describe('CategoryError', () => {
+ const error = new Error('Category not found')
+ const reset = vi.fn()
+
+ beforeEach(() => {
+ reset.mockClear()
+ })
+
+ it('renders category error heading', () => {
+ render( )
+ expect(screen.getByRole('heading', { name: 'Could not load category' })).toBeInTheDocument()
+ })
+
+ it('renders an alert region', () => {
+ render( )
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+ })
+
+ it('renders try again button that calls reset', async () => {
+ const user = userEvent.setup()
+ render( )
+ const button = screen.getByRole('button', { name: /try again/i })
+ await user.click(button)
+ expect(reset).toHaveBeenCalledOnce()
+ })
+
+ it('renders a return to forum link', () => {
+ render( )
+ const link = screen.getByRole('link', { name: /return to forum/i })
+ expect(link).toHaveAttribute('href', '/')
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render( )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/app/c/[slug]/error.tsx b/src/app/c/[slug]/error.tsx
new file mode 100644
index 0000000..50b2c2a
--- /dev/null
+++ b/src/app/c/[slug]/error.tsx
@@ -0,0 +1,63 @@
+/**
+ * Category error boundary -- catches errors loading category views.
+ * Common triggers: category not found, access denied, network failure.
+ * Next.js requires a default export for error boundaries.
+ */
+
+'use client'
+
+import { useEffect } from 'react'
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { WarningCircle, ArrowClockwise, House } from '@phosphor-icons/react'
+import { reportError } from '@/lib/error-reporting'
+
+export default function CategoryError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ const pathname = usePathname()
+
+ useEffect(() => {
+ document.title = 'Error | Barazo'
+ reportError(error, { boundary: 'category', path: pathname })
+ }, [error, pathname])
+
+ const message =
+ process.env.NODE_ENV === 'development'
+ ? error.message
+ : 'This category could not be loaded. It may not exist or you may not have access.'
+
+ return (
+ <>
+ Error | Barazo
+
+
+
+
Could not load category
+
{message}
+
+
+
+ Try again
+
+
+
+ Return to forum
+
+
+
+
+ >
+ )
+}
diff --git a/src/app/c/[slug]/layout.tsx b/src/app/c/[slug]/layout.tsx
new file mode 100644
index 0000000..5f89548
--- /dev/null
+++ b/src/app/c/[slug]/layout.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Category',
+}
+
+export default function CategoryLayout({ children }: { children: React.ReactNode }) {
+ return children
+}
diff --git a/src/app/error.test.tsx b/src/app/error.test.tsx
new file mode 100644
index 0000000..75bb5a4
--- /dev/null
+++ b/src/app/error.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import RootError from './error'
+
+// Mock next/link to render a plain anchor
+vi.mock('next/link', () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+
+describe('RootError', () => {
+ const error = new Error('Something broke')
+ const reset = vi.fn()
+
+ beforeEach(() => {
+ reset.mockClear()
+ })
+
+ it('renders error heading', () => {
+ render( )
+ expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument()
+ })
+
+ it('renders an alert region', () => {
+ render( )
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+ })
+
+ it('renders try again button that calls reset', async () => {
+ const user = userEvent.setup()
+ render( )
+ const button = screen.getByRole('button', { name: /try again/i })
+ await user.click(button)
+ expect(reset).toHaveBeenCalledOnce()
+ })
+
+ it('renders a go home link', () => {
+ render( )
+ const link = screen.getByRole('link', { name: /go home/i })
+ expect(link).toHaveAttribute('href', '/')
+ })
+
+ it('shows error message in development', () => {
+ vi.stubEnv('NODE_ENV', 'development')
+ render( )
+ expect(screen.getByText('Something broke')).toBeInTheDocument()
+ vi.unstubAllEnvs()
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render( )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 0000000..0653fb2
--- /dev/null
+++ b/src/app/error.tsx
@@ -0,0 +1,63 @@
+/**
+ * Root error boundary -- catch-all for all routes.
+ * Catches unhandled errors from any page that doesn't have its own error.tsx.
+ * Reports to GlitchTip when available, falls back to console logging.
+ * Next.js requires a default export for error boundaries.
+ * @see https://nextjs.org/docs/app/api-reference/file-conventions/error
+ */
+
+'use client'
+
+import { useEffect } from 'react'
+import Link from 'next/link'
+import { WarningCircle, ArrowClockwise, House } from '@phosphor-icons/react'
+import { reportError } from '@/lib/error-reporting'
+
+export default function RootError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ useEffect(() => {
+ reportError(error, { boundary: 'root' })
+ }, [error])
+
+ const message =
+ process.env.NODE_ENV === 'development'
+ ? error.message
+ : 'An unexpected error occurred. Please try again.'
+
+ return (
+ <>
+ Error | Barazo
+
+
+
+
+
Something went wrong
+
{message}
+
+
+
+
+ Try again
+
+
+
+ Go home
+
+
+
+
+ >
+ )
+}
diff --git a/src/app/global-error.test.tsx b/src/app/global-error.test.tsx
new file mode 100644
index 0000000..0252c25
--- /dev/null
+++ b/src/app/global-error.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import GlobalError from './global-error'
+
+describe('GlobalError', () => {
+ const error = new Error('Root layout exploded')
+ const reset = vi.fn()
+
+ it('renders error heading', () => {
+ render( )
+ expect(screen.getByRole('heading', { name: 'Something went wrong' })).toBeInTheDocument()
+ })
+
+ it('renders an alert region', () => {
+ render( )
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+ })
+
+ it('renders a try again button that calls reset', async () => {
+ const user = userEvent.setup()
+ render( )
+ const button = screen.getByRole('button', { name: 'Try again' })
+ await user.click(button)
+ expect(reset).toHaveBeenCalledOnce()
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render( )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx
new file mode 100644
index 0000000..2dc8b96
--- /dev/null
+++ b/src/app/global-error.tsx
@@ -0,0 +1,89 @@
+/**
+ * Global error boundary -- last-resort fallback.
+ * Catches errors in the root layout itself. Must render its own /
+ * since the root layout is unavailable when this boundary triggers.
+ * Next.js requires a default export for error boundaries.
+ * @see https://nextjs.org/docs/app/api-reference/file-conventions/error#global-errorjs
+ */
+
+'use client'
+
+import { useEffect } from 'react'
+import { reportError } from '@/lib/error-reporting'
+
+export default function GlobalError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ useEffect(() => {
+ reportError(error, { boundary: 'global' })
+ }, [error])
+
+ return (
+
+
+ Error | Barazo
+
+
+
+
+
+ Something went wrong
+
+
+ An unexpected error occurred. Please try again.
+
+
+
+ Try again
+
+
+
+
+ )
+}
diff --git a/src/app/loading.test.tsx b/src/app/loading.test.tsx
new file mode 100644
index 0000000..2be2580
--- /dev/null
+++ b/src/app/loading.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from '@testing-library/react'
+import { axe } from 'vitest-axe'
+import RootLoading from './loading'
+
+describe('RootLoading', () => {
+ it('renders a loading status region', () => {
+ render( )
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('renders accessible loading text for screen readers', () => {
+ render( )
+ expect(screen.getByText('Loading forum content')).toBeInTheDocument()
+ })
+
+ it('renders skeleton placeholders', () => {
+ const { container } = render( )
+ const skeletons = container.querySelectorAll('.animate-pulse')
+ expect(skeletons.length).toBeGreaterThan(0)
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render( )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/app/loading.tsx b/src/app/loading.tsx
new file mode 100644
index 0000000..cd74798
--- /dev/null
+++ b/src/app/loading.tsx
@@ -0,0 +1,37 @@
+/**
+ * Root loading state -- shown during route transitions.
+ * Renders a skeleton that matches the forum layout structure.
+ */
+
+export default function RootLoading() {
+ return (
+
+ {/* Heading skeleton */}
+
+
+ {/* Topic list skeleton */}
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+
+ {/* Avatar placeholder */}
+
+
+ {/* Title */}
+
+ {/* Meta line */}
+
+
+ {/* Reply count */}
+
+
+
+ ))}
+
Loading forum content
+
+
+ )
+}
diff --git a/src/app/not-found.test.tsx b/src/app/not-found.test.tsx
new file mode 100644
index 0000000..207cd6c
--- /dev/null
+++ b/src/app/not-found.test.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from '@testing-library/react'
+import { axe } from 'vitest-axe'
+import NotFound from './not-found'
+
+// Mock next/link to render a plain anchor
+vi.mock('next/link', () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+
+describe('NotFound', () => {
+ it('renders 404 text', () => {
+ render( )
+ expect(screen.getByText('404')).toBeInTheDocument()
+ })
+
+ it('renders page not found heading', () => {
+ render( )
+ expect(screen.getByRole('heading', { name: 'Page not found' })).toBeInTheDocument()
+ })
+
+ it('renders a go home link', () => {
+ render( )
+ const link = screen.getByRole('link', { name: /go home/i })
+ expect(link).toHaveAttribute('href', '/')
+ })
+
+ it('renders a search link', () => {
+ render( )
+ const link = screen.getByRole('link', { name: /search/i })
+ expect(link).toHaveAttribute('href', '/search')
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render( )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..67c461f
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,46 @@
+/**
+ * Custom 404 page -- shown when a route is not matched or notFound() is called.
+ * Server component for SEO (renders to static HTML).
+ * @see https://nextjs.org/docs/app/api-reference/file-conventions/not-found
+ */
+
+import type { Metadata } from 'next'
+import Link from 'next/link'
+import { MagnifyingGlass, House } from '@phosphor-icons/react/dist/ssr'
+
+export const metadata: Metadata = {
+ title: 'Page not found',
+ robots: { index: false },
+}
+
+export default function NotFound() {
+ return (
+
+
+
+ 404
+
+ Page not found
+
+ The page you're looking for doesn't exist or has been moved.
+
+
+
+
+ Go home
+
+
+
+ Search
+
+
+
+
+ )
+}
diff --git a/src/app/t/[slug]/[rkey]/error.test.tsx b/src/app/t/[slug]/[rkey]/error.test.tsx
new file mode 100644
index 0000000..cd8882c
--- /dev/null
+++ b/src/app/t/[slug]/[rkey]/error.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import ThreadError from './error'
+
+vi.mock('next/link', () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/t/test-topic/abc123',
+}))
+
+describe('ThreadError', () => {
+ const error = new Error('Thread not found')
+ const reset = vi.fn()
+
+ beforeEach(() => {
+ reset.mockClear()
+ })
+
+ it('renders topic error heading', () => {
+ render( )
+ expect(screen.getByRole('heading', { name: 'Could not load topic' })).toBeInTheDocument()
+ })
+
+ it('renders an alert region', () => {
+ render( )
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+ })
+
+ it('renders try again button that calls reset', async () => {
+ const user = userEvent.setup()
+ render( )
+ const button = screen.getByRole('button', { name: /try again/i })
+ await user.click(button)
+ expect(reset).toHaveBeenCalledOnce()
+ })
+
+ it('renders a return to forum link', () => {
+ render( )
+ const link = screen.getByRole('link', { name: /return to forum/i })
+ expect(link).toHaveAttribute('href', '/')
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render( )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/app/t/[slug]/[rkey]/error.tsx b/src/app/t/[slug]/[rkey]/error.tsx
new file mode 100644
index 0000000..7253cde
--- /dev/null
+++ b/src/app/t/[slug]/[rkey]/error.tsx
@@ -0,0 +1,63 @@
+/**
+ * Thread view error boundary -- catches errors loading individual topics/threads.
+ * Common triggers: thread not found, permission denied, network failure.
+ * Next.js requires a default export for error boundaries.
+ */
+
+'use client'
+
+import { useEffect } from 'react'
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { WarningCircle, ArrowClockwise, House } from '@phosphor-icons/react'
+import { reportError } from '@/lib/error-reporting'
+
+export default function ThreadError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ const pathname = usePathname()
+
+ useEffect(() => {
+ document.title = 'Error | Barazo'
+ reportError(error, { boundary: 'thread', path: pathname })
+ }, [error, pathname])
+
+ const message =
+ process.env.NODE_ENV === 'development'
+ ? error.message
+ : 'This topic could not be loaded. It may have been removed, or there was a network issue.'
+
+ return (
+ <>
+ Error | Barazo
+
+
+
+
Could not load topic
+
{message}
+
+
+
+ Try again
+
+
+
+ Return to forum
+
+
+
+
+ >
+ )
+}
diff --git a/src/app/t/[slug]/[rkey]/layout.tsx b/src/app/t/[slug]/[rkey]/layout.tsx
new file mode 100644
index 0000000..9ee8682
--- /dev/null
+++ b/src/app/t/[slug]/[rkey]/layout.tsx
@@ -0,0 +1,9 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Topic',
+}
+
+export default function TopicLayout({ children }: { children: React.ReactNode }) {
+ return children
+}
diff --git a/src/app/t/[slug]/[rkey]/loading.test.tsx b/src/app/t/[slug]/[rkey]/loading.test.tsx
new file mode 100644
index 0000000..f22e6e9
--- /dev/null
+++ b/src/app/t/[slug]/[rkey]/loading.test.tsx
@@ -0,0 +1,27 @@
+import { render, screen } from '@testing-library/react'
+import { axe } from 'vitest-axe'
+import ThreadLoading from './loading'
+
+describe('ThreadLoading', () => {
+ it('renders a loading status region', () => {
+ render( )
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('renders accessible loading text for screen readers', () => {
+ render( )
+ expect(screen.getByText('Loading topic and replies')).toBeInTheDocument()
+ })
+
+ it('renders skeleton placeholders', () => {
+ const { container } = render( )
+ const skeletons = container.querySelectorAll('.animate-pulse')
+ expect(skeletons.length).toBeGreaterThan(0)
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render( )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/app/t/[slug]/[rkey]/loading.tsx b/src/app/t/[slug]/[rkey]/loading.tsx
new file mode 100644
index 0000000..cc8254c
--- /dev/null
+++ b/src/app/t/[slug]/[rkey]/loading.tsx
@@ -0,0 +1,56 @@
+/**
+ * Thread loading state -- shown while a topic and its replies are loading.
+ * Matches the topic view layout with skeleton placeholders.
+ */
+
+export default function ThreadLoading() {
+ return (
+
+ {/* Breadcrumb skeleton */}
+
+
+ {/* Topic skeleton */}
+
+ {/* Title */}
+
+ {/* Author + date */}
+
+ {/* Content lines */}
+
+
+
+ {/* Replies skeleton */}
+
+ {Array.from({ length: 3 }, (_, i) => (
+
+ ))}
+
+
Loading topic and replies
+
+ )
+}
diff --git a/src/app/u/[handle]/error.test.tsx b/src/app/u/[handle]/error.test.tsx
new file mode 100644
index 0000000..49e3223
--- /dev/null
+++ b/src/app/u/[handle]/error.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { axe } from 'vitest-axe'
+import ProfileError from './error'
+
+vi.mock('next/link', () => ({
+ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/u/alice.bsky.social',
+}))
+
+describe('ProfileError', () => {
+ const error = new Error('Profile not found')
+ const reset = vi.fn()
+
+ beforeEach(() => {
+ reset.mockClear()
+ })
+
+ it('renders profile error heading', () => {
+ render( )
+ expect(screen.getByRole('heading', { name: 'Could not load profile' })).toBeInTheDocument()
+ })
+
+ it('renders an alert region', () => {
+ render( )
+ expect(screen.getByRole('alert')).toBeInTheDocument()
+ })
+
+ it('renders try again button that calls reset', async () => {
+ const user = userEvent.setup()
+ render( )
+ const button = screen.getByRole('button', { name: /try again/i })
+ await user.click(button)
+ expect(reset).toHaveBeenCalledOnce()
+ })
+
+ it('renders a return to forum link', () => {
+ render( )
+ const link = screen.getByRole('link', { name: /return to forum/i })
+ expect(link).toHaveAttribute('href', '/')
+ })
+
+ it('passes axe accessibility check', async () => {
+ const { container } = render( )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+})
diff --git a/src/app/u/[handle]/error.tsx b/src/app/u/[handle]/error.tsx
new file mode 100644
index 0000000..8efc3ca
--- /dev/null
+++ b/src/app/u/[handle]/error.tsx
@@ -0,0 +1,62 @@
+/**
+ * Profile error boundary -- catches errors loading user profiles.
+ * Common triggers: user not found, profile loading failure, PDS unreachable.
+ * Next.js requires a default export for error boundaries.
+ */
+
+'use client'
+
+import { useEffect } from 'react'
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { WarningCircle, ArrowClockwise, House } from '@phosphor-icons/react'
+import { reportError } from '@/lib/error-reporting'
+
+export default function ProfileError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ const pathname = usePathname()
+
+ useEffect(() => {
+ reportError(error, { boundary: 'profile', path: pathname })
+ }, [error, pathname])
+
+ const message =
+ process.env.NODE_ENV === 'development'
+ ? error.message
+ : 'This profile could not be loaded. The user may not exist or their identity server may be unavailable.'
+
+ return (
+ <>
+ Error | Barazo
+
+
+
+
Could not load profile
+
{message}
+
+
+
+ Try again
+
+
+
+ Return to forum
+
+
+
+
+ >
+ )
+}
diff --git a/src/lib/error-reporting.test.ts b/src/lib/error-reporting.test.ts
new file mode 100644
index 0000000..6e5d30c
--- /dev/null
+++ b/src/lib/error-reporting.test.ts
@@ -0,0 +1,35 @@
+import { reportError } from './error-reporting'
+
+describe('reportError', () => {
+ it('logs to console.error with structured context', () => {
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const error = new Error('Test error')
+
+ reportError(error, { boundary: 'root' })
+
+ expect(spy).toHaveBeenCalledWith(
+ '[Barazo]',
+ 'root',
+ 'Test error',
+ expect.objectContaining({ boundary: 'root' })
+ )
+
+ spy.mockRestore()
+ })
+
+ it('includes additional context in the log', () => {
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const error = new Error('Admin error')
+
+ reportError(error, { boundary: 'admin', page: '/admin/settings' })
+
+ expect(spy).toHaveBeenCalledWith(
+ '[Barazo]',
+ 'admin',
+ 'Admin error',
+ expect.objectContaining({ boundary: 'admin', page: '/admin/settings' })
+ )
+
+ spy.mockRestore()
+ })
+})
diff --git a/src/lib/error-reporting.ts b/src/lib/error-reporting.ts
new file mode 100644
index 0000000..09057b5
--- /dev/null
+++ b/src/lib/error-reporting.ts
@@ -0,0 +1,20 @@
+/**
+ * Error reporting utility.
+ * Logs errors with structured context. When GlitchTip/@sentry/nextjs is
+ * installed, add `import * as Sentry from '@sentry/nextjs'` and call
+ * `Sentry.captureException(error, { tags: context })` here.
+ */
+
+interface ErrorContext {
+ /** Which boundary caught the error (e.g. 'root', 'admin', 'thread') */
+ boundary: string
+ /** Additional metadata */
+ [key: string]: string
+}
+
+export function reportError(error: Error, context: ErrorContext): void {
+ console.error('[Barazo]', context.boundary, error.message, context)
+
+ // TODO: Add GlitchTip/Sentry integration when @sentry/nextjs is installed.
+ // See .env.example NEXT_PUBLIC_SENTRY_DSN.
+}