docs(02): create phase plan

This commit is contained in:
lorentz 2026-05-03 14:48:41 -04:00
parent e67c51ec7c
commit 29ff7fd8fd
3 changed files with 1163 additions and 1 deletions

View file

@ -57,7 +57,9 @@ Decimal phases appear between their surrounding integers in numeric order.
4. Tapping Sign out in the drawer signs the user out and lands them on `/auth/sign-in`
5. `app/mobile/nav/page.tsx` no longer exists; visiting `/mobile/nav` does not render the old standalone nav page
6. Page content scrolls under the sticky header and is not hidden behind the bottom nav (bottom padding accounts for nav height + safe-area inset)
**Plans**: TBD
**Plans**: 2 plans
- [ ] 02-01-PLAN.md — Build mobile shell components (HeaderBar, BottomNav, MoreDrawer) + analyzer placeholder (SHELL-02..04, SHELL-06, NAV-01..03, DRAWER-01..05)
- [ ] 02-02-PLAN.md — Wire new components into app/mobile/layout.tsx, delete app/mobile/nav/page.tsx (SHELL-01, SHELL-05, DRAWER-06)
**UI hint**: yes
### Phase 3: Dashboard Restyle

View file

@ -0,0 +1,754 @@
---
phase: 02-mobile-shell-more-drawer
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- components/mobile/HeaderBar.tsx
- components/mobile/BottomNav.tsx
- components/mobile/MoreDrawer.tsx
- app/mobile/analyzer/page.tsx
autonomous: true
requirements:
- SHELL-02
- SHELL-03
- SHELL-04
- SHELL-06
- NAV-01
- NAV-02
- NAV-03
- DRAWER-01
- DRAWER-02
- DRAWER-03
- DRAWER-04
- DRAWER-05
must_haves:
truths:
- "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"
artifacts:
- path: "components/mobile/HeaderBar.tsx"
provides: "Sticky header — WulfMark+wordmark link, Bell placeholder, avatar trigger for drawer"
contains: "export function HeaderBar"
- path: "components/mobile/BottomNav.tsx"
provides: "Fixed bottom tab bar — 4 tabs + More button"
contains: "export function BottomNav"
- path: "components/mobile/MoreDrawer.tsx"
provides: "shadcn Sheet drawer with three sections"
contains: "export function MoreDrawer"
- path: "app/mobile/analyzer/page.tsx"
provides: "Placeholder route so the new Analyzer tab resolves until Phase 6 ships"
contains: "export default function"
key_links:
- from: "components/mobile/HeaderBar.tsx"
to: "components/branding/wulf-mark.tsx"
via: "WulfMark import (variant='mark' and variant='wordmark')"
pattern: "from ['\"]@/components/branding/wulf-mark['\"]"
- from: "components/mobile/MoreDrawer.tsx"
to: "components/ui/sheet.tsx"
via: "Sheet, SheetContent, SheetTrigger imports"
pattern: "from ['\"]@/components/ui/sheet['\"]"
- from: "components/mobile/MoreDrawer.tsx"
to: "lib/auth-client.ts"
via: "signOut + useSession imports"
pattern: "from ['\"]@/lib/auth-client['\"]"
- from: "components/mobile/BottomNav.tsx"
to: "/mobile/analyzer"
via: "Analyzer tab href"
pattern: "/mobile/analyzer"
- from: "components/mobile/BottomNav.tsx"
to: "MoreDrawer trigger"
via: "onMoreClick prop or onOpenChange invocation"
pattern: "onMoreClick|onOpenChange"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Key types and exports the executor will use. Extracted so executor does not need to re-grep the codebase. -->
From components/branding/wulf-mark.tsx:
```typescript
export function WulfMark(props: {
variant?: 'mark' | 'wordmark';
className?: string;
alt?: string;
priority?: boolean;
}): JSX.Element;
```
From lib/auth-client.ts (Better Auth client):
```typescript
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:
```ts
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:
```typescript
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:
```typescript
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-safe``padding-top: env(safe-area-inset-top)`
- `pb-safe``padding-bottom: env(safe-area-inset-bottom)`
</interfaces>
<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>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create components/mobile/MoreDrawer.tsx (Sheet drawer with 3 sections + Sign out)</name>
<files>components/mobile/MoreDrawer.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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):
```tsx
'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.
</action>
<verify>
<automated>test -f components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "side=\"right\"" components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "signOut()" components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "/auth/sign-in" components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "Mobile sections" components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "Full site" components/mobile/MoreDrawer.tsx &amp;&amp; grep -q "Account" components/mobile/MoreDrawer.tsx</automated>
</verify>
<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>
<done>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"`.</done>
</task>
<task type="auto">
<name>Task 2: Create components/mobile/HeaderBar.tsx (sticky top header — brand, Bell, avatar)</name>
<files>components/mobile/HeaderBar.tsx</files>
<read_first>
- 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)
</read_first>
<action>
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`.
```tsx
'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`.
</action>
<verify>
<automated>test -f components/mobile/HeaderBar.tsx &amp;&amp; grep -q "sticky top-0" components/mobile/HeaderBar.tsx &amp;&amp; grep -q "bg-background/95 backdrop-blur" components/mobile/HeaderBar.tsx &amp;&amp; grep -q "/mobile/dashboard" components/mobile/HeaderBar.tsx &amp;&amp; grep -q 'aria-label="Notifications"' components/mobile/HeaderBar.tsx &amp;&amp; grep -q "WulfMark" components/mobile/HeaderBar.tsx &amp;&amp; grep -q "pt-safe" components/mobile/HeaderBar.tsx &amp;&amp; grep -q "h-7 w-7" components/mobile/HeaderBar.tsx</automated>
</verify>
<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>
<done>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).</done>
</task>
<task type="auto">
<name>Task 3: Create components/mobile/BottomNav.tsx (5-cell bottom bar — 4 tabs + More)</name>
<files>components/mobile/BottomNav.tsx</files>
<read_first>
- 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)
</read_first>
<action>
Create the file `components/mobile/BottomNav.tsx`:
```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.
</action>
<verify>
<automated>test -f components/mobile/BottomNav.tsx &amp;&amp; grep -q "/mobile/dashboard" components/mobile/BottomNav.tsx &amp;&amp; grep -q "/mobile/tickets" components/mobile/BottomNav.tsx &amp;&amp; grep -q "/mobile/finance" components/mobile/BottomNav.tsx &amp;&amp; grep -q "/mobile/analyzer" components/mobile/BottomNav.tsx &amp;&amp; grep -q "max-w-lg mx-auto" components/mobile/BottomNav.tsx &amp;&amp; grep -q "pathname.*startsWith" components/mobile/BottomNav.tsx &amp;&amp; grep -q "text-primary" components/mobile/BottomNav.tsx &amp;&amp; grep -q "pb-safe" components/mobile/BottomNav.tsx</automated>
</verify>
<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>
<done>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`.</done>
</task>
<task type="auto">
<name>Task 4: Create app/mobile/analyzer/page.tsx (placeholder so Analyzer tab does not 404 before Phase 6)</name>
<files>app/mobile/analyzer/page.tsx</files>
<read_first>
- .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)
</read_first>
<action>
Create `app/mobile/analyzer/page.tsx`:
```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.
</action>
<verify>
<automated>test -f app/mobile/analyzer/page.tsx &amp;&amp; grep -q "export default function" app/mobile/analyzer/page.tsx &amp;&amp; grep -q "coming soon" app/mobile/analyzer/page.tsx</automated>
</verify>
<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>
<done>Visiting `/mobile/analyzer` after build renders a small "coming soon" card; no 404.</done>
</task>
<task type="auto">
<name>Task 5: Type-check and build to confirm new components compile cleanly without breaking anything</name>
<files>(no files written — gate task)</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>npx tsc --noEmit --pretty &amp;&amp; npm run build</automated>
</verify>
<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>
<done>TypeScript and Next.js build both pass with the four new files in place; existing routes unchanged.</done>
</task>
</tasks>
<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>
<verification>
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
</verification>
<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>
<output>
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)
</output>

View file

@ -0,0 +1,406 @@
---
phase: 02-mobile-shell-more-drawer
plan: 02
type: execute
wave: 2
depends_on:
- 02-01
files_modified:
- app/mobile/layout.tsx
- app/mobile/nav/page.tsx
autonomous: false
requirements:
- SHELL-01
- SHELL-05
- DRAWER-06
must_haves:
truths:
- "app/mobile/layout.tsx is rewritten to import HeaderBar, BottomNav, MoreDrawer from components/mobile/* and renders them around <main>{children}</main>"
- "The layout owns a single React.useState boolean that opens/closes the MoreDrawer; HeaderBar's onAvatarClick and BottomNav's onMoreClick both flip this state to true"
- "The <main> content area scrolls and has bottom padding equal to bottom-nav height (h-16 = 64px) plus env(safe-area-inset-bottom) so content does not hide under the nav"
- "app/mobile/nav/page.tsx no longer exists — the file is deleted in this same change"
- "Visiting /mobile/dashboard, /mobile/tickets, /mobile/finance, and /mobile/analyzer all render inside the new layout (header + bottom nav visible, no 404)"
- "TypeScript compiles (npx tsc --noEmit) and Next.js builds (npm run build) successfully"
artifacts:
- path: "app/mobile/layout.tsx"
provides: "New mobile shell wiring HeaderBar + BottomNav + MoreDrawer with shared drawer state"
contains: "MoreDrawer"
- path: "app/mobile/nav/page.tsx"
provides: "DELETED — drawer fully replaces the standalone nav page (DRAWER-06)"
deleted: true
key_links:
- from: "app/mobile/layout.tsx"
to: "components/mobile/HeaderBar.tsx"
via: "import + render with onAvatarClick"
pattern: "from ['\"]@/components/mobile/HeaderBar['\"]"
- from: "app/mobile/layout.tsx"
to: "components/mobile/BottomNav.tsx"
via: "import + render with onMoreClick"
pattern: "from ['\"]@/components/mobile/BottomNav['\"]"
- from: "app/mobile/layout.tsx"
to: "components/mobile/MoreDrawer.tsx"
via: "import + render with shared open/onOpenChange state"
pattern: "from ['\"]@/components/mobile/MoreDrawer['\"]"
- from: "Header avatar AND Bottom-nav More button"
to: "MoreDrawer open state"
via: "Single useState in app/mobile/layout.tsx"
pattern: "useState"
---
<objective>
Replace `app/mobile/layout.tsx` with the new shell that wires HeaderBar + BottomNav + MoreDrawer (built in Plan 01) around `<main>{children}</main>`, owning a single shared drawer-open state. Delete `app/mobile/nav/page.tsx` in the same change so the drawer fully replaces the old standalone nav page.
Purpose: Land SHELL-01 (replace in place), SHELL-05 (scrollable content with bottom-nav-aware padding), and DRAWER-06 (delete the old nav route). After this plan, every `/mobile/*` page renders under the new shell and the four primary tabs + avatar + More all behave per spec.
Output: Modified `app/mobile/layout.tsx`, deleted `app/mobile/nav/page.tsx`. Build passes. Visual checkpoint confirms the shell renders correctly on at least one mobile route.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md
@.planning/phases/02-mobile-shell-more-drawer/02-01-PLAN.md
@docs/superpowers/specs/2026-05-03-mobile-shell-design.md
@CLAUDE.md
@app/mobile/layout.tsx
@app/mobile/nav/page.tsx
@app/styles/brand.css
<interfaces>
<!-- Components consumed by the new layout. All authored in Plan 01. -->
From components/mobile/HeaderBar.tsx (Plan 01):
```typescript
export function HeaderBar(props: { onAvatarClick: () => void }): JSX.Element;
```
From components/mobile/BottomNav.tsx (Plan 01):
```typescript
export function BottomNav(props: { onMoreClick: () => void }): JSX.Element;
```
From components/mobile/MoreDrawer.tsx (Plan 01):
```typescript
export function MoreDrawer(props: {
open: boolean;
onOpenChange: (open: boolean) => void;
}): JSX.Element;
```
CSS utilities available in `app/styles/brand.css`:
- `pt-safe`, `pb-safe`
</interfaces>
<scope_boundary>
This plan **only** touches:
- `app/mobile/layout.tsx` (full rewrite)
- `app/mobile/nav/page.tsx` (delete)
Do NOT modify:
- The three new components (Plan 01 owns them)
- Any page under `app/mobile/dashboard|tickets|finance|analyzer|page.tsx` (out of phase)
- `components/navigation/app-navigation.tsx` (desktop nav)
- `app/layout.tsx` (root, owned by Phase 1)
</scope_boundary>
</context>
<tasks>
<task type="auto">
<name>Task 1: Rewrite app/mobile/layout.tsx to wire HeaderBar + BottomNav + MoreDrawer with shared state</name>
<files>app/mobile/layout.tsx</files>
<read_first>
- app/mobile/layout.tsx (current 3-tab layout being replaced — read fully so executor knows what's there)
- components/mobile/HeaderBar.tsx (Plan 01 output — confirms onAvatarClick prop)
- components/mobile/BottomNav.tsx (Plan 01 output — confirms onMoreClick prop)
- components/mobile/MoreDrawer.tsx (Plan 01 output — confirms open/onOpenChange props)
- app/styles/brand.css (confirm `pt-safe` and `pb-safe` are available)
- .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (SHELL-05: bottom padding = nav height + safe-area)
</read_first>
<action>
**Replace the entire contents** of `app/mobile/layout.tsx` with:
```tsx
'use client';
/* Mobile shell — phase 02 (SHELL-01, SHELL-05).
*
* Header: <HeaderBar /> (sticky, brand + Bell + avatar)
* Body: <main> (scrollable, padded so content clears the bottom nav)
* Foot: <BottomNav /> (fixed, 4 tabs + More)
* Drawer: <MoreDrawer /> opened from BOTH the header avatar and the More cell.
*
* The drawer's open state lives here so a single Sheet instance is shared
* between the two triggers — no duplicate Sheets, no prop-drilling sagas. */
import { useState } from 'react';
import { HeaderBar } from '@/components/mobile/HeaderBar';
import { BottomNav } from '@/components/mobile/BottomNav';
import { MoreDrawer } from '@/components/mobile/MoreDrawer';
export default function MobileLayout({ children }: { children: React.ReactNode }) {
const [drawerOpen, setDrawerOpen] = useState(false);
return (
<div className="flex flex-col min-h-screen bg-background max-w-lg mx-auto">
<HeaderBar onAvatarClick={() => setDrawerOpen(true)} />
{/* SHELL-05: scrollable content area; bottom padding = bottom-nav (h-16
= 64px = pb-16) plus the device safe-area inset, so content never
hides under the bar. */}
<main className="flex-1 overflow-y-auto pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))]">
{children}
</main>
<BottomNav onMoreClick={() => setDrawerOpen(true)} />
<MoreDrawer open={drawerOpen} onOpenChange={setDrawerOpen} />
</div>
);
}
```
Notes:
- This file replaces the existing 3-tab layout entirely. No legacy imports, no dead code paths.
- `'use client'` is required because we use `useState`.
- The `pb-[calc(...)]` arbitrary value gives `<main>` enough bottom padding to clear the 64px nav plus the home-indicator inset (SHELL-05 + PWA-04 reuse). Tailwind 4 supports the `calc()` arbitrary value here.
- A single `useState` is the entire shared-state mechanism — no Zustand, no Context, no third-party state lib (per CLAUDE.md "no new state libraries").
- Both triggers set the same boolean. The `MoreDrawer` itself controls its close (Radix `onOpenChange` fires when overlay is clicked or Esc is pressed) and propagates back through `setDrawerOpen`.
</action>
<verify>
<automated>grep -q "from '@/components/mobile/HeaderBar'" app/mobile/layout.tsx &amp;&amp; grep -q "from '@/components/mobile/BottomNav'" app/mobile/layout.tsx &amp;&amp; grep -q "from '@/components/mobile/MoreDrawer'" app/mobile/layout.tsx &amp;&amp; grep -q "useState" app/mobile/layout.tsx &amp;&amp; grep -q "onAvatarClick" app/mobile/layout.tsx &amp;&amp; grep -q "onMoreClick" app/mobile/layout.tsx &amp;&amp; grep -q "drawerOpen" app/mobile/layout.tsx &amp;&amp; grep -q "safe-area-inset-bottom" app/mobile/layout.tsx</automated>
</verify>
<acceptance_criteria>
- `grep -E "from ['\"]@/components/mobile/HeaderBar['\"]" app/mobile/layout.tsx` matches
- `grep -E "from ['\"]@/components/mobile/BottomNav['\"]" app/mobile/layout.tsx` matches
- `grep -E "from ['\"]@/components/mobile/MoreDrawer['\"]" app/mobile/layout.tsx` matches
- `grep -E "useState" app/mobile/layout.tsx` matches (single shared state)
- `grep -E "onAvatarClick" app/mobile/layout.tsx` matches AND `grep -E "onMoreClick" app/mobile/layout.tsx` matches (both triggers wired)
- `grep -E "open=" app/mobile/layout.tsx` matches AND `grep -E "onOpenChange=" app/mobile/layout.tsx` matches (drawer controlled)
- `grep -E "safe-area-inset-bottom" app/mobile/layout.tsx` matches (SHELL-05 padding for bottom nav clearance)
- `grep -E "max-w-lg mx-auto" app/mobile/layout.tsx` matches (CONTEXT.md container width)
- `! grep -E "Menu, " app/mobile/layout.tsx` exits 0 (the legacy `Menu`-as-link import from the old layout is gone)
- `! grep -E "/mobile/nav" app/mobile/layout.tsx` exits 0 (no link to the deleted standalone nav route)
</acceptance_criteria>
<done>The layout renders the three new components, owns a single useState for drawer open/close, and pads `<main>` to clear the bottom nav + safe area.</done>
</task>
<task type="auto">
<name>Task 2: Delete app/mobile/nav/page.tsx (DRAWER-06)</name>
<files>app/mobile/nav/page.tsx</files>
<read_first>
- app/mobile/nav/page.tsx (final read of the file being deleted, so the executor knows what is leaving the codebase)
- .planning/phases/02-mobile-shell-more-drawer/02-CONTEXT.md (DRAWER-06 — delete in same change as drawer ships; no redirect)
- components/mobile/MoreDrawer.tsx (Plan 01 output — verifies the drawer already covers everything the old page did)
</read_first>
<action>
Delete the file:
```bash
rm app/mobile/nav/page.tsx
```
Then check the directory is empty (or only contains other files we don't care about) and remove it if it became empty:
```bash
# If app/mobile/nav is now empty, remove the directory too.
if [ -d app/mobile/nav ] && [ -z "$(ls -A app/mobile/nav)" ]; then
rmdir app/mobile/nav
fi
```
Notes:
- Per CONTEXT.md DRAWER-06: "Recommend NO redirect (just delete) — the URL was never bookmarked-worthy." Visiting `/mobile/nav` after this change yields Next.js's standard 404, which is the desired behavior.
- Confirm no other file in the repo references `/mobile/nav` or imports from `app/mobile/nav/...`. Run a quick grep before deletion (the old `app/mobile/layout.tsx` had the only known reference, and Task 1 already removed it).
</action>
<verify>
<automated>! test -f app/mobile/nav/page.tsx</automated>
</verify>
<acceptance_criteria>
- `! test -f app/mobile/nav/page.tsx` exits 0 (file deleted)
- `! grep -r --include="*.ts" --include="*.tsx" "/mobile/nav" app components 2>/dev/null` exits 0 (no remaining references in source)
- The deletion shows up in `git status` as a deleted file
</acceptance_criteria>
<done>`app/mobile/nav/page.tsx` no longer exists; no source file references `/mobile/nav` anywhere.</done>
</task>
<task type="auto">
<name>Task 3: Type-check and full build to confirm the new shell compiles end-to-end</name>
<files>(no files written — gate task)</files>
<read_first>
- app/mobile/layout.tsx (the file just rewritten)
- components/mobile/HeaderBar.tsx (Plan 01)
- components/mobile/BottomNav.tsx (Plan 01)
- components/mobile/MoreDrawer.tsx (Plan 01)
</read_first>
<action>
Run:
```bash
npx tsc --noEmit --pretty
npm run build
```
Both must exit 0. If either fails, fix the offending file and rerun until clean. Common things to check if it fails:
- Did Task 2 leave a dangling import to the deleted `nav/page.tsx`? (Should be impossible, but grep `/mobile/nav` if a build error names that path.)
- Did the `'use client'` directive end up below an import? (Must be the very first line.)
- Is the `pb-[calc(theme(spacing.16)+env(safe-area-inset-bottom))]` Tailwind 4 arbitrary value valid? If Tailwind rejects it, fall back to an inline style on the `<main>`: `style={{ paddingBottom: 'calc(4rem + env(safe-area-inset-bottom))' }}` and remove the `pb-[...]` class.
Do NOT modify any file other than `app/mobile/layout.tsx` to fix build issues.
</action>
<verify>
<automated>npx tsc --noEmit --pretty &amp;&amp; npm run build</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit --pretty` exits 0
- `npm run build` exits 0
- `git status --short app components` shows: 1 modified (`app/mobile/layout.tsx`) and 1 deleted (`app/mobile/nav/page.tsx`); no other unexpected modifications
</acceptance_criteria>
<done>Type-check + build both pass with the new shell wired and the old nav page deleted.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: Visual verification of the new mobile shell on a real device or DevTools mobile preview</name>
<files>(no files written — human checkpoint)</files>
<read_first>
- app/mobile/layout.tsx (the file just rewritten — executor confirms what was shipped)
- .planning/ROADMAP.md (Phase 2 success criteria #16 — these are what the human is verifying)
- .planning/REQUIREMENTS.md (SHELL-01..06, NAV-01..03, DRAWER-01..06)
</read_first>
<action>
Pause and surface a checkpoint to the user. Present this exact verification script and wait for the user's "approved" response.
**What was built (summary for the user):**
The new mobile shell is fully wired:
- `app/mobile/layout.tsx` rewritten — sticky header (Wulf mark + "Pulse" wordmark, Bell, avatar), scrollable `<main>`, fixed bottom nav (Dashboard / Tickets / Finance / Analyzer / More)
- `app/mobile/nav/page.tsx` deleted
- `app/mobile/analyzer/page.tsx` placeholder ("Coming soon" card) so the Analyzer tab resolves until Phase 6
- `<MoreDrawer />` opens from BOTH the header avatar and the bottom-nav More button, with three sections (Mobile sections / Full site / Account + Sign out)
All built on existing shadcn primitives, the `WulfMark` component, and Better Auth's `signOut()` — no new state libs, no shadcn avatar primitive added.
**How to verify (user runs through this on a phone-sized viewport):**
1. **Start the dev server** if not already running: `npm run dev`. Pulse should start on http://localhost:3100.
2. **Open the mobile shell in a phone-sized viewport** — Chrome DevTools (F12) → toggle device toolbar (Ctrl+Shift+M / Cmd+Shift+M) → pick "iPhone 15 Pro" or any 390-414px wide device. Visit `http://localhost:3100/mobile/dashboard`.
3. **Verify the header (SHELL-02..04):**
- [ ] Sticky bar at the top with `bg-background/95 backdrop-blur` + bottom border
- [ ] Left side: Wulf "W" mark + "Pulse" wordmark; tapping it navigates to `/mobile/dashboard`
- [ ] Right side: a Bell icon button next to a small avatar circle (initials)
- [ ] No page title text in the header itself
- [ ] Bell button is keyboard-focusable (Tab to it, then Space/Enter — should not throw or navigate; it's a placeholder, no menu)
- [ ] Tapping the avatar opens the right-side Sheet drawer
4. **Verify the bottom nav (SHELL-06, NAV-01..03):**
- [ ] Fixed bar at the bottom, full width, `border-t bg-background`
- [ ] Five cells in order: Dashboard, Tickets, Finance, Analyzer, More
- [ ] Active tab uses `text-primary` (Wulf blue); inactive use `text-muted-foreground`
- [ ] Tapping each tab routes to its URL: `/mobile/dashboard`, `/mobile/tickets`, `/mobile/finance`, `/mobile/analyzer`
- [ ] Tapping a row INSIDE `/mobile/tickets/[id]` (e.g., open any ticket) keeps Tickets highlighted (active detection via `pathname.startsWith`)
- [ ] Tapping More opens the same drawer the avatar opens
- [ ] Visiting `/mobile/analyzer` shows the "Coming soon" placeholder card (NOT a 404)
5. **Verify the drawer (DRAWER-01..05):**
Open the drawer (avatar OR More).
- [ ] Drawer slides in from the right (`side="right"`)
- [ ] Section 1 "Mobile sections" — single row: Engagement (no `ExternalLink` hint icon)
- [ ] Section 2 "Full site" — five rows: Quotes, Configuration Items, Backup Status, Ticket Digest, Admin / Sync (each row has the `ExternalLink` icon on the right)
- [ ] Section 3 "Account" — shows the signed-in user's initials, name, and email; below it, a red "Sign out" button
- [ ] Tapping any row inside the drawer navigates AND closes the drawer
- [ ] Tapping the X / outside the drawer / pressing Esc closes it
- [ ] Sign out: tap it → page navigates to `/auth/sign-in` AND the user is signed out (refreshing brings you to the sign-in page; no auto-redirect to `/mobile`)
6. **Verify content does not hide under the bottom nav (SHELL-05):**
- [ ] On `/mobile/dashboard` (or any mobile page), scroll to the bottom of the content. The last visible content sits ABOVE the bottom nav, not under it.
- [ ] On a phone with a home indicator (or in DevTools with iPhone preset), the bottom nav has extra space below for the indicator inset (no overlap).
7. **Verify the old nav route is gone (DRAWER-06):**
- [ ] Visit `http://localhost:3100/mobile/nav` directly. It returns Next.js's 404 page (NOT the old standalone nav UI).
8. **Quick regression on existing pages:**
- [ ] `/mobile/dashboard`, `/mobile/tickets`, `/mobile/finance` all still render their previous content unchanged — only the chrome around them is new.
- [ ] `/mobile/tickets/[id]` (open a ticket) still renders inside the new shell.
**Resume signal:** Reply "approved" once all checks pass. If something is broken or off-spec, describe what you saw and which check failed (e.g., "Drawer opens from the bottom, not the right" or "Bottom nav overlaps the last content row on /mobile/finance"). The executor will fix and re-verify.
</action>
<verify>
<automated>echo "Manual verification — user must reply 'approved' or describe a failure. No automated check applicable; preceding tasks (1-3) verify code-level invariants."</automated>
</verify>
<acceptance_criteria>
- User replies "approved" after running the verification script above
- All 8 verification sections pass on the user's device/preview
- If any check fails, the executor returns to Task 1 or Task 2 to fix and re-runs Task 3 (build) and Task 4 (re-verify) before requesting approval again
</acceptance_criteria>
<done>User has explicitly replied "approved", confirming the new shell renders correctly on a phone-sized viewport and all 6 ROADMAP success criteria for Phase 2 are met.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → drawer Sign out | Reuses Plan 01's MoreDrawer; calls Better Auth `signOut()` and navigates to `/auth/sign-in` — same trust boundary as the existing top-bar `UserMenu`. |
| Browser → all Link routes | All routes already exist or are placeholders (`/mobile/analyzer` placeholder shipped in Plan 01). No new endpoints. |
## STRIDE Threat Register (ASVS-L1 baseline)
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-05 | Tampering | layout.tsx drawer-state useState | accept | Local React state, not URL-driven. An attacker cannot pre-open the drawer via crafted URL. State has no security relevance — it merely toggles UI visibility. |
| T-02-06 | Information Disclosure | Deletion of `/mobile/nav` route | accept | The deleted page surfaced no PII beyond what the new drawer surfaces (same email field). Net change: identical surface area. |
| T-02-07 | Denial of Service | New shell mounts on every `/mobile/*` request | accept | Layout is lightweight: 1 useState, 3 component imports, no fetches. Cost is negligible vs. the existing layout. |
| T-02-08 | Repudiation | Sign out action | mitigate | Better Auth records sign-out in its session table; not a Pulse-introduced repudiation surface. Inherited from `lib/auth-client.ts`. |
</threat_model>
<verification>
After this plan completes:
1. `app/mobile/layout.tsx` imports HeaderBar, BottomNav, MoreDrawer:
- `grep -E "@/components/mobile/HeaderBar" app/mobile/layout.tsx` matches
- `grep -E "@/components/mobile/BottomNav" app/mobile/layout.tsx` matches
- `grep -E "@/components/mobile/MoreDrawer" app/mobile/layout.tsx` matches
2. `app/mobile/nav/page.tsx` does not exist:
- `! test -f app/mobile/nav/page.tsx` exits 0
3. No source file references `/mobile/nav`:
- `! grep -r --include="*.ts" --include="*.tsx" "/mobile/nav" app components 2>/dev/null` exits 0
4. Build is clean:
- `npx tsc --noEmit --pretty` exits 0
- `npm run build` exits 0
5. Visual checkpoint passed (Task 4):
- Header sticky, brand link goes to `/mobile/dashboard`, Bell focusable with no menu, avatar opens drawer
- Bottom nav shows 5 cells with correct icons + routes; active tab uses `text-primary`; `/mobile/tickets/123` highlights Tickets
- Drawer (`side="right"`) shows 3 sections; Sign out signs out and lands on `/auth/sign-in`
- `/mobile/analyzer` renders the placeholder, NOT a 404
- `/mobile/nav` returns 404
- Content does not hide under the bottom nav (SHELL-05)
</verification>
<success_criteria>
- All 4 tasks complete (3 auto + 1 visual checkpoint with explicit "approved")
- `app/mobile/layout.tsx` is rewritten to use the new components with shared drawer state
- `app/mobile/nav/page.tsx` is deleted
- Type-check + build both pass
- Visual checkpoint approved by user
- Phase 2 ROADMAP success criteria #16 are all satisfied (header, 5-cell nav, drawer with 3 sections, sign-out flow, /mobile/nav gone, content not hidden under bar)
- All 15 phase requirements (SHELL-01..06, NAV-01..03, DRAWER-01..06) are now closed across Plan 01 + Plan 02
</success_criteria>
<output>
After completion, create `.planning/phases/02-mobile-shell-more-drawer/02-02-SUMMARY.md` documenting:
- Which requirements this plan addressed (SHELL-01, SHELL-05, DRAWER-06) and confirmation that combined with Plan 01 all 15 phase requirements are now satisfied
- The final wiring (layout owns one `useState`, both triggers share it)
- Any deviations from the plan during execution (e.g., if `pb-[calc(...)]` had to fall back to inline style)
- Visual checkpoint outcomes (which checks passed, any minor adjustments made)
- Notes for Phases 37: every mobile page lands inside this shell automatically; pages should NOT add their own header or bottom nav
</output>