wulf-pulse/.planning/phases/02-mobile-shell-more-drawer/02-01-PLAN.md
lorentz 9658640c04 fix(04-01): restore phase 2/3 work lost by worktree soft-reset
The soft reset to 77073ba inadvertently staged deletions of all phase 2
and 3 artifacts. This commit restores them from their source commits so
subsequent task commits build on the complete prior-phase foundation:
- components/mobile/{BottomNav,HeaderBar,KpiCardMobile,MoreDrawer,NeedsAttentionStrip,WorkerStatusRow}
- app/mobile/layout.tsx, dashboard/page.tsx, analyzer/page.tsx
- app/api/mobile/dashboard/route.ts
- All .planning/** files from phases 01-04
- CLAUDE.md, app/layout.tsx, app/styles/brand.css, public/manifest.json
2026-05-03 18:01:14 -04:00

36 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
02-mobile-shell-more-drawer 01 execute 1
components/mobile/HeaderBar.tsx
components/mobile/BottomNav.tsx
components/mobile/MoreDrawer.tsx
app/mobile/analyzer/page.tsx
true
SHELL-02
SHELL-03
SHELL-04
SHELL-06
NAV-01
NAV-02
NAV-03
DRAWER-01
DRAWER-02
DRAWER-03
DRAWER-04
DRAWER-05
truths artifacts key_links
components/mobile/HeaderBar.tsx exists and exports a HeaderBar component that renders the WulfMark + 'Pulse' wordmark linking to /mobile/dashboard, a Bell button (aria-label='Notifications', empty onClick), and an avatar-circle button that triggers the MoreDrawer
components/mobile/BottomNav.tsx exists and exports a BottomNav component with 5 cells: 4 routed tabs (Dashboard, Tickets, Finance, Analyzer) and a 5th 'More' button that opens the drawer
components/mobile/MoreDrawer.tsx exists and exports a MoreDrawer component built on shadcn Sheet (side='right') with three sections: Mobile sections (Engagement), Full site (Quotes, Configuration Items, Backup Status, Ticket Digest, Admin/Sync each with ExternalLink icon), Account (current user read-only + Sign out)
MoreDrawer's open/close state is controlled via props (open, onOpenChange) so two triggers (header avatar + bottom-nav More button) can share one drawer
BottomNav's active-tab detection uses pathname.startsWith(href) so /mobile/tickets/123 highlights the Tickets tab
MoreDrawer Sign out button calls signOut() then router.push('/auth/sign-in')
app/mobile/analyzer/page.tsx exists as a minimal placeholder so the bottom-nav Analyzer tab does not 404 before Phase 6
TypeScript compiles (npx tsc --noEmit) and Next.js builds (npm run build) successfully
path provides contains
components/mobile/HeaderBar.tsx Sticky header — WulfMark+wordmark link, Bell placeholder, avatar trigger for drawer export function HeaderBar
path provides contains
components/mobile/BottomNav.tsx Fixed bottom tab bar — 4 tabs + More button export function BottomNav
path provides contains
components/mobile/MoreDrawer.tsx shadcn Sheet drawer with three sections export function MoreDrawer
path provides contains
app/mobile/analyzer/page.tsx Placeholder route so the new Analyzer tab resolves until Phase 6 ships export default function
from to via pattern
components/mobile/HeaderBar.tsx components/branding/wulf-mark.tsx WulfMark import (variant='mark' and variant='wordmark') from ['"]@/components/branding/wulf-mark['"]
from to via pattern
components/mobile/MoreDrawer.tsx components/ui/sheet.tsx Sheet, SheetContent, SheetTrigger imports from ['"]@/components/ui/sheet['"]
from to via pattern
components/mobile/MoreDrawer.tsx lib/auth-client.ts signOut + useSession imports from ['"]@/lib/auth-client['"]
from to via pattern
components/mobile/BottomNav.tsx /mobile/analyzer Analyzer tab href /mobile/analyzer
from to via pattern
components/mobile/BottomNav.tsx MoreDrawer trigger onMoreClick prop or onOpenChange invocation onMoreClick|onOpenChange
Build the three new shell components (HeaderBar, BottomNav, MoreDrawer) and a minimal `/mobile/analyzer` placeholder page, all under `components/mobile/*` and `app/mobile/analyzer/page.tsx`. None of these files are imported by the current shell, so this plan adds files only — the existing `app/mobile/layout.tsx` and `app/mobile/nav/page.tsx` keep working until Plan 02 wires the new pieces in.

Purpose: Lay down the three reusable shell pieces with literal JSX, controlled drawer state, and route entries so Plan 02 can replace layout.tsx in a single small change.

Output: 4 new files. Build still passes. Existing /mobile routes unchanged in behavior.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/ROADMAP.md @.planning/REQUIREMENTS.md @.planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md @docs/superpowers/specs/2026-05-03-mobile-shell-design.md @CLAUDE.md @DESIGN.md @app/mobile/layout.tsx @app/mobile/nav/page.tsx @app/styles/brand.css @components/ui/sheet.tsx @components/ui/button.tsx @components/branding/wulf-mark.tsx @components/navigation/user-menu.tsx @lib/auth-client.ts

From components/branding/wulf-mark.tsx:

export function WulfMark(props: {
  variant?: 'mark' | 'wordmark';
  className?: string;
  alt?: string;
  priority?: boolean;
}): JSX.Element;

From lib/auth-client.ts (Better Auth client):

export const signIn, signOut, useSession, getSession;
// useSession() returns { data: session | null, ... }
// session.user has: { name?: string, email?: string, role?: string, image?: string | null }

The UserMenu component (components/navigation/user-menu.tsx) shows the canonical pattern:

const initials = (user.name ?? user.email ?? '?')
  .split(/[\s@]/)
  .filter(Boolean)
  .slice(0, 2)
  .map((p) => p[0]?.toUpperCase())
  .join('');
async function handleSignOut() {
  await signOut();
  router.push('/auth/sign-in');
}

Reuse this pattern. Do NOT add the shadcn avatar primitive — it is not present in components/ui/ and we do not need it; the initials-circle pattern matches the existing UserMenu.

From components/ui/sheet.tsx:

export function Sheet(props: { open?: boolean; onOpenChange?: (open: boolean) => void; children: ReactNode });
export function SheetTrigger(props: { asChild?: boolean; children: ReactNode });
export function SheetContent(props: { side?: "top" | "right" | "bottom" | "left"; className?: string; showCloseButton?: boolean; children: ReactNode });
export function SheetHeader(props: { className?: string; children: ReactNode });
export function SheetTitle(props: { className?: string; children: ReactNode });
export function SheetDescription(props: { className?: string; children: ReactNode });
export function SheetClose(props: { asChild?: boolean; children: ReactNode });

A SheetContent must contain a SheetTitle (Radix accessibility requirement) — wrap headings in SheetHeader → SheetTitle. Use SheetDescription (or visually-hidden description) if needed.

From components/ui/button.tsx:

export function Button(props: ButtonHTMLAttributes & {
  variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
  size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
  asChild?: boolean;
});

CSS utilities available in app/styles/brand.css (already imported by globals.css):

  • pt-safepadding-top: env(safe-area-inset-top)
  • pb-safepadding-bottom: env(safe-area-inset-bottom)

<scope_boundary> This plan does not touch:

  • app/mobile/layout.tsx (Plan 02 rewrites it)
  • app/mobile/nav/page.tsx (Plan 02 deletes it)
  • Anything in app/mobile/dashboard/*, app/mobile/tickets/*, app/mobile/finance/* (out of phase)
  • The shadcn avatar primitive — do NOT add it; we use the existing initials-circle pattern from UserMenu.
  • components/navigation/app-navigation.tsx — desktop nav, untouched. </scope_boundary>
Task 1: Create components/mobile/MoreDrawer.tsx (Sheet drawer with 3 sections + Sign out) components/mobile/MoreDrawer.tsx - components/ui/sheet.tsx (Sheet/SheetContent/SheetTitle/SheetClose API and side="right" behavior) - lib/auth-client.ts (verify `signOut` and `useSession` are exported) - components/navigation/user-menu.tsx (reference for initials pattern + signOut handler) - app/mobile/nav/page.tsx (reference for the existing DESKTOP_LINKS list to migrate) - .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (locked: side="right", three sections, Sign out flow) Create the file `components/mobile/MoreDrawer.tsx` with literal contents below (controlled `open`/`onOpenChange` so the same drawer can be triggered from the header avatar AND the bottom-nav More button):
'use client';

/* MoreDrawer — phase 02 (DRAWER-01..05).
 *
 * shadcn Sheet (side="right") with three top-to-bottom sections:
 *   1. Mobile sections — Engagement (in-shell route, no ExternalLink hint)
 *   2. Full site       — desktop-only routes, each with ExternalLink hint
 *   3. Account         — current user (read-only) + Sign out
 *
 * Open state is controlled by the parent so the header avatar AND the
 * bottom-nav More cell can both trigger this single drawer. */

import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
  ExternalLink,
  FileText,
  Server,
  HardDrive,
  BarChart3,
  Settings,
  Users,
  LogOut,
} from 'lucide-react';
import {
  Sheet,
  SheetContent,
  SheetHeader,
  SheetTitle,
  SheetDescription,
  SheetClose,
} from '@/components/ui/sheet';
import { useSession, signOut } from '@/lib/auth-client';
import { toast } from 'sonner';

const MOBILE_SECTIONS = [
  { href: '/mobile/engagement', label: 'Engagement', icon: Users },
];

const DESKTOP_LINKS = [
  { href: '/quotes',               label: 'Quotes',              icon: FileText },
  { href: '/configuration-items',  label: 'Configuration Items', icon: Server },
  { href: '/backup-status',        label: 'Backup Status',       icon: HardDrive },
  { href: '/admin/ticket-digest',  label: 'Ticket Digest',       icon: BarChart3 },
  { href: '/admin/sync',           label: 'Admin / Sync',        icon: Settings },
];

interface MoreDrawerProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

export function MoreDrawer({ open, onOpenChange }: MoreDrawerProps) {
  const router = useRouter();
  const { data: session } = useSession();
  const user = session?.user as
    | { name?: string; email?: string; image?: string | null }
    | undefined;

  const initials = (user?.name ?? user?.email ?? '?')
    .split(/[\s@]/)
    .filter(Boolean)
    .slice(0, 2)
    .map((p) => p[0]?.toUpperCase())
    .join('');

  async function handleSignOut() {
    try {
      await signOut();
      router.push('/auth/sign-in');
    } catch (e) {
      toast.error('Sign out failed');
      console.error('Sign out failed:', e);
    }
  }

  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent side="right" className="w-80 sm:max-w-sm flex flex-col">
        <SheetHeader>
          <SheetTitle>Menu</SheetTitle>
          <SheetDescription className="sr-only">
            Navigation, full-site links, and account actions.
          </SheetDescription>
        </SheetHeader>

        {/* Section 1: Mobile sections (DRAWER-03) */}
        <div className="px-4">
          <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
            Mobile sections
          </p>
          <div className="rounded-2xl border divide-y overflow-hidden">
            {MOBILE_SECTIONS.map(({ href, label, icon: Icon }) => (
              <SheetClose asChild key={href}>
                <Link
                  href={href}
                  className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
                >
                  <Icon className="w-4 h-4 text-muted-foreground shrink-0" />
                  <span className="text-sm flex-1">{label}</span>
                </Link>
              </SheetClose>
            ))}
          </div>
        </div>

        {/* Section 2: Full site (DRAWER-04) */}
        <div className="px-4">
          <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
            Full site
          </p>
          <div className="rounded-2xl border divide-y overflow-hidden">
            {DESKTOP_LINKS.map(({ href, label, icon: Icon }) => (
              <SheetClose asChild key={href}>
                <Link
                  href={href}
                  className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
                >
                  <Icon className="w-4 h-4 text-muted-foreground shrink-0" />
                  <span className="text-sm flex-1">{label}</span>
                  <ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
                </Link>
              </SheetClose>
            ))}
          </div>
        </div>

        {/* Section 3: Account (DRAWER-05) */}
        <div className="px-4 mt-auto pb-safe">
          <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
            Account
          </p>
          <div className="rounded-2xl border overflow-hidden">
            {user && (
              <div className="flex items-center gap-3 px-4 py-3 border-b">
                <span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary/15 text-primary text-xs font-semibold shrink-0">
                  {initials}
                </span>
                <div className="flex-1 min-w-0">
                  {user.name && (
                    <p className="text-sm font-medium leading-tight truncate">
                      {user.name}
                    </p>
                  )}
                  {user.email && (
                    <p className="text-xs text-muted-foreground truncate" title={user.email}>
                      {user.email}
                    </p>
                  )}
                </div>
              </div>
            )}
            <button
              type="button"
              onClick={handleSignOut}
              className="w-full flex items-center gap-3 px-4 py-3 text-destructive hover:bg-destructive/10 transition-colors"
            >
              <LogOut className="w-4 h-4 shrink-0" />
              <span className="text-sm">Sign out</span>
            </button>
          </div>
        </div>
      </SheetContent>
    </Sheet>
  );
}

Notes:

  • side="right" — locked decision (CONTEXT.md, DRAWER-02).
  • Engagement intentionally has no ExternalLink icon (it's an in-shell route per DRAWER-03).
  • Quotes/Configuration Items/Backup Status/Ticket Digest/Admin/Sync each carry ExternalLink (DRAWER-04). Do NOT include Engagement in the desktop list (it migrated to "Mobile sections").
  • SheetClose asChild wraps each link so tapping a row closes the drawer (better UX; Radix Sheet pattern).
  • pb-safe on Section 3 keeps the Sign out row clear of the home indicator on iOS.
  • <SheetDescription className="sr-only"> satisfies Radix's a11y requirement when the description is non-visual. test -f components/mobile/MoreDrawer.tsx && grep -q "side="right"" components/mobile/MoreDrawer.tsx && grep -q "signOut()" components/mobile/MoreDrawer.tsx && grep -q "/auth/sign-in" components/mobile/MoreDrawer.tsx && grep -q "Mobile sections" components/mobile/MoreDrawer.tsx && grep -q "Full site" components/mobile/MoreDrawer.tsx && grep -q "Account" components/mobile/MoreDrawer.tsx <acceptance_criteria>
    • test -f components/mobile/MoreDrawer.tsx exits 0
    • grep -E "export function MoreDrawer" components/mobile/MoreDrawer.tsx matches
    • grep -E "side=\"right\"" components/mobile/MoreDrawer.tsx matches (DRAWER-02)
    • grep -E "/mobile/engagement" components/mobile/MoreDrawer.tsx matches (DRAWER-03)
    • grep -E "/quotes" components/mobile/MoreDrawer.tsx matches AND grep -E "/configuration-items" components/mobile/MoreDrawer.tsx matches AND grep -E "/backup-status" components/mobile/MoreDrawer.tsx matches AND grep -E "/admin/ticket-digest" components/mobile/MoreDrawer.tsx matches AND grep -E "/admin/sync" components/mobile/MoreDrawer.tsx matches (DRAWER-04)
    • grep -E "ExternalLink" components/mobile/MoreDrawer.tsx matches (DRAWER-04 hint)
    • grep -E "signOut\(\)" components/mobile/MoreDrawer.tsx matches AND grep -E "/auth/sign-in" components/mobile/MoreDrawer.tsx matches (DRAWER-05)
    • grep -E "open: boolean" components/mobile/MoreDrawer.tsx matches AND grep -E "onOpenChange" components/mobile/MoreDrawer.tsx matches (controlled drawer) </acceptance_criteria> The drawer file exists, exports MoreDrawer({ open, onOpenChange }), contains all three sections with correct routes, calls signOut() then router.push('/auth/sign-in'), and uses side="right".
Task 2: Create components/mobile/HeaderBar.tsx (sticky top header — brand, Bell, avatar) components/mobile/HeaderBar.tsx - components/branding/wulf-mark.tsx (WulfMark prop signature) - components/navigation/user-menu.tsx (initials pattern reference) - app/styles/brand.css (confirm `pt-safe` utility exists) - components/ui/button.tsx (Button variant/size API) - .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (header decisions: SHELL-02..04, no page title) Create the file `components/mobile/HeaderBar.tsx` with literal contents below. The header takes `onAvatarClick` so the parent layout can wire it to the same drawer state used by `BottomNav`.
'use client';

/* HeaderBar — phase 02 (SHELL-02..04).
 *
 * Sticky top bar inside the /mobile shell. Three slots:
 *   left:  WulfMark + "Pulse" wordmark, linked to /mobile/dashboard
 *   right: Bell icon button (placeholder, aria-label="Notifications")
 *   right: compact avatar circle — opens the More drawer (parent owns state)
 *
 * No page title in the header — pages render their own H1. */

import Link from 'next/link';
import { Bell } from 'lucide-react';
import { WulfMark } from '@/components/branding/wulf-mark';
import { useSession } from '@/lib/auth-client';

interface HeaderBarProps {
  onAvatarClick: () => void;
}

export function HeaderBar({ onAvatarClick }: HeaderBarProps) {
  const { data: session } = useSession();
  const user = session?.user as
    | { name?: string; email?: string }
    | undefined;

  const initials = (user?.name ?? user?.email ?? '?')
    .split(/[\s@]/)
    .filter(Boolean)
    .slice(0, 2)
    .map((p) => p[0]?.toUpperCase())
    .join('');

  return (
    <header className="sticky top-0 z-30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-b pt-safe">
      <div className="flex items-center justify-between px-4 h-14">
        {/* Left: brand mark + wordmark, linked to /mobile/dashboard */}
        <Link
          href="/mobile/dashboard"
          className="flex items-center gap-2 -ml-1 px-1 rounded-md hover:bg-accent/50 transition-colors"
          aria-label="Pulse — go to Dashboard"
        >
          <WulfMark variant="mark" className="h-6 w-auto" />
          <span className="font-bold text-base tracking-tight">Pulse</span>
        </Link>

        {/* Right: Bell placeholder, then avatar trigger */}
        <div className="flex items-center gap-1">
          <button
            type="button"
            onClick={() => { /* SHELL-03: placeholder — no menu, no badge */ }}
            aria-label="Notifications"
            className="inline-flex items-center justify-center h-9 w-9 rounded-md hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <Bell className="h-5 w-5" />
          </button>

          <button
            type="button"
            onClick={onAvatarClick}
            aria-label="Open menu"
            className="inline-flex items-center justify-center h-9 w-9 rounded-md hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-primary/15 text-primary text-[11px] font-semibold">
              {initials}
            </span>
          </button>
        </div>
      </div>
    </header>
  );
}

Notes:

  • pt-safe is added on the sticky header so the notch/dynamic-island doesn't overlap content (SHELL-02 + Phase 1 PWA-04).
  • bg-background/95 backdrop-blur matches CONTEXT.md SHELL-02.
  • Bell onClick is intentionally empty (SHELL-03 placeholder); future phase wires real notifications.
  • Avatar is h-7 w-7 per SHELL-04 — wrapped in a h-9 w-9 button to give a 36px touch target.
  • No <h1> / no page title in header (SHELL-02 explicit).
  • We do NOT use the shadcn avatar primitive — initials-circle pattern matches existing UserMenu. test -f components/mobile/HeaderBar.tsx && grep -q "sticky top-0" components/mobile/HeaderBar.tsx && grep -q "bg-background/95 backdrop-blur" components/mobile/HeaderBar.tsx && grep -q "/mobile/dashboard" components/mobile/HeaderBar.tsx && grep -q 'aria-label="Notifications"' components/mobile/HeaderBar.tsx && grep -q "WulfMark" components/mobile/HeaderBar.tsx && grep -q "pt-safe" components/mobile/HeaderBar.tsx && grep -q "h-7 w-7" components/mobile/HeaderBar.tsx <acceptance_criteria>
    • test -f components/mobile/HeaderBar.tsx exits 0
    • grep -E "export function HeaderBar" components/mobile/HeaderBar.tsx matches
    • grep -E "sticky top-0" components/mobile/HeaderBar.tsx matches AND grep -E "bg-background/95 backdrop-blur" components/mobile/HeaderBar.tsx matches AND grep -E "border-b" components/mobile/HeaderBar.tsx matches (SHELL-02)
    • grep -E "/mobile/dashboard" components/mobile/HeaderBar.tsx matches (brand link target, SHELL-02)
    • grep -E "WulfMark" components/mobile/HeaderBar.tsx matches AND grep -E "Pulse" components/mobile/HeaderBar.tsx matches (mark + wordmark, SHELL-02)
    • grep -E "aria-label=\"Notifications\"" components/mobile/HeaderBar.tsx matches AND grep -E "Bell" components/mobile/HeaderBar.tsx matches (SHELL-03)
    • grep -E "h-7 w-7" components/mobile/HeaderBar.tsx matches (compact avatar, SHELL-04)
    • grep -E "onAvatarClick" components/mobile/HeaderBar.tsx matches (avatar opens drawer via parent state, SHELL-04)
    • grep -E "pt-safe" components/mobile/HeaderBar.tsx matches (PWA-04 reuse / safe-area)
    • ! grep -E "<h1" components/mobile/HeaderBar.tsx exits 0 (no page title in header, SHELL-02 explicit) </acceptance_criteria> HeaderBar renders WulfMark+wordmark linked to /mobile/dashboard, a Bell button with aria-label="Notifications" and empty onClick, and an avatar-circle button that calls onAvatarClick (parent wires this to the drawer state).
Task 3: Create components/mobile/BottomNav.tsx (5-cell bottom bar — 4 tabs + More) components/mobile/BottomNav.tsx - app/mobile/layout.tsx (current 3-tab pattern; we extend to 4 tabs + More) - app/styles/brand.css (confirm `pb-safe` utility exists) - .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (locked: SHELL-06, NAV-01..03 — Dashboard/Tickets/Finance/Analyzer + More) Create the file `components/mobile/BottomNav.tsx`:
'use client';

/* BottomNav — phase 02 (SHELL-06, NAV-01..03, DRAWER-01).
 *
 * Fixed bottom bar with five cells:
 *   - Dashboard (LayoutDashboard) -> /mobile/dashboard
 *   - Tickets   (Ticket)          -> /mobile/tickets
 *   - Finance   (DollarSign)      -> /mobile/finance
 *   - Analyzer  (Sparkles)        -> /mobile/analyzer
 *   - More      (Menu)            -> opens the MoreDrawer (parent state)
 *
 * Active tab detected via pathname.startsWith(href). Active = text-primary,
 * inactive = text-muted-foreground. */

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
  LayoutDashboard,
  Ticket,
  DollarSign,
  Sparkles,
  Menu,
} from 'lucide-react';

const TABS = [
  { href: '/mobile/dashboard', label: 'Dashboard', icon: LayoutDashboard },
  { href: '/mobile/tickets',   label: 'Tickets',   icon: Ticket },
  { href: '/mobile/finance',   label: 'Finance',   icon: DollarSign },
  { href: '/mobile/analyzer',  label: 'Analyzer',  icon: Sparkles },
] as const;

interface BottomNavProps {
  onMoreClick: () => void;
}

export function BottomNav({ onMoreClick }: BottomNavProps) {
  const pathname = usePathname();

  return (
    <nav
      aria-label="Primary"
      className="fixed bottom-0 left-0 right-0 z-30 border-t bg-background pb-safe"
    >
      <div className="max-w-lg mx-auto flex h-16">
        {TABS.map(({ href, label, icon: Icon }) => {
          const active = pathname?.startsWith(href) ?? false;
          return (
            <Link
              key={href}
              href={href}
              aria-current={active ? 'page' : undefined}
              className={`flex-1 flex flex-col items-center justify-center gap-0.5 text-[11px] transition-colors ${
                active
                  ? 'text-primary'
                  : 'text-muted-foreground hover:text-foreground'
              }`}
            >
              <Icon className="w-5 h-5" aria-hidden="true" />
              <span>{label}</span>
            </Link>
          );
        })}
        <button
          type="button"
          onClick={onMoreClick}
          aria-label="Open menu"
          className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
        >
          <Menu className="w-5 h-5" aria-hidden="true" />
          <span>More</span>
        </button>
      </div>
    </nav>
  );
}

Notes:

  • max-w-lg mx-auto keeps the nav width-aligned with the content gutter (SHELL-06 + CONTEXT.md).
  • pb-safe on the outer <nav> so the home indicator inset is reserved (PWA-04 reuse).
  • h-16 = 64px nav height; the layout's <main> will pad by 64 + safe-area to keep content above the bar (handled in Plan 02).
  • Active detection is pathname?.startsWith(href) (NAV-03).
  • The More cell is a <button>, not a Link — it triggers a controlled drawer via onMoreClick.
  • Icons use aria-hidden="true" because the visible label already names the destination. test -f components/mobile/BottomNav.tsx && grep -q "/mobile/dashboard" components/mobile/BottomNav.tsx && grep -q "/mobile/tickets" components/mobile/BottomNav.tsx && grep -q "/mobile/finance" components/mobile/BottomNav.tsx && grep -q "/mobile/analyzer" components/mobile/BottomNav.tsx && grep -q "max-w-lg mx-auto" components/mobile/BottomNav.tsx && grep -q "pathname.*startsWith" components/mobile/BottomNav.tsx && grep -q "text-primary" components/mobile/BottomNav.tsx && grep -q "pb-safe" components/mobile/BottomNav.tsx <acceptance_criteria>
    • test -f components/mobile/BottomNav.tsx exits 0
    • grep -E "export function BottomNav" components/mobile/BottomNav.tsx matches
    • grep -E "/mobile/dashboard" components/mobile/BottomNav.tsx matches AND grep -E "/mobile/tickets" components/mobile/BottomNav.tsx matches AND grep -E "/mobile/finance" components/mobile/BottomNav.tsx matches AND grep -E "/mobile/analyzer" components/mobile/BottomNav.tsx matches (NAV-02)
    • grep -E "LayoutDashboard" components/mobile/BottomNav.tsx matches AND grep -E "\\bTicket\\b" components/mobile/BottomNav.tsx matches AND grep -E "DollarSign" components/mobile/BottomNav.tsx matches AND grep -E "Sparkles" components/mobile/BottomNav.tsx matches AND grep -E "\\bMenu\\b" components/mobile/BottomNav.tsx matches (NAV-01 + DRAWER-01 icons)
    • grep -E "pathname.*startsWith" components/mobile/BottomNav.tsx matches (NAV-03)
    • grep -E "text-primary" components/mobile/BottomNav.tsx matches AND grep -E "text-muted-foreground" components/mobile/BottomNav.tsx matches (NAV-03 active/inactive)
    • grep -E "fixed bottom-0" components/mobile/BottomNav.tsx matches AND grep -E "border-t" components/mobile/BottomNav.tsx matches AND grep -E "max-w-lg mx-auto" components/mobile/BottomNav.tsx matches (SHELL-06)
    • grep -E "pb-safe" components/mobile/BottomNav.tsx matches (safe-area for home indicator)
    • grep -E "onMoreClick" components/mobile/BottomNav.tsx matches (DRAWER-01 trigger via parent state) </acceptance_criteria> BottomNav exports a 5-cell nav: 4 routed Links (Dashboard, Tickets, Finance, Analyzer) with active-state via pathname.startsWith(href), plus a More button that calls onMoreClick.
Task 4: Create app/mobile/analyzer/page.tsx (placeholder so Analyzer tab does not 404 before Phase 6) app/mobile/analyzer/page.tsx - .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (decisions §Routes & files: "Add a placeholder app/mobile/analyzer/page.tsx so the new bottom-nav Analyzer tab doesn't 404 before Phase 6 lands. Minimal 'coming soon' component is sufficient.") - app/mobile/page.tsx (style reference for a minimal mobile page) Create `app/mobile/analyzer/page.tsx`:
/* Placeholder for /mobile/analyzer.
 *
 * Phase 02 only adds the Analyzer tab to the bottom nav — the real feed
 * lands in Phase 6 (`docs/superpowers/specs/2026-05-03-mobile-shell-design.md`
 * §6.4). This file exists so tapping the Analyzer tab resolves to a real
 * route instead of 404. Phase 6 will replace this file with the actual
 * read-only feed page.
 *
 * DO NOT add features, data fetching, or UI beyond the "Coming soon"
 * card here — Phase 6 owns the real implementation. */

import { Sparkles } from 'lucide-react';

export const metadata = {
  title: 'Analyzer · Pulse',
};

export default function MobileAnalyzerPlaceholder() {
  return (
    <div className="p-4">
      <div className="rounded-2xl border bg-card p-6 flex flex-col items-center text-center gap-3">
        <div className="h-12 w-12 rounded-2xl bg-primary/10 text-primary flex items-center justify-center">
          <Sparkles className="h-6 w-6" />
        </div>
        <h1 className="text-lg font-semibold">Analyzer feed coming soon</h1>
        <p className="text-sm text-muted-foreground max-w-xs">
          The mobile Analyzer feed is on its way. Until then, view full
          analyses on the desktop Analyzer.
        </p>
      </div>
    </div>
  );
}

Notes:

  • This is a server component (no 'use client' needed) — keeps it cheap.
  • Deliberately stubbed; Phase 6 (ANL-01..06) replaces this entire file.
  • No data fetching, no /api/mobile/analyzer/feed call — those belong in Phase 6. test -f app/mobile/analyzer/page.tsx && grep -q "export default function" app/mobile/analyzer/page.tsx && grep -q "coming soon" app/mobile/analyzer/page.tsx <acceptance_criteria>
    • test -f app/mobile/analyzer/page.tsx exits 0
    • grep -E "export default function" app/mobile/analyzer/page.tsx matches
    • grep -iE "coming soon" app/mobile/analyzer/page.tsx matches (placeholder copy present)
    • ! grep -E "/api/mobile/analyzer" app/mobile/analyzer/page.tsx exits 0 (no Phase 6 data fetching) </acceptance_criteria> Visiting /mobile/analyzer after build renders a small "coming soon" card; no 404.
Task 5: Type-check and build to confirm new components compile cleanly without breaking anything (no files written — gate task) - components/mobile/HeaderBar.tsx (just authored) - components/mobile/BottomNav.tsx (just authored) - components/mobile/MoreDrawer.tsx (just authored) - app/mobile/analyzer/page.tsx (just authored) Run `npx tsc --noEmit --pretty` and `npm run build` to confirm the four new files compile in the existing project. The current `app/mobile/layout.tsx` and `app/mobile/nav/page.tsx` are untouched, so existing routes must still build.

If tsc reports errors, fix them in the offending file(s) and rerun until both pass. Common issues to expect:

  • Missing import → re-add the import
  • any cast on session.user → keep the typed cast pattern from UserMenu.tsx
  • JSX-runtime / JSX namespace not found → not expected (tsconfig has it); if it appears, leave it for the executor to investigate

Do not modify any other files in this task. npx tsc --noEmit --pretty && npm run build <acceptance_criteria> - npx tsc --noEmit --pretty exits 0 - npm run build exits 0 - The four new files exist (re-confirmed) and no existing file was modified by this task: git status --short components/mobile app/mobile/analyzer shows only the four new files, no modifications to anything else </acceptance_criteria> TypeScript and Next.js build both pass with the four new files in place; existing routes unchanged.

<threat_model>

Trust Boundaries

Boundary Description
Browser → drawer Sign out Calls signOut() on the existing Better Auth client; an authenticated session already exists
Browser → all Link routes Standard client-side navigation; no new endpoints, no new data
Browser → header Bell Empty handler (placeholder per SHELL-03); not a trust boundary in this iteration

STRIDE Threat Register (ASVS-L1 baseline)

Threat ID Category Component Disposition Mitigation Plan
T-02-01 Tampering MoreDrawer Sign out button accept No new endpoint introduced; reuses Better Auth signOut() from lib/auth-client.ts. CSRF protection is provided by the existing Better Auth cookie + SameSite policy.
T-02-02 Information Disclosure Account section showing user email accept Email is already visible in the existing top-bar UserMenu on every desktop page; no new data surface or endpoint. Read-only display only.
T-02-03 Spoofing Avatar trigger opens drawer that contains Sign out mitigate Drawer state is local React state, not URL-driven; an attacker cannot pre-open the drawer via crafted URL. Sign out always navigates to /auth/sign-in server-rendered route, which Better Auth controls.
T-02-04 Denial of Service Bell button placeholder accept Empty handler — no fetch, no work, no DOS surface. Phase 7+ will revisit when the real notification list ships (NOTIF-01).
</threat_model>
After this plan completes:
  1. The four new files exist:
    • test -f components/mobile/HeaderBar.tsx
    • test -f components/mobile/BottomNav.tsx
    • test -f components/mobile/MoreDrawer.tsx
    • test -f app/mobile/analyzer/page.tsx
  2. npx tsc --noEmit --pretty exits 0
  3. npm run build exits 0
  4. app/mobile/layout.tsx is unchanged from start (still imports LayoutDashboard, Ticket, DollarSign, Menu only — not Sparkles):
    • ! grep -E "Sparkles" app/mobile/layout.tsx exits 0 (we have NOT yet wired the new bottom nav — Plan 02 does that)
  5. app/mobile/nav/page.tsx still exists (Plan 02 deletes it)
  6. No new dependencies added: git diff package.json package-lock.json is empty

<success_criteria>

  • All 5 tasks complete
  • 4 new files exist (HeaderBar, BottomNav, MoreDrawer, analyzer placeholder)
  • Each component matches its locked decisions from CONTEXT.md (D-locked: side="right", 5-cell nav, three drawer sections, Bell placeholder, h-7 w-7 avatar, max-w-lg mx-auto bottom nav)
  • TypeScript + Next build both pass
  • No existing files modified by this plan (verifiable via git status --short)
  • Plan 02 will pick up these components and wire them into the layout </success_criteria>
After completion, create `.planning/phases/02-mobile-shell-more-drawer/02-01-SUMMARY.md` documenting: - Which requirements this plan addressed (SHELL-02..04, SHELL-06, NAV-01..03, DRAWER-01..05) - The four files created and their roles - Locked decisions honored (Sheet `side="right"`, no shadcn avatar primitive added, Bell empty `onClick`, no page title in header) - Any deviations from the plan (should be none) - Notes for Plan 02 (which props to pass to each component)