wulf-pulse/.planning/phases/04-tickets-restyle/04-UI-SPEC.md
lorentz 9658640c04 fix(04-01): restore phase 2/3 work lost by worktree soft-reset
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
2026-05-03 18:01:14 -04:00

17 KiB
Raw Blame History

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) with Search icon left (pl-9), X clear 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") + SlidersHorizontal or ChevronDown/ChevronUp icon; shows active filter count badge when ≥1 filter active
  • Uses Collapsible from components/ui/collapsible.tsx with open state 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
  • 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, triggers same fetch path
  • Loading more indicator: small Loader2 icon (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() (not router.push())
  • On mount: hydrate filter state from useSearchParams(); wrap in Suspense boundary (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's t.status != 5 filter; 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> with aria-pressed
  • Collapsible toggle: aria-expanded on trigger, id/aria-controls pairing
  • 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 (RefreshCw h-4 w-4) with aria-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 — preserved
  • relTime() helper — preserved inline
  • kiosk_settings data 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)