diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 00000000..e77c0f2e --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,111 @@ +# Migration Guide + +Human-readable migration instructions for Stellar Dev Dashboard component library changes. +The interactive version with before/after code examples lives in Storybook: +**Design System / Migration**. + +Full changelog: `docs/api/CHANGELOG.md`. + +--- + +## Version History + +### v0.1.0 (June 2025) — Initial Release + +First public release. No migration required. + +**Added:** +- Dashboard shell: Sidebar, MobileHeader, MobileSidebar, DashboardGrid +- Core components: Card, StatCard, CopyableValue, ThemeToggle +- Chart suite: NetworkMetricsChart, BalanceHistoryChart, AccountActivityChart +- Accessibility: AccessibilitySettings, ScreenReaderAnnouncer, KeyboardNavigation +- Network: NetworkIndicator, OfflineBanner, RetryButton +- Assets: AssetCard, AssetDiscovery +- Forms: ValidatedInput +- Design system: tokens, colors, spacing, typography, variants +- Storybook 8.5 with a11y, viewport addons, and dark/light theme toolbar + +--- + +## Breaking Changes + +No breaking changes in v0.1.0. + +Future breaking changes will be documented here with migration steps and, where possible, +a codemod command. + +--- + +## Active Migration Tracks + +### JavaScript → TypeScript + +The codebase is migrating from `.jsx` to `.tsx`. New components **must** be TypeScript. + +**Steps for converting an existing component:** + +1. Rename the file from `.jsx` to `.tsx`. +2. Add prop types using interfaces from `src/types/components.ts` where they exist. +3. Run `npm run type-check` to surface type errors. +4. Fix errors — the `tsconfig.json` has `allowJs: true` so no config changes are needed. +5. Update any `.stories.tsx` imports if the file extension changed. + +```ts +// src/types/components.ts — add your interface +export interface MyComponentProps { + title: string; + onAction?: () => void; +} + +// src/components/dashboard/MyComponent.tsx +import type { MyComponentProps } from '../../types/components'; + +export default function MyComponent({ title, onAction }: MyComponentProps) { … } +``` + +--- + +### Inline Styles → CSS Custom Properties + +Components should use `var(--token)` rather than hard-coded hex values. + +```jsx +// ❌ Before +
+ +// ✅ After +
+``` + +Reference the full token list in `docs/design-system.md` or the **Design System / Tokens** +Storybook story. + +Do **not** remove legacy token aliases until all call sites are migrated. + +--- + +### Adopting the Variant System + +New component variants should live in `src/design-system/variants.ts`, not as ad-hoc inline +style objects scattered across component files. + +```ts +// src/design-system/variants.ts — add to the relevant group +{ + key: 'ghost', + label: 'Ghost', + description: 'Transparent background, border only. For tertiary actions.', + composition: ['border.default', 'text.primary', 'radii.sm'], + status: 'planned', // → 'ready' once implemented +} +``` + +--- + +## Adding a Breaking Change (for maintainers) + +1. Add to `docs/api/CHANGELOG.md` under `## Breaking Changes` for the next version. +2. Add a migration entry to `stories/Migration.stories.tsx`. +3. If automatable, provide a codemod: `npx codemod `. +4. Keep legacy aliases active for at least one minor version before removing them. +5. Update this document. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 00000000..60265f0d --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,124 @@ +# Component Performance Guide + +Reference for component render budgets, bundle size targets, and optimization patterns. +The interactive version of this guide lives in Storybook: **Design System / Performance**. + +--- + +## Build Budget + +| Metric | Target | Enforcement | +|--------|--------|-------------| +| Total bundle (gzipped) | ≤ 500 KB | `scripts/check-coverage.mjs` in CI | +| Lighthouse Performance | ≥ 90 | `npm run test:lighthouse` | +| LCP (largest contentful paint) | < 2.5 s | Lighthouse CI | +| CLS (cumulative layout shift) | < 0.1 | Playwright visual tests | + +--- + +## Bundle Size by Component Group + +| Component | Gzipped | Notes | +|-----------|---------|-------| +| Card / StatCard | ~1 KB | Inline styles only | +| CopyableValue | ~1 KB | Single clipboard effect | +| ThemeToggle | ~2 KB | lucide-react icon + Zustand read | +| NetworkIndicator | ~2 KB | Pure display | +| ValidatedInput | ~3 KB | Self-contained validation | +| BottomSheet | ~4 KB | `useResponsive` + touch gestures | +| ResponsiveContainer | ~4 KB | Three exported components | +| AssetCard | ~3 KB | No chart deps | +| NetworkMetricsChart | ~8 KB | Recharts (shared chunk) | +| BalanceHistoryChart | ~6 KB | Recharts (shared chunk) | +| TransactionBuilder | ~28 KB | stellar-sdk; **must be lazy-loaded** | +| ContractInteraction | ~24 KB | soroban-client; **must be lazy-loaded** | +| D3VisualizationSuite | ~35 KB | Full D3 force graph; **lazy-loaded on demand** | + +--- + +## Render Time Targets + +| Category | Budget | Examples | +|----------|--------|---------| +| Pure UI components | < 1 ms | Card, StatCard, CopyableValue, ThemeToggle, ValidatedInput | +| Layout components | < 5 ms | Sidebar, MobileHeader, BottomSheet, ResponsiveContainer | +| Chart components | < 50 ms | NetworkMetricsChart, BalanceHistoryChart | +| Feature panels (API-dependent) | < 100 ms to first paint | Overview, Account, Transactions | +| Heavy editors | < 200 ms after lazy load | TransactionBuilder, ContractInteraction | + +--- + +## Optimization Patterns + +### 1. Code Split Heavy Components + +```jsx +// ✅ Required for any component > 10 KB +const TransactionBuilder = React.lazy(() => + import('./components/dashboard/TransactionBuilder') +); + +}> + + +``` + +### 2. Virtualize Long Lists + +`src/components/common/VirtualList.jsx` is available for lists with > 50 items. + +```jsx +import VirtualList from './components/common/VirtualList'; + + } +/> +``` + +### 3. Memoize Stable References + +Only memoize after profiling confirms a performance problem. + +```jsx +const chartData = useMemo( + () => buildChartSeries(rawLedgers), + [rawLedgers] +); +``` + +### 4. Debounce Live Queries + +```jsx +const debouncedQuery = useDebounce(query, 300); + +useEffect(() => { + if (debouncedQuery) fetchAssets(debouncedQuery); +}, [debouncedQuery]); +``` + +### 5. Respect prefers-reduced-motion + +```jsx +const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; +const duration = prefersReduced ? 0 : tokens.motion.duration.normal; +``` + +The `AccessibilityContext` exposes a `reducedMotion` flag — use it in components +rather than reading the media query directly. + +--- + +## Profiling + +```bash +# Lighthouse CI (requires a local build) +npm run test:lighthouse + +# Bundle analysis +npm run build:analyze + +# Vitest coverage +npm run test:coverage +``` diff --git a/docs/design-system.md b/docs/design-system.md index 0fa36ddd..f0a69e8a 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,72 +1,228 @@ # Design System -This document tracks the dashboard design system in five layers: +This document is the authoritative reference for the Stellar Dev Dashboard design system. +The interactive token catalog, variant browser, accessibility guidelines, performance budgets, +and migration guides all live in Storybook under the **Design System** section — run +`npm run storybook` and navigate to **Design System / Tokens**. -1. Design tokens -2. Variants -3. Consistency -4. Documentation -5. Migration +--- -## 1. Design Tokens +## Overview + +The design system has five layers: -Tokens are the source of truth for color, spacing, and typography. +| Layer | Purpose | Source | +|-------|---------|--------| +| **Design Tokens** | Single source of truth for color, spacing, typography, radii, motion | `src/design-system/` | +| **Variants** | Explicit, composable component styles built from tokens | `src/design-system/variants.ts` | +| **Consistency** | Automated lint, snapshot, and CI checks | ESLint + Playwright | +| **Documentation** | Storybook-first component docs | `stories/` | +| **Migration** | Versioned guides for breaking changes | `docs/api/CHANGELOG.md` + Storybook | -- Color tokens should expose semantic names for brand, surface, text, and borders. -- Spacing tokens should stay on a small, predictable scale. -- Typography tokens should define font families, weights, sizes, and line heights. +--- -Current implementation lives in: +## 1. Design Tokens -- `src/design-system/tokens.ts` -- `src/design-system/colors.ts` -- `src/design-system/spacing.ts` -- `src/design-system/typography.ts` +Tokens are the single source of truth. They are defined in TypeScript and consumed +via CSS custom properties at runtime. + +### Token Files + +| File | Exports | Description | +|------|---------|-------------| +| `src/design-system/tokens.ts` | `tokens` | Master token object — colors, spacing, typography, radii, motion | +| `src/design-system/colors.ts` | `colors` | `tokens.colors` re-export | +| `src/design-system/spacing.ts` | `spacing` | `tokens.spacing` re-export | +| `src/design-system/typography.ts` | `typography` | `tokens.typography` re-export | +| `src/design-system/variants.ts` | `variantSystem` | Component variant catalog | +| `src/design-system/index.ts` | all above | Barrel export | + +### Color Tokens + +Semantic names prevent hard-coded hex values from leaking into components. + +```ts +import { colors } from 'src/design-system'; + +// ✅ Use semantic tokens +colors.surface.card // '#0f172a' +colors.text.primary // '#f8fafc' +colors.semantic.success // '#28a745' +colors.border.default // 'rgba(148,163,184,0.22)' +``` + +At runtime, components use CSS custom properties so they theme-switch automatically: + +```css +/* src/styles/globals.css — dark theme (default) */ +:root, [data-theme='dark'] { + --bg-base: #08111f; + --bg-card: #0f172a; + --bg-elevated: #111827; + --cyan: #00e5ff; + --green: #22c55e; + --red: #ef4444; + --amber: #f59e0b; + --text-primary: #f8fafc; + --text-muted: #94a3b8; + --border: rgba(148,163,184,0.22); + --font-sans: 'Inter', system-ui, sans-serif; + --font-display: 'Syne', sans-serif; + --font-mono: 'Space Mono', monospace; +} +``` + +### Spacing Scale + +``` +xs → 4px +sm → 8px +md → 16px +lg → 24px +xl → 32px +xxl → 48px +``` + +Layout-specific aliases: `spacing.layout.page = 24px`, `.section = 32px`, `.card = 16px`. + +### Typography + +| Token | Value | +|-------|-------| +| `typography.fontFamily.display` | `'Syne, sans-serif'` | +| `typography.fontFamily.body` | `'Inter, system-ui, sans-serif'` | +| `typography.fontFamily.mono` | `'Space Mono, monospace'` | +| `typography.fontSize.xs` | `12px` | +| `typography.fontSize.sm` | `14px` | +| `typography.fontSize.md` | `16px` | +| `typography.fontSize.lg` | `20px` | +| `typography.fontSize.xl` | `24px` | + +### Border Radii + +``` +xs → 4px (chips, small badges) +sm → 8px (inputs, buttons) +md → 12px (cards, dropdowns) +lg → 16px (panels, modals) +xl → 24px (large surfaces) +pill → 999px (network badges, tags) +``` + +### Motion + +| Token | Value | Use | +|-------|-------|-----| +| `motion.duration.fast` | `120ms` | Hover, focus rings | +| `motion.duration.normal` | `180ms` | Transitions, open/close | +| `motion.duration.slow` | `280ms` | Full panel animations | +| `motion.easing.standard` | `cubic-bezier(0.2,0,0,1)` | Most transitions | +| `motion.easing.emphasized` | `cubic-bezier(0.2,0.8,0.2,1)` | Emphasized entrances | + +Always check `prefers-reduced-motion` and set duration to `0` when it is active. + +--- ## 2. Variants -Component variants should be explicit, predictable, and composable. +Component variants are defined in `src/design-system/variants.ts` as a `VariantGroup[]`. +Each `VariantDefinition` records which tokens compose the variant, so changing a token +cascades automatically. + +```ts +import { variantSystem } from 'src/design-system'; -- Variant system: one catalog per component family. -- Component variants: primary, secondary, destructive, and state-specific styles. -- Variant composition: build new variants from tokens instead of adding one-off CSS. +// Find a specific variant +const primaryButton = variantSystem + .find(g => g.key === 'buttons') + ?.variants.find(v => v.key === 'primary'); +``` -Current implementation lives in: +Variant statuses: +- **ready** — implemented and tested +- **guidance** — design intent documented; component may not yet enforce it +- **planned** — reserved for a future implementation -- `src/design-system/variants.ts` +See the **Design System / Component Variants** story in Storybook for the interactive catalog. + +--- ## 3. Consistency -Consistency comes from automation, not memory. +Consistency is enforced by automation, not convention. + +### Linting + +```bash +npm run lint # ESLint — naming conventions, import rules, unused vars +npm run type-check # TypeScript strict mode (src/lib/**) +``` + +### Visual Testing -- Automated checks for token usage and component drift. -- Linting rules for naming, import usage, and approved patterns. -- CI validation for screenshots and visual regressions. +```bash +npm run test:visual # Playwright visual snapshots +npm run test:visual:update # Update snapshots after intentional changes +npm run test:chromatic # Chromatic cloud snapshots (CI) +``` -Recommended checks: +### CI Checks -- ESLint rules for naming and style drift -- Snapshot or visual tests for shared components -- CI validation for token and component changes +Every PR runs: +1. ESLint + Prettier +2. TypeScript type check +3. Unit tests + coverage gate +4. Playwright visual and a11y tests +5. Bundle size check (500 KB gzip limit) +6. Storybook build validation + +--- ## 4. Documentation -Documentation should help people choose the right pattern quickly. +Documentation is Storybook-first. Every public component has a story that shows: + +- All visual states (default, loading, error, empty, disabled) +- Dark and light theme +- Mobile, tablet, and desktop viewports +- axe-core accessibility pass (visible in the A11y panel) +- Prop table (via `argTypes` or the TypeScript interface) + +Story files live at: +- `stories/*.stories.tsx` — standalone stories +- `src/components/**/*.stories.tsx` — co-located stories (scanned by Storybook) + +### Adding a New Component Story -- Design system docs: explain the system and where source files live. -- Component docs: show props, variants, and examples. -- Usage guidelines: explain when to compose, extend, or reuse. +1. Create `stories/MyComponent.stories.tsx`. +2. Set `title` to match the component's group (e.g., `'Layout/MyComponent'`). +3. Add `parameters.docs.description.component` to explain what the component does. +4. Export named stories for: Default, Loading (if async), Error state, Mobile viewport. +5. If the component depends on Zustand or external APIs, use a standalone replica + (see `ThemeToggle.stories.tsx` for the pattern). + +--- ## 5. Migration -Migration should be versioned and boring. +Migration guides are documented in two places: + +1. **`docs/api/CHANGELOG.md`** — machine-readable changelog. +2. **Design System / Migration story in Storybook** — human-readable guides with + before/after code examples. + +When introducing a breaking change: + +1. Add an entry to `CHANGELOG.md` under `## Breaking Changes`. +2. Add a migration entry to `stories/Migration.stories.tsx`. +3. If the change is automatable, provide a codemod command. +4. Keep legacy token aliases active until all call sites are migrated. -- Migration guides should explain how to move from the old pattern to the new one. -- Breaking changes should be listed before release. -- Version tracking should link design-system changes to release notes or changelog entries. +--- ## Maintenance Notes -- Keep legacy token aliases until call sites have been migrated. +- Keep legacy token aliases until all call sites have been migrated. - Prefer composition over new variants unless the new style has a clear reusable purpose. -- Update docs whenever a token, variant, or rule changes. +- Update this document and the relevant Storybook story whenever a token, variant, or rule changes. +- Run `npm run storybook` to verify changes visually before pushing. diff --git a/stories/AssetComponents.stories.tsx b/stories/AssetComponents.stories.tsx new file mode 100644 index 00000000..63b34510 --- /dev/null +++ b/stories/AssetComponents.stories.tsx @@ -0,0 +1,568 @@ +/** + * D-028 — Asset component stories. + * + * AssetCard and AssetDiscovery depend on Zustand store and live Horizon API calls. + * Stories use static replicas to demonstrate all documented UI states without + * network I/O, while referencing the real component file for import context. + */ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { TrendingUp, TrendingDown, Star, Plus, ExternalLink, Search, Filter, X } from 'lucide-react'; + +const meta: Meta = { + title: 'Assets', + parameters: { + docs: { + description: { + component: + 'Asset discovery and display components. Real components live at `src/components/assets/`. These stories use isolated replicas to avoid Zustand / Horizon API dependencies.', + }, + }, + }, +}; +export default meta; + +// ─── Data fixtures ──────────────────────────────────────────────────────────── + +interface MockAsset { + code: string; + issuer: string; + issuerShort: string; + balance?: string; + price?: number; + change24h?: number; + domain?: string; + trusted?: boolean; + popular?: boolean; +} + +const ASSETS: MockAsset[] = [ + { + code: 'XLM', + issuer: 'native', + issuerShort: 'Native', + balance: '12,345.00', + price: 0.1134, + change24h: 3.21, + domain: 'stellar.org', + trusted: true, + popular: true, + }, + { + code: 'USDC', + issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + issuerShort: 'Centre.io', + balance: '500.00', + price: 1.0001, + change24h: 0.01, + domain: 'centre.io', + trusted: true, + popular: true, + }, + { + code: 'yXLM', + issuer: 'GARDNV3Q7YGT4AKSDF25LT32YSCCW4EV22Y2TV3I2PU2MMEJCE4SGTSA', + issuerShort: 'Ultra Capital', + balance: '234.50', + price: 0.1089, + change24h: -1.42, + domain: 'ultracapital.xyz', + trusted: true, + popular: false, + }, + { + code: 'SHX', + issuer: 'GDSTRSHXHGJ7ZIVRBXEYE5Q74XUVCUSEKEBR7UCHEUUEK72N7I7KJ6JH', + issuerShort: 'Stronghold', + balance: '1,000.00', + price: 0.0023, + change24h: -5.3, + domain: 'stronghold.co', + trusted: false, + popular: false, + }, +]; + +// ─── AssetCard replica ──────────────────────────────────────────────────────── + +const AssetCardPreview = ({ + asset, + compact = false, +}: { + asset: MockAsset; + compact?: boolean; +}) => { + const isNative = asset.issuer === 'native'; + const positive = (asset.change24h ?? 0) >= 0; + + return ( +
+ {/* Top accent bar */} +
+
+ {/* Header */} +
+
+
+ {asset.code} +
+
+ {asset.issuerShort} +
+
+
+ {!isNative && ( + + )} +
+
+ + {/* Balance */} + {asset.balance && ( +
+
+ Balance +
+
+ {asset.balance} +
+
+ )} + + {/* Price + change */} + {asset.price !== undefined && ( +
+ + ${asset.price.toFixed(4)} + + {asset.change24h !== undefined && ( + + {positive ? : } + {positive ? '+' : ''}{asset.change24h.toFixed(2)}% + + )} +
+ )} + + {/* Domain link */} + {asset.domain && !compact && ( + e.preventDefault()} + style={{ + fontSize: '10px', + color: 'var(--cyan)', + display: 'flex', + alignItems: 'center', + gap: '4px', + textDecoration: 'none', + }} + > + + {asset.domain} + + )} +
+
+ ); +}; + +// ─── AssetCard stories ──────────────────────────────────────────────────────── + +export const AssetCardDefault: StoryObj = { + name: 'AssetCard — Default', + render: () => , + parameters: { + docs: { description: { story: 'Standard asset card with balance, price, and 24h change.' } }, + }, +}; + +export const AssetCardPositive: StoryObj = { + name: 'AssetCard — Positive Change', + render: () => , +}; + +export const AssetCardNegative: StoryObj = { + name: 'AssetCard — Negative Change', + render: () => , +}; + +export const AssetCardUntrusted: StoryObj = { + name: 'AssetCard — Untrusted Asset', + render: () => , + parameters: { + docs: { + description: { + story: + 'Asset without an established trustline. The star button is unfilled to indicate it can be added.', + }, + }, + }, +}; + +export const AssetCardGrid: StoryObj = { + name: 'AssetCard — Grid', + render: () => ( +
+ {ASSETS.map((a) => ( + + ))} +
+ ), + parameters: { + docs: { description: { story: 'Grid layout showing multiple asset cards.' } }, + }, +}; + +export const AssetCardMobile: StoryObj = { + name: 'AssetCard — Mobile', + render: () => ( +
+ {ASSETS.slice(0, 3).map((a) => ( + + ))} +
+ ), + parameters: { + viewport: { defaultViewport: 'mobile375' }, + docs: { description: { story: 'Compact card layout for mobile viewports.' } }, + }, +}; + +// ─── AssetDiscovery replica ─────────────────────────────────────────────────── + +const AssetDiscoveryPreview = ({ + loading = false, + empty = false, +}: { + loading?: boolean; + empty?: boolean; +}) => { + const [query, setQuery] = useState(''); + const filtered = empty + ? [] + : ASSETS.filter( + (a) => + a.code.toLowerCase().includes(query.toLowerCase()) || + a.issuerShort.toLowerCase().includes(query.toLowerCase()), + ); + + return ( +
+
+
+ Asset Discovery +
+
+ Search Stellar assets by code, issuer, or domain +
+
+ + {/* Search bar */} +
+
+ + setQuery(e.target.value)} + placeholder="Search by code or issuer…" + aria-label="Search assets" + style={{ + flex: 1, + background: 'none', + border: 'none', + outline: 'none', + fontSize: '13px', + color: 'var(--text-primary)', + fontFamily: 'var(--font-sans)', + }} + /> + {query && ( + + )} +
+ +
+ + {/* Results */} +
+ {loading ? ( +
+
+ Searching assets… +
+ ) : empty || filtered.length === 0 ? ( +
+ +
+ No assets found +
+
+ Try a different search term or check the asset code. +
+
+ ) : ( + filtered.map((asset, i) => ( +
+
+ {asset.code.slice(0, 2)} +
+
+
+ {asset.code} + {asset.trusted && ( + + )} +
+
+ {asset.issuerShort} +
+
+ {asset.price !== undefined && ( +
+
+ ${asset.price.toFixed(4)} +
+ {asset.change24h !== undefined && ( +
= 0 ? 'var(--green)' : 'var(--red)', + }} + > + {asset.change24h >= 0 ? '+' : ''} + {asset.change24h.toFixed(2)}% +
+ )} +
+ )} + {!asset.trusted && asset.issuer !== 'native' && ( + + )} +
+ )) + )} +
+
+ ); +}; + +export const AssetDiscoveryDefault: StoryObj = { + name: 'AssetDiscovery — Default', + render: () => , + parameters: { + docs: { + description: { + story: + 'Asset discovery panel with a live-filtered search. Real component at `src/components/assets/AssetDiscovery.jsx`.', + }, + }, + }, +}; + +export const AssetDiscoveryLoading: StoryObj = { + name: 'AssetDiscovery — Loading', + render: () => , +}; + +export const AssetDiscoveryEmpty: StoryObj = { + name: 'AssetDiscovery — No Results', + render: () => , + parameters: { + docs: { description: { story: 'Empty state when no assets match the search query.' } }, + }, +}; + +export const AssetDiscoveryMobile: StoryObj = { + name: 'AssetDiscovery — Mobile', + render: () => , + parameters: { viewport: { defaultViewport: 'mobile375' } }, +}; diff --git a/stories/DesignSystem.stories.tsx b/stories/DesignSystem.stories.tsx new file mode 100644 index 00000000..81006531 --- /dev/null +++ b/stories/DesignSystem.stories.tsx @@ -0,0 +1,637 @@ +/** + * D-028 — Design System documentation story. + * + * Surfaces the design token catalog (colors, spacing, typography, radii, motion) + * and the variant system directly in Storybook so developers never need to leave + * the component browser to look up a token or understand how a variant is composed. + */ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { tokens } from '../src/design-system/tokens'; +import { variantSystem } from '../src/design-system/variants'; + +const meta: Meta = { + title: 'Design System/Tokens', + parameters: { + docs: { + description: { + component: + 'Source-of-truth token catalog. All colors, spacing, typography, radii, and motion values used across the dashboard.', + }, + }, + }, +}; +export default meta; + +// ─── Shared primitives ──────────────────────────────────────────────────────── + +const SectionTitle = ({ children }: { children: React.ReactNode }) => ( +

+ {children} +

+); + +const TokenLabel = ({ name, value }: { name: string; value: string }) => ( +
+ {name} + {value} +
+); + +// ─── Colors ─────────────────────────────────────────────────────────────────── + +export const Colors: StoryObj = { + name: 'Colors', + render: () => { + const swatches: { label: string; token: string; value: string }[] = [ + { label: 'brand.primary', token: 'tokens.colors.brand.primary', value: tokens.colors.brand.primary }, + { label: 'brand.secondary', token: 'tokens.colors.brand.secondary', value: tokens.colors.brand.secondary }, + { label: 'semantic.info', token: 'tokens.colors.semantic.info', value: tokens.colors.semantic.info }, + { label: 'semantic.success', token: 'tokens.colors.semantic.success', value: tokens.colors.semantic.success }, + { label: 'semantic.warning', token: 'tokens.colors.semantic.warning', value: tokens.colors.semantic.warning }, + { label: 'semantic.danger', token: 'tokens.colors.semantic.danger', value: tokens.colors.semantic.danger }, + { label: 'surface.background', token: 'tokens.colors.surface.background', value: tokens.colors.surface.background }, + { label: 'surface.card', token: 'tokens.colors.surface.card', value: tokens.colors.surface.card }, + { label: 'surface.elevated', token: 'tokens.colors.surface.elevated', value: tokens.colors.surface.elevated }, + { label: 'text.primary', token: 'tokens.colors.text.primary', value: tokens.colors.text.primary }, + { label: 'text.secondary', token: 'tokens.colors.text.secondary', value: tokens.colors.text.secondary }, + { label: 'text.muted', token: 'tokens.colors.text.muted', value: tokens.colors.text.muted }, + { label: 'border.subtle', token: 'tokens.colors.border.subtle', value: tokens.colors.border.subtle }, + { label: 'border.default', token: 'tokens.colors.border.default', value: tokens.colors.border.default }, + { label: 'border.strong', token: 'tokens.colors.border.strong', value: tokens.colors.border.strong }, + ]; + + return ( +
+ Color Tokens +

+ Import from{' '} + + src/design-system/colors + {' '} + or access via CSS custom properties ( + var(--cyan),{' '} + var(--bg-card), + etc.) in inline styles. +

+
+ {swatches.map(({ label, value }) => ( +
+
+
+
+ {label} +
+
+ {value} +
+
+
+ ))} +
+ + CSS Custom Properties (Runtime) +

+ These are the CSS variables set in{' '} + src/styles/globals.css{' '} + and switched by the{' '} + data-theme attribute. +

+
+ {[ + ['--bg-base', 'Page background'], + ['--bg-card', 'Card surface'], + ['--bg-elevated', 'Elevated surface (inputs, dropdowns)'], + ['--border', 'Default border'], + ['--cyan', 'Primary accent (interactive elements)'], + ['--cyan-dim', 'Muted cyan for glow borders'], + ['--green', 'Success state'], + ['--red', 'Destructive / error state'], + ['--amber', 'Warning / testnet badge'], + ['--text-primary', 'High-emphasis text'], + ['--text-secondary', 'Medium-emphasis text'], + ['--text-muted', 'Labels, captions, meta'], + ['--font-sans', 'Body font (Inter)'], + ['--font-display', 'Heading font (Syne)'], + ['--font-mono', 'Code/address font (Space Mono)'], + ].map(([token, desc]) => ( +
+ + {token} + + {desc} +
+ ))} +
+
+ ); + }, + parameters: { layout: 'padded' }, +}; + +// ─── Spacing ────────────────────────────────────────────────────────────────── + +export const Spacing: StoryObj = { + name: 'Spacing', + render: () => { + const scale = Object.entries(tokens.spacing).filter(([, v]) => typeof v === 'string') as [string, string][]; + return ( +
+ Spacing Scale +

+ Import from{' '} + src/design-system/spacing. + Use the scale keys (xs → xxl) in component styles. Prefer tokens over arbitrary pixel values. +

+
+ {scale.map(([key, value]) => ( +
+ + {key} + +
+ + {value} + +
+ ))} +
+ +
+ Layout Spacing + {Object.entries(tokens.spacing.layout).map(([key, value]) => ( + + ))} +
+
+ ); + }, + parameters: { layout: 'padded' }, +}; + +// ─── Typography ─────────────────────────────────────────────────────────────── + +export const Typography: StoryObj = { + name: 'Typography', + render: () => ( +
+ Type Scale +

+ Import from{' '} + src/design-system/typography. +

+ + {/* Font families */} +
+
+ Font Families +
+ {Object.entries(tokens.typography.fontFamily).map(([key, value]) => ( +
+
+ + typography.fontFamily.{key} + + {value} +
+
+ Stellar Dev Dashboard +
+
+ ))} +
+ + {/* Font sizes */} +
+
+ Font Sizes +
+ {Object.entries(tokens.typography.fontSize).map(([key, value]) => ( +
+ {key} + Aa + {value} +
+ ))} +
+ + {/* Font weights */} +
+
+ Font Weights +
+ {Object.entries(tokens.typography.fontWeight).map(([key, value]) => ( +
+ {key} + + The quick brown fox + + {value} +
+ ))} +
+
+ ), + parameters: { layout: 'padded' }, +}; + +// ─── Radii ──────────────────────────────────────────────────────────────────── + +export const Radii: StoryObj = { + name: 'Border Radii', + render: () => ( +
+ Border Radii +

+ Use via{' '} + tokens.radii.* or + CSS variables{' '} + var(--radius-sm),{' '} + var(--radius-md), + etc. +

+
+ {Object.entries(tokens.radii).map(([key, value]) => ( +
+
+ {key} + {value} +
+ ))} +
+
+ ), + parameters: { layout: 'padded' }, +}; + +// ─── Motion ─────────────────────────────────────────────────────────────────── + +export const Motion: StoryObj = { + name: 'Motion', + render: () => ( +
+ Motion Tokens +

+ Use these values for consistent animation durations and easing. Respect{' '} + prefers-reduced-motion — + disable or simplify transitions when this media query is active. +

+ +
+
Durations
+ {Object.entries(tokens.motion.duration).map(([key, value]) => ( + + ))} +
+ +
+
Easing
+ {Object.entries(tokens.motion.easing).map(([key, value]) => ( + + ))} +
+ +
Live Demo
+
+ {Object.entries(tokens.motion.duration).map(([key, value]) => { + const [hovered, setHovered] = React.useState(false); + return ( + + ); + })} +
+
+ ), + parameters: { layout: 'padded' }, +}; + +// ─── Variants ───────────────────────────────────────────────────────────────── + +export const Variants: StoryObj = { + name: 'Component Variants', + render: () => { + const statusColors: Record = { + ready: 'var(--green)', + guidance: 'var(--cyan)', + planned: 'var(--amber)', + }; + + return ( +
+ Variant System +

+ Defined in{' '} + src/design-system/variants.ts. + Variants are composed from token references so they stay in sync when tokens change. +

+
+ {(['ready', 'guidance', 'planned'] as const).map((s) => ( + + {s} + + ))} + = implementation status +
+ +
+ {variantSystem.map((group) => ( +
+
+
{group.label}
+
{group.purpose}
+
+
+ {group.variants.map((variant, i) => ( +
+
+
+ {variant.label} + + {variant.status} + +
+
{variant.description}
+
+ {variant.composition.map((token) => ( + + {token} + + ))} +
+
+
+ ))} +
+
+ ))} +
+
+ ); + }, + parameters: { layout: 'padded' }, +}; + +// ─── Accessibility guidelines ───────────────────────────────────────────────── + +export const AccessibilityGuidelines: StoryObj = { + name: 'Accessibility Guidelines', + render: () => ( +
+ Accessibility Guidelines +

+ The dashboard targets WCAG 2.1 AA. These guidelines apply to all new components. + Full validation requires manual testing with assistive technologies and expert review. +

+ + {[ + { + title: 'Keyboard Navigation', + items: [ + 'All interactive elements are reachable by Tab.', + 'Enter / Space activate buttons and links.', + 'Escape closes modals, sheets, and dropdowns.', + 'Arrow keys navigate lists, menus, and grids.', + 'Ctrl+K opens the command palette from anywhere.', + ], + }, + { + title: 'Screen Reader Support', + items: [ + 'Meaningful aria-label on icon-only buttons.', + 'aria-live="polite" for async state changes (loading, errors, success).', + 'Landmark regions:
,
+ ), + parameters: { layout: 'padded' }, +}; diff --git a/stories/Introduction.stories.tsx b/stories/Introduction.stories.tsx index a8cf3ef7..c64fdf9d 100644 --- a/stories/Introduction.stories.tsx +++ b/stories/Introduction.stories.tsx @@ -11,47 +11,79 @@ export default meta; export const Overview: StoryObj = { render: () => ( -
+

✦ Stellar Dev Dashboard — Component Catalog

-

+

A real-time, open-source developer dashboard for the Stellar network. + Stack: React 18 · Vite 5 · TypeScript · Zustand · Recharts · Storybook 8.5 +

+

+ Components use CSS custom properties for theming — switch Dark/Light with the toolbar above. + axe-core accessibility checks run automatically on every story.

Component Groups

- +
- + + {[ - ['Dashboard', 'Card, StatCard, CopyableValue'], - ['Layout', 'ThemeToggle, NetworkIndicator, OfflineBanner, ResponsiveContainer'], - ['Errors', 'NetworkError, RetryButton'], - ['Notifications', 'NotificationItem, ScreenReaderAnnouncer'], - ['Accessibility', 'AccessibilitySettings, KeyboardNavigation'], - ['Mobile', 'BottomSheet'], - ['Dashboard/Utilities', 'PriceTicker, Faucet, RealTimeLedger, ExplorerEmbed'], - ['Charts', 'NetworkMetricsChart, BalanceHistoryChart, AccountActivityChart'], - ].map(([group, components]) => ( + ['Dashboard/Cards', 'Card, StatCard', 'src/components/dashboard/Card.tsx'], + ['Dashboard/CopyableValue', 'CopyableValue', 'src/components/dashboard/CopyableValue.tsx'], + ['Dashboard/Utilities', 'PriceTicker, Faucet, RealTimeLedger, ExplorerEmbed', 'src/components/dashboard/'], + ['Layout/Navigation', 'Sidebar, MobileHeader, SearchBar', 'src/components/layout/'], + ['Layout/ThemeToggle', 'ThemeToggle', 'src/components/layout/ThemeToggle.tsx'], + ['Layout/NetworkIndicator', 'NetworkIndicator', 'src/components/layout/NetworkIndicator.jsx'], + ['Layout/OfflineBanner', 'OfflineBanner', 'src/components/layout/OfflineBanner.tsx'], + ['Layout/ResponsiveContainer', 'ResponsiveContainer, ResponsiveGrid, ResponsiveFlex', 'src/components/layout/ResponsiveContainer.tsx'], + ['Mobile/BottomSheet', 'BottomSheet', 'src/components/mobile/BottomSheet.tsx'], + ['Forms/ValidatedInput', 'ValidatedInput', 'src/components/validation/ValidatedInput.tsx'], + ['Assets', 'AssetCard, AssetDiscovery', 'src/components/assets/'], + ['Charts', 'NetworkMetricsChart, BalanceHistoryChart, AccountActivityChart', 'src/components/charts/'], + ['Notifications & Errors', 'NetworkError, RetryButton, NotificationItem', 'src/components/errors/ · notifications/'], + ['Accessibility', 'AccessibilitySettings, ScreenReaderAnnouncer, KeyboardNavigation', 'src/components/accessibility/'], + ['Design System/Tokens', 'Colors, Spacing, Typography, Radii, Motion, Variants', 'src/design-system/'], + ['Design System/Performance', 'Bundle budgets, render targets, patterns', '—'], + ['Design System/Migration', 'Breaking changes, migration guides, version history', 'docs/api/CHANGELOG.md'], + ].map(([group, components, source]) => ( + ))}
GroupStory Group ComponentsSource
{group} {components}{source}
-

Features

-
    +

    Storybook Features

    +
    • 🌗 Theme toolbar — switch Dark / Light on every story
    • -
    • 📱 Viewport toolbar — test Mobile (375px), Tablet (768px), Desktop (1280px)
    • +
    • 📱 Viewport toolbar — Mobile (375px / 390px), Tablet (768px), Desktop (1280px)
    • Accessibility panel — axe-core checks run automatically on every story
    • +
    • 📖 Design System stories — token catalog, variant system, accessibility guidelines, performance budgets, and migration guide all live in Storybook
    + +

    Quick Reference

    +
    + {[ + { label: 'Run Storybook', code: 'npm run storybook' }, + { label: 'Build Storybook', code: 'npm run build-storybook' }, + { label: 'Type check', code: 'npm run type-check' }, + { label: 'Lint', code: 'npm run lint' }, + ].map(({ label, code }) => ( +
    +
    {label}
    + {code} +
    + ))} +
), parameters: { layout: 'padded' }, diff --git a/stories/LayoutComponents.stories.tsx b/stories/LayoutComponents.stories.tsx new file mode 100644 index 00000000..750864c8 --- /dev/null +++ b/stories/LayoutComponents.stories.tsx @@ -0,0 +1,639 @@ +/** + * D-028 — Layout component stories (Sidebar, MobileHeader, SearchBar). + * + * Full layout components depend on the Zustand store and router context. + * Stories use isolated replicas that faithfully represent all documented + * UI states — see docs/components.md for the authoritative prop reference. + */ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { Search, Bell, X, ChevronRight, LayoutDashboard, ArrowLeftRight, Code2, BarChart3, Droplets, Wallet, Settings, Beaker } from 'lucide-react'; + +const meta: Meta = { + title: 'Layout/Navigation', + parameters: { + docs: { + description: { + component: + 'Navigation and layout shell components. Real components live at `src/components/layout/`. These stories use isolated replicas to avoid full app context.', + }, + }, + }, +}; +export default meta; + +// ─── Sidebar ────────────────────────────────────────────────────────────────── + +const NAV_ITEMS = [ + { id: 'overview', label: 'Overview', icon: LayoutDashboard }, + { id: 'transactions', label: 'Transactions', icon: ArrowLeftRight }, + { id: 'contracts', label: 'Contracts', icon: Code2 }, + { id: 'analytics', label: 'Analytics', icon: BarChart3 }, + { id: 'dex', label: 'DEX Explorer', icon: Droplets }, + { id: 'wallet', label: 'Wallet', icon: Wallet }, + { id: 'testnet', label: 'Testnet Tools', icon: Beaker }, + { id: 'settings', label: 'Settings', icon: Settings }, +]; + +const SidebarPreview = ({ + activeItem = 'overview', + network = 'testnet', +}: { + activeItem?: string; + network?: string; +}) => { + const [active, setActive] = useState(activeItem); + const NETWORK_COLORS: Record = { + mainnet: 'var(--green)', + testnet: 'var(--amber)', + futurenet: 'var(--cyan)', + }; + + return ( +
+ {/* Logo */} +
+
+ S +
+
+
+ Stellar Dev +
+
+ + {network} +
+
+
+ + {/* Nav items */} + + + {/* Account badge */} +
+
+ G +
+
+
+ GABC…XYZ +
+
Connected
+
+
+
+ ); +}; + +export const SidebarDefault: StoryObj = { + name: 'Sidebar — Testnet', + render: () => , + parameters: { + docs: { + description: { + story: + 'Desktop sidebar with testnet badge. Active item highlighted with a left border and background tint. Real component: `src/components/layout/Sidebar.tsx`.', + }, + }, + }, +}; + +export const SidebarMainnet: StoryObj = { + name: 'Sidebar — Mainnet', + render: () => , +}; + +export const SidebarFuturenet: StoryObj = { + name: 'Sidebar — Futurenet', + render: () => , +}; + +// ─── MobileHeader ───────────────────────────────────────────────────────────── + +const MobileHeaderPreview = ({ + menuOpen = false, + network = 'testnet', +}: { + menuOpen?: boolean; + network?: string; +}) => { + const [open, setOpen] = useState(menuOpen); + const NETWORK_COLORS: Record = { + mainnet: 'var(--green)', + testnet: 'var(--amber)', + futurenet: 'var(--cyan)', + }; + + return ( +
+
+ {/* Hamburger */} + + + {/* Logo */} + + Stellar Dev + + + {/* Spacer */} +
+ + {/* Network badge */} + + + {network} + + + {/* Notification bell */} + +
+ + {open && ( +
+ {NAV_ITEMS.map(({ id, label, icon: Icon }) => ( + + ))} +
+ )} +
+ ); +}; + +export const MobileHeaderDefault: StoryObj = { + name: 'MobileHeader — Closed', + render: () => , + parameters: { + viewport: { defaultViewport: 'mobile375' }, + docs: { + description: { + story: + 'Fixed top bar with hamburger, logo, network badge, and notification bell. Real component: `src/components/layout/MobileHeader.tsx`.', + }, + }, + }, +}; + +export const MobileHeaderMenuOpen: StoryObj = { + name: 'MobileHeader — Menu Open', + render: () => , + parameters: { + viewport: { defaultViewport: 'mobile375' }, + docs: { description: { story: 'Mobile navigation drawer expanded.' } }, + }, +}; + +// ─── SearchBar ──────────────────────────────────────────────────────────────── + +const SearchBarPreview = ({ + placeholder = 'Search accounts, transactions, contracts…', + hasResults = false, +}: { + placeholder?: string; + hasResults?: boolean; +}) => { + const [query, setQuery] = useState(''); + const [focused, setFocused] = useState(false); + + const MOCK_RESULTS = [ + { type: 'Account', value: 'GABC...XYZ', detail: 'Public Key' }, + { type: 'Transaction', value: 'a1b2c3...', detail: 'Hash' }, + { type: 'Contract', value: 'CDEF...', detail: 'Soroban Contract' }, + ]; + + const showDropdown = (focused && query.length > 0) || (hasResults && focused); + + return ( +
+
+ + setQuery(e.target.value)} + onFocus={() => setFocused(true)} + onBlur={() => setTimeout(() => setFocused(false), 150)} + placeholder={placeholder} + aria-label="Global search" + aria-autocomplete="list" + aria-expanded={showDropdown} + role="combobox" + style={{ + flex: 1, + background: 'none', + border: 'none', + outline: 'none', + fontSize: '13px', + color: 'var(--text-primary)', + fontFamily: 'var(--font-sans)', + }} + /> + + ⌘K + +
+ + {showDropdown && ( +
+ {MOCK_RESULTS.map((r, i) => ( +
+ + {r.type} + + + {r.value} + + {r.detail} +
+ ))} +
+ )} +
+ ); +}; + +export const SearchBarDefault: StoryObj = { + name: 'SearchBar — Default', + render: () => , + parameters: { + docs: { + description: { + story: + 'Global search bar. Gains a cyan focus ring when active. Dropdown shows results when query is non-empty. Real component: `src/components/layout/SearchBar.tsx`.', + }, + }, + }, +}; + +export const SearchBarWithResults: StoryObj = { + name: 'SearchBar — With Results', + render: () => { + const [focused, setFocused] = React.useState(true); + return ( +
+
+ + +
+
+ {[ + { type: 'Account', value: 'GABC...XYZ', detail: 'Public Key' }, + { type: 'Transaction', value: 'a1b2c3...', detail: 'Hash' }, + ].map((r, i) => ( +
+ + {r.type} + + {r.value} + {r.detail} +
+ ))} +
+
+ ); + }, +}; + +export const SearchBarMobile: StoryObj = { + name: 'SearchBar — Mobile', + render: () => , + parameters: { viewport: { defaultViewport: 'mobile375' } }, +}; diff --git a/stories/Migration.stories.tsx b/stories/Migration.stories.tsx new file mode 100644 index 00000000..74454939 --- /dev/null +++ b/stories/Migration.stories.tsx @@ -0,0 +1,419 @@ +/** + * D-028 — Migration guide story. + * + * Documents breaking changes, deprecation paths, and migration instructions + * for each major version transition. This lives in Storybook so it is + * co-located with the component catalog — no context-switching required. + */ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Design System/Migration', + parameters: { + docs: { + description: { + component: + 'Migration guides and breaking change log for the Stellar Dev Dashboard component library.', + }, + }, + }, +}; +export default meta; + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +const SectionTitle = ({ children }: { children: React.ReactNode }) => ( +

+ {children} +

+); + +const Chip = ({ + label, + color, +}: { + label: string; + color: 'red' | 'amber' | 'green' | 'cyan'; +}) => { + const c = { red: 'var(--red)', amber: 'var(--amber)', green: 'var(--green)', cyan: 'var(--cyan)' }[color]; + return ( + + {label} + + ); +}; + +const CodeBlock = ({ code }: { code: string }) => ( +
+    {code}
+  
+); + +// ─── Version History ────────────────────────────────────────────────────────── + +export const VersionHistory: StoryObj = { + name: 'Version History', + render: () => { + const versions = [ + { + version: '0.1.0', + date: 'Jun 2025', + summary: 'Initial release — dashboard shell, layout system, core components.', + changes: [ + { type: 'new' as const, description: 'Dashboard shell: Sidebar, MobileHeader, MobileSidebar, DashboardGrid' }, + { type: 'new' as const, description: 'Core components: Card, StatCard, CopyableValue, ThemeToggle' }, + { type: 'new' as const, description: 'Chart suite: NetworkMetricsChart, BalanceHistoryChart, AccountActivityChart' }, + { type: 'new' as const, description: 'Accessibility: AccessibilitySettings, ScreenReaderAnnouncer, KeyboardNavigation' }, + { type: 'new' as const, description: 'Network: NetworkIndicator, OfflineBanner, RetryButton' }, + { type: 'new' as const, description: 'Assets: AssetCard, AssetDiscovery' }, + { type: 'new' as const, description: 'Forms: ValidatedInput (D-027)' }, + { type: 'new' as const, description: 'Design system: tokens, colors, spacing, typography, variants' }, + { type: 'new' as const, description: 'Storybook 8.5 with a11y, viewport addons and dark/light theme toolbar' }, + ], + }, + ]; + + const typeChip = (t: 'new' | 'break' | 'fix' | 'dep') => { + const map = { new: ['green', 'New'], break: ['red', 'Breaking'], fix: ['cyan', 'Fix'], dep: ['amber', 'Deprecated'] } as const; + return ; + }; + + return ( +
+ Version History +

+ Full changelog is also available at{' '} + docs/api/CHANGELOG.md. +

+ + {versions.map((v) => ( +
+
+ + v{v.version} + + {v.date} + {v.summary} +
+
+ {v.changes.map((c, i) => ( +
+ {typeChip(c.type)} + {c.description} +
+ ))} +
+
+ ))} +
+ ); + }, + parameters: { layout: 'padded' }, +}; + +// ─── Breaking Changes ───────────────────────────────────────────────────────── + +export const BreakingChanges: StoryObj = { + name: 'Breaking Changes', + render: () => ( +
+ Breaking Changes +

+ This section documents breaking changes that require code changes in consuming code. + Follow the migration steps below each entry before upgrading. +

+ + {/* Placeholder for future breaking changes — shown as "no breaking changes yet" */} +
+
+
+ No breaking changes in v0.1.0 +
+
+ This is the initial release. Future breaking changes will be documented here. +
+
+ + {/* Template for future breaking changes */} +
+
+ + + Example: Renamed prop (template for v0.2.0) + + + v0.2.0 · Card + +
+
+
+ The glow prop + on Card will be renamed to{' '} + highlighted. +
+ … + +// v0.2.0+ (new) +`} + /> +
+ Run{' '} + + npx codemod stellar-card-glow-to-highlighted + {' '} + to automate the rename. +
+
+
+
+ ), + parameters: { layout: 'padded' }, +}; + +// ─── Migration Guides ───────────────────────────────────────────────────────── + +export const MigrationGuides: StoryObj = { + name: 'Migration Guides', + render: () => ( +
+ Migration Guides + + {[ + { + title: 'JavaScript → TypeScript Components', + status: 'In Progress', + statusColor: 'amber' as const, + description: + 'The codebase is migrating from .jsx to .tsx. New components must be TypeScript. Existing .jsx components are being converted incrementally.', + steps: [ + 'Rename the file from .jsx to .tsx.', + 'Add prop types using interfaces from src/types/components.ts where they exist.', + 'Enable strict type checking: tsconfig.json already has allowJs: true — no config change needed.', + 'Fix type errors surfaced by tsc --noEmit.', + 'Update the import in any .stories.tsx file if the extension changes.', + ], + code: `// src/types/components.ts — add your interface here +export interface MyComponentProps { + title: string; + onAction?: () => void; +} + +// src/components/dashboard/MyComponent.tsx +import type { MyComponentProps } from '../../types/components'; + +export default function MyComponent({ title, onAction }: MyComponentProps) { + // … +}`, + }, + { + title: 'Inline Styles → CSS Custom Properties', + status: 'Ongoing', + statusColor: 'cyan' as const, + description: + 'Components should use CSS custom properties (var(--token)) instead of hard-coded hex values. This ensures dark/light theme switching works correctly.', + steps: [ + 'Replace hard-coded colors with the closest CSS variable from src/styles/globals.css.', + 'Reference the token catalog (Design System / Tokens story) for the full list.', + 'Test both themes using the Theme toolbar in Storybook.', + 'Do not remove legacy token aliases until all call sites are migrated.', + ], + code: `// ❌ Before — hard-coded +
+ +// ✅ After — theme-aware +
`, + }, + { + title: 'Adopting the Variant System', + status: 'Available', + statusColor: 'green' as const, + description: + 'New component variants should be defined in src/design-system/variants.ts, not as ad-hoc inline style objects.', + steps: [ + 'Check variants.ts — does a matching variant already exist?', + 'If yes, read its composition array and apply the referenced tokens.', + 'If no, add a new VariantDefinition to the appropriate group (or create a new group).', + 'Document the variant with a status of "planned" until it is implemented, then update to "ready".', + 'Add a Storybook story to Design System / Component Variants.', + ], + code: `// src/design-system/variants.ts — adding a new variant +{ + key: 'ghost', + label: 'Ghost', + description: 'Transparent background, border only. For tertiary actions.', + composition: ['border.default', 'text.primary', 'radii.sm'], + status: 'planned', +}`, + }, + ].map(({ title, status, statusColor, description, steps, code }) => ( +
+
+ + {title} + + +
+
+

+ {description} +

+
+ Steps +
+
    + {steps.map((step) => ( +
  1. + {step} +
  2. + ))} +
+ +
+
+ ))} +
+ ), + parameters: { layout: 'padded' }, +}; diff --git a/stories/Performance.stories.tsx b/stories/Performance.stories.tsx new file mode 100644 index 00000000..bc7b2f21 --- /dev/null +++ b/stories/Performance.stories.tsx @@ -0,0 +1,431 @@ +/** + * D-028 — Performance documentation story. + * + * Documents bundle size, render budget, and component-level performance + * guidance. Uses static content — no live measurements at story-render time. + */ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Design System/Performance', + parameters: { + docs: { + description: { + component: + 'Component performance guidelines — bundle budgets, render targets, and optimization patterns for the Stellar Dev Dashboard.', + }, + }, + }, +}; +export default meta; + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +const SectionTitle = ({ children }: { children: React.ReactNode }) => ( +

+ {children} +

+); + +const statusColor = (status: 'good' | 'warn' | 'fail') => { + if (status === 'good') return 'var(--green)'; + if (status === 'warn') return 'var(--amber)'; + return 'var(--red)'; +}; + +// ─── Bundle Size ────────────────────────────────────────────────────────────── + +export const BundleSize: StoryObj = { + name: 'Bundle Size', + render: () => { + const rows: { component: string; gzip: string; status: 'good' | 'warn' | 'fail'; note: string }[] = [ + { component: 'Card / StatCard', gzip: '~1 KB', status: 'good', note: 'Inline styles only, no external deps' }, + { component: 'CopyableValue', gzip: '~1 KB', status: 'good', note: 'Single effect, one clipboard write' }, + { component: 'ThemeToggle', gzip: '~2 KB', status: 'good', note: 'lucide-react icon + Zustand read' }, + { component: 'NetworkIndicator', gzip: '~2 KB', status: 'good', note: 'Pure display, no subscriptions' }, + { component: 'ValidatedInput', gzip: '~3 KB', status: 'good', note: 'Self-contained validation logic' }, + { component: 'BottomSheet', gzip: '~4 KB', status: 'good', note: 'useResponsive hook + touch gestures' }, + { component: 'ResponsiveContainer / Grid / Flex', gzip: '~4 KB', status: 'good', note: 'Three exported components' }, + { component: 'AssetCard', gzip: '~3 KB', status: 'good', note: 'No chart deps' }, + { component: 'NetworkMetricsChart', gzip: '~8 KB', status: 'warn', note: 'Recharts AreaChart (shared via code split)' }, + { component: 'BalanceHistoryChart', gzip: '~6 KB', status: 'warn', note: 'Recharts BarChart + PieChart' }, + { component: 'TransactionBuilder', gzip: '~28 KB', status: 'warn', note: 'stellar-sdk + fee logic; lazy-loaded' }, + { component: 'ContractInteraction', gzip: '~24 KB', status: 'warn', note: 'soroban-client + ABI parsing; lazy-loaded' }, + { component: 'D3VisualizationSuite', gzip: '~35 KB', status: 'warn', note: 'Full D3 force graph; lazy-loaded on demand' }, + ]; + + return ( +
+ Bundle Size per Component +

+ The overall build target is 500 KB gzipped (enforced in CI via{' '} + scripts/check-coverage.mjs). + Large components are code-split with{' '} + React.lazy(). + Sizes below are per-component estimates excluding shared chunks (Recharts, stellar-sdk, etc. are bundled once). +

+ +
+
+ Component + Gzipped + Status + Notes +
+ {rows.map((row, i) => ( +
+ + {row.component} + + + {row.gzip} + + + {row.status} + + {row.note} +
+ ))} +
+ +
+ {( + [ + ['good', '< 5 KB', 'Target for pure UI components'], + ['warn', '5–40 KB', 'Acceptable for feature components; must be lazy-loaded'], + ['fail', '> 40 KB', 'Requires code splitting or dependency audit'], + ] as const + ).map(([status, range, label]) => ( +
+ + {range} + — {label} +
+ ))} +
+
+ ); + }, + parameters: { layout: 'padded' }, +}; + +// ─── Render Time ────────────────────────────────────────────────────────────── + +export const RenderTime: StoryObj = { + name: 'Render Time Targets', + render: () => ( +
+ Render Time Targets +

+ Measured with{' '} + React.Profiler{' '} + in development builds on a mid-range device. Production builds are faster due to minification and + disabled dev warnings. Run{' '} + npm run test:lighthouse for + full Lighthouse metrics. +

+ + {[ + { + category: 'Pure UI Components', + budget: '< 1 ms', + examples: ['Card', 'StatCard', 'CopyableValue', 'ThemeToggle', 'NetworkIndicator', 'ValidatedInput'], + note: 'No side effects on mount. Should never exceed budget.', + }, + { + category: 'Layout Components', + budget: '< 5 ms', + examples: ['Sidebar', 'MobileHeader', 'BottomSheet', 'ResponsiveContainer'], + note: 'May run useResponsive hook; acceptable cost.', + }, + { + category: 'Chart Components', + budget: '< 50 ms', + examples: ['NetworkMetricsChart', 'BalanceHistoryChart', 'AccountActivityChart'], + note: 'Recharts layout pass is CPU-bound. Virtualize data to ≤ 500 points.', + }, + { + category: 'Feature Panels (API-dependent)', + budget: '< 100 ms to first paint; data via Suspense/loading state', + examples: ['Overview', 'Account', 'Transactions', 'Contracts', 'DEXExplorer'], + note: 'Show skeleton or spinner immediately. Data fetches are async.', + }, + { + category: 'Heavy Editors', + budget: 'Lazy-load; < 200 ms to interactive after load', + examples: ['TransactionBuilder', 'ContractInteraction', 'D3VisualizationSuite'], + note: 'Must use React.lazy() + Suspense. Never eager-import.', + }, + ].map(({ category, budget, examples, note }) => ( +
+
+
{category}
+ + {budget} + +
+
+ {examples.map((ex) => ( + + {ex} + + ))} +
+
{note}
+
+ ))} +
+ ), + parameters: { layout: 'padded' }, +}; + +// ─── Patterns ───────────────────────────────────────────────────────────────── + +export const OptimizationPatterns: StoryObj = { + name: 'Optimization Patterns', + render: () => ( +
+ Optimization Patterns + + {[ + { + title: 'Code Split Heavy Components', + code: `// ✅ Do +const TransactionBuilder = React.lazy(() => + import('./components/dashboard/TransactionBuilder') +); + +// Inside render: +}> + +`, + note: 'Required for any component > 10 KB. The app-level router already splits by route.', + }, + { + title: 'Virtualize Long Lists', + code: `// ✅ Do — use VirtualList for > 50 items +import VirtualList from './components/common/VirtualList'; + + } +/> + +// ❌ Don't — map 1000+ items unconditionally +transactions.map((tx) => )`, + note: 'VirtualList is already available at src/components/common/VirtualList.jsx.', + }, + { + title: 'Memoize Expensive Renders', + code: `// ✅ Do — stable reference for chart data +const chartData = useMemo( + () => buildChartSeries(rawLedgers), + [rawLedgers] +); + +// ✅ Do — pure display component +const StatCard = React.memo(({ label, value, sub, accent }: StatCardProps) => { ... });`, + note: 'Only memoize when profiling confirms a perf problem. Premature memoization adds cognitive overhead.', + }, + { + title: 'Debounce Live Search / API Calls', + code: `// ✅ Do — debounce asset search +const [query, setQuery] = useState(''); +const debouncedQuery = useDebounce(query, 300); + +useEffect(() => { + if (debouncedQuery) fetchAssets(debouncedQuery); +}, [debouncedQuery]);`, + note: 'Avoid firing Horizon requests on every keystroke.', + }, + { + title: 'Respect prefers-reduced-motion', + code: `// ✅ Do — check the media query +const prefersReduced = window.matchMedia( + '(prefers-reduced-motion: reduce)' +).matches; + +const duration = prefersReduced + ? 0 + : tokens.motion.duration.normal; // '180ms'`, + note: 'The AccessibilityContext exposes a reducedMotion flag — use it instead of reading the media query directly.', + }, + ].map(({ title, code, note }) => ( +
+
+ {title} +
+
+            {code}
+          
+
+ {note} +
+
+ ))} +
+ ), + parameters: { layout: 'padded' }, +}; diff --git a/stories/ValidatedInput.stories.tsx b/stories/ValidatedInput.stories.tsx new file mode 100644 index 00000000..9daa1ea2 --- /dev/null +++ b/stories/ValidatedInput.stories.tsx @@ -0,0 +1,298 @@ +/** + * D-028 — ValidatedInput component stories. + * + * Covers all visual and interaction states documented in D-027: + * default, focused, error, touched-with-error, disabled, required, + * and a form-composition example. + */ +import React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { ValidatedInput } from '../src/components/validation/ValidatedInput'; + +const meta: Meta = { + title: 'Forms/ValidatedInput', + component: ValidatedInput, + parameters: { + docs: { + description: { + component: + 'Accessible form input with real-time validation. Shows error messages only after the field has been touched (blurred), preventing premature validation feedback.', + }, + }, + }, + argTypes: { + type: { + control: { type: 'select' }, + options: ['text', 'email', 'url', 'password', 'number'], + description: 'HTML input type', + }, + disabled: { control: 'boolean', description: 'Disables the input' }, + required: { control: 'boolean', description: 'Marks the field as required' }, + error: { control: 'text', description: 'Validation error message' }, + touched: { control: 'boolean', description: 'Whether the field has been interacted with' }, + label: { control: 'text' }, + placeholder: { control: 'text' }, + }, +}; +export default meta; +type Story = StoryObj; + +// ─── Controlled wrapper ─────────────────────────────────────────────────────── + +const Controlled = ({ + initialValue = '', + error, + type = 'text', + label, + placeholder, + required, + disabled, +}: { + initialValue?: string; + error?: string | null; + type?: 'text' | 'email' | 'url' | 'password' | 'number'; + label?: string; + placeholder?: string; + required?: boolean; + disabled?: boolean; +}) => { + const [value, setValue] = useState(initialValue); + const [touched, setTouched] = useState(false); + return ( +
+ setTouched(true)} + touched={touched} + error={error ?? null} + type={type} + label={label} + placeholder={placeholder} + required={required} + disabled={disabled} + /> +
+ ); +}; + +// ─── Stories ────────────────────────────────────────────────────────────────── + +export const Default: Story = { + render: () => , + parameters: { + docs: { description: { story: 'Default idle state — no error, not yet touched.' } }, + }, +}; + +export const WithLabel: Story = { + render: () => , + parameters: { + docs: { description: { story: 'Text input with a visible label.' } }, + }, +}; + +export const RequiredField: Story = { + render: () => , + parameters: { + docs: { description: { story: 'Required fields render a red asterisk next to the label.' } }, + }, +}; + +export const WithValidationError: Story = { + render: () => { + const [value, setValue] = useState('not-a-key'); + return ( +
+ {}} + touched={true} + error="Must be a valid Stellar public key starting with G" + placeholder="G..." + required + /> +
+ ); + }, + parameters: { + docs: { + description: { + story: + 'Error state after field is touched. The error message is announced via `aria-live="polite"` and the input gets `aria-invalid="true"`.', + }, + }, + }, +}; + +export const NoErrorWhenUntouched: Story = { + render: () => { + const [value, setValue] = useState(''); + return ( +
+ +
+ ); + }, + parameters: { + docs: { + description: { + story: + 'Error exists in state but `touched=false` — error is suppressed until the user blurs. Prevents premature red states on load.', + }, + }, + }, +}; + +export const DisabledState: Story = { + render: () => , + parameters: { + docs: { description: { story: 'Disabled input — reduced opacity and `not-allowed` cursor.' } }, + }, +}; + +export const EmailInput: Story = { + render: () => { + const [value, setValue] = useState('bad-email'); + return ( +
+ +
+ ); + }, +}; + +export const PasswordInput: Story = { + render: () => , + parameters: { + docs: { description: { story: 'Password type — value is masked.' } }, + }, +}; + +export const FormComposition: Story = { + name: 'Form Composition', + render: () => { + const [destination, setDestination] = useState(''); + const [amount, setAmount] = useState(''); + const [destinationTouched, setDestinationTouched] = useState(false); + const [amountTouched, setAmountTouched] = useState(false); + + const destinationError = + destination && !/^G[A-Z2-7]{55}$/.test(destination) + ? 'Must be a valid Stellar public key (starts with G, 56 chars)' + : null; + + const amountError = + amount && (isNaN(Number(amount)) || Number(amount) <= 0) + ? 'Amount must be a positive number' + : null; + + const canSubmit = !destinationError && !amountError && destination && amount; + + return ( +
+
+ Send Payment +
+ + setDestinationTouched(true)} + touched={destinationTouched} + error={destinationError} + placeholder="G..." + required + /> + + setAmountTouched(true)} + touched={amountTouched} + error={amountError} + type="number" + placeholder="0.00" + required + /> + + +
+ ); + }, + parameters: { + docs: { + description: { + story: + 'Composing multiple ValidatedInput fields in a form. Validation runs on blur; errors are cleared as the user types a valid value.', + }, + }, + }, +}; + +export const MobileViewport: Story = { + render: () => , + parameters: { + viewport: { defaultViewport: 'mobile375' }, + docs: { description: { story: 'Form at mobile viewport.' } }, + }, +};