Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
466 changes: 466 additions & 0 deletions docs/superpowers/plans/2026-06-17-breadcrumb-navigation.md

Large diffs are not rendered by default.

129 changes: 129 additions & 0 deletions docs/superpowers/specs/2026-06-17-breadcrumb-navigation-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Breadcrumb Navigation for Nested Dashboard Pages Design

**Date:** 2026-06-17
**Status:** Approved design (pending spec review)
**Branch:** `feat/breadcrumb-nav` (off `main`)

## Problem

The dashboard has only top-level global navigation (sidebar / mobile bottom-tabs →
Overview, Events, Settings). Once an organizer drills into an event's nested pages
(`/dashboard/events/[eventId]` and its `attendees` / `configure` / `engine` children),
there is **no upward or contextual navigation**: no breadcrumb trail and no "back to this
event" link. The only ways back to the specific event are the browser back button or the
sidebar (which lands on the top-level Events list, losing context). The Settings → Guide
page is the lone exception, with an ad-hoc `← Back to Settings` link.

## Goals

- Every nested dashboard page shows where it is and lets the user climb back to its parent(s).
- One reusable, accessible, responsive breadcrumb pattern across the app.
- Minimal, low-risk change: a presentational component dropped onto each nested page.

## Non-Goals

- Wizard-style Previous/Next stepping through the setup flow (explicitly deferred).
- Any change to the global sidebar / mobile tab bar.
- Navigation on the public attendee pages (`/join/[slug]`, `/results/[allocationId]`) — these
are standalone landing pages and stay as-is.

## Components

### 1. `components/layout/breadcrumb.tsx` (new) — generic, presentational
- Props: `items: { label: React.ReactNode; href?: string }[]`. `label` is `ReactNode` so a
caller can pass a skeleton element **or an icon + text** (e.g. `<><Settings/> Settings</>`) —
no separate `icon` field is needed (would be redundant API surface; YAGNI).
- Renders semantic breadcrumb markup: `<nav aria-label="Breadcrumb"><ol>…</ol></nav>` with one
`<li>` per segment. Items with an `href` render as Next `<Link>`s
(`text-muted-foreground hover:text-foreground`); the **last** item renders as plain text with
`aria-current="page"` (muted, not a link) regardless of whether it has an `href`.
- Segments separated by a `ChevronRight` icon (lucide), `aria-hidden`, inside the `<ol>` between
items. The `<ol>` is `flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm` — `flex-wrap` so it
never overflows on narrow screens, and an explicit **`gap-y-1`** so wrapped lines don't collide
vertically.
- Pure/presentational — no data fetching. Independently testable.

### 2. `components/layout/event-breadcrumb.tsx` (new) — two event variants

Breadcrumbs are presentation-first: pages that already hold the event title feed it in
directly (no breadcrumb-initiated fetch). Only the two pages that have no other source for
the title self-resolve it. Both variants share private `items(...)` and skeleton helpers and
render the generic `<Breadcrumb>`.

Shared item-building rule:
- Always starts with `{ label: "Events", href: "/dashboard/events" }`.
- Then the event segment. If `current` is provided, the event segment is a link
(`href: "/dashboard/events/{eventId}"`) and `{ label: current }` is appended as the leaf.
If `current` is omitted, the event segment is the leaf (used on the event-detail page) —
no `href`, so the generic component marks it `aria-current`.
- The event-title label is a **skeleton element**
(`<span data-testid="breadcrumb-title-skeleton" aria-hidden class="inline-block h-4 w-24 align-middle rounded bg-muted animate-pulse" />`)
whenever the title is unavailable, so swapping in the real title never does a text-swap
flicker / layout jump.

- **`EventBreadcrumb({ eventId, title?, current? })` — pure (no data fetching).**
Used by pages that already loaded the event (**event detail**, **attendees**). Renders the
skeleton when `title` is `undefined` (e.g. attendees' first paint before its `useEvent`
resolves), the real `title` otherwise. This is the component that satisfies "breadcrumbs are
pure presentation" for the pages where the data is already in hand.

- **`EventBreadcrumbAuto({ eventId, current? })` — self-resolving (`"use client"`).**
Used only by the two pages that don't otherwise load the event: the **engine** page (loads
participants/allocations, not the event) and the **configure** page (a server component that
loads nothing). Calls `useEvent(eventId)`; shows the skeleton while `isLoading`, falls back to
the literal `"Event"` only if loading finished with no event (rare — the page itself 404s),
else `event.title`. Self-resolving here is the pragmatic choice: forcing purity would mean a
server-side fetch in Configure (extra round-trip + auth coupling) or a title-only fetch in
Engine — both strictly worse than this small encapsulated reader.

### 3. Page integrations (one line each, at the top of the page content)
- `app/dashboard/events/[eventId]/page.tsx` (has `event` post-guard) →
`<EventBreadcrumb eventId={eventId} title={event.title} />` (leaf = event title).
- `app/dashboard/events/[eventId]/attendees/page.tsx` (has `useEvent`) →
`<EventBreadcrumb eventId={eventId} title={event?.title} current="Attendees" />`.
- `app/dashboard/events/[eventId]/engine/page.tsx` (no event load) →
`<EventBreadcrumbAuto eventId={eventId} current="Allocation" />`.
- `app/dashboard/events/[eventId]/configure/page.tsx` (server component) →
`<EventBreadcrumbAuto eventId={eventId} current="Configure" />` (a client island inside the
server page).
- `app/dashboard/settings/guide/page.tsx` → replace the existing `← Back to Settings` link with
the **generic** `<Breadcrumb items={[{ label: "Settings", href: "/dashboard/settings" }, { label: "Guide" }]} />`
(no event fetch needed).

The breadcrumb renders **above** each page's existing `<h1>` title block; **the page `<h1>`
remains the primary heading — it is not shrunk or removed because the breadcrumb shows the title.**
The leaf segment uses the noun **"Allocation"** (not "Run Allocation") for consistency with
`Attendees` / `Configure` / `Guide`.

## Data Flow

`useEvent(eventId)` (existing `hooks/use-events` SWR hook returning `{ event, isLoading }`) is the
only data dependency, and only for `EventBreadcrumb`. The generic `Breadcrumb` is pure.

## Error / Edge Handling

- Event still loading or not found → title falls back to `"Event"`; the breadcrumb still renders
and remains navigable. The page's own loading/not-found UI is unchanged.
- Long event titles: the breadcrumb wraps (flex-wrap); no truncation logic needed for v1.

## Testing (Vitest, `tests/components/`)

- `breadcrumb.test.tsx`:
- renders every segment's label;
- parent items (with `href`) are links pointing to the right path;
- the last item is **not** a link and carries `aria-current="page"`.
- `event-breadcrumb.test.tsx`:
- **`EventBreadcrumb` (pure, no mock needed):** with `title` set + `current`, the title is a
link and `current` is the `aria-current` leaf; with `title` set and no `current`, the title
is the leaf; with `title` undefined, the skeleton (`data-testid="breadcrumb-title-skeleton"`)
renders.
- **`EventBreadcrumbAuto` (mock `@/hooks/use-events` `useEvent`):** shows the resolved title +
`current` leaf when loaded; shows the skeleton while `isLoading`; falls back to `"Event"` when
loaded with no event.
- Gates: `tsc --noEmit`, `npm run lint`, `npm test`, production `build`.

## Out of Scope

- Wizard Previous/Next navigation through Attendees → Configure → Run Allocation.
- Truncation/ellipsis of very long titles (revisit only if it becomes a problem).
- Public attendee pages and the global nav.
2 changes: 2 additions & 0 deletions frontend/app/dashboard/events/[eventId]/attendees/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { useEvent } from "@/hooks/use-events";
import { AttendeesTable } from "@/components/attendees/attendees-table";
import { QRDisplay } from "@/components/attendees/qr-display";
import { Skeleton } from "@/components/ui/skeleton";
import { EventBreadcrumb } from "@/components/layout/event-breadcrumb";

export default function AttendeesPage({ params }: { params: Promise<{ eventId: string }> }) {
const { eventId } = use(params);
const { event } = useEvent(eventId);

return (
<div className="space-y-6">
<EventBreadcrumb eventId={eventId} title={event?.title} current="Attendees" />
<div>
<h1 className="text-xl font-bold">Attendees</h1>
<p className="text-sm text-muted-foreground">Manage participants and share the registration QR code</p>
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/dashboard/events/[eventId]/configure/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { ConfigForm } from "@/components/configure/config-form";
import { EventBreadcrumbAuto } from "@/components/layout/event-breadcrumb";

export default async function ConfigurePage({ params }: { params: Promise<{ eventId: string }> }) {
const { eventId } = await params;
return (
<div className="space-y-6">
<EventBreadcrumbAuto eventId={eventId} current="Configure" />
<div>
<h1 className="text-xl font-bold">Configure Allocation</h1>
<p className="text-sm text-muted-foreground">Set balancing weights and role constraints</p>
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/dashboard/events/[eventId]/engine/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useSession } from "next-auth/react";
import { fetchAPI } from "@/lib/api";
import { RunPanel } from "@/components/engine/run-panel";
import { ResultsGrid } from "@/components/engine/results-grid";
import { EventBreadcrumbAuto } from "@/components/layout/event-breadcrumb";
import type { Allocation } from "@/hooks/use-allocation";

export default function EnginePage({ params }: { params: Promise<{ eventId: string }> }) {
Expand Down Expand Up @@ -41,6 +42,7 @@ export default function EnginePage({ params }: { params: Promise<{ eventId: stri

return (
<div className="space-y-6">
<EventBreadcrumbAuto eventId={eventId} current="Allocation" />
<div>
<h1 className="text-xl font-bold">Allocation Engine</h1>
<p className="text-sm text-muted-foreground">Generate balanced teams from registered participants</p>
Expand Down
2 changes: 2 additions & 0 deletions frontend/app/dashboard/events/[eventId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Users, Settings, Zap, ArrowRight, Archive, Trash2, Calendar } from "lucide-react";
import { EventBreadcrumb } from "@/components/layout/event-breadcrumb";

export default function EventPage({ params }: { params: Promise<{ eventId: string }> }) {
const { eventId } = use(params);
Expand Down Expand Up @@ -77,6 +78,7 @@ export default function EventPage({ params }: { params: Promise<{ eventId: strin

return (
<div className="space-y-6">
<EventBreadcrumb eventId={eventId} title={event.title} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">{event.title}</h1>
Expand Down
7 changes: 2 additions & 5 deletions frontend/app/dashboard/settings/guide/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { GUIDE_STEPS } from "@/lib/guide-steps";
import { Breadcrumb } from "@/components/layout/breadcrumb";

export default function GuidePage() {
return (
<div className="space-y-8 max-w-3xl">
<div>
<Link href="/dashboard/settings" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" /> Back to Settings
</Link>
<Breadcrumb items={[{ label: "Settings", href: "/dashboard/settings" }, { label: "Guide" }]} />
<h1 className="text-2xl font-bold tracking-tight mt-2">How SquadSync works</h1>
<p className="text-sm text-muted-foreground mt-1">A quick walkthrough from sign-in to published teams.</p>
</div>
Expand Down
46 changes: 46 additions & 0 deletions frontend/components/layout/breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Fragment } from "react";
import Link from "next/link";
import { ChevronRight } from "lucide-react";

export interface BreadcrumbItem {
label: React.ReactNode;
href?: string;
}

export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
return (
<nav aria-label="Breadcrumb">
<ol className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
{items.map((item, i) => {
const isLast = i === items.length - 1;
return (
<Fragment key={i}>
<li className="flex items-center">
{item.href && !isLast ? (
<Link
href={item.href}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{item.label}
</Link>
) : (
<span
className={isLast ? "font-medium text-foreground" : "text-muted-foreground"}
aria-current={isLast ? "page" : undefined}
>
{item.label}
</span>
)}
</li>
{!isLast && (
<li aria-hidden className="flex items-center text-muted-foreground/50">
<ChevronRight className="h-3.5 w-3.5" />
</li>
)}
</Fragment>
);
})}
</ol>
</nav>
);
}
58 changes: 58 additions & 0 deletions frontend/components/layout/event-breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use client";

import { useEvent } from "@/hooks/use-events";
import { Breadcrumb, type BreadcrumbItem } from "@/components/layout/breadcrumb";

// Fixed-size placeholder so swapping in the real title never shifts the layout.
const titleSkeleton = (
<span
data-testid="breadcrumb-title-skeleton"
aria-hidden
className="inline-block h-4 w-24 align-middle rounded bg-muted animate-pulse"
/>
);

function buildItems(
eventId: string,
titleLabel: BreadcrumbItem["label"],
current?: string,
): BreadcrumbItem[] {
const items: BreadcrumbItem[] = [
{ label: "Events", href: "/dashboard/events" },
current
? { label: titleLabel, href: `/dashboard/events/${eventId}` }
: { label: titleLabel },
];
if (current) items.push({ label: current });
return items;
}

// Pure presentation: the page supplies the title it already loaded. Renders a
// skeleton until `title` is defined (e.g. a client page's first paint).
export function EventBreadcrumb({
eventId,
title,
current,
}: {
eventId: string;
title?: string;
current?: string;
}) {
return <Breadcrumb items={buildItems(eventId, title ?? titleSkeleton, current)} />;
}

// Self-resolving: for pages that don't otherwise load the event (the engine
// page and the server-rendered configure page). Skeleton while loading; "Event"
// only as a rare loaded-but-missing fallback (the page itself 404s then).
export function EventBreadcrumbAuto({
eventId,
current,
}: {
eventId: string;
current?: string;
}) {
const { event, isLoading } = useEvent(eventId);
const titleLabel: BreadcrumbItem["label"] =
isLoading && !event ? titleSkeleton : (event?.title ?? "Event");
return <Breadcrumb items={buildItems(eventId, titleLabel, current)} />;
}
47 changes: 47 additions & 0 deletions frontend/tests/components/breadcrumb.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Breadcrumb } from "@/components/layout/breadcrumb";

describe("Breadcrumb", () => {
it("renders all segment labels", () => {
render(<Breadcrumb items={[
{ label: "Events", href: "/dashboard/events" },
{ label: "My Event", href: "/dashboard/events/1" },
{ label: "Attendees" },
]} />);
expect(screen.getByText("Events")).toBeInTheDocument();
expect(screen.getByText("My Event")).toBeInTheDocument();
expect(screen.getByText("Attendees")).toBeInTheDocument();
});

it("renders parent items (with href, not last) as links", () => {
render(<Breadcrumb items={[
{ label: "Events", href: "/dashboard/events" },
{ label: "Attendees" },
]} />);
expect(screen.getByRole("link", { name: "Events" })).toHaveAttribute("href", "/dashboard/events");
});

it("renders the last item as the current page, not a link", () => {
render(<Breadcrumb items={[
{ label: "Events", href: "/dashboard/events" },
{ label: "Attendees" },
]} />);
expect(screen.queryByRole("link", { name: "Attendees" })).toBeNull();
expect(screen.getByText("Attendees")).toHaveAttribute("aria-current", "page");
});

it("never links the last item even if it has an href", () => {
render(<Breadcrumb items={[
{ label: "Events", href: "/dashboard/events" },
{ label: "My Event", href: "/dashboard/events/1" },
]} />);
expect(screen.queryByRole("link", { name: "My Event" })).toBeNull();
expect(screen.getByText("My Event")).toHaveAttribute("aria-current", "page");
});

it("exposes a labelled breadcrumb nav", () => {
render(<Breadcrumb items={[{ label: "Events", href: "/dashboard/events" }, { label: "X" }]} />);
expect(screen.getByRole("navigation", { name: /breadcrumb/i })).toBeInTheDocument();
});
});
Loading
Loading