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
17 KiB
| phase | slug | status | shadcn_initialized | preset | created |
|---|---|---|---|---|---|
| 4 | tickets-restyle | draft | true | new-york / neutral base / CSS variables | 2026-05-03 |
Phase 4 — UI Design Contract: Tickets Restyle
Visual and interaction contract for the mobile Tickets list page and detail header reskin. Generated by gsd-ui-researcher. Consumed by gsd-ui-checker, gsd-planner, gsd-executor.
All decisions tagged [D-NN] are LOCKED in 04-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) |
| 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)) |
| In-page sticky zone | Filter strip header row sticks below the HeaderBar at top-0 z-10 |
Spacing Scale
Declared values (multiples of 4). Pulled from existing mobile page patterns and DESIGN.md §3.
| Token | Value | Usage in this phase |
|---|---|---|
| xs | 4px | Icon gaps (gap-1), badge padding (px-1.5 py-0.5) |
| sm | 8px | Row internal gaps (gap-2), filter chip gaps (gap-2) |
| sm+ | 12px (3 × 4) | Secondary stacking, filter strip pb-3 |
| md | 16px | Horizontal page padding (px-4), filter panel px-4, list row vertical padding (py-4) |
| lg | 24px | Vertical section breaks between filter header and list |
| xl | 32px | Empty-state vertical padding (py-8) |
| 2xl | 48px | Empty-state with no-results full-screen centering (py-12) |
Touch-target exception: interactive controls (toggle, select, filter chips, Load more button) must reach a minimum 44 × 44 px tap target — use min-h-[44px] or py-2.5/py-3 padding to satisfy this on compact elements.
Exceptions: 4px-wide priority stripe is a visual mark, not an interactive target — border-l-4 exactly as specified in D-15.
Typography
All sizes are Tailwind defaults read from DESIGN.md §2 and matched to the existing app/mobile/tickets/page.tsx patterns.
Two weights only: font-normal (400) and font-semibold (600). font-medium (500) is not used in this phase.
| Role | Size class | Weight | Line Height | Font | Usage |
|---|---|---|---|---|---|
| Row title | text-sm (14px) |
font-semibold (600) |
leading-snug (1.375) |
IBM Plex Sans | Ticket title in list row — 1-line truncate |
| Body / secondary | text-xs (12px) |
font-normal (400) |
leading-normal (1.5) |
IBM Plex Sans | Company name, age, assignee in row |
| Ticket number / ID | text-[10px] (10px) |
font-mono / font-normal (400) |
leading-normal |
IBM Plex Mono | Ticket # badge in row (font-mono) |
| Detail breadcrumb | text-sm (14px) |
font-semibold (600) |
leading-normal |
IBM Plex Sans | "Tickets / #T20250001" in detail header |
| Filter label | text-xs (12px) |
font-semibold (600) |
leading-none |
IBM Plex Sans | Chip labels (Open, High, etc.) |
| Count / status line | text-xs (12px) |
font-normal (400) |
leading-normal |
IBM Plex Sans | "N open tickets" beneath filter strip |
Heading note: No text-2xl page title is rendered by the list page — the shell HeaderBar owns the brand mark; the list page opens directly with the filter strip. The detail page retains its existing text-base font-bold ticket title (body is out of scope).
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 the recipe in DESIGN.md §2.
| Role | Token / Class | Usage |
|---|---|---|
| Dominant surface (60%) | bg-background |
Page background, filter strip background, row background |
| Secondary surface (30%) | bg-muted / bg-muted/50 |
Ticket number badge, filter chip hover, row hover (hover:bg-muted/50) |
| Primary accent (10%) | text-primary / bg-primary / bg-primary/15 |
Active filter chip selected state (bg-primary text-primary-foreground) |
| Muted text | text-muted-foreground |
Company, age, assignee — all secondary row fields |
| Border | border-border |
Row dividers, filter strip border-b, filter chip borders |
| Destructive | text-destructive |
Error toast, retry button label |
Accent reserved for: active filter chip selected state only. Not used for row hover, icons, or decorative elements.
Priority Stripe Colors [D-15] — LOCKED
| Priority | Border class | Semantic |
|---|---|---|
| 1 — Critical | border-red-500 |
Red |
| 2 — High | border-orange-400 |
Orange |
| 3 — Medium | border-amber-400 |
Amber |
| 4 — Low | border-slate-300 |
Slate |
These are direct Tailwind palette references (not CSS variable tokens) per the DESIGN.md §2 recipe for status hues outside the core token set. The border-l-4 stripe is the exclusive carrier of priority color — the old bg-{color} priority dot is removed [D-17].
Component Inventory
Primary Visual Anchor
The primary focal point on the list page is the ticket title row. The text-sm font-semibold title creates a clear hierarchy against the text-xs font-normal secondary metadata (company, age, queue) beneath it. Every row is visually anchored to this title line — readers land there first, then scan down to context.
Filter Strip [D-01 through D-04] — Collapsible
Container: sticky top-0 bg-background z-10 border-b px-4 pt-4 pb-3 space-y-2
Search row (always visible):
Input(shadcn) withSearchicon left (pl-9),Xclear button right- Placeholder: "Search tickets, company…"
- 400ms debounce [D-03]
Collapsible toggle row (always visible):
- Left: "N open tickets" count (
text-xs text-muted-foreground) - Right: "Filters" button (
Button variant="ghost" size="sm") +SlidersHorizontalorChevronDown/ChevronUpicon; shows active filter count badge when ≥1 filter active - Uses
Collapsiblefromcomponents/ui/collapsible.tsxwithopenstate managed in component [D-01]
Collapsible expanded panel [D-02]:
| Control | Component | Options | URL param |
|---|---|---|---|
| Status | chip group | Open / In Progress / Waiting (multi-select) | status (comma-int) |
| Priority | chip group | Critical / High / Medium / Low (multi-select) | priority (comma-int) |
| Queue | Select (shadcn) |
Sourced from existing /api/mobile/tickets queue list |
queue (int) |
| Assigned to me | Switch or Toggle (shadcn) |
on/off | mine (1/absent) |
Filter chips: shrink-0 px-3 py-1 rounded-full text-xs font-semibold border transition-colors — active: bg-primary text-primary-foreground border-primary; inactive: border-border hover:bg-muted/50
"Clear all" button: text-xs text-muted-foreground underline, appears only when ≥1 filter is active [D-04]
Ticket List Row [D-15 through D-17]
Outer wrapper: <Link> — flex items-start border-l-4 {priority-border-class} px-4 py-4 hover:bg-muted/50 transition-colors active:bg-muted/50
Row interior structure:
[4px priority stripe via border-l-4]
[content area flex-1 min-w-0]
[title row]: ticket title (1-line truncate, text-sm font-semibold) + ChevronRight (right, shrink-0)
[company]: text-xs text-muted-foreground truncate
[metadata row]: ticket# badge (font-mono text-[10px] bg-muted) | queue label (text-[10px] text-muted-foreground) | age (text-[10px] + Clock icon, ml-auto)
Note: assignee initials avatar (D-16) — render as a small circular span with bg-primary/15 text-primary when assigned_to is present. Keep relTime() helper as-is [D-17].
Row dividers: divide-y on the list container — matches existing pattern.
Infinite Scroll Sentinel + Load More [D-12 through D-14]
- 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, triggers same fetch path - Loading more indicator: small
Loader2icon (w-4 h-4 animate-spin text-muted-foreground) centered above the Load more button during in-flight requests
Detail Page In-Page Header [D-18]
Replaces the existing <button onClick={() => router.back()}>Back</button> header.
New header bar: flex items-center justify-between px-4 py-3 border-b
Left slot: <button> with ArrowLeft icon (h-4 w-4) + "Tickets" text, router.back(), text-sm text-muted-foreground hover:text-foreground
Center slot: breadcrumb — "Tickets / #" + ticket_number in text-sm font-semibold (truncate if needed)
Right slot: <a> to desktop ticket URL (/analyzer/ticket/{id} or Autotask direct URL) with ExternalLink icon (h-4 w-4), text-muted-foreground hover:text-foreground, target="_blank" rel="noopener noreferrer", aria-label="Open on desktop"
The existing body below (stats grid, description, timeline) is untouched [D-19].
Skeleton Loading State [D-21]
Initial load (5 placeholder rows): use a purpose-built TicketRowSkeleton — not the generic SkeletonRow from skeleton-helpers.tsx since ticket rows have a specific 4px stripe + metadata layout.
Shape:
[border-l-4 border-muted]
[Skeleton h-4 w-3/4] ← title
[Skeleton h-3 w-1/2 mt-1] ← company
[flex gap-2 mt-2]
[Skeleton h-3 w-12] ← ticket#
[Skeleton h-3 w-16 ml-auto] ← age
Render 5 instances: Array.from({ length: 5 }).map((_, i) => <TicketRowSkeleton key={i} />)
Subsequent page load: Loader2 w-4 h-4 animate-spin text-muted-foreground mx-auto my-2
Error state: toast.error() via sonner + Load more button label changes to "Retry" [D-21]
Interaction Contracts
URL Sync [D-05 through D-07]
- Filter changes call
router.replace()(notrouter.push()) - On mount: hydrate filter state from
useSearchParams(); wrap inSuspenseboundary (Next.js 16 requirement) - Cursor is NOT persisted to URL [D-07]
- URL param keys:
q,status,priority,queue,mine - Default behavior when no URL params: treat as
status=1,8,7(Open + In Progress + Waiting) — matches existing route'st.status != 5filter; the URL-synced status param replaces this hardcoded condition
Cursor Pagination [D-08 through D-11]
- Page size: 25 rows
- Cursor: base64(JSON(
{ last_activity_date: ISO, id: number })) - API returns
{ tickets, nextCursor: string | null, hasMore: boolean } - Client state:
tickets: Ticket[](appended on each page),nextCursor: string | null,hasMore: boolean
Filter State Machine
| Filter event | URL change | List reset |
|---|---|---|
| Search input change (400ms debounce) | q param updated |
Yes — reset to page 1, cursor null |
| Status chip toggled | status param updated |
Yes |
| Priority chip toggled | priority param updated |
Yes |
| Queue selected | queue param updated |
Yes |
| Mine toggle | mine param toggled |
Yes |
| Clear all | All filter params removed | Yes |
| Scroll to bottom | No URL change | No — append only |
Accessibility
- All filter chips:
role="checkbox"(multi-select) or native<button>witharia-pressed - Collapsible toggle:
aria-expandedon trigger,id/aria-controlspairing - Sentinel div:
aria-hidden="true" - Load more button: explicit
aria-label="Load more tickets"for screen readers - Detail back button:
aria-label="Back to Tickets" - Detail external link:
aria-label="Open ticket on desktop" - Priority stripe: decorative only; priority is also conveyed textually in the ticket number badge row context
- Empty state refresh affordance: icon-only button (
RefreshCwh-4 w-4) witharia-label="Refresh ticket list"— no visible text label
Copywriting Contract
All copy locked from D-20, D-21, and REQUIREMENTS.md.
| Element | Copy | Source |
|---|---|---|
| Search placeholder | "Search tickets, company…" | Existing (keep) |
| Filter toggle label (collapsed) | "Filters" | D-02 |
| Filter toggle label with actives | "Filters (N)" | Inferred from D-04 |
| Clear filters button | "Clear all" | D-04 |
| Assigned-to-me toggle | "Assigned to me" | D-02 |
| Ticket count line | "{N} open tickets" | Existing pattern (keep) |
| Empty state — filters active | "No tickets match your filters" | D-20 |
| Empty state CTA — filters active | "Clear filters" (button) | D-20 |
| Empty state — no filters, no tickets | "No tickets to triage right now" | D-20 |
| Empty state refresh affordance | icon-only (RefreshCw) with aria-label="Refresh ticket list" — no visible text |
D-20 |
| Initial load state | 5 skeleton rows (no text) | D-21 |
| Load more button (idle) | "Load more" | D-14 |
| Load more button (loading) | "Loading…" (Loader2 spinner) | D-21 |
| Load more button (error/retry) | "Retry" | D-21 |
| Detail breadcrumb | "Tickets / #{ticket_number}" | D-18 |
| Detail back button | "Tickets" (with ArrowLeft icon) | D-18 |
| Detail external link | icon-only with aria-label "Open on desktop" | D-18 |
| Error toast | "Failed to load tickets" | D-21 (general toast convention) |
Destructive actions: None in this phase. The list and detail are read-only from the manager's perspective.
Component Files to Create
Following the Phase 3 pattern (components named with Mobile suffix, stored in components/mobile/):
| File | Purpose |
|---|---|
components/mobile/TicketRowSkeleton.tsx |
Skeleton placeholder matching ticket row shape |
components/mobile/TicketFilterStrip.tsx |
Collapsible filter strip (search + status/priority/queue/mine) |
The list page (app/mobile/tickets/page.tsx) and API route (app/api/mobile/tickets/route.ts) are modified in-place. The detail page (app/mobile/tickets/[id]/page.tsx) receives only header edits.
Component comment block convention (Phase 3 pattern):
/* ComponentName — phase 04 (TICK-NN).
* Purpose: one-line description.
* Props: ... */
API Shape Contract
The route file exports TypeScript interfaces for the page to import type. Pattern from Phase 3 summary.
// app/api/mobile/tickets/route.ts — exported interfaces
export interface MobileTicket {
id: number;
ticket_number: string;
title: string;
status: number;
priority: number; // 1=Critical, 2=High, 3=Medium, 4=Low
create_date: string;
last_activity_date: string;
due_date_time: string | null;
queue_id: number;
queue_label: string;
company_name: string;
assigned_to: string;
}
export interface MobileTicketListResponse {
tickets: MobileTicket[];
nextCursor: string | null;
hasMore: boolean;
}
The getMobileCompanyFilter() helper is preserved without modification [04-CONTEXT.md code_context].
Registry Safety
| Registry | Blocks Used | Safety Gate |
|---|---|---|
| shadcn official | Collapsible, Button, Input, Select, Switch, Skeleton |
not required |
No third-party registries. All components are either shadcn official primitives or purpose-built in components/mobile/. The Collapsible from components/ui/collapsible.tsx is already installed (confirmed in codebase scan).
What Stays Unchanged
Per D-19 and phase boundary:
- Detail page body (stats grid, description collapsible, timeline, time entries) — untouched
getMobileCompanyFilter()company scoping logic — preservedrelTime()helper — preserved inlinekiosk_settingsdata flow — unaffected- BottomNav active-tab detection (
pathname.startsWith('/mobile/tickets')) — already correct, no change needed
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: 04-tickets-restyle UI-SPEC created: 2026-05-03 UI-SPEC revised: 2026-05-03 (checker blocking fixes — typography weights, spacing, color contradiction, visual anchor, copywriting, spacing scale) Source decisions: 04-CONTEXT.md D-01 through D-21 (all locked)