docs(08): UI design contract

This commit is contained in:
lorentz 2026-05-07 17:44:42 -04:00
parent f0d06ad06f
commit 725fe731ca

View file

@ -0,0 +1,282 @@
---
phase: 8
slug: engagement-user-profile-new
status: draft
shadcn_initialized: true
preset: "new-york / neutral base / cssVariables: true / Tailwind v4"
created: 2026-05-07
---
# Phase 8 — UI Design Contract
## Engagement User Profile (NEW)
> Visual and interaction contract for `/mobile/engagement/[userId]`.
> Generated by gsd-ui-researcher. Consumed by gsd-ui-checker, gsd-planner, gsd-executor, gsd-ui-auditor.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | shadcn/ui |
| Preset | new-york style, neutral base color, cssVariables: true |
| Component library | Radix UI (via shadcn) |
| Icon library | lucide-react |
| Font | IBM Plex Sans (`--font-sans`), IBM Plex Mono (`--font-mono`) |
Source: `components.json` + `npx shadcn info` (detected, not assumed)
---
## Spacing Scale
Declared values (multiples of 4 only). Reuses Phase 7 established tokens.
| Token | Value | Usage |
|-------|-------|-------|
| xs | 4px | Inline gaps (`gap-1`, `mt-1`), icon-to-text padding |
| sm | 8px | Compact element spacing (`gap-2`, `mt-2`, `space-y-2`) |
| md | 16px | Default card padding (`px-4`, `py-4`), section padding |
| lg | 24px | Section separation (`space-y-6`), card-to-card gap |
| xl | 32px | Major vertical rhythm between page sections |
| 2xl | 48px | Not used in this phase (no full-page top padding) |
| 3xl | 64px | Not used in this phase |
Exceptions:
- Avatar: `h-14 w-14` (56px) for identity header — larger than standard row avatar (`h-8 w-8`) to carry header weight
- Row list items: `py-1.5` (6px) vertical padding inside breakdown card metric rows (matches CONTEXT.md D-16 spec)
- Period chip strip: `min-h-[44px]` touch target (matches `EngagementPeriodChips` existing implementation)
- Sticky chip bar: `-mx-4 px-4` bleed-to-edge pattern (matches Phase 7 component exactly)
Source: CONTEXT.md D-11, D-16; Phase 7 `EngagementSummaryCard.tsx`, `EngagementUserRow.tsx`
---
## Typography
Four sizes, two weights. Matches Phase 7 established token set exactly.
| Role | Size | Weight | Line Height | Usage |
|------|------|--------|-------------|-------|
| Display | 24px (`text-2xl`) | 600 (`font-semibold`) | none (leading-none) | Hero metric numbers in 2×2 grid |
| Body | 14px (`text-sm`) | 400 (`font-normal`) | 1.5 | Activity breakdown metric rows, recent-entry row text |
| Label | 12px (`text-xs`) | 400 (`font-normal`) | 1.5 | Card section sub-labels, metric labels under hero numbers, relative timestamps |
| Heading | 14px (`text-sm`) | 500 (`font-medium`) | 1.5 | Breakdown card subsection headers (`text-sm font-medium text-muted-foreground`) |
Additional fixed sizes (from existing components, do not change):
- Avatar initials: `text-[10px] font-semibold` — inherits from `EngagementUserRow` pattern
- Period chips: `text-[10px] font-semibold` — inherits from `EngagementPeriodChips`
- User display name in identity header: `text-lg font-semibold` (one step above body, carries identity weight)
- Section H1 (page title): `text-xl font-semibold` (matches Phase 7 overview H1 pattern)
Source: CONTEXT.md D-11, D-16; `EngagementSummaryCard.tsx`, `EngagementUserRow.tsx`, `EngagementPeriodChips.tsx`
---
## Color
All values are CSS custom property tokens from `app/globals.css`. Do not use raw Tailwind color utilities (no `bg-blue-500`, no `text-gray-400`). Use semantic tokens only.
| Role | Token | Light Value | Dark Value | Usage |
|------|-------|-------------|------------|-------|
| Dominant (60%) | `bg-background` / `text-foreground` | `oklch(1 0 0)` / `oklch(0.145 0 0)` | inverse | Page background, scrollable content area |
| Secondary (30%) | `bg-card` / `text-card-foreground` + `bg-muted` | `oklch(1 0 0)` / `oklch(0.97 0 0)` | `oklch(0.205 0 0)` / `oklch(0.269 0 0)` | All `Card` containers, skeleton fills, avatar backgrounds |
| Accent (10%) | `bg-primary` / `text-primary` | `oklch(0.55 0.16 220)` logo blue | `oklch(0.62 0.17 220)` | See reserved list below |
| Muted text | `text-muted-foreground` | `oklch(0.556 0 0)` | `oklch(0.708 0 0)` | Metric labels, subsection headers, secondary identity rows |
| Destructive | `text-destructive` / `bg-destructive` | `oklch(0.577 0.245 27.325)` | `oklch(0.704 0.191 22.216)` | Not used in this phase (no destructive actions) |
| Border | `border-border` | `oklch(0.922 0 0)` | `oklch(1 0 0 / 14%)` | Card borders, dividers, row separators |
**Accent (`text-primary` / `bg-primary`) reserved for:**
1. Active period chip background (`bg-primary text-primary-foreground`)
2. Hours-bar fill in identity header or compact display bars (`bg-primary`)
3. `mailto:` email link text (`text-primary`)
4. Billable badge accent (if using `Badge` with `variant="default"`)
Inactive period chips use `bg-muted text-foreground`. Do NOT apply `text-primary` to general body text or section headers.
Source: CONTEXT.md D-08, D-11; `app/globals.css`; `EngagementPeriodChips.tsx`
---
## Component Inventory
All components are either reused from prior phases or are new Phase 8 components built on shadcn primitives.
### Reused from Phase 7 (no modification)
| Component | File | Usage in Phase 8 |
|-----------|------|-----------------|
| `EngagementPeriodChips` | `components/mobile/EngagementPeriodChips.tsx` | Period selector (D7/D30/D90), default D30 |
| `EngagementUserRow``getInitials()` | `components/mobile/EngagementUserRow.tsx` | Initials computation for header avatar |
### Reused shadcn primitives
| Primitive | Import | Usage |
|-----------|--------|-------|
| `Card`, `CardContent` | `@/components/ui/card` | Identity header card, 2×2 metric grid cards, activity breakdown card, recent-items sections |
| `Skeleton` | `@/components/ui/skeleton` | All loading states (avatar, metric cards, breakdown rows, recent lists) |
| `Collapsible`, `CollapsibleContent`, `CollapsibleTrigger` | `@/components/ui/collapsible` | Tap-to-expand recent time entries and recent meetings (D-20) |
| `Badge` | `@/components/ui/badge` | Billable badge on time entry rows |
### New components for Phase 8
| Component | File | Purpose |
|-----------|------|---------|
| `EngagementProfileHeader` | `components/mobile/EngagementProfileHeader.tsx` | Identity card: avatar (photo or initials) + name + job title + department + email mailto link + last active |
| `EngagementProfileMetricGrid` | `components/mobile/EngagementProfileMetricGrid.tsx` | 2×2 grid of 4 hero metrics using Card + `text-2xl font-semibold` pattern |
| `EngagementProfileBreakdown` | `components/mobile/EngagementProfileBreakdown.tsx` | Single card with 3 subsections: Time / Communication / Meetings (D-14..D-17) |
| `EngagementRecentEntries` | `components/mobile/EngagementRecentEntries.tsx` | Collapsible list of up to 10 recent time entries (D-18..D-21) |
| `EngagementRecentMeetings` | `components/mobile/EngagementRecentMeetings.tsx` | Collapsible list of up to 10 recent Teams meetings (D-18..D-21) |
| `EngagementProfileSkeleton` | `components/mobile/EngagementProfileSkeleton.tsx` | Full loading skeleton: header + 4 metric cards + breakdown card + 2 list skeletons (D-23) |
---
## Layout Structure
Single-column, `'use client'`, phone-first. No sidebars, no multi-column layouts at any breakpoint in this phase.
```
app/mobile/engagement/[userId]/page.tsx
├── <main class="px-4 pb-safe"> (scrollable, bottom padding for nav)
│ ├── <h1> User display name — page title
│ ├── EngagementPeriodChips sticky top-0, z-10, edge-to-edge bleed
│ ├── EngagementProfileHeader identity card (avatar + fields)
│ ├── EngagementProfileMetricGrid 2×2 grid, gap-3
│ ├── EngagementProfileBreakdown single card, 3 subsections
│ ├── EngagementRecentEntries collapsible list section
│ └── EngagementRecentMeetings collapsible list section
```
**Sticky chip bar class** (exact, inherited from Phase 7):
`sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4`
**Card spacing** (between sections):
`space-y-4` between identity header, metric grid, breakdown card, and each recent section.
**2×2 metric grid**: `grid grid-cols-2 gap-3`
**Activity breakdown subsections**: separated by `border-t border-border` inside the single card, no extra padding headers — just `text-sm font-medium text-muted-foreground mb-2` label followed by metric rows.
---
## Interaction Contracts
### Period chip selection
- Chips: 7d / 30d / 90d mapping to `D7` / `D30` / `D90`
- Default: `D30`
- On change: re-fetch `/api/engagement/user/[userId]?period={D7|D30|D90}`
- Period change resets metric grid + breakdown card + does NOT reset recent-items count (always 10, per D-19)
- Active chip: `bg-primary text-primary-foreground`; inactive: `bg-muted text-foreground`
### Avatar/photo loading
- `<img src="/api/mobile/engagement/user/[userId]/photo" ...>` with `onError` fallback to initials `<span>`
- Initials rendered in `h-14 w-14 rounded-full bg-muted` with `text-base font-semibold text-foreground`
- Photo rendered as `h-14 w-14 rounded-full object-cover`
- No FOUC: render initials immediately, upgrade to photo on load success
### Loading skeleton
- Shown while initial fetch is in flight
- Skeleton layout mirrors final: header-skeleton → 4 metric-card skeletons (2×2) → breakdown-card-skeleton → 2 list-skeletons
- Use `Skeleton` primitives at matching heights/widths
- Period chips render immediately (not skeleton) — they drive the fetch
### Tap-to-expand (recent items)
- State: `Set<string>` of expanded IDs held in component-local `useState`
- Collapsed default: shows one-line summary row
- Expanded: `CollapsibleContent` reveals detail rows below the summary
- Animation: `transition-all duration-200` on `CollapsibleContent` (shadcn default behavior)
- No URL state, no router push, no scroll jump on expand
### Error states
- 404 from endpoint: render inline "User not found" message with back `Link` to `/mobile/engagement` (no toast)
- 500 / network error: `toast.error(...)` (sonner) + inline retry button on the page body (D-24)
- Photo 404 / 503: silently fall back to initials (no toast, no error message)
### Navigation
- Back gesture / browser back: Next.js App Router default `scrollRestoration: true` returns to overview at previous scroll position (D-04)
- No custom `sessionStorage` workaround unless execution phase confirms it's broken
- No explicit "Back" button required in the page body — the mobile shell header already provides back navigation via the standard iOS/Android gesture + App Router prefetch
---
## Copywriting Contract
| Element | Copy |
|---------|------|
| Page H1 | `{displayName}` (the user's full name — not a generic title) |
| Period chip labels | `7d` / `30d` / `90d` (lowercase, concise) |
| Hero metric labels | "Hours worked" / "Billable hours" / "Days worked" / "Meetings attended" |
| Activity section heading — Time | "Time" |
| Activity section heading — Communication | "Communication" |
| Activity section heading — Meetings | "Meetings" |
| After-hours row | "After-hours · {X}% messages, {Y}% meetings" |
| Utilization row | "Utilization · {Z}%" (billable ÷ hours worked) |
| Zoom row label | "Zoom calls" |
| Recent time entries section header | "Recent time entries" |
| Recent meetings section header | "Recent meetings" |
| Billable badge | "Billable" (shadcn `Badge variant="secondary"`) |
| Empty state — time entries | "No time entries in the last 30 days" (hardcoded copy; period context implicit from chips) |
| Empty state — meetings | "No meetings recorded" |
| Empty state — both empty, user found | Section headers still render; each section shows its inline empty message |
| Error state — 404 | "User not found" (heading) + "This profile is no longer available." (body) + "Back to Engagement" (link) |
| Error state — 500 | Toast: `"Failed to load profile — tap to retry"` + inline `"Retry"` button below skeleton |
| Last active — relative (≤7 days) | "Active {N} hours ago" / "Active {N} days ago" |
| Last active — absolute (>7 days) | "Last active {MMM D, YYYY}" (formatted via `useUserTimezone()`) |
| Email link | `{email}` — the address itself as the link text; `mailto:` href |
| Department row | `{department}` — raw value, no prefix label |
No destructive actions in this phase. No confirmation dialogs.
Source: CONTEXT.md D-06, D-12, D-14, D-15, D-21, D-24; REQUIREMENTS.md ENG-06..08
---
## Date/Time Formatting
All date display goes through `useUserTimezone()` hook from `lib/hooks/use-user-timezone.ts`.
| Value | Format |
|-------|--------|
| `entry_date` (time entry) | `MMM d` if current year; `MMM d, yyyy` if prior year |
| `recentTeamsMeetings.date` | Same as above |
| Last active — relative | Use `date-fns` `formatDistanceToNow(date, { addSuffix: true })` with TZ option |
| Last active — absolute (>7d) | `{ month: 'short', day: 'numeric', year: 'numeric', timeZone: tz }` via `Intl.DateTimeFormat` |
| Threshold for relative vs absolute | ≤7 days = relative; >7 days = absolute |
Source: CONTEXT.md D-06; `lib/hooks/use-user-timezone.ts` (Phase 7.1 pattern)
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | `card`, `skeleton`, `collapsible`, `badge` | not required |
No third-party registries. `registries: {}` confirmed in `components.json`.
---
## Accessibility Notes
- Avatar `<img>` must have `alt="{displayName}"` when photo loads; `aria-hidden="true"` on the initials `<span>` (decorative fallback)
- Period chips use `role="button"` + `aria-pressed={isActive}` (inherits from `EngagementPeriodChips` exactly)
- Collapsible trigger buttons include visible text label (entry date + hours) — no icon-only triggers
- Metric rows in breakdown card: use `<dl>` / `<dt>` / `<dd>` semantics or `flex justify-between` with visible labels — avoid value-only spans
- Email link: `<a href="mailto:{email}" className="text-primary underline-offset-4 hover:underline">`
- Touch targets: all interactive elements (chips, expand triggers, links) meet 44px minimum height
---
## 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