20 KiB
| phase | slug | status | shadcn_initialized | preset | created |
|---|---|---|---|---|---|
| 6 | analyzer-feed-new | draft | true | new-york / neutral base / CSS variables | 2026-05-04 |
Phase 6 — UI Design Contract: Analyzer Feed (NEW)
Visual and interaction contract for the mobile Analyzer feed list and per-analysis summary detail page. Generated by gsd-ui-researcher. Consumed by gsd-ui-checker, gsd-planner, gsd-executor.
All decisions tagged [D-NN] are LOCKED in 06-CONTEXT.md and must not be re-litigated.
Design System
| Property | Value |
|---|---|
| Tool | shadcn/ui (new-york style) |
| Preset | components.json — new-york, neutral base, CSS variables, lucide icons |
| Component library | Radix UI (via shadcn) |
| Icon library | lucide-react |
| Font | IBM Plex Sans (sans), IBM Plex Mono (numeric/ID fields) |
Source: components.json (confirmed present), DESIGN.md §2, app/styles/brand.css.
Viewport Contract
| Property | Value |
|---|---|
| Reference device | iPhone 15 Pro — 393 × 852 CSS pixels |
| Max width constraint | max-w-lg mx-auto (from app/mobile/layout.tsx — Phase 2) |
| Shell chrome | HeaderBar (sticky, h-14 + pt-safe) + BottomNav (fixed h-16 + pb-safe) |
| Scrollable content area | <main> in layout — bottom padding = calc(theme(spacing.16)+env(safe-area-inset-bottom)) |
| No in-page sticky zone | No filter strip — analyzer feed has no collapsible filter row (deferred, out of scope) |
Spacing Scale
Declared values (multiples of 4). Mirrors Phase 4/5 contract exactly.
| Token | Value | Usage in this phase |
|---|---|---|
| xs | 4px | Badge internal padding (px-1.5 py-0.5), pip gap (gap-1), icon gap |
| sm | 8px | Row internal gaps (gap-2), pip-to-badge gap (gap-2) |
| sm+ | 12px (3 × 4) | Pip flex container gap (gap-1.5), secondary spacing |
| md | 16px | Horizontal page padding (px-4), Card vertical padding (py-4) |
| lg | 24px | Vertical section gap on detail page (gap-6) |
| xl | 32px | Empty state vertical padding (py-8) |
| 2xl | 48px | Full empty-state screen centering (py-12) |
Touch-target exception: "Load more" fallback button minimum min-h-[44px] tap target (use py-3 padding). The ExternalLink footer link on the detail page must also reach min-h-[44px].
Exceptions: Stage pip dots are decorative visual indicators (h-1.5 w-1.5), not interactive targets — they do not need touch padding. [D-14]
Typography
Two weights only: font-normal (400) and font-semibold (600). font-medium (500) is NOT used. [D-32]
Three sizes. [D-33]
| Role | Size class | Weight | Line Height | Font | Usage |
|---|---|---|---|---|---|
| Row title / section heading | text-sm (14px) |
font-semibold (600) |
leading-snug (1.375) |
IBM Plex Sans | Ticket title (1-line truncate), detail section headings, page H1 |
| Body / secondary | text-xs (12px) |
font-normal (400) |
leading-normal (1.5) |
IBM Plex Sans | Summary clamp (2-line), company name, section body text |
| Badge / time-ago / ticket number / pip labels | text-[10px] (10px) |
font-normal (400) |
leading-normal |
IBM Plex Mono (ticket#) / IBM Plex Sans (badges, time) | Ticket number badge (mono), confidence badge text, "Review" pill, time-ago, pip caret labels |
Page H1 "Analyzer": text-base font-semibold (renders in page body, not in the shell HeaderBar). [D-35]
Detail page long-form text (Summary, Next Step, Next Step Rationale body): text-sm font-normal leading-relaxed whitespace-pre-wrap. [D-21]
Color
All colors use CSS variable tokens from app/globals.css + app/styles/brand.css. Direct Tailwind palette references are used only for semantic status colors per DESIGN.md §2.
| Role | Token / Class | Usage |
|---|---|---|
| Dominant surface (60%) | bg-background |
Page background, detail page background |
| Secondary surface (30%) | bg-muted / bg-muted/50 |
Ticket number badge (bg-muted rounded px-1.5 py-0.5), row hover (hover:bg-muted/50) |
| Primary accent (10%) | text-primary |
Active BottomNav tab only (inherited from Phase 2 shell) |
| Muted text | text-muted-foreground |
Summary 2-line clamp, company name, time-ago, pip labels, caret separator |
| Card surface | bg-card / border |
shadcn Card wrapping each feed row and detail sections |
| Destructive | text-destructive / bg-destructive/10 |
"Review" pill (needs_human_review), error toast, Retry button label |
Accent (bg-primary / text-primary) reserved for: (1) active BottomNav tab indicator (the Analyzer tab text-primary state, inherited from Phase 2 shell) and (2) stage pip filled/used state (bg-primary on dots representing stages the analysis reached, see §"Stage Pip Colors" below). Not used for row borders, hover states, badge backgrounds, text labels, or any other element.
Stage Pip Colors [D-13, D-14]
| State | Class | Semantic |
|---|---|---|
| Used (filled) | bg-primary |
Haiku / Sonnet / Opus stage was executed |
| Unused (empty) | bg-muted-foreground/30 |
Stage was not reached |
Confidence Badge Colors [D-15, D-16]
| Bucket | Condition | Background | Text | Label |
|---|---|---|---|---|
| High | confidence_score >= 0.85 |
bg-green-500/10 |
text-green-700 |
"High" |
| Medium | 0.65 <= score < 0.85 |
bg-amber-500/10 |
text-amber-700 |
"Medium" |
| Low | score < 0.65 |
bg-slate-500/10 |
text-slate-600 |
"Low" |
| Absent | confidence_score IS NULL |
— | — | (render nothing) |
Dark mode: Use dark:text-green-400 / dark:text-amber-400 / dark:text-slate-400 for badge text in dark context — the /10 background holds in both modes via opacity.
Review Pill Colors [D-17]
| State | Classes |
|---|---|
needs_human_review = true |
bg-destructive/10 text-destructive |
needs_human_review = false |
(render nothing — no empty pill placeholder) |
Component Inventory
Primary Visual Anchor
Each feed row's primary focal point is the ticket title on line 2 — text-sm font-semibold with 1-line truncate. Readers land on the title first, then scan down to the summary clamp. The footer pip row and confidence badge are secondary metadata; they must not visually compete with the title.
Feed Row Card [D-10, D-11, D-12] — AnalyzerFeedRow
Outer wrapper: <Card> (shadcn) — no border-l-4 stripe. The Card is the full surface. [D-11]
Inner layout: <CardContent className="px-4 py-4 space-y-1.5">
[Line 1 — header row] flex justify-between items-center
LEFT: ticket number bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono
RIGHT: time-ago text-[10px] text-muted-foreground
[Line 2 — title]
text-sm font-semibold leading-snug truncate
[Line 3 — summary clamp]
text-xs text-muted-foreground line-clamp-2
Null fallback: render "—"
[Footer — flex justify-between items-center mt-1]
LEFT: AnalyzerStagePips component
RIGHT: flex gap-2 items-center
ConfidenceBadge component (or nothing if null)
"Review" pill (or nothing if false)
Tap target: entire Card is wrapped in <Link href="/mobile/analyzer/[id]"> with cursor-pointer hover:bg-muted/50 transition-colors active:bg-muted/50 on the Card. [D-12]
Row list container: space-y-3 between cards. [D-34]
Stage Pips — AnalyzerStagePips [D-13, D-14]
flex items-center gap-1.5
[dot h-1.5 w-1.5 rounded-full {filled|muted}] Triage (haiku_used)
[caret › text-[10px] text-muted-foreground]
[dot h-1.5 w-1.5 rounded-full {filled|muted}] Analyze (sonnet_used)
[caret › text-[10px] text-muted-foreground]
[dot h-1.5 w-1.5 rounded-full {filled|muted}] Deep Review (opus_used)
Filled state: bg-primary (used). Muted state: bg-muted-foreground/30 (not reached). [D-13]
No animation, no hover states, no tooltip — static visual indicator only. [D-13, CONTEXT.md §specifics]
Accessibility: render a visually-hidden <span className="sr-only"> describing stages used, e.g., "Stages: Triage, Analyze" for screen readers.
Confidence Badge — ConfidenceBadge [D-15, D-16]
Uses shadcn Badge from components/ui/badge.tsx. Render as custom variant via inline className override (not a new variant — match the Badge prop signature).
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0.5 border-0 {bucket-bg} {bucket-text}"
>
{label}
</Badge>
No border on the badge (set border-0 via className). Background and text color from the confidence bucket table above. [D-16]
When confidence_score is null: render null (no element). [D-15]
Review Pill [D-17]
Inline with confidence badge in the footer right slot:
{needsHumanReview && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0.5 border-0 bg-destructive/10 text-destructive"
>
Review
</Badge>
)}
Copy: exactly "Review" — no icon, no exclamation mark. [D-17, CONTEXT.md §specifics]
Skeleton Row — AnalyzerRowSkeleton [D-28]
Mirrors TicketRowSkeleton shape without the border-l-4 stripe. Uses shadcn Skeleton.
<Card>
<CardContent className="px-4 py-4 space-y-1.5">
[flex justify-between]
[Skeleton h-3 w-16] ← ticket number
[Skeleton h-3 w-10] ← time-ago
[Skeleton h-4 w-3/4 mt-0.5] ← title
[Skeleton h-3 w-full mt-1] ← summary line 1
[Skeleton h-3 w-2/3] ← summary line 2
[flex justify-between mt-2]
[Skeleton h-2 w-20] ← pip row
[Skeleton h-3 w-12] ← badge
</CardContent>
</Card>
Render 5 instances on initial load: Array.from({ length: 5 }).map((_, i) => <AnalyzerRowSkeleton key={i} />) [D-28]
Infinite Scroll Sentinel + Load More [D-08, D-09]
Identical contract to Phase 4 [04-UI-SPEC.md]:
- Sentinel:
<div ref={sentinelRef} aria-hidden="true" />at list end IntersectionObserverwithrootMargin: '200px'firesfetchNextPage()when sentinel enters viewport- Guard: no-op if
loadingMore || !hasMore - Load more button:
w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50— always rendered whenhasMore, focusable,aria-label="Load more analyses" - Loading more indicator:
Loader2 w-4 h-4 animate-spin text-muted-foreground mx-auto my-2centered above the Load more button during in-flight requests
Detail Page In-Page Header [D-19] — mirrors Phase 4 D-18
Renders below shell HeaderBar (shell provides sticky bg-background/95 header — this is an in-page section, not sticky).
flex items-center justify-between px-4 py-3 border-b
LEFT: <button> ArrowLeft h-4 w-4 + "Analyzer" text → router.back()
text-sm text-muted-foreground hover:text-foreground
aria-label="Back to Analyzer"
CENTER: breadcrumb "Analyzer / #{ticketNumber}"
text-sm font-semibold truncate
RIGHT: <a> href="/analyzer/analysis/[id]" target="_blank" rel="noopener noreferrer"
ExternalLink h-4 w-4 text-muted-foreground hover:text-foreground
aria-label="Open full analysis on desktop"
[D-19, D-22 — ExternalLink pattern from Phase 2 DRAWER-04]
Detail Page Identity Block [D-20]
px-4 pt-4 pb-2 space-y-1
[ticket number] text-[10px] font-mono bg-muted rounded px-1.5 py-0.5 inline-block
[title] text-base font-semibold leading-snug
[company name] text-xs text-muted-foreground
[completed-at] text-[10px] text-muted-foreground (e.g. "3 days ago")
[flex gap-2 items-center mt-1]
AnalyzerStagePips (same component as feed row)
ConfidenceBadge (same component as feed row)
Review pill (same component as feed row, if applicable)
Detail Page Content Sections [D-21]
Three sections in order: Summary, Next Step, Next Step Rationale.
Each section:
<section className="px-4 py-4 space-y-2">
<h2 className="text-sm font-semibold">{section heading}</h2>
<p className="text-sm font-normal leading-relaxed text-foreground whitespace-pre-wrap">
{field value}
</p>
</section>
Null fallback for each field: render "Summary not available." / "Next step not available." / "Rationale not available." in text-sm text-muted-foreground. [D-21]
Sections separated by <Separator /> (shadcn) between them for visual clarity.
Detail Page Footer Link [D-22]
px-4 py-4 border-t
<a href="/analyzer/analysis/[id]"
target="_blank" rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm font-semibold text-primary hover:underline min-h-[44px]">
View full analysis
<ExternalLink className="h-4 w-4" />
</a>
Copy: exactly "View full analysis" — not "View on desktop", not "Open analysis". [D-22, CONTEXT.md §specifics]
Interaction Contracts
Infinite Scroll [D-08, D-09] — mirrors Phase 4
- Page size: 25 rows (server-capped) [D-05]
- Cursor: base64(JSON(
{ completed_at: ISO, id: uuid })). [D-06] - API response:
{ analyses: AnalyzerFeedRow[], nextCursor: string | null, hasMore: boolean }[D-07] - Client state:
analyses: AnalyzerFeedRow[](appended),nextCursor: string | null,hasMore: boolean - No URL cursor persistence (no filter state to sync — feed has no filters this phase) [D-38]
Detail Page Navigation [D-12, D-18, D-19]
- Tap on feed row → Next.js
<Link href="/mobile/analyzer/[id]">navigation (notrouter.push) - Detail back button →
router.back()(returns to the feed at last scroll position via browser history) - "View full analysis" →
target="_blank"external link to/analyzer/analysis/[id] - The detail page ID is the
analyzer_analysesUUID (shareable URL) [D-18]
Loading States
| Phase | What renders |
|---|---|
| Initial load | 5 AnalyzerRowSkeleton instances — no text, no spinner |
| Load more in-flight | Loader2 animate-spin above Load more button; button disabled |
| Detail page loading | Skeleton blocks: identity block (4 Skeleton lines) + 3 section skeletons |
| Error on initial load | toast.error("Failed to load analyses") + inline retry affordance (see Copywriting) |
| Error on load more | toast.error("Failed to load more analyses") + Load more button label → "Retry" |
Accessibility
- Feed rows: each
<Link>has implicitrole="link"; title is the accessible name - AnalyzerStagePips: visually-hidden
<span className="sr-only">with text description (e.g., "Stages completed: Triage, Analyze") - Sentinel div:
aria-hidden="true" - Load more button:
aria-label="Load more analyses" - Detail back button:
aria-label="Back to Analyzer" - Detail external link:
aria-label="Open full analysis on desktop" - Confidence badge:
aria-label="Confidence: High"(etc.) on the<Badge>element - Review pill:
aria-label="Needs human review"on the<Badge>element
Copywriting Contract
| Element | Copy | Source |
|---|---|---|
| Page H1 | "Analyzer" | [D-35] |
| Feed row — null summary fallback | "—" (em dash) | [D-10] |
| Summary null fallback | "Summary not available." | [D-21] |
| Next Step null fallback | "Next step not available." | [D-21] |
| Next Step Rationale null fallback | "Rationale not available." | [D-21] |
| Empty state heading | "No analyses yet" | [D-31] |
| Empty state body | "Completed AI ticket analyses will appear here." | [D-31] |
| Empty state CTA link label | "Open desktop Analyzer" | [D-31] |
| Empty state CTA destination | /analyzer/tickets |
[D-31] |
| Initial load state | 5 skeleton rows (no text) | [D-28] |
| Load more button (idle) | "Load more" | [D-09] |
| Load more button (loading) | "Loading…" (Loader2 spinner, button disabled) | [D-29] |
| Load more button (error/retry) | "Retry" | [D-30] |
| Error toast — initial load | "Failed to load analyses" | [D-30] |
| Error toast — load more | "Failed to load more analyses" | [D-30] |
| Detail breadcrumb | "Analyzer / #{ticketNumber}" | [D-19] |
| Detail back button | "Analyzer" (with ArrowLeft icon) | [D-19] |
| Detail external link | "Open full analysis on desktop" (aria-label) | [D-22] |
| Detail footer link visible label | "View full analysis" (with ExternalLink icon) | [D-22] |
| Detail section heading — Summary | "Summary" | [D-21] |
| Detail section heading — Next Step | "Next Step" | [D-21] |
| Detail section heading — Rationale | "Next Step Rationale" | [D-21] |
| Review pill label | "Review" | [D-17] |
| Confidence badge labels | "High" / "Medium" / "Low" | [D-15] |
| Stage pip sr-only | "Stages completed: {list}" | default — accessibility |
Destructive actions: None. Feed and detail are fully read-only. No confirmation dialogs, no destructive buttons. [D-23, ANL-05]
Component Files to Create
Following the Phase 3/4/5 pattern (kebab-case files, components/mobile/ directory):
| File | Purpose |
|---|---|
components/mobile/AnalyzerFeedRow.tsx |
Feed row Card: header/title/summary/footer. Receives AnalyzerFeedRow type from route. |
components/mobile/AnalyzerStagePips.tsx |
Three dots + caret separators, driven by haiku_used, sonnet_used, opus_used. Pure presentational. |
components/mobile/ConfidenceBadge.tsx |
shadcn Badge with bucket color logic. Renders nothing when score is null. |
components/mobile/AnalyzerRowSkeleton.tsx |
Skeleton placeholder matching analyzer row shape (no priority stripe). |
app/mobile/analyzer/page.tsx |
Replace placeholder — 'use client', feed list + IntersectionObserver + Load more. |
app/mobile/analyzer/[id]/page.tsx |
New detail page — 'use client', MobileDetailHeader, identity block, 3 sections, footer link. |
app/api/mobile/analyzer/feed/route.ts |
GET handler — requireAuth, cursor-paginated query, exports AnalyzerFeedRow and AnalyzerFeedResponse types. |
MobileDetailHeader extraction (back chevron + breadcrumb + external link row): extract as components/mobile/MobileDetailHeader.tsx if the diff from Phase 4's detail header is trivial (same three-slot layout); mirror inline otherwise. Decision deferred to executor per CONTEXT.md Claude's Discretion.
Component comment block convention (Phase 3/4/5 pattern):
/* ComponentName — phase 06 (ANL-NN).
* Purpose: one-line description.
* Props: ... */
API Shape Contract
The route file exports TypeScript interfaces for the page to import type. Mirrors Phase 4 pattern. [D-26, D-27]
// app/api/mobile/analyzer/feed/route.ts — exported interfaces
export interface AnalyzerFeedRow {
id: string; // analyzer_analyses UUID
ticketNumber: string;
title: string;
companyName: string;
summary: string | null;
confidenceScore: number | null;
haikuUsed: boolean;
sonnetUsed: boolean;
opusUsed: boolean;
needsHumanReview: boolean;
completedAt: string; // ISO string
analysisVersion: number;
}
export interface AnalyzerFeedResponse {
analyses: AnalyzerFeedRow[];
nextCursor: string | null;
hasMore: boolean;
}
Detail page imports PersistedAnalysis from lib/types/analyzer.ts directly via the existing GET /api/analyzer/analyses/[id] endpoint — no parallel type. [D-27]
Registry Safety
| Registry | Blocks Used | Safety Gate |
|---|---|---|
| shadcn official | Card, CardContent, Badge, Skeleton, Button, Separator |
not required |
No third-party registries. All components are either shadcn official primitives or purpose-built in components/mobile/. [D-36, D-37, D-38]
What Stays Unchanged
Per D-36, D-37, D-38:
- Desktop analyzer routes (
/analyzer/*,/api/analyzer/*) — untouched lib/services/analyzer/**pipeline, persistence, worker — untouchedgetMobileCompanyFilter()scoping helper inapp/api/mobile/tickets/route.ts— duplicate inline in the new feed route; do not abstract yet (D-04)relTime()helper — duplicate inline; extract shared only when third caller appears (D-04 note)app/mobile/layout.tsx(Phase 2 shell) — untouched; analyzer pages dock inside it- BottomNav active-tab detection (
pathname.startsWith('/mobile/analyzer')) — already correct;/mobile/analyzer/[id]highlights Analyzer tab automatically
Checker Sign-Off
- Dimension 1 Copywriting: PASS
- Dimension 2 Visuals: PASS
- Dimension 3 Color: PASS
- Dimension 4 Typography: PASS
- Dimension 5 Spacing: PASS
- Dimension 6 Registry Safety: PASS
Approval: pending
Phase: 06-analyzer-feed-new UI-SPEC created: 2026-05-04 Source decisions: 06-CONTEXT.md D-01 through D-39 (all locked) Typography/spacing/color mirrors: 04-UI-SPEC.md (Phase 4 approved contract)