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
hrefthe 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-comparisonstays out). - Signed-out sidebar branch (
if (!user)inapp-sidebar.tsx) is not touched;frontend/__tests__/components/app-sidebar.public-menu.test.tsxmust stay green unchanged. - Admin gating stays exactly
user?.app_metadata?.is_admin === true. - Editorial design system only (
frontend/components/editorial/, tokens fromfrontend/app/globals.css); no new gradients, glass cards orbg-{indigo,purple,violet}-100pills (docs/reference/DESIGN.md). - New i18n keys go into
NavigationTranslationsinfrontend/lib/i18n/types.tsand into bothfrontend/lib/i18n/translations/en.tsandpl.ts;npm run typecheckfails if either file misses one. docs/superpowers/is in.gitignore:161but tracked — commit anything under it withgit add -f.- Worktree:
.worktrees/feat-690-sidebar-flowson branchfeat/690-sidebar-flowsfromorigin/main;frontend/node_modulesis a hardlink copy (cp -al) of the main checkout's — do notnpm installin 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:
- Add the import:
import { FLOWS, isStepActive, visibleSteps } from "@/lib/navigation/flows"; - 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. - 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}>.)
- In the admin group (
{isAdmin && ( ... )}), delete the twoSidebarMenuItems for/argumentation-analysisand/judge-fingerprint— they now render inside the Case flow. Keep/saved-searches,/topic-modeling,/admin. - Remove icon imports that are no longer used in this file after the edit (
Search,FolderOpen,FileJson,TrendingUp,MessageSquare,Scale,GitBranch,Fingerprint,Waypoints,Historyif none of them remain referenced — check with eslint, which flags unused imports). KeepLayoutDashboard,Bookmark,Layers,ShieldCheck,LogIn,UserPlus(dashboard, admin and public branches still use them). -
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). -
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):
- Step 4: Mount it in the layout
In frontend/components/layouts/AppLayoutWrapper.tsx:
- Add
import { FlowStepper } from "@/components/editorial/FlowStepper";next to theAppSidebarimport (line 7). - Change
to
- 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.