diff --git a/docs/superpowers/plans/2026-09-16-phase-1b-design-system-and-board.md b/docs/superpowers/plans/2026-09-16-phase-1b-design-system-and-board.md
new file mode 100644
index 0000000..d24130c
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-16-phase-1b-design-system-and-board.md
@@ -0,0 +1,2173 @@
+# Phase 1b — Design System and Board Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build the themed design system, the application shell, and a working Kanban board that the owner can drag cards around and a client manager can read and set priorities on.
+
+**Architecture:** Semantic CSS custom properties defined once per theme and exposed to Tailwind 4 through `@theme inline`, so no component ever hardcodes a colour. A server component loads a project's board using the signed-in user's own PocketBase token, which means the read rules from Plan 1a are the real boundary rather than a UI convention. Drag and drop is `@dnd-kit`, with the position arithmetic extracted into a pure, unit-tested module. The owner writes card moves straight to PocketBase; a client manager's edits go through the `/api/client/*` routes built in Plan 1a.
+
+**Tech Stack:** Next.js 16 · React 19 · Tailwind 4 · Radix primitives · @dnd-kit · PocketBase realtime · vitest
+
+**Spec:** `docs/superpowers/specs/2026-09-16-client-board-phase-1-design.md` §8
+
+**Prerequisite:** Plan 1a complete. `npm test` and `npm run test:integration` pass, and `npm run seed` has been run.
+
+---
+
+## Design direction
+
+The visual language comes from two references the developer chose: a dark-teal dashboard with a floating white panel and one warm coral accent, and a light analytics dashboard with a KPI row, status pills and inline progress bars.
+
+**What is taken from them:**
+
+- **A tinted page behind a raised, floating panel.** This is the single most valuable idea in either reference. It is what stops the product reading as an admin panel.
+- **One warm accent against a cool neutral field.** It works in the reference *because it appears once*. Here it is reserved for exactly three things: an overdue target date, the Inbox count badge, and critical priority. If it starts appearing on buttons and headings it stops meaning anything.
+- **Pills and inline progress bars** for status, from the second reference.
+
+**What is deliberately not taken:**
+
+- **The hero illustration.** It consumes roughly a quarter of the most valuable space on the page and carries no information. A client opens this to find out whether work is progressing; that space owes them data.
+- **The reference's contrast level.** Pale grey text on white photographs well and disappears on a mid-range monitor. Every text token below is checked to at least 4.5:1 against the surface it sits on.
+- **The reference's padding.** Both references are low-density dashboards. A six-column board is dense by nature — at the reference's spacing you would fit two and a half columns on a laptop. The board gets its own tighter scale, defined in Task 2.
+
+**Density targets for the board:** column width 288px, gutter 12px, card tile padding 12px, tile title 13px/1.45. At 1440px this shows five columns with the sixth reachable by scroll.
+
+---
+
+## File structure
+
+```
+src/
+├── app/
+│ ├── globals.css # REWRITE — token system
+│ ├── layout.tsx # REWRITE — font, theme, no-flash
+│ ├── page.tsx # REWRITE — role-aware home
+│ └── p/[clientSlug]/[projectSlug]/
+│ └── page.tsx # board route (server component)
+├── components/
+│ ├── theme/
+│ │ ├── theme-script.tsx # inline no-flash script
+│ │ └── theme-toggle.tsx # light / dark / system
+│ ├── shell/
+│ │ ├── app-shell.tsx # page background + floating panel
+│ │ ├── sidebar.tsx # icon rail with labels
+│ │ └── top-bar.tsx # title, project switcher, account
+│ ├── board/
+│ │ ├── board.tsx # dnd context + optimistic state
+│ │ ├── board-column.tsx
+│ │ ├── card-tile.tsx
+│ │ ├── client-card-controls.tsx # priority + date, client only
+│ │ └── use-board-realtime.ts
+│ └── ui/
+│ ├── pill.tsx
+│ ├── icon-button.tsx
+│ └── dropdown.tsx # thin Radix wrapper
+└── lib/
+ ├── board/
+ │ ├── move.ts # pure drop-position arithmetic
+ │ ├── move.test.ts
+ │ ├── dates.ts # overdue / due-soon classification
+ │ └── dates.test.ts
+ └── data/
+ └── board.ts # server-side board loader
+```
+
+**On shadcn/ui.** The spec names shadcn/ui, and these components follow its structure exactly — Radix primitives wrapped in local, editable components under `src/components/ui/`. They are written by hand rather than generated by `npx shadcn add`, for two reasons: the generator's output is styled for its own default token names and would have to be rewritten against the tokens in Task 1 anyway, and its init step prompts interactively, which an agent cannot answer. The result is the same architecture without the rework.
+
+---
+
+## Task 1: Theme tokens
+
+Every colour in the application is defined here once. No component may hardcode a hex value or a Tailwind palette colour such as `bg-neutral-900` — if a needed colour is missing, add a token rather than reaching past the system.
+
+**Files:**
+- Modify: `src/app/globals.css` (replaces the Plan 1a placeholder entirely)
+
+- [ ] **Step 1: Write the token system**
+
+`src/app/globals.css`:
+
+```css
+@import "tailwindcss";
+
+/*
+ * Dark mode is class-based, not media-based, because the user picks it
+ * explicitly and the choice persists. See components/theme/theme-script.tsx.
+ */
+@custom-variant dark (&:where(.dark, .dark *));
+
+/*
+ * Semantic tokens. Named for the job they do, never for the colour they are:
+ * `--surface` survives a palette change, `--grey-100` does not.
+ *
+ * Every text token below clears 4.5:1 against the surface it is used on, in
+ * both themes. If you add one, check it — the references this design borrows
+ * from both fail that bar, and pale-grey-on-white is exactly how a board
+ * becomes unreadable on a client's mid-range laptop.
+ */
+:root {
+ /* Structure: a tinted page with a raised panel floating on it. */
+ --page: #e9ecf1;
+ --panel: #ffffff;
+ --surface: #f6f7f9;
+ --surface-raised: #eef0f4;
+
+ --border-subtle: #e2e5ea;
+ --border-strong: #cbd1d9;
+
+ --text-primary: #101319;
+ --text-secondary: #4a5260;
+ --text-muted: #6b7382;
+
+ /* Cool brand blue: primary actions, links, selection. */
+ --brand: #2f6dad;
+ --brand-hover: #275d95;
+ --brand-fg: #ffffff;
+ --brand-soft: #e7eff8;
+
+ /*
+ * The warm accent. Reserved for exactly three things: an overdue date, the
+ * Inbox count badge, and critical priority. It reads as urgent only while
+ * it stays rare.
+ */
+ --accent: #c94a24;
+ --accent-fg: #ffffff;
+ --accent-soft: #fbeae3;
+
+ --priority-critical: #c94a24;
+ --priority-high: #a8660a;
+ --priority-medium: #2f6dad;
+ --priority-low: #6b7382;
+
+ --shadow-panel: 0 1px 2px rgba(16, 19, 25, 0.04),
+ 0 16px 40px -12px rgba(16, 19, 25, 0.16);
+ --shadow-tile: 0 1px 2px rgba(16, 19, 25, 0.06);
+ --shadow-drag: 0 12px 28px -6px rgba(16, 19, 25, 0.28);
+}
+
+.dark {
+ --page: #08090b;
+ --panel: #131519;
+ --surface: #1a1d22;
+ --surface-raised: #22262d;
+
+ --border-subtle: #262a31;
+ --border-strong: #383d47;
+
+ --text-primary: #f1f3f6;
+ --text-secondary: #aab2bf;
+ --text-muted: #838c9b;
+
+ --brand: #5e9ee0;
+ --brand-hover: #7cb0e8;
+ --brand-fg: #08090b;
+ --brand-soft: #16263a;
+
+ --accent: #ff8a5c;
+ --accent-fg: #1a0e08;
+ --accent-soft: #2e1a12;
+
+ --priority-critical: #ff8a5c;
+ --priority-high: #e0a23c;
+ --priority-medium: #5e9ee0;
+ --priority-low: #838c9b;
+
+ --shadow-panel: 0 1px 2px rgba(0, 0, 0, 0.4),
+ 0 16px 40px -12px rgba(0, 0, 0, 0.6);
+ --shadow-tile: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --shadow-drag: 0 12px 28px -6px rgba(0, 0, 0, 0.7);
+}
+
+/*
+ * Expose the tokens to Tailwind. `@theme inline` keeps the var() reference
+ * live, so `bg-surface` resolves at runtime and follows the theme class.
+ * Without `inline`, Tailwind would bake in the :root value and dark mode
+ * would silently do nothing.
+ */
+@theme inline {
+ --color-page: var(--page);
+ --color-panel: var(--panel);
+ --color-surface: var(--surface);
+ --color-surface-raised: var(--surface-raised);
+
+ --color-border-subtle: var(--border-subtle);
+ --color-border-strong: var(--border-strong);
+
+ --color-text-primary: var(--text-primary);
+ --color-text-secondary: var(--text-secondary);
+ --color-text-muted: var(--text-muted);
+
+ --color-brand: var(--brand);
+ --color-brand-hover: var(--brand-hover);
+ --color-brand-fg: var(--brand-fg);
+ --color-brand-soft: var(--brand-soft);
+
+ --color-accent: var(--accent);
+ --color-accent-fg: var(--accent-fg);
+ --color-accent-soft: var(--accent-soft);
+
+ --color-priority-critical: var(--priority-critical);
+ --color-priority-high: var(--priority-high);
+ --color-priority-medium: var(--priority-medium);
+ --color-priority-low: var(--priority-low);
+
+ --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
+
+ --radius-tile: 10px;
+ --radius-panel: 18px;
+}
+
+* {
+ border-color: var(--border-subtle);
+}
+
+body {
+ background: var(--page);
+ color: var(--text-primary);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+}
+
+/*
+ * Numbers in stats, dates and counts must not jitter as they change.
+ * Applied wherever a figure updates in place.
+ */
+.tabular {
+ font-variant-numeric: tabular-nums;
+ font-feature-settings: "tnum";
+}
+
+/* A consistent focus ring, since every interactive element needs one and
+ Tailwind's default differs per element. */
+.focus-ring {
+ outline: 2px solid var(--brand);
+ outline-offset: 2px;
+}
+
+:focus-visible {
+ outline: 2px solid var(--brand);
+ outline-offset: 2px;
+}
+```
+
+- [ ] **Step 2: Verify the build compiles the tokens**
+
+```bash
+npm run build
+```
+
+Expected: build succeeds. A Tailwind error naming an unknown utility means a token is referenced before it is declared in `@theme inline`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/app/globals.css
+git commit -m "feat(theme): semantic token system for light and dark"
+```
+
+---
+
+## Task 2: Theme switching without a flash
+
+**Files:**
+- Create: `src/components/theme/theme-script.tsx`
+- Create: `src/components/theme/theme-toggle.tsx`
+- Modify: `src/app/layout.tsx`
+
+- [ ] **Step 1: Create the no-flash script**
+
+`src/components/theme/theme-script.tsx`:
+
+```tsx
+/**
+ * Applies the stored theme before first paint.
+ *
+ * This has to be a blocking inline script in
. A React effect runs after
+ * hydration, by which point the browser has already painted the default theme
+ * and the user sees a white flash on every navigation to a dark board. There
+ * is no way to fix that from inside React.
+ */
+const SCRIPT = `
+(function () {
+ try {
+ var stored = localStorage.getItem('theme');
+ var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
+ var dark = stored === 'dark' || (stored !== 'light' && prefersDark);
+ document.documentElement.classList.toggle('dark', dark);
+ document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
+ } catch (e) {}
+})();
+`;
+
+export function ThemeScript() {
+ return ;
+}
+```
+
+- [ ] **Step 2: Create the toggle**
+
+`src/components/theme/theme-toggle.tsx`:
+
+```tsx
+"use client";
+
+import { useEffect, useState } from "react";
+
+type Theme = "light" | "dark" | "system";
+
+function apply(theme: Theme) {
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+ const dark = theme === "dark" || (theme === "system" && prefersDark);
+
+ document.documentElement.classList.toggle("dark", dark);
+ document.documentElement.style.colorScheme = dark ? "dark" : "light";
+
+ if (theme === "system") localStorage.removeItem("theme");
+ else localStorage.setItem("theme", theme);
+}
+
+const OPTIONS: Array<{ value: Theme; label: string }> = [
+ { value: "light", label: "Light" },
+ { value: "system", label: "Auto" },
+ { value: "dark", label: "Dark" },
+];
+
+export function ThemeToggle() {
+ const [theme, setTheme] = useState("system");
+
+ useEffect(() => {
+ const stored = localStorage.getItem("theme");
+ if (stored === "light" || stored === "dark") setTheme(stored);
+ }, []);
+
+ function choose(next: Theme) {
+ setTheme(next);
+ apply(next);
+ }
+
+ return (
+
+ {OPTIONS.map((option) => (
+
+ ))}
+
+ );
+}
+```
+
+- [ ] **Step 3: Rewrite the root layout**
+
+`src/app/layout.tsx`:
+
+```tsx
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+
+import { ThemeScript } from "@/components/theme/theme-script";
+import "./globals.css";
+
+const inter = Inter({
+ subsets: ["latin"],
+ variable: "--font-inter",
+ display: "swap",
+});
+
+export const metadata: Metadata = {
+ title: "Client Board",
+ description: "Project tracking for client work",
+};
+
+export default function RootLayout({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ return (
+ // suppressHydrationWarning: ThemeScript mutates the class on before
+ // React hydrates, so server and client markup differ here by design.
+
+
+
+
+ {children}
+
+ );
+}
+```
+
+- [ ] **Step 4: Verify manually**
+
+Run `npm run dev` and load `http://localhost:3000/login`.
+
+Expected: the page respects the OS theme on first load with no flash of the wrong one. Set `localStorage.setItem('theme','dark')` in the console, hard-reload, and confirm it loads dark immediately rather than flashing light first.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/theme src/app/layout.tsx
+git commit -m "feat(theme): light/dark/system toggle with no first-paint flash"
+```
+
+---
+
+## Task 3: UI primitives
+
+Three small components used throughout. Each does one thing.
+
+**Files:**
+- Create: `src/components/ui/pill.tsx`
+- Create: `src/components/ui/icon-button.tsx`
+- Create: `src/components/ui/dropdown.tsx`
+
+- [ ] **Step 1: Install the Radix primitive**
+
+```bash
+npm install @radix-ui/react-dropdown-menu lucide-react clsx tailwind-merge
+```
+
+- [ ] **Step 2: Create the class-merging helper and Pill**
+
+`src/lib/cn.ts`:
+
+```ts
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+/** Merge class names, letting later Tailwind utilities win over earlier ones. */
+export function cn(...inputs: ClassValue[]): string {
+ return twMerge(clsx(inputs));
+}
+```
+
+`src/components/ui/pill.tsx`:
+
+```tsx
+import { cn } from "@/lib/cn";
+
+type Tone = "neutral" | "brand" | "accent" | "critical" | "high" | "medium" | "low";
+
+const TONES: Record = {
+ neutral: "bg-surface-raised text-text-secondary",
+ brand: "bg-brand-soft text-brand",
+ accent: "bg-accent-soft text-accent",
+ critical: "bg-accent-soft text-priority-critical",
+ high: "bg-surface-raised text-priority-high",
+ medium: "bg-brand-soft text-priority-medium",
+ low: "bg-surface-raised text-priority-low",
+};
+
+export function Pill({
+ tone = "neutral",
+ children,
+ className,
+}: {
+ tone?: Tone;
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+```
+
+- [ ] **Step 3: Create IconButton and Dropdown**
+
+`src/components/ui/icon-button.tsx`:
+
+```tsx
+import { cn } from "@/lib/cn";
+
+export function IconButton({
+ label,
+ children,
+ className,
+ ...props
+}: React.ButtonHTMLAttributes & {
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+```
+
+`src/components/ui/dropdown.tsx`:
+
+```tsx
+"use client";
+
+import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
+
+import { cn } from "@/lib/cn";
+
+export const Dropdown = DropdownMenu.Root;
+export const DropdownTrigger = DropdownMenu.Trigger;
+
+export function DropdownContent({
+ children,
+ align = "start",
+ className,
+}: {
+ children: React.ReactNode;
+ align?: "start" | "center" | "end";
+ className?: string;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function DropdownItem({
+ children,
+ onSelect,
+ active,
+}: {
+ children: React.ReactNode;
+ onSelect?: () => void;
+ active?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function DropdownLabel({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+```
+
+- [ ] **Step 4: Verify the build**
+
+```bash
+npm run build
+```
+
+Expected: build succeeds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/ui src/lib/cn.ts package.json package-lock.json
+git commit -m "feat(ui): pill, icon button and dropdown primitives"
+```
+
+---
+
+## Task 4: Date classification
+
+Pure logic, test-first. Drives the one place the warm accent is allowed to appear on a card.
+
+**Files:**
+- Create: `src/lib/board/dates.ts`
+- Test: `src/lib/board/dates.test.ts`
+
+- [ ] **Step 1: Write the failing tests**
+
+`src/lib/board/dates.test.ts`:
+
+```ts
+import { describe, it, expect } from "vitest";
+import { classifyDueDate, formatDueDate } from "@/lib/board/dates";
+
+const today = new Date("2026-09-16T10:00:00Z");
+
+describe("classifyDueDate", () => {
+ it("returns none for an unset date", () => {
+ expect(classifyDueDate("", today)).toBe("none");
+ expect(classifyDueDate("2026-09-16 00:00:00.000Z", today)).toBe("today");
+ });
+
+ it("returns overdue for a past date", () => {
+ expect(classifyDueDate("2026-09-15", today)).toBe("overdue");
+ });
+
+ it("returns today for the current date", () => {
+ expect(classifyDueDate("2026-09-16", today)).toBe("today");
+ });
+
+ it("returns soon for within three days", () => {
+ expect(classifyDueDate("2026-09-17", today)).toBe("soon");
+ expect(classifyDueDate("2026-09-19", today)).toBe("soon");
+ });
+
+ it("returns later beyond three days", () => {
+ expect(classifyDueDate("2026-09-20", today)).toBe("later");
+ });
+
+ it("compares whole days, ignoring the time of day", () => {
+ // A date due "today" must not flip to overdue at 00:01 simply because the
+ // stored timestamp is midnight and the clock has moved past it.
+ const lateInDay = new Date("2026-09-16T23:59:00Z");
+ expect(classifyDueDate("2026-09-16 00:00:00.000Z", lateInDay)).toBe("today");
+ });
+});
+
+describe("formatDueDate", () => {
+ it("returns an empty string for an unset date", () => {
+ expect(formatDueDate("", today)).toBe("");
+ });
+
+ it("writes today and tomorrow in words", () => {
+ expect(formatDueDate("2026-09-16", today)).toBe("Today");
+ expect(formatDueDate("2026-09-17", today)).toBe("Tomorrow");
+ });
+
+ it("writes a nearby date as a short day and month", () => {
+ expect(formatDueDate("2026-09-24", today)).toBe("24 Sep");
+ });
+
+ it("includes the year when the date is in another year", () => {
+ expect(formatDueDate("2027-02-03", today)).toBe("3 Feb 2027");
+ });
+});
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+npm test
+```
+
+Expected: FAIL — `Failed to resolve import "@/lib/board/dates"`.
+
+- [ ] **Step 3: Write the implementation**
+
+`src/lib/board/dates.ts`:
+
+```ts
+/**
+ * Target date classification and formatting.
+ *
+ * Comparisons are whole-day, in UTC. PocketBase stores a date field as
+ * "2026-09-16 00:00:00.000Z", so a naive timestamp comparison marks anything
+ * due today as overdue the moment the clock passes midnight — the board would
+ * turn red every morning for cards that are perfectly on time.
+ */
+
+export type DueClass = "none" | "overdue" | "today" | "soon" | "later";
+
+/** Whole days between two dates, positive when `date` is in the future. */
+function dayDelta(date: string, now: Date): number {
+ const target = Date.UTC(
+ Number(date.slice(0, 4)),
+ Number(date.slice(5, 7)) - 1,
+ Number(date.slice(8, 10))
+ );
+ const reference = Date.UTC(
+ now.getUTCFullYear(),
+ now.getUTCMonth(),
+ now.getUTCDate()
+ );
+ return Math.round((target - reference) / 86_400_000);
+}
+
+export function classifyDueDate(
+ targetDate: string,
+ now: Date = new Date()
+): DueClass {
+ if (!targetDate) return "none";
+
+ const delta = dayDelta(targetDate, now);
+ if (delta < 0) return "overdue";
+ if (delta === 0) return "today";
+ if (delta <= 3) return "soon";
+ return "later";
+}
+
+const MONTHS = [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+];
+
+export function formatDueDate(
+ targetDate: string,
+ now: Date = new Date()
+): string {
+ if (!targetDate) return "";
+
+ const delta = dayDelta(targetDate, now);
+ if (delta === 0) return "Today";
+ if (delta === 1) return "Tomorrow";
+
+ const year = Number(targetDate.slice(0, 4));
+ const month = MONTHS[Number(targetDate.slice(5, 7)) - 1];
+ const day = Number(targetDate.slice(8, 10));
+
+ if (year !== now.getUTCFullYear()) return `${day} ${month} ${year}`;
+ return `${day} ${month}`;
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+npm test
+```
+
+Expected: PASS — 10 tests in `dates.test.ts`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/board
+git commit -m "feat(board): whole-day due date classification and formatting"
+```
+
+---
+
+## Task 5: Drop position arithmetic
+
+The subtle part of drag and drop, extracted so it can be tested without a browser.
+
+**Files:**
+- Create: `src/lib/board/move.ts`
+- Test: `src/lib/board/move.test.ts`
+
+- [ ] **Step 1: Write the failing tests**
+
+`src/lib/board/move.test.ts`:
+
+```ts
+import { describe, it, expect } from "vitest";
+import { computeDropPosition, type MovableCard } from "@/lib/board/move";
+
+const column: MovableCard[] = [
+ { id: "a", position: 1000 },
+ { id: "b", position: 2000 },
+ { id: "c", position: 3000 },
+];
+
+describe("computeDropPosition — moving into a different column", () => {
+ it("drops at the top", () => {
+ expect(computeDropPosition(column, 0, null)).toBe(0);
+ });
+
+ it("drops in the middle", () => {
+ expect(computeDropPosition(column, 1, null)).toBe(1500);
+ });
+
+ it("drops at the bottom", () => {
+ expect(computeDropPosition(column, 3, null)).toBe(4000);
+ });
+
+ it("drops into an empty column", () => {
+ expect(computeDropPosition([], 0, null)).toBe(1000);
+ });
+});
+
+describe("computeDropPosition — reordering within the same column", () => {
+ it("excludes the dragged card from its own neighbours", () => {
+ // Moving "a" to the end. If "a" were still counted, the last neighbour
+ // would be "c" at 3000 and the answer 4000 — but "a" leaving the top does
+ // not change that. The real trap is the middle case below.
+ expect(computeDropPosition(column, 3, "a")).toBe(4000);
+ });
+
+ it("computes the midpoint of the remaining cards, not including itself", () => {
+ // Moving "c" between "a" and "b". With "c" removed the neighbours are
+ // 1000 and 2000, so the answer is 1500. If "c" were left in the list the
+ // index would point at the wrong pair and the card would land back where
+ // it started.
+ expect(computeDropPosition(column, 1, "c")).toBe(1500);
+ });
+
+ it("moves the first card to the second slot", () => {
+ // Remaining after removing "a": [b=2000, c=3000]. Index 1 sits between
+ // them, so 2500.
+ expect(computeDropPosition(column, 1, "a")).toBe(2500);
+ });
+
+ it("returns the card's own position when dropped where it already is", () => {
+ expect(computeDropPosition(column, 1, "b")).toBe(2000);
+ });
+});
+
+describe("computeDropPosition — guards", () => {
+ it("clamps an index beyond the end", () => {
+ expect(computeDropPosition(column, 99, null)).toBe(4000);
+ });
+
+ it("clamps a negative index", () => {
+ expect(computeDropPosition(column, -5, null)).toBe(0);
+ });
+
+ it("tolerates an unsorted input list", () => {
+ const shuffled: MovableCard[] = [
+ { id: "c", position: 3000 },
+ { id: "a", position: 1000 },
+ { id: "b", position: 2000 },
+ ];
+ expect(computeDropPosition(shuffled, 1, null)).toBe(1500);
+ });
+});
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+npm test
+```
+
+Expected: FAIL — `Failed to resolve import "@/lib/board/move"`.
+
+- [ ] **Step 3: Write the implementation**
+
+`src/lib/board/move.ts`:
+
+```ts
+/**
+ * Where a dragged card lands.
+ *
+ * The one thing worth being careful about: when a card is reordered inside
+ * its own column, it must be removed from the list before its neighbours are
+ * read. Leave it in and the index points at the wrong pair, and the card
+ * snaps back to roughly where it started — a bug that looks like the drag
+ * "not working" rather than like arithmetic.
+ */
+
+import { positionBetween } from "@/lib/ordering/fractional";
+
+export interface MovableCard {
+ id: string;
+ position: number;
+}
+
+/**
+ * @param destinationCards every card currently in the destination column
+ * @param dropIndex the slot the card is being dropped into, 0-based
+ * @param draggedCardId the dragged card's id when reordering within the
+ * same column; null when it comes from another column
+ */
+export function computeDropPosition(
+ destinationCards: MovableCard[],
+ dropIndex: number,
+ draggedCardId: string | null
+): number {
+ const ordered = [...destinationCards]
+ .filter((card) => card.id !== draggedCardId)
+ .sort((a, b) => a.position - b.position);
+
+ const index = Math.max(0, Math.min(dropIndex, ordered.length));
+
+ const before = index > 0 ? ordered[index - 1].position : null;
+ const after = index < ordered.length ? ordered[index].position : null;
+
+ return positionBetween(before, after);
+}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+npm test
+```
+
+Expected: PASS — 11 tests in `move.test.ts`.
+
+Note the `computeDropPosition(column, 0, null)` case returns 0, because `positionBetween(null, 1000)` is `1000 - SPACING`. Positions are allowed to go negative; only their order matters.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/board/move.ts src/lib/board/move.test.ts
+git commit -m "feat(board): drop position arithmetic with same-column handling"
+```
+
+---
+
+## Task 6: Board data loader
+
+**Files:**
+- Create: `src/lib/data/board.ts`
+
+- [ ] **Step 1: Write the loader**
+
+`src/lib/data/board.ts`:
+
+```ts
+import "server-only";
+
+import { getServerPb } from "@/lib/pb/server";
+import type {
+ CardRecord,
+ ClientRecord,
+ ColumnRecord,
+ ProjectRecord,
+} from "@/lib/pb/types";
+
+export interface BoardData {
+ client: ClientRecord;
+ project: ProjectRecord;
+ columns: ColumnRecord[];
+ cards: CardRecord[];
+}
+
+export interface ProjectSummary {
+ project: ProjectRecord;
+ client: ClientRecord;
+ openCards: number;
+ inboxCards: number;
+}
+
+/**
+ * Everything one board needs, loaded with the signed-in user's own PocketBase
+ * token.
+ *
+ * Using the user's token rather than the superuser's is the point: the read
+ * rules from Plan 1a do the scoping, so a client manager requesting another
+ * client's board gets nothing back from the database itself. The UI is not
+ * what keeps them out.
+ *
+ * Returns null when the project does not exist OR the user may not see it.
+ * Callers render a 404 for both — distinguishing them would confirm that
+ * another client's project is real.
+ */
+export async function loadBoard(
+ clientSlug: string,
+ projectSlug: string
+): Promise {
+ const pb = await getServerPb();
+ if (!pb.authStore.isValid) return null;
+
+ try {
+ const client = await pb
+ .collection("clients")
+ .getFirstListItem(`slug = "${clientSlug}"`);
+
+ const project = await pb
+ .collection("projects")
+ .getFirstListItem(
+ `slug = "${projectSlug}" && client = "${client.id}"`
+ );
+
+ const columns = await pb
+ .collection("columns")
+ .getFullList({
+ filter: `project = "${project.id}"`,
+ sort: "position",
+ });
+
+ const cards = await pb.collection("cards").getFullList({
+ filter: `project = "${project.id}" && archived = false`,
+ sort: "position",
+ });
+
+ return { client, project, columns, cards };
+ } catch {
+ return null;
+ }
+}
+
+/** Every project the signed-in user may see, with counts for the home page. */
+export async function loadProjectSummaries(): Promise {
+ const pb = await getServerPb();
+ if (!pb.authStore.isValid) return [];
+
+ const projects = await pb.collection("projects").getFullList({
+ filter: 'status != "archived"',
+ sort: "name",
+ });
+
+ if (projects.length === 0) return [];
+
+ const clients = await pb.collection("clients").getFullList();
+ const clientsById = new Map(clients.map((c) => [c.id, c]));
+
+ const summaries: ProjectSummary[] = [];
+
+ for (const project of projects) {
+ const client = clientsById.get(project.client);
+ if (!client) continue;
+
+ const inbox = await pb
+ .collection("columns")
+ .getFirstListItem(
+ `project = "${project.id}" && is_inbox = true`
+ )
+ .catch(() => null);
+
+ const done = await pb
+ .collection("columns")
+ .getFirstListItem(
+ `project = "${project.id}" && is_done = true`
+ )
+ .catch(() => null);
+
+ const openFilter = done
+ ? `project = "${project.id}" && archived = false && column != "${done.id}"`
+ : `project = "${project.id}" && archived = false`;
+
+ const open = await pb
+ .collection("cards")
+ .getList(1, 1, { filter: openFilter, fields: "id" });
+
+ const inboxCount = inbox
+ ? await pb.collection("cards").getList(1, 1, {
+ filter: `column = "${inbox.id}" && archived = false`,
+ fields: "id",
+ })
+ : null;
+
+ summaries.push({
+ project,
+ client,
+ openCards: open.totalItems,
+ inboxCards: inboxCount?.totalItems ?? 0,
+ });
+ }
+
+ return summaries;
+}
+```
+
+- [ ] **Step 2: Verify the build**
+
+```bash
+npm run build
+```
+
+Expected: build succeeds.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/lib/data
+git commit -m "feat(data): board and project summary loaders scoped by read rules"
+```
+
+---
+
+## Task 7: Application shell
+
+Implements the reference's structure: a tinted page with a raised panel floating on it.
+
+**Files:**
+- Create: `src/components/shell/sidebar.tsx`
+- Create: `src/components/shell/top-bar.tsx`
+- Create: `src/components/shell/app-shell.tsx`
+
+- [ ] **Step 1: Create the sidebar**
+
+`src/components/shell/sidebar.tsx`:
+
+```tsx
+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 (
+
+ );
+}
+```
+
+- [ ] **Step 2: Create the top bar**
+
+`src/components/shell/top-bar.tsx`:
+
+```tsx
+"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 (
+
+
+
+ {title}
+
+ {subtitle && (
+
{subtitle}
+ )}
+
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 3: Create the shell**
+
+`src/components/shell/app-shell.tsx`:
+
+```tsx
+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}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Verify the build**
+
+```bash
+npm run build
+```
+
+Expected: build succeeds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/shell
+git commit -m "feat(shell): floating panel layout with labelled icon rail"
+```
+
+---
+
+## Task 8: Home page
+
+**Files:**
+- Modify: `src/app/page.tsx` (replaces the Plan 1a placeholder)
+
+- [ ] **Step 1: Write the home page**
+
+`src/app/page.tsx`:
+
+```tsx
+import Link from "next/link";
+import { redirect } from "next/navigation";
+import { Inbox } from "lucide-react";
+
+import { AppShell } from "@/components/shell/app-shell";
+import { TopBar } from "@/components/shell/top-bar";
+import { Pill } from "@/components/ui/pill";
+import { getSession } from "@/lib/auth/session";
+import { loadProjectSummaries } from "@/lib/data/board";
+
+export default async function HomePage() {
+ const session = await getSession();
+ if (!session) redirect("/login");
+
+ const summaries = await loadProjectSummaries();
+
+ return (
+
+
+
+
+ {summaries.length === 0 ? (
+
+
No projects yet
+
+ {session.role === "owner"
+ ? "Create one from Manage, or run npm run seed for demo data."
+ : "Nothing has been shared with you yet."}
+
+ );
+}
+```
+
+- [ ] **Step 3: Verify manually as the owner**
+
+Sign in as `owner@example.com`, open the WFDash board, and drag a card between columns.
+
+Expected: the card follows the pointer with a raised shadow, lands where dropped, and stays there after a reload. Drag a card to the middle of a populated column and confirm it lands between its neighbours rather than snapping to the end.
+
+- [ ] **Step 4: Verify manually as a client manager**
+
+Sign in as `manager@wf.example.com` and open the same board.
+
+Expected: cards do not drag at all, and the cursor stays a normal pointer.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/board/board.tsx package.json package-lock.json
+git commit -m "feat(board): owner drag and drop with optimistic persistence"
+```
+
+---
+
+## Task 11: Client priority and date controls
+
+The client manager's two editable fields, posting to the routes built in Plan 1a.
+
+**Files:**
+- Create: `src/components/board/client-card-controls.tsx`
+
+- [ ] **Step 1: Write the controls**
+
+`src/components/board/client-card-controls.tsx`:
+
+```tsx
+"use client";
+
+import { useState } from "react";
+
+import {
+ Dropdown,
+ DropdownContent,
+ DropdownItem,
+ DropdownLabel,
+ DropdownTrigger,
+} from "@/components/ui/dropdown";
+import type { CardRecord, Priority } from "@/lib/pb/types";
+
+const PRIORITIES: Array<{ value: Priority; label: string }> = [
+ { value: "critical", label: "Critical" },
+ { value: "high", label: "High" },
+ { value: "medium", label: "Medium" },
+ { value: "low", label: "Low" },
+];
+
+/**
+ * The only two fields a client manager may change.
+ *
+ * Both go through PATCH /api/client/cards/:id, never straight to PocketBase.
+ * Their token cannot write to the cards collection at all — see Plan 1a — so
+ * a direct SDK call from here would simply fail. The route is the path.
+ */
+export function ClientCardControls({
+ card,
+ onPatched,
+}: {
+ card: CardRecord;
+ onPatched: (card: CardRecord) => void;
+}) {
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function patch(body: Record) {
+ setSaving(true);
+ setError(null);
+
+ const previous = { ...card };
+ onPatched({ ...card, ...body } as CardRecord);
+
+ const response = await fetch(`/api/client/cards/${card.id}`, {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ setSaving(false);
+
+ if (!response.ok) {
+ onPatched(previous);
+ setError("Could not save");
+ return;
+ }
+
+ const { card: updated } = await response.json();
+ onPatched({ ...card, ...updated } as CardRecord);
+ }
+
+ const currentPriority =
+ PRIORITIES.find((p) => p.value === card.priority) ?? PRIORITIES[2];
+
+ return (
+