wulf-pulse/.planning/phases/05-finance-restyle/05-UI-SPEC.md

20 KiB
Raw Blame History

phase slug status shadcn_initialized preset created
5 finance-restyle draft true new-york / neutral base / CSS variables 2026-05-03

Phase 5 — UI Design Contract: Finance Restyle

Visual and interaction contract for the mobile Finance page restyle. Generated by gsd-ui-researcher. Consumed by gsd-ui-checker, gsd-planner, gsd-executor.

All decisions tagged [D-NN] are LOCKED in 05-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))
Minimum viewport target 360px width — no horizontal overflow at this width [D-12]

Spacing Scale

Declared values (multiples of 4). Mirrors Phase 4 UI-SPEC exactly [D-10, D-11].

Token Value Usage in this phase
xs 4px Icon gaps (gap-1), badge padding (px-2 py-1)
sm 8px Row internal gaps (gap-2), section-header gap (gap-2)
sm+ 12px (3 × 4) Between rows in a list (space-y-3), card internal gap (gap-3) [D-11]
md 16px Horizontal page padding (px-4), vertical page padding (py-4) [D-10]
lg 24px Between sections (space-y-6) [D-10]
xl 32px Empty/error-state vertical padding (py-8)
2xl 48px Large empty-state full-screen centering (py-12)

Touch-target exception: Refresh and Sync QBO icon buttons must reach a minimum 44 × 44 px tap target — use p-3 or min-h-[44px] [D-14].

Exceptions: None beyond the 44 px touch target rule.


Typography

Two weights only: font-normal (400) and font-semibold (600). font-medium (500) is NOT used in this phase [D-03].

Role Size class Weight Line Height Font Usage
KPI dollar value text-2xl (24px) (4 summary tiles only) font-semibold (600) leading-tight (1.25) IBM Plex Sans Total AR / Current / Overdue / Paid MTD tile values [D-02, D-04]
Row primary line text-sm (14px) font-semibold (600) leading-snug (1.375) IBM Plex Sans Customer name (left) + amount (right) in invoice/payment/customer rows [D-06, D-07]
Row secondary / metadata text-xs (12px) font-normal (400) leading-normal (1.5) IBM Plex Sans Invoice #, doc number, date, status chip, "Xd overdue" label [D-06]
KPI tile label text-xs (12px) font-semibold (600) leading-none IBM Plex Sans "TOTAL AR", "CURRENT", etc. — uppercase tracking-wider [D-02]
KPI tile caption text-[10px] (10px) font-normal (400) leading-normal IBM Plex Sans YTD annotation on Paid MTD tile, "N invoices" caption [D-02, D-04]
ID / badge text-[10px] (10px) font-normal (400) leading-normal IBM Plex Mono Aging bucket labels, count labels [D-04]
Section header text-sm (14px) font-semibold (600) leading-none IBM Plex Sans Section titles ("Open Invoices", "Top AR by Customer", etc.) [D-13]

Heading note: Page-level "Finance" heading is removed in the restyle — the shell HeaderBar owns the brand mark. The page opens directly with the KPI tile grid. The lastSync timestamp renders as text-[10px] text-muted-foreground inline near the Refresh/Sync controls, not as an H1 sub-caption.


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 hues (aging tones) per the recipe in DESIGN.md §2.

Role Token / Class Usage
Dominant surface (60%) bg-background Page background, card background, row background
Secondary surface (30%) bg-muted / bg-muted/50 Section count badge, row hover (hover:bg-muted/50), Sync progress banner background
Primary accent (10%) text-primary / bg-primary KPI proportion bar fill, active tab underline (border-primary text-primary), Sync QBO button (bg-primary text-primary-foreground)
Muted text text-muted-foreground All secondary row fields, section headers, last-sync timestamp, aging labels
Border border-border Row dividers (divide-y), Card borders, Collapsible trigger borders
Destructive text-destructive / border-l-destructive Error card, KpiCardMobile tone="attention" left border (Overdue tile)

Accent reserved for: active tab indicator underline, Sync QBO primary button, KpiCardMobile proportion bar fill. NOT used for row hover, icon decoration, or section headers.

Aging Bucket Status Colors [D-08] — LOCKED

Bucket Tone Classes
130 days Caution text-amber-600 bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800
3160 days Warning text-orange-600 bg-orange-50 dark:bg-orange-950/30 border-orange-200 dark:border-orange-800
60+ days Critical text-destructive bg-destructive/10 border-destructive/30

These use the DESIGN.md §2 status-hue recipe (bg-{hue}-500/15 text-{hue}-600) for acceptable contrast in both modes. 60+ days uses the --destructive CSS variable token, not a raw red palette value.

Invoice Status Colors [D-06]

Status Color
Overdue amount text-destructive
Overdue "Xd overdue" label text-destructive
Paid / collected amount text-emerald-600 (payment rows only)
Current balance inherit (text-foreground)

Component Inventory

Primary Visual Anchor

The primary focal point per section is the customer name + amount pair on line 1 of each stacked row. The text-sm font-semibold weight on both the name (left) and currency amount (right) creates an immediate scan path — readers scan the amount column first, then the customer name. Section headers act as waypoints between data groups.

KPI Summary Tiles — 2×2 Grid [D-01, D-02]

Layout: grid grid-cols-2 gap-3 px-4 pt-4

Reuse KpiCardMobile from components/mobile/KpiCardMobile.tsx (Phase 3). No new card component needed.

Tile label value caption tone
Total AR "TOTAL AR" fmt$(summary.total_ar) "{N} open invoices" "default"
Current "CURRENT" fmt$(summary.current_balance) "{N} invoices" "default"
Overdue "OVERDUE" fmt$(summary.overdue_balance) "{N} invoices" "attention" (destructive left border)
Paid MTD "PAID MTD" fmt$(summary.paid_mtd) "YTD: {fmt$(paid_ytd)}" "default"

Note: KpiCardMobile renders value via .toLocaleString() when numeric. Pass pre-formatted currency string (output of fmt$()) for dollar tiles — the component accepts string for value.

Aging Buckets — 3-Cell Row [D-08]

Layout: grid grid-cols-3 gap-2 inside a px-4 container. No horizontal scroll.

Each cell is a <Card> (or <div className="rounded-xl border p-3">) with:

[balance: text-sm font-semibold]
[label: text-[10px] text-muted-foreground]
[count: text-[10px] text-muted-foreground font-mono]

Visible only when summary.overdue_balance > 0.

Section header above: text-sm font-semibold + optional text-xs text-muted-foreground count badge.

Invoice / Payment List Rows — Stacked Card Rows [D-06]

Container: divide-y block inside CollapsibleContent. Max height max-h-80 overflow-y-auto for invoices; max-h-64 overflow-y-auto for payments (preserves existing scroll behavior).

Row structure (2-line layout):

[px-4 py-3 flex items-start justify-between gap-2]
  [left: flex-1 min-w-0]
    Line 1: customer name — text-sm font-semibold truncate
    Line 2: invoice# · due date · status chip · "Xd overdue" — text-xs text-muted-foreground
  [right: shrink-0 text-right]
    Line 1: balance amount — text-sm font-semibold (text-destructive if overdue)
    Line 2: txn date (payments only) — text-xs text-muted-foreground

No priority stripe (Finance has no priority taxonomy). Row hover: hover:bg-muted/50 transition-colors.

Top Customers List [D-07]

Same 2-line row structure as invoice rows but without the right-side date:

[px-4 py-3 flex items-center gap-3]
  [left: flex-1 min-w-0]
    Line 1: customer name — text-sm font-semibold truncate
    Line 2: "{N} invoice(s)" — text-xs text-muted-foreground
  [right: shrink-0 text-right]
    Line 1: balance — text-sm font-semibold
    [proportion bar: w-16 h-1 rounded-full bg-muted overflow-hidden]
      [fill: h-full bg-primary/70 rounded-full, width = % of total_ar]

Monthly Revenue — Stacked List [D-09]

No chart. Renders as a divide-y list inside a Card (or bordered div).

Each row:

[px-4 py-3 flex items-center justify-between]
  [left: month label — text-sm font-semibold]
  [right: revenue — text-sm font-semibold + count — text-xs text-muted-foreground]

Section header: "Revenue — last 12 months" in text-sm font-semibold.

Visible only when monthly_revenue.length > 0.

Collapsible Section Triggers [D-16]

Use shadcn Collapsible + CollapsibleTrigger + CollapsibleContent. Replace existing bare <button onClick> pattern.

Trigger bar: w-full flex items-center justify-between px-4 py-4 hover:bg-muted/50 transition-colors

Left slot: icon (h-4 w-4 text-muted-foreground) + section label (text-sm font-semibold) + count badge (text-xs bg-muted rounded-full px-2 py-1 font-mono)

Right slot: ChevronDown (open) / ChevronRight (closed), h-4 w-4 text-muted-foreground

Invoice Tab Toggle [D-15]

Replace existing border-b-2 tab buttons with a shadcn-styled segmented chip pair.

Two <button> elements in a flex row, separated by border-b:

Active chip: flex-1 text-xs py-2 font-semibold border-b-2 border-primary text-primary transition-colors Inactive chip: flex-1 text-xs py-2 font-normal border-b-2 border-transparent text-muted-foreground transition-colors

Labels: "Overdue ({N})" / "Current ({N})"

Page Header Controls [D-14]

Replace existing control row with icon-button + labeled action pattern:

Refresh button: <button aria-label="Refresh finance data">p-3 rounded-full hover:bg-muted/50 transition-colors disabled:opacity-40

  • Icon: RefreshCw h-4 w-4 (adds animate-spin class when loading)

Sync QBO button: <button aria-label="Sync from QuickBooks">flex items-center gap-2 px-3 py-2 rounded-xl bg-primary text-primary-foreground text-xs font-semibold disabled:opacity-50 hover:bg-primary/90 transition-colors

  • Icon: CloudDownload h-3.5 w-3.5 (adds animate-pulse when syncing)
  • Label: "Sync QBO" (idle) / "Syncing…" (in-flight)

Last sync timestamp: text-[10px] text-muted-foreground inline below the page H1 (if present) or as a caption line next to the Refresh button. Not a tooltip — render inline.

Sync Progress Banner [D-17]

When syncMsg is non-null, render a banner in bg-muted text-xs text-muted-foreground rounded-xl px-3 py-2:

[flex items-center gap-2]
  [RefreshCw h-3 w-3 animate-spin shrink-0]
  [{syncMsg text}]

Skeleton Loading State [D-17]

Initial load renders:

  1. 4 KPI tile skeletons: grid grid-cols-2 gap-3 px-4 pt-4 — each a Skeleton h-20 rounded-xl
  2. 1 aging row skeleton: grid grid-cols-3 gap-2 px-4 — three Skeleton h-16 rounded-xl
  3. 3 list row skeletons (x2 for both collapsibles): each row is:
    [px-4 py-3 flex justify-between gap-2]
      [Skeleton h-4 w-2/3]
      [Skeleton h-4 w-16]
    

Use Skeleton from components/ui/skeleton.tsx directly.


Interaction Contracts

Collapsible State [D-16]

  • invoicesOpen and paymentsOpen are local useState<boolean>(false) — collapsed by default
  • Use shadcn Collapsible with open={invoicesOpen} onOpenChange={setInvoicesOpen}
  • No URL sync for collapsible state (Finance has no deep-link filter requirement)

Invoice Tab State [D-15]

  • tab state: useState<'open' | 'overdue'>('overdue') — overdue default
  • Tab change is a local state mutation only (no URL sync, no data refetch)
  • Switching tab immediately filters the already-fetched open_invoices array client-side

Sync Flow [D-14]

  • syncAndRefresh() shows the progress banner during the poll loop (existing logic kept intact)
  • On completion: toast.success("QuickBooks sync complete") via sonner
  • On failure: toast.error("Sync failed — check QBO connection") via sonner
  • The progress banner disappears (setSyncMsg(null)) after load + loadLastSync resolve

Empty State [D-19]

  • When summary.total_ar === 0 AND open_invoices.length === 0: render neutral card with "No outstanding AR" copy — no empty tables
  • Other zero-data sections:
    • recent_payments.length === 0: render "—" inside the collapsible body (do not hide the collapsible header)
    • top_customers.length === 0: hide the Top Customers section entirely
    • monthly_revenue.length === 0: hide the Monthly Revenue section entirely

Error State [D-18]

Full-page error (initial load failure): render a destructive-tinted card:

[rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-6 text-center mx-4]
  [text-sm font-semibold text-destructive] "Failed to load finance data"
  [text-xs text-muted-foreground mt-1] "Check your connection and try again."
  [Button variant="outline" size="sm" className="mt-4" onClick={load}] "Retry"

Transient sync failures: toast.error() only — no inline error banner.

Accessibility [D-14]

  • Refresh icon button: aria-label="Refresh finance data" (no visible text label)
  • Sync QBO button: aria-label="Sync from QuickBooks" + visible "Sync QBO" label
  • All collapsible triggers: aria-expanded via Radix CollapsibleTrigger (automatic)
  • Tab buttons: native <button> with aria-pressed={tab === t} or role="tab" pattern
  • Aging bucket cells: read-only, no interactive role needed
  • Row containers: no interactive role (read-only list)

Copywriting Contract

All copy locked from 05-CONTEXT.md D-14 through D-19 and REQUIREMENTS.md FIN-01, FIN-02.

Element Copy Source
Refresh button aria-label "Refresh finance data" D-14
Sync QBO button label (idle) "Sync QBO" D-14
Sync QBO button label (in-flight) "Syncing…" Existing (keep)
Sync progress — starting "Starting sync…" Existing (keep)
Sync progress — in progress "Syncing with QuickBooks…" Existing (keep)
Sync progress — 409 conflict "Sync already in progress — refreshing data…" Existing (keep)
Sync progress — refreshing "Refreshing data…" Existing (keep)
Sync success toast "QuickBooks sync complete" D-18 convention
Sync failure toast "Sync failed — check QBO connection" D-18
Initial load failure heading "Failed to load finance data" D-18
Initial load failure body "Check your connection and try again." D-18
Load error retry button "Retry" D-18
Empty state — no AR "No outstanding AR" D-19
Empty payments (in collapsible) "—" (em-dash, no additional copy) D-19
Section header — invoices "Open Invoices" Existing (keep)
Section header — payments "Recent Payments" Existing (keep)
Section header — customers "Top AR by Customer" Existing (keep)
Section header — aging "Overdue Aging" Existing (keep)
Section header — revenue "Revenue — last 12 months" Existing (keep)
Invoice tab — overdue "Overdue ({N})" Existing (keep)
Invoice tab — current "Current ({N})" Existing (keep)
KPI tile label — total AR "TOTAL AR" D-02
KPI tile label — current "CURRENT" D-02
KPI tile label — overdue "OVERDUE" D-02
KPI tile label — paid MTD "PAID MTD" D-02
Paid MTD caption "YTD: {fmt$(paid_ytd)}" D-02

Destructive actions: None in this phase. Finance page is entirely read-only; Sync QBO is a non-destructive sync trigger with no confirmation dialog required.


Component Files

Following the Phase 3/4 pattern (components in components/mobile/). Finance restyle is primarily a page-body rewrite with no new stand-alone components unless the FinanceRow pattern warrants extraction.

File Purpose Action
app/mobile/finance/page.tsx Main finance page — complete restyle Rewrite in place
components/mobile/KpiCardMobile.tsx 2×2 KPI tile — reused directly No changes [D-01]
components/mobile/FinanceRow.tsx (optional) Shared 2-line row for invoices, payments, customers Create if DRY saves ≥2 duplicated row implementations

If FinanceRow is extracted it is an internal helper (components/mobile/FinanceRow.tsx), not a public export. No other public mobile components are added.

Component comment block convention (Phase 3 pattern):

/* ComponentName — phase 05 (FIN-NN).
 * Purpose: one-line description.
 * Props: ... */

API Shape Contract

API route (app/api/mobile/finance/route.ts) and the FinanceData interface in app/mobile/finance/page.tsx are unchanged [D-20, D-21].

The page continues to import type the response shape from the inline FinanceData interface already defined in page.tsx. No new exported interfaces needed.

Helper functions in the page file (fmt$(), fmtDate()) are preserved unchanged [D-05].


Registry Safety

Registry Blocks Used Safety Gate
shadcn official Card, CardContent, Collapsible, CollapsibleTrigger, CollapsibleContent, Skeleton, Button not required

No third-party registries. All components are either shadcn official primitives already installed in components/ui/, existing Phase 3 mobile components in components/mobile/, or purpose-built helpers. Collapsible is confirmed installed at components/ui/collapsible.tsx.


What Stays Unchanged

Per D-20 through D-23 and phase boundary:

  • app/api/mobile/finance/route.ts — not touched
  • FinanceData interface (inline in page.tsx) — not touched
  • fmt$() and fmtDate() helpers — preserved
  • syncAndRefresh() poll logic — preserved (reskin controls only)
  • loadLastSync() logic — preserved
  • invoicesOpen / paymentsOpen / tab state shape — preserved (just wired to shadcn Collapsible)
  • app/mobile/layout.tsx — not touched (HeaderBar + BottomNav provided by Phase 2) [D-23]
  • BottomNav active-tab detection (pathname.startsWith('/mobile/finance')) — already correct, no change

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: 05-finance-restyle UI-SPEC created: 2026-05-03 UI-SPEC revised: 2026-05-03 (checker blocking fixes — typography 5→4 sizes, spacing non-multiples-of-4, iteration 2 — badge px-1.5/py-0.5 → px-2/py-1, gap-1.5 → gap-2) Source decisions: 05-CONTEXT.md D-01 through D-23 (all locked) Typography/spacing mirrors: 04-UI-SPEC.md (Phase 4 Tickets Restyle) KPI tile pattern mirrors: 03-01-SUMMARY.md (KpiCardMobile, Phase 3)