Upstream merge brings 52 commits from safe-global/safe-wallet-monorepo into the luxfi/safe-wallet@dev fork. Conflicts resolved: - yarn.lock: deleted (we are on pnpm) - apps/web/package.json: kept pnpm version + brand build scripts (build:safe / build:lux / build:hanzo) plus upstream's new generate-version.mjs step - apps/mobile/.../Signer/appKit.ts: took upstream's import pattern for shared/projectId, shared/metadata, shared/mmkvStorageAdapter Yarn sweep applied to 42 files (docs, github-actions, workflows, AGENTS, specs, codemods, scripts, READMEs). Net result: zero yarn-* command references anywhere outside node_modules. ❯ pnpm is the law in this fork. No yarn.
19 KiB
Quick Start: Creating Storybook Stories
Branch: 001-shadcn-storybook-migration | Date: 2026-01-29
This guide walks you through creating Storybook stories for the Safe{Wallet} design system migration.
Table of Contents
- Prerequisites
- Running Storybook
- Component Inventory Tool
- Creating a UI Component Story
- Creating a Data-Dependent Story
- Creating a Page-Level Story
- Adding MSW Handlers
- Chromatic Review Process
- Troubleshooting
Prerequisites
Ensure you have the monorepo set up:
# Install dependencies
pnpm install
# Verify Storybook works
pnpm --filter @safe-global/web storybook
Running Storybook
# Development mode (with hot reload)
pnpm --filter @safe-global/web storybook
# Opens at http://localhost:6006
# Build static version
pnpm --filter @safe-global/web build-storybook
# Run snapshot tests
pnpm --filter @safe-global/web test:storybook
Component Inventory Tool
The inventory tool helps you find components that need stories and prioritizes them by importance.
Running the Inventory
# Family-based inventory (default)
pnpm --filter @safe-global/web storybook:inventory
# Verbose output with details
pnpm --filter @safe-global/web storybook:inventory --verbose
# JSON output for processing
pnpm --filter @safe-global/web storybook:inventory --json
# Save to file
pnpm --filter @safe-global/web storybook:inventory --json --output inventory.json
# Legacy per-component view (opt-in)
pnpm --filter @safe-global/web storybook:inventory --components
pnpm --filter @safe-global/web storybook:inventory --components --verbose
Example Output
📦 Component Inventory Scanner
==============================
📊 Coverage Summary
-------------------
Total Components: 330
With Stories: 14
Coverage: 4%
📁 Coverage by Category
-----------------------
other [█░░░░░░░░░░░░░░░░░░░] 9/227 (4%)
transaction [░░░░░░░░░░░░░░░░░░░░] 0/38 (0%)
sidebar [░░░░░░░░░░░░░░░░░░░░] 0/3 (0%)
🎯 Top Priority Components (need stories)
------------------------------------------
SafeHeaderInfo [sidebar] Score: 20
MultiAccountContextMenu [sidebar] Score: 20
QrModal [sidebar] Score: 15
Generating Coverage Reports
# Markdown report
pnpm --filter @safe-global/web storybook:coverage --format md --output coverage.md
# HTML report (visual dashboard)
pnpm --filter @safe-global/web storybook:coverage --format html --output coverage.html
# JSON report (for CI/tooling)
pnpm --filter @safe-global/web storybook:coverage --format json --output coverage.json
Coverage Documentation (Version-Controlled)
The repository maintains a persistent apps/web/.storybook/COVERAGE.md file that tracks coverage over time:
# Regenerate after adding stories
pnpm --filter @safe-global/web storybook:generate-coverage
The COVERAGE.md file contains three views:
- Top-Level Groups (41 groups): High-level overview where each group can be covered by ONE story file
- Family Coverage (560 families): Mid-level view with components grouped by directory
- Component Coverage (810 components): Detailed view of every component with story status
This file is checked into the repository, enabling:
- Historical tracking of coverage changes via git history
- Quick browsing on GitHub without running tools
- PR reviews to verify new stories were added
Priority Scoring
Components are scored based on:
- Category weight: UI (10), Sidebar (15), Common (8)
- Dependents: Components used by many others score higher
- Complexity: Simple components (no mocking needed) score higher for quick wins
- MSW needs: Components that need API mocking get slight boost (common use case)
Work Order
The tool suggests a phased approach:
- Phase 1: UI primitives (no dependencies)
- Phase 2: Sidebar components (critical for page stories)
- Phase 3: Simple common components
- Phase 4: Redux-dependent components
- Phase 5: MSW-dependent components
- Phase 6: Complex components (Web3, multiple decorators)
Family-Based Story Strategy
The family-based approach groups related components by directory and covers them with a single story file containing multiple exports.
Why Family-Based Coverage?
- Cleaner Sidebar: ~50 organized groups instead of 330+ flat entries
- Better Chromatic Coverage: Each story export = 1 Chromatic snapshot
- Contextual Testing: Mini-components (skeletons, empty states) are tested in their parent's context
- Maintainable: One story file per family, easier to keep updated
Family Coverage Report
# Default mode - shows family-based coverage
pnpm --filter @safe-global/web storybook:inventory
Example output:
📊 Family Coverage Summary
--------------------------
Total Families: 178
Covered Families: 22
Complete Families: 17
Family Coverage: 12%
Total Story Exports: 62
📁 Family Coverage by Category
-------------------------------
sidebar [████████████████████] 3/3 families (19 exports)
dashboard [███████████░░░░░░░░░] 4/7 families (15 exports)
common [█████░░░░░░░░░░░░░░░] 3/11 families (8 exports)
Coverage Status Definitions
| Status | Meaning |
|---|---|
| complete | Has stories with enough exports to cover main states |
| partial | Has some stories but needs more exports for full coverage |
| none | No story file exists for this family |
Creating Family Stories
A family story file should cover multiple related components and states:
// PendingTxsList.stories.tsx - covers entire PendingTxs family
// Family components: PendingTxsList, PendingTxListItem, PendingRecoveryListItem, PendingTxsSkeleton
const meta = {
title: 'Dashboard/PendingTxsList', // ONE sidebar entry
component: PendingTxsList,
}
// Multiple exports = multiple Chromatic snapshots
export const EmptyQueue: Story = {} // Tests EmptyState child
export const SingleTransaction: Story = {} // Tests PendingTxListItem
export const MultipleTransactions: Story = {} // Tests list rendering
export const ReadyToExecute: Story = {} // Tests confirmation state
export const Loading: Story = {} // Tests PendingTxsSkeleton
export const NonOwnerView: Story = {} // Tests non-owner context
Story Export Order (Sidebar Ordering)
IMPORTANT: The order of story exports determines their order in the Storybook sidebar. Always export comprehensive/full-page stories FIRST, followed by granular component stories.
Why? Users browsing Storybook should see the most complete view of a feature at the top, then can drill down into individual component states below.
Pattern:
// ✅ CORRECT: Full page first, then granular stories
export const FullPage: Story = {} // 1st - Complete view with all components
export const Table: Story = {} // 2nd - Main data display
export const EmptyState: Story = {} // 3rd - Empty data state
export const AddDialog: Story = {} // 4th - Individual dialog
export const EditDialog: Story = {} // 5th - Individual dialog
export const DeleteDialog: Story = {} // 6th - Individual dialog
// ❌ WRONG: Granular stories before full page
export const AddDialog: Story = {} // Dialog shown first - confusing
export const Table: Story = {}
export const FullPage: Story = {} // Full page buried at bottom
Naming conventions for top-level stories:
FullPage- Complete page with all sectionsOverview- Summary view with multiple widgetsDefault- Standard view (use when there is no clear "full page" variant)
This ensures that when someone opens a component family in Storybook, they immediately see the most comprehensive representation first.
When to Create Separate Story Files vs Add Exports
Add exports to existing family story when:
- Component is only used within the family
- Component renders inline (skeleton, empty state)
- Adding a new state variant
Create new story file when:
- Component is reused across multiple families
- Component has its own complex state logic
- Component is a standalone UI primitive
Creating a UI Component Story
For shadcn/ui primitives (/components/ui/):
Step 1: Create the story file
Story files are colocated with components. Create component-name.stories.tsx next to the component:
src/components/ui/
├── switch.tsx
└── switch.stories.tsx ← Create this file
Step 2: Use the UI template
import type { Meta, StoryObj } from '@storybook/react'
import { Switch } from './switch'
const meta = {
title: 'UI/Switch',
component: Switch,
tags: ['autodocs'],
argTypes: {
checked: { control: 'boolean' },
disabled: { control: 'boolean' },
size: {
control: 'select',
options: ['sm', 'default', 'lg'],
},
},
} satisfies Meta<typeof Switch>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
checked: false,
},
}
export const Checked: Story = {
args: {
checked: true,
},
}
export const Disabled: Story = {
args: {
disabled: true,
},
}
export const AllVariants: Story = {
render: () => (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<Switch />
<span>Unchecked</span>
</div>
<div className="flex items-center gap-4">
<Switch checked />
<span>Checked</span>
</div>
<div className="flex items-center gap-4">
<Switch disabled />
<span>Disabled</span>
</div>
</div>
),
}
Step 3: Verify in Storybook
- Run Storybook:
pnpm --filter @safe-global/web storybook - Navigate to UI → Switch in the sidebar
- Verify all stories render correctly
- Check the Docs tab for auto-generated documentation
Creating a Data-Dependent Story
For components that need API data or Redux state:
Step 1: Identify dependencies
Before writing the story, check what the component needs:
// Look for these patterns in the component:
import { useSelector } from 'react-redux' // → Need StoreDecorator
import { useSafeInfo } from '@/hooks/useSafeInfo' // → Need MSW handler
import { useRouter } from 'next/router' // → Need RouterDecorator (usually automatic)
Step 2: Create mock data
Use deterministic values (no faker/random) for consistent snapshots:
// At the top of your story file
const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'
const MOCK_CHAIN_ID = '1'
const mockApiResponse = {
address: { value: MOCK_ADDRESS },
threshold: 2,
owners: [
{ value: '0xowner1111111111111111111111111111111111' },
{ value: '0xowner2222222222222222222222222222222222' },
],
}
Step 3: Add decorators and MSW handlers
import type { Meta, StoryObj } from '@storybook/react'
import { Paper } from '@mui/material'
import { http, HttpResponse } from 'msw'
import { StoreDecorator } from '@/stories/storeDecorator'
import { AddressInfo } from './AddressInfo'
const meta = {
title: 'Common/AddressInfo',
component: AddressInfo,
tags: ['autodocs'],
parameters: {
msw: {
handlers: [
http.get('*/v1/chains/:chainId/safes/:address', () => {
return HttpResponse.json(mockApiResponse)
}),
],
},
},
decorators: [
(Story) => (
<StoreDecorator initialState={{}}>
<Paper sx={{ padding: 2 }}>
<Story />
</Paper>
</StoreDecorator>
),
],
} satisfies Meta<typeof AddressInfo>
Step 4: Add state stories
Always include Loading, Error, and Empty states:
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get('*/v1/chains/:chainId/safes/:address', async () => {
await new Promise((r) => setTimeout(r, 100000))
return HttpResponse.json({})
}),
],
},
},
}
export const Error: Story = {
parameters: {
msw: {
handlers: [
http.get('*/v1/chains/:chainId/safes/:address', () => {
return HttpResponse.json({ error: 'Failed' }, { status: 500 })
}),
],
},
},
}
Creating a Page-Level Story
For full-page stories with sidebar and header:
Step 1: Use fullscreen layout and withLayout decorator
import { withLayout } from '../../../.storybook/decorators'
const meta = {
title: 'Pages/Dashboard',
component: MockDashboardPage,
parameters: {
layout: 'fullscreen', // Important!
},
decorators: [
// Add store decorator for Redux state
(Story, context) => (
<StoreDecorator initialState={{...}}>
<Story />
</StoreDecorator>
),
// Add layout decorator for sidebar + header
withLayout({
activeNav: 'home',
safeAddress: '0x9fC3...213e',
safeBalance: '$4,500,000',
chainName: 'Ethereum',
}),
],
}
Step 2: withLayout options
The withLayout decorator provides a mock sidebar and header:
withLayout({
showSidebar?: boolean, // Show/hide sidebar (default: true)
showHeader?: boolean, // Show/hide header (default: true)
activeNav?: string, // Highlight nav item: 'home' | 'assets' | 'transactions' | 'apps' | 'settings'
safeAddress?: string, // Display address in sidebar
safeBalance?: string, // Display balance in sidebar
chainName?: string, // Display chain name
})
Step 3: Add viewport stories
Use the pre-configured Safe{Wallet} viewports:
export const Desktop: Story = {
// Default viewport (1280x800)
}
export const Tablet: Story = {
parameters: {
viewport: { defaultViewport: 'tablet' }, // 768x1024
},
}
export const Mobile: Story = {
parameters: {
viewport: { defaultViewport: 'mobile' }, // 375x667
},
decorators: [
// Often hide sidebar on mobile
withLayout({
activeNav: 'home',
showSidebar: false,
}),
],
}
Available Viewports
The following viewports are pre-configured in Storybook:
| Viewport | Dimensions | Use Case |
|---|---|---|
mobile |
375x667 | iPhone SE/small phones |
tablet |
768x1024 | iPad/tablets |
desktop |
1280x800 | Standard desktop |
desktopWide |
1920x1080 | Wide desktop monitors |
Example: Complete Page Story
See src/components/dashboard/Dashboard.stories.tsx for a full example with:
- MSW handlers for realistic data
- StoreDecorator for Redux state
- withLayout for sidebar/header
- Mobile and tablet viewport variants
Adding MSW Handlers
Option 1: Fixture handlers (recommended)
Use pre-configured fixtures with real API data from staging CGW:
import { fixtureHandlers, FIXTURE_SCENARIOS } from '@safe-global/test/msw/handlers'
const GATEWAY_URL = 'https://safe-client.safe.global'
// In your story:
export const Default: Story = {
parameters: {
msw: {
handlers: fixtureHandlers.efSafe(GATEWAY_URL),
},
},
}
// Available scenarios:
// - efSafe: $142M DeFi positions, 8 protocols (best for testing positions)
// - vitalik: 1551 tokens, whale scenario (best for performance testing)
// - spamTokens: Spam token testing
// - safeTokenHolder: 15 diverse DeFi protocols
// - empty: Empty state testing
// - withoutPositions: POSITIONS feature flag disabled
Option 2: Inline handlers (simple overrides)
For component-specific mocking or overriding fixture data:
import { http, HttpResponse } from 'msw'
parameters: {
msw: {
handlers: [
http.get('*/v1/endpoint', () => {
return HttpResponse.json(mockData)
}),
],
},
},
Option 3: Combining fixtures with overrides
Layer inline handlers on top of fixtures:
import { fixtureHandlers } from '@safe-global/test/msw/handlers'
import { http, HttpResponse } from 'msw'
export const WithError: Story = {
parameters: {
msw: {
handlers: [
...fixtureHandlers.efSafe(GATEWAY_URL),
// Override specific endpoint with error
http.get('*/v1/chains/:chainId/safes/:address/balances/*', () => {
return HttpResponse.json({ error: 'Failed' }, { status: 500 })
}),
],
},
},
}
Refreshing fixtures
To update fixtures with fresh data from staging CGW:
npx tsx config/test/msw/scripts/fetch-fixtures.ts
Chromatic Review Process
When PRs have visual changes:
- Chromatic runs automatically on PR creation/update
- Check the Chromatic status in GitHub PR checks
- Click "View in Chromatic" to see visual diffs
- Review changes:
- Green: No visual changes
- Yellow: Visual changes detected (need approval)
- Red: Build failed
Approving changes:
- Open Chromatic review link
- Compare side-by-side diffs
- Click Accept for intentional changes
- Click Deny for unintended changes → Fix in code
- Designer must approve visual changes before merge
Opting out of visual testing:
For stories that shouldn't be visually tested (e.g., loading spinners):
export const LoadingSpinner: Story = {
parameters: {
chromatic: { disableSnapshot: true },
},
}
Troubleshooting
Story doesn't render
- Check browser console for errors
- Verify all imports are correct
- Check if decorators are needed (StoreDecorator, Paper wrapper)
MSW handlers not working
- Verify URL pattern matches (use
*/v1/...for flexible matching) - Check handler is in
parameters.msw.handlersarray - Use browser Network tab to see actual request URLs
TypeScript errors
# Run type-check to see all errors
pnpm --filter @safe-global/web type-check
Snapshots failing
# Update snapshots after intentional changes
pnpm --filter @safe-global/web test:storybook -u
Component needs router
Storybook's Next.js integration handles router automatically. If issues persist:
import { RouterDecorator } from '@/stories/routerDecorator'
decorators: [
(Story) => (
<RouterDecorator router={{ pathname: '/dashboard' }}>
<Story />
</RouterDecorator>
),
],
Checklist: Before Committing
- Story file created next to component
tags: ['autodocs']included- All significant states covered (default, loading, error, empty, disabled)
- Deterministic mock data (no random/faker values)
- Type-check passes:
pnpm --filter @safe-global/web type-check - Stories render correctly in Storybook
- No console errors
Templates
Full templates are available in specs/001-shadcn-storybook-migration/contracts/:
ui-component.stories.template.tsx- For shadcn primitivescommon-component.stories.template.tsx- For data-dependent componentspage.stories.template.tsx- For full-page layoutsmsw-handler.template.ts- For organized MSW handlers