docs(06): UI design contract

This commit is contained in:
lorentz 2026-05-03 21:04:58 -04:00
parent a44f28d71b
commit 4b2e030ca3

View file

@ -0,0 +1,480 @@
---
phase: 6
slug: analyzer-feed-new
status: draft
shadcn_initialized: true
preset: new-york / neutral base / CSS variables
created: 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 reserved for: active BottomNav tab indicator only (the Analyzer tab `text-primary` state). Not used for row elements, badges, or hover states.
### 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).
```tsx
<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:
```tsx
{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
- `IntersectionObserver` with `rootMargin: '200px'` fires `fetchNextPage()` 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 when `hasMore`, focusable, `aria-label="Load more analyses"`
- Loading more indicator: `Loader2 w-4 h-4 animate-spin text-muted-foreground mx-auto my-2` centered 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 (not `router.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_analyses` UUID (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 implicit `role="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):
```typescript
/* 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]
```typescript
// 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 — untouched
- `getMobileCompanyFilter()` scoping helper in `app/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)*