+
+
+
+ );
+}
+```
+
+Verify against UI-SPEC §"Feed Row Card": title `text-sm font-semibold leading-snug truncate` (1-line), summary `text-xs text-muted-foreground line-clamp-2`, ticket# badge `bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono`, time-ago `text-[10px] text-muted-foreground`, footer flex row, Review pill EXACTLY `bg-destructive/10 text-destructive` with copy "Review" (D-17). NO icons in the Review pill, NO exclamation mark.
+
+The `relTime()` helper is duplicated inline (per D-04 and CONTEXT.md "duplicate inline; extract shared only when third caller appears"). The tickets page is the second caller; analyzer row is the third — but the diff is small enough that inline duplication keeps Plan 06-02 atomic. A future cleanup phase can extract.
+
+
+ npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(AnalyzerStagePips|ConfidenceBadge|AnalyzerRowSkeleton|AnalyzerFeedRow)\\.tsx" || echo "TypeScript clean for new components"
+
+
+ - All 4 files exist: `for f in components/mobile/AnalyzerStagePips.tsx components/mobile/ConfidenceBadge.tsx components/mobile/AnalyzerRowSkeleton.tsx components/mobile/AnalyzerFeedRow.tsx; do test -f "$f" || echo "MISSING $f"; done` produces no MISSING output
+ - Each file starts with `'use client';`: `head -1 components/mobile/AnalyzerStagePips.tsx components/mobile/ConfidenceBadge.tsx components/mobile/AnalyzerRowSkeleton.tsx components/mobile/AnalyzerFeedRow.tsx | grep -c "'use client'"` returns 4
+ - Each file exports its named component: `grep -E "^export function (AnalyzerStagePips|ConfidenceBadge|AnalyzerRowSkeleton|AnalyzerFeedRow)" components/mobile/Analyzer*.tsx components/mobile/ConfidenceBadge.tsx | wc -l` returns 4
+ - AnalyzerStagePips renders correct dot classes: `grep -F 'bg-primary' components/mobile/AnalyzerStagePips.tsx` returns at least one match AND `grep -F 'bg-muted-foreground/30' components/mobile/AnalyzerStagePips.tsx` returns at least one match
+ - AnalyzerStagePips dot size correct: `grep -F 'h-1.5 w-1.5' components/mobile/AnalyzerStagePips.tsx` returns at least one match
+ - AnalyzerStagePips includes sr-only label: `grep -F 'sr-only' components/mobile/AnalyzerStagePips.tsx` returns at least one match
+ - ConfidenceBadge thresholds exact (D-15): `grep -F '0.85' components/mobile/ConfidenceBadge.tsx` returns at least one match AND `grep -F '0.65' components/mobile/ConfidenceBadge.tsx` returns at least one match
+ - ConfidenceBadge tones exact: `grep -F 'bg-green-500/10' components/mobile/ConfidenceBadge.tsx` returns at least one match AND `grep -F 'bg-amber-500/10' components/mobile/ConfidenceBadge.tsx` returns at least one match AND `grep -F 'bg-slate-500/10' components/mobile/ConfidenceBadge.tsx` returns at least one match
+ - ConfidenceBadge dark-mode tones present: `grep -F 'dark:text-green-400' components/mobile/ConfidenceBadge.tsx` returns at least one match
+ - ConfidenceBadge returns null on null score: `grep -E 'score === null.*return null' components/mobile/ConfidenceBadge.tsx` returns at least one match (or equivalent early-return)
+ - AnalyzerFeedRow links to detail: `grep -F 'href={`/mobile/analyzer/${row.id}`}' components/mobile/AnalyzerFeedRow.tsx` returns at least one match (or use `grep -E 'mobile/analyzer/' components/mobile/AnalyzerFeedRow.tsx`)
+ - AnalyzerFeedRow has NO border-l-4: `grep -F 'border-l-4' components/mobile/AnalyzerFeedRow.tsx components/mobile/AnalyzerRowSkeleton.tsx` returns ZERO matches (D-11 — no priority stripe)
+ - Title row classes exact: `grep -F 'text-sm font-semibold leading-snug truncate' components/mobile/AnalyzerFeedRow.tsx` returns at least one match
+ - Summary clamp class: `grep -F 'line-clamp-2' components/mobile/AnalyzerFeedRow.tsx` returns at least one match
+ - Summary fallback character is em-dash: `grep -F "'—'" components/mobile/AnalyzerFeedRow.tsx` returns at least one match (literal em-dash, not "--")
+ - Review pill copy exact: `grep -E '>Review<' components/mobile/AnalyzerFeedRow.tsx` returns at least one match
+ - Review pill tone: `grep -F 'bg-destructive/10 text-destructive' components/mobile/AnalyzerFeedRow.tsx` returns at least one match
+ - Skeleton renders no border-l-4 (already covered by previous check)
+ - AnalyzerFeedRow imports the type, not the component, from the route file: `grep -E 'import type \\{ AnalyzerFeedRow.*from .@/app/api/mobile/analyzer/feed/route.' components/mobile/AnalyzerFeedRow.tsx` returns at least one match
+ - `npx tsc --noEmit --pretty` exits 0
+
+
+ Four components compile, exported with correct props interfaces, render the exact Tailwind classes and copy strings declared in 06-UI-SPEC. Task 2 can compose them in the page.
+
+
+
+
+ Task 2: Replace placeholder with the feed list page (fetch, IntersectionObserver, Load more, empty/error states)
+ app/mobile/analyzer/page.tsx
+
+ - app/mobile/analyzer/page.tsx (current placeholder — being replaced wholesale)
+ - .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-08, D-09, D-28, D-29, D-30, D-31, D-34, D-35, D-38)
+ - .planning/phases/06-analyzer-feed-new/06-UI-SPEC.md §"Infinite Scroll Sentinel + Load More" + §"Copywriting Contract" + §"Loading States"
+ - app/mobile/tickets/page.tsx (PATTERN SOURCE — copy the IntersectionObserver setup at lines 188-203, the loadFirst/loadMore pattern at lines 121-174, the Load more button at lines 292-304, the inline spinner at lines 285-289)
+ - components/ui/empty-state.tsx (EmptyState component — has `icon`, `title`, `description`, `action` props)
+ - app/api/mobile/analyzer/feed/route.ts (the endpoint Plan 06-01 created)
+
+
+**Delete the existing placeholder content** at `app/mobile/analyzer/page.tsx` and replace with the real feed page. The file is being rewritten end-to-end; no part of the placeholder JSX/imports survives.
+
+The page is `'use client'` (CLAUDE.md mobile pattern; D-38 — no SWR, no react-query, plain `useState` + `useEffect` + `fetch`). It does NOT need `Suspense` because it has NO `useSearchParams()` calls (no URL filter sync this phase per CONTEXT.md "feed has no filters this phase"). If TypeScript or runtime requires Suspense for some other reason, wrap as in `app/mobile/tickets/page.tsx` lines 73-79.
+
+Implementation:
+
+```tsx
+'use client';
+
+import { useEffect, useState, useCallback, useRef } from 'react';
+import Link from 'next/link';
+import { Loader2, Sparkles, ExternalLink } from 'lucide-react';
+import { toast } from 'sonner';
+import { AnalyzerFeedRow } from '@/components/mobile/AnalyzerFeedRow';
+import { AnalyzerRowSkeleton } from '@/components/mobile/AnalyzerRowSkeleton';
+import type { AnalyzerFeedRow as AnalyzerFeedRowType, AnalyzerFeedResponse } from '@/app/api/mobile/analyzer/feed/route';
+
+export default function MobileAnalyzerPage() {
+ // List state
+ const [analyses, setAnalyses] = useState([]);
+ const [nextCursor, setNextCursor] = useState(null);
+ const [hasMore, setHasMore] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [error, setError] = useState(null);
+
+ // First page (mount)
+ const loadFirst = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const r = await fetch('/api/mobile/analyzer/feed?limit=25');
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ const data: AnalyzerFeedResponse = await r.json();
+ setAnalyses(data.analyses);
+ setNextCursor(data.nextCursor);
+ setHasMore(data.hasMore);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to load analyses';
+ setError(msg);
+ toast.error('Failed to load analyses');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ // Cursor advance
+ const loadMore = useCallback(async () => {
+ if (loadingMore || !hasMore || !nextCursor) return;
+ setLoadingMore(true);
+ setError(null);
+ try {
+ const sp = new URLSearchParams({ cursor: nextCursor, limit: '25' });
+ const r = await fetch(`/api/mobile/analyzer/feed?${sp.toString()}`);
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ const data: AnalyzerFeedResponse = await r.json();
+ setAnalyses(prev => [...prev, ...data.analyses]);
+ setNextCursor(data.nextCursor);
+ setHasMore(data.hasMore);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to load more analyses';
+ setError(msg);
+ toast.error('Failed to load more analyses');
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [loadingMore, hasMore, nextCursor]);
+
+ useEffect(() => { void loadFirst(); }, [loadFirst]);
+
+ // IntersectionObserver — D-08 (rootMargin: '200px', no-op when loadingMore || !hasMore)
+ const sentinelRef = useRef(null);
+ useEffect(() => {
+ const node = sentinelRef.current;
+ if (!node) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries[0]?.isIntersecting && hasMore && !loadingMore && !loading) {
+ void loadMore();
+ }
+ },
+ { rootMargin: '200px' },
+ );
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [hasMore, loadingMore, loading, loadMore]);
+
+ // ──── Render ────
+ return (
+
+ {/* Page H1 — D-35 (in body, not in shell HeaderBar) */}
+
+ );
+}
+```
+
+**Copy contract — these strings are LOCKED in 06-UI-SPEC §"Copywriting Contract":**
+- Page H1: exactly `Analyzer` (D-35)
+- Empty state heading: exactly `No analyses yet` (D-31)
+- Empty state body: exactly `Completed AI ticket analyses will appear here.` (D-31)
+- Empty state CTA visible label: exactly `Open desktop Analyzer` linking to `/analyzer/tickets` (D-31)
+- Load more button (idle): exactly `Load more`
+- Load more button (loading): exactly `Loading…` (with ellipsis character, not three dots)
+- Load more button (error): exactly `Retry`
+- Error toast (initial load): exactly `Failed to load analyses`
+- Error toast (load more): exactly `Failed to load more analyses`
+
+**Container per D-34:** Outer `
` matches the page-level rhythm; rows in `
` per UI-SPEC §"Feed Row Card" row list container.
+
+**Read-only (ANL-05, D-23):** This page renders NO Buttons that suggest re-running, editing, or prompt-tuning. Only the Load more fallback button (which is a pagination control, not an analysis action) and the empty-state link to desktop. NO `