docs(04): UI design contract for tickets-restyle phase
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
83f326c3d1
commit
16bc4aa5c9
1 changed files with 358 additions and 0 deletions
358
.planning/phases/04-tickets-restyle/04-UI-SPEC.md
Normal file
358
.planning/phases/04-tickets-restyle/04-UI-SPEC.md
Normal 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)*
|
||||
Loading…
Add table
Add a link
Reference in a new issue