docs(04): UI design contract for tickets-restyle phase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lorentz 2026-05-03 17:40:16 -04:00
parent 83f326c3d1
commit 16bc4aa5c9

View file

@ -0,0 +1,358 @@
---
phase: 4
slug: tickets-restyle
status: draft
shadcn_initialized: true
preset: new-york / neutral base / CSS variables
created: 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`) |
| md | 16px | Horizontal page padding (`px-4`), filter panel `px-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.
| 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-medium` (500) | `leading-normal` | IBM Plex Sans | "Tickets / #T20250001" in detail header |
| Filter label | `text-xs` (12px) | `font-medium` (500) | `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-accent`) |
| 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
### 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-medium border transition-colors` — active: `bg-primary text-primary-foreground border-primary`; inactive: `border-border hover:bg-accent`
"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-3.5 hover:bg-accent transition-colors active:bg-accent`
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-medium hover:bg-accent 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-medium` (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
---
## 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 | "Refresh" (icon button) | 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):
```typescript
/* 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.
```typescript
// 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*
*Source decisions: 04-CONTEXT.md D-01 through D-21 (all locked)*