Skip to content

Persona Flows — Phase A (Sidebar as four flows + FlowStepper) 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: Regroup the signed-in sidebar into four persona flows (Ask, Explore, Code, Case) driven by one config, and show a "step n of m" stepper on every page that belongs to a flow.

Architecture: A single pure config frontend/lib/navigation/flows.ts lists the four flows and their ordered steps (href, i18n key, icon, active-match rule, admin gating). app-sidebar.tsx renders its workflow groups by mapping over that config instead of hand-written JSX; a new FlowStepper editorial component resolves the current pathname against the same config and renders the flow name, step index and prev/next links. docs/reference/sidebar-map.md is rewritten from the config and a Jest test keeps the two in sync.

Tech Stack: Next.js 15 App Router, React 19, TypeScript strict, Jest + Testing Library, lucide-react icons, in-house i18n (frontend/lib/i18n, useTranslation().t(key, values) with {{placeholder}} interpolation; EN/PL symmetry enforced by pl: Translations typing, not by a test).

Spec: docs/superpowers/specs/2026-09-20-persona-flows-design.md §4 (Phase A). Issue: #690 (parent #687, overlaps sidebar tasks of #536).

Global Constraints

  • Every href the signed-in sidebar renders today must still be rendered: /, /search, /search/extractions, /collections, /schemas, /chat, /topics, /history, /precedents, /reasoning-lines; admin-only: /saved-searches, /topic-modeling, /argumentation-analysis, /judge-fingerprint, /admin. Two are added: /extract, /extractions. Nothing else hidden is promoted (/dataset-comparison stays out).
  • Signed-out sidebar branch (if (!user) in app-sidebar.tsx) is not touched; frontend/__tests__/components/app-sidebar.public-menu.test.tsx must stay green unchanged.
  • Admin gating stays exactly user?.app_metadata?.is_admin === true.
  • Editorial design system only (frontend/components/editorial/, tokens from frontend/app/globals.css); no new gradients, glass cards or bg-{indigo,purple,violet}-100 pills (docs/reference/DESIGN.md).
  • New i18n keys go into NavigationTranslations in frontend/lib/i18n/types.ts and into both frontend/lib/i18n/translations/en.ts and pl.ts; npm run typecheck fails if either file misses one.
  • docs/superpowers/ is in .gitignore:161 but tracked — commit anything under it with git add -f.
  • Worktree: .worktrees/feat-690-sidebar-flows on branch feat/690-sidebar-flows from origin/main; frontend/node_modules is a hardlink copy (cp -al) of the main checkout's — do not npm install in the worktree.
  • Commits: Conventional Commits, footer Refs #690, no AI attribution.
  • Run commands from frontend/: npx jest <path>, npx eslint --max-warnings 0 <files>, npm run typecheck, npm run validate.

File Structure

File Responsibility
frontend/lib/navigation/flows.ts (create) The only definition of flows, steps, hrefs, icons, match rules, admin gating. Pure; no React. Exports FLOWS, findFlowStep, isStepActive, visibleSteps.
frontend/lib/i18n/types.ts (modify, NavigationTranslations) Adds flowAsk, flowExplore, flowCode, flowCase, flowStep, flowLabel, searchHistory, runExtraction, extractionJobs.
frontend/lib/i18n/translations/en.ts, pl.ts (modify, navigation block) Values for the keys above.
frontend/components/app-sidebar.tsx (modify, signed-in branch only) Dashboard group stays; the three "Phase" groups are replaced by FLOWS.map(...); the admin group keeps /saved-searches, /topic-modeling, /admin (judge fingerprint and argumentation analysis move into the Case flow as adminOnly steps).
frontend/components/editorial/FlowStepper.tsx (create) + barrel export Client component: resolves usePathname() via findFlowStep, renders flow eyebrow, "Step n of m", prev/next links; null outside a flow.
frontend/components/layouts/AppLayoutWrapper.tsx (modify) Mounts <FlowStepper /> above <main> inside the scroll column.
docs/reference/sidebar-map.md (rewrite) Human-readable map generated by hand from flows.ts.
Tests frontend/__tests__/lib/navigation/flows.test.ts, frontend/__tests__/components/app-sidebar.flows.test.tsx, frontend/__tests__/components/editorial/FlowStepper.test.tsx, frontend/__tests__/docs/sidebar-map.test.ts.

Task 1: Flow config and i18n keys

Files: - Create: frontend/lib/navigation/flows.ts - Modify: frontend/lib/i18n/types.ts (interface NavigationTranslations, around line 110–165) - Modify: frontend/lib/i18n/translations/en.ts (navigation: block, lines 66–141) - Modify: frontend/lib/i18n/translations/pl.ts (navigation: block, same keys) - Test: frontend/__tests__/lib/navigation/flows.test.ts

Interfaces: - Consumes: TranslationKey from @/lib/i18n/types; LucideIcon type from lucide-react. - Produces (used by Tasks 2–4):

export type FlowId = "ask" | "explore" | "code" | "case";
export interface FlowStep {
  href: string;
  labelKey: TranslationKey;   // always `navigation.<key>`
  icon: LucideIcon;
  match: "exact" | "prefix";
  adminOnly?: boolean;
}
export interface Flow { id: FlowId; labelKey: TranslationKey; steps: readonly FlowStep[] }
export const FLOWS: readonly Flow[];
export function isStepActive(step: FlowStep, pathname: string): boolean;
export function visibleSteps(flow: Flow, isAdmin: boolean): FlowStep[];
export function findFlowStep(pathname: string, isAdmin: boolean):
  { flow: Flow; step: FlowStep; index: number; total: number } | null;
  • Step 1: Write the failing test

Create frontend/__tests__/lib/navigation/flows.test.ts:

/**
 * flows.ts is the single source of truth for the signed-in sidebar and the
 * FlowStepper. These tests pin the route inventory (spec §4, #690) so a
 * refactor cannot silently drop a destination, and pin the matching rules
 * the stepper relies on.
 */
import {
  FLOWS,
  findFlowStep,
  isStepActive,
  visibleSteps,
} from "@/lib/navigation/flows";

const allHrefs = FLOWS.flatMap((f) => f.steps.map((s) => s.href));

describe("FLOWS inventory", () => {
  it("has the four flows in order", () => {
    expect(FLOWS.map((f) => f.id)).toEqual(["ask", "explore", "code", "case"]);
  });

  it("keeps every route the sidebar rendered before #690, plus the two extraction routes", () => {
    const expected = [
      "/search", "/chat", "/history",
      "/search/extractions", "/collections", "/topics",
      "/schemas", "/extract", "/extractions",
      "/precedents", "/reasoning-lines", "/judge-fingerprint", "/argumentation-analysis",
    ];
    expect([...allHrefs].sort()).toEqual([...expected].sort());
  });

  it("does not promote hidden routes", () => {
    expect(allHrefs).not.toContain("/dataset-comparison");
    expect(allHrefs).not.toContain("/statistics");
  });

  it("marks judge fingerprint and argumentation analysis admin-only", () => {
    const caseFlow = FLOWS.find((f) => f.id === "case")!;
    const adminOnly = caseFlow.steps.filter((s) => s.adminOnly).map((s) => s.href);
    expect(adminOnly.sort()).toEqual(["/argumentation-analysis", "/judge-fingerprint"]);
  });

  it("uses only navigation.* label keys", () => {
    for (const f of FLOWS) {
      expect(f.labelKey.startsWith("navigation.")).toBe(true);
      for (const s of f.steps) expect(s.labelKey.startsWith("navigation.")).toBe(true);
    }
  });
});

describe("isStepActive", () => {
  const exact = { href: "/search", labelKey: "navigation.searchJudgments", icon: () => null, match: "exact" } as const;
  const prefix = { href: "/collections", labelKey: "navigation.researchCollections", icon: () => null, match: "prefix" } as const;

  it("exact matches only the same path", () => {
    expect(isStepActive(exact as never, "/search")).toBe(true);
    expect(isStepActive(exact as never, "/search/extractions")).toBe(false);
  });

  it("prefix matches the path and its children, not sibling prefixes", () => {
    expect(isStepActive(prefix as never, "/collections")).toBe(true);
    expect(isStepActive(prefix as never, "/collections/abc")).toBe(true);
    expect(isStepActive(prefix as never, "/collectionsX")).toBe(false);
  });
});

describe("visibleSteps / findFlowStep", () => {
  it("hides admin-only steps from non-admins and counts only visible steps", () => {
    const caseFlow = FLOWS.find((f) => f.id === "case")!;
    expect(visibleSteps(caseFlow, false).map((s) => s.href)).toEqual(["/precedents", "/reasoning-lines"]);
    expect(visibleSteps(caseFlow, true)).toHaveLength(4);
  });

  it("resolves a pathname to its flow, 1-based index and visible total", () => {
    expect(findFlowStep("/reasoning-lines/42", false)).toMatchObject({
      flow: expect.objectContaining({ id: "case" }),
      index: 2,
      total: 2,
    });
    expect(findFlowStep("/judge-fingerprint", true)).toMatchObject({ index: 3, total: 4 });
  });

  it("does not resolve an admin-only step for a non-admin", () => {
    expect(findFlowStep("/judge-fingerprint", false)).toBeNull();
  });

  it("/search/extractions belongs to Explore, not Ask, and /extract is not /extractions", () => {
    expect(findFlowStep("/search/extractions", false)?.flow.id).toBe("explore");
    expect(findFlowStep("/extract", false)?.step.href).toBe("/extract");
    expect(findFlowStep("/extractions/9", false)?.step.href).toBe("/extractions");
  });

  it("returns null outside any flow", () => {
    expect(findFlowStep("/about", false)).toBeNull();
    expect(findFlowStep("/", true)).toBeNull();
  });
});
  • Step 2: Run test to verify it fails

Run: cd frontend && npx jest __tests__/lib/navigation/flows.test.ts Expected: FAIL — Cannot find module '@/lib/navigation/flows'.

  • Step 3: Add the i18n keys

In frontend/lib/i18n/types.ts, inside export interface NavigationTranslations { ... }, after the existing phaseAnalyze: string; line add:

  // Persona flows (#690) — group labels and stepper copy
  flowAsk: string;
  flowExplore: string;
  flowCode: string;
  flowCase: string;
  /** Stepper: "Step {{n}} of {{m}}" */
  flowStep: string;
  /** aria-label of the stepper <nav> */
  flowLabel: string;
  searchHistory: string;
  runExtraction: string;
  extractionJobs: string;

In frontend/lib/i18n/translations/en.ts, inside navigation: { ... } after phaseAnalyze: '3. Analyze', add:

    flowAsk: 'Ask',
    flowExplore: 'Explore',
    flowCode: 'Code',
    flowCase: 'Case',
    flowStep: 'Step {{n}} of {{m}}',
    flowLabel: 'Workflow steps',
    searchHistory: 'Search History',
    runExtraction: 'Run Extraction',
    extractionJobs: 'Extraction Jobs',

In frontend/lib/i18n/translations/pl.ts, same position:

    flowAsk: 'Zapytaj',
    flowExplore: 'Zbadaj',
    flowCode: 'Koduj',
    flowCase: 'Sprawa',
    flowStep: 'Krok {{n}} z {{m}}',
    flowLabel: 'Kroki przepływu',
    searchHistory: 'Historia wyszukiwań',
    runExtraction: 'Uruchom ekstrakcję',
    extractionJobs: 'Zadania ekstrakcji',
  • Step 4: Write the config

Create frontend/lib/navigation/flows.ts:

/**
 * Persona flows — the single source of truth for the signed-in sidebar
 * groups and the FlowStepper (spec: docs/superpowers/specs/
 * 2026-09-20-persona-flows-design.md §4, issue #690).
 *
 * Four flows, each an ordered list of steps that map 1:1 onto existing
 * routes. Adding a route to the sidebar means adding a step here — nothing
 * else. `docs/reference/sidebar-map.md` mirrors this file and a Jest test
 * keeps the two in sync.
 */
import type { LucideIcon } from "lucide-react";
import {
  FileJson,
  Fingerprint,
  FolderOpen,
  GitBranch,
  History,
  ListChecks,
  MessageSquare,
  Play,
  Scale,
  Search,
  TrendingUp,
  Waypoints,
} from "lucide-react";

import type { TranslationKey } from "@/lib/i18n/types";

export type FlowId = "ask" | "explore" | "code" | "case";

export interface FlowStep {
  href: string;
  /** Always a `navigation.*` key — the sidebar and stepper both call t(). */
  labelKey: TranslationKey;
  icon: LucideIcon;
  /** `exact`: pathname === href. `prefix`: href itself or any child route. */
  match: "exact" | "prefix";
  /** Rendered only when `user.app_metadata.is_admin === true` (#607). */
  adminOnly?: boolean;
}

export interface Flow {
  id: FlowId;
  labelKey: TranslationKey;
  steps: readonly FlowStep[];
}

export const FLOWS: readonly Flow[] = [
  {
    id: "ask",
    labelKey: "navigation.flowAsk",
    steps: [
      { href: "/search", labelKey: "navigation.searchJudgments", icon: Search, match: "exact" },
      { href: "/chat", labelKey: "navigation.chat", icon: MessageSquare, match: "prefix" },
      { href: "/history", labelKey: "navigation.searchHistory", icon: History, match: "exact" },
    ],
  },
  {
    id: "explore",
    labelKey: "navigation.flowExplore",
    steps: [
      { href: "/search/extractions", labelKey: "navigation.searchExtractedData", icon: FileJson, match: "exact" },
      { href: "/collections", labelKey: "navigation.researchCollections", icon: FolderOpen, match: "prefix" },
      { href: "/topics", labelKey: "navigation.topicTrends", icon: TrendingUp, match: "exact" },
    ],
  },
  {
    id: "code",
    labelKey: "navigation.flowCode",
    steps: [
      { href: "/schemas", labelKey: "navigation.schemas", icon: FileJson, match: "prefix" },
      { href: "/extract", labelKey: "navigation.runExtraction", icon: Play, match: "exact" },
      { href: "/extractions", labelKey: "navigation.extractionJobs", icon: ListChecks, match: "prefix" },
    ],
  },
  {
    id: "case",
    labelKey: "navigation.flowCase",
    steps: [
      { href: "/precedents", labelKey: "navigation.precedentSearch", icon: Scale, match: "exact" },
      { href: "/reasoning-lines", labelKey: "navigation.reasoningLines", icon: Waypoints, match: "prefix" },
      { href: "/judge-fingerprint", labelKey: "navigation.judgeFingerprint", icon: Fingerprint, match: "exact", adminOnly: true },
      { href: "/argumentation-analysis", labelKey: "navigation.argumentationAnalysis", icon: GitBranch, match: "exact", adminOnly: true },
    ],
  },
];

export function isStepActive(step: FlowStep, pathname: string): boolean {
  if (step.match === "exact") return pathname === step.href;
  return pathname === step.href || pathname.startsWith(`${step.href}/`);
}

export function visibleSteps(flow: Flow, isAdmin: boolean): FlowStep[] {
  return flow.steps.filter((s) => !s.adminOnly || isAdmin);
}

export function findFlowStep(
  pathname: string,
  isAdmin: boolean,
): { flow: Flow; step: FlowStep; index: number; total: number } | null {
  for (const flow of FLOWS) {
    const steps = visibleSteps(flow, isAdmin);
    const i = steps.findIndex((s) => isStepActive(s, pathname));
    if (i !== -1) {
      return { flow, step: steps[i], index: i + 1, total: steps.length };
    }
  }
  return null;
}

Why /search/extractions resolves to Explore and not Ask: Ask's /search step is exact, so it does not swallow /search/extractions; Explore's step is exact too. Why /extract vs /extractions: /extract is exact; /extractions is prefix, and "/extract".startsWith("/extractions/") is false.

  • Step 5: Run tests and typecheck

Run: cd frontend && npx jest __tests__/lib/navigation/flows.test.ts && npm run typecheck Expected: all tests PASS; typecheck exit 0 (both translation files have every new key; pl.ts is typed Translations, so a missing key is a compile error).

  • Step 6: Lint and commit

Run: cd frontend && npx eslint --max-warnings 0 lib/navigation/flows.ts __tests__/lib/navigation/flows.test.ts lib/i18n/types.ts lib/i18n/translations/en.ts lib/i18n/translations/pl.ts

git add frontend/lib/navigation/flows.ts frontend/__tests__/lib/navigation/flows.test.ts frontend/lib/i18n/types.ts frontend/lib/i18n/translations/en.ts frontend/lib/i18n/translations/pl.ts
git commit -m "feat(nav): add persona flow config and i18n keys

Refs #690"

Task 2: Sidebar renders its workflow groups from FLOWS

Files: - Modify: frontend/components/app-sidebar.tsx — signed-in branch only: the three SidebarGroups labelled navigation.phasePlan / phaseSearch / phaseAnalyze (lines ~236–352) and the admin group (lines ~358–412). Dashboard group (lines ~218–234), header, language switcher and command-palette block stay as they are. - Test: frontend/__tests__/components/app-sidebar.flows.test.tsx

Interfaces: - Consumes: FLOWS, isStepActive, visibleSteps from @/lib/navigation/flows (Task 1). - Produces: nothing new; DOM contract is "one <a href> per visible step, grouped under a SidebarGroupLabel per flow".

  • Step 1: Write the failing test

Create frontend/__tests__/components/app-sidebar.flows.test.tsx. It mirrors the mocking style of app-sidebar.public-menu.test.tsx but with a signed-in user:

/**
 * The signed-in sidebar is rendered from lib/navigation/flows.ts (#690).
 * These tests pin: every route that was in the sidebar before the regroup is
 * still there, /extract and /extractions were added, admin-only steps are
 * hidden for non-admins, and the four flow labels appear in order.
 */
import React from 'react';
import { render, screen, within } from '@testing-library/react';

let mockUser: { app_metadata?: { is_admin?: boolean } } | null = { app_metadata: {} };

jest.mock('@/contexts/AuthContext', () => ({
  useAuth: () => ({ user: mockUser, loading: false }),
}));
jest.mock('@/contexts/ChatContext', () => ({
  useChat: () => ({ createNewChat: jest.fn() }),
}));
jest.mock('@/contexts/CommandPaletteContext', () => ({
  useCommandPaletteSafe: () => ({ open: jest.fn() }),
}));
jest.mock('next/navigation', () => ({
  usePathname: () => '/collections/abc',
}));
jest.mock('@/contexts/LanguageContext', () => ({
  useTranslation: () => ({ t: (key: string) => key }),
}));
jest.mock('@/components/language-switcher', () => ({
  LanguageSwitcherMinimal: () => null,
}));

const { AppSidebar } = require('@/components/app-sidebar');
const { SidebarProvider } = require('@/components/ui/sidebar');

function renderSidebar() {
  return render(
    <SidebarProvider>
      <AppSidebar />
    </SidebarProvider>,
  );
}

function hrefs(): string[] {
  return screen
    .getAllByRole('link')
    .map((a) => a.getAttribute('href') ?? '')
    .filter((h) => h.startsWith('/'));
}

beforeAll(() => {
  Object.defineProperty(window, 'matchMedia', {
    writable: true,
    value: jest.fn().mockImplementation((query: string) => ({
      matches: false, media: query, onchange: null,
      addListener: jest.fn(), removeListener: jest.fn(),
      addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(),
    })),
  });
});

describe('AppSidebar signed-in flows', () => {
  beforeEach(() => { mockUser = { app_metadata: {} }; });

  it('renders the four flow labels in order and no legacy phase labels', () => {
    renderSidebar();
    const labels = screen.getAllByText(/^navigation\.flow(Ask|Explore|Code|Case)$/).map((el) => el.textContent);
    expect(labels).toEqual(['navigation.flowAsk', 'navigation.flowExplore', 'navigation.flowCode', 'navigation.flowCase']);
    expect(screen.queryByText('navigation.phasePlan')).not.toBeInTheDocument();
  });

  it('keeps every pre-#690 signed-in route and adds /extract and /extractions', () => {
    renderSidebar();
    const got = hrefs();
    for (const h of ['/', '/search', '/search/extractions', '/collections', '/schemas', '/chat', '/topics', '/history', '/precedents', '/reasoning-lines', '/extract', '/extractions']) {
      expect(got).toContain(h);
    }
  });

  it('hides admin-only routes from a non-admin', () => {
    renderSidebar();
    const got = hrefs();
    for (const h of ['/judge-fingerprint', '/argumentation-analysis', '/saved-searches', '/topic-modeling', '/admin']) {
      expect(got).not.toContain(h);
    }
    expect(screen.queryByText('navigation.administration')).not.toBeInTheDocument();
  });

  it('shows admin-only routes for an admin, with judge fingerprint inside the Case flow', () => {
    mockUser = { app_metadata: { is_admin: true } };
    renderSidebar();
    const got = hrefs();
    for (const h of ['/judge-fingerprint', '/argumentation-analysis', '/saved-searches', '/topic-modeling', '/admin']) {
      expect(got).toContain(h);
    }
    const caseGroup = screen.getByText('navigation.flowCase').closest('[data-sidebar="group"]') as HTMLElement;
    expect(within(caseGroup).getByRole('link', { name: /judgeFingerprint/ })).toHaveAttribute('href', '/judge-fingerprint');
  });

  it('marks the prefix-matched step active on a child route', () => {
    renderSidebar(); // pathname mocked as /collections/abc
    const link = screen.getByRole('link', { name: /researchCollections/ });
    expect(link.closest('[data-active="true"]')).not.toBeNull();
  });
});

Note on selectors: frontend/components/ui/sidebar.tsx renders SidebarGroup with data-sidebar="group" (line 485) and SidebarMenuButton with data-active={isActive} (line 627) — verified 2026-09-20.

  • Step 2: Run test to verify it fails

Run: cd frontend && npx jest __tests__/components/app-sidebar.flows.test.tsx Expected: FAIL — first test finds navigation.phasePlan and no navigation.flowAsk; second test lacks /extract.

  • Step 3: Replace the phase groups with a map over FLOWS

In frontend/components/app-sidebar.tsx:

  1. Add the import: import { FLOWS, isStepActive, visibleSteps } from "@/lib/navigation/flows";
  2. Delete the three groups whose labels are t('navigation.phasePlan'), t('navigation.phaseSearch'), t('navigation.phaseAnalyze') — everything from the {/* Phase 1 — Plan ... */} comment through the closing </SidebarGroup> of the Phase 3 block.
  3. In their place render:
 {/* Persona flows — groups and items come from lib/navigation/flows.ts (#690) */}
 {FLOWS.map((flow) => {
 const steps = visibleSteps(flow, isAdmin);
 if (steps.length === 0) return null;
 return (
 <SidebarGroup key={flow.id} className="p-0">
 <SidebarGroupLabel className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
 {t(flow.labelKey)}
 </SidebarGroupLabel>
 <SidebarGroupContent className="px-0">
 <SidebarMenu className="space-y-1">
 {steps.map((step) => {
 const Icon = step.icon;
 const label = t(step.labelKey);
 return (
 <SidebarMenuItem key={step.href}>
 <ConditionalTooltip content={label} isIconMode={isIconMode}>
 <SidebarMenuButton asChild isActive={isStepActive(step, pathname)}>
 <Link href={step.href} prefetch={step.href === "/search" ? false : undefined}>
 <Icon />
 <span>{label}</span>
 </Link>
 </SidebarMenuButton>
 </ConditionalTooltip>
 </SidebarMenuItem>
 );
 })}
 </SidebarMenu>
 </SidebarGroupContent>
 </SidebarGroup>
 );
 })}

(prefetch={false} on /search preserves the existing behaviour of that one link — see the current <Link href="/search" prefetch={false}>.)

  1. In the admin group ({isAdmin && ( ... )}), delete the two SidebarMenuItems for /argumentation-analysis and /judge-fingerprint — they now render inside the Case flow. Keep /saved-searches, /topic-modeling, /admin.
  2. Remove icon imports that are no longer used in this file after the edit (Search, FolderOpen, FileJson, TrendingUp, MessageSquare, Scale, GitBranch, Fingerprint, Waypoints, History if none of them remain referenced — check with eslint, which flags unused imports). Keep LayoutDashboard, Bookmark, Layers, ShieldCheck, LogIn, UserPlus (dashboard, admin and public branches still use them).
  3. Update the file header comment: replace the "Navigation Philosophy" bullets with one line: - Signed-in groups are the four persona flows from lib/navigation/flows.ts (#690).

  4. Step 4: Run the new test, the public-menu test, typecheck, lint

Run: cd frontend && npx jest __tests__/components/app-sidebar.flows.test.tsx __tests__/components/app-sidebar.public-menu.test.tsx && npm run typecheck && npx eslint --max-warnings 0 components/app-sidebar.tsx __tests__/components/app-sidebar.flows.test.tsx Expected: both suites PASS; typecheck 0; eslint clean (no unused imports).

  • Step 5: Commit
git add frontend/components/app-sidebar.tsx frontend/__tests__/components/app-sidebar.flows.test.tsx
git commit -m "feat(nav): render sidebar workflow groups from the flow config

Refs #690"

Task 3: FlowStepper component, mounted in the app layout

Files: - Create: frontend/components/editorial/FlowStepper.tsx - Modify: frontend/components/editorial/index.ts (add export) - Modify: frontend/components/layouts/AppLayoutWrapper.tsx (mount above <main>, line ~112) - Test: frontend/__tests__/components/editorial/FlowStepper.test.tsx

Interfaces: - Consumes: findFlowStep, visibleSteps (Task 1); useAuth from @/contexts/AuthContext; useTranslation from @/contexts/LanguageContext; usePathname from next/navigation; Eyebrow from @/components/editorial. - Produces: export function FlowStepper(): React.JSX.Element | null — no props.

  • Step 1: Write the failing test

Create frontend/__tests__/components/editorial/FlowStepper.test.tsx:

/**
 * FlowStepper tells the user which persona flow and which step they are on
 * (#690). It reads lib/navigation/flows.ts; it renders nothing on routes that
 * belong to no flow, and admin-only steps only for admins.
 */
import React from 'react';
import { render, screen } from '@testing-library/react';

let mockPathname = '/reasoning-lines/42';
let mockUser: { app_metadata?: { is_admin?: boolean } } | null = { app_metadata: {} };

jest.mock('next/navigation', () => ({ usePathname: () => mockPathname }));
jest.mock('@/contexts/AuthContext', () => ({
  useAuth: () => ({ user: mockUser, loading: false }),
}));
jest.mock('@/contexts/LanguageContext', () => ({
  useTranslation: () => ({
    t: (key: string, values?: Record<string, string | number>) =>
      key === 'navigation.flowStep' ? `Step ${values?.n} of ${values?.m}` : key,
  }),
}));

const { FlowStepper } = require('@/components/editorial/FlowStepper');

describe('FlowStepper', () => {
  beforeEach(() => {
    mockPathname = '/reasoning-lines/42';
    mockUser = { app_metadata: {} };
  });

  it('shows the flow name, step index and prev/next links on a flow route', () => {
    render(<FlowStepper />);
    const nav = screen.getByRole('navigation', { name: 'navigation.flowLabel' });
    expect(nav).toHaveTextContent('navigation.flowCase');
    expect(nav).toHaveTextContent('Step 2 of 2');
    expect(screen.getByRole('link', { name: /precedentSearch/ })).toHaveAttribute('href', '/precedents');
    expect(screen.queryByRole('link', { name: /judgeFingerprint/ })).not.toBeInTheDocument();
  });

  it('counts admin-only steps for an admin', () => {
    mockUser = { app_metadata: { is_admin: true } };
    render(<FlowStepper />);
    expect(screen.getByRole('navigation')).toHaveTextContent('Step 2 of 4');
    expect(screen.getByRole('link', { name: /judgeFingerprint/ })).toHaveAttribute('href', '/judge-fingerprint');
  });

  it('renders nothing outside a flow', () => {
    mockPathname = '/about';
    const { container } = render(<FlowStepper />);
    expect(container).toBeEmptyDOMElement();
  });

  it('renders nothing for a signed-out visitor', () => {
    mockUser = null;
    const { container } = render(<FlowStepper />);
    expect(container).toBeEmptyDOMElement();
  });

  it('omits the previous link on the first step and the next link on the last', () => {
    mockPathname = '/search';
    render(<FlowStepper />);
    expect(screen.queryByText('common.previous')).not.toBeInTheDocument();
    expect(screen.getByRole('link', { name: /common\.next/ })).toHaveAttribute('href', '/chat');
  });
});
  • Step 2: Run test to verify it fails

Run: cd frontend && npx jest __tests__/components/editorial/FlowStepper.test.tsx Expected: FAIL — Cannot find module '@/components/editorial/FlowStepper'.

  • Step 3: Write the component

Create frontend/components/editorial/FlowStepper.tsx:

"use client";

/**
 * FlowStepper — "which persona flow am I in, and which step?" (#690).
 *
 * Reads the current pathname against lib/navigation/flows.ts and renders a
 * single hairline row: flow eyebrow · "Step n of m" · previous / next step
 * links. Renders nothing on routes outside every flow and for signed-out
 * visitors, so it is safe to mount once in AppLayoutWrapper.
 */
import Link from "next/link";
import { usePathname } from "next/navigation";

import { useAuth } from "@/contexts/AuthContext";
import { useTranslation } from "@/contexts/LanguageContext";
import { findFlowStep, visibleSteps } from "@/lib/navigation/flows";

import { Eyebrow } from "./Eyebrow";

export function FlowStepper(): React.JSX.Element | null {
  const pathname = usePathname();
  const { user } = useAuth();
  const { t } = useTranslation();

  if (!user) return null;
  const isAdmin = user.app_metadata?.is_admin === true;
  const hit = findFlowStep(pathname ?? "", isAdmin);
  if (!hit) return null;

  const steps = visibleSteps(hit.flow, isAdmin);
  const prev = hit.index > 1 ? steps[hit.index - 2] : null;
  const next = hit.index < hit.total ? steps[hit.index] : null;

  return (
    <nav
      aria-label={t("navigation.flowLabel")}
      className="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-[color:var(--rule)] bg-[color:var(--parchment)] px-6 py-2 font-mono text-xs text-[color:var(--ink-soft)]"
    >
      <Eyebrow>{t(hit.flow.labelKey)}</Eyebrow>
      <span aria-current="step">{t("navigation.flowStep", { n: hit.index, m: hit.total })}</span>
      <span className="ml-auto flex items-center gap-4">
        {prev && (
          <Link href={prev.href} className="hover:text-[color:var(--ink)]">
             {t("common.previous")}: {t(prev.labelKey)}
          </Link>
        )}
        {next && (
          <Link href={next.href} className="hover:text-[color:var(--ink)]">
            {t("common.next")}: {t(next.labelKey)} 
          </Link>
        )}
      </span>
    </nav>
  );
}

common.previous and common.next already exist in en.ts (lines 22–23) and pl.ts. Eyebrow is the existing editorial primitive (frontend/components/editorial/Eyebrow.tsx); if its props require a children string only, this usage is fine.

Add to frontend/components/editorial/index.ts (after the EditorialPagination export):

export { FlowStepper } from "./FlowStepper";
  • Step 4: Mount it in the layout

In frontend/components/layouts/AppLayoutWrapper.tsx:

  1. Add import { FlowStepper } from "@/components/editorial/FlowStepper"; next to the AppSidebar import (line 7).
  2. Change
                  <div className="flex-1 overflow-y-auto">
                    <main className="bg-background">

to

                  <div className="flex-1 overflow-y-auto">
                    <FlowStepper />
                    <main className="bg-background">
  • Step 5: Run tests, typecheck, lint

Run: cd frontend && npx jest __tests__/components/editorial/FlowStepper.test.tsx __tests__/components/app-sidebar.flows.test.tsx && npm run typecheck && npx eslint --max-warnings 0 components/editorial/FlowStepper.tsx components/editorial/index.ts components/layouts/AppLayoutWrapper.tsx __tests__/components/editorial/FlowStepper.test.tsx Expected: PASS / 0 / clean.

No test renders AppLayoutWrapper today (grep -rl AppLayoutWrapper frontend/__tests__ is empty), and frontend/tests/setup.ts:64 mocks next/navigation globally with usePathname() → '/', so any future layout test gets a stepper that renders nothing. Test files override that global mock with their own jest.mock('next/navigation', …) and must require() the component after the mock, as the tests in this plan do.

  • Step 6: Commit
git add frontend/components/editorial/FlowStepper.tsx frontend/components/editorial/index.ts frontend/components/layouts/AppLayoutWrapper.tsx frontend/__tests__/components/editorial/FlowStepper.test.tsx
git commit -m "feat(nav): add FlowStepper showing the current persona flow step

Refs #690"

Task 4: Rewrite docs/reference/sidebar-map.md and pin it to the config

Files: - Rewrite: docs/reference/sidebar-map.md - Test: frontend/__tests__/docs/sidebar-map.test.ts

Interfaces: - Consumes: FLOWS (Task 1). The test reads the markdown file from disk with fs relative to frontend/.

  • Step 1: Write the failing test

Create frontend/__tests__/docs/sidebar-map.test.ts:

/**
 * docs/reference/sidebar-map.md documented 6 routes while the sidebar rendered
 * 17 (APP_STATUS_2026-08-21 §5b called it stale). This test makes the doc a
 * mirror of lib/navigation/flows.ts: every step href must appear in the doc
 * as an inline-code route, and the doc must not list routes the sidebar does
 * not render.
 */
import { readFileSync } from "node:fs";
import path from "node:path";

import { FLOWS } from "@/lib/navigation/flows";

const DOC = path.resolve(__dirname, "../../../docs/reference/sidebar-map.md");
const ALWAYS = ["/", "/saved-searches", "/topic-modeling", "/admin"]; // dashboard + admin group, defined in app-sidebar.tsx

function routesInDoc(): string[] {
  const md = readFileSync(DOC, "utf8");
  const seen = new Set<string>();
  for (const m of md.matchAll(/`(\/[a-z0-9\-\/]*)`/g)) seen.add(m[1]);
  return [...seen];
}

describe("docs/reference/sidebar-map.md", () => {
  const expected = new Set([...ALWAYS, ...FLOWS.flatMap((f) => f.steps.map((s) => s.href))]);

  it("lists every route the sidebar renders", () => {
    const doc = new Set(routesInDoc());
    for (const href of expected) expect(doc.has(href)).toBe(true);
  });

  it("does not list routes the sidebar does not render", () => {
    for (const href of routesInDoc()) {
      if (href.startsWith("/documents/") || href.startsWith("/auth/")) continue; // reader + sign-in are mentioned in prose
      expect(expected.has(href)).toBe(true);
    }
  });
});
  • Step 2: Run test to verify it fails

Run: cd frontend && npx jest __tests__/docs/sidebar-map.test.ts Expected: FAIL — the current doc lacks /extract, /chat, /precedents … and lists /schemas/base and /dataset-comparison, which the sidebar does not render.

  • Step 3: Rewrite the doc

Replace the whole of docs/reference/sidebar-map.md with:

# Sidebar reference

The signed-in sidebar is rendered from one config,
`frontend/lib/navigation/flows.ts` (#690). It shows the **Dashboard**, four
**persona flows**, and — for admins only — an **Administration** group. A
`FlowStepper` row under the top bar tells you which flow and which step the
current page belongs to. This page mirrors that config; a Jest test
(`frontend/__tests__/docs/sidebar-map.test.ts`) fails when the two drift.

Design: `docs/superpowers/specs/2026-09-20-persona-flows-design.md` §2, §4.

| Item | Route | What it does |
|---|---|---|
| **Dashboard** | `/` | Corpus statistics, recent work, entry points to every flow (#646). |

## Ask — find and read judgments

| Step | Route | What it does |
|---|---|---|
| 1. Search Judgments | `/search` | Keyword (Meilisearch) and semantic (pgvector) search over the corpus; anonymous. |
| 2. Chat | `/chat` | RAG chat over retrieved judgments, with citations. |
| 3. Search History | `/history` | Your past queries. |

## Explore — cohorts and statistics

| Step | Route | What it does |
|---|---|---|
| 1. Search Extracted Data | `/search/extractions` | Filter the corpus on the 51 pre-extracted base fields (facets, NL filter, CSV export). Rows open the reader at `/documents/[id]`. |
| 2. Research Collections | `/collections` | Named judgment sets — the working folder for a research question. |
| 3. Topic Trends | `/topics` | Popular and trending search topics. |

## Code — your own extraction schema

| Step | Route | What it does |
|---|---|---|
| 1. Schemas | `/schemas` | Schema library, including the base coding schema and the LLM schema builder. |
| 2. Run Extraction | `/extract` | Pick a collection and a schema, start an extraction job. |
| 3. Extraction Jobs | `/extractions` | Job list and per-job results. |

## Case — from a fact pattern to a memo

| Step | Route | What it does |
|---|---|---|
| 1. Precedent Search | `/precedents` | Describe a fact pattern; get similar judgments with matching factors. |
| 2. Reasoning Lines | `/reasoning-lines` | Lines of judicial reasoning across the selected judgments. |
| 3. Judge Fingerprint *(admin)* | `/judge-fingerprint` | Per-judge statistics. |
| 4. Argumentation Analysis *(admin)* | `/argumentation-analysis` | LLM analysis of argument structure. |

## Administration (admins only)

| Item | Route | What it does |
|---|---|---|
| Saved Searches | `/saved-searches` | Persisted queries and filters. |
| Topic Modeling | `/topic-modeling` | UMAP topic map (empty until `judgments.umap_x` is populated). |
| Admin Panel | `/admin` | Users, stats, system, content. |

## Not in the sidebar

Reachable by URL or in-page link only: `/documents/[id]` (the reader — every
result row links here), the static PL/UK comparison, `/schema-chat`,
`/settings`, `/statistics`, `/help`, `/about`, `/changelog`. The Statistics
view over a cohort (spec Phase B) will be added to the Explore flow when it
ships.
  • Step 4: Run the test and lint

Run: cd frontend && npx jest __tests__/docs/sidebar-map.test.ts && npx eslint --max-warnings 0 __tests__/docs/sidebar-map.test.ts Expected: PASS; clean. If node:fs imports are rejected by the Jest environment, switch to import { readFileSync } from "fs".

  • Step 5: Commit
git add docs/reference/sidebar-map.md frontend/__tests__/docs/sidebar-map.test.ts
git commit -m "docs(nav): regenerate sidebar map from the flow config and pin it with a test

Refs #690"

Task 5: Full verification and PR

Files: none new. This task exists so the branch is proven green as a whole, not test-by-test.

  • Step 1: Full frontend gate

Run: cd frontend && npm run validate && npx jest Expected: validate exit 0 (deps:check, lint, typecheck); Jest all suites pass. Quote the Tests: summary line in the PR body.

  • Step 2: Route-contract E2E locally (optional — CI runs it as a required check)

Per docs/how-to/run-live-e2e-verification.md and the route-contract harness in frontend/tests/route-contract-e2e/: build with the CI env vars first, then run the npm script. If skipped locally, say so in the PR body; Frontend Route Contract (Chromium) is required on the PR and will run it.

  • Step 3: Commit the plan file into the branch (gitignored path)
git add -f docs/superpowers/plans/2026-09-20-persona-flows-phase-a.md
git commit -m "docs(nav): add phase A implementation plan

Refs #690"
  • Step 4: Review, push, PR

Spawn a reviewer on git diff origin/main...HEAD before opening the PR (global rule). Then:

git push -u origin feat/690-sidebar-flows
gh pr create --base main --title "feat(nav): sidebar as four persona flows with a flow stepper" --body-file <body>

PR body: summary (four flows from flows.ts, FlowStepper, sidebar-map regenerated + pinned), the Tests: line from Step 1, note whether route-contract was run locally, Closes #690, Refs #687 #536.

  • Step 5: Merge

After the seven required checks are green: gh pr merge <n> --merge --delete-branch, then git worktree remove .worktrees/feat-690-sidebar-flows. Tick the three sidebar checkboxes in #536 with a comment pointing at the PR.