diff --git a/PR_DESCRIPTION_GOVERNANCE_EMPTY_STATES.md b/PR_DESCRIPTION_GOVERNANCE_EMPTY_STATES.md new file mode 100644 index 0000000..a61fe48 --- /dev/null +++ b/PR_DESCRIPTION_GOVERNANCE_EMPTY_STATES.md @@ -0,0 +1,95 @@ +# PR: Governance Empty-State Illustrations + +## Summary + +Adds governance-specific empty states — **No Proposals**, **No Votes Cast**, and +**No Delegates** — to the Distribution Dashboard, with three original SVG +illustrations, monochrome print variants, CTA wiring, CSS completion for the +`EmptyState` system, docs, and tests. + +## Changes + +### Illustrations (`src/components/designSystem/EmptyState.tsx`) +Redesigned the three governance glyphs from placeholder-level shapes into +distinct original illustrations (token-driven, `aria-hidden`, `isMonochrome`-ready): + +| Variant | Glyph | +|---|---| +| `governance-proposals` | Stacked proposal documents + "create" badge | +| `governance-votes` | Ballot slipping into an empty ballot box | +| `governance-delegates` | Delegate with two delegation chain links | + +### Wiring (`src/pages/DistributionDashboard.tsx`) +Each governance block now renders its `EmptyState` when data is empty, otherwise +the existing mock content renders unchanged: + +| Block | Variant | Primary CTA | +|---|---|---| +| Proposal detail | `governance-proposals` | Create Proposal → `/startup/governance/proposals/create` | +| Results breakdown | `governance-votes` | View Active Proposals | +| Delegation | `governance-delegates` | Delegate Voting Power | + +Demo affordance for review/screenshots: `?govEmpty=proposals|votes|delegates|all`. + +Monochrome print: the dashboard passes `isMonochrome={isPrinting}` via the +existing `usePrintMode` hook; a `@media print` block flattens the container. + +### CSS (`src/index.css`) +Added the previously missing `.empty-state-*` rules (completing the UX152 +system): glass-card container, icon wrap, title/body/context, responsive actions +(stack → row ≥480px), RTL-friendly, and print monochrome handling. Consumes +existing `--ds-state-*` and colour tokens. + +### Assets (`docs/uiux/governance-empty-states/`) +Standalone SVG sources + `512×512` PNG fallbacks for each illustration in colour +and monochrome, plus export guidance (qlmanage rasterization). + +### Repairs to pre-existing breakage +`DistributionDashboard.tsx` did not previously compile (missing ``, +missing `useUploadQueue()` call, missing state, missing imports). These were +restored so the page renders and its tests pass. Also fixed a real a11y +violation surfaced by the axe suite: focusable ``s in +`RevenuePayoutChart.tsx` now carry `role="img"`. + +### Tests +- `EmptyState.test.tsx` — all 9 variants render; governance SVGs decorative; + monochrome palette (`#000000`/`#555555`) and colour-mode assertions. +- `DistributionDashboard.test.tsx` — all three empty states under `?govEmpty=all`, + Create Proposal CTA href, Back to Discovery links, print-mode monochrome SVG, + axe checks with 0 violations (default + empty renders); stale assertions updated. + +### Documentation +- `docs/uiux/governance-empty-state-illustrations.md` — specs, colour strategy, + asset table + export guidance, tokens, responsive, a11y, testing, demo URLs. +- `docs/uiux/ux152-branded-empty-state-system.md` — governance variants added. + +## Screenshots + +Demo URLs to capture before/after: +- Before: `/startup/distributions` (mock data always present) +- After (empty): `/startup/distributions?govEmpty=all` +- Per-state: `?govEmpty=proposals` · `?govEmpty=votes` · `?govEmpty=delegates` + +## Testing + +```bash +npm run lint +npx vitest run src/pages/DistributionDashboard.test.tsx src/components/designSystem/EmptyState.test.tsx +``` + +Full suite and repo-wide lint verified on the feature branch — no new failures +or lint errors introduced; `DistributionDashboard.test.tsx` moves from failing +to passing. + +## Checklist + +- [x] 3 original governance SVG illustrations (proposals/votes/delegates) +- [x] Monochrome print variants (SVG palette + `@media print` CSS) +- [x] Wired into `DistributionDashboard.tsx` with CTAs +- [x] `.empty-state-*` CSS completed (was missing) +- [x] PNG fallbacks exported (512×512) + export guidance +- [x] Accessibility (WCAG 2.1 AA) — axe checks pass with 0 violations +- [x] Tests added/updated +- [x] Documentation written +- [x] `npm run lint` clean on changed files +- [x] Full test suite: no new failures diff --git a/PR_DESCRIPTION_INVESTOR_HERO.md b/PR_DESCRIPTION_INVESTOR_HERO.md new file mode 100644 index 0000000..3d0694a --- /dev/null +++ b/PR_DESCRIPTION_INVESTOR_HERO.md @@ -0,0 +1,85 @@ +# Investor Dashboard Hero — Issue: Investor Portfolio Hero UI/UX + +## Summary + +Redeveloped the hero band of the Investor Portfolio Summary (`/investor/portfolio`) +to a complete, documented, WCAG 2.1 AA–compliant hero: 4 KPI tiles with full state +coverage, a responsive portfolio sparkline, and a contrast-safe primary CTA. +No unrelated dashboard sections or business logic were touched. + +## Changes + +### Hero component — `src/components/DashboardHero.tsx` +- **KPI tile states** now fully documented and accessible: + - **Normal** — value + optional trend / action link + - **Empty** — contextual messages: "No investments yet", "No payouts scheduled", + "No pending actions" (`emptyText` on `KPIData`) + - **Error** — friendly "Couldn't load this data." + icon + optional + **Try again** button (`onRetry`, per-tile overrides hero-level) + - **Loading** — skeleton with `role="status"`, `aria-busy="true"`, labelled +- **Sparkline** is now responsive: visible at all breakpoints (was hidden on + mobile), full-width on mobile, fixed 160px on `sm+`, `role="img"` + descriptive + label, token colours (`--success`/`--error`). +- Stable `data-testid`s on the section, header, KPI grid, and each tile. +- Each value/trend exposes an `aria-label`; all interactive elements keep `focus-ring`. + +### Page — `src/pages/InvestorPortfolioSummary.tsx` +- Passes contextual `emptyText` to every KPI. +- Added `__kpiStatus` injectable prop so the tile states can be driven through + the real page wiring in tests/demos (follows the existing `__` prop convention). + +### Design system — `src/index.css` + `src/pages/DesignTokens/tokens.ts` +- New tokens `--primary-btn-bg: #2563eb`, `--primary-btn-bg-hover: #1d4ed8`. +- `.btn-primary` now uses them: white text contrast rises **3.68:1 → 5.17:1** + (hover 6.70:1), meeting WCAG 2.1 AA. `--primary` is untouched so text links + keep their 4.86:1 contrast. +- Empty-state text bumped to `text-slate-400` (6.96:1). + +### Test infra — `src/test/setup.ts` +- Added a `window.matchMedia` stub (jsdom lacks it) — unblocks `usePrintMode`/ + chart widget tests that previously crashed on mount. + +## Tests + +- `src/components/DashboardHero.test.tsx` — 29 tests +- `src/pages/InvestorPortfolioSummary.test.tsx` — 21 tests (rewritten; the old + file asserted a removed `KpiHeader` and stale copy) + +Coverage: new investor (no positions), negative returns, error state, empty state, +loading state, dark mode rendering, responsive layout classes, and `jest-axe` on +nominal / loading / error / empty / dark states — **all passing**. + +## Accessibility notes + +| Check | Result | +|---|---| +| `jest-axe` | 0 violations across all states | +| Landmark + heading | `section` labelled by the single `h1` | +| Status semantics | `role="status"` on loading/empty/error tiles | +| Keyboard / focus | Native links/buttons, logical order, `focus-ring` visible | +| CTA contrast | White on `#2563eb` = **5.17:1** (AA ≥ 4.5:1) | +| Link contrast | `#3b82f6` on dark = **4.86:1** | +| Empty text contrast | `#94a3b8` on dark = **6.96:1** | +| Reduced motion | `prefers-reduced-motion` pauses pulse/fade | + +## Verification + +- `npm run lint` — no new errors in changed files (baseline repo already has 9 + pre-existing errors in unrelated files) +- Component tests — all hero/page tests pass +- Visual tests — none exist in this repo (no Storybook/Playwright); see + before/after below + +## Before / After + +> Manual screenshots at 360px / 768px / 1280px (light & dark) are attached to +> the PR per the [design doc](docs/uiux/investor-dashboard-hero.md). + +| Aspect | Before | After | +|---|---|---| +| Empty tiles | generic "No data yet" | contextual per-tile messaging | +| Error tiles | static, no action | friendly message + "Try again" | +| Loading tiles | unlabelled skeleton | `role="status"` + `aria-busy` | +| Sparkline | hidden on mobile | responsive, scales 100%→160px | +| Primary CTA | 3.68:1 (AA fail) | 5.17:1 (AA pass) | +| Docs | — | `docs/uiux/investor-dashboard-hero.md` | diff --git a/PR_DESCRIPTION_NETWORK_SWITCHER_RECENTS.md b/PR_DESCRIPTION_NETWORK_SWITCHER_RECENTS.md new file mode 100644 index 0000000..02bffce --- /dev/null +++ b/PR_DESCRIPTION_NETWORK_SWITCHER_RECENTS.md @@ -0,0 +1,33 @@ +# Network Switcher: Recent Networks quick list + +## What & why + +The Network Switcher popover now surfaces a user-scoped **Recent Networks** section pinned above the full list, so frequent switchers reach their last 3 networks in one tap. The section is backed by existing per-user `localStorage` persistence (key `revora-recent-networks:{userId}`), restores on load, and degrades gracefully on corrupted storage. + +A partial implementation shipped in `946ad87`; this PR closes the correctness, accessibility, and testing gaps. + +## Behavior + +- Last **3 unique** networks, most-recent-first; selecting a network moves it to the top, dedupes, and caps at 3. +- Current network may appear in recents (marked `aria-selected`); recents are excluded from "All Networks" so each network appears exactly once. +- No recents → section (and divider) hidden entirely; no networks → lightweight empty state. +- Every selection is recorded; switching logic unchanged. + +## Accessibility (WCAG 2.1 AA) + +- Sections now announced to screen readers: each list is `role="group"` labelled from its visible header via `aria-labelledby` (headers were previously `aria-hidden` with no SR label). +- Popover `role="listbox"` now contains only valid children (option groups); decorative divider and footer `Close` moved out of the listbox role — fixes `aria-required-children`. +- Arrow-key navigation flows across both sections (`↓/↑/Home/End`), `Escape` closes, focus lands on the listbox on open. +- jest-axe: 0 violations in light and dark mode with recents present. + +## Test results + +- `NetworkSwitcher.test.tsx`: 22 tests (was 16, 2 of them broken) — new coverage: no-recents empty state, most-recent-first ordering, dedupe-to-top, cap-at-3, cross-session persistence (mocked storage), corrupted-storage fallback, keyboard nav across sections, group `aria-labelledby`, empty state, axe light+dark. +- `RecentNetworksProvider.test.tsx`: 8 tests, passing. +- Full suite: 37 failing files (all pre-existing; 0 in changed files), 0 new lint errors, no new tsc errors. + +## Files changed + +- `src/components/NetworkSwitcher/NetworkSwitcherPanel.tsx` — recency-ordered recents (was network-array order), valid listbox children, group `aria-labelledby`. +- `src/components/NetworkSwitcher/NetworkSwitcher.test.tsx` — stateful harness + 13 new/restored tests. +- `docs/uiux/network-switcher-recent-networks.md` — design doc. diff --git a/docs/uiux/governance-empty-state-illustrations.md b/docs/uiux/governance-empty-state-illustrations.md new file mode 100644 index 0000000..54804a0 --- /dev/null +++ b/docs/uiux/governance-empty-state-illustrations.md @@ -0,0 +1,200 @@ +# Governance Empty-State Illustrations + +## Scope + +Governance-specific empty states for the Revora Distribution Dashboard: +**No Proposals**, **No Votes Cast**, and **No Delegates** — with original SVG +illustrations, monochrome print variants, CTA wiring, documentation, and tests. + +## Problem Statement + +The governance area of the Distribution Dashboard always rendered mock data. +There were no empty states for the three core governance scenarios, and the +`EmptyState` component already declared `governance-proposals` / +`governance-votes` / `governance-delegates` variants whose glyphs were +placeholder-level and unstyled. + +| Scenario | Previous Treatment | +|---|---| +| No active proposals | Mock proposal always shown | +| No votes cast | Mock results breakdown always shown | +| No delegates | Mock delegation UI always shown | +| `.empty-state-*` classes | Referenced by `EmptyState` but **no CSS rules existed** | + +## Solution + +Three original, token-driven SVG illustrations were added to +[`src/components/designSystem/EmptyState.tsx`](src/components/designSystem/EmptyState.tsx) +and wired into [`src/pages/DistributionDashboard.tsx`](src/pages/DistributionDashboard.tsx) +using the established conditional pattern (see `AuditTrail.tsx`). + +| Variant | Glyph Concept | Use Case | +|---|---|---| +| `governance-proposals` | Stacked proposal documents + "create" badge | No active proposals | +| `governance-votes` | Ballot slipping into an empty ballot box | No votes cast | +| `governance-delegates` | Delegate with two delegation chain links | No delegates | + +### Wiring + +Each governance block renders its `EmptyState` only when the corresponding data +is empty, otherwise the existing mock content renders unchanged. + +| Governance block | Empty variant | Primary CTA | Target | +|---|---|---|---| +| Proposal detail card | `governance-proposals` | Create Proposal | `/startup/governance/proposals/create` | +| Results breakdown | `governance-votes` | View Active Proposals | `/startup/governance/proposals/create` | +| Delegation UI | `governance-delegates` | Delegate Voting Power | `/investor/portal` | + +> **Note:** dedicated proposal-list and delegate-management routes do not exist +> yet; CTAs resolve to the closest available destination. Re-point the `href` +> values when those routes land. + +A demo affordance exposes the empty states for review and screenshotting: + +| URL | Shows | +|---|---| +| `/startup/distributions?govEmpty=proposals` | Proposals empty state | +| `/startup/distributions?govEmpty=votes` | Votes empty state | +| `/startup/distributions?govEmpty=delegates` | Delegates empty state | +| `/startup/distributions?govEmpty=all` | All three | + +When the parameter is absent the page renders exactly as before (mock data). + +### Monochrome print variant + +- The `isMonochrome` prop (existing) switches the SVG palette to grayscale + (`#000000` / `#555555` / `#333333`) and removes the drop shadow. +- The dashboard passes `isMonochrome={isPrinting}` from the existing + [`usePrintMode`](src/hooks/usePrintMode.ts) hook, matching the chart widgets. +- A `@media print` block in `src/index.css` flattens the container and actions + to monochrome for print output. + +## Illustration Colour Strategy + +Same rules as the rest of the `EmptyState` set: + +- **Strokes**: CSS custom properties (`var(--primary)`, `var(--text-muted)`, + `var(--text-accent)`) so illustrations adapt to light/dark mode. +- **Fills**: same tokens with alpha channels for subtlety on both backgrounds. +- **Error severity**: outer badge, well, and accent ring switch to `var(--error)`. +- **ViewBox** is always `0 0 96 96`; default render size `96px` (`size` prop). + +## Asset Files + +Standalone exports (identical geometry to the runtime glyphs) live in +`docs/uiux/governance-empty-states/` for docs, print, and design handoff: + +| File | Variant | +|---|---| +| `governance-proposals.svg` / `.png` | Proposals, colour | +| `governance-votes.svg` / `.png` | Votes, colour | +| `governance-delegates.svg` / `.png` | Delegates, colour | +| `governance-proposals-mono.svg` / `.png` | Proposals, monochrome print | +| `governance-votes-mono.svg` / `.png` | Votes, monochrome print | +| `governance-delegates-mono.svg` / `.png` | Delegates, monochrome print | + +The runtime never loads these files — the SVG glyphs are inlined in +`EmptyState.tsx` so they inherit design tokens and the `isMonochrome` prop. +The exports are the canonical source for print/design review. + +### Contributor export guidance (regenerate PNGs) + +macOS `qlmanage` is used to rasterize the SVGs (no rsvg-convert/inkscape on the +reference machine): + +```bash +cd docs/uiux/governance-empty-states +for f in governance-*.svg; do + qlmanage -t -s 512 -o . "$f" + mv "$f.png" "${f%.svg}.png" +done +``` + +Verify: each PNG is `512×512` (`sips -g pixelWidth -g pixelHeight *.png`) and +each SVG is well-formed XML (`xmllint --noout *.svg`). + +## Design Tokens Consumed + +| Token | Purpose | +|---|---| +| `--ds-state-gap` | Vertical gap between illustration, text, and actions | +| `--ds-state-pad-y` / `--ds-state-pad-x` | Container padding | +| `--ds-state-max-w` | Body text column max-width | +| `--ds-state-icon-size` | Icon well diameter | +| `--text-main` / `--text-muted` / `--text-accent` | Title / body / context colours | +| `--primary` / `--error` | Illustration + CTA colours | +| `--glass-bg` / `--glass-border` / `--glass-blur` | Container background/border/blur | +| `--shadow-xl` | Container elevation | +| `--radius-2xl` | Container border-radius | +| `--primary-btn-bg` | CTA fill (WCAG 2.1 AA on dark) | + +## CSS Classes + +`.empty-state-*` rules were previously missing; they are now defined in +`src/index.css` (completing the UX152 system): + +| Class | Purpose | +|---|---| +| `.empty-state-container` | Centred column, glass-card styling | +| `.empty-state-container--error` | Error border + title tint | +| `.empty-state-icon-wrap` | Centres the decorative SVG | +| `.empty-state-content` | Text column, capped max-width | +| `.empty-state-title` | `--font-size-xl`, semibold | +| `.empty-state-body` | `--font-size-sm`, muted | +| `.empty-state-context` | Accent colour | +| `.empty-state-actions` | Stack (mobile) → row (≥480px) | +| `.empty-state-action` | Auto-width buttons inside a row | + +## Responsive Behaviour + +| Viewport | Layout | +|---|---| +| 320–479 px | Single column; actions stack full-width | +| 480 px+ | Actions sit side-by-side (auto-width, min 11rem) | +| 768 px+ | Container centres at `max-width: 38rem` | + +## Accessibility (WCAG 2.1 AA) + +| Feature | Implementation | +|---|---| +| Decorative illustrations | `aria-hidden="true"`, `role="presentation"` | +| Status announcement | `role="status"`, `aria-live="polite"` on root | +| Heading association | `aria-labelledby` → `

` | +| Keyboard focus | ` + )} ); } if (data.status === 'empty' || data.value == null) { + const emptyText = data.emptyText ?? 'No data yet'; return ( -
-
+
+
{icon}
{data.label}
-
- - No data yet +
+
); } const isPositiveTrend = data.trend !== undefined && data.trend >= 0; - + return ( -
+
{icon}
{data.label}
-
+
{data.type === 'currency' ? formatCurrency(data.value) : data.value}
- +
{data.trend !== undefined && ( -
- {isPositiveTrend ? : } +
= 0 ? '+' : ''}${data.trend.toFixed(1)}%`} + > + {isPositiveTrend ?
)} - + {data.actionText && data.actionLink && ( - {data.actionText} + {data.actionText}
@@ -101,8 +161,9 @@ export const DashboardHero: React.FC = ({ pendingActions, sparklineData = [], isNewInvestor = false, + onRetry, }) => { - // A simple SVG sparkline + // A simple SVG sparkline — responsive (full-width on mobile, fixed on sm+). const renderSparkline = () => { if (sparklineData.length < 2) return null; const max = Math.max(...sparklineData); @@ -110,50 +171,57 @@ export const DashboardHero: React.FC = ({ const range = max - min || 1; const height = 40; const width = 120; - - const points = sparklineData.map((val, i) => { - const x = (i / (sparklineData.length - 1)) * width; - const y = height - ((val - min) / range) * height; - return `${x},${y}`; - }).join(' '); + + const points = sparklineData + .map((val, i) => { + const x = (i / (sparklineData.length - 1)) * width; + const y = height - ((val - min) / range) * height; + return `${x},${y}`; + }) + .join(' '); const isPositive = sparklineData[sparklineData.length - 1] >= sparklineData[0]; - const strokeColor = isPositive ? '#10b981' : '#ef4444'; // success or error colors + const strokeColor = isPositive ? 'var(--success, #10b981)' : 'var(--error, #ef4444)'; return ( -
- - - -
+ + + ); }; return ( -
-
-
-
+
+
+
+

{isNewInvestor ? 'Welcome to Revora' : 'Portfolio Overview'}

- {isNewInvestor - ? 'Discover high-yield revenue share offerings and start building your portfolio.' + {isNewInvestor + ? 'Discover high-yield revenue share offerings and start building your portfolio.' : 'Track your personal returns and manage upcoming actions.'}

{!isNewInvestor && renderSparkline()}
- -
+ +
Explore Offerings @@ -163,11 +231,11 @@ export const DashboardHero: React.FC = ({
-
- } /> - } /> - } /> - } /> +
+ } testId="kpi-tile-total-value" onRetry={onRetry} /> + } testId="kpi-tile-realized-gains" onRetry={onRetry} /> + } testId="kpi-tile-upcoming-payouts" onRetry={onRetry} /> + } testId="kpi-tile-pending-actions" onRetry={onRetry} />
); diff --git a/src/components/NetworkSwitcher/NetworkSwitcher.test.tsx b/src/components/NetworkSwitcher/NetworkSwitcher.test.tsx index 47a5c19..def3d8a 100644 --- a/src/components/NetworkSwitcher/NetworkSwitcher.test.tsx +++ b/src/components/NetworkSwitcher/NetworkSwitcher.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { axe } from 'jest-axe'; import { NetworkSwitcher } from './NetworkSwitcher'; import { RecentNetworksProvider } from '../RecentNetworksProvider/RecentNetworksProvider'; @@ -24,10 +25,39 @@ beforeEach(() => { localStorageMock.clear(); }); +afterEach(() => { + document.documentElement.removeAttribute('data-theme'); +}); + function renderWithProvider(ui: React.ReactElement) { return render({ui}); } +/** + * Stateful harness mirroring how AppShell owns the current network id. The real + * NetworkSwitcher is controlled, so selections must update the trigger for + * subsequent panel interactions to work. + */ +function Harness({ items = networks }: { items?: typeof networks }) { + const [currentNetworkId, setCurrentNetworkId] = React.useState(); + return ( + + ); +} + +function openPanel() { + fireEvent.click(screen.getByRole('button', { name: /current network/i })); +} + +function selectNetwork(name: string) { + openPanel(); + fireEvent.click(screen.getByText(name)); +} + describe('NetworkSwitcher', () => { it('renders trigger with current network name', () => { renderWithProvider( @@ -51,7 +81,7 @@ describe('NetworkSwitcher', () => { renderWithProvider( {}} />, ); - fireEvent.click(screen.getByRole('button', { name: /select network/i })); + openPanel(); expect(screen.getByRole('listbox')).toBeInTheDocument(); }); @@ -59,7 +89,7 @@ describe('NetworkSwitcher', () => { renderWithProvider( {}} />, ); - fireEvent.click(screen.getByRole('button', { name: /select network/i })); + openPanel(); expect(screen.getAllByRole('option')).toHaveLength(4); }); @@ -68,7 +98,7 @@ describe('NetworkSwitcher', () => { renderWithProvider( , ); - fireEvent.click(screen.getByRole('button', { name: /select network/i })); + openPanel(); fireEvent.click(screen.getByText('Solana')); expect(onChange).toHaveBeenCalledWith('solana'); }); @@ -77,7 +107,7 @@ describe('NetworkSwitcher', () => { renderWithProvider( {}} />, ); - fireEvent.click(screen.getByRole('button', { name: /select network/i })); + openPanel(); fireEvent.click(screen.getByText('Solana')); expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); @@ -86,7 +116,7 @@ describe('NetworkSwitcher', () => { renderWithProvider( {}} />, ); - fireEvent.click(screen.getByRole('button', { name: /select network/i })); + openPanel(); fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' }); expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); @@ -95,7 +125,7 @@ describe('NetworkSwitcher', () => { renderWithProvider( {}} />, ); - fireEvent.click(screen.getByRole('button', { name: /select network/i })); + openPanel(); const listbox = screen.getByRole('listbox'); const options = screen.getAllByRole('option'); options[0].focus(); @@ -109,37 +139,158 @@ describe('NetworkSwitcher', () => { expect(document.activeElement).toBe(options[0]); }); + it('hides the Recent Networks section for a new user with no recents', () => { + renderWithProvider(); + openPanel(); + expect(screen.queryByText('Recent Networks')).not.toBeInTheDocument(); + expect(screen.queryByRole('separator')).not.toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(4); + }); + it('shows recents section after networks are used', () => { - const onChange = vi.fn(); - renderWithProvider( - , - ); - fireEvent.click(screen.getByRole('button', { name: /select network/i })); - fireEvent.click(screen.getByText('Ethereum')); - fireEvent.click(screen.getByRole('button', { name: /Ethereum/i })); - fireEvent.click(screen.getByText('Polygon')); - fireEvent.click(screen.getByRole('button', { name: /Polygon/i })); + renderWithProvider(); + selectNetwork('Ethereum'); + selectNetwork('Polygon'); + openPanel(); expect(screen.getByText('Recent Networks')).toBeInTheDocument(); + expect(screen.getByText('All Networks')).toBeInTheDocument(); + }); + + it('lists recents most-recent-first and excludes them from All Networks', () => { + renderWithProvider(); + selectNetwork('Ethereum'); + selectNetwork('Polygon'); + openPanel(); + const recentsGroup = screen.getByRole('group', { name: /recent networks/i }); + const recents = screen.getAllByRole('option').filter((el) => + recentsGroup.contains(el), + ); + expect(recents.map((el) => el.textContent)).toEqual(['Polygon', 'Ethereum']); + const allGroup = screen.getByRole('group', { name: /all networks/i }); + const all = screen.getAllByRole('option').filter((el) => allGroup.contains(el)); + expect(all.map((el) => el.textContent)).toEqual(['Solana', 'Arbitrum']); }); it('renders separator between recents and all networks', () => { - const onChange = vi.fn(); - renderWithProvider( - , + renderWithProvider(); + selectNetwork('Ethereum'); + selectNetwork('Polygon'); + openPanel(); + expect(screen.getByTestId('network-switcher-separator')).toBeInTheDocument(); + }); + + it('moves a duplicate selection to the top without repeating it', () => { + renderWithProvider(); + selectNetwork('Ethereum'); + selectNetwork('Polygon'); + selectNetwork('Ethereum'); + openPanel(); + const recentsGroup = screen.getByRole('group', { name: /recent networks/i }); + const recents = screen.getAllByRole('option').filter((el) => + recentsGroup.contains(el), + ); + expect(recents.map((el) => el.textContent)).toEqual(['Ethereum', 'Polygon']); + const allGroup = screen.getByRole('group', { name: /all networks/i }); + const all = screen.getAllByRole('option').filter((el) => allGroup.contains(el)); + expect(all.map((el) => el.textContent)).toEqual(['Solana', 'Arbitrum']); + }); + + it('keeps only the three most recent networks', () => { + renderWithProvider(); + selectNetwork('Ethereum'); + selectNetwork('Polygon'); + selectNetwork('Solana'); + selectNetwork('Arbitrum'); + openPanel(); + const recentsGroup = screen.getByRole('group', { name: /recent networks/i }); + const recents = screen.getAllByRole('option').filter((el) => + recentsGroup.contains(el), ); - fireEvent.click(screen.getByRole('button', { name: /select network/i })); - fireEvent.click(screen.getByText('Ethereum')); - fireEvent.click(screen.getByRole('button', { name: /Ethereum/i })); - fireEvent.click(screen.getByText('Polygon')); - fireEvent.click(screen.getByRole('button', { name: /Polygon/i })); - expect(screen.getByRole('separator')).toBeInTheDocument(); + expect(recents).toHaveLength(3); + expect(recents.map((el) => el.textContent)).toEqual([ + 'Arbitrum', + 'Solana', + 'Polygon', + ]); + const allGroup = screen.getByRole('group', { name: /all networks/i }); + const all = screen.getAllByRole('option').filter((el) => allGroup.contains(el)); + expect(all.map((el) => el.textContent)).toEqual(['Ethereum']); + }); + + it('restores recents persisted from a previous session', () => { + store['revora-recent-networks:default'] = JSON.stringify(['polygon', 'ethereum']); + renderWithProvider(); + openPanel(); + const recentsGroup = screen.getByRole('group', { name: /recent networks/i }); + const recents = screen.getAllByRole('option').filter((el) => + recentsGroup.contains(el), + ); + expect(recents.map((el) => el.textContent)).toEqual(['Polygon', 'Ethereum']); + }); + + it('treats corrupted persisted recents as empty', () => { + store['revora-recent-networks:default'] = 'not-json['; + renderWithProvider(); + openPanel(); + expect(screen.queryByText('Recent Networks')).not.toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(4); + }); + + it('supports keyboard navigation across recent and all-network sections', () => { + store['revora-recent-networks:default'] = JSON.stringify(['polygon', 'ethereum']); + renderWithProvider(); + openPanel(); + const listbox = screen.getByRole('listbox'); + const options = screen.getAllByRole('option'); + expect(options.map((el) => el.textContent)).toEqual([ + 'Polygon', + 'Ethereum', + 'Solana', + 'Arbitrum', + ]); + options[0].focus(); + fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(options[1]); + fireEvent.keyDown(listbox, { key: 'ArrowDown' }); + expect(document.activeElement).toBe(options[2]); + fireEvent.keyDown(listbox, { key: 'ArrowUp' }); + expect(document.activeElement).toBe(options[1]); + fireEvent.keyDown(listbox, { key: 'ArrowUp' }); + expect(document.activeElement).toBe(options[0]); + }); + + it('labels each section group from its visible heading', () => { + store['revora-recent-networks:default'] = JSON.stringify(['polygon']); + renderWithProvider(); + openPanel(); + expect(screen.getByRole('group', { name: 'Recent Networks' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'All Networks' })).toBeInTheDocument(); + }); + + it('shows an empty state when no networks are configured', () => { + renderWithProvider(); + openPanel(); + expect(screen.queryByText('Recent Networks')).not.toBeInTheDocument(); + expect(screen.queryByText('All Networks')).not.toBeInTheDocument(); + expect(screen.getByText('No networks available')).toBeInTheDocument(); + }); + + it('passes axe in light and dark mode with recents present', async () => { + store['revora-recent-networks:default'] = JSON.stringify(['polygon', 'ethereum']); + const { container } = renderWithProvider(); + openPanel(); + expect(await axe(container)).toHaveNoViolations(); + + document.documentElement.setAttribute('data-theme', 'dark'); + expect(await axe(container)).toHaveNoViolations(); + document.documentElement.removeAttribute('data-theme'); }); it('closes on close button click', () => { renderWithProvider( {}} />, ); - fireEvent.click(screen.getByRole('button', { name: /select network/i })); + openPanel(); fireEvent.click(screen.getByText('Close')); expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); diff --git a/src/components/NetworkSwitcher/NetworkSwitcherPanel.tsx b/src/components/NetworkSwitcher/NetworkSwitcherPanel.tsx index 22264bb..d724738 100644 --- a/src/components/NetworkSwitcher/NetworkSwitcherPanel.tsx +++ b/src/components/NetworkSwitcher/NetworkSwitcherPanel.tsx @@ -17,8 +17,11 @@ export function NetworkSwitcherPanel({ }: NetworkSwitcherPanelProps) { const { recentNetworkIds, addRecentNetwork } = useRecentNetworks(); const panelRef = useRef(null); + const listboxRef = useRef(null); - const recentNetworks = networks.filter((n) => recentNetworkIds.includes(n.id)); + const recentNetworks = recentNetworkIds + .map((id) => networks.find((n) => n.id === id)) + .filter((n): n is Network => Boolean(n)); const nonRecentNetworks = networks.filter((n) => !recentNetworkIds.includes(n.id)); const handleNetworkClick = useCallback( @@ -40,7 +43,7 @@ export function NetworkSwitcherPanel({ }, [onClose]); useEffect(() => { - panelRef.current?.focus(); + listboxRef.current?.focus(); }, []); const handleKeyDown = useCallback( @@ -101,29 +104,8 @@ export function NetworkSwitcherPanel({ ); return ( -
- {recentNetworks.length > 0 && ( - <> - -
    - {recentNetworks.map(renderNetworkItem)} -
-
- - )} - - {nonRecentNetworks.length === 0 && recentNetworks.length === 0 ? ( +
+ {networks.length === 0 ? (

No networks available

@@ -131,9 +113,55 @@ export function NetworkSwitcherPanel({

) : ( -
    - {nonRecentNetworks.map(renderNetworkItem)} -
+
+ {recentNetworks.length > 0 && ( + <> + +
    + {recentNetworks.map(renderNetworkItem)} +
+ )}
+ )}
@@ -649,16 +742,52 @@ export const DistributionDashboard: React.FC = () => { {/* Governance Delegation */}
- + {isEmptyDelegates ? ( + + ) : ( + + )}
{/* Governance Results Breakdown */}
- + {isEmptyVotes ? ( + + ) : ( + + )}
{/* Financial terms wizard step */} @@ -711,5 +840,6 @@ export const DistributionDashboard: React.FC = () => { }} />
+
); }; diff --git a/src/pages/InvestorPortfolioSummary.test.tsx b/src/pages/InvestorPortfolioSummary.test.tsx index dd5eecb..44e4984 100644 --- a/src/pages/InvestorPortfolioSummary.test.tsx +++ b/src/pages/InvestorPortfolioSummary.test.tsx @@ -1,15 +1,28 @@ /** * InvestorPortfolioSummary.test.tsx - * Issue #163 – Investor Portfolio Summary page - * Coverage target ≥95% on InvestorPortfolioSummary.tsx + * Investor Portfolio Summary page — hero-focused coverage + * + * Covers: + * • Hero section wiring (heading, CTA, KPI tiles, sparkline) + * • New investor (no positions) empty states + * • Negative return trend rendering + * • KPI error + loading states driven through __kpiStatus + * • Responsive layout classes + * • Dark mode rendering + * • Accessibility (jest-axe on the hero) + * • Widgets still render alongside the hero */ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it } from "vitest"; +import { axe, toHaveNoViolations } from "jest-axe"; import { InvestorPortfolioSummary } from "./InvestorPortfolioSummary"; import type { AllocationSlice } from "../components/AllocationWidget"; import type { PerformanceDataPoint } from "../components/PerformanceTrendWidget"; +expect.extend(toHaveNoViolations); + const ALLOCS: AllocationSlice[] = [ { id: "1", label: "TechFlow AI", value: 45000, percentage: 45 }, { id: "2", label: "Quantum Ledger", value: 30000, percentage: 30 }, @@ -22,95 +35,168 @@ const PERF: PerformanceDataPoint[] = [ { month: "Mar", value: 103000 }, ]; -const renderPage = (overrides = {}) => +const renderPage = (overrides: Record = {}) => render( ); -describe("InvestorPortfolioSummary", () => { - it("renders portfolio-summary testid", () => { +const hero = () => screen.getByTestId("investor-hero"); + +afterEach(() => { + document.documentElement.removeAttribute("data-theme"); +}); + +describe("InvestorPortfolioSummary – hero", () => { + it("renders the hero section and page shell", () => { renderPage(); + expect(hero()).toBeInTheDocument(); expect(screen.getByTestId("portfolio-summary")).toBeInTheDocument(); }); - it("renders page heading", () => { + it("renders Portfolio Overview heading for an existing investor", () => { renderPage(); - expect(screen.getByRole("heading", { level: 1, name: /Portfolio Summary/i })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1, name: /Portfolio Overview/i })).toBeInTheDocument(); }); - it("renders back navigation link to /investor/portal", () => { - renderPage(); - const link = screen.getByRole("link", { name: /Back to Investor Discovery/i }); - expect(link).toHaveAttribute("href", "/investor/portal"); + it("renders Welcome to Revora heading for a new investor", () => { + renderPage({ __allocations: [], __performance: [] }); + expect(screen.getByRole("heading", { level: 1, name: /Welcome to Revora/i })).toBeInTheDocument(); }); - it("renders KPI header", () => { + it("renders the primary and secondary CTAs with the right routes", () => { renderPage(); - expect(screen.getByTestId("kpi-header")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Explore Offerings/i })).toHaveAttribute("href", "/investor/portal"); + expect(screen.getByRole("link", { name: /Account Settings/i })).toHaveAttribute("href", "/investor/settings"); }); - it("renders allocation widget", () => { + it("renders the four KPI tiles", () => { renderPage(); - expect(screen.getByTestId("allocation-widget")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-tile-total-value")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-tile-realized-gains")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-tile-upcoming-payouts")).toBeInTheDocument(); + expect(screen.getByTestId("kpi-tile-pending-actions")).toBeInTheDocument(); }); - it("renders performance widget", () => { + it("shows the current portfolio value from the last performance point", () => { renderPage(); - expect(screen.getByTestId("performance-widget")).toBeInTheDocument(); + expect(within(hero()).getByText("$103,000")).toBeInTheDocument(); + }); + + it("falls back to totalInvested when performance is empty", () => { + renderPage({ __performance: [] }); + expect(within(hero()).getByText("$100,000")).toBeInTheDocument(); }); - it("computes totalInvested from allocations", () => { + it("renders the sparkline for an existing investor", () => { renderPage(); - // 45000+30000+25000 = 100000 → $100,000 (appears in KPI + allocation bar) - expect(screen.getAllByText("$100,000").length).toBeGreaterThan(0); + expect(within(hero()).getByTestId("portfolio-sparkline")).toBeInTheDocument(); }); - it("shows currentValue = last performance point", () => { + it("renders back navigation link to /investor/portal", () => { renderPage(); - // last point value = 103000 → $103,000 - expect(screen.getByText("$103,000")).toBeInTheDocument(); + const link = screen.getByRole("link", { name: /Back to Investor Discovery/i }); + expect(link).toHaveAttribute("href", "/investor/portal"); }); - it("shows activeHoldings = 3", () => { + it("renders the allocation and performance widgets alongside the hero", () => { renderPage(); - // KPI header "3" for activeHoldings - expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByTestId("allocation-widget")).toBeInTheDocument(); + expect(screen.getByTestId("performance-widget")).toBeInTheDocument(); }); +}); - it("renders with default mock data when no props given", () => { - render( - - - - ); - expect(screen.getByTestId("portfolio-summary")).toBeInTheDocument(); - expect(screen.getByTestId("kpi-header")).toBeInTheDocument(); +describe("InvestorPortfolioSummary – new investor (no positions)", () => { + it("shows contextual empty states on each KPI tile", () => { + renderPage({ __allocations: [], __performance: [] }); + expect(screen.getAllByText("No investments yet").length).toBeGreaterThan(0); + expect(screen.getByText("No payouts scheduled")).toBeInTheDocument(); + expect(screen.getByText("No pending actions")).toBeInTheDocument(); }); - it("handles empty allocations gracefully", () => { - renderPage({ __allocations: [] }); - // totalInvested = 0, currentValue = last perf value - expect(screen.getByTestId("portfolio-summary")).toBeInTheDocument(); + it("does not render a sparkline for a new investor", () => { + renderPage({ __allocations: [], __performance: [] }); + expect(within(hero()).queryByTestId("portfolio-sparkline")).not.toBeInTheDocument(); }); +}); - it("handles empty performance gracefully (currentValue falls back to totalInvested)", () => { - renderPage({ __performance: [] }); - expect(screen.getByTestId("portfolio-summary")).toBeInTheDocument(); +describe("InvestorPortfolioSummary – negative returns", () => { + it("renders a negative trend in red in the hero", () => { + const NEGATIVE_PERF: PerformanceDataPoint[] = [ + { month: "Jan", value: 110000 }, + { month: "Feb", value: 100000 }, + { month: "Mar", value: 95000 }, + ]; + renderPage({ __performance: NEGATIVE_PERF }); + // totalInvested = $100,000, current = $95,000 → -5.0% + const trend = within(hero()).getByText("5.0%"); + expect(trend.parentElement).toHaveClass("text-red-400"); + }); + + it("renders a down-trending sparkline label", () => { + const NEGATIVE_PERF: PerformanceDataPoint[] = [ + { month: "Jan", value: 110000 }, + { month: "Feb", value: 100000 }, + { month: "Mar", value: 95000 }, + ]; + renderPage({ __performance: NEGATIVE_PERF }); + expect(within(hero()).getByRole("img", { name: /sparkline trending down/i })).toBeInTheDocument(); }); +}); - it("handles single holding", () => { - renderPage({ - __allocations: [{ id: "1", label: "Solo Fund", value: 10000, percentage: 100 }], - }); - expect(screen.getByText("Solo Fund")).toBeInTheDocument(); +describe("InvestorPortfolioSummary – KPI error and loading states", () => { + it("renders friendly retry messaging on every tile when errored", () => { + renderPage({ __kpiStatus: "error" }); + expect(screen.getAllByText(/load this data/i).length).toBe(4); + // Retry buttons are wired by the data layer via DashboardHero's onRetry — + // the page (mock data) does not provide a handler. + expect(screen.queryByRole("button", { name: /Retry loading/i })).not.toBeInTheDocument(); + }); + + it("renders loading skeletons with role=status and aria-busy", () => { + renderPage({ __kpiStatus: "loading" }); + const statuses = screen.getAllByRole("status"); + expect(statuses.length).toBe(4); + statuses.forEach((node) => expect(node).toHaveAttribute("aria-busy", "true")); + }); +}); + +describe("InvestorPortfolioSummary – responsive layout", () => { + it("uses a 1→2→4 column KPI grid", () => { + renderPage(); + const grid = screen.getByTestId("kpi-grid"); + expect(grid).toHaveClass("grid-cols-1", "sm:grid-cols-2", "lg:grid-cols-4"); }); - it("renders subtitle text", () => { + it("keeps the sparkline responsive and mobile-visible", () => { renderPage(); - expect( - screen.getByText(/Your holdings, allocation, and 12-month performance at a glance/i) - ).toBeInTheDocument(); + const sparkline = within(hero()).getByTestId("portfolio-sparkline"); + expect(sparkline).toHaveClass("w-full", "sm:w-40"); + }); +}); + +describe("InvestorPortfolioSummary – dark mode", () => { + it("renders the hero with token-based dark surfaces and passes axe", async () => { + document.documentElement.setAttribute("data-theme", "dark"); + renderPage(); + expect(screen.getByTestId("kpi-tile-total-value")).toHaveClass("glass-card"); + expect(screen.getByRole("heading", { level: 1 })).toHaveClass("text-main"); + const results = await axe(hero()); + expect(results).toHaveNoViolations(); + }); +}); + +describe("InvestorPortfolioSummary – accessibility", () => { + it("has no axe violations on the hero in nominal state", async () => { + renderPage(); + const results = await axe(hero()); + expect(results).toHaveNoViolations(); + }); + + it("has no axe violations on the hero in empty state", async () => { + renderPage({ __allocations: [], __performance: [] }); + const results = await axe(hero()); + expect(results).toHaveNoViolations(); }); }); diff --git a/src/pages/InvestorPortfolioSummary.tsx b/src/pages/InvestorPortfolioSummary.tsx index 402149f..48785bd 100644 --- a/src/pages/InvestorPortfolioSummary.tsx +++ b/src/pages/InvestorPortfolioSummary.tsx @@ -33,38 +33,45 @@ interface InvestorPortfolioSummaryProps { /** Inject mock data for testing */ __allocations?: AllocationSlice[]; __performance?: PerformanceDataPoint[]; + /** Force every KPI tile into a given status (demo/testing of tile states) */ + __kpiStatus?: KPIData['status']; } export const InvestorPortfolioSummary: React.FC = ({ __allocations = MOCK_ALLOCATIONS, __performance = MOCK_PERFORMANCE, + __kpiStatus, }) => { const totalInvested = __allocations.reduce((s, a) => s + a.value, 0); const currentValue = __performance.length > 0 ? __performance[__performance.length - 1].value : totalInvested; const totalReturn = totalInvested > 0 ? ((currentValue - totalInvested) / totalInvested) * 100 : 0; - + const isNewInvestor = __allocations.length === 0; + const kpiStatus: KPIData['status'] = __kpiStatus ?? (isNewInvestor ? 'empty' : 'success'); const totalValueKPI: KPIData = { label: "Total Value", value: currentValue, type: 'currency', - status: isNewInvestor ? 'empty' : 'success', + status: kpiStatus, trend: totalReturn, + emptyText: "No investments yet", }; const realizedGainsKPI: KPIData = { label: "Realized Gains", value: isNewInvestor ? null : currentValue - totalInvested, // simplified for mock type: 'currency', - status: isNewInvestor ? 'empty' : 'success', + status: kpiStatus, + emptyText: "No investments yet", }; const upcomingPayoutsKPI: KPIData = { label: "Upcoming Payouts", value: isNewInvestor ? null : 3, type: 'number', - status: isNewInvestor ? 'empty' : 'success', + status: kpiStatus, + emptyText: "No payouts scheduled", actionText: isNewInvestor ? undefined : 'View calendar', actionLink: isNewInvestor ? undefined : '/investor/calendar' }; @@ -73,7 +80,8 @@ export const InvestorPortfolioSummary: React.FC = label: "Pending Actions", value: 1, type: 'number', - status: 'success', // Always show 1 for mock purposes + status: kpiStatus, + emptyText: "No pending actions", actionText: 'Review now', actionLink: '/investor/actions' }; diff --git a/src/test/setup.ts b/src/test/setup.ts index 8391917..9a38810 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -13,6 +13,21 @@ global.ResizeObserver = class ResizeObserver { disconnect() {} }; +// matchMedia stub — jsdom does not implement it. Components that detect print +// mode (usePrintMode) and some chart widgets call window.matchMedia() on mount. +if (!window.matchMedia) { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} + // jsdom does not implement scrollIntoView — provide a minimal stub if (!Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = function () {};