Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/storybook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Storybook

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

# Needed so the update job can push regenerated baselines back to the branch.
permissions:
contents: write

jobs:
visual-regression:
runs-on: ubuntu-latest
# Run inside the Playwright image so Chromium + system fonts match exactly how
# the committed baselines are generated. Keep this tag in sync with the
# `playwright` version in package.json. See CONTRIBUTING.md.
container:
image: mcr.microsoft.com/playwright:v1.60.0-noble

steps:
- uses: actions/checkout@v4

- name: Install pnpm
run: npm install -g pnpm@9.15.9

- run: pnpm install --frozen-lockfile

- name: Ensure Chromium is installed
run: pnpm exec playwright install chromium

- name: Build Storybook
run: pnpm build-storybook --quiet

# On PRs: verify every story against the committed baselines (fail on diff).
# On main / manual runs: (re)generate baselines so they live in the canonical
# CI environment.
- name: Visual regression
run: |
pnpm exec concurrently -k -s first -n SB,TEST \
"pnpm exec http-server storybook-static --port 6006 --silent" \
"pnpm exec wait-on tcp:127.0.0.1:6006 && pnpm test-storybook --url http://127.0.0.1:6006 ${{ github.event_name == 'pull_request' && ' ' || '-u' }}"

- name: Commit updated baselines
if: github.event_name != 'pull_request'
run: |
if [ -n "$(git status --porcelain __image_snapshots__)" ]; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add __image_snapshots__
git commit -m "chore: update storybook visual baselines [skip ci]"
git push
fi

- name: Upload snapshot diffs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: image-snapshot-diffs
path: '**/__diff_output__/**'
if-no-files-found: ignore
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ dist/
.env.*
reference/
*.local
storybook-static/
**/__diff_output__/
19 changes: 19 additions & 0 deletions .storybook/decorators/withChain.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useState } from 'react';
import type { Decorator } from '@storybook/react';
import { ChainContext, type Chain } from '@/context/ChainContext';

/**
* Provides a fake ChainContext so components that call `useChain()` render in
* isolation. The active chain is stateful, so the ChainSwitcher dropdown stays
* interactive inside a story.
*/
export function withChain(initialChain: Chain = 'horizen'): Decorator {
return function ChainDecorator(Story) {
const [chain, setChain] = useState<Chain>(initialChain);
return (
<ChainContext.Provider value={{ chain, setChain }}>
<Story />
</ChainContext.Provider>
);
};
}
45 changes: 45 additions & 0 deletions .storybook/decorators/withStealthKeys.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ContextType } from 'react';
import type { Decorator } from '@storybook/react';
import { StealthKeysContext } from '@/context/StealthKeysContext';

type StealthKeysValue = NonNullable<ContextType<typeof StealthKeysContext>>;

const noop = () => {};

const baseValue: StealthKeysValue = {
evmKeys: null,
evmMetaAddress: null,
stellarKeys: null,
stellarMetaAddress: null,
solanaKeys: null,
solanaMetaAddress: null,
ckbKeys: null,
ckbMetaAddress: null,
setEvmKeys: noop,
setEvmMetaAddress: noop,
setStellarKeys: noop,
setStellarMetaAddress: noop,
setSolanaKeys: noop,
setSolanaMetaAddress: noop,
setCkbKeys: noop,
setCkbMetaAddress: noop,
clearEvm: noop,
clearStellar: noop,
clearSolana: noop,
clearCkb: noop,
};

/**
* Provides a fake StealthKeysContext. Pass overrides (e.g. `stellarKeys`,
* `stellarMetaAddress`) to simulate a wallet that has already derived keys.
* Setters default to no-ops so stories never mutate real state.
*/
export function withStealthKeys(overrides: Partial<StealthKeysValue> = {}): Decorator {
return function StealthKeysDecorator(Story) {
return (
<StealthKeysContext.Provider value={{ ...baseValue, ...overrides }}>
<Story />
</StealthKeysContext.Provider>
);
};
}
35 changes: 35 additions & 0 deletions .storybook/decorators/withStellarWallet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Decorator } from '@storybook/react';
import { StellarWalletContext } from '@/context/StellarWalletContext';

interface StellarWalletOverrides {
address?: string | null;
isConnected?: boolean;
connect?: () => Promise<void>;
disconnect?: () => void;
signMessage?: (message: string) => Promise<Uint8Array>;
signTransaction?: (xdr: string) => Promise<string>;
}

/**
* Provides a fake StellarWalletContext with stubbed async methods, so no story
* ever touches Freighter or the network. Pass `address` to simulate a connected
* wallet; `isConnected` defaults to `address !== null`.
*/
export function withStellarWallet(overrides: StellarWalletOverrides = {}): Decorator {
const address = overrides.address ?? null;
const value = {
address,
isConnected: overrides.isConnected ?? address !== null,
connect: overrides.connect ?? (async () => {}),
disconnect: overrides.disconnect ?? (() => {}),
signMessage: overrides.signMessage ?? (async () => new Uint8Array(64)),
signTransaction: overrides.signTransaction ?? (async () => ''),
};
return function StellarWalletDecorator(Story) {
return (
<StellarWalletContext.Provider value={value}>
<Story />
</StellarWalletContext.Provider>
);
};
}
69 changes: 69 additions & 0 deletions .storybook/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { StellarMatchCardProps } from '@/components/StellarMatchCard';

/** A fake Stellar stealth meta-address (`st:xlm:` + 64-byte hex), for display only. */
export const SAMPLE_META_ADDRESS =
'st:xlm:' +
'3b9a4c2e8f1d6705a2c4e6981b3d5f70' +
'9e2c4a6088d1f3b5d7092e4c6a8b0d2f' +
'1a3c5e7088b0d2f406182a3c5e7088b0' +
'd2f406182a4c6e8088a2c4e6088b0d2f';

/** A handful of fake but well-formed-looking Stellar account addresses (G…, 56 chars). */
const SAMPLE_STEALTH_ADDRESSES = [
'GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX',
'GCKFBEIYTKP6RCZX6YQX3FNGPXY7QHFB7TQVUMHDLPZ6KUVCZNFP7B4S',
'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ',
'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H',
'GDRXE2BQUC3AZNPVFSCEZ76NJ3WWL25FYFK6RGZGIEKWE4SOOHSUJUJ6',
];

/** A single fake Stellar stealth address (G…), for send/receive result stories. */
export const SAMPLE_STEALTH_ADDRESS = SAMPLE_STEALTH_ADDRESSES[0];

/** A fake 64-char hex stealth private scalar. */
export const SAMPLE_SCALAR_HEX = 'a3f1c94d7e2b80561fd0e9c4a8b62370d1559e4cab8f0273e6d419a5cf0b8d24';

/** A fake 64-char hex transaction hash. */
export const SAMPLE_TX_HASH = '7c1e4b2a9f0d3856e1c7a4b0d92f5e83a6c0419d7e2b8053fd0e9c4a8b623701';

function addressForIndex(i: number): string {
if (i < SAMPLE_STEALTH_ADDRESSES.length) return SAMPLE_STEALTH_ADDRESSES[i];
// Derive a deterministic, plausible-looking G… address for larger lists.
const seed = SAMPLE_STEALTH_ADDRESSES[i % SAMPLE_STEALTH_ADDRESSES.length];
const tail = (i * 7).toString(32).toUpperCase().replace(/[018]/g, 'A').padStart(3, '2');
return seed.slice(0, 53) + tail.slice(-3);
}

/**
* Builds a single `StellarMatchCard` prop set. Defaults to a loaded, funded
* match; pass overrides to model loading / error / withdrawn states.
*/
export function makeMatch(
i = 0,
overrides: Partial<StellarMatchCardProps> = {},
): StellarMatchCardProps {
return {
stealthAddress: addressForIndex(i),
scalarHex: SAMPLE_SCALAR_HEX,
balance: (10 + i).toFixed(7),
balanceState: 'loaded',
dest: '',
withdrawing: false,
withdrawHash: null,
feeBumpHash: null,
error: '',
showKey: false,
showSponsorPrompt: false,
onDestChange: () => {},
onWithdraw: () => {},
onSponsoredWithdraw: () => {},
onCancelSponsor: () => {},
onRevealKey: () => {},
...overrides,
};
}

/** Builds `n` funded matches with varying addresses and balances. */
export function makeMatches(n: number): StellarMatchCardProps[] {
return Array.from({ length: n }, (_, i) => makeMatch(i));
}
13 changes: 13 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/addon-a11y'],
framework: {
name: '@storybook/react-vite',
options: {},
},
staticDirs: ['../public'],
};

export default config;
6 changes: 6 additions & 0 deletions .storybook/preview-head.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
32 changes: 32 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Buffer } from 'buffer';
(window as unknown as Record<string, unknown>).Buffer = Buffer;

import type { Preview } from '@storybook/react';
import { initialize, mswLoader } from 'msw-storybook-addon';
import '../src/index.css';

// Start the mock service worker so no story can make a real network request.
initialize({ onUnhandledRequest: 'bypass' });

const preview: Preview = {
parameters: {
backgrounds: {
default: 'surface',
values: [{ name: 'surface', value: '#0e0e0e' }],
},
controls: { expanded: true },
a11y: { context: '#storybook-root' },
},
loaders: [mswLoader],
decorators: [
(Story) => (
<div className="dark min-h-screen bg-surface p-6 font-body text-on-surface antialiased">
<div className="mx-auto w-full max-w-[720px]">
<Story />
</div>
</div>
),
],
};

export default preview;
46 changes: 46 additions & 0 deletions .storybook/test-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { TestRunnerConfig } from '@storybook/test-runner';
import { toMatchImageSnapshot } from 'jest-image-snapshot';

/**
* Visual-regression config for `@storybook/test-runner`. Every story is rendered
* in headless Chromium, its play function is run, and a full-page screenshot is
* diffed against the committed baseline in `__image_snapshots__/`.
*
* Baselines are platform-sensitive (font anti-aliasing differs across OSes), so
* generate/update them in the CI-matching Playwright Docker image. See
* CONTRIBUTING.md.
*/
const config: TestRunnerConfig = {
setup() {
expect.extend({ toMatchImageSnapshot });
},
async preVisit(page) {
// Headless Chromium denies clipboard writes, which would throw inside copy
// stories. Grant the permission and stub the (writable) prototype method so
// the UI state transition is what gets tested.
await page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'])
.catch(() => {});
await page.addInitScript(() => {
if (typeof Clipboard !== 'undefined' && Clipboard.prototype) {
Clipboard.prototype.writeText = () => Promise.resolve();
Clipboard.prototype.readText = () => Promise.resolve('');
}
});
},
async postVisit(page, context) {
// Wait for web fonts to settle so snapshots are deterministic.
await page.evaluate(() => document.fonts.ready);

const image = await page.screenshot({ fullPage: true });
expect(image).toMatchImageSnapshot({
customSnapshotsDir: `${process.cwd()}/__image_snapshots__`,
customSnapshotIdentifier: context.id,
failureThreshold: 0.02,
failureThresholdType: 'percent',
});
},
};

export default config;
Loading