---
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 text-muted-foreground`) | 400 (regular) | 1.4 |
| Heading (card title) | 18px (`text-lg font-bold`) | 700 (bold) | 1.2 |
| Display (page title) | 24px (`text-2xl font-bold tracking-tight`) | 700 (bold) | 1.2 |

**Exactly two font weights are used on this phase's pages: 400 (regular) and
700 (bold).** Label is differentiated from Body by size (12px vs 14px) and
color (`text-muted-foreground`) — not by weight — so both share 400. Heading
(card title) is differentiated from Display (page title) by size (18px vs
24px) alone — both use 700, matching the pre-existing, unchanged `PageHeader`
H1 (`components/navigation/page-header.tsx`) reused as-is on both new pages.
This intentionally deviates from `DESIGN.md`'s incidental `text-lg
font-semibold` (600) card-title example elsewhere in the app: shadcn's
`CardTitle` primitive defaults to `font-semibold` (`components/ui/card.tsx`
line 35), so every `<CardTitle>` on this phase's pages must pass an explicit
`className="font-bold"` override (Tailwind/`cn()` merge lets the later class
win) to hit the declared 700 weight. Do this only inside
`components/phishing/*` — do not change the shared `CardTitle` primitive's
default, which other pages still rely on.

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. Monospace is a font-family distinction, not a weight — it does not count against the 2-weight budget above.

---

## 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 `<pre>` 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
- The "Classify this campaign" CTA button in the grouped-but-not-yet-classified empty state (see Empty & Error States)
- 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 ticket" |
| 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." |
| Not-yet-classified empty state (grouped campaign, zero `classifications` rows) heading | "Not yet classified" |
| Not-yet-classified empty state body | "This campaign is grouped but hasn't been classified yet. Run classification to generate a verdict and recommended remediation actions." |
| Not-yet-classified empty state CTA | "Classify this campaign" → `POST /api/phishing/campaigns/{id}/classify`, then refetch (gated on `phishing:analyze`; hidden when absent, matching the Reclassify button) |
| 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): `<AppNavigation />` + `<PageHeader />` + `<main className="container mx-auto px-6 py-6">`.

### 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 `<md`; a 2-column grid for the bottom two read-only panels at `lg+`. Order (top to bottom) is fixed and intentional: classification (the "so what") and the action area (the actual job) come before the supporting evidence/timeline detail — an operator arriving via LiveLink should see the verdict and be able to act without scrolling.

```
<PageHeader
  title="Ticket #{ticketNumber}"
  description="{campaign.status === 'open' ? 'Awaiting triage' : humanized campaign.status}"
  breadcrumbs={['Phishing', 'Ticket #{ticketNumber}']}
/>
<main>
  [state-dependent body — see "Empty & Error States" below]

  On full-campaign success:
    1. <ClassificationCard />           — full width
    2. <ActionAreaCard />               — full width
    3. <div className="grid gap-6 lg:grid-cols-2">
         <EvidenceCard />               — left column, lg+; stacks above Timeline on <lg
         <TimelineCard />               — right column, lg+
       </div>
</main>
```

Each section is a shadcn `<Card>` with a `<CardHeader><CardTitle className="font-bold">` (18px/700 per Typography — the explicit `font-bold` override is required since `CardTitle` defaults to `font-semibold`) carrying a leading `lucide-react` icon (`h-4 w-4 mr-2`, inline per `DESIGN.md` §6).

### Campaigns list page — layout

```
<PageHeader title="Phishing Campaigns" description="Automatically detected phishing and spam campaigns awaiting triage." />
<main>
  <DataTable columns={...} data={campaigns} />  — components/admin/DataTable.tsx
</main>
```

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 `<EmptyState icon={ShieldAlert} title="No campaigns yet" description="Campaigns appear here once the ticket scanner or an on-demand analysis groups a reported message." />` 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 `<Select>` ("Report: Ticket #{ticketNumber} — {relative date}") chooses which message's evidence populates the tabs below; default selection = most recently linked report. Tabs (`components/ui/tabs.tsx`):

1. **Headers** — a 2-column definition-list grid (`grid grid-cols-2 gap-x-6 gap-y-2 text-sm`) of From / Display name / Sender domain / Reply-To / Return-Path / To / Cc / Subject / Date / Message-ID / Received chain (collapsed under an `Accordion` item since it can be long) / SPF / DKIM / DMARC. SPF/DKIM/DMARC render as `StatusBadge` (pass=green, fail=red, none=slate, per the color table). Message-ID, Return-Path, and all email addresses render `font-mono text-xs`.
2. **URLs (D-09)** — `<UrlList>`: each extracted URL as `<code className="font-mono text-xs truncate">{url}</code>` with a trailing icon-only Copy button (`lucide-react` `Copy`, `size="icon"` `variant="ghost"`, `aria-label="Copy URL"`) that calls `navigator.clipboard.writeText(url)` + `toast.success('Copied')`. **Never** an `<a>`/`href`, never a click handler that navigates. Empty state: "No URLs found in this message." (size="sm" inline text, not the full `EmptyState` component — this is a tab body, not a page section).
3. **Attachments (D-10)** — shadcn `Table`: filename, content-type (`StatusBadge variantClass="bg-slate-500/15 text-slate-600"`), size (human-readable, e.g. "482 KB"), hash (`font-mono text-xs`, truncated with a `title` attribute showing the full value, same icon-only Copy button affordance as URLs, `aria-label="Copy hash"` — copying a hash carries no click risk, so this one is decorative convenience, not a safety requirement). No content is ever fetched or rendered — filenames/hashes only, per EVID-04.
4. **Body preview** — `<pre className="max-h-96 overflow-y-auto whitespace-pre-wrap rounded-md bg-muted p-4 font-mono text-xs">{bodyPreview}</pre>`. Plain text only — never `dangerouslySetInnerHTML`, never rendered as HTML even if the source email was HTML. This is the sanitized/truncated preview column (`messages.body_preview`), not the raw body.
5. **Blast Radius** — reads `getBlastRadius()` output (planner's call whether fetched fresh per page load or read from the latest classification's persisted `reasons`, per CONTEXT.md discretion). If `status === 'unavailable'`: render the copy from the Copywriting Contract table above (reason-specific), no data table, no CTA — this is informational only. If `status === 'ok'`: a stat row of labeled numbers (Matched / Delivered / Held / Rejected / Clicked, each `.num` utility) above a per-recipient `Table` (recipient email `font-mono text-xs`, status `StatusBadge` per the color table).

All icon-only affordances in this card (Copy buttons above) must carry an
explicit `aria-label` — never rely on the icon alone for accessibility.

---

## Classification Display Spec (REVIEW-04)

`ClassificationCard` — read-only display of the latest `classifications` row (`ORDER BY created_at DESC LIMIT 1`).

- Header row: verdict `StatusBadge` (per color table) + `{confidence}% confidence` (`.num` inline) + right-aligned "Reclassify ticket" button (`variant="outline"`, `size="sm"`), gated: `hasPermission(session.user.role, 'phishing', 'analyze')` — hidden (not disabled) when absent, since re-triggering analysis is a distinct, lower-stakes action than approve/remediate and REVIEW-06's disabled-vs-hidden requirement only names approve/remediate explicitly.
- Summary: `classifications.summary`, `text-sm`.
- Reasons: `<ul className="list-disc pl-5 text-sm text-muted-foreground space-y-1">`, one `<li>` per `reasons` array entry.
- Recommended actions: informational chip row only (`StatusBadge` per action-status color, muted/slate tone since these aren't yet approved) — humanized labels (`block_sender` → "Block sender", `purge_message` → "Purge message", etc.). This card never renders checkboxes or editable params — that interactive surface lives entirely in `ActionAreaCard` below, so there is exactly one place an operator can approve an action, not two.
- If `requires_approval === true`: an inline `Alert` (warning tone, amber): "This classification recommends a destructive action and requires explicit approval before remediation can proceed."
- If no classification exists, this card is omitted entirely. There are two such paths, and the review page (not this card) chooses the replacement:
  1. **Grouped campaign, zero `classifications` rows** — the default/initial state for every newly-detected campaign, because classification is never auto-triggered by the detection/grouping pipeline (webhook, sweep, and on-demand `/analyze` all group but do NOT call `classifyCampaign`; `POST /api/phishing/campaigns/{id}/classify` is the only trigger). The review page renders the "Not yet classified" empty state with a "Classify this campaign" CTA (see Empty & Error States and Copywriting Contract) in place of this card. This is the common case, not an edge case.
  2. **Ungrouped standalone report (D-08)** — replaced by the D-08 `Alert` notice.

---

## Action Area Spec (REVIEW-05, D-03, D-04, D-05, D-06)

`ActionAreaCard` — the only interactive remediation surface on the page.

### Checkbox list (D-03)

One row per string in the latest classification's `recommendedActions` (`string[]`, e.g. `['block_sender', 'purge_message']`). Each row:

```
[ ] Block sender                                    [collapsed/expanded params ▾]
    Sender email:  [__________________]  (pre-filled, editable)
```

- Checkbox label = humanized action type (see mapping table below).
- Params form renders **always visible** under each row (not gated on the checkbox being checked) so the operator can review defaults before deciding what to check — but only checked rows' params are included in the submitted `POST /approve` body.
- No recommended-action default params exist in the backend today (`recommended_actions` is a bare `string[]` — confirmed via `lib/services/campaign-classifier.ts`). This UI-SPEC prescribes the **client-side default-param derivation** the executor must implement, sourced from already-fetched campaign evidence:

| Action type | Default params (derived client-side) | Editable fields |
|---|---|---|
| `no_action` | `{}` | none — render "No parameters — informational verdict, no remediation needed." instead of a form |
| `warn_user` | `{ recipientEmail: <primary report's requesterEmail>, message: '' }` | recipientEmail (Input), message (Textarea, placeholder: default warning copy) |
| `block_sender` | `{ senderEmail: <messages.headers.from>, senderDomain: <derived domain> }` | senderEmail (Input), senderDomain (Input) |
| `purge_message` | `{ messageId: <messages.message_id>, mailboxes: [] }` | messageId (Input, pre-filled), mailboxes (Input, comma-separated, empty by default — helper text: "Enter affected mailboxes, comma-separated") |
| `reset_password` | `{ userPrincipalName: <primary report's requesterEmail> }` | userPrincipalName (Input) |
| `isolate_endpoint` | `{ deviceId: '' }` | deviceId (Input, empty — helper text: "No device identifier available from evidence — enter manually") |
| `disable_forwarding_rule` | `{ userPrincipalName: <primary report's requesterEmail>, ruleName: '' }` | userPrincipalName (Input, pre-filled), ruleName (Input, empty) |

Submission produces exactly `ApproveActionInput[]` (`{ actionType, params }`) matching `lib/services/remediation-service.ts` — one entry per checked row, `params` = the current (possibly edited) form values for that row.

### Buttons

Three buttons, always rendered (never removed from the DOM, per D-05), each disabled with a `Tooltip` explanation when a blocking condition applies. Evaluate conditions **in this priority order** per button — the first matching reason is the tooltip shown:

**"Approve selected"** (primary variant):
1. No `approve` permission → "Requires approve permission"
2. Campaign resolved (see below) → resolved-state copy
3. Zero checkboxes checked → "Select at least one action to approve"
4. Otherwise enabled → `POST /approve { actions: [...] }` → on success: `toast.success`, refetch campaign detail (D-04, no optimistic mutation)

**"Remediate approved actions"** (outline variant):
1. No `remediate` permission → "Requires remediate permission"
2. Campaign resolved → resolved-state copy
3. No `remediation_actions` row with `status === 'approved'` → "No approved actions to remediate"
4. Otherwise enabled → opens `AlertDialog` confirmation (copy above) → `POST /remediate` (no body) → on success: `toast.success`, refetch

**"Mark as false positive"** (destructive variant):
1. No `approve` permission (mark-false-positive is gated on `approve` per the canonical route) → "Requires approve permission"
2. Campaign resolved → resolved-state copy
3. Any `remediation_actions` row with `status` in `('approved','completed')` → "Cannot mark false positive — this campaign already has approved or completed remediation" (mirrors the API's D-04 409 guard exactly — client-side check must not diverge from server behavior)
4. Otherwise enabled → opens `AlertDialog` with an optional reason `Textarea` → `POST /mark-false-positive { reason? }` → on success: `toast.success`, refetch

**"Resolved" definition (D-05):** `campaigns.status === 'false_positive'` OR any `remediation_actions.status === 'completed'`. Resolved-state tooltip copy:
- Remediated: "Already remediated on {completed_at date} by {approved_by}"
- False positive: "Marked as false positive on {campaigns.updated_at date}"

Permission checks use `hasPermission(session.user.role, 'phishing', 'approve' | 'remediate')` from `lib/permissions.ts`, called client-side via `useSession()` (`lib/auth-client.ts`) — the exact function the server-side routes already use (D-06/REVIEW-06 — no separate or relaxed check).

---

## Timeline Spec (REVIEW-02)

`TimelineCard` — a single chronological (ascending, oldest-first — matches the existing `reports ... ORDER BY created_at ASC` convention in `app/api/phishing/campaigns/[id]/route.ts`) vertical list merging three sources client-side (or server-merged if the extended detail endpoint sorts them — planner's call per CONTEXT.md discretion):

1. **Reports** — one entry per linked report: "Report linked — Ticket #{ticketNumber} ({companyName})", icon `FileText` (muted).
2. **Classifications** — one entry per classification history row: "Classified as {verdict} ({confidence}%)", icon `Sparkles`, tinted per verdict color (color table above).
3. **Audit events** — one entry per `audit_events` row, `event_type` mapped to icon + label:

| `event_type` | Label | Icon | Tint |
|---|---|---|---|
| `remediation_approved` | "{N} action(s) approved by {actor}" | `CheckCircle2` | blue |
| `remediation_completed` | "Remediation completed" | `ShieldCheck` | green |
| `campaign_marked_false_positive` | "Marked as false positive" | `XCircle` | slate |
| (any other/future value) | humanized `event_type` (snake_case → Title Case) fallback | `Circle` | muted |

Visual: left rail with an 8px dot (`h-2 w-2 rounded-full`, tinted per row) connected by a 1px `border-l`, right side: label (`text-sm`) + timestamp (`font-mono text-xs text-muted-foreground`, relative with the absolute value in a `title` attribute) + actor when present (`text-xs text-muted-foreground`). No new "Timeline" primitive needed — build with plain `div`s and Tailwind, this is a one-off layout, not a reusable pattern elsewhere in Pulse yet.

---

## Empty & Error States

| State | Trigger | Rendering |
|---|---|---|
| Loading | initial fetch in flight | `SkeletonHeader` + 3× `SkeletonCard` (`components/ui/skeleton-helpers.tsx`) in place of the page body |
| D-07 — not yet triaged | ticket→campaign resolution finds no `reports` row for this `ticket_id` | Full-width `EmptyState` (`size="md"`) — icon `SearchX`, title/body/CTA per Copywriting Contract. Not a 404 — the page still renders its normal `PageHeader` shell. |
| D-08 — ungrouped report | `reports` row exists, `campaign_id IS NULL` | `Alert` (info tone) with the D-08 copy, followed by the `EvidenceCard` alone (no `ClassificationCard`, `ActionAreaCard`, or `TimelineCard` — there is nothing to show for those yet) |
| Grouped, not yet classified | campaign resolved (`campaign_id` set) but `classifications` array is empty — the DEFAULT state for a freshly-grouped campaign, since classification is never auto-triggered | `EmptyState` (`size="md"`, icon `Sparkles`, title/body/CTA per Copywriting Contract) in place of `ClassificationCard` + `ActionAreaCard`, followed by the read-only `EvidenceCard` + `TimelineCard` (evidence/timeline exist even without a classification). The CTA POSTs `POST /api/phishing/campaigns/{id}/classify` then refetches. Neither `ClassificationCard` nor `ActionAreaCard` is rendered in this state (both require a non-null classification). |
| Load error | fetch throws / non-2xx | `Alert` (destructive tone) with the error copy + "Retry" button that re-runs the fetch |
| Campaigns list empty | `GET /api/phishing/campaigns` returns `items: []` | `EmptyState` in place of `DataTable`, per Campaigns list page section above |

---

## 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
