---
phase: 14
slug: pax8-ui-surface
status: approved
reviewed_at: 2026-07-11
shadcn_initialized: true
preset: "style=new-york, baseColor=neutral, cssVariables=true, iconLibrary=lucide, rsc=true"
created: 2026-07-11
---

# Phase 14 — UI Design Contract

> Visual and interaction contract for frontend phases. Generated by gsd-ui-researcher, verified by gsd-ui-checker.

This phase adds a new top-level page (`/pax8`) inside an already-mature,
already-tokenized design system (see `DESIGN.md`, `components.json`). No new
visual language is being introduced — this contract pins down which existing
tokens/components apply and resolves the phase-specific gaps CONTEXT.md left
to discretion (tab labels, column set, copy). Nearly every field below is
pre-populated from `DESIGN.md`, `14-CONTEXT.md` (D-01…D-10), and
`14-RESEARCH.md` — no user questions were required for this phase.

---

## Design System

| Property | Value |
|----------|-------|
| Tool | shadcn (already initialized — `components.json` present) |
| Preset | `style: new-york`, `baseColor: neutral`, `cssVariables: true`, `rsc: true`, `tsx: true`, prefix: none (source: `components.json`) |
| Component library | Radix UI primitives (via shadcn), `components/ui/` |
| Icon library | `lucide-react`, imported per-icon (source: `DESIGN.md` §6) |
| Font | IBM Plex Sans (`--font-plex-sans`, weights 300/400/500/600/700) for UI text; IBM Plex Mono for numerics/IDs/timestamps only (source: `DESIGN.md` §2 Type) |

No shadcn init gate was needed — the project already has a locked preset.
No new `npx shadcn add` components are required for this phase (see
Component Inventory below) so the registry vetting gate does not apply.

---

## Spacing Scale

Declared values (must be multiples of 4) — matches the sitewide scale in
`DESIGN.md` §3, applied as-is to `/pax8`:

| Token | Value | Usage |
|-------|-------|-------|
| xs | 4px | Icon-to-text gaps (`gap-1`), inline badge padding |
| sm | 8px | Compact row spacing (`gap-2`), candidate-card internal gaps |
| md | 16px | Default element spacing (`gap-4`), Card body padding |
| lg | 24px | Page container padding (`container mx-auto px-6 py-6`), section gaps (`space-y-6`) |
| xl | 32px | Layout gaps between major page regions (Companies tab ↔ Needs Review tab content) |
| 2xl | 48px | Not used this phase |
| 3xl | 64px | Not used this phase |

Exceptions: none. `/pax8` follows the standard page shell exactly
(`PageHeader` + `container mx-auto px-6 py-6` + `space-y-6` between the tab
bar, table/card sections) — same shell `device-link-conflicts` uses.

---

## Typography

Matches the sitewide scale documented in `DESIGN.md` §2 (Type) and used
throughout `DetailModal.tsx` / `device-link-conflicts/page.tsx`. This is a
pre-existing, sitewide scale — not introduced for this phase.

**3-weight exception (D-10 — user-approved, not Claude's discretion):** the
UI checker's Dimension 4 gate initially blocked this spec for declaring 3
font weights (400/600/700) against its generic 2-weight default cap. The
tradeoff was presented to the user directly — match `DESIGN.md`'s existing
sitewide type scale (IBM Plex Sans, weights 300–700, already used on every
other Pulse page) vs. trim `/pax8` alone to 2 weights and look visually
inconsistent with the rest of the app. The user explicitly chose to keep the
3-weight sitewide pattern for this phase. See `14-CONTEXT.md` D-10 for the
full record. This exception applies only because it is a recorded,
user-approved decision — it is not a general license to exceed the 2-weight
cap on future phases without the same explicit approval.

| Role | Size | Weight | Line Height |
|------|------|--------|-------------|
| Body | 14px (`text-sm`) | 400 (regular) | 1.5 |
| Label | 12px (`text-xs`, uppercase, `tracking-wider`) | 600 (semibold) | 1.4 |
| Heading | 18px (`text-lg`, Card/section titles) | 600 (semibold) | 1.3 |
| Display | 24px (`text-2xl`, page H1 via `PageHeader`) | 700 (bold) | 1.2 |

Numerics (subscription counts, cost totals, seat counts) render in
`font-mono` (IBM Plex Mono) with `tabular-nums`, matching `DetailModal`'s
existing `.num`/hours-entry convention — never in the sans body font.

---

## Color

Matches `DESIGN.md` §2 (Colors, OKLch) exactly — no new hex values, no new
tokens for this phase.

| Role | Value | Usage |
|------|-------|-------|
| Dominant (60%) | `--background` / `--card` (white light / near-black dark) | Page surface, table/card backgrounds |
| Secondary (30%) | `--secondary` / `--muted` (`oklch(0.97 0 0)` light) | Table header row, DetailModal field-label column (`bg-muted/40`), "Details" inline badge row background |
| Accent (10%) | `--primary` (Wulf blue `oklch(0.55 0.16 220)`) | Reserved for: the "Link company" resolve button, active `Tabs` indicator (Companies / Needs Review), input/select focus rings, and hyperlinks (matched-company name link, `ExternalLink` affordances) |
| Destructive | `--destructive` (`oklch(0.577 0.245 27.325)`) | No destructive actions exist in this phase (read-only view + a non-destructive resolve mutation) — token reserved but unused; do not repurpose it for the "Needs Review" flag itself (that's an informational/warning state, not destructive) |

Accent reserved for (explicit list — nothing else may use `--primary`):
1. "Link company" / "Resolve" action buttons in the Needs Review tab
2. Active state of the Companies / Needs Review `Tabs` trigger
3. Focus rings on `Input`/`Select`/`Command` (manual company search)
4. Text links (matched Autotask company name, any external-link affordance)

Warning/attention color for the Needs Review section itself: amber
(`border-amber-200` card border + `text-amber-500` `AlertTriangle` icon),
matching `device-link-conflicts`'s existing convention exactly — this is a
semantic status hue per `DESIGN.md`'s "status hues outside this set" rule,
not the 10% accent.

---

## Visual Hierarchy (Focal Points)

Each tab has exactly one primary visual anchor — everything else on the tab
is secondary weight (muted borders, smaller type, no accent color):

- **Companies tab:** the `DataTable` is the focal point. It occupies the
  full content width immediately below the tab bar with no competing cards
  or panels; row hover and the matched/unmatched `Badge` are the only
  in-table emphasis.
- **Needs Review tab:** the amber-bordered candidate cards
  (`border-amber-200`, one per unresolved `pax8_company_match_review` row)
  are the focal point. The `AlertTriangle` icon + amber border draws the eye
  first, then the per-candidate confidence `Badge` and "Link company"
  button (10% accent color) as the secondary action target within each card.
- **DetailModal drill-down (either tab):** the new subscriptions/cost-
  breakdown table is the focal point of the formatted tab — it renders
  above the fold, before the Identity/System field groups, since it answers
  the manager's primary question ("what am I paying for") ahead of metadata.

---

## Copywriting Contract

| Element | Copy |
|---------|------|
| Primary CTA | **"Link company"** — button on each top-3 candidate row in the Needs Review tab (mirrors `device-link-conflicts`'s per-candidate resolve button); the manual-search fallback path's action button reads **"Link to selected company"** once a search result is chosen |
| Empty state heading (Companies tab, zero synced companies) | "No PAX8 companies synced yet" |
| Empty state body (Companies tab) | "Run the PAX8 sync from `/admin/integrations`, then refresh this page." |
| Empty state heading (Needs Review tab, zero open reviews) | "No companies need review" |
| Empty state body (Needs Review tab, zero open reviews) | "Every synced PAX8 company is matched to an Autotask company." (mirrors `device-link-conflicts`'s "No conflicts" `Alert` copy pattern exactly) |
| Empty state — per-row, zero-candidate case (D-09) | Heading: **"No suggested matches"** · Body: **"Search manually to link this company to its Autotask counterpart."** — rendered inline within that review card, not a separate page/flow |
| Error state | Heading: "Couldn't load PAX8 data" · Body: "Check your connection and try again, or visit `/admin/integrations` if PAX8 sync is disabled." (mirrors the existing `Alert variant="destructive"` / "Failed to load" pattern in `device-link-conflicts`) |
| Destructive confirmation | None in this phase. There is no delete/write-back action — the resolve mutation only repoints `pax8_companies.autotask_company_id` and is correctable by re-resolving. Mirrors `device-link-conflicts`'s precedent of no confirmation dialog on resolve; a success toast (`sonner`, `toast.success`) reading **"Linked to {companyName}"** is sufficient feedback, matching the existing `"Linked to CI {ciId}"` pattern verbatim in shape |
| Resolve failure toast | `toast.error(err.message ?? 'Resolve failed')` — matches `device-link-conflicts` exactly |
| Tab labels | **"Companies"** and **"Needs Review"** (per D-04's own example labels); "Needs Review" carries a count badge showing the open-review total when > 0 (Claude's Discretion item, resolved: yes, show count — consistent with `device-link-conflicts`'s "N unresolved conflicts" line and with the existing count-badge pattern already used on `DetailModal`'s Time/Notes tab triggers) |

---

## Registry Safety

| Registry | Blocks Used | Safety Gate |
|----------|-------------|--------------|
| shadcn official | `Tabs`, `Dialog`/`DetailModal` (extended in place), `Card`, `Badge`, `Select`, `Alert`, `Skeleton`, `Button`, `Separator`, `Input` — all already vendored in `components/ui/`, zero new `npx shadcn add` installs required | not required |

No third-party registries declared or used. Registry vetting gate does not
apply to this phase.

---

## Component Inventory & Implementation Notes

Everything needed is either already vendored (`components/ui/`) or already
built (`components/admin/DataTable.tsx`) — the only build-vs-reuse decision
this phase makes is an **additive extension to `DetailModal.tsx`**, flagged
as Pitfall 1 in `14-RESEARCH.md` and repeated here because it directly
governs whether D-02's "used as-is" framing produces a usable UI:

- **Companies tab** — `components/admin/DataTable.tsx` (D-01), columns:
  company name, matched Autotask company (or an "Unmatched" `Badge`), active
  subscription count, city/country (sortable). Row click opens the
  extended `DetailModal`.
- **Cost-breakdown drill-down** — `components/admin/DetailModal.tsx`,
  **extended additively**, not passed data "as-is":
  1. Add an optional `kind?: 'ticket' | 'company' | 'pax8_company'` prop so
     `detectGroups()` stops guessing from ambiguous field-name sniffing
     (a PAX8 company object has `name`, not `company_name`).
  2. Add a `PAX8_COMPANY_GROUPS` field-group set (Identity: name, status,
     city/state/country; System: id, synced_at, is_deleted) reusing existing
     `FieldType`s (`bool`, `date`, `id` — no new scalar types needed).
  3. Add one new, unconditional formatted-tab section (not a `FieldGroup`)
     that renders the subscriptions/cost-breakdown array as a compact table:
     product name, quantity, billing term, latest billed amount
     (`line_total`, never recomputed from `unit_price × quantity` — Pitfall
     3), styled with the same rounded-border card look as every other
     formatted-tab group, plus a summed total row.
  4. Do not touch `TICKET_GROUPS`/`COMPANY_GROUPS` or their detection
     branches — this must be a pure addition, verified by reading the file
     in full (`components/admin/DetailModal.tsx` lines 41–132, 256–260).
  5. Raw tab: unchanged mechanism, fed the raw company + subscriptions +
     order-items payload.
- **Needs Review tab** — new cards mirroring
  `app/admin/device-link-conflicts/page.tsx`'s existing list-with-resolve
  layout exactly (amber-bordered `Card`, candidate rows with confidence
  `Badge`, per-candidate "Link company" button) plus a manual-search
  fallback using shadcn `Command`/`Popover` over the existing
  `/api/data/companies-list` endpoint (D-05) — new UI, not vendored, but a
  direct structural copy of an existing page.
- **Nav entry** — one new object in `components/navigation/app-navigation.tsx`'s
  `navigationItems` array (both desktop `NavigationMenu` and mobile `Sheet`
  consume the same array).

---

## Checker Sign-Off

- [x] Dimension 1 Copywriting: PASS
- [x] Dimension 2 Visuals: PASS
- [x] Dimension 3 Color: PASS
- [x] Dimension 4 Typography: PASS
- [x] Dimension 5 Spacing: PASS
- [x] Dimension 6 Registry Safety: PASS

**Approval:** approved 2026-07-11
