kanban-dev-webapp/docs/superpowers/plans/2026-09-17-phase-6-navigation-and-project-kind.md
Dave 2d87cd505b chore(config): update .gitignore and enhance Next.js configuration
Added .superpowers directory to .gitignore to prevent tracking of specific Pocketbase data. Updated Next.js configuration to enable standalone output for optimized Docker image builds, reducing the need for the full node_modules tree.
2026-09-19 07:52:09 +02:00

40 KiB

Phase 6 — Navigation and Project Kind 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: Group projects by client on the home page and in a new sidebar "Kanban" flyout that jumps straight into a project's board; make the dashboard the click target for a project tile; add a kind field so a project can be tagged prototype — full board, full dashboard, same permissions, just visually distinguished.

Architecture: One new select field on projects (kind: "project" | "prototype", backfilled to "project"). All new browsing surfaces (home page grouping, sidebar flyout) read with the caller's own PocketBase token, exactly like the existing home page and /plan — the Phase 1 read rules do the scoping, not a UI filter or a role branch anyone could forget. AppShell becomes an async server component that loads the sidebar's project tree once and passes it down, so no existing page needs to change to get the new nav.

Tech Stack: Next.js 16 App Router · PocketBase 0.30 · Tailwind 4 · vitest

Spec: docs/superpowers/specs/2026-09-17-phase-6-navigation-and-project-kind-design.md

Prerequisite: Phase 1 complete (clients/projects/read-rules exist). No other phase is required.


What already exists — do not rebuild this

The dashboard already has a "Back to board" link (src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx:66-73), and the board's TopBar already links to "Dashboard" (src/app/p/[clientSlug]/[projectSlug]/page.tsx). The dashboard ↔ board shortcut from the spec already works. Task 5 verifies this directly instead of writing new code for it — if it turns out broken, fix it there, but do not add a second link.


File structure

scripts/
└── setup-remote.ts                 # + ensureProjectKindField
src/
├── lib/
│   ├── pb/types.ts                 # + ProjectKind, kind on ProjectRecord
│   └── data/
│       └── board.ts                # + loadSidebarProjects, SidebarProjectGroup
├── app/
│   ├── actions/
│   │   └── projects.ts             # createProject accepts kind
│   ├── page.tsx                    # grouped by client, prototype tag, links to dashboard
│   └── admin/projects/page.tsx     # Kind column
└── components/
    ├── admin/
    │   └── project-form.tsx        # Kind select
    └── shell/
        ├── app-shell.tsx           # async — loads the sidebar tree
        ├── sidebar.tsx             # renders KanbanNav
        └── kanban-nav.tsx          # NEW — the flyout
tests/
└── integration/
    └── project-kind-and-nav.test.ts   # NEW

Task 1: The kind field

Files:

  • Modify: scripts/setup-remote.ts

  • Modify: src/lib/pb/types.ts

  • Step 1: Add the type

In src/lib/pb/types.ts, add beside the other project-related types (near ProjectStatus, line 5):

export type ProjectKind = "project" | "prototype";

Then add kind to ProjectRecord:

export interface ProjectRecord extends BaseRecord {
  client: string;
  name: string;
  slug: string;
  description: string;
  status: ProjectStatus;
  kind: ProjectKind;
  color: string;
  repo_owner: string;
  repo_name: string;
  repo_url: string;
  /** Server-only. Must never appear in a browser payload. */
  webhook_secret: string;
  last_ingest_at: string;
  session_gap_minutes: number;
  pre_session_minutes: number;
  post_session_minutes: number;
}
  • Step 2: Add the migration helper

In scripts/setup-remote.ts, add a new function beside ensureProjectIngestFields (after it, around line 183):

/**
 * Add the `kind` field to projects, for Phase 6's project/prototype
 * distinction — a prototype is a full project (board, dashboard, same
 * permissions), just tagged as not yet a committed piece of work.
 *
 * Backfilled to "project" for every existing row. Nothing already in
 * production should read as a prototype just because the field is new.
 *
 * Re-runs are safe: the field is not duplicated, and backfill only touches
 * rows where kind is still unset.
 */
async function ensureProjectKindField(pb: PocketBase): Promise<void> {
  const projects = await pb.collections.getOne("projects");
  const have = new Set(projects.fields.map((f: { name: string }) => f.name));

  if (!have.has("kind")) {
    const fields = [...(projects.fields as unknown[])];
    fields.push({
      name: "kind",
      type: "select",
      required: false,
      maxSelect: 1,
      values: ["project", "prototype"],
      presentable: false,
      hidden: false,
      system: false,
    });
    await pb.collections.update(projects.id, { fields });
    console.log("  + projects field: kind");
  } else {
    console.log("  = projects.kind already present");
  }

  const unset = await pb.collection("projects").getFullList({
    filter: 'kind = ""',
    fields: "id",
  });
  for (const project of unset) {
    await pb.collection("projects").update(project.id, { kind: "project" });
  }
  console.log(`  = project kind backfilled (${unset.length} row(s))`);
}
  • Step 3: Call it from main()

In main(), directly after the existing await ensureProjectIngestFields(pb); call (around line 898):

  // Add ingest-related fields to projects (only if missing — the projects
  // collection already exists from earlier migrations).
  await ensureProjectIngestFields(pb);

  // Add the project/prototype kind field for Phase 6.
  await ensureProjectKindField(pb);
  • Step 4: Run it against the live PocketBase
set -a && source .env.local && set +a && npm run setup-remote

Expected: prints + projects field: kind (or = projects.kind already present on a re-run) followed by = project kind backfilled (N row(s)).

  • Step 5: Verify the build
npm run build

Expected: build succeeds.

  • Step 6: Commit
git add scripts/setup-remote.ts src/lib/pb/types.ts
git commit -m "feat(projects): add kind field (project | prototype)"

Task 2: Creating and displaying a project's kind

Files:

  • Modify: src/app/actions/projects.ts

  • Modify: src/components/admin/project-form.tsx

  • Modify: src/app/admin/projects/page.tsx

  • Modify: src/app/page.tsx

  • Step 1: Accept kind in createProject

In src/app/actions/projects.ts, change the createProject signature and the record it creates:

export async function createProject(input: {
  clientId: string;
  name: string;
  description: string;
  repoOwner: string;
  repoName: string;
  color: string;
  kind?: ProjectRecord["kind"];
}): Promise<{ id: string }> {
  await requireOwner();
  const pb = await getServerPb();

  const name = input.name.trim().slice(0, 120);
  if (!name) throw new Error("A name is required");

  const siblings = await pb.collection("projects").getFullList<ProjectRecord>({
    filter: `client = "${input.clientId}"`,
    fields: "slug",
  });

  const project = await pb.collection("projects").create<ProjectRecord>({
    client: input.clientId,
    name,
    slug: uniqueSlug(slugify(name), siblings.map((p) => p.slug)),
    description: input.description.slice(0, 20000),
    status: "active",
    kind: input.kind ?? "project",
    color: input.color || "#2f6dad",
    repo_owner: input.repoOwner.trim(),
    repo_name: input.repoName.trim(),
    repo_url: "",
  });

The rest of the function (column seeding, error handling, revalidatePath) is unchanged.

  • Step 2: Add the Kind toggle to the create form

In src/components/admin/project-form.tsx, add state and a select:

"use client";

import { useState, useTransition } from "react";
import { Plus } from "lucide-react";

import { createProject } from "@/app/actions/projects";
import { DEFAULT_BRAND_COLOR } from "@/lib/brand-default";
import type { ClientRecord, ProjectRecord } from "@/lib/pb/types";

export function NewProjectForm({ clients }: { clients: ClientRecord[] }) {
  const [open, setOpen] = useState(false);
  const [clientId, setClientId] = useState(clients[0]?.id ?? "");
  const [name, setName] = useState("");
  const [description, setDescription] = useState("");
  const [repoOwner, setRepoOwner] = useState("");
  const [repoName, setRepoName] = useState("");
  const [kind, setKind] = useState<ProjectRecord["kind"]>("project");
  const [pending, startTransition] = useTransition();
  const [error, setError] = useState<string | null>(null);

  if (clients.length === 0) {
    return (
      <p className="text-xs text-text-muted">Create a client first.</p>
    );
  }

  function submit(event: React.FormEvent) {
    event.preventDefault();
    if (!name.trim() || !clientId) return;
    setError(null);

    startTransition(async () => {
      try {
        await createProject({
          clientId,
          name,
          description,
          repoOwner,
          repoName,
          color: DEFAULT_BRAND_COLOR,
          kind,
        });
        setName("");
        setDescription("");
        setRepoOwner("");
        setRepoName("");
        setKind("project");
        setOpen(false);
      } catch (e) {
        setError(e instanceof Error ? e.message : "Could not create that project");
      }
    });
  }

  if (!open) {
    return (
      <button
        type="button"
        onClick={() => setOpen(true)}
        className="inline-flex items-center gap-1.5 rounded-lg bg-brand px-3 py-1.5 text-sm font-medium text-brand-fg transition-colors hover:bg-brand-hover"
      >
        <Plus size={14} strokeWidth={2} />
        New project
      </button>
    );
  }

  const field =
    "w-full rounded-lg border border-border-subtle bg-panel px-3 py-2 text-sm text-text-primary outline-none focus:border-brand";

  return (
    <form
      onSubmit={submit}
      className="w-full space-y-3 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4"
    >
      <div className="grid gap-3 sm:grid-cols-2">
        <label className="space-y-1">
          <span className="text-xs text-text-muted">Client</span>
          <select
            value={clientId}
            onChange={(event) => setClientId(event.target.value)}
            className={field}
          >
            {clients.map((client) => (
              <option key={client.id} value={client.id}>
                {client.name}
              </option>
            ))}
          </select>
        </label>

        <label className="space-y-1">
          <span className="text-xs text-text-muted">Project name</span>
          <input
            autoFocus
            value={name}
            onChange={(event) => setName(event.target.value)}
            className={field}
          />
        </label>
      </div>

      <label className="block space-y-1">
        <span className="text-xs text-text-muted">Description</span>
        <textarea
          rows={2}
          value={description}
          onChange={(event) => setDescription(event.target.value)}
          className={field}
        />
      </label>

      <div className="grid gap-3 sm:grid-cols-2">
        <label className="space-y-1">
          <span className="text-xs text-text-muted">
            Repository owner{" "}
            <span className="text-text-muted">(used from Phase 2)</span>
          </span>
          <input
            value={repoOwner}
            onChange={(event) => setRepoOwner(event.target.value)}
            placeholder="David"
            className={field}
          />
        </label>

        <label className="space-y-1">
          <span className="text-xs text-text-muted">Repository name</span>
          <input
            value={repoName}
            onChange={(event) => setRepoName(event.target.value)}
            placeholder="WFDASH-WEBAPP"
            className={field}
          />
        </label>
      </div>

      <label className="block space-y-1">
        <span className="text-xs text-text-muted">
          Kind{" "}
          <span className="text-text-muted">
            (a prototype is full-featured — this only tags it)
          </span>
        </span>
        <select
          value={kind}
          onChange={(event) => setKind(event.target.value as ProjectRecord["kind"])}
          className={field}
        >
          <option value="project">Project</option>
          <option value="prototype">Prototype</option>
        </select>
      </label>

      {error && (
        <p role="alert" className="text-xs text-accent">
          {error}
        </p>
      )}

      <div className="flex items-center gap-2">
        <button
          type="submit"
          disabled={pending || !name.trim()}
          className="rounded-lg bg-brand px-3 py-2 text-sm font-medium text-brand-fg disabled:opacity-50"
        >
          {pending ? "Creating…" : "Create project"}
        </button>
        <button
          type="button"
          onClick={() => setOpen(false)}
          className="px-2 py-2 text-sm text-text-muted hover:text-text-secondary"
        >
          Cancel
        </button>
      </div>
    </form>
  );
}
  • Step 3: Add a Kind column to the admin table

In src/app/admin/projects/page.tsx, add "Kind" to the headers and a cell:

        <AdminTable
          headers={["Project", "Client", "Repository", "Kind", "Cards", "Status"]}
          empty="No projects yet."
        >
          {rows.map(({ project, client, cardCount }) => (
            <tr key={project.id}>
              <AdminCell>
                <Link
                  href={`/admin/projects/${project.id}`}
                  className="font-medium text-text-primary hover:text-brand"
                >
                  {project.name}
                </Link>
                <Link
                  href={`/p/${client.slug}/${project.slug}`}
                  className="ml-3 text-xs text-text-muted hover:text-text-secondary"
                >
                  Board
                </Link>
              </AdminCell>
              <AdminCell>{client.name}</AdminCell>
              <AdminCell>
                {project.repo_owner && project.repo_name ? (
                  <code className="text-xs">
                    {project.repo_owner}/{project.repo_name}
                  </code>
                ) : (
                  <span className="text-xs text-text-muted">Not linked</span>
                )}
              </AdminCell>
              <AdminCell>
                {project.kind === "prototype" ? (
                  <Pill tone="accent">Prototype</Pill>
                ) : (
                  <span className="text-xs text-text-muted">Project</span>
                )}
              </AdminCell>
              <AdminCell className="tabular">{cardCount}</AdminCell>
              <AdminCell>
                <Pill tone={project.status === "active" ? "brand" : "neutral"}>
                  {project.status}
                </Pill>
              </AdminCell>
            </tr>
          ))}
        </AdminTable>

Only the headers array and the two new lines inside the row change; everything else in the file is unchanged.

  • Step 4: Group the home page by client, tag prototypes, link to the dashboard

Replace src/app/page.tsx with:

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, type ProjectSummary } from "@/lib/data/board";
import type { ClientRecord } from "@/lib/pb/types";

interface ClientGroup {
  client: ClientRecord;
  summaries: ProjectSummary[];
}

/** Preserves the incoming (project-name) order within each client's group. */
function groupByClient(summaries: ProjectSummary[]): ClientGroup[] {
  const groups = new Map<string, ClientGroup>();

  for (const summary of summaries) {
    const group = groups.get(summary.client.id) ?? {
      client: summary.client,
      summaries: [],
    };
    group.summaries.push(summary);
    groups.set(summary.client.id, group);
  }

  return [...groups.values()].sort((a, b) =>
    a.client.name.localeCompare(b.client.name)
  );
}

export default async function HomePage() {
  const session = await getSession();
  if (!session) redirect("/login");

  const summaries = await loadProjectSummaries();
  const groups = groupByClient(summaries);

  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">
        {groups.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>
        ) : (
          <div className="space-y-8">
            {groups.map((group) => (
              <section key={group.client.id} className="space-y-3">
                {/* Only the owner sees more than one group — a client manager's
                    own name over their own projects would be noise. */}
                {session.role === "owner" && (
                  <div className="flex items-center gap-2">
                    <span
                      aria-hidden
                      className="h-2 w-2 shrink-0 rounded-full"
                      style={{ background: group.client.brand_color || "var(--brand)" }}
                    />
                    <h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
                      {group.client.name}
                    </h2>
                  </div>
                )}

                <ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
                  {group.summaries.map(({ project, client, openCards, inboxCards }) => (
                    <li key={project.id}>
                      <Link
                        href={`/p/${client.slug}/${project.slug}/dashboard`}
                        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>
                          {project.kind === "prototype" && (
                            <Pill tone="accent">Prototype</Pill>
                          )}
                          {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>
              </section>
            ))}
          </div>
        )}
      </div>
    </AppShell>
  );
}

Note the tile now carries two possible pills (Prototype and the inbox count) — both render if both apply; that is intentional, a prototype can still have inbox requests on it.

ProjectSummary needs to be exported from src/lib/data/board.ts for the type ProjectSummary import above — check export interface ProjectSummary at line 18 already has export, so no change needed there.

  • Step 5: Verify the build
npm run build

Expected: build succeeds.

  • Step 6: Commit
git add src/app/actions/projects.ts src/components/admin/project-form.tsx src/app/admin/projects/page.tsx src/app/page.tsx
git commit -m "feat(projects): kind on create/admin, home page grouped by client"

Task 3: The sidebar Kanban flyout

Files:

  • Modify: src/lib/data/board.ts

  • Create: src/components/shell/kanban-nav.tsx

  • Modify: src/components/shell/sidebar.tsx

  • Modify: src/components/shell/app-shell.tsx

  • Step 1: Add the loader

In src/lib/data/board.ts, add beside loadProjectSummaries (the file already imports getServerPb, CardRecord, ClientRecord, ColumnRecord, ProjectRecord — no new imports needed):

export interface SidebarProject {
  id: string;
  name: string;
  slug: string;
  kind: ProjectRecord["kind"];
}

export interface SidebarProjectGroup {
  client: ClientRecord;
  projects: SidebarProject[];
}

/**
 * The sidebar's Kanban flyout tree, grouped by client.
 *
 * Read with the caller's own token, exactly like loadProjectSummaries above —
 * an owner gets every client, a client manager gets only their own project(s),
 * with the read rules doing the scoping rather than a role branch here.
 */
export async function loadSidebarProjects(): Promise<SidebarProjectGroup[]> {
  const pb = await getServerPb();
  if (!pb.authStore.isValid) return [];

  const projects = await pb.collection("projects").getFullList<ProjectRecord>({
    filter: 'status != "archived"',
    sort: "name",
    fields: "id,name,slug,kind,client",
  });
  if (projects.length === 0) return [];

  const clients = await pb.collection("clients").getFullList<ClientRecord>();
  const clientsById = new Map(clients.map((c) => [c.id, c]));

  const groups = new Map<string, SidebarProjectGroup>();
  for (const project of projects) {
    const client = clientsById.get(project.client);
    if (!client) continue;

    const group = groups.get(client.id) ?? { client, projects: [] };
    group.projects.push({
      id: project.id,
      name: project.name,
      slug: project.slug,
      kind: project.kind,
    });
    groups.set(client.id, group);
  }

  return [...groups.values()].sort((a, b) =>
    a.client.name.localeCompare(b.client.name)
  );
}
  • Step 2: Write the flyout

src/components/shell/kanban-nav.tsx:

"use client";

import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Kanban } from "lucide-react";

import { cn } from "@/lib/cn";
import type { SidebarProjectGroup } from "@/lib/data/board";
import type { Role } from "@/lib/pb/types";

/**
 * The sidebar's Kanban entry.
 *
 * Not a Link like the rest of the rail: clicking it opens a flyout beside the
 * rail, and picking a project from it navigates straight into that project's
 * board. This is the one nav item that is a picker rather than a destination,
 * so it owns its own open/closed state rather than reading currentPath.
 */
export function KanbanNav({
  role,
  groups,
  active,
}: {
  role: Role;
  groups: SidebarProjectGroup[];
  active: boolean;
}) {
  const [open, setOpen] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open) return;

    function onPointerDown(event: PointerEvent) {
      if (
        containerRef.current &&
        !containerRef.current.contains(event.target as Node)
      ) {
        setOpen(false);
      }
    }

    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open]);

  return (
    <div ref={containerRef} className="relative">
      <button
        type="button"
        onClick={() => setOpen((value) => !value)}
        aria-expanded={open}
        aria-haspopup="true"
        className={cn(
          "flex w-[60px] flex-col items-center gap-1 rounded-xl px-1 py-2.5 transition-colors",
          active || open
            ? "bg-panel text-text-primary shadow-[var(--shadow-tile)]"
            : "text-text-muted hover:text-text-secondary"
        )}
      >
        <Kanban size={18} strokeWidth={1.75} />
        <span className="text-[10px] font-medium leading-none">Kanban</span>
      </button>

      {open && (
        <div className="absolute left-[68px] top-0 z-20 max-h-[80vh] w-64 overflow-y-auto rounded-xl border border-border-subtle bg-panel p-2 shadow-[var(--shadow-panel)]">
          {groups.length === 0 ? (
            <p className="p-2 text-xs text-text-muted">No projects yet.</p>
          ) : (
            groups.map((group) => (
              <div key={group.client.id} className="mb-2 last:mb-0">
                {/* A client manager only ever has one group — their own — so a
                    header naming their own client would be pure noise. */}
                {role === "owner" && (
                  <p className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-text-muted">
                    {group.client.name}
                  </p>
                )}
                {group.projects.map((project) => (
                  <Link
                    key={project.id}
                    href={`/p/${group.client.slug}/${project.slug}`}
                    onClick={() => setOpen(false)}
                    className="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised hover:text-text-primary"
                  >
                    <span className="truncate">{project.name}</span>
                    {project.kind === "prototype" && (
                      <span className="shrink-0 rounded-full bg-accent-soft px-1.5 py-0.5 text-[10px] font-medium text-accent">
                        Prototype
                      </span>
                    )}
                  </Link>
                ))}
              </div>
            ))
          )}
        </div>
      )}
    </div>
  );
}
  • Step 3: Wire it into the sidebar

Replace src/components/shell/sidebar.tsx with:

import Link from "next/link";
import { LayoutGrid, FolderKanban, Settings, Users, CalendarRange } from "lucide-react";

import { cn } from "@/lib/cn";
import { KanbanNav } from "@/components/shell/kanban-nav";
import type { SidebarProjectGroup } from "@/lib/data/board";
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: "/plan", label: "Plan", icon: CalendarRange },
  { 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 },
];

function NavLink({ item, active }: { item: NavItem; active: boolean }) {
  const Icon = item.icon;
  return (
    <Link
      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>
  );
}

/**
 * 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.
 *
 * "Kanban" is not in NAV: it is not a destination, it is a picker (see
 * KanbanNav), so it renders separately between Projects and the rest.
 */
export function Sidebar({
  role,
  currentPath,
  kanbanGroups,
}: {
  role: Role;
  currentPath: string;
  kanbanGroups: SidebarProjectGroup[];
}) {
  const items = NAV.filter((item) => !item.ownerOnly || role === "owner");
  const isActive = (item: NavItem) =>
    item.href === "/" ? currentPath === "/" : currentPath.startsWith(item.href);

  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>

      <NavLink item={items[0]} active={isActive(items[0])} />

      <KanbanNav
        role={role}
        groups={kanbanGroups}
        active={currentPath.startsWith("/p/")}
      />

      {items.slice(1).map((item) => (
        <NavLink key={item.href} item={item} active={isActive(item)} />
      ))}
    </nav>
  );
}

items[0] is always { href: "/", label: "Projects" } — it has no ownerOnly flag, so the filter never removes it.

  • Step 4: Load the tree in AppShell

Replace src/components/shell/app-shell.tsx with:

import { Sidebar } from "@/components/shell/sidebar";
import { loadSidebarProjects } from "@/lib/data/board";
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.
 *
 * Async so every existing call site gets the sidebar's Kanban tree for free —
 * no page.tsx needs to change to pass it down.
 */
export async function AppShell({
  role,
  currentPath,
  children,
}: {
  role: Role;
  currentPath: string;
  children: React.ReactNode;
}) {
  const kanbanGroups = await loadSidebarProjects();

  return (
    <div className="flex h-screen overflow-hidden bg-page px-3 pb-3 pt-0">
      <Sidebar role={role} currentPath={currentPath} kanbanGroups={kanbanGroups} />

      <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 5: Verify the build
npm run build

Expected: build succeeds. If TypeScript complains that AppShell cannot be used as a JSX element in a particular call site, that call site is inside a Client Component boundary — check it is not passing AppShell itself across a "use client" boundary (passing plain data as props is fine; every current page.tsx renders AppShell directly and is itself a Server Component, so this should not occur, but confirm before working around it).

  • Step 6: Commit
git add src/lib/data/board.ts src/components/shell/kanban-nav.tsx src/components/shell/sidebar.tsx src/components/shell/app-shell.tsx
git commit -m "feat(nav): sidebar Kanban flyout, grouped by client"

Task 4: Integration tests

Files:

  • Create: tests/integration/project-kind-and-nav.test.ts

  • Step 1: Write the tests

tests/integration/project-kind-and-nav.test.ts:

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import PocketBase from "pocketbase";

import { APP_URL, DEMO, adminPb, requireServices, signIn } from "./helpers";

let admin: PocketBase;
let ownClientId: string;

beforeAll(async () => {
  await requireServices();
  admin = await adminPb();

  const client = await admin
    .collection("clients")
    .getFirstListItem(`slug = "${DEMO.clientSlug}"`);
  ownClientId = client.id;
});

describe("a prototype is visible to its own client immediately", () => {
  let projectId: string;

  afterAll(async () => {
    if (projectId) await admin.collection("projects").delete(projectId).catch(() => {});
  });

  it("shows the Prototype tag on the home page for both roles", async () => {
    const stamp = Date.now();
    const project = await admin.collection("projects").create({
      client: ownClientId,
      name: `Kind probe ${stamp}`,
      slug: `kind-probe-${stamp}`,
      status: "active",
      kind: "prototype",
    });
    projectId = project.id;

    const ownerCookie = await signIn(DEMO.ownerEmail, DEMO.ownerPassword);
    const ownerHtml = await fetch(`${APP_URL}/`, {
      headers: { cookie: ownerCookie },
    }).then((r) => r.text());
    expect(ownerHtml).toContain(`Kind probe ${stamp}`);
    expect(ownerHtml).toContain("Prototype");

    const managerCookie = await signIn(DEMO.managerEmail, DEMO.managerPassword);
    const managerHtml = await fetch(`${APP_URL}/`, {
      headers: { cookie: managerCookie },
    }).then((r) => r.text());
    expect(managerHtml).toContain(`Kind probe ${stamp}`);
  });
});

describe("client scoping is unchanged by the new nav", () => {
  it("keeps another client's project out of a manager's home page and Kanban flyout", async () => {
    const stamp = Date.now();
    const other = await admin
      .collection("clients")
      .create({ name: `Nav probe ${stamp}`, slug: `nav-probe-${stamp}`, active: true });
    const otherProject = await admin.collection("projects").create({
      client: other.id,
      name: `Nav probe project ${stamp}`,
      slug: `nav-probe-${stamp}`,
      status: "active",
    });

    const cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword);
    const html = await fetch(`${APP_URL}/`, { headers: { cookie } }).then((r) =>
      r.text()
    );

    expect(html).not.toContain(`Nav probe project ${stamp}`);
    expect(html).not.toContain(`Nav probe ${stamp}`);

    await admin.collection("projects").delete(otherProject.id);
    await admin.collection("clients").delete(other.id);
  });

  it("renders the home page for the owner with the new grouped layout", async () => {
    const cookie = await signIn(DEMO.ownerEmail, DEMO.ownerPassword);
    const response = await fetch(`${APP_URL}/`, { headers: { cookie } });
    expect(response.status).toBe(200);
  });
});
  • Step 2: Run the integration suite

With npm run dev running:

set -a && source .env.local && set +a && npm run test:integration

Expected: PASS — all prior integration tests plus these 3.

  • Step 3: Commit
git add tests/integration/project-kind-and-nav.test.ts
git commit -m "test(nav): prototype visibility and client scoping on the new home page"

Task 5: Full verification

This task is work, not a summary. Run every command and act on what it prints.

  • Step 1: Run every test
set -a && source .env.local && set +a && npm run test:all

Expected: PASS — unit tests unchanged in count from before this phase (no new pure logic was added; everything here is data plumbing and components), plus 3 new integration tests.

  • Step 2: Verify the build and the colour check
npm run build
grep -rnE "bg-(neutral|gray|slate|zinc|stone)-|text-(neutral|gray|slate|zinc|stone)-|#[0-9a-fA-F]{6}" --include="*.tsx" src/components src/app

Expected: build succeeds; the grep prints nothing new introduced by this phase (it may already print pre-existing matches from before Phase 6 — only new matches in files this phase touched are this phase's problem).

  • Step 3: Check the home page groups correctly

Open / as the owner. Every client with a visible project must show as its own section, sorted by client name, with that client's projects underneath.

Sign in as the client manager. Their own projects show with no client-name header above them (the section header is owner-only, per the design).

  • Step 4: Check a project tile opens the dashboard, and the shortcut back to the board still works

Click a project tile from /. Expected: lands on that project's dashboard, not the board.

From the dashboard, use the existing "Back to board" link. Expected: lands on the board. From the board, use the existing "Dashboard" link in its TopBar. Expected: back on the dashboard. If either of these pre-existing links is missing or broken, fix it as part of this task — do not add a new, second link next to a broken one.

  • Step 5: Check the Kanban flyout

Click "Kanban" in the sidebar. As the owner: every client with visible projects appears as a header, with its projects listed underneath. Click a project. Expected: the flyout closes and the project's board (not dashboard) opens directly, with no stop on the home page.

Click "Kanban" again to open it, then click anywhere outside the panel. Expected: it closes without navigating.

Sign in as the client manager and open "Kanban". Expected: their own project(s) list directly, no client-name header above them.

  • Step 6: Check a prototype

As the owner, create a project via "New project" in Manage with Kind set to "Prototype". Expected: it appears on the home page and in the Kanban flyout with a "Prototype" tag, under its client, with a full working board and dashboard exactly like any other project — no feature is missing or disabled.

Sign in as that client's manager. Expected: the prototype is visible to them immediately, tagged the same way (per the "visible to the client immediately" decision — there is no visibility toggle to test).

  • Step 7: Check both themes

Open the home page groups, the Kanban flyout (open and with a prototype tag visible), and the admin Kind column, in both light and dark. All text and pills must be legible in both.

  • Step 8: Commit any fixes
git add -A
git commit -m "fix: issues found during Phase 6 verification"

Definition of done

  1. npm run test:all passes, including the 3 new integration tests.
  2. npm run build succeeds; the colour check introduces no new matches.
  3. The home page groups projects by client; a client manager sees no header over their own single group, an owner sees one header per client.
  4. A project tile opens the dashboard; the existing dashboard↔board links still work in both directions.
  5. The sidebar's "Kanban" flyout lists clients (owner) or projects directly (client manager), and clicking a project opens its board directly.
  6. A project created with kind: "prototype" is a fully working project — same board, same dashboard, same permissions — tagged "Prototype" wherever it appears, visible to its own client immediately.
  7. No PocketBase rule changed. A client manager still cannot see another client's clients, projects, or cards, on the home page or in the flyout.

Deliberately not in this plan

  • A "promote prototype to project" workflow. Flipping kind is an edit on the existing create-time-only form fields — there is no update path for kind today because there is no general project-edit UI yet (status, repo_owner, etc. have the same gap). Adding one is a separate, larger change to /admin/projects/[projectId] and out of scope here.
  • A prototype visibility toggle. Explicitly rejected during design — a prototype is visible to its client the moment it exists.
  • Onboarding the four existing Forgejo repos. Data entry against the existing (now kind-aware) createProject flow, done directly once the repo URLs and client mapping are supplied — not a code change.
  • Restructuring /admin/projects or /admin/clients beyond the one new column. They are CRUD tables, not the browsing nav this phase is about.