diff --git a/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md new file mode 100644 index 0000000..d4db6e0 --- /dev/null +++ b/.planning/phases/22-approval-ui-livelink-addressable-campaign-review-and-approve/22-UI-SPEC.md @@ -0,0 +1,383 @@ +--- +phase: 22 +slug: approval-ui-livelink-addressable-campaign-review-and-approve +status: draft +shadcn_initialized: true +preset: "style: new-york, baseColor: neutral, iconLibrary: lucide, rsc: true (existing project-wide components.json — not phase-specific)" +created: 2026-07-16 +--- + +# Phase 22 — UI Design Contract + +> Visual and interaction contract for Approval UI (LiveLink): the ticket-scoped +> campaign review page (`/phishing/tickets/{ticketId}`) and the new campaigns +> list page (`/phishing`). Generated by gsd-ui-researcher, verified by +> gsd-ui-checker. + +All tokens below are the **existing Pulse project design system** (`DESIGN.md`), +pre-populated here, not re-decided for this phase. This phase introduces zero +new tokens — it composes existing primitives + one new shadcn primitive +(`tooltip`, needed for D-05/D-06 disabled-state explanations, not currently in +`components/ui/`). + +--- + +## Design System + +| Property | Value | +|----------|-------| +| Tool | shadcn (already initialized — `components.json` present) | +| Preset | `style: new-york`, `baseColor: neutral`, `cssVariables: true`, no registries beyond official | +| Component library | Radix primitives via shadcn | +| Icon library | `lucide-react` | +| Font | IBM Plex Sans (`--font-plex-sans`, weights 300–700); IBM Plex Mono for numerics/IDs/timestamps/hashes | +| New primitive required | `tooltip` — run `npx shadcn add tooltip` before/during Wave 1. Nothing else needs adding; `checkbox`, `tabs`, `accordion`, `alert`, `alert-dialog`, `card`, `select`, `table` all already exist. | + +--- + +## Spacing Scale + +Declared values (existing Pulse scale, multiples of 4 — see `DESIGN.md` §3): + +| Token | Value | Usage | +|-------|-------|-------| +| xs | 4px | Icon gaps, badge inline padding | +| sm | 8px | Inline row gaps (`gap-2`), compact spacing | +| md | 16px | Form field gaps (`gap-4`), default element spacing | +| lg | 24px | Card body padding, section gap (`space-y-6` between page sections) | +| xl | 32px | Layout gaps (rare on this page) | +| 2xl | 48px | Not used on this phase's pages | +| 3xl | 64px | Not used on this phase's pages | + +**Container/page rule (inherited, D-01):** `container mx-auto px-6 py-6`, sections separated by `space-y-6`, grid layouts use `gap-6`. + +**Exceptions:** +- Copy-to-clipboard icon buttons (URL/hash rows) use the shadcn `Button size="icon"` default (32px / `h-8 w-8`), not a 44px touch target — matches the rest of Pulse's desktop-density console; these are secondary affordances next to already-selectable monospace text, not primary touch targets. +- Timeline rail dots: 8px (`h-2 w-2`), matching `components/ui/status-light.tsx`'s smallest size token — not a new size. + +--- + +## Typography + +| Role | Size | Weight | Line Height | +|------|------|--------|-------------| +| Body | 14px (`text-sm`) | 400 (regular) | 1.5 | +| Label | 12px (`text-xs font-medium`) | 500 (medium) | 1.4 | +| Heading (card title) | 18px (`text-lg font-semibold`) | 600 (semibold) | 1.2 | +| Display (page title) | 24px (`text-2xl font-bold tracking-tight`) | 700 (bold) | 1.2 | + +This phase introduces two new weights of its own: **400 (regular)** for body/evidence text and **600 (semibold)** for card titles/labels — matching `DESIGN.md`'s existing `text-lg font-semibold` card-title convention. The **700 (bold)** Display row is the pre-existing, unchanged `PageHeader` H1 (`components/navigation/page-header.tsx`), reused as-is on both new pages — not a new weight this phase introduces, called out here only because the template requires all four roles documented. + +Monospace (IBM Plex Mono, `font-mono`) is mandatory for: Message-ID, Return-Path, sender/recipient email addresses in header dumps, extracted URLs (D-09), attachment hashes (D-10), and all timestamps — per `DESIGN.md` §2's "mono for numerics/IDs/timestamps" rule. + +--- + +## Color + +| Role | Value | Usage | +|------|-------|-------| +| Dominant (60%) | `--background` / `--card` (white / near-black) | Page surface, card surfaces | +| Secondary (30%) | `--muted` / `--secondary` (near-white gray / dark gray) | Card headers, table header rows, the body-preview `
` block background, disabled-row backgrounds |
+| Accent (10%) | `--primary` (Wulf blue `oklch(0.55 0.16 220)`) | Reserved for the specific elements listed below only |
+| Destructive | `--destructive` | Reserved for the specific elements listed below only |
+
+**Accent (`--primary`) reserved for — nothing else:**
+- The "Approve selected" primary submit button (only primary-filled button on the Action Area card)
+- The "Analyze this ticket" CTA button in the D-07 not-yet-triaged empty state
+- Focus rings on all inputs/buttons/checkboxes (existing global convention, unchanged)
+- The active-tab underline inside the Evidence card's tab strip (mirrors the existing top-nav active-state convention, `DESIGN.md` §4)
+
+**Destructive (`--destructive`) reserved for — nothing else:**
+- The "Mark as false positive" button and its confirmation `AlertDialog` (irreversible per Phase 20's no-un-mark-path decision)
+- The THREAT verdict `StatusBadge`
+- The "rejected" per-recipient blast-radius status badge
+
+**Everything else uses semantic hue badges per `DESIGN.md`'s `bg-{hue}-500/15 text-{hue}-600` recipe — never `--primary` or `--destructive`:**
+
+| Element | Tone | Recipe |
+|---|---|---|
+| SPAM verdict badge | slate | `bg-slate-500/15 text-slate-600` |
+| UNWANTED verdict badge | amber | `bg-amber-500/15 text-amber-600` |
+| THREAT verdict badge | red (destructive) | `bg-destructive/15 text-destructive` |
+| SPF/DKIM/DMARC "pass" | green | `bg-green-500/15 text-green-600` |
+| SPF/DKIM/DMARC "fail" | red | `bg-red-500/15 text-red-600` |
+| SPF/DKIM/DMARC "none"/neutral | slate | `bg-slate-500/15 text-slate-600` |
+| Blast-radius "delivered" | green | `bg-green-500/15 text-green-600` |
+| Blast-radius "held" | amber | `bg-amber-500/15 text-amber-600` |
+| Blast-radius "rejected" | destructive (see above) | `bg-destructive/15 text-destructive` |
+| Blast-radius "unknown" | muted | `bg-muted text-muted-foreground` |
+| `remediation_actions.status = proposed` | slate | `bg-slate-500/15 text-slate-600` |
+| `remediation_actions.status = approved` | blue | `bg-blue-500/15 text-blue-600` |
+| `remediation_actions.status = completed` | green | `bg-green-500/15 text-green-600` |
+| `campaigns.status = false_positive` | slate | `bg-slate-500/15 text-slate-600` |
+| Secondary buttons (Remediate, Reclassify) | outline variant | shadcn `Button variant="outline"` — no fill color |
+
+---
+
+## Copywriting Contract
+
+| Element | Copy |
+|---------|------|
+| Primary CTA (Action Area) | "Approve selected" — disabled until ≥1 checkbox is checked |
+| Secondary CTA (Action Area) | "Remediate approved actions" |
+| Destructive CTA (Action Area) | "Mark as false positive" |
+| Reclassify CTA (Classification card) | "Reclassify" |
+| D-07 empty state (no report yet) heading | "Not yet triaged" |
+| D-07 empty state body | "This ticket hasn't been scanned for phishing indicators yet. Run analysis now to extract evidence and see a classification." |
+| D-07 empty state CTA | "Analyze this ticket" → `POST /api/phishing/tickets/{ticket_id}/analyze`, then refetch |
+| D-08 standalone-report notice (report exists, no campaign yet) | Inline `Alert` (info tone): "Grouping in progress — this report hasn't been linked to a campaign yet. The evidence below is from this report only; classification and remediation will appear once grouping completes." |
+| Error state (load failure) | "Couldn't load this campaign. {error message} — try reloading the page." + "Retry" button |
+| Blast radius unavailable (not configured) | "Blast radius unavailable — Mimecast isn't configured for this environment." |
+| Blast radius unavailable (lookup failed) | "Blast radius lookup failed: {error}. Classification proceeded without it." |
+| URLs tab empty | "No URLs found in this message." |
+| Destructive confirmation — Mark as false positive | "Mark this campaign as a false positive? This cannot be undone — there is no way to reverse it later." Confirm button label: "Mark as false positive" (destructive variant) |
+| Destructive confirmation — Remediate | "Remediate {N} approved action(s): {comma-separated humanized action types}. This executes the simulated remediation effect and cannot be undone." Confirm button label: "Remediate" (destructive variant, since it commits an irreversible state transition even though the underlying provider call is simulated in this milestone) |
+| Disabled-tooltip — no permission | "Requires {approve/remediate} permission" |
+| Disabled-tooltip — already resolved (remediated) | "Already remediated on {date} by {approver}" |
+| Disabled-tooltip — already resolved (false positive) | "Marked as false positive on {date}" |
+| Disabled-tooltip — nothing checked | "Select at least one action to approve" |
+| Disabled-tooltip — nothing approved | "No approved actions to remediate" |
+| Disabled-tooltip — false-positive blocked by remediation | "Cannot mark false positive — this campaign already has approved or completed remediation" |
+| Toast — approve success | "Approved {N} remediation action(s)" |
+| Toast — remediate success | "Remediation completed for {N} action(s)" |
+| Toast — mark-false-positive success | "Campaign marked as false positive" |
+| Toast — any action failure | "{Action} failed: {error message}" |
+| Campaigns list page title | "Phishing Campaigns" |
+| Campaigns list page description | "Automatically detected phishing and spam campaigns awaiting triage." |
+| Campaigns list empty state | Heading: "No campaigns yet" / Body: "Campaigns appear here once the ticket scanner or an on-demand analysis groups a reported message." |
+
+---
+
+## Registry Safety
+
+| Registry | Blocks Used | Safety Gate |
+|----------|-------------|-------------|
+| shadcn official | `tooltip` (new addition this phase), plus existing `checkbox`, `tabs`, `accordion`, `alert`, `alert-dialog`, `card`, `select`, `table`, `button`, `badge`, `separator`, `collapsible` | not required (official registry) |
+| Third-party | none | not applicable — no third-party registries declared, `components.json` has `"registries": {}` |
+
+---
+
+## Page Composition
+
+### Route map
+
+| Route | Purpose | Requirement |
+|---|---|---|
+| `/phishing` | NEW — minimal campaigns list, entry point for the nav (D-00) | additive, not REVIEW-01..06 |
+| `/phishing/tickets/[ticketId]` | Ticket-ID-addressable review page — the LiveLink target | REVIEW-01..06 |
+
+Both use the full chrome shell (D-01): `` + `` + `
`. + +### Nav placement (D-02) + +Add a flat top-level item to `components/navigation/app-navigation.tsx`'s `navigationItems`, immediately after "PAX8" and before "Backup Status" — matching the existing precedent of a single-entity top-level item with no submenu (PAX8 is the most recent example of exactly this shape). Do **not** nest it under an existing dropdown (Analyzer/Admin) — phishing triage is its own operational surface, not a sub-tool of either. + +```ts +{ + title: 'Phishing', + href: '/phishing', + icon: ShieldAlert, // lucide-react — distinct from Analyzer's Sparkles and Admin's Activity + description: 'Phishing/spam campaign triage, evidence, and remediation approval', +} +``` + +Visibility: no role gate — every role has `phishing: ["read"]` at minimum (`lib/permissions.ts`), so the item renders for `user`, `admin`, and `super-admin` alike (`DESIGN.md`'s "hide items the user can't access" principle — nobody is fully excluded from `read`). Approve/remediate/mark-false-positive gating happens inside the page (D-06), not at the nav level. + +### Ticket-scoped review page — layout order + +Single-column stack (`space-y-6`) at ` +
+ [state-dependent body — see "Empty & Error States" below] + + On full-campaign success: + 1. — full width + 2. — full width + 3.
+ — left column, lg+; stacks above Timeline on — right column, lg+ +
+
+``` + +Each section is a shadcn `` with a `` (18px/600 per Typography) carrying a leading `lucide-react` icon (`h-4 w-4 mr-2`, inline per `DESIGN.md` §6). + +### Campaigns list page — layout + +``` + +
+ — components/admin/DataTable.tsx +
+``` + +Use `DataTable` (not the raw shadcn `Table`) since campaign volume is expected to grow past 25 rows over time (`DESIGN.md` §7's threshold) and operators will want to sort/search. Columns: + +| Column | Source | Render | +|---|---|---| +| Campaign | `campaignKey` (fallback: "Campaign {id.slice(0,8)}") | `font-mono text-sm` | +| Status | `status` | `StatusBadge` per the color table above | +| Reports | `reportCount` | `.num` utility, right-aligned | +| First seen | `firstSeenAt` | relative + absolute on hover, `font-mono text-xs` | +| Last seen | `lastSeenAt` | relative + absolute on hover, `font-mono text-xs` | + +Row click navigates to `/phishing/tickets/{firstReportTicketId}`. **Data requirement this UI-SPEC prescribes:** `GET /api/phishing/campaigns` must additionally return `firstReportTicketId` (Autotask ticket ID of the campaign's earliest linked report, via a cheap `LEFT JOIN LATERAL`/subquery against `reports` ordered by `created_at ASC LIMIT 1`) — additive field, does not change any existing consumer's shape. This keeps exactly one review-page implementation (ticket-scoped, per REVIEW-01) rather than building a second campaign-ID-scoped page just for list-row navigation. If planner finds a cleaner path (e.g. reusing `reportsRes` already fetched elsewhere), that's fine — the contract is the *navigation target*, not the implementation. + +If `campaigns` list is empty: render `` in place of the table. + +--- + +## Component Inventory + +| Component | Status | Notes | +|---|---|---| +| `components/ui/tooltip.tsx` | **NEW** — `npx shadcn add tooltip` | Needed for D-05/D-06 disabled-button explanations. Does not exist in the codebase today (confirmed by grep). | +| `components/phishing/classification-card.tsx` | NEW | REVIEW-04 | +| `components/phishing/action-area-card.tsx` | NEW | REVIEW-05, D-03, D-04, D-05, D-06 | +| `components/phishing/evidence-card.tsx` | NEW | REVIEW-03 | +| `components/phishing/timeline-card.tsx` | NEW | REVIEW-02 | +| `components/phishing/url-list.tsx` | NEW | D-09 inert copy-only URL rows, reusable across evidence tab + any future surface | +| `components/admin/DataTable.tsx` | reused, unchanged | campaigns list | +| `components/ui/empty-state.tsx` | reused, unchanged | D-07, campaigns-list-empty | +| `components/ui/status-badge.tsx` | reused, unchanged | every badge in the color table above | +| `components/navigation/page-header.tsx` | reused, unchanged | both pages | +| `components/ui/alert.tsx` | reused | D-08 notice, blast-radius-unavailable notice | +| `components/ui/alert-dialog.tsx` | reused | Remediate + Mark-false-positive confirmations | +| `components/ui/tabs.tsx` | reused | Evidence card sections (Headers / URLs / Attachments / Body preview / Blast Radius) | +| `components/ui/checkbox.tsx` | reused | Action Area recommended-actions list | +| `components/ui/skeleton-helpers.tsx` | reused | loading state (`SkeletonHeader` + 3× `SkeletonCard`) | +| `sonner` (`toast`) | reused | all action feedback, per `DESIGN.md` §8 | + +--- + +## Evidence Display Spec (REVIEW-03) + +`EvidenceCard` renders one message's parsed evidence. If the campaign has more than one linked report/message, an outer `