Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0e6c5c1
fix(goodreserve-widget): align success screen and sizing with Figma
Ryjen1 Jun 15, 2026
078f35c
fix(goodreserve-widget): make the amount input editable on web
Ryjen1 Jun 15, 2026
3ce87bc
fix(goodreserve-widget): make swap math and execution safe
Ryjen1 Jun 15, 2026
f6e649b
refactor(goodreserve-widget): harden adapter state machine and chain …
Ryjen1 Jun 15, 2026
540f76f
fix(goodreserve-widget): apply amount input textAlign via DOM style
Ryjen1 Jun 15, 2026
4609f28
feat(goodreserve-widget): align layout and colors to the Figma reference
Ryjen1 Jun 15, 2026
81b3b49
fix(goodreserve-widget): correct success amount, explorer link, and q…
Ryjen1 Jun 15, 2026
624120c
fix(goodreserve-widget): harden adapter state, callbacks, and add sta…
Ryjen1 Jun 15, 2026
ffc2a65
fix(goodreserve-widget): resolve idle status, quote freshness, and er…
Ryjen1 Jun 15, 2026
7d405dc
refactor(goodreserve-widget): move styling onto the theming contract
Ryjen1 Jun 15, 2026
c92ecbc
fix(goodreserve-widget): finish theming migration and fix sub-theme c…
Ryjen1 Jun 15, 2026
cd05c0b
feat(goodreserve-widget): integrate the real GoodReserve SDK behind a…
Ryjen1 Jun 15, 2026
d62cbcc
fix(goodreserve-widget): correct price label, explorer URL, and themi…
Ryjen1 Jun 15, 2026
55e05a1
test(storybook): raise test-runner timeout to avoid cold-start flakes
Ryjen1 Jun 15, 2026
1fa9eaf
chore: update test screenshots from latest test execution
Ryjen1 Jun 15, 2026
599d8ee
chore(goodreserve-widget): link SDK seam to PR #35, align XDC, drop t…
Ryjen1 Jun 17, 2026
a8a4d9f
fix(storybook): restore quote-ready-xdc story export
Ryjen1 Jun 17, 2026
54c18dd
fix(goodreserve-widget): revert XDC explorer to xdcscan.com
Ryjen1 Jun 18, 2026
d546849
fix(goodreserve-widget): integrate real SDK, decompose view, fix tests
Ryjen1 Jun 22, 2026
4762bf7
fix(goodreserve-widget): source chain ids from SDK, use SDK chain val…
Ryjen1 Jun 22, 2026
75271b7
chore: update test screenshots
Ryjen1 Jun 24, 2026
7f6ad6f
Revert "chore: update test screenshots"
Ryjen1 Jun 24, 2026
1944654
feat: extract drawers, fix transaction success flow, add live wallet …
Ryjen1 Jun 24, 2026
f57c6af
fix adapter file to show success immediately when we get the hash
Ryjen1 Jun 25, 2026
3d70c37
fix: address review feedback - wait for receipt before success, gate …
Ryjen1 Jun 25, 2026
1b7a857
fix: update Drawer story test to search in document body for portal-r…
Ryjen1 Jun 25, 2026
bdb48b7
fix: update Drawer test to search in document body for portal content
Ryjen1 Jun 25, 2026
aa25ab1
fix: address review feedback - design tokens, hook decomposition, pre…
Ryjen1 Jun 30, 2026
31080a6
fix: refactor CTA button state using lookup table pattern
Ryjen1 Jun 30, 2026
6e5d208
fix: correct decimals fallback — G$=18 on all reserve chains, stable=…
Ryjen1 Jun 30, 2026
02db07c
fix: update E2E tests, align story selectors, and regenerate snapshots
Ryjen1 Jul 1, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ coverage/
.vite/
.codex
/test-results/
# Per-widget tests/<widget>/test-results/ folders are tracked as UI evidence
# baselines (mirrors citizen-claim-widget's pattern). The root /test-results/
# above is the Playwright transient trace/video output and stays ignored.
playwright-report/
**/storybook-static/
**/.yalc
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,10 @@ GoodWidget/
- Story interaction checks: `pnpm test:storybook`.
- Playwright QA/state-flow checks: `pnpm test:demo`.
- Root Playwright runtime artifacts (trace/video/attachments): `/test-results/` (gitignored).
- Committable screenshot evidence: `tests/design-system/test-results/` and
`tests/widgets/<widget-name>/test-results/`.
- Nested widget Playwright test-runs (`tests/**/test-results/`) are gitignored

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Nested widget Playwright test-runs (`tests/**/test-results/`) are gitignored
- Committable screenshot evidence: `tests/design-system/test-results/` and
`tests/widgets/<widget-name>/test-results/`.

transient run output. The canonical visual evidence for a widget lives in
`examples/storybook/src/stories/<widget>/screenshots/` (curated, deterministic,
generated by the screenshot regen script) — commit only that curated set.
- Detailed workflow, fixture behavior, and QA reporting template live in
[`docs/demo-environment.md`](docs/demo-environment.md) and [`docs/qa-guide.md`](docs/qa-guide.md).

Expand Down
2 changes: 0 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,6 @@ Key patterns in `packages/claim-widget/src/ClaimWidget.tsx`:

## Demo and Review Environment

<<<<<<< HEAD

### React web demo lab

`examples/react-web` is a route-based Vite + React + RN-web SPA that serves as
Expand Down
11 changes: 11 additions & 0 deletions examples/storybook/.storybook/test-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { TestRunnerConfig } from '@storybook/test-runner'

const config: TestRunnerConfig = {
setup() {
// Increase Jest timeout from the default 15000ms to 60000ms
// to prevent cold-start compilation timeouts in CI/test runner.
jest.setTimeout(60000)
},
}

export default config
56 changes: 46 additions & 10 deletions examples/storybook/src/fixtures/goodReserveWidgetMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,19 @@ export const reserveWidgetMockStates: Record<string, Partial<ReserveSwapWidgetAd
chainId: 8453,
address: '0x1111111111111111111111111111111111111111',
},
sdkInitializing: {
status: 'sdk_initializing',
hasProvider: true,
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
},
idleBuy: {
status: 'idle_buy',
status: 'idle',
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
hasProvider: true,
tokenInSymbol: 'USDm',
tokenOutSymbol: 'G$',
tokenInBalance: '120.00',
tokenOutBalance: '10340.22',
inputAmount: '',
Expand All @@ -30,13 +38,15 @@ export const reserveWidgetMockStates: Record<string, Partial<ReserveSwapWidgetAd
hasProvider: true,
inputAmount: '25',
tokenInBalance: '120.00',
tokenOutBalance: '10340.22',
},
quoteLoading: {
status: 'quote_loading',
chainId: 42220,
hasProvider: true,
inputAmount: '25',
tokenInBalance: '120.00',
tokenOutBalance: '10340.22',
},
quoteReady: {
status: 'quote_ready',
Expand All @@ -46,9 +56,9 @@ export const reserveWidgetMockStates: Record<string, Partial<ReserveSwapWidgetAd
tokenInBalance: '120.00',
quote: {
outputAmount: '108.2500',
price: '0.2310',
price: '4.33000',
minimumReceived: '108.1417',
priceImpactPercent: '~0.01%',
priceImpactPercent: 'N/A',
exitContributionPercent: '0%',
},
},
Expand Down Expand Up @@ -80,9 +90,9 @@ export const reserveWidgetMockStates: Record<string, Partial<ReserveSwapWidgetAd
inputAmount: '25',
quote: {
outputAmount: '108.2500',
price: '0.2310',
price: '4.33000',
minimumReceived: '108.1417',
priceImpactPercent: '~0.01%',
priceImpactPercent: 'N/A',
exitContributionPercent: '0%',
},
},
Expand All @@ -93,17 +103,23 @@ export const reserveWidgetMockStates: Record<string, Partial<ReserveSwapWidgetAd
inputAmount: '25',
quote: {
outputAmount: '108.2500',
price: '0.2310',
price: '4.33000',
minimumReceived: '108.1417',
priceImpactPercent: '~0.01%',
priceImpactPercent: 'N/A',
exitContributionPercent: '0%',
},
},
swapSuccess: {
status: 'swap_success',
chainId: 42220,
hasProvider: true,
txHash: '0xabc123',
tokenOutSymbol: 'G$',
tokenOutBalance: '12,500',
// Post-swap reality: quote is cleared and the received amount is preserved
// in lastSwapOutput (distinct from the wallet balance).
lastSwapOutput: '10,230',
quote: null,
txHash: '0xabc1230000000000000000000000000000000000000000000000000000000000',
},
swapError: {
status: 'swap_error',
Expand All @@ -123,9 +139,29 @@ export const reserveWidgetMockStates: Record<string, Partial<ReserveSwapWidgetAd
inputAmount: '40',
quote: {
outputAmount: '8.9231',
price: '4.4820',
price: '0.22308',
minimumReceived: '8.9142',
priceImpactPercent: '~0.02%',
priceImpactPercent: 'N/A',
exitContributionPercent: '0%',
},
},
// Buy-ready state on XDC (chain 50) — exercises the dynamic network label and
// the USDC stable-token symbol used on XDC.
xdcQuoteReady: {
status: 'quote_ready',
chainId: 50,
hasProvider: true,
direction: 'buy',
tokenInSymbol: 'USDC',
tokenOutSymbol: 'G$',
tokenInBalance: '500.00',
tokenOutBalance: '0.00',
inputAmount: '50',
quote: {
outputAmount: '216.5000',
price: '4.33000',
minimumReceived: '216.2835',
priceImpactPercent: 'N/A',
exitContributionPercent: '0%',
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export const Default: Story = {
const trigger = canvas.getByRole('button', { name: /open drawer/i })
await userEvent.click(trigger)
// After clicking, the Close button should appear inside the Drawer
await expect(canvas.getByRole('button', { name: /close/i })).toBeDefined()
// The Drawer is rendered in a portal, so we need to search the entire document
const closeButton = await within(document.body).findByRole('button', { name: /close/i })
await expect(closeButton).toBeDefined()
},
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

misses injected provider demo, cannot be tested now.
how have you tested the flow?

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type Story = StoryObj<typeof meta>

// Renders one deterministic reserve state per story for CI-safe widget coverage.
const renderStory = (mockState: Story['args']['mockState'], dataTestId: string) => (
<div data-testid={dataTestId} style={{ width: 380 }}>
<div data-testid={dataTestId} style={{ width: 390 }}>
<GoodReserveWidget provider={provider} mockState={mockState} />
</div>
)
Expand All @@ -27,6 +27,11 @@ export const NoProvider: Story = {
render: () => renderStory(reserveWidgetMockStates.noProvider, 'GoodReserveWidget-no-provider'),
}

export const SdkInitializing: Story = {
render: () =>
renderStory(reserveWidgetMockStates.sdkInitializing, 'GoodReserveWidget-sdk-initializing'),
}

export const UnsupportedChain: Story = {
render: () =>
renderStory(reserveWidgetMockStates.unsupportedChain, 'GoodReserveWidget-unsupported-chain'),
Expand All @@ -53,6 +58,11 @@ export const QuoteReadySell: Story = {
renderStory(reserveWidgetMockStates.sellQuoteReady, 'GoodReserveWidget-quote-ready-sell'),
}

export const QuoteReadyXdc: Story = {
render: () =>
renderStory(reserveWidgetMockStates.xdcQuoteReady, 'GoodReserveWidget-quote-ready-xdc'),
}

export const QuoteError: Story = {
render: () => renderStory(reserveWidgetMockStates.quoteError, 'GoodReserveWidget-quote-error'),
}
Expand Down Expand Up @@ -82,3 +92,53 @@ export const SwapSuccess: Story = {
export const SwapError: Story = {
render: () => renderStory(reserveWidgetMockStates.swapError, 'GoodReserveWidget-swap-error'),
}

// Live adapter (no mockState) so the real amount-input wiring is exercised.
// Used by the Playwright "types into the input" coverage. The SDK is now
// statically imported and reaches the real getReserveStats/getBuyQuote path
// against a connected wallet provider.
export const Interactive: Story = {
render: () => (
<div data-testid="GoodReserveWidget-interactive" style={{ width: 390 }}>
<GoodReserveWidget provider={provider} />
</div>
),
}

// Live wallet test - uses real MetaMask/wallet extension for end-to-end testing.
// This story requires a browser wallet extension (MetaMask, etc.) to be installed.
// NOT for CI - requires manual testing with real wallet connection.
export const LiveWallet: Story = {
render: () => {
// Check if window.ethereum exists (MetaMask or other wallet extension)
if (typeof window === 'undefined' || !(window as any).ethereum) {
return (
<div style={{ padding: '20px', maxWidth: '400px' }}>
<h2>Wallet Required</h2>
<p>This story requires a browser wallet extension (MetaMask, etc.) to test the live SDK path.</p>
<p><strong>To test:</strong></p>
<ol>
<li>Install MetaMask or another EIP-1193 compatible wallet</li>
<li>Connect to Celo mainnet or XDC network</li>
<li>Refresh this page</li>
<li>The widget will use your real wallet for testing</li>
</ol>
</div>
)
}

// Use the real wallet provider
const walletProvider = (window as any).ethereum

return (
<div data-testid="GoodReserveWidget-live-wallet" style={{ width: 390, minHeight: 600, paddingBottom: 40 }}>
<div style={{ marginBottom: '10px', padding: '10px', backgroundColor: '#fff3cd', border: '1px solid #ffc107', borderRadius: '4px' }}>
<strong>⚠️ Live Wallet Test</strong><br />
Using real wallet: {walletProvider.isMetaMask ? 'MetaMask' : 'Wallet Extension'}<br />
<small>Test the full swap flow: quote → confirm → execute → success</small>
</div>
<GoodReserveWidget provider={walletProvider} />
</div>
)
},
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion packages/citizen-claim-widget/src/CitizenClaimWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ function Countdown({ nextClaim }: { nextClaim: Date }) {
useEffect(() => {
const id = setInterval(() => setTimeLeft(getTimeLeft()), 1000)
return () => clearInterval(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
// getTimeLeft reads `nextClaim` only; intentionally excluded from deps.
}, [nextClaim])

const h = Math.floor(timeLeft / 3600)
Expand Down
2 changes: 1 addition & 1 deletion packages/citizen-claim-widget/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ export function useCitizenClaimAdapter(
// Auto-refresh claim status whenever wallet connection or chain changes
useEffect(() => {
void loadClaimStatus()
// eslint-disable-next-line react-hooks/exhaustive-deps
// Re-run only on wallet identity changes; loadClaimStatus is stable per render.
}, [isConnected, address, chainId])

// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface WalletContextValue extends WalletState {
connect: () => Promise<void>
}

export interface HostContextValue extends HostState {}
export type HostContextValue = HostState

export interface GoodWidgetContextValue extends GoodWidgetState {
connect: () => Promise<void>
Expand Down
1 change: 1 addition & 0 deletions packages/goodreserve-widget/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"react-dom": ">=18.0.0"
},
"dependencies": {
"@goodsdks/good-reserve": "^0.1.0",
"@goodwidget/core": "workspace:*",
"@goodwidget/embed": "workspace:*",
"@goodwidget/ui": "workspace:*",
Expand Down
63 changes: 45 additions & 18 deletions packages/goodreserve-widget/src/GoodReserveWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,59 @@
import React, { useEffect } from 'react'
import React, { useEffect, useMemo, useRef } from 'react'
import { GoodWidgetProvider } from '@goodwidget/core'
import { mergeThemeOverrides } from '@goodwidget/ui'
import type { EIP1193Provider } from '@goodwidget/core'
import { ReserveSwapView } from './ReserveSwapView'
import { useGoodReserveAdapter } from './useGoodReserveAdapter'
import type { ReserveSwapWidgetProps } from './widgetRuntimeContract'
import { goodReserveWidgetConfig } from './config'

function GoodReserveWidgetInner({
onSwapSuccess,
onSwapError,
mockState,
}: Pick<ReserveSwapWidgetProps, 'onSwapSuccess' | 'onSwapError' | 'mockState'>) {
preferredChainId,
}: Pick<
ReserveSwapWidgetProps,
'onSwapSuccess' | 'onSwapError' | 'mockState' | 'preferredChainId'
>) {
const adapter = useGoodReserveAdapter(mockState)
const { status, txHash, error, address, chainId } = adapter.state

// Emits swap lifecycle callbacks for host integrations.
// Hold the host callbacks in refs so inline arrow functions (a new reference
// each parent render) do not re-run the lifecycle effect and re-fire the
// callbacks on an unchanged swap_success / swap_error state.
const onSwapSuccessRef = useRef(onSwapSuccess)
const onSwapErrorRef = useRef(onSwapError)
useEffect(() => {
if (adapter.state.status === 'swap_success' && adapter.state.txHash) {
onSwapSuccess?.({
address: adapter.state.address,
chainId: adapter.state.chainId,
transactionHash: adapter.state.txHash,
})
onSwapSuccessRef.current = onSwapSuccess
}, [onSwapSuccess])
useEffect(() => {
onSwapErrorRef.current = onSwapError
}, [onSwapError])

// Emits swap lifecycle callbacks for host integrations, keyed only on the
// discrete lifecycle fields so it fires once per real status transition.
useEffect(() => {
if (status === 'swap_success' && txHash) {
onSwapSuccessRef.current?.({ address, chainId, transactionHash: txHash })
return
}

if (adapter.state.status === 'swap_error' && adapter.state.error) {
onSwapError?.({
address: adapter.state.address,
chainId: adapter.state.chainId,
message: adapter.state.error,
})
if (status === 'swap_error' && error) {
onSwapErrorRef.current?.({ address, chainId, message: error })
}
}, [adapter.state, onSwapError, onSwapSuccess])
}, [status, txHash, error, address, chainId])

return <ReserveSwapView adapter={adapter} />
return <ReserveSwapView adapter={adapter} preferredChainId={preferredChainId} />
}

/**
* Merges the reserve-widget author defaults with any host-provided config.
* Precedence: goodReserveWidgetConfig < host config < themeOverrides.
* This follows the same pattern as the governance widget's GovernanceWidgetProvider.
*/
function createReserveWidgetConfig(hostConfig?: ReserveSwapWidgetProps['config']) {
return mergeThemeOverrides(goodReserveWidgetConfig, hostConfig)
}

// Public widget entry wired to GoodWidget runtime context + theming contract.
Expand All @@ -44,18 +65,24 @@ export function GoodReserveWidget({
onSwapSuccess,
onSwapError,
mockState,
preferredChainId,
}: ReserveSwapWidgetProps) {
// Merge the widget author config with any host config. Memoised so the
// Tamagui config object is stable across parent renders.
const mergedConfig = useMemo(() => createReserveWidgetConfig(config), [config])

return (
<GoodWidgetProvider
provider={provider as EIP1193Provider | undefined}
config={config}
config={mergedConfig}
themeOverrides={themeOverrides}
defaultTheme={defaultTheme}
>
<GoodReserveWidgetInner
onSwapSuccess={onSwapSuccess}
onSwapError={onSwapError}
mockState={mockState}
preferredChainId={preferredChainId}
/>
</GoodWidgetProvider>
)
Expand Down
Loading