diff --git a/src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx b/src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx
new file mode 100644
index 0000000..cef8128
--- /dev/null
+++ b/src/app/p/[clientSlug]/[projectSlug]/c/[cardId]/page.tsx
@@ -0,0 +1,89 @@
+import Link from "next/link";
+import { notFound, redirect } from "next/navigation";
+import { ArrowLeft } from "lucide-react";
+
+import { AppShell } from "@/components/shell/app-shell";
+import { TopBar } from "@/components/shell/top-bar";
+import { CardHeader } from "@/components/card/card-header";
+import { CardActions } from "@/components/card/card-actions";
+import { SubtaskList } from "@/components/card/subtask-list";
+import { CommentThread } from "@/components/card/comment-thread";
+import { ActivityTimeline } from "@/components/card/activity-timeline";
+import { Pill } from "@/components/ui/pill";
+import { getSession } from "@/lib/auth/session";
+import { loadCardDetail } from "@/lib/data/card";
+
+export default async function CardPage({
+ params,
+}: {
+ params: Promise<{ clientSlug: string; projectSlug: string; cardId: string }>;
+}) {
+ const session = await getSession();
+ if (!session) redirect("/login");
+
+ const { clientSlug, projectSlug, cardId } = await params;
+ const detail = await loadCardDetail(clientSlug, projectSlug, cardId);
+ if (!detail) notFound();
+
+ const isOwner = session.role === "owner";
+ const boardHref = `/p/${clientSlug}/${projectSlug}`;
+
+ return (
+
+
+ {isOwner && (
+
+ )}
+
+
+
+
+
+
+
+ Back to board
+
+
{detail.column.name}
+ {!detail.card.accepted &&
+ detail.card.created_by_role === "client" && (
+
Requested
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/card/card-actions.tsx b/src/components/card/card-actions.tsx
new file mode 100644
index 0000000..43645f8
--- /dev/null
+++ b/src/components/card/card-actions.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { useTransition } from "react";
+import { useRouter } from "next/navigation";
+import { Check, Archive } from "lucide-react";
+
+import { acceptCard, archiveCard } from "@/app/actions/cards";
+import type { CardRecord } from "@/lib/pb/types";
+
+export function CardActions({
+ card,
+ clientSlug,
+ projectSlug,
+}: {
+ card: CardRecord;
+ clientSlug: string;
+ projectSlug: string;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+
+ const showAccept = !card.accepted && card.created_by_role === "client";
+
+ return (
+
+ {showAccept && (
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/card/card-header.tsx b/src/components/card/card-header.tsx
new file mode 100644
index 0000000..146407a
--- /dev/null
+++ b/src/components/card/card-header.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+import { updateCard } from "@/app/actions/cards";
+import type { CardRecord } from "@/lib/pb/types";
+
+/**
+ * Title and description.
+ *
+ * Editable in place for the owner, read-only for a client manager. The client
+ * may not change these — their two editable fields are priority and target
+ * date, which live on the board tile and go through /api/client/*.
+ */
+export function CardHeader({
+ card,
+ editable,
+ clientSlug,
+ projectSlug,
+}: {
+ card: CardRecord;
+ editable: boolean;
+ clientSlug: string;
+ projectSlug: string;
+}) {
+ const [pending, startTransition] = useTransition();
+ const [title, setTitle] = useState(card.title);
+ const [description, setDescription] = useState(card.description);
+
+ function saveTitle() {
+ if (title.trim() === card.title || !title.trim()) {
+ setTitle(card.title);
+ return;
+ }
+ startTransition(() =>
+ updateCard({ cardId: card.id, title, clientSlug, projectSlug }).then(
+ () => undefined
+ )
+ );
+ }
+
+ function saveDescription() {
+ if (description === card.description) return;
+ startTransition(() =>
+ updateCard({ cardId: card.id, description, clientSlug, projectSlug }).then(
+ () => undefined
+ )
+ );
+ }
+
+ if (!editable) {
+ return (
+
+
+ {card.title}
+
+ {card.description ? (
+
+ {card.description}
+
+ ) : (
+
No description yet.
+ )}
+
+ );
+ }
+
+ return (
+
+ setTitle(event.target.value)}
+ onBlur={saveTitle}
+ disabled={pending}
+ aria-label="Card title"
+ className="w-full rounded-lg bg-transparent text-xl font-semibold tracking-tight text-text-primary outline-none hover:bg-surface focus:bg-surface"
+ />
+
+ );
+}
\ No newline at end of file