diff --git a/src/app/dock/error.tsx b/src/app/dock/error.tsx
new file mode 100644
index 0000000..8514a1a
--- /dev/null
+++ b/src/app/dock/error.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { useEffect } from 'react';
+import Link from 'next/link';
+import { GiSinkingShip } from 'react-icons/gi';
+import { cn } from '@/lib/utils/cn';
+
+interface DockErrorProps {
+ error: Error & { digest?: string };
+ unstable_retry: () => void;
+}
+
+function DockError({ error, unstable_retry }: DockErrorProps) {
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+
+
+ We’ve sprung a leak
+
+ We hit rough water loading your dock. Try again, or head back.
+
+
+
+
+ Back to your dock
+
+
+
+ );
+}
+
+const styles = {
+ root: cn(
+ 'text-gd-text flex min-h-[70vh] flex-col items-center justify-center gap-4 px-6 text-center',
+ ),
+ icon: cn('text-gd-error text-7xl'),
+ title: cn('text-3xl font-semibold'),
+ description: cn('text-gd-muted max-w-md text-balance'),
+ actions: cn('mt-2 flex flex-wrap items-center justify-center gap-3'),
+ retry: cn(
+ 'bg-gd-accent text-gd-bg hover:bg-gd-accent-glow active:translate-y-px',
+ 'focus-visible:outline-gd-accent focus-visible:outline-2 focus-visible:outline-offset-2',
+ 'rounded-lg px-5 py-2.5 text-sm font-medium transition-colors',
+ ),
+ back: cn(
+ 'text-gd-muted hover:text-gd-text active:translate-y-px',
+ 'focus-visible:outline-gd-accent focus-visible:outline-2 focus-visible:outline-offset-2',
+ 'rounded-lg px-5 py-2.5 text-sm font-medium transition-colors',
+ ),
+};
+
+export default DockError;
diff --git a/src/app/dock/not-found.tsx b/src/app/dock/not-found.tsx
new file mode 100644
index 0000000..1c0c3c0
--- /dev/null
+++ b/src/app/dock/not-found.tsx
@@ -0,0 +1,39 @@
+import Link from 'next/link';
+import { GiBoatHorizon } from 'react-icons/gi';
+import { cn } from '@/lib/utils/cn';
+
+function DockNotFound() {
+ return (
+
+
+
+
404
+
Project not found
+
+
+ That project drifted off the map — it may have been removed, or the link’s wrong.
+
+
+ Back to your dock
+
+
+ );
+}
+
+const styles = {
+ root: cn(
+ 'text-gd-text flex min-h-[70vh] flex-col items-center justify-center gap-4 px-6 text-center',
+ ),
+ icon: cn('text-gd-muted text-7xl'),
+ heading: cn('flex flex-col items-center gap-1'),
+ code: cn('text-gd-accent text-sm font-semibold tracking-[0.2em]'),
+ title: cn('text-3xl font-semibold'),
+ description: cn('text-gd-muted max-w-md text-balance'),
+ action: cn(
+ 'bg-gd-accent text-gd-bg hover:bg-gd-accent-glow active:translate-y-px',
+ 'focus-visible:outline-gd-accent focus-visible:outline-2 focus-visible:outline-offset-2',
+ 'mt-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors',
+ ),
+};
+
+export default DockNotFound;
diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 0000000..9e388bc
--- /dev/null
+++ b/src/app/error.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { useEffect } from 'react';
+import { GiSinkingShip } from 'react-icons/gi';
+import { cn } from '@/lib/utils/cn';
+
+interface RootErrorProps {
+ error: Error & { digest?: string };
+ unstable_retry: () => void;
+}
+
+function RootError({ error, unstable_retry }: RootErrorProps) {
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+
+
+ We’ve sprung a leak
+
+ Something went wrong on deck. Try again — if it keeps happening, check back later.
+
+
+
+ );
+}
+
+const styles = {
+ root: cn(
+ 'bg-gd-bg text-gd-text flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center',
+ ),
+ icon: cn('text-gd-error text-7xl'),
+ title: cn('text-3xl font-semibold'),
+ description: cn('text-gd-muted max-w-md text-balance'),
+ action: cn(
+ 'bg-gd-accent text-gd-bg hover:bg-gd-accent-glow active:translate-y-px',
+ 'focus-visible:outline-gd-accent focus-visible:outline-2 focus-visible:outline-offset-2',
+ 'mt-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors',
+ ),
+};
+
+export default RootError;
diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx
new file mode 100644
index 0000000..b37b558
--- /dev/null
+++ b/src/app/global-error.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import { useEffect } from 'react';
+import { GiSinkingShip } from 'react-icons/gi';
+import { cn } from '@/lib/utils/cn';
+import './globals.css';
+
+interface GlobalErrorProps {
+ error: Error & { digest?: string };
+ unstable_retry: () => void;
+}
+
+function GlobalError({ error, unstable_retry }: GlobalErrorProps) {
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+
+
+ Something went wrong · GhostDock
+
+
+ We’ve sprung a leak
+
+ GhostDock hit an unexpected error. Try again in a moment.
+
+
+
+
+
+ );
+}
+
+const styles = {
+ body: cn('bg-gd-bg text-gd-text'),
+ root: cn('flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center'),
+ icon: cn('text-gd-error text-7xl'),
+ title: cn('text-3xl font-semibold'),
+ description: cn('text-gd-muted max-w-md text-balance'),
+ action: cn(
+ 'bg-gd-accent text-gd-bg hover:bg-gd-accent-glow active:translate-y-px',
+ 'focus-visible:outline-gd-accent focus-visible:outline-2 focus-visible:outline-offset-2',
+ 'mt-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors',
+ ),
+};
+
+export default GlobalError;
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..a490b72
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,39 @@
+import Link from 'next/link';
+import { GiBoatHorizon } from 'react-icons/gi';
+import { cn } from '@/lib/utils/cn';
+
+function RootNotFound() {
+ return (
+
+
+
+
404
+
Lost in the fog
+
+
+ This page drifted off the map. Let’s get you back to shore.
+
+
+ Return home
+
+
+ );
+}
+
+const styles = {
+ root: cn(
+ 'bg-gd-bg text-gd-text flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center',
+ ),
+ icon: cn('text-gd-muted text-7xl'),
+ heading: cn('flex flex-col items-center gap-1'),
+ code: cn('text-gd-accent text-sm font-semibold tracking-[0.2em]'),
+ title: cn('text-3xl font-semibold'),
+ description: cn('text-gd-muted max-w-md text-balance'),
+ action: cn(
+ 'bg-gd-accent text-gd-bg hover:bg-gd-accent-glow active:translate-y-px',
+ 'focus-visible:outline-gd-accent focus-visible:outline-2 focus-visible:outline-offset-2',
+ 'mt-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors',
+ ),
+};
+
+export default RootNotFound;
diff --git a/src/app/p/[slug]/error.tsx b/src/app/p/[slug]/error.tsx
new file mode 100644
index 0000000..136401e
--- /dev/null
+++ b/src/app/p/[slug]/error.tsx
@@ -0,0 +1,46 @@
+'use client';
+
+import { useEffect } from 'react';
+import { cn } from '@/lib/utils/cn';
+
+// Applies the visitor's saved theme before first paint, so there is no light/dark flash.
+const themeScript = `(function(){try{var t=localStorage.getItem('pp-theme');if(t==='light'||t==='dark'){document.documentElement.setAttribute('data-pp-theme',t);}}catch(e){}})();`;
+
+interface PublicErrorProps {
+ error: Error & { digest?: string };
+ unstable_retry: () => void;
+}
+
+function PublicError({ error, unstable_retry }: PublicErrorProps) {
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+
+ return (
+ <>
+
+
+ Something went wrong
+ This page couldn’t be loaded. Try again in a moment.
+
+
+ >
+ );
+}
+
+const styles = {
+ root: cn(
+ 'bg-pp-paper text-pp-ink flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center',
+ ),
+ title: cn('text-pp-ink-strong text-3xl font-semibold'),
+ description: cn('text-pp-muted max-w-md text-balance'),
+ action: cn(
+ 'border-pp-rule text-pp-ink hover:bg-pp-paper-2 active:translate-y-px',
+ 'focus-visible:outline-pp-focus focus-visible:outline-2 focus-visible:outline-offset-2',
+ 'mt-2 rounded-lg border px-5 py-2.5 text-sm font-medium transition-colors',
+ ),
+};
+
+export default PublicError;
diff --git a/src/app/p/[slug]/loading.tsx b/src/app/p/[slug]/loading.tsx
new file mode 100644
index 0000000..ffd11b9
--- /dev/null
+++ b/src/app/p/[slug]/loading.tsx
@@ -0,0 +1,40 @@
+import { cn } from '@/lib/utils/cn';
+
+// Applies the visitor's saved theme before first paint, so there is no light/dark flash.
+const themeScript = `(function(){try{var t=localStorage.getItem('pp-theme');if(t==='light'||t==='dark'){document.documentElement.setAttribute('data-pp-theme',t);}}catch(e){}})();`;
+
+function PublicLoading() {
+ return (
+ <>
+
+
+ Loading project…
+
+
+ >
+ );
+}
+
+const styles = {
+ root: cn('bg-pp-paper min-h-screen'),
+ srOnly: cn('sr-only'),
+ inner: cn('mx-auto flex max-w-2xl flex-col gap-6 px-5 py-16 motion-safe:animate-pulse sm:py-20'),
+ title: cn('bg-pp-rule h-10 w-2/3 rounded-md'),
+ lines: cn('flex flex-col gap-2.5'),
+ line: cn('bg-pp-rule h-4 w-full rounded'),
+ badges: cn('mt-2 flex gap-2'),
+ badge: cn('bg-pp-rule h-7 w-20 rounded-full'),
+};
+
+export default PublicLoading;
diff --git a/src/app/p/[slug]/not-found.tsx b/src/app/p/[slug]/not-found.tsx
new file mode 100644
index 0000000..190302c
--- /dev/null
+++ b/src/app/p/[slug]/not-found.tsx
@@ -0,0 +1,42 @@
+import Link from 'next/link';
+import { cn } from '@/lib/utils/cn';
+
+// Applies the visitor's saved theme before first paint, so there is no light/dark flash.
+const themeScript = `(function(){try{var t=localStorage.getItem('pp-theme');if(t==='light'||t==='dark'){document.documentElement.setAttribute('data-pp-theme',t);}}catch(e){}})();`;
+
+function PublicNotFound() {
+ return (
+ <>
+
+
+
+
404
+
Project not found
+
+
+ This project page doesn’t exist, or it hasn’t been published yet.
+
+
+ Go to GhostDock
+
+
+ >
+ );
+}
+
+const styles = {
+ root: cn(
+ 'bg-pp-paper text-pp-ink flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center',
+ ),
+ heading: cn('flex flex-col items-center gap-1'),
+ code: cn('text-pp-muted text-sm font-semibold tracking-[0.2em]'),
+ title: cn('text-pp-ink-strong text-3xl font-semibold'),
+ description: cn('text-pp-muted max-w-md text-balance'),
+ action: cn(
+ 'border-pp-rule text-pp-ink hover:bg-pp-paper-2 active:translate-y-px',
+ 'focus-visible:outline-pp-focus focus-visible:outline-2 focus-visible:outline-offset-2',
+ 'mt-2 rounded-lg border px-5 py-2.5 text-sm font-medium transition-colors',
+ ),
+};
+
+export default PublicNotFound;