diff --git a/apps/web/package.json b/apps/web/package.json
index f92e158..b78668a 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -18,6 +18,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^9.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@hello-pangea/dnd": "^18.0.1",
"@mosaic/shared": "workspace:*",
"@mosaic/ui": "workspace:*",
"@tanstack/react-query": "^5.90.20",
diff --git a/apps/web/src/app/(authenticated)/kanban/page.tsx b/apps/web/src/app/(authenticated)/kanban/page.tsx
new file mode 100644
index 0000000..f2b2292
--- /dev/null
+++ b/apps/web/src/app/(authenticated)/kanban/page.tsx
@@ -0,0 +1,469 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import type { ReactElement } from "react";
+import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
+import type {
+ DropResult,
+ DroppableProvided,
+ DraggableProvided,
+ DraggableStateSnapshot,
+} from "@hello-pangea/dnd";
+
+import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
+import { fetchTasks, updateTask } from "@/lib/api/tasks";
+import { useWorkspaceId } from "@/lib/hooks";
+import type { Task } from "@mosaic/shared";
+import { TaskStatus, TaskPriority } from "@mosaic/shared";
+
+/* ---------------------------------------------------------------------------
+ Column configuration
+ --------------------------------------------------------------------------- */
+
+interface ColumnConfig {
+ status: TaskStatus;
+ label: string;
+ accent: string;
+}
+
+const COLUMNS: ColumnConfig[] = [
+ { status: TaskStatus.NOT_STARTED, label: "To Do", accent: "var(--ms-blue-400)" },
+ { status: TaskStatus.IN_PROGRESS, label: "In Progress", accent: "var(--ms-amber-400)" },
+ { status: TaskStatus.PAUSED, label: "Paused", accent: "var(--ms-purple-400)" },
+ { status: TaskStatus.COMPLETED, label: "Done", accent: "var(--ms-teal-400)" },
+ { status: TaskStatus.ARCHIVED, label: "Archived", accent: "var(--muted)" },
+];
+
+/* ---------------------------------------------------------------------------
+ Priority badge helper
+ --------------------------------------------------------------------------- */
+
+interface PriorityStyle {
+ label: string;
+ bg: string;
+ color: string;
+}
+
+function getPriorityStyle(priority: TaskPriority): PriorityStyle {
+ switch (priority) {
+ case TaskPriority.HIGH:
+ return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" };
+ case TaskPriority.MEDIUM:
+ return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
+ case TaskPriority.LOW:
+ return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
+ default:
+ return { label: String(priority), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
+ }
+}
+
+/* ---------------------------------------------------------------------------
+ Task Card
+ --------------------------------------------------------------------------- */
+
+interface TaskCardProps {
+ task: Task;
+ provided: DraggableProvided;
+ snapshot: DraggableStateSnapshot;
+ columnAccent: string;
+}
+
+function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): ReactElement {
+ const [hovered, setHovered] = useState(false);
+ const priorityStyle = getPriorityStyle(task.priority);
+
+ return (
+
{
+ setHovered(true);
+ }}
+ onMouseLeave={() => {
+ setHovered(false);
+ }}
+ style={{
+ background: "var(--surface)",
+ border: `1px solid ${hovered || snapshot.isDragging ? columnAccent : "var(--border)"}`,
+ borderRadius: "var(--r)",
+ padding: 12,
+ marginBottom: 8,
+ cursor: "grab",
+ transition: "border-color 0.15s, box-shadow 0.15s",
+ boxShadow: snapshot.isDragging ? "var(--shadow-lg)" : "none",
+ ...provided.draggableProps.style,
+ }}
+ >
+ {/* Title */}
+
+ {task.title}
+
+
+ {/* Priority badge */}
+
+ {priorityStyle.label}
+
+
+ {/* Description */}
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+ );
+}
+
+/* ---------------------------------------------------------------------------
+ Kanban Column
+ --------------------------------------------------------------------------- */
+
+interface KanbanColumnProps {
+ config: ColumnConfig;
+ tasks: Task[];
+}
+
+function KanbanColumn({ config, tasks }: KanbanColumnProps): ReactElement {
+ return (
+
+ {/* Column header */}
+
+
+ {config.label}
+
+
+ {tasks.length}
+
+
+
+ {/* Droppable area */}
+
+ {(provided: DroppableProvided) => (
+
+ {tasks.map((task, index) => (
+
+ {(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+
+ );
+}
+
+/* ---------------------------------------------------------------------------
+ Kanban Board Page
+ --------------------------------------------------------------------------- */
+
+export default function KanbanPage(): ReactElement {
+ const workspaceId = useWorkspaceId();
+ const [tasks, setTasks] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ /* --- data fetching --- */
+
+ const loadTasks = useCallback(async (wsId: string | null): Promise => {
+ try {
+ setIsLoading(true);
+ setError(null);
+ const filters = wsId !== null ? { workspaceId: wsId } : {};
+ const data = await fetchTasks(filters);
+ setTasks(data);
+ } catch (err: unknown) {
+ console.error("[Kanban] Failed to fetch tasks:", err);
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Something went wrong loading tasks. You could try again when ready."
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!workspaceId) {
+ setIsLoading(false);
+ return;
+ }
+
+ let cancelled = false;
+
+ async function load(): Promise {
+ try {
+ setIsLoading(true);
+ setError(null);
+ const filters = workspaceId !== null ? { workspaceId } : {};
+ const data = await fetchTasks(filters);
+ if (!cancelled) {
+ setTasks(data);
+ }
+ } catch (err: unknown) {
+ console.error("[Kanban] Failed to fetch tasks:", err);
+ if (!cancelled) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Something went wrong loading tasks. You could try again when ready."
+ );
+ }
+ } finally {
+ if (!cancelled) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ void load();
+
+ return (): void => {
+ cancelled = true;
+ };
+ }, [workspaceId]);
+
+ /* --- group tasks by status --- */
+
+ function groupByStatus(allTasks: Task[]): Record {
+ const grouped: Record = {
+ [TaskStatus.NOT_STARTED]: [],
+ [TaskStatus.IN_PROGRESS]: [],
+ [TaskStatus.PAUSED]: [],
+ [TaskStatus.COMPLETED]: [],
+ [TaskStatus.ARCHIVED]: [],
+ };
+
+ for (const task of allTasks) {
+ grouped[task.status].push(task);
+ }
+
+ return grouped;
+ }
+
+ const grouped = groupByStatus(tasks);
+
+ /* --- drag-and-drop handler --- */
+
+ const handleDragEnd = useCallback(
+ (result: DropResult) => {
+ const { source, destination, draggableId } = result;
+
+ // Dropped outside a droppable area
+ if (!destination) return;
+
+ // Dropped in same position
+ if (source.droppableId === destination.droppableId && source.index === destination.index) {
+ return;
+ }
+
+ const newStatus = destination.droppableId as TaskStatus;
+ const taskId = draggableId;
+
+ // Optimistic update: move card in local state
+ setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
+
+ // Persist to API
+ const wsId = workspaceId ?? undefined;
+ updateTask(taskId, { status: newStatus }, wsId).catch((err: unknown) => {
+ console.error("[Kanban] Failed to update task status:", err);
+ // Revert on failure by re-fetching
+ void loadTasks(workspaceId);
+ });
+ },
+ [workspaceId, loadTasks]
+ );
+
+ /* --- retry handler --- */
+
+ function handleRetry(): void {
+ void loadTasks(workspaceId);
+ }
+
+ /* --- render --- */
+
+ return (
+
+ {/* Page header */}
+
+
+ Kanban Board
+
+
+ Visualize and manage task progress across stages
+
+
+
+ {/* Loading state */}
+ {isLoading ? (
+
+
+
+ ) : error !== null ? (
+ /* Error state */
+
+
{error}
+
+
+ ) : tasks.length === 0 ? (
+ /* Empty state */
+
+
+ No tasks yet. Create some tasks to see them here.
+
+
+ ) : (
+ /* Board */
+
+
+ {COLUMNS.map((col) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/lib/api/tasks.ts b/apps/web/src/lib/api/tasks.ts
index 9074b59..2fedb4a 100644
--- a/apps/web/src/lib/api/tasks.ts
+++ b/apps/web/src/lib/api/tasks.ts
@@ -5,7 +5,7 @@
import type { Task } from "@mosaic/shared";
import type { TaskStatus, TaskPriority } from "@mosaic/shared";
-import { apiGet, type ApiResponse } from "./client";
+import { apiGet, apiPatch, type ApiResponse } from "./client";
export interface TaskFilters {
status?: TaskStatus;
@@ -34,3 +34,15 @@ export async function fetchTasks(filters?: TaskFilters): Promise {
const response = await apiGet>(endpoint, filters?.workspaceId);
return response.data;
}
+
+/**
+ * Update a task by ID
+ */
+export async function updateTask(
+ id: string,
+ data: Partial,
+ workspaceId?: string
+): Promise {
+ const res = await apiPatch>(`/api/tasks/${id}`, data, workspaceId);
+ return res.data;
+}
diff --git a/docs/scratchpads/468-kanban-page.md b/docs/scratchpads/468-kanban-page.md
new file mode 100644
index 0000000..c6a810d
--- /dev/null
+++ b/docs/scratchpads/468-kanban-page.md
@@ -0,0 +1,35 @@
+# PG-PAGE-003: Kanban Board Page
+
+## Task
+
+Build Kanban board page with drag-and-drop columns mapped to TaskStatus.
+
+## Issue
+
+Refs #468
+
+## Files Changed
+
+- `apps/web/src/app/(authenticated)/kanban/page.tsx` (new)
+- `apps/web/src/lib/api/tasks.ts` (added `updateTask`)
+- `package.json` / `pnpm-lock.yaml` (added `@hello-pangea/dnd`)
+
+## Design Decisions
+
+- Used `@hello-pangea/dnd` (maintained fork of react-beautiful-dnd) for drag-and-drop
+- 5 columns mapped to TaskStatus enum: NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED, ARCHIVED
+- Optimistic update pattern: move card immediately, revert by re-fetching on API failure
+- Priority badge always shown (field is non-optional in Task type)
+- Column count badge uses `color-mix()` for transparent accent backgrounds
+- Horizontal scroll on mobile with `overflow-x: auto` and `min-width: 280px` per column
+
+## Verification
+
+- Lint: clean (my files pass, pre-existing errors in runner-jobs.ts not my scope)
+- Build: `next build` succeeds, `/kanban` route present in output
+- TypeScript: no type errors
+- Design tokens: all colors from CSS custom properties, no hardcoded colors
+
+## Status
+
+Complete
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1dca6a7..d7af660 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -386,6 +386,9 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.4)
+ '@hello-pangea/dnd':
+ specifier: ^18.0.1
+ version: 18.0.1(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mosaic/shared':
specifier: workspace:*
version: link:../../packages/shared
@@ -1172,6 +1175,12 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ '@hello-pangea/dnd@18.0.1':
+ resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -1529,7 +1538,6 @@ packages:
'@mosaicstack/telemetry-client@0.1.1':
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
- engines: {node: '>=18'}
'@mrleebo/prisma-ast@0.13.1':
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
@@ -3944,6 +3952,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ css-box-model@1.2.1:
+ resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==}
+
css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
@@ -5996,6 +6007,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ raf-schd@4.0.3:
+ resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
+
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -7559,7 +7573,7 @@ snapshots:
chalk: 5.6.2
commander: 12.1.0
dotenv: 17.2.4
- drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
+ drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
open: 10.2.0
pg: 8.17.2
prettier: 3.8.1
@@ -7951,6 +7965,18 @@ snapshots:
protobufjs: 7.5.4
yargs: 17.7.2
+ '@hello-pangea/dnd@18.0.1(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@babel/runtime': 7.28.6
+ css-box-model: 1.2.1
+ raf-schd: 4.0.3
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ react-redux: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1)
+ redux: 5.0.1
+ transitivePeerDependencies:
+ - '@types/react'
+
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -10621,7 +10647,7 @@ snapshots:
optionalDependencies:
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
better-sqlite3: 12.6.2
- drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
+ drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -10646,7 +10672,7 @@ snapshots:
optionalDependencies:
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
better-sqlite3: 12.6.2
- drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
+ drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -11099,6 +11125,10 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ css-box-model@1.2.1:
+ dependencies:
+ tiny-invariant: 1.3.3
+
css.escape@1.5.1: {}
cssesc@3.0.0: {}
@@ -11455,6 +11485,17 @@ snapshots:
dotenv@17.2.4: {}
+ drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
+ optionalDependencies:
+ '@opentelemetry/api': 1.9.0
+ '@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
+ '@types/pg': 8.16.0
+ better-sqlite3: 12.6.2
+ kysely: 0.28.10
+ pg: 8.17.2
+ postgres: 3.4.8
+ prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
+
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
optionalDependencies:
'@opentelemetry/api': 1.9.0
@@ -11465,6 +11506,7 @@ snapshots:
pg: 8.17.2
postgres: 3.4.8
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
+ optional: true
dunder-proto@1.0.1:
dependencies:
@@ -13167,6 +13209,8 @@ snapshots:
queue-microtask@1.2.3: {}
+ raf-schd@4.0.3: {}
+
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1