diff --git a/src/components/shell/app-shell.tsx b/src/components/shell/app-shell.tsx
new file mode 100644
index 0000000..4330355
--- /dev/null
+++ b/src/components/shell/app-shell.tsx
@@ -0,0 +1,29 @@
+import { Sidebar } from "@/components/shell/sidebar";
+import type { Role } from "@/lib/pb/types";
+
+/**
+ * The page background with a raised panel floating on it.
+ *
+ * Borrowed from the design reference and the reason the product does not read
+ * as an admin panel. The panel owns the scroll; the page behind it never
+ * moves, so the elevation stays believable.
+ */
+export function AppShell({
+ role,
+ currentPath,
+ children,
+}: {
+ role: Role;
+ currentPath: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {children}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/shell/sidebar.tsx b/src/components/shell/sidebar.tsx
new file mode 100644
index 0000000..1de5c90
--- /dev/null
+++ b/src/components/shell/sidebar.tsx
@@ -0,0 +1,75 @@
+import Link from "next/link";
+import { LayoutGrid, FolderKanban, Settings, Users } from "lucide-react";
+
+import { cn } from "@/lib/cn";
+import type { Role } from "@/lib/pb/types";
+
+interface NavItem {
+ href: string;
+ label: string;
+ icon: React.ComponentType<{ size?: number; strokeWidth?: number }>;
+ ownerOnly?: boolean;
+}
+
+const NAV: NavItem[] = [
+ { href: "/", label: "Projects", icon: LayoutGrid },
+ { href: "/admin/clients", label: "Clients", icon: Users, ownerOnly: true },
+ { href: "/admin/projects", label: "Manage", icon: FolderKanban, ownerOnly: true },
+ { href: "/admin/settings", label: "Settings", icon: Settings, ownerOnly: true },
+];
+
+/**
+ * The icon rail from the reference: an icon over a small label, not an icon
+ * alone. Icon-only rails force everyone to learn a private vocabulary, and a
+ * client manager visits this maybe twice a month.
+ *
+ * Owner-only links are filtered out here for tidiness. That is presentation,
+ * not security — the pages themselves re-check the role.
+ */
+export function Sidebar({
+ role,
+ currentPath,
+}: {
+ role: Role;
+ currentPath: string;
+}) {
+ const items = NAV.filter((item) => !item.ownerOnly || role === "owner");
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/shell/top-bar.tsx b/src/components/shell/top-bar.tsx
new file mode 100644
index 0000000..eba9fac
--- /dev/null
+++ b/src/components/shell/top-bar.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { LogOut } from "lucide-react";
+
+import { ThemeToggle } from "@/components/theme/theme-toggle";
+import { IconButton } from "@/components/ui/icon-button";
+
+export function TopBar({
+ title,
+ subtitle,
+ children,
+}: {
+ title: string;
+ subtitle?: string;
+ children?: React.ReactNode;
+}) {
+ const router = useRouter();
+
+ async function signOut() {
+ await fetch("/api/auth/logout", { method: "POST" });
+ router.push("/login");
+ router.refresh();
+ }
+
+ return (
+
+