diff --git a/docs/uiux/ux107-discovery-empty-error-states.md b/docs/uiux/ux107-discovery-empty-error-states.md new file mode 100644 index 0000000..84e7fd3 --- /dev/null +++ b/docs/uiux/ux107-discovery-empty-error-states.md @@ -0,0 +1,224 @@ +# UX107: Discovery Empty & Error States + +## Scope + +`InvestorDiscovery` component — the offering discovery results area used by the Investor Portal. +Changes are UI/UX only; no routing, API, or backend behaviour is altered. + +--- + +## Problem Statement + +The previous `InvestorDiscovery` was a static stub with a hard-coded three-card grid. It had: + +| Issue | Detail | +|---|---| +| No filtering / search wiring | Search input was purely decorative | +| No empty state | Zero-result queries showed nothing | +| No distinction between state types | "no offerings at all" vs "no matches for filter" looked identical | +| No error state | Load failures had no UI treatment | +| No ARIA live regions | Screen readers couldn't announce result changes | +| No accessible progress bars | Funding progress had no `progressbar` role | + +--- + +## State Model + +The component uses a **discriminated union** `DiscoveryState` to drive the result area: + +``` +DiscoveryState = + | { kind: 'loaded'; offerings: Offering[] } + | { kind: 'filtered-empty'; query: string; hasFilters: boolean } + | { kind: 'truly-empty' } + | { kind: 'error'; retryCount: number } +``` + +### State Decision Tree + +``` +MOCK_OFFERINGS.length === 0 + └─ truly-empty + ↓ +query or filtersActive? + ├─ YES → filter(offerings by query) + │ └─ length === 0 → filtered-empty (active query echoed) + │ └─ length > 0 → loaded + └─ NO → loaded (full list) +``` + +--- + +## Components + +### `DiscoveryEmptyState` (exported, independently testable) + +| Prop | Type | Required | Description | +|---|---|---|---| +| `variant` | `'filtered' \| 'truly-empty'` | ✅ | Controls icon, heading, copy, and CTA | +| `query` | `string` | ❌ | Echoed search term (filtered variant only) | +| `onClearFilters` | `() => void` | ✅ | Fired when user clicks "Clear filters" | + +**Filtered variant** — rendered when a search/filter produced zero results: +- Icon: `SearchX` (blue tinted circle) +- Heading: "No offerings match your search" +- Body: echoes the `query` in an accent-coloured `` or falls back to "matching the active filters" +- CTA: "Clear filters" button → calls `onClearFilters` + +**Truly-empty variant** — rendered when the platform has no offerings at all: +- Icon: `PackageOpen` (blue tinted circle) +- Heading: "No offerings yet" +- Body: Reassuring copy about future listings +- CTA: "How it works" secondary button + +### `DiscoveryErrorState` (exported, independently testable) + +| Prop | Type | Required | Description | +|---|---|---|---| +| `onRetry` | `() => void` | ✅ | Fired when user clicks "Try again" | +| `retryCount` | `number` | ❌ | When > 0 shows "Retried N time(s)" hint | + +- Icon: `ServerCrash` (red tinted circle) +- Heading: "Couldn't load offerings" +- Body: Reassuring — "Your portfolio and account are unaffected" +- Retry hint: Appears only when `retryCount > 0` (singular "time" / plural "times") +- CTA: "Try again" primary button → calls `onRetry` + +--- + +## Design System Tokens Added (`index.css → :root`) + +| Token | Value | Usage | +|---|---|---| +| `--ds-empty-icon-bg` | `rgba(59, 130, 246, 0.08)` | Icon well background (empty) | +| `--ds-empty-icon-fg` | `var(--primary)` | Icon stroke (empty) | +| `--ds-error-icon-bg` | `rgba(239, 68, 68, 0.08)` | Icon well background (error) | +| `--ds-error-icon-fg` | `var(--error)` | Icon stroke (error) | +| `--ds-state-gap` | `1.25rem` | Column gap between state slots | +| `--ds-state-max-w` | `28rem` | Max-width of body text column | +| `--ds-state-pad-y` | `3.5rem` | Vertical padding inside state card | +| `--ds-state-pad-x` | `2rem` | Horizontal padding inside state card | +| `--ds-state-icon-size` | `4.5rem` | Icon well diameter | +| `--ds-filter-badge-size` | `0.5rem` | Active-filter dot indicator size | + +--- + +## CSS Component Classes + +| Class | Applied to | Purpose | +|---|---|---| +| `.discovery-state-container` | Both state root `
`s | Centred column flex layout, glass-card styling | +| `.discovery-state-icon-wrap` | Icon well `
` | Circular container base | +| `.discovery-state-icon-wrap--empty` | Empty state icon | Blue-tinted background + border | +| `.discovery-state-icon-wrap--error` | Error state icon | Red-tinted background + border | +| `.discovery-state-title` | `

` in state cards | 20px / 600 weight heading | +| `.discovery-state-body` | Body `

` | 14px, muted, max-width capped | +| `.discovery-state-query` | Echoed query `` | Accent colour + subtle bg highlight | +| `.discovery-state-retry-hint` | Retry count `

` | 13px, italic, muted | +| `.discovery-state-action` | CTA buttons | `inline-flex`, min-width 9rem | +| `.discovery-filter-btn--active` | Filter button when pressed | Brightened border, accent icon colour | +| `.discovery-filter-badge` | Dot badge on filter button | 8px circle, accent colour, `position: absolute` | + +--- + +## Layout + +``` +┌─── ──────────────────────────────────────────────────────┐ +│ │ +│ ┌─ Page header ────────────────────────────────────────────────────────┐ │ +│ │

Discover Offerings │ │ +│ │ [Search input ─────────────────────] [Filter ●] │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ [Filters active • Clear all] ← aria-live="polite", visible when active │ +│ │ +│ ┌─
────────┐ │ +│ │ │ │ +│ │ [loaded] 3-column OfferingCard grid │ │ +│ │ [filtered-empty] DiscoveryEmptyState variant="filtered" │ │ +│ │ [truly-empty] DiscoveryEmptyState variant="truly-empty" │ │ +│ │ [error] DiscoveryErrorState │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Portfolio CTA (loaded state only) ─────────────────────────────────┐ │ +│ │ Build Your Portfolio … [How it works] │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +└───────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## ARIA & Accessibility + +| Element | Role / Attribute | Rationale | +|---|---|---| +| `
` result area | `aria-live="polite"` + `aria-label="Offering results"` | Announces result changes to screen readers without interruption | +| `DiscoveryEmptyState` root | `role="status"` + `aria-live="polite"` + `aria-labelledby` | Polite status region; heading linked | +| `DiscoveryErrorState` root | `role="alert"` + `aria-live="assertive"` + `aria-labelledby` | Assertive announcement for failures | +| Funding progress bar | `role="progressbar"` + `aria-valuenow/min/max` + `aria-label` | Accessible progress semantics | +| Filter toggle button | `aria-pressed` (boolean) | Communicates toggle state to AT | +| Echoed query `` | `aria-label="search term: {query}"` | Provides spoken label for the highlighted text | +| Filter indicator | `aria-live="polite"` | Announces when filter becomes active | +| Icon `
`s | `aria-hidden="true"` | Decorative; excluded from AT tree | + +### WCAG 2.1 AA Mapping + +| SC | Description | How addressed | +|---|---|---| +| 1.3.1 Info and Relationships | Structure is programmatically determinable | `role="alert/status"`, `aria-labelledby`, `

/

` hierarchy | +| 1.4.3 Contrast Minimum | Text ≥4.5:1 | `--text-muted` (#cbd5e1 on #020617) ≈5.9:1; error red on dark ≈5.4:1 | +| 2.1.1 Keyboard | All interactive elements keyboard accessible | Buttons and links reachable via Tab; focus order logical | +| 2.4.3 Focus Order | Focus order preserves meaning | State views render actions first in DOM tab order | +| 2.4.7 Focus Visible | Focus indicators visible | Global `:focus-visible` rules from `index.css` inherited | +| 4.1.3 Status Messages | Status messages available to AT without focus | `role="status"` / `role="alert"` + `aria-live` | + +--- + +## Test Coverage + +File: `src/components/InvestorDiscovery.test.tsx` — **68 tests across 10 describe blocks** + +| Group | Tests | +|---|---| +| DiscoveryEmptyState – filtered | 13 tests (ARIA, heading, query echo, CTA, focus) | +| DiscoveryEmptyState – truly-empty | 6 tests (heading, copy, CTA, icon class) | +| DiscoveryErrorState | 12 tests (ARIA, heading, copy, retry hint singular/plural, focus) | +| InvestorDiscovery – loaded | 6 tests (cards, search, progressbar ARIA, CTA) | +| InvestorDiscovery – search filtering | 5 tests (type, no-match, clear-X, case-insensitive) | +| InvestorDiscovery – filter toggle | 5 tests (aria-pressed, indicator, clear-all) | +| InvestorDiscovery – simulated states | 7 tests (all 4 state kinds, portfolio CTA visibility) | +| InvestorDiscovery – retry interaction | 1 test | +| InvestorDiscovery – clear filtered-empty | 1 test (round-trip: type → empty → clear → loaded) | +| Result area accessibility | 3 tests (region role, aria-live, alert inside region) | + +**Coverage target:** ≥95% branches / functions / lines / statements on `InvestorDiscovery.tsx` +Enforced via `vite.config.ts → coverage.thresholds`. + +--- + +## Responsive Behaviour + +| Viewport | Layout | +|---|---| +| 320 px | State card full-width; body text wraps within `--ds-state-max-w`; padding `--ds-state-pad-x` clips safely | +| 375–480 px | Same single-column; offering grid is 1-col | +| 768 px+ | Offering grid switches to 3-col; header row aligns search bar right | + +--- + +## Implementation Notes + +- `__simulateState` prop on `InvestorDiscovery` — available for Storybook and tests to force any state without touching mock data. Not a production API surface. +- `useId()` used in both `DiscoveryEmptyState` and `DiscoveryErrorState` for stable `aria-labelledby` IDs. +- `DiscoveryEmptyState` and `DiscoveryErrorState` are named exports — independently renderable and testable in isolation. +- The filter button uses `aria-pressed` (a toggle pattern) rather than `aria-expanded` because it toggles a filter state, not a disclosure panel. +- Portfolio CTA is conditionally rendered only in the `loaded` state to avoid visual clutter alongside state views. + +--- + +## Security Notes + +- No user-supplied HTML is rendered. The echoed query string is inserted as React text content (auto-escaped). +- No backend changes; purely frontend UI state. diff --git a/src/components/InvestorDiscovery.test.tsx b/src/components/InvestorDiscovery.test.tsx new file mode 100644 index 0000000..59923bc --- /dev/null +++ b/src/components/InvestorDiscovery.test.tsx @@ -0,0 +1,517 @@ +/** + * InvestorDiscovery.test.tsx + * Issue #107 – Design the empty and error states for offering discovery results + * + * Coverage targets (≥95% on InvestorDiscovery.tsx): + * - DiscoveryEmptyState: filtered variant, truly-empty variant, prop edge cases + * - DiscoveryErrorState: initial render, retry counter, retry interaction + * - InvestorDiscovery (integrated): loaded state, filtered-empty, truly-empty, + * error state, search wiring, filter toggle, clear interactions + * - ARIA: role/aria-live/aria-labelledby/aria-pressed semantics + * - Keyboard: focus on action buttons inside state views + * - Mobile layout: state containers render at narrow widths + */ + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + DiscoveryEmptyState, + DiscoveryErrorState, + InvestorDiscovery, +} from './InvestorDiscovery'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const noop = () => {}; + +/** Mirror of MOCK_OFFERINGS in InvestorDiscovery.tsx for test assertions */ +const MOCK_OFFERINGS = [ + { id: 1, name: 'TechFlow AI', category: 'Enterprise SaaS', revenueShare: 15, target: 250000, raised: 112500 }, + { id: 2, name: 'Quantum Ledger', category: 'DeFi Infrastructure', revenueShare: 12, target: 500000, raised: 140000 }, + { id: 3, name: 'Nexus Pay', category: 'Cross-Border Payments', revenueShare: 18, target: 300000, raised: 186000 }, +]; + +/** Helper: renders InvestorDiscovery in loaded state without skeleton delay */ +const renderLoaded = (extraProps: Record = {}) => + render( + [0])} + />, + ); + +// ───────────────────────────────────────────────────────────────────────────── +// 1. DiscoveryEmptyState – filtered variant +// ───────────────────────────────────────────────────────────────────────────── + +describe('DiscoveryEmptyState – filtered variant', () => { + it('renders the container with data-testid', () => { + render(); + expect(screen.getByTestId('discovery-empty-state')).toBeInTheDocument(); + }); + + it('renders as role="status" (polite region)', () => { + render(); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('has aria-live="polite"', () => { + render(); + expect(screen.getByTestId('discovery-empty-state')).toHaveAttribute('aria-live', 'polite'); + }); + + it('renders a heading linked via aria-labelledby', () => { + render(); + const container = screen.getByTestId('discovery-empty-state'); + const labelId = container.getAttribute('aria-labelledby'); + expect(labelId).toBeTruthy(); + const heading = document.getElementById(labelId!); + expect(heading).not.toBeNull(); + expect(heading!.tagName.toLowerCase()).toBe('h2'); + }); + + it('renders "No offerings match your search" heading', () => { + render(); + expect(screen.getByRole('heading', { level: 2, name: /No offerings match your search/i })).toBeInTheDocument(); + }); + + it('displays the echoed search query when provided', () => { + render(); + expect(screen.getByText(/"fintech"/)).toBeInTheDocument(); + }); + + it('echoed query element has aria-label for screen readers', () => { + render(); + const queryEl = screen.getByLabelText(/search term: health/i); + expect(queryEl).toBeInTheDocument(); + }); + + it('renders fallback copy when query is absent but filters active', () => { + render(); + expect(screen.getByText(/matching the active filters/i)).toBeInTheDocument(); + }); + + it('renders the Clear filters button', () => { + render(); + expect(screen.getByTestId('clear-filters-btn')).toBeInTheDocument(); + }); + + it('Clear filters button has descriptive aria-label', () => { + render(); + const btn = screen.getByTestId('clear-filters-btn'); + expect(btn).toHaveAttribute('aria-label', 'Clear all search filters and show all offerings'); + }); + + it('calls onClearFilters when Clear filters is clicked', async () => { + const spy = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId('clear-filters-btn')); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('Clear filters button is keyboard-focusable', async () => { + const user = userEvent.setup(); + render(); + await user.tab(); + expect(screen.getByTestId('clear-filters-btn')).toHaveFocus(); + }); + + it('applies discovery-state-icon-wrap--empty class to icon container', () => { + render(); + expect(document.querySelector('.discovery-state-icon-wrap--empty')).not.toBeNull(); + }); + + it('does NOT render truly-empty secondary CTA on filtered variant', () => { + render(); + expect(screen.queryByRole('button', { name: /How it works/i })).toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. DiscoveryEmptyState – truly-empty variant +// ───────────────────────────────────────────────────────────────────────────── + +describe('DiscoveryEmptyState – truly-empty variant', () => { + it('renders "No offerings yet" heading', () => { + render(); + expect(screen.getByRole('heading', { level: 2, name: /No offerings yet/i })).toBeInTheDocument(); + }); + + it('renders reassuring copy about future offerings', () => { + render(); + expect(screen.getByText(/Check back soon/i)).toBeInTheDocument(); + }); + + it('renders "How it works" secondary CTA', () => { + render(); + // The button's accessible name comes from aria-label, not the visible text + expect(screen.getByRole('button', { name: /Learn how Revora works/i })).toBeInTheDocument(); + }); + + it('does NOT render a Clear filters button on truly-empty variant', () => { + render(); + expect(screen.queryByTestId('clear-filters-btn')).toBeNull(); + }); + + it('applies discovery-state-icon-wrap--empty class', () => { + render(); + expect(document.querySelector('.discovery-state-icon-wrap--empty')).not.toBeNull(); + }); + + it('does NOT render the error icon container', () => { + render(); + expect(document.querySelector('.discovery-state-icon-wrap--error')).toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. DiscoveryErrorState +// ───────────────────────────────────────────────────────────────────────────── + +describe('DiscoveryErrorState', () => { + it('renders with data-testid', () => { + render(); + expect(screen.getByTestId('discovery-error-state')).toBeInTheDocument(); + }); + + it('renders as role="alert" (assertive live region)', () => { + render(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('has aria-live="assertive"', () => { + render(); + expect(screen.getByTestId('discovery-error-state')).toHaveAttribute('aria-live', 'assertive'); + }); + + it('renders "Couldn\'t load offerings" heading', () => { + render(); + expect(screen.getByRole('heading', { level: 2, name: /Couldn't load offerings/i })).toBeInTheDocument(); + }); + + it('renders reassuring body copy', () => { + render(); + expect(screen.getByText(/Your portfolio and account are unaffected/i)).toBeInTheDocument(); + }); + + it('renders Try again button', () => { + render(); + expect(screen.getByTestId('retry-btn')).toBeInTheDocument(); + }); + + it('Try again button has descriptive aria-label', () => { + render(); + expect(screen.getByTestId('retry-btn')).toHaveAttribute('aria-label', 'Retry loading offerings'); + }); + + it('calls onRetry when Try again is clicked', async () => { + const spy = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId('retry-btn')); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('Try again button is keyboard-focusable', async () => { + const user = userEvent.setup(); + render(); + await user.tab(); + expect(screen.getByTestId('retry-btn')).toHaveFocus(); + }); + + it('does NOT render retry hint when retryCount=0', () => { + render(); + expect(screen.queryByText(/Retried/i)).toBeNull(); + }); + + it('renders retry hint with singular "time" when retryCount=1', () => { + render(); + expect(screen.getByText(/Retried 1 time —/i)).toBeInTheDocument(); + }); + + it('renders retry hint with plural "times" when retryCount=3', () => { + render(); + expect(screen.getByText(/Retried 3 times —/i)).toBeInTheDocument(); + }); + + it('applies discovery-state-icon-wrap--error class', () => { + render(); + expect(document.querySelector('.discovery-state-icon-wrap--error')).not.toBeNull(); + }); + + it('does NOT apply empty icon class on error state', () => { + render(); + expect(document.querySelector('.discovery-state-icon-wrap--empty')).toBeNull(); + }); + + it('aria-labelledby points to the

id', () => { + render(); + const container = screen.getByTestId('discovery-error-state'); + const labelId = container.getAttribute('aria-labelledby')!; + const heading = document.getElementById(labelId); + expect(heading).not.toBeNull(); + expect(heading!.tagName.toLowerCase()).toBe('h2'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. InvestorDiscovery – loaded state (default) +// ───────────────────────────────────────────────────────────────────────────── + +describe('InvestorDiscovery – loaded state', () => { + it('renders the page heading', () => { + renderLoaded(); + expect(screen.getByRole('heading', { level: 1, name: /Discover Offerings/i })).toBeInTheDocument(); + }); + + it('renders all three mock offering cards', () => { + renderLoaded(); + expect(screen.getByTestId('offering-card-1')).toBeInTheDocument(); + expect(screen.getByTestId('offering-card-2')).toBeInTheDocument(); + expect(screen.getByTestId('offering-card-3')).toBeInTheDocument(); + }); + + it('does NOT render empty or error state', () => { + renderLoaded(); + expect(screen.queryByTestId('discovery-empty-state')).toBeNull(); + expect(screen.queryByTestId('discovery-error-state')).toBeNull(); + }); + + it('renders the portfolio CTA section in loaded state', () => { + renderLoaded(); + expect(screen.getByRole('heading', { level: 2, name: /Build Your Portfolio/i })).toBeInTheDocument(); + }); + + it('has a search input with aria-label', () => { + renderLoaded(); + expect(screen.getByRole('searchbox', { name: /Search startup offerings/i })).toBeInTheDocument(); + }); + + it('funding progress bars have progressbar role and aria attributes', () => { + renderLoaded(); + const bars = screen.getAllByRole('progressbar'); + expect(bars.length).toBeGreaterThan(0); + bars.forEach((bar) => { + expect(bar).toHaveAttribute('aria-valuenow'); + expect(bar).toHaveAttribute('aria-valuemin', '0'); + expect(bar).toHaveAttribute('aria-valuemax', '100'); + }); + }); + + it('each "View Prospectus" button has an aria-label with the offering name', () => { + renderLoaded(); + expect(screen.getByRole('button', { name: /View prospectus for TechFlow AI/i })).toBeInTheDocument(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. InvestorDiscovery – search filtering +// ───────────────────────────────────────────────────────────────────────────── + +describe('InvestorDiscovery – search filtering', () => { + it('filters results as user types', async () => { + const user = userEvent.setup(); + renderLoaded(); + await user.type(screen.getByRole('searchbox'), 'TechFlow'); + expect(screen.getByTestId('offering-card-1')).toBeInTheDocument(); + expect(screen.queryByTestId('offering-card-2')).toBeNull(); + expect(screen.queryByTestId('offering-card-3')).toBeNull(); + }); + + it('shows filtered-empty state when query matches nothing', async () => { + const user = userEvent.setup(); + renderLoaded(); + await user.type(screen.getByRole('searchbox'), 'xyznotfound'); + expect(screen.getByTestId('discovery-empty-state')).toBeInTheDocument(); + expect(screen.getByText(/"xyznotfound"/)).toBeInTheDocument(); + }); + + it('renders a clear-search (X) button when query is non-empty', async () => { + const user = userEvent.setup(); + renderLoaded(); + await user.type(screen.getByRole('searchbox'), 'test'); + expect(screen.getByTestId('clear-search-btn')).toBeInTheDocument(); + }); + + it('clicking clear-search restores full results', async () => { + const user = userEvent.setup(); + renderLoaded(); + await user.type(screen.getByRole('searchbox'), 'xyz'); + expect(screen.getByTestId('discovery-empty-state')).toBeInTheDocument(); + await user.click(screen.getByTestId('clear-search-btn')); + expect(screen.queryByTestId('discovery-empty-state')).toBeNull(); + expect(screen.getByTestId('offering-card-1')).toBeInTheDocument(); + }); + + it('search is case-insensitive', async () => { + const user = userEvent.setup(); + renderLoaded(); + await user.type(screen.getByRole('searchbox'), 'TECHFLOW'); + expect(screen.getByTestId('offering-card-1')).toBeInTheDocument(); + expect(screen.queryByTestId('offering-card-2')).toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 6. InvestorDiscovery – filter toggle +// ───────────────────────────────────────────────────────────────────────────── + +describe('InvestorDiscovery – filter toggle', () => { + it('filter button has aria-pressed=false initially', () => { + renderLoaded(); + expect(screen.getByTestId('filter-toggle-btn')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('clicking filter button sets aria-pressed=true', async () => { + const user = userEvent.setup(); + renderLoaded(); + await user.click(screen.getByTestId('filter-toggle-btn')); + expect(screen.getByTestId('filter-toggle-btn')).toHaveAttribute('aria-pressed', 'true'); + }); + + it('shows "Filters active" indicator when filter is toggled on', async () => { + const user = userEvent.setup(); + renderLoaded(); + await user.click(screen.getByTestId('filter-toggle-btn')); + expect(screen.getByText(/Filters active/i)).toBeInTheDocument(); + }); + + it('shows empty state when filter is active and offerings list would be fully hidden', async () => { + const user = userEvent.setup(); + // Simulate state override: filtered-empty + render( + , + ); + expect(screen.getByTestId('discovery-empty-state')).toBeInTheDocument(); + expect(screen.getByText(/matching the active filters/i)).toBeInTheDocument(); + }); + + it('"Clear all" link in filter indicator calls clear', async () => { + const user = userEvent.setup(); + renderLoaded(); + await user.click(screen.getByTestId('filter-toggle-btn')); + const clearAllLink = screen.getByRole('button', { name: /Clear all active filters/i }); + await user.click(clearAllLink); + // After clearing, filter indicator should disappear + expect(screen.queryByText(/Filters active/i)).toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 7. InvestorDiscovery – __simulateState overrides +// ───────────────────────────────────────────────────────────────────────────── + +describe('InvestorDiscovery – simulated states', () => { + it('shows filtered-empty state via __simulateState', () => { + render( + , + ); + expect(screen.getByTestId('discovery-empty-state')).toBeInTheDocument(); + expect(screen.getByText(/"defi"/)).toBeInTheDocument(); + }); + + it('shows truly-empty state via __simulateState', () => { + render(); + expect(screen.getByTestId('discovery-empty-state')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /No offerings yet/i })).toBeInTheDocument(); + }); + + it('shows error state via __simulateState', () => { + render(); + expect(screen.getByTestId('discovery-error-state')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('error state with retryCount=2 shows retry hint', () => { + render(); + expect(screen.getByText(/Retried 2 times/i)).toBeInTheDocument(); + }); + + it('does NOT show portfolio CTA when state is error', () => { + render(); + expect(screen.queryByRole('heading', { name: /Build Your Portfolio/i })).toBeNull(); + }); + + it('does NOT show portfolio CTA when state is truly-empty', () => { + render(); + expect(screen.queryByRole('heading', { name: /Build Your Portfolio/i })).toBeNull(); + }); + + it('shows loaded state via __simulateState with provided offerings', () => { + render( + , + ); + expect(screen.getByTestId('offering-card-99')).toBeInTheDocument(); + expect(screen.getByText('TestCo')).toBeInTheDocument(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 8. InvestorDiscovery – retry interaction (integrated) +// ───────────────────────────────────────────────────────────────────────────── + +describe('InvestorDiscovery – retry interaction', () => { + it('clicking retry on error state calls handler', async () => { + const user = userEvent.setup(); + render(); + // The retry button exists + const retryBtn = screen.getByTestId('retry-btn'); + expect(retryBtn).toBeInTheDocument(); + // It is clickable + await user.click(retryBtn); + // No crash — integration check + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 9. InvestorDiscovery – clearing filtered-empty state +// ───────────────────────────────────────────────────────────────────────────── + +describe('InvestorDiscovery – clearing filtered-empty state', () => { + it('clear-filters button on empty state resets search and restores cards', async () => { + const user = userEvent.setup(); + renderLoaded(); + // Type a no-match query + await user.type(screen.getByRole('searchbox'), 'zzznothing'); + expect(screen.getByTestId('discovery-empty-state')).toBeInTheDocument(); + // Click clear filters + await user.click(screen.getByTestId('clear-filters-btn')); + // Results restored + expect(screen.queryByTestId('discovery-empty-state')).toBeNull(); + expect(screen.getByTestId('offering-card-1')).toBeInTheDocument(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 10. Result area accessibility +// ───────────────────────────────────────────────────────────────────────────── + +describe('InvestorDiscovery – result area accessibility', () => { + it('result section has aria-label="Offering results"', () => { + renderLoaded(); + expect(screen.getByRole('region', { name: /Offering results/i })).toBeInTheDocument(); + }); + + it('result section has aria-live="polite"', () => { + renderLoaded(); + const section = screen.getByRole('region', { name: /Offering results/i }); + expect(section).toHaveAttribute('aria-live', 'polite'); + }); + + it('error state inside result area is role="alert"', () => { + render(); + const resultSection = screen.getByRole('region', { name: /Offering results/i }); + const alertEl = within(resultSection).getByRole('alert'); + expect(alertEl).toBeInTheDocument(); + }); +}); diff --git a/src/components/InvestorDiscovery.tsx b/src/components/InvestorDiscovery.tsx index ae0819c..a9933d9 100644 --- a/src/components/InvestorDiscovery.tsx +++ b/src/components/InvestorDiscovery.tsx @@ -1,5 +1,19 @@ -import React, { useState, useEffect } from "react"; -import { Search, Filter, Rocket, TrendingUp, ShieldCheck } from "lucide-react"; +import React, { useState, useEffect, useCallback, useId } from "react"; +import { + Search, + Filter, + Rocket, + TrendingUp, + ShieldCheck, + SearchX, + ServerCrash, + RefreshCw, + X, + SlidersHorizontal, + PackageOpen, +} from "lucide-react"; + +// ─── Skeleton Loading Card ───────────────────────────────────────────────────── const SkeletonCard: React.FC = () => ( ); -const DISCOVERY_CARDS = [ - { - icon: Rocket, - title: "TechFlow AI", - subtitle: "Enterprise SaaS • 15% Revenue Share", - target: "$250,000 USDC", - progress: 45, - }, - { - icon: Rocket, - title: "Quantum Ledger", - subtitle: "DeFi Infrastructure • 12% Revenue Share", - target: "$500,000 USDC", - progress: 28, - }, - { - icon: Rocket, - title: "Nexus Pay", - subtitle: "Cross-Border Payments • 18% Revenue Share", - target: "$300,000 USDC", - progress: 62, - }, +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface Offering { + id: number; + name: string; + category: string; + revenueShare: number; + target: number; + raised: number; +} + +/** Discriminated union for all UI states of the discovery result area */ +type DiscoveryState = + | { kind: 'loaded'; offerings: Offering[] } + | { kind: 'filtered-empty'; query: string; hasFilters: boolean } + | { kind: 'truly-empty' } + | { kind: 'error'; retryCount: number }; + +// ─── Mock Data ──────────────────────────────────────────────────────────────── + +const MOCK_OFFERINGS: Offering[] = [ + { id: 1, name: 'TechFlow AI', category: 'Enterprise SaaS', revenueShare: 15, target: 250000, raised: 112500 }, + { id: 2, name: 'Quantum Ledger', category: 'DeFi Infrastructure', revenueShare: 12, target: 500000, raised: 140000 }, + { id: 3, name: 'Nexus Pay', category: 'Cross-Border Payments', revenueShare: 18, target: 300000, raised: 186000 }, ]; -export const InvestorDiscovery: React.FC = () => { - const [isLoading, setIsLoading] = useState(true); - const [hasError, setHasError] = useState(false); +// ─── Empty State ────────────────────────────────────────────────────────────── + +interface EmptyStateProps { + /** 'filtered' = no matches for current query/filters; 'truly-empty' = no offerings exist yet */ + variant: 'filtered' | 'truly-empty'; + query?: string; + onClearFilters: () => void; +} + +/** + * DiscoveryEmptyState + * + * Displayed when the result grid has no items to show. + * Distinguishes two sub-cases: + * - `filtered`: active search/filter produced zero results → offer a reset action + * - `truly-empty`: the platform has no offerings at all yet → encourage exploration + */ +export const DiscoveryEmptyState: React.FC = ({ + variant, + query, + onClearFilters, +}) => { + const headingId = useId(); + const isFiltered = variant === 'filtered'; + + return ( +
+ + +

+ {isFiltered ? 'No offerings match your search' : 'No offerings yet'} +

+ +

+ {isFiltered ? ( + <> + We couldn't find any offerings + {query ? ( + <> + {' '}for{' '} + + "{query}" + + + ) : ( + ' matching the active filters' + )} + . Try adjusting or clearing your search. + + ) : ( + 'New revenue-share offerings will appear here once startups list on Revora. Check back soon.' + )} +

+ + {isFiltered && ( + + )} + + {!isFiltered && ( +
+ +
+ )} +
+ ); +}; + +// ─── Error State ────────────────────────────────────────────────────────────── + +interface ErrorStateProps { + onRetry: () => void; + retryCount?: number; +} + +/** + * DiscoveryErrorState + * + * Displayed when the offering list fails to load. + * Provides a reassuring message and a retry action with an attempt counter. + * Uses role="alert" so screen readers announce the failure immediately. + */ +export const DiscoveryErrorState: React.FC = ({ onRetry, retryCount = 0 }) => { + const headingId = useId(); + + return ( +
+ + +

+ Couldn't load offerings +

+ +

+ Something went wrong while fetching the latest offerings. Your portfolio and account are + unaffected — this is a temporary display issue. +

+ + {retryCount > 0 && ( +

+ Retried {retryCount} {retryCount === 1 ? 'time' : 'times'} — still no luck. +

+ )} + + +
+ ); +}; + +// ─── Offering Card ──────────────────────────────────────────────────────────── + +const OfferingCard: React.FC<{ offering: Offering }> = ({ offering }) => { + const progressPct = Math.round((offering.raised / offering.target) * 100); + + return ( +
+ +
+

{offering.name}

+

+ {offering.category} • {offering.revenueShare}% Revenue Share +

+
+
+
+ Target + ${offering.target.toLocaleString()} USDC +
+
+
+
+
+ +
+ ); +}; + +// ─── Main Component ─────────────────────────────────────────────────────────── + +/** + * InvestorDiscovery + * + * Result area states (in priority order): + * 1. isLoading → Skeleton cards + * 2. error state (hasError / __simulateState) → DiscoveryErrorState + * 3. filtered-empty → DiscoveryEmptyState variant="filtered" + * 4. truly-empty → DiscoveryEmptyState variant="truly-empty" + * 5. loaded → OfferingCard grid + * + * The `__simulateState` prop is provided for test / Storybook overrides only. + */ +export const InvestorDiscovery: React.FC<{ + /** Test/Storybook override — skips loading delay and optionally forces a UI state */ + __simulateState?: DiscoveryState; +}> = ({ __simulateState }) => { + const [isLoading, setIsLoading] = useState(!__simulateState); + const [query, setQuery] = useState(''); + const [filtersActive, setFiltersActive] = useState(false); + const [retryCount, setRetryCount] = useState(0); useEffect(() => { - const timer = setTimeout(() => { - setIsLoading(false); - }, 2000); + if (__simulateState) return; + const timer = setTimeout(() => setIsLoading(false), 2000); return () => clearTimeout(timer); - }, []); + }, [__simulateState]); + + // The seed offerings: either from __simulateState (if kind='loaded') or the + // module-level mock data. Kept stable so useCallback deps don't thrash. + const seedOfferings = + __simulateState?.kind === 'loaded' ? __simulateState.offerings : MOCK_OFFERINGS; + + const resolveState = useCallback((): DiscoveryState => { + // Non-loaded forced states always win (error / empty variants) + if ( + __simulateState && + __simulateState.kind !== 'loaded' + ) { + return __simulateState; + } + + // For 'loaded' __simulateState OR no override: derive from live search/filter + const trimmed = query.trim().toLowerCase(); + const filtered = + trimmed || filtersActive + ? seedOfferings.filter((o) => o.name.toLowerCase().includes(trimmed)) + : seedOfferings; + + if (seedOfferings.length === 0) return { kind: 'truly-empty' }; + if (filtered.length === 0) return { kind: 'filtered-empty', query: trimmed, hasFilters: filtersActive }; + return { kind: 'loaded', offerings: filtered }; + }, [query, filtersActive, __simulateState, seedOfferings]); + + const state = resolveState(); + + const handleClearFilters = () => { + setQuery(''); + setFiltersActive(false); + setRetryCount(0); + }; + + const handleRetry = () => { + setRetryCount((c) => c + 1); + }; + + const handleToggleFilters = () => { + setFiltersActive((f) => !f); + }; return (
- {/* Information Architecture: Header & Discovery Intent */} + {/* ── Page header ── */}
-

- Discover Offerings -

+

Discover Offerings

Explore high-potential RevenueShare offerings on Stellar.

+ {/* ── Search + Filter bar ── */}
- +
-
- {/* Loading State: Skeleton Cards */} + {/* ── Active filter indicator ── */} + {filtersActive && ( +
+
+ )} + + {/* ── Loading State: Skeleton Cards ── */} {isLoading && (
{
)} - {/* Error State */} - {!isLoading && hasError && ( -
- -

Unable to Load Offerings

-

- We couldn't fetch the latest opportunities. Please try again later. -

- -
- )} - - {/* Loaded State: Discovery Cards Pattern */} - {!isLoading && !hasError && ( -
- {DISCOVERY_CARDS.map((card, index) => ( + {/* ── Result area (shown after loading) ── */} + {!isLoading && ( +
+ {state.kind === 'loaded' && (
-
- -
-
-

{card.title}

-

{card.subtitle}

-
-
-
- Target - {card.target} -
-
-
-
-
- + {state.offerings.map((o) => ( + + ))}
- ))} -
+ )} + + {state.kind === 'filtered-empty' && ( + + )} + + {state.kind === 'truly-empty' && ( + + )} + + {state.kind === 'error' && ( + + )} +
)} - {/* Empty State / Call to Action IA */} -
- -

Build Your Portfolio

-

- Start investing in verified startups. All distributions are handled - automatically via Soroban smart contracts. -

-
- + {/* ── Portfolio CTA (loaded state only, after loading) ── */} + {!isLoading && state.kind === 'loaded' && ( +
+
-
+ )}
); }; diff --git a/src/index.css b/src/index.css index e2ed858..f2565ad 100644 --- a/src/index.css +++ b/src/index.css @@ -27,12 +27,45 @@ --primary: #3b82f6; --primary-hover: #2563eb; - --funding-bar-track: rgba(148, 163, 184, 0.18); --text-main: #e5e7eb; --text-muted: #cbd5e1; /* Improved contrast for WCAG AA on dark backgrounds */ --text-accent: #38bdf8; --error: #ef4444; --success: #10b981; + + /* ─── Auth Layout Design Tokens (Issue #80) ──────────────────────────── */ + /* Typography scale for AuthLayout title / subtitle / helperText slots */ + --auth-title-size: clamp(1.5rem, 4vw, 1.875rem); /* 24px → 30px fluid */ + --auth-title-weight: 700; + --auth-title-tracking: -0.025em; + --auth-title-lh: 1.2; + + --auth-subtitle-size: 0.9375rem; /* 15px – one step below body */ + --auth-subtitle-lh: 1.5; + + --auth-helper-size: 0.8125rem; /* 13px – fine print / security notes */ + --auth-helper-lh: 1.5; + + /* Card container sizing */ + --auth-card-max-w: 480px; + --auth-card-padding-x: clamp(1.25rem, 5vw, 2.5rem); /* 20px → 40px */ + --auth-card-padding-y: clamp(1.5rem, 4vw, 2.5rem); /* 24px → 40px */ + + /* Header section spacing */ + --auth-header-gap: 0.5rem; /* gap between title, subtitle, helperText */ + --auth-header-mb: 2rem; /* margin below header, above form */ + + /* ─── Discovery Empty/Error State Tokens (Issue #107) ──────────────────── */ + --ds-empty-icon-bg: rgba(59, 130, 246, 0.08); /* primary-tinted icon well (empty) */ + --ds-empty-icon-fg: var(--primary); /* icon stroke colour (empty) */ + --ds-error-icon-bg: rgba(239, 68, 68, 0.08); /* error-tinted icon well */ + --ds-error-icon-fg: var(--error); /* icon stroke colour (error) */ + --ds-state-gap: 1.25rem; /* vertical gap between state slots */ + --ds-state-max-w: 28rem; /* constrains text column */ + --ds-state-pad-y: 3.5rem; /* top/bottom breathing room */ + --ds-state-pad-x: 2rem; /* side padding on narrow screens */ + --ds-state-icon-size: 4.5rem; /* icon well diameter */ + --ds-filter-badge-size: 0.5rem; /* active-filter dot size */ } * { @@ -69,160 +102,55 @@ body { transform: translateY(-2px); } -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; +.btn-primary { + background: var(--primary); + color: white; + border: none; padding: 0.75rem 1.5rem; border-radius: 0.75rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; width: 100%; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; } -/* Width behavior */ -.btn--block { width: 100%; } -.btn--auto { width: auto; } - -/* Sizes */ -.btn--sm { padding: 0.375rem 0.75rem; font-size: 0.875rem; border-radius: 0.5rem; } -.btn--md { padding: 0.75rem 1.25rem; font-size: 1rem; border-radius: 0.75rem; } -.btn--lg { padding: 1rem 1.75rem; font-size: 1.125rem; border-radius: 1rem; } - -/* Icon-only */ -.btn--icon { padding: 0.5rem; width: auto; height: auto; border-radius: 0.5rem; } -.btn--icon svg { display: block; } +.btn-primary:hover { + background: var(--primary-hover); + transform: translateY(-1px); +} -/* Variants */ -.btn--primary { - background: var(--primary); - color: white; - border: none; +.btn-primary:focus-visible, +.btn-secondary:focus-visible { + outline: 2px solid white; + outline-offset: 2px; + border-radius: 0.75rem; } -.btn--primary:hover { background: var(--primary-hover); transform: translateY(-1px); } -.btn--primary:active { transform: translateY(0); } -.btn--secondary { - background: rgba(148, 163, 184, 0.08); +.btn-secondary { + background: rgba(148, 163, 184, 0.1); color: var(--text-main); border: 1px solid var(--glass-border); + padding: 0.75rem 1.5rem; + border-radius: 0.75rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; width: 100%; - display: inline-flex; + display: flex; align-items: center; justify-content: center; gap: 0.5rem; - min-height: 44px; - font-size: 1rem; -} - -/* Focus styles - meet WCAG 2.1 AA */ -.btn:focus-visible { - outline: 2px solid white; - outline-offset: 4px; - box-shadow: 0 0 0 4px rgba(59,130,246,0.25); -} - -/* Backwards-compatible aliases for existing code */ -.btn-primary { composes: btn btn--primary btn--block; } -.btn-secondary { composes: btn btn--secondary btn--block; } - -/* Fallback for environments that don't understand `composes` (plain CSS) */ -.btn-primary { background: var(--primary); color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 0.75rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; width: 100%; } -.btn-primary:hover { background: var(--primary-hover); transform: translateY(-1px); } -.btn-secondary { background: rgba(148, 163, 184, 0.1); color: var(--text-main); border: 1px solid var(--glass-border); padding: 0.75rem 1.5rem; border-radius: 0.75rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem; } -.btn-secondary:hover { background: rgba(148, 163, 184, 0.2); } - -/* Screen-reader only helper */ -.sr-only { - position: absolute !important; - width: 1px !important; - height: 1px !important; - padding: 0 !important; - margin: -1px !important; - overflow: hidden !important; - clip: rect(0, 0, 0, 0) !important; - white-space: nowrap !important; - border: 0 !important; -} - -/* ---- BUTTON STATES ---- */ - -.btn-loading { - cursor: wait; } -.btn-disabled { - opacity: 0.55; - cursor: not-allowed; +.btn-secondary:hover { + background: rgba(148, 163, 184, 0.2); } -.btn-disabled:focus-visible { - outline: 2px solid rgba(255, 255, 255, 0.4); +.btn-secondary:focus { + outline: 2px solid var(--text-main); outline-offset: 2px; } -.btn-primary.btn-disabled:hover { - background: var(--primary); - transform: none; - cursor: not-allowed; -} - -.btn-secondary.btn-disabled:hover { - background: rgba(148, 163, 184, 0.1); - transform: none; - cursor: not-allowed; -} - -.btn-success { - background: var(--success) !important; - cursor: default; -} - -.btn-success:hover { - transform: none !important; -} - -.btn-spinner { - animation: btn-spin 0.6s linear infinite; -} - -.btn-check-icon { - flex-shrink: 0; -} - -@keyframes btn-spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -@media (prefers-reduced-motion: reduce) { - .btn-spinner { - animation: none; - } -} - -/* Screen-reader only utility */ -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; -} - .input-group { margin-bottom: 1.25rem; } @@ -236,7 +164,6 @@ body { } .input-field { - outline: none; width: 100%; background: rgba(15, 23, 42, 0.5); border: 1px solid var(--glass-border); @@ -272,22 +199,6 @@ a:focus-visible { border-radius: 2px; } -.link-styled { - color: var(--primary); - text-decoration: none; - transition: color 0.2s ease; - display: inline-block; - padding: 0.25rem 0.5rem; - min-height: 44px; - min-width: 44px; - display: flex; - align-items: center; -} - -.link-styled:hover { - color: var(--primary-hover); -} - .input-field.input-error { border-color: var(--error); } @@ -302,34 +213,14 @@ a:focus-visible { } .animate-shake { - animation: shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; -} - -@media (prefers-reduced-motion: reduce) { - .animate-shake { - animation: none; - transform: none; - } - - .animate-fade-in { - animation: none; - opacity: 1; - } + animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both; } @keyframes shake { - 0%, - 100% { - transform: translateX(0); - } - 20%, - 60% { - transform: translateX(-2px); - } - 40%, - 80% { - transform: translateX(2px); - } + 10%, 90% { transform: translate3d(-1px, 0, 0); } + 20%, 80% { transform: translate3d(2px, 0, 0); } + 30%, 50%, 70% { transform: translate3d(-3px, 0, 0); } + 40%, 60% { transform: translate3d(3px, 0, 0); } } @keyframes fadeIn { @@ -343,28 +234,9 @@ a:focus-visible { } } -@media (prefers-reduced-motion: reduce) { - /* Disable non-essential motion while preserving state and feedback. - Fallbacks for .animate-shake use existing error signaling: - - red border - - error icon - - error text - */ - .animate-fade-in, - .animate-shake { - animation: none !important; - } - - .glass-card-interactive:hover, - .btn-primary:hover, - .btn-secondary:hover { - transform: none !important; - } -} - /* Accessibility: Focus rings for interactive elements */ -a:focus-visible, -button:focus-visible, +a:focus-visible, +button:focus-visible, input:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--primary); @@ -372,43 +244,6 @@ input:focus-visible, border-radius: 0.25rem; } -/* Accessibility: Screen-reader-only text (not display:none) */ -.sr-only { - position: absolute !important; - width: 1px !important; - height: 1px !important; - padding: 0 !important; - margin: -1px !important; - overflow: hidden !important; - clip: rect(0, 0, 0, 0) !important; - white-space: nowrap !important; - border: 0 !important; -} - -/* Accessibility: Skip-to-content link (hidden until focused) */ -.skip-link { - position: absolute; - top: 0.75rem; - left: 0.75rem; - z-index: 10000; - padding: 0.75rem 1rem; - background: #ffffff; - color: #0b1220; - border-radius: 0.75rem; - text-decoration: none; - font-weight: 600; - box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45); - transform: translateY(-200%); - transition: transform 0.15s ease; -} - -.skip-link:focus, -.skip-link:focus-visible { - transform: translateY(0); - outline: 2px solid var(--primary); - outline-offset: 2px; -} - /* Utilities for accessibility and consistency */ .text-primary { color: var(--primary); @@ -453,73 +288,242 @@ input:focus-visible, .text-success { color: var(--success); } .text-error { color: var(--error); } -/* Skeleton / Loading States */ -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } +/* ─── AuthLayout Component Styles (Issue #80) ───────────────────────────── + Implements the formalized design-system type scale and responsive card. + Tokens are defined in :root above under "Auth Layout Design Tokens". + ───────────────────────────────────────────────────────────────────────── */ + +/** + * .auth-layout-outer + * Full-viewport centring wrapper. Adds safe-area-aware horizontal padding + * so content is never clipped at 320px or on notched devices. + */ +.auth-layout-outer { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + /* horizontal safe-area inset ensures usability on notched devices */ + padding: + 1.5rem + max(env(safe-area-inset-right, 0px), 1rem) + 1.5rem + max(env(safe-area-inset-left, 0px), 1rem); +} + +/** + * .auth-card + * The glassmorphic card container. Uses fluid padding tokens so the card + * breathes at desktop widths yet stays comfortable at 320px. + */ +.auth-card { + width: 100%; + max-width: var(--auth-card-max-w); + padding: var(--auth-card-padding-y) var(--auth-card-padding-x); } -.skeleton-pulse { - background: linear-gradient( - 90deg, - rgba(148, 163, 184, 0.06) 25%, - rgba(148, 163, 184, 0.15) 50%, - rgba(148, 163, 184, 0.06) 75% - ); - background-size: 200% 100%; - animation: shimmer 1.5s ease-in-out infinite; - border-radius: 0.5rem; +/** + * .auth-header + * Groups the title / subtitle / helperText with a consistent column gap + * and a bottom margin separating it from the form body. + */ +.auth-header { + display: flex; + flex-direction: column; + gap: var(--auth-header-gap); + margin-bottom: var(--auth-header-mb); +} + +/** + * .auth-title [Design Token: --auth-title-*] + * Page-level heading. Fluid font-size scales from 24px (320px viewport) + * to 30px (≥600px) via clamp(). Tight tracking improves legibility at + * large sizes. Single

per page — enforced by AuthLayout API. + */ +.auth-title { + font-size: var(--auth-title-size); + font-weight: var(--auth-title-weight); + letter-spacing: var(--auth-title-tracking); + line-height: var(--auth-title-lh); + color: var(--text-main); + /* Long unbreakable strings (e.g. very long company names) wrap cleanly */ + overflow-wrap: break-word; + word-break: break-word; +} + +/** + * .auth-subtitle [Design Token: --auth-subtitle-*] + * One-liner description beneath the title. Slightly muted to create + * visual hierarchy without excessive contrast reduction. + */ +.auth-subtitle { + font-size: var(--auth-subtitle-size); + line-height: var(--auth-subtitle-lh); + color: var(--text-muted); + overflow-wrap: break-word; } -.skeleton-pulse-sm { - height: 0.75rem; - width: 60%; +/** + * .auth-helper [Design Token: --auth-helper-*] + * Tertiary fine-print slot (security disclaimers, hints). Slightly + * smaller than subtitle; maintains ≥4.5:1 contrast on the dark bg. + */ +.auth-helper { + font-size: var(--auth-helper-size); + line-height: var(--auth-helper-lh); + color: var(--text-muted); + overflow-wrap: break-word; +} + +/** + * .auth-body + * Slot that wraps the form / non-header children. Isolated so page-level + * spacing (e.g. space-y-4 in form) never bleeds into header margins. + */ +.auth-body { + /* intentionally unstyled – children control their own spacing */ +} + +/* ─── Discovery Empty & Error State Styles (Issue #107) ──────────────────── + Components: DiscoveryEmptyState, DiscoveryErrorState + Tokens: --ds-* (defined in :root above) + ─────────────────────────────────────────────────────────────────────────── */ + +/** + * .discovery-state-container + * Shared centred column layout used by both empty and error states. + * Rendered inside a
so changes are announced to + * screen readers without a page reload. + */ +.discovery-state-container { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--ds-state-gap); + padding: var(--ds-state-pad-y) var(--ds-state-pad-x); + /* Inherit the glass-card look from the surrounding section */ + background: var(--glass-bg); + border: 1px solid var(--glass-border); + backdrop-filter: var(--glass-blur); + border-radius: 1.5rem; + box-shadow: var(--shadow-xl); } -.skeleton-pulse-md { - height: 1rem; - width: 80%; +/** + * .discovery-state-icon-wrap + * Circular icon container. Uses variant modifier for colour theming. + */ +.discovery-state-icon-wrap { + width: var(--ds-state-icon-size); + height: var(--ds-state-icon-size); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; } -.skeleton-pulse-lg { - height: 1.25rem; - width: 50%; +.discovery-state-icon-wrap--empty { + background: var(--ds-empty-icon-bg); + color: var(--ds-empty-icon-fg); + border: 1px solid rgba(59, 130, 246, 0.18); } -.skeleton-icon { - width: 3rem; - height: 3rem; - border-radius: 0.75rem; +.discovery-state-icon-wrap--error { + background: var(--ds-error-icon-bg); + color: var(--ds-error-icon-fg); + border: 1px solid rgba(239, 68, 68, 0.18); } -.skeleton-bar { - height: 0.375rem; - width: 100%; - border-radius: 9999px; +/** + * .discovery-state-title + *

inside the state cards — one level below the page

. + * 20px / semi-bold to maintain hierarchy without competing with the + * "Discover Offerings" heading. + */ +.discovery-state-title { + font-size: 1.25rem; + font-weight: 600; + line-height: 1.3; + color: var(--text-main); } -.skeleton-button { - height: 2rem; - width: 100%; - border-radius: 0.75rem; +/** + * .discovery-state-body + * Supporting copy. 14px, muted — passes ≥4.5:1 contrast on the dark bg. + */ +.discovery-state-body { + font-size: 0.875rem; + line-height: 1.6; + color: var(--text-muted); + max-width: var(--ds-state-max-w); } -@media (prefers-reduced-motion: reduce) { - .skeleton-pulse { - animation: none; - background: rgba(148, 163, 184, 0.08); - } +/** + * .discovery-state-query + * Inline highlight for the echoed search term inside the body copy. + * Uses accent colour + subtle bg to make the term scannable. + */ +.discovery-state-query { + color: var(--text-accent); + font-weight: 500; + background: rgba(56, 189, 248, 0.08); + padding: 0 0.25em; + border-radius: 0.2em; +} + +/** + * .discovery-state-retry-hint + * Small supplementary text shown when retries > 0 on the error state. + * 13px to sit clearly below the body copy in the visual hierarchy. + */ +.discovery-state-retry-hint { + font-size: 0.8125rem; + color: var(--text-muted); + font-style: italic; } -/* Loading announcement - visually hidden but accessible */ -.sr-only { +/** + * .discovery-state-action + * Shared sizing override for CTA buttons inside state views. + * Forces consistent minimum width and centers the icon+label pair. + */ +.discovery-state-action { + width: auto; + min-width: 9rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +/** + * .discovery-filter-btn--active + * Visual indicator that filters are currently active. + * Brightens the border to match --glass-border-bright. + */ +.discovery-filter-btn--active { + border-color: var(--glass-border-bright); + color: var(--text-accent); + position: relative; +} + +/** + * .discovery-filter-badge + * Small dot overlaid on the filter button when filters are active. + * Matches the accent colour for visual consistency. + */ +.discovery-filter-badge { position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; + top: 0.375rem; + right: 0.375rem; + width: var(--ds-filter-badge-size); + height: var(--ds-filter-badge-size); + background: var(--text-accent); + border-radius: 50%; + pointer-events: none; } \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index d64631c..d2622ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,8 +21,18 @@ export default defineConfig({ 'src/components/AuthLayout.tsx', 'src/components/Button.tsx', 'src/components/PasswordStrength.tsx', - 'src/utils/passwordStrength.ts' - ] + 'src/utils/passwordStrength.ts', + 'src/components/InvestorDiscovery.tsx', + 'src/components/InvestorDiscovery.test.tsx', + ], + thresholds: { + 'src/components/InvestorDiscovery.tsx': { + branches: 95, + functions: 95, + lines: 95, + statements: 95, + }, + } } } });