70 KiB
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:
@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
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
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:
/**
* Applies the stored theme before first paint.
*
* This has to be a blocking inline script in <head>. 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 <script dangerouslySetInnerHTML={{ __html: SCRIPT }} />;
}
- Step 2: Create the toggle
src/components/theme/theme-toggle.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<Theme>("system");
useEffect(() => {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") setTheme(stored);
}, []);
function choose(next: Theme) {
setTheme(next);
apply(next);
}
return (
<div
role="radiogroup"
aria-label="Colour theme"
className="inline-flex rounded-lg border border-border-subtle bg-surface p-0.5"
>
{OPTIONS.map((option) => (
<button
key={option.value}
role="radio"
aria-checked={theme === option.value}
onClick={() => choose(option.value)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
theme === option.value
? "bg-panel text-text-primary shadow-[var(--shadow-tile)]"
: "text-text-muted hover:text-text-secondary"
}`}
>
{option.label}
</button>
))}
</div>
);
}
- Step 3: Rewrite the root layout
src/app/layout.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 <html> before
// React hydrates, so server and client markup differ here by design.
<html lang="en" className={inter.variable} suppressHydrationWarning>
<head>
<ThemeScript />
</head>
<body>{children}</body>
</html>
);
}
- 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
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
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:
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:
import { cn } from "@/lib/cn";
type Tone = "neutral" | "brand" | "accent" | "critical" | "high" | "medium" | "low";
const TONES: Record<Tone, string> = {
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 (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none",
TONES[tone],
className
)}
>
{children}
</span>
);
}
- Step 3: Create IconButton and Dropdown
src/components/ui/icon-button.tsx:
import { cn } from "@/lib/cn";
export function IconButton({
label,
children,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
label: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-lg text-text-muted",
"transition-colors hover:bg-surface-raised hover:text-text-primary",
className
)}
{...props}
>
{children}
</button>
);
}
src/components/ui/dropdown.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 (
<DropdownMenu.Portal>
<DropdownMenu.Content
align={align}
sideOffset={6}
className={cn(
"z-50 min-w-56 rounded-xl border border-border-subtle bg-panel p-1",
"shadow-[var(--shadow-panel)]",
className
)}
>
{children}
</DropdownMenu.Content>
</DropdownMenu.Portal>
);
}
export function DropdownItem({
children,
onSelect,
active,
}: {
children: React.ReactNode;
onSelect?: () => void;
active?: boolean;
}) {
return (
<DropdownMenu.Item
onSelect={onSelect}
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm outline-none",
"data-[highlighted]:bg-surface-raised",
active ? "text-brand" : "text-text-secondary"
)}
>
{children}
</DropdownMenu.Item>
);
}
export function DropdownLabel({ children }: { children: React.ReactNode }) {
return (
<DropdownMenu.Label className="px-2.5 py-1.5 text-[11px] font-medium uppercase tracking-wide text-text-muted">
{children}
</DropdownMenu.Label>
);
}
- Step 4: Verify the build
npm run build
Expected: build succeeds.
- Step 5: Commit
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:
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
npm test
Expected: FAIL — Failed to resolve import "@/lib/board/dates".
- Step 3: Write the implementation
src/lib/board/dates.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
npm test
Expected: PASS — 10 tests in dates.test.ts.
- Step 5: Commit
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:
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
npm test
Expected: FAIL — Failed to resolve import "@/lib/board/move".
- Step 3: Write the implementation
src/lib/board/move.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
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
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:
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<BoardData | null> {
const pb = await getServerPb();
if (!pb.authStore.isValid) return null;
try {
const client = await pb
.collection("clients")
.getFirstListItem<ClientRecord>(`slug = "${clientSlug}"`);
const project = await pb
.collection("projects")
.getFirstListItem<ProjectRecord>(
`slug = "${projectSlug}" && client = "${client.id}"`
);
const columns = await pb
.collection("columns")
.getFullList<ColumnRecord>({
filter: `project = "${project.id}"`,
sort: "position",
});
const cards = await pb.collection("cards").getFullList<CardRecord>({
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<ProjectSummary[]> {
const pb = await getServerPb();
if (!pb.authStore.isValid) return [];
const projects = await pb.collection("projects").getFullList<ProjectRecord>({
filter: 'status != "archived"',
sort: "name",
});
if (projects.length === 0) return [];
const clients = await pb.collection("clients").getFullList<ClientRecord>();
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<ColumnRecord>(
`project = "${project.id}" && is_inbox = true`
)
.catch(() => null);
const done = await pb
.collection("columns")
.getFirstListItem<ColumnRecord>(
`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
npm run build
Expected: build succeeds.
- Step 3: Commit
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:
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 (
<nav
aria-label="Main"
className="flex w-[76px] shrink-0 flex-col items-center gap-1 py-5"
>
<div className="mb-5 grid h-9 w-9 place-items-center rounded-xl bg-brand text-brand-fg">
<span className="text-sm font-semibold">CB</span>
</div>
{items.map((item) => {
const Icon = item.icon;
const active =
item.href === "/"
? currentPath === "/"
: currentPath.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={cn(
"flex w-[60px] flex-col items-center gap-1 rounded-xl px-1 py-2.5 transition-colors",
active
? "bg-panel text-text-primary shadow-[var(--shadow-tile)]"
: "text-text-muted hover:text-text-secondary"
)}
>
<Icon size={18} strokeWidth={1.75} />
<span className="text-[10px] font-medium leading-none">
{item.label}
</span>
</Link>
);
})}
</nav>
);
}
- Step 2: Create the top bar
src/components/shell/top-bar.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 (
<header className="flex items-center gap-4 border-b border-border-subtle px-6 py-4">
<div className="min-w-0">
<h1 className="truncate text-lg font-semibold tracking-tight">
{title}
</h1>
{subtitle && (
<p className="truncate text-xs text-text-muted">{subtitle}</p>
)}
</div>
<div className="ml-auto flex items-center gap-3">
{children}
<ThemeToggle />
<IconButton label="Sign out" onClick={signOut}>
<LogOut size={16} strokeWidth={1.75} />
</IconButton>
</div>
</header>
);
}
- Step 3: Create the shell
src/components/shell/app-shell.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 (
<div className="flex h-screen overflow-hidden bg-page px-3 pb-3 pt-0">
<Sidebar role={role} currentPath={currentPath} />
<main className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-[var(--radius-panel)] border border-border-subtle bg-panel shadow-[var(--shadow-panel)]">
{children}
</main>
</div>
);
}
- Step 4: Verify the build
npm run build
Expected: build succeeds.
- Step 5: Commit
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:
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 (
<AppShell role={session.role} currentPath="/">
<TopBar
title="Projects"
subtitle={
session.role === "owner"
? "Everything you are building"
: "Your active projects"
}
/>
<div className="flex-1 overflow-y-auto p-6">
{summaries.length === 0 ? (
<div className="mx-auto mt-16 max-w-sm text-center">
<h2 className="text-base font-medium">No projects yet</h2>
<p className="mt-1.5 text-sm text-text-muted">
{session.role === "owner"
? "Create one from Manage, or run npm run seed for demo data."
: "Nothing has been shared with you yet."}
</p>
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{summaries.map(({ project, client, openCards, inboxCards }) => (
<li key={project.id}>
<Link
href={`/p/${client.slug}/${project.slug}`}
className="group block rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4 transition-colors hover:border-border-strong"
>
<div className="flex items-start gap-3">
<span
aria-hidden
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: client.brand_color || "var(--brand)" }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{project.name}
</p>
<p className="truncate text-xs text-text-muted">
{client.name}
</p>
</div>
{inboxCards > 0 && (
<Pill tone="accent">
<Inbox size={11} strokeWidth={2} />
<span className="tabular">{inboxCards}</span>
</Pill>
)}
</div>
<div className="mt-4 flex items-center gap-2">
<span className="tabular text-2xl font-semibold tracking-tight">
{openCards}
</span>
<span className="text-xs text-text-muted">
open {openCards === 1 ? "card" : "cards"}
</span>
</div>
</Link>
</li>
))}
</ul>
)}
</div>
</AppShell>
);
}
- Step 2: Verify manually
With PocketBase, the dev server and seeded data all running, sign in as manager@wf.example.com / manager-password-123.
Expected: the WFDash Web App card appears showing its open-card count. Toggle the theme and confirm every surface, border and text colour follows. Sign in as owner@example.com / owner-password-123 and confirm the sidebar now shows the owner-only links.
- Step 3: Commit
git add src/app/page.tsx
git commit -m "feat(home): role-aware project list with inbox badges"
Task 9: Board columns and card tiles
Static rendering first. Drag and drop is wired in Task 10, so a layout problem and a drag problem never have to be debugged at the same time.
Files:
-
Create:
src/components/board/card-tile.tsx -
Create:
src/components/board/board-column.tsx -
Create:
src/app/p/[clientSlug]/[projectSlug]/page.tsx -
Step 1: Create the card tile
src/components/board/card-tile.tsx:
"use client";
import { forwardRef } from "react";
import { Inbox, CalendarDays } from "lucide-react";
import { Pill } from "@/components/ui/pill";
import { cn } from "@/lib/cn";
import { classifyDueDate, formatDueDate } from "@/lib/board/dates";
import type { CardRecord, Priority } from "@/lib/pb/types";
const PRIORITY_TONE: Record<Priority, "critical" | "high" | "medium" | "low"> = {
critical: "critical",
high: "high",
medium: "medium",
low: "low",
};
const PRIORITY_LABEL: Record<Priority, string> = {
critical: "Critical",
high: "High",
medium: "Medium",
low: "Low",
};
export const CardTile = forwardRef<
HTMLDivElement,
{
card: CardRecord;
dragging?: boolean;
className?: string;
children?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>
>(function CardTile({ card, dragging, className, children, ...props }, ref) {
const due = classifyDueDate(card.target_date);
const dueLabel = formatDueDate(card.target_date);
return (
<div
ref={ref}
className={cn(
"rounded-[var(--radius-tile)] border border-border-subtle bg-panel p-3",
"shadow-[var(--shadow-tile)] transition-shadow",
dragging && "shadow-[var(--shadow-drag)] opacity-90",
className
)}
{...props}
>
<p className="text-[13px] font-medium leading-[1.45] text-text-primary">
{card.title}
</p>
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
<Pill tone={PRIORITY_TONE[card.priority]}>
{PRIORITY_LABEL[card.priority]}
</Pill>
{dueLabel && (
// The warm accent's second sanctioned use: an overdue date. Anything
// not yet late stays neutral, or "late" stops registering.
<Pill tone={due === "overdue" ? "accent" : "neutral"}>
<CalendarDays size={11} strokeWidth={2} />
<span className="tabular">{dueLabel}</span>
</Pill>
)}
{card.created_by_role === "client" && !card.accepted && (
<Pill tone="brand">
<Inbox size={11} strokeWidth={2} />
Requested
</Pill>
)}
</div>
{children}
</div>
);
});
- Step 2: Create the column
src/components/board/board-column.tsx:
"use client";
import { cn } from "@/lib/cn";
import type { ColumnRecord } from "@/lib/pb/types";
export function BoardColumn({
column,
count,
isOver,
children,
setDroppableRef,
}: {
column: ColumnRecord;
count: number;
isOver?: boolean;
children: React.ReactNode;
setDroppableRef?: (element: HTMLElement | null) => void;
}) {
return (
<section
aria-label={column.name}
className="flex w-[288px] shrink-0 flex-col"
>
<header className="flex items-center gap-2 px-1 pb-2.5">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
{column.name}
</h2>
<span className="tabular rounded-full bg-surface-raised px-1.5 py-0.5 text-[11px] font-medium text-text-muted">
{count}
</span>
</header>
<div
ref={setDroppableRef}
className={cn(
"flex min-h-32 flex-1 flex-col gap-2 rounded-[var(--radius-tile)] bg-surface p-2 transition-colors",
isOver && "bg-brand-soft"
)}
>
{children}
{count === 0 && (
<p className="px-1 py-6 text-center text-xs text-text-muted">
{column.is_inbox ? "No requests" : "Nothing here"}
</p>
)}
</div>
</section>
);
}
- Step 3: Create the client card controls
src/components/board/client-card-controls.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<string | null>(null);
async function patch(body: Record<string, unknown>) {
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 (
<div className="mt-3 flex items-center gap-2 border-t border-border-subtle pt-2.5">
<Dropdown>
<DropdownTrigger asChild>
<button
type="button"
disabled={saving}
className="rounded-md px-1.5 py-1 text-[11px] font-medium text-text-secondary hover:bg-surface-raised disabled:opacity-50"
>
{currentPriority.label}
</button>
</DropdownTrigger>
<DropdownContent>
<DropdownLabel>Priority</DropdownLabel>
{PRIORITIES.map((option) => (
<DropdownItem
key={option.value}
active={option.value === card.priority}
onSelect={() => patch({ priority: option.value })}
>
{option.label}
</DropdownItem>
))}
</DropdownContent>
</Dropdown>
<label className="ml-auto flex items-center gap-1.5">
<span className="sr-only">Target date</span>
<input
type="date"
disabled={saving}
value={card.target_date ? card.target_date.slice(0, 10) : ""}
onChange={(event) =>
patch({ target_date: event.target.value || null })
}
className="tabular rounded-md border border-border-subtle bg-surface px-1.5 py-1 text-[11px] text-text-secondary disabled:opacity-50"
/>
</label>
{error && (
<span role="alert" className="text-[11px] text-accent">
{error}
</span>
)}
</div>
);
}
- Step 4: Commit
git add src/components/board
git commit -m "feat(board): column, card tile and client control presentation"
Task 10: Owner card move route
Files:
- Create:
src/app/api/owner/cards/[id]/move/route.ts
Why this route exists. Plan 1a stores the auth token in an httpOnly cookie, which JavaScript cannot read. That is the right call for a token, and it has a consequence worth stating plainly: the browser-side PocketBase SDK has no credentials and cannot write anything. An owner drag that called pbBrowser.collection("cards").update(...) would fail silently with a 403 and the card would snap back.
The two ways out are to make the cookie readable by JavaScript, or to move the write server-side. The first hands a long-lived token to any XSS on the page; the second costs one small route. This plan takes the second.
src/lib/pb/browser.ts from Plan 1a therefore stays unauthenticated and is used for nothing in this plan. Leave it in place — Plan 1c may authenticate it deliberately for realtime, with that tradeoff made explicitly.
- Step 1: Write the route
src/app/api/owner/cards/[id]/move/route.ts:
import { getServerPb } from "@/lib/pb/server";
import { requireSession } from "@/lib/auth/session";
import { AccessError } from "@/lib/auth/access";
import { jsonError, jsonOk } from "@/lib/http/json";
import type { CardRecord } from "@/lib/pb/types";
/**
* PATCH /api/owner/cards/:id/move — the owner drops a card in a new place.
*
* Unlike the /api/client/* routes, this one uses the caller's OWN PocketBase
* token, not a superuser's. The owner-only write rule from Plan 1a is what
* authorises the update, so a client manager reaching this route is refused by
* the database even though the handler itself is simple.
*
* That is why there is no field whitelist here: PocketBase is already
* enforcing who may write. Only the two positional fields are forwarded
* regardless, so a stray body field cannot ride along.
*/
export async function PATCH(
request: Request,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
try {
const session = await requireSession();
const { id } = await context.params;
if (session.role !== "owner") {
return jsonError("Card not found", 404);
}
let body: unknown;
try {
body = await request.json();
} catch {
return jsonError("Invalid request body", 400);
}
const record = body as Record<string, unknown> | null;
const column = record?.column;
const position = record?.position;
if (typeof column !== "string" || !column) {
return jsonError("A destination column is required", 400);
}
if (typeof position !== "number" || !Number.isFinite(position)) {
return jsonError("A numeric position is required", 400);
}
const pb = await getServerPb();
const updated = await pb
.collection("cards")
.update<CardRecord>(id, { column, position });
return jsonOk({
card: {
id: updated.id,
column: updated.column,
position: updated.position,
},
});
} catch (error) {
if (error instanceof AccessError) {
return jsonError(error.message, error.status);
}
console.error("[owner-move] failed", error);
return jsonError("Could not move the card", 500);
}
}
- Step 2: Verify the build
npm run build
Expected: build succeeds; the route list includes /api/owner/cards/[id]/move.
- Step 3: Commit
git add src/app/api/owner
git commit -m "feat(api): owner card move route using the caller's own token"
Task 11: Drag, drop and persistence
Files:
- Create:
src/components/board/board.tsx - Create:
src/app/p/[clientSlug]/[projectSlug]/page.tsx
Who may drag. The owner only. A client manager reads the board and edits two fields through the controls from Task 9; the spec is explicit that they may not move cards between columns. @dnd-kit is given no sensors at all for a client manager, so there is nothing to disable and nothing to bypass. This is presentation convenience, not the security boundary — the boundary is the PocketBase write rule from Plan 1a, which refuses their token outright.
- Step 1: Install @dnd-kit
npm install @dnd-kit/core@^6.3.1 @dnd-kit/sortable@^10.0.0 @dnd-kit/utilities@^3.2.2
- Step 2: Write the board
src/components/board/board.tsx:
"use client";
import { useMemo, useState } from "react";
import {
DndContext,
DragOverlay,
PointerSensor,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { BoardColumn } from "@/components/board/board-column";
import { CardTile } from "@/components/board/card-tile";
import { ClientCardControls } from "@/components/board/client-card-controls";
import { computeDropPosition } from "@/lib/board/move";
import type { CardRecord, ColumnRecord, Role } from "@/lib/pb/types";
function SortableCard({
card,
role,
onPatched,
}: {
card: CardRecord;
role: Role;
onPatched: (card: CardRecord) => void;
}) {
const draggable = role === "owner";
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: card.id, disabled: !draggable });
return (
<CardTile
ref={setNodeRef}
card={card}
dragging={isDragging}
style={{ transform: CSS.Translate.toString(transform), transition }}
className={draggable ? "cursor-grab active:cursor-grabbing" : undefined}
{...(draggable ? attributes : {})}
{...(draggable ? listeners : {})}
>
{role === "client_manager" && (
<ClientCardControls card={card} onPatched={onPatched} />
)}
</CardTile>
);
}
function Droppable({
column,
count,
children,
}: {
column: ColumnRecord;
count: number;
children: React.ReactNode;
}) {
const { setNodeRef, isOver } = useDroppable({ id: `column:${column.id}` });
return (
<BoardColumn
column={column}
count={count}
isOver={isOver}
setDroppableRef={setNodeRef}
>
{children}
</BoardColumn>
);
}
export function Board({
role,
projectId,
columns,
initialCards,
}: {
role: Role;
projectId: string;
columns: ColumnRecord[];
initialCards: CardRecord[];
}) {
const [cards, setCards] = useState<CardRecord[]>(initialCards);
const [activeId, setActiveId] = useState<string | null>(null);
// A client manager gets no sensors, so @dnd-kit never starts a drag.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
);
const byColumn = useMemo(() => {
const map = new Map<string, CardRecord[]>();
for (const column of columns) map.set(column.id, []);
for (const card of cards) map.get(card.column)?.push(card);
for (const list of map.values()) list.sort((a, b) => a.position - b.position);
return map;
}, [cards, columns]);
const activeCard = activeId ? cards.find((c) => c.id === activeId) : null;
function onDragStart(event: DragStartEvent) {
setActiveId(String(event.active.id));
}
async function onDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over) return;
const card = cards.find((c) => c.id === String(active.id));
if (!card) return;
const overId = String(over.id);
// Dropping on a column's empty area gives "column:<id>"; dropping on
// another card gives that card's id and the destination is its column.
let destinationColumnId: string;
let dropIndex: number;
if (overId.startsWith("column:")) {
destinationColumnId = overId.slice("column:".length);
dropIndex = (byColumn.get(destinationColumnId) ?? []).length;
} else {
const overCard = cards.find((c) => c.id === overId);
if (!overCard) return;
destinationColumnId = overCard.column;
const list = byColumn.get(destinationColumnId) ?? [];
dropIndex = list.findIndex((c) => c.id === overCard.id);
}
const destination = byColumn.get(destinationColumnId) ?? [];
const sameColumn = card.column === destinationColumnId;
const position = computeDropPosition(
destination,
dropIndex,
sameColumn ? card.id : null
);
if (sameColumn && position === card.position) return;
const previous = cards;
const optimistic = cards.map((c) =>
c.id === card.id ? { ...c, column: destinationColumnId, position } : c
);
setCards(optimistic);
// Through the server route, not the browser SDK: the auth token lives in
// an httpOnly cookie that JavaScript cannot read, so the browser-side
// PocketBase instance has no credentials. See Task 10.
const response = await fetch(`/api/owner/cards/${card.id}/move`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ column: destinationColumnId, position }),
});
if (!response.ok) {
console.error("[board] move failed", response.status);
setCards(previous);
}
}
function onPatched(updated: CardRecord) {
setCards((current) =>
current.map((c) => (c.id === updated.id ? { ...c, ...updated } : c))
);
}
const content = (
<div className="flex h-full gap-3 overflow-x-auto p-6">
{columns.map((column) => {
const list = byColumn.get(column.id) ?? [];
return (
<Droppable key={column.id} column={column} count={list.length}>
<SortableContext
items={list.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
{list.map((card) => (
<SortableCard
key={card.id}
card={card}
role={role}
onPatched={onPatched}
/>
))}
</SortableContext>
</Droppable>
);
})}
</div>
);
if (role !== "owner") {
return <div className="flex-1 overflow-hidden">{content}</div>;
}
return (
<div className="flex-1 overflow-hidden" data-project={projectId}>
<DndContext
sensors={sensors}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{content}
<DragOverlay>
{activeCard && <CardTile card={activeCard} dragging />}
</DragOverlay>
</DndContext>
</div>
);
}
- Step 3: Create the board route
src/app/p/[clientSlug]/[projectSlug]/page.tsx:
import { notFound, redirect } from "next/navigation";
import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { Board } from "@/components/board/board";
import { getSession } from "@/lib/auth/session";
import { loadBoard } from "@/lib/data/board";
export default async function BoardPage({
params,
}: {
params: Promise<{ clientSlug: string; projectSlug: string }>;
}) {
const session = await getSession();
if (!session) redirect("/login");
const { clientSlug, projectSlug } = await params;
const board = await loadBoard(clientSlug, projectSlug);
// loadBoard returns null both for "does not exist" and "not yours". Rendering
// the same 404 for both is deliberate: a distinct 403 would confirm to one
// client that another client's project is real.
if (!board) notFound();
return (
<AppShell
role={session.role}
currentPath={`/p/${clientSlug}/${projectSlug}`}
>
<TopBar title={board.project.name} subtitle={board.client.name} />
<Board
role={session.role}
projectId={board.project.id}
columns={board.columns}
initialCards={board.cards}
/>
</AppShell>
);
}
- Step 4: Verify manually as the owner
npm run build
Expected: build succeeds. Then 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 5: 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. Change a card's priority and set a target date; both persist across a reload. Set a date in the past and confirm the date pill turns accent-coloured. In the PocketBase admin UI, confirm two new activity rows exist with the correct old and new values.
- Step 6: Commit
git add src/components/board/board.tsx src/app/p package.json package-lock.json
git commit -m "feat(board): owner drag and drop with optimistic persistence"
Task 11b: REMOVED — superseded
The client control component moved into Task 9, where it is created alongside the other presentation components so that Task 11's board compiles. The code block below is the same file and is retained only so a reader following an older copy of this plan does not think a step is missing. Do not create this file twice.
Duplicate of the Task 9 Step 3 file — skip
src/components/board/client-card-controls.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<string | null>(null);
async function patch(body: Record<string, unknown>) {
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 (
<div className="mt-3 flex items-center gap-2 border-t border-border-subtle pt-2.5">
<Dropdown>
<DropdownTrigger asChild>
<button
type="button"
disabled={saving}
className="rounded-md px-1.5 py-1 text-[11px] font-medium text-text-secondary hover:bg-surface-raised disabled:opacity-50"
>
{currentPriority.label}
</button>
</DropdownTrigger>
<DropdownContent>
<DropdownLabel>Priority</DropdownLabel>
{PRIORITIES.map((option) => (
<DropdownItem
key={option.value}
active={option.value === card.priority}
onSelect={() => patch({ priority: option.value })}
>
{option.label}
</DropdownItem>
))}
</DropdownContent>
</Dropdown>
<label className="ml-auto flex items-center gap-1.5">
<span className="sr-only">Target date</span>
<input
type="date"
disabled={saving}
value={card.target_date ? card.target_date.slice(0, 10) : ""}
onChange={(event) =>
patch({ target_date: event.target.value || null })
}
className="tabular rounded-md border border-border-subtle bg-surface px-1.5 py-1 text-[11px] text-text-secondary disabled:opacity-50"
/>
</label>
{error && (
<span role="alert" className="text-[11px] text-accent">
{error}
</span>
)}
</div>
);
}
- Step 2: Verify manually
Sign in as manager@wf.example.com, open the board, change a card's priority and set a target date.
Expected: the pill updates immediately, and the change survives a reload. Set a date in the past and confirm the date pill turns accent-coloured. In the PocketBase admin UI, confirm two new activity rows exist with the correct old and new values.
- Step 3: Commit
git add src/components/board/client-card-controls.tsx
git commit -m "feat(board): client priority and target date controls"
Task 12: Realtime updates
Files:
-
Create:
src/components/board/use-board-realtime.ts -
Modify:
src/components/board/board.tsx -
Step 1: Write the hook
src/components/board/use-board-realtime.ts:
"use client";
import { useEffect } from "react";
import { pbBrowser } from "@/lib/pb/browser";
import type { CardRecord } from "@/lib/pb/types";
/**
* Keep a board in sync with other sessions.
*
* The subscription is scoped by the same read rules as every other query, so a
* client manager receives events only for their own client's cards — the
* filter below is a bandwidth optimisation, not the access control.
*
* Local edits echo back as events. Applying them is harmless: the record we
* receive is the record we just wrote.
*/
export function useBoardRealtime(
projectId: string,
onChange: (action: string, card: CardRecord) => void
) {
useEffect(() => {
let cancelled = false;
const promise = pbBrowser
.collection("cards")
.subscribe<CardRecord>(
"*",
(event) => {
if (cancelled) return;
if (event.record.project !== projectId) return;
onChange(event.action, event.record);
},
{ filter: `project = "${projectId}"` }
);
return () => {
cancelled = true;
promise.then(() => pbBrowser.collection("cards").unsubscribe("*")).catch(() => {});
};
}, [projectId, onChange]);
}
- Step 2: Wire it into the board
In src/components/board/board.tsx, add the import:
import { useCallback } from "react";
import { useBoardRealtime } from "@/components/board/use-board-realtime";
Then inside Board, immediately after the activeCard line, add:
const onRemoteChange = useCallback(
(action: string, record: CardRecord) => {
setCards((current) => {
if (action === "delete") {
return current.filter((c) => c.id !== record.id);
}
// A card being dragged locally must not be yanked out from under the
// pointer by an echo of our own optimistic write.
if (record.id === activeId) return current;
const exists = current.some((c) => c.id === record.id);
if (!exists) {
return record.archived ? current : [...current, record];
}
return current.map((c) => (c.id === record.id ? record : c));
});
},
[activeId]
);
useBoardRealtime(projectId, onRemoteChange);
The data-project={projectId} attribute on the owner branch's wrapper can stay; projectId is now used by the hook in both branches.
- Step 3: Verify manually
Open the same board in two browser windows, signed in as the owner in one and the client manager in the other. Drag a card in the owner's window.
Expected: it moves in the manager's window within a second, without a reload.
- Step 4: Commit
git add src/components/board
git commit -m "feat(board): realtime card updates across sessions"
Task 13: Full verification
- Step 1: Run every test
set -a && source .env.local && set +a && npm run test:all
Expected: PASS. Unit tests now total 68 — the 47 from Plan 1a plus 10 in dates.test.ts and 11 in move.test.ts. Integration tests remain 10.
- Step 2: Verify the production build
npm run build
Expected: build succeeds with no type errors. The route list includes /, /login, and /p/[clientSlug]/[projectSlug].
- Step 3: Check both themes at both roles
Sign in as each user in turn, in light and in dark:
-
No element is invisible or unreadable in either theme.
-
No hardcoded colour survives:
grep -rn "bg-neutral-\|bg-gray-\|bg-slate-\|text-neutral-\|text-gray-\|#[0-9a-fA-F]\{6\}" src/components src/app --include=*.tsxreturns nothing outsideglobals.css. -
The accent colour appears only on overdue dates, inbox badges and critical priority.
-
Step 4: Commit any fixes
git add -A
git commit -m "fix(theme): resolve contrast and token issues found in review"
Definition of done
npm run test:allpasses: 68 unit, 10 integration.npm run buildsucceeds with no type errors.- The owner can drag cards within and between columns; positions survive a reload.
- A client manager cannot drag anything, and can change priority and target date, with both writing
activityrows. - A client manager opening another client's board URL directly gets a 404.
- Light and dark both render correctly, chosen theme persists, and there is no flash of the wrong theme on load.
- No component hardcodes a colour.
Deliberately not in this plan
Plan 1c: card detail view with description, subtasks, comments and activity timeline; admin pages for clients, projects and users; the Inbox accept flow that sets accepted = true and start_date; card creation UI for both roles; archive.
Phases 2–5 per the spec: Forgejo ingestion and the effort dashboard — which is where the KPI row with sparklines from the second design reference belongs — AI humanization, the client intake wizard, and the roadmap view.