+ );
+ }
+ ```
+
+ Key behaviors / locked decisions:
+ - `router.replace()` not `router.push()` (D-05).
+ - Cursor is NOT in URL (D-07) — only `q`, `status`, `priority`, `queue`, `mine`.
+ - `border-l-4` + exact UI-SPEC class strings: `border-red-500`, `border-orange-400`, `border-amber-400`, `border-slate-300` (D-15).
+ - No priority dot rendered (D-17 — the legacy `
` is removed).
+ - Sentinel `aria-hidden="true"` (UI-SPEC accessibility section).
+ - Load more button has `aria-label="Load more tickets"` and is always rendered when `hasMore` so screen-reader users have a focusable control even after the IntersectionObserver triggers (TICK-06 / D-14).
+ - Skeleton state for initial load only — subsequent `loadingMore` shows the small spinner above Load more (D-21).
+ - Title uses `truncate` (1-line, per UI-SPEC "Row title — 1-line truncate") — the legacy code used `line-clamp-2`; switch to `truncate` to match locked spec.
+ - Use `` wrapper because `useSearchParams()` requires it in Next.js 16 (CLAUDE.md memory entry).
+
+ Anti-patterns (do NOT do):
+ - Do NOT introduce SWR / react-query (CLAUDE.md).
+ - Do NOT use `router.push()` for filter updates (D-05).
+ - Do NOT persist `cursor` to the URL (D-07).
+ - Do NOT add a separate count endpoint — Plan 02 deliberately uses `tickets.length + hasMore ? 1 : 0` as a "≥N" approximation; revisit only if the exact count is needed (out of scope this phase).
+ - Do NOT reintroduce the priority dot — the stripe replaces it (D-17).
+ - Do NOT use Tailwind class `border-yellow-400` for priority 3 (legacy used yellow; UI-SPEC locked it to `border-amber-400`).
+ - Do NOT call `router.push()` on every keystroke — the debounced effect handles URL sync once the search settles.
+
+ Discretionary choices made (per "Claude's Discretion" in 04-CONTEXT.md):
+ - Assignee initials avatar: `inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary/15 text-primary text-[10px] font-semibold` rendering up to 2 initials, falling back to `·`.
+ - Queue option list is derived from the first page's tickets — no separate `/api/mobile/queues` endpoint. Acceptable for v1; the Select still works because the parent always passes the most recent set after the first load.
+ - "Open total" approximated as `tickets.length + (hasMore ? 1 : 0)` — visible label reads "N open tickets"; precision deferred until a count endpoint exists.
+
+
+ npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/tickets/page\.tsx" || echo "OK: page typechecks"
+
+
+ - `grep -q "'use client'" app/mobile/tickets/page.tsx` (client component declaration)
+ - `grep -q "Suspense" app/mobile/tickets/page.tsx` (Suspense wrapper for useSearchParams — Next.js 16 requirement)
+ - `grep -q "useSearchParams" app/mobile/tickets/page.tsx` (URL hydration)
+ - `grep -q "router\.replace" app/mobile/tickets/page.tsx` (D-05 — replace not push)
+ - `! grep -q "router\.push" app/mobile/tickets/page.tsx` (no push for filter updates)
+ - `grep -q "TicketFilterStrip" app/mobile/tickets/page.tsx` (uses Plan 01 component)
+ - `grep -q "TicketRowSkeleton" app/mobile/tickets/page.tsx` (uses Plan 01 skeleton)
+ - `grep -q "import type.*MobileTicketListResponse" app/mobile/tickets/page.tsx` (typed response — Phase 3 pattern)
+ - `grep -q "IntersectionObserver" app/mobile/tickets/page.tsx` (D-12)
+ - `grep -q "rootMargin: '200px'" app/mobile/tickets/page.tsx` (D-12 — exact margin)
+ - `grep -q "border-red-500" app/mobile/tickets/page.tsx` (priority 1 — D-15)
+ - `grep -q "border-orange-400" app/mobile/tickets/page.tsx` (priority 2 — D-15)
+ - `grep -q "border-amber-400" app/mobile/tickets/page.tsx` (priority 3 — D-15, NOT yellow)
+ - `grep -q "border-slate-300" app/mobile/tickets/page.tsx` (priority 4 — D-15)
+ - `grep -q "border-l-4" app/mobile/tickets/page.tsx` (4px stripe — D-15)
+ - `grep -q "Load more" app/mobile/tickets/page.tsx` (TICK-06 fallback)
+ - `grep -q 'aria-label="Load more tickets"' app/mobile/tickets/page.tsx` (a11y)
+ - `grep -q 'aria-hidden="true"' app/mobile/tickets/page.tsx` (sentinel a11y)
+ - `grep -q "No tickets match your filters" app/mobile/tickets/page.tsx` (D-20 empty state — filtered)
+ - `grep -q "No tickets to triage right now" app/mobile/tickets/page.tsx` (D-20 empty state — unfiltered)
+ - `grep -q "Clear filters" app/mobile/tickets/page.tsx` (D-20 CTA)
+ - `grep -q "function relTime" app/mobile/tickets/page.tsx` (D-17 helper preserved)
+ - `! grep -q "PRIORITY_DOT" app/mobile/tickets/page.tsx` (D-17 — dot removed)
+ - `! grep -q "border-yellow-400" app/mobile/tickets/page.tsx` (legacy yellow replaced by amber)
+ - `! grep -qE "useSWR|@tanstack/react-query|zustand" app/mobile/tickets/page.tsx` (CLAUDE.md — no forbidden libs)
+ - `! grep -q "?page=" app/mobile/tickets/page.tsx` (no legacy page param)
+ - `npx tsc --noEmit --pretty 2>&1` reports no errors for `app/mobile/tickets/page.tsx`
+
+ The rewritten page hydrates filters from `useSearchParams()` inside a Suspense boundary, calls `router.replace()` to sync filter changes back to the URL, fetches the cursor-paginated API on first load and on filter changes, advances via cursor on IntersectionObserver intersection (with a Load more fallback), renders priority-stripe rows using the four locked Tailwind border classes, shows skeleton rows on initial load and a small spinner during cursor advances, and renders the two distinct empty-state copies. TypeScript compiles cleanly.
+
+
+
+ Task 2: Verify the new tickets list end-to-end on a real device or simulator
+ app/mobile/tickets/page.tsx (verifying — not modifying)
+ Human verification only — see below for the 14-step checklist. No code changes. Pause execution and wait for the user to confirm the new list page behaves per spec on a phone-width viewport.
+ echo "Manual checkpoint — see resume-signal"
+ User confirms all 14 checklist items pass on a phone-width viewport (real device or DevTools iPhone 15 Pro emulation), or describes precisely which step failed and why.
+
+ The mobile Tickets list page now uses the new shell-aligned layout: Collapsible filter strip, URL-synced filter state, priority-stripe rows, IntersectionObserver-driven infinite scroll, Load more fallback button, skeleton loading state, and the two D-20 empty-state copies. The detail page link target (`/mobile/tickets/[id]`) is unchanged — that page's header reskin is shipped by Plan 04-03 in parallel.
+
+
+ Start the dev server (`npm run dev` → http://localhost:3100) and sign in. Then on a phone-width viewport (or Chrome DevTools iPhone 15 Pro emulation):
+
+ 1. **Initial load + skeleton** — Navigate to `/mobile/tickets`. You should briefly see 5 skeleton rows (each with a muted left stripe + 3 placeholder lines), then the real tickets render.
+ 2. **Default state** — Filter strip is COLLAPSED. Search input visible. "Filters" button visible. Count line shows "N open tickets". Each row has a 4px colored left stripe (red / orange / amber / slate) — no dot.
+ 3. **Single-tap row** — Tap any row → routes to `/mobile/tickets/[id]` (existing detail page; header reskin from Plan 04-03 may or may not be live yet — body should render either way).
+ 4. **Filter strip expands** — Tap "Filters". Panel reveals four controls: status chips (Open / In Progress / Waiting), priority chips (Critical / High / Medium / Low), queue Select, "Assigned to me" Switch.
+ 5. **URL deep-link — set filters** — Tap "High" priority chip. URL updates IN PLACE to include `?priority=2` (no new history entry — back button takes you OUT of `/mobile/tickets`, not to a previous filter state).
+ 6. **URL deep-link — reload** — Reload the page with the URL still showing `?priority=2`. Filter strip hydrates with "High" already selected; list shows only priority-2 tickets.
+ 7. **Search debounce** — Type in the search box. URL updates ~400ms after you stop typing, not on every keystroke.
+ 8. **Clear all** — Tap "Clear all". Status returns to default (Open + In Progress + Waiting), priority/queue/mine reset, URL params clear.
+ 9. **Infinite scroll** — Scroll to the bottom of the list. The next ~25 rows append automatically (small spinner appears briefly above "Load more"). The page does NOT navigate to a new URL.
+ 10. **Load more button** — Confirm the "Load more" button is visible and focusable (Tab to it). Clicking it also advances the list. When the list is exhausted, the button disappears.
+ 11. **Empty state with filters** — Set filters that return no rows (e.g., a non-existent search term). Page shows "No tickets match your filters" + a "Clear filters" button. Tapping it restores defaults.
+ 12. **No charts / no recharts imports** — Sanity check: open DevTools network tab and confirm only `/api/mobile/tickets` is called (no extra count or queue endpoints).
+ 13. **Priority colors** — A row with `priority=1` has `border-red-500`, `priority=2` `border-orange-400`, `priority=3` `border-amber-400`, `priority=4` `border-slate-300`. These are direct Tailwind palette references per UI-SPEC §"Priority Stripe Colors".
+ 14. **Detail back nav (Plan 04-03 dependency)** — From a detail page, the device back gesture returns you to the list at the same scroll position with filters intact. (Plan 04-03 reskins the in-page back chevron — UX should still work without it.)
+
+ Type "approved" if all 14 checks pass. If any fail, describe the failure precisely (which step, what you saw vs. expected).
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| URL search params → component state | A user-supplied URL (incl. shared deep links) populates filter state and is fed into API calls |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-04-07 | Tampering | URL params (`status`, `priority`, `queue`, `mine`, `q`) | mitigate | `parseFilterFromSearch()` runs `parseInt + isNaN` filter on every numeric value; non-numeric tokens silently dropped. The page only forwards values to the API, which itself parameterises and validates. |
+| T-04-08 | Information Disclosure | search query reflected in URL | accept | Query parameters appear in browser history and any logging — same risk as the existing implementation; users searching for sensitive terms is a userland concern. |
+| T-04-09 | Denial of Service | rapid filter changes flood the API | mitigate | Search input debounced 400ms (D-03). Other filters are discrete user actions (chip tap, dropdown change) — already rate-limited by human input speed. |
+| T-04-10 | Repudiation | mobile actions are read-only | accept | This page is read-only; no audit logging needed. Detail page comments/edits are out of scope. |
+
+
+
+1. `npx tsc --noEmit --pretty` passes — no type errors in `app/mobile/tickets/page.tsx`.
+2. All `` grep checks for Task 1 return success.
+3. Human-verify checklist (Task 2) reaches "approved".
+4. Phase-level smoke: visit `/mobile/tickets`, then `/mobile/tickets?priority=1`, then `/mobile/tickets?status=&priority=&q=zzz_no_match` — three different rendered states (default list, priority-1 only, empty state with Clear filters CTA).
+
+
+
+- TICK-01: Collapsible filter strip default-collapsed, expands to status/priority/queue/mine controls.
+- TICK-02: All four filter primitives sync to the URL via `router.replace()`; reload hydrates state.
+- TICK-03: Each row has a `border-l-4` stripe with the correct priority Tailwind class.
+- TICK-04: Single-tap on a row navigates to `/mobile/tickets/[id]`.
+- TICK-05: ~25-per-page cursor advance via `IntersectionObserver` with `rootMargin: '200px'`.
+- TICK-06: A focusable "Load more" button is rendered whenever `hasMore` is true.
+- D-20 empty states render correct copy with correct CTAs.
+- No new state libraries introduced; CLAUDE.md conventions honored.
+
+
+
diff --git a/.planning/phases/04-tickets-restyle/04-03-PLAN.md b/.planning/phases/04-tickets-restyle/04-03-PLAN.md
new file mode 100644
index 0000000..a26f06b
--- /dev/null
+++ b/.planning/phases/04-tickets-restyle/04-03-PLAN.md
@@ -0,0 +1,218 @@
+---
+phase: 04
+plan: 03
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - app/mobile/tickets/[id]/page.tsx
+autonomous: true
+requirements: [TICK-07]
+must_haves:
+ truths:
+ - "Detail page in-page header shows a back chevron + 'Tickets' label that calls router.back()"
+ - "Detail page in-page header shows the breadcrumb 'Tickets / #{ticket_number}' centered"
+ - "Detail page in-page header shows an external-link icon that opens the desktop ticket URL in a new tab"
+ - "The shell HeaderBar (Wulf mark + Bell + avatar) from app/mobile/layout.tsx still renders above the in-page header"
+ - "The detail body (priority/status badges, stats grid, description, timeline) is unchanged from the legacy implementation"
+ artifacts:
+ - path: "app/mobile/tickets/[id]/page.tsx"
+ provides: "Mobile ticket detail page with reskinned in-page header per D-18"
+ contains: "ArrowLeft"
+ key_links:
+ - from: "app/mobile/tickets/[id]/page.tsx"
+ to: "lucide-react ArrowLeft + ExternalLink icons"
+ via: "named import"
+ pattern: "ExternalLink"
+ - from: "app/mobile/tickets/[id]/page.tsx"
+ to: "/api/mobile/tickets/{id}/timeline endpoint"
+ via: "fetch — unchanged from legacy"
+ pattern: "fetch\\(`/api/mobile/tickets/"
+---
+
+
+Reskin only the in-page header bar at the top of `app/mobile/tickets/[id]/page.tsx` per D-18: replace the current "← Back" button with a three-slot header (back chevron + label, breadcrumb, external link). The detail body — priority badge row, h1 title, stats grid, description block, timeline — stays untouched per D-19.
+
+Purpose: closes TICK-07. The shell HeaderBar already renders above this page from `app/mobile/layout.tsx`, so the in-page header docks under it consistently with the new shell language.
+
+Output:
+- Modified `app/mobile/tickets/[id]/page.tsx` with the new three-slot header bar and `ExternalLink` icon import. Body untouched.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/REQUIREMENTS.md
+@.planning/phases/04-tickets-restyle/04-CONTEXT.md
+@.planning/phases/04-tickets-restyle/04-UI-SPEC.md
+@.planning/phases/02-mobile-shell/02-CONTEXT.md
+@CLAUDE.md
+@app/mobile/tickets/[id]/page.tsx
+
+
+
+
+
+ Task 1: Reskin the in-page header of /mobile/tickets/[id] with back chevron, breadcrumb, and external-link icon
+ app/mobile/tickets/[id]/page.tsx
+
+ - app/mobile/tickets/[id]/page.tsx (full 357-line current file — only lines ~239-242 change in the header; everything else is preserved)
+ - .planning/phases/04-tickets-restyle/04-CONTEXT.md decisions D-18 (header structure) and D-19 (body unchanged)
+ - .planning/phases/04-tickets-restyle/04-UI-SPEC.md "Detail Page In-Page Header" section
+
+
+ Open `app/mobile/tickets/[id]/page.tsx` and make TWO surgical edits.
+
+ **Edit 1: Add `ExternalLink` to the lucide-react imports (line 5-8 region).**
+
+ Current import block:
+ ```typescript
+ import {
+ ArrowLeft, RefreshCw, Clock, FileText, Timer, CheckCircle2,
+ ChevronDown, ChevronRight, User, Briefcase, AlertCircle, EyeOff, Eye, Mail, AlignLeft, Code2,
+ } from 'lucide-react';
+ ```
+
+ Add `ExternalLink` to the named imports (alphabetical position: after `Eye`, before `Mail`). Final form:
+ ```typescript
+ import {
+ ArrowLeft, RefreshCw, Clock, FileText, Timer, CheckCircle2,
+ ChevronDown, ChevronRight, User, Briefcase, AlertCircle, EyeOff, Eye, ExternalLink, Mail, AlignLeft, Code2,
+ } from 'lucide-react';
+ ```
+
+ Do NOT remove any existing import — `ArrowLeft`, `RefreshCw`, etc. all remain in use.
+
+ **Edit 2: Replace the legacy back button (currently at lines ~240-242) with the three-slot header bar per D-18 / UI-SPEC §"Detail Page In-Page Header".**
+
+ Current code (the section to replace — inside the `{/* Ticket header */}` div, only the FIRST element of that block):
+ ```tsx
+
+ ```
+
+ Replace with the new three-slot header. **Important context:** the parent `
` already provides horizontal padding and the bottom border. The new header must NOT double-bracket the border. So the new header bar replaces ONLY the back button — keep the parent div as-is, just substitute its first child:
+
+ ```tsx
+
+ ```
+
+ Notes on the layout:
+ - `-mx-4 px-4` extends the header band to the parent div's edges and reapplies internal padding so the `border-b` runs full-width visually under the new header.
+ - The legacy parent div still has its own `border-b` from when it bracketed the entire header (badges + title + stats + description). That outer `border-b` stays — it now sits below the description / stats area, which is the correct visual structure (the new header has its own divider; the outer border still divides the header section from the timeline).
+ - Breadcrumb uses `text-sm font-semibold truncate` — UI-SPEC §"Typography" `Detail breadcrumb` row.
+ - Desktop URL: `/analyzer/ticket/{id}` — this is the desktop analyzer ticket view (the canonical desktop ticket URL in this codebase). UI-SPEC permits "Autotask direct URL" as alternative; the analyzer URL is the in-app desktop equivalent and stays inside the auth boundary.
+ - `aria-hidden="true"` on icons because the surrounding text / aria-label provides the accessible name.
+
+ **What NOT to change (D-19 — body untouched):**
+ - The badges row (`
` with priority + ticket number + status pills) — preserve verbatim.
+ - The `
{ticket.title}
` — preserve verbatim.
+ - The metadata icons row (Briefcase / User / Clock with company / assignee / created date) — preserve verbatim.
+ - The 3-card stats grid (Notes / Time entries / Hours logged) — preserve verbatim.
+ - The Description Collapsible block + Timeline section — preserve verbatim.
+ - The `TimelineCard` component definition — preserve verbatim.
+ - The `loading` and `error` states — preserve verbatim.
+ - All helper functions (`fmtDate`, `fmtHours`, `renderContent`, `relTime`) — preserve verbatim.
+ - The `STATUS_LABEL`, `PRIORITY_LABEL`, `PRIORITY_COLOR` constant maps — preserve verbatim.
+
+ Do NOT touch `app/mobile/tickets/[id]/timeline/route.ts` or any other file. The plan's `files_modified` is exactly one file.
+
+ Anti-patterns (do NOT do):
+ - Do NOT remove the parent `
` wrapper — the body still expects it.
+ - Do NOT remove or alter the existing badges, title, stats, description, or timeline — body is out of scope (D-19).
+ - Do NOT introduce a `` element here — that's the shell's job and already renders from `app/mobile/layout.tsx`.
+ - Do NOT swap the desktop URL to an external Autotask link unless the analyzer URL is unreachable — the UI-SPEC accepts either; analyzer URL is preferred (in-app navigation).
+
+
+ npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/tickets/\[id\]/page\.tsx" || echo "OK: detail page typechecks"
+
+
+ - `grep -q "ExternalLink" app/mobile/tickets/[id]/page.tsx` (icon imported and used)
+ - `grep -q 'aria-label="Back to Tickets"' app/mobile/tickets/[id]/page.tsx` (D-18 / UI-SPEC accessibility)
+ - `grep -q 'aria-label="Open ticket on desktop"' app/mobile/tickets/[id]/page.tsx` (D-18 external link a11y)
+ - `grep -q "Tickets / #" app/mobile/tickets/[id]/page.tsx` (D-18 breadcrumb literal)
+ - `grep -q "router\.back()" app/mobile/tickets/[id]/page.tsx` (back gesture preserved)
+ - `grep -q "/analyzer/ticket/" app/mobile/tickets/[id]/page.tsx` (desktop URL in href)
+ - `grep -q 'target="_blank"' app/mobile/tickets/[id]/page.tsx` (opens in new tab)
+ - `grep -q 'rel="noopener noreferrer"' app/mobile/tickets/[id]/page.tsx` (security on target=_blank)
+ - `grep -q "TimelineCard" app/mobile/tickets/[id]/page.tsx` (body component preserved — D-19)
+ - `grep -q "function fmtDate" app/mobile/tickets/[id]/page.tsx` (body helper preserved — D-19)
+ - `grep -q "function renderContent" app/mobile/tickets/[id]/page.tsx` (body helper preserved — D-19)
+ - `grep -q "Notes" app/mobile/tickets/[id]/page.tsx && grep -q "Time entries" app/mobile/tickets/[id]/page.tsx && grep -q "Hours logged" app/mobile/tickets/[id]/page.tsx` (stats grid preserved — D-19)
+ - `! grep -qE ">\\s*Back\\s*" app/mobile/tickets/[id]/page.tsx` (legacy "Back" text removed in favor of "Tickets")
+ - `npx tsc --noEmit --pretty 2>&1` reports no errors for `app/mobile/tickets/[id]/page.tsx`
+
+ The detail page imports `ExternalLink`, renders the three-slot in-page header (back chevron + "Tickets" label, breadcrumb "Tickets / #{ticket_number}", external-link icon to `/analyzer/ticket/{id}`), and leaves the badges row, title, stats grid, description, and timeline byte-identical to the legacy implementation. TypeScript compiles cleanly.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| in-page link → external desktop URL | The new ExternalLink anchor opens `/analyzer/ticket/{id}` in a new tab |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-04-11 | Tampering | external link `target="_blank"` reverse tabnabbing | mitigate | `rel="noopener noreferrer"` on the anchor — prevents the opened page from accessing `window.opener`, even though `/analyzer/ticket/{id}` is same-origin. Defense in depth. |
+| T-04-12 | Information Disclosure | desktop URL leaks ticket id in browser tab | accept | Same id is already in the current page URL; no new exposure. |
+
+
+
+1. `npx tsc --noEmit --pretty` passes — no errors for `app/mobile/tickets/[id]/page.tsx`.
+2. All Task 1 acceptance-criteria greps return success.
+3. Manual smoke (developer terminal): visit `/mobile/tickets/` and confirm:
+ - The shell HeaderBar (Wulf wordmark + Bell + avatar) renders at the very top from `app/mobile/layout.tsx`.
+ - Below it, the new in-page header shows back chevron + "Tickets" on the left, breadcrumb in the center, ExternalLink icon on the right.
+ - Below that, the unchanged badges row → title → stats grid → description → timeline.
+ - Tapping "Tickets" calls `router.back()` and returns to the list.
+ - Tapping the ExternalLink icon opens `/analyzer/ticket/` in a new tab.
+
+
+
+- TICK-07: Detail page header reskinned to match the new shell language (back chevron + breadcrumb + external link); body unchanged.
+- D-18 implemented exactly: three slots (back, breadcrumb, external).
+- D-19 honored: badges, title, stats grid, description, timeline byte-identical.
+- TypeScript clean.
+- The shell HeaderBar from `app/mobile/layout.tsx` continues to render above this in-page header — no double-rendering.
+
+
+