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
36 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-mobile-shell-more-drawer | 01 | execute | 1 |
|
true |
|
|
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.tsFrom 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-safe→padding-top: env(safe-area-inset-top)pb-safe→padding-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
avatarprimitive — do NOT add it; we use the existing initials-circle pattern fromUserMenu. components/navigation/app-navigation.tsx— desktop nav, untouched. </scope_boundary>
'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
ExternalLinkicon (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 asChildwraps each link so tapping a row closes the drawer (better UX; Radix Sheet pattern).pb-safeon 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.tsxexits 0grep -E "export function MoreDrawer" components/mobile/MoreDrawer.tsxmatchesgrep -E "side=\"right\"" components/mobile/MoreDrawer.tsxmatches (DRAWER-02)grep -E "/mobile/engagement" components/mobile/MoreDrawer.tsxmatches (DRAWER-03)grep -E "/quotes" components/mobile/MoreDrawer.tsxmatches ANDgrep -E "/configuration-items" components/mobile/MoreDrawer.tsxmatches ANDgrep -E "/backup-status" components/mobile/MoreDrawer.tsxmatches ANDgrep -E "/admin/ticket-digest" components/mobile/MoreDrawer.tsxmatches ANDgrep -E "/admin/sync" components/mobile/MoreDrawer.tsxmatches (DRAWER-04)grep -E "ExternalLink" components/mobile/MoreDrawer.tsxmatches (DRAWER-04 hint)grep -E "signOut\(\)" components/mobile/MoreDrawer.tsxmatches ANDgrep -E "/auth/sign-in" components/mobile/MoreDrawer.tsxmatches (DRAWER-05)grep -E "open: boolean" components/mobile/MoreDrawer.tsxmatches ANDgrep -E "onOpenChange" components/mobile/MoreDrawer.tsxmatches (controlled drawer) </acceptance_criteria> The drawer file exists, exportsMoreDrawer({ open, onOpenChange }), contains all three sections with correct routes, callssignOut()thenrouter.push('/auth/sign-in'), and usesside="right".
'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-safeis added on the sticky header so the notch/dynamic-island doesn't overlap content (SHELL-02 + Phase 1 PWA-04).bg-background/95 backdrop-blurmatches CONTEXT.md SHELL-02.- Bell
onClickis intentionally empty (SHELL-03 placeholder); future phase wires real notifications. - Avatar is
h-7 w-7per SHELL-04 — wrapped in ah-9 w-9button 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.tsxexits 0grep -E "export function HeaderBar" components/mobile/HeaderBar.tsxmatchesgrep -E "sticky top-0" components/mobile/HeaderBar.tsxmatches ANDgrep -E "bg-background/95 backdrop-blur" components/mobile/HeaderBar.tsxmatches ANDgrep -E "border-b" components/mobile/HeaderBar.tsxmatches (SHELL-02)grep -E "/mobile/dashboard" components/mobile/HeaderBar.tsxmatches (brand link target, SHELL-02)grep -E "WulfMark" components/mobile/HeaderBar.tsxmatches ANDgrep -E "Pulse" components/mobile/HeaderBar.tsxmatches (mark + wordmark, SHELL-02)grep -E "aria-label=\"Notifications\"" components/mobile/HeaderBar.tsxmatches ANDgrep -E "Bell" components/mobile/HeaderBar.tsxmatches (SHELL-03)grep -E "h-7 w-7" components/mobile/HeaderBar.tsxmatches (compact avatar, SHELL-04)grep -E "onAvatarClick" components/mobile/HeaderBar.tsxmatches (avatar opens drawer via parent state, SHELL-04)grep -E "pt-safe" components/mobile/HeaderBar.tsxmatches (PWA-04 reuse / safe-area)! grep -E "<h1" components/mobile/HeaderBar.tsxexits 0 (no page title in header, SHELL-02 explicit) </acceptance_criteria> HeaderBar renders WulfMark+wordmark linked to /mobile/dashboard, a Bell button witharia-label="Notifications"and empty onClick, and an avatar-circle button that callsonAvatarClick(parent wires this to the drawer state).
'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-autokeeps the nav width-aligned with the content gutter (SHELL-06 + CONTEXT.md).pb-safeon 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 viaonMoreClick. - 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.tsxexits 0grep -E "export function BottomNav" components/mobile/BottomNav.tsxmatchesgrep -E "/mobile/dashboard" components/mobile/BottomNav.tsxmatches ANDgrep -E "/mobile/tickets" components/mobile/BottomNav.tsxmatches ANDgrep -E "/mobile/finance" components/mobile/BottomNav.tsxmatches ANDgrep -E "/mobile/analyzer" components/mobile/BottomNav.tsxmatches (NAV-02)grep -E "LayoutDashboard" components/mobile/BottomNav.tsxmatches ANDgrep -E "\\bTicket\\b" components/mobile/BottomNav.tsxmatches ANDgrep -E "DollarSign" components/mobile/BottomNav.tsxmatches ANDgrep -E "Sparkles" components/mobile/BottomNav.tsxmatches ANDgrep -E "\\bMenu\\b" components/mobile/BottomNav.tsxmatches (NAV-01 + DRAWER-01 icons)grep -E "pathname.*startsWith" components/mobile/BottomNav.tsxmatches (NAV-03)grep -E "text-primary" components/mobile/BottomNav.tsxmatches ANDgrep -E "text-muted-foreground" components/mobile/BottomNav.tsxmatches (NAV-03 active/inactive)grep -E "fixed bottom-0" components/mobile/BottomNav.tsxmatches ANDgrep -E "border-t" components/mobile/BottomNav.tsxmatches ANDgrep -E "max-w-lg mx-auto" components/mobile/BottomNav.tsxmatches (SHELL-06)grep -E "pb-safe" components/mobile/BottomNav.tsxmatches (safe-area for home indicator)grep -E "onMoreClick" components/mobile/BottomNav.tsxmatches (DRAWER-01 trigger via parent state) </acceptance_criteria> BottomNav exports a 5-cell nav: 4 routed Links (Dashboard, Tickets, Finance, Analyzer) with active-state viapathname.startsWith(href), plus a More button that callsonMoreClick.
/* 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/feedcall — 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.tsxexits 0grep -E "export default function" app/mobile/analyzer/page.tsxmatchesgrep -iE "coming soon" app/mobile/analyzer/page.tsxmatches (placeholder copy present)! grep -E "/api/mobile/analyzer" app/mobile/analyzer/page.tsxexits 0 (no Phase 6 data fetching) </acceptance_criteria> Visiting/mobile/analyzerafter build renders a small "coming soon" card; no 404.
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
anycast onsession.user→ keep the typed cast pattern fromUserMenu.tsx- JSX-runtime /
JSXnamespace 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> |
- The four new files exist:
test -f components/mobile/HeaderBar.tsxtest -f components/mobile/BottomNav.tsxtest -f components/mobile/MoreDrawer.tsxtest -f app/mobile/analyzer/page.tsx
npx tsc --noEmit --prettyexits 0npm run buildexits 0app/mobile/layout.tsxis unchanged from start (still importsLayoutDashboard, Ticket, DollarSign, Menuonly — notSparkles):! grep -E "Sparkles" app/mobile/layout.tsxexits 0 (we have NOT yet wired the new bottom nav — Plan 02 does that)
app/mobile/nav/page.tsxstill exists (Plan 02 deletes it)- No new dependencies added:
git diff package.json package-lock.jsonis 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>