Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
860bf56
Initial plan
Copilot May 27, 2026
74815e4
chore: outline goodreserve-widget implementation plan
Copilot May 27, 2026
4037031
feat: scaffold and implement goodreserve swap widget package
Copilot May 27, 2026
f31eb12
chore: remove skills artifacts and add goodreserve story screenshots
Copilot May 28, 2026
079ded5
feat: finalize goodreserve swap widget UI and behavior (#44)
Jun 3, 2026
f366109
fix(goodreserve-widget): align success screen and sizing with Figma
Jun 3, 2026
debd220
fix(goodreserve-widget): make the amount input editable on web
Jun 3, 2026
abe513f
fix(goodreserve-widget): make swap math and execution safe
Jun 3, 2026
b49ce13
refactor(goodreserve-widget): harden adapter state machine and chain …
Jun 3, 2026
9a98e45
fix(goodreserve-widget): apply amount input textAlign via DOM style
Jun 4, 2026
6ab7839
feat(goodreserve-widget): align layout and colors to the Figma reference
Jun 4, 2026
4597c7e
fix(goodreserve-widget): correct success amount, explorer link, and q…
Jun 4, 2026
b65dcf0
fix(goodreserve-widget): harden adapter state, callbacks, and add sta…
Jun 6, 2026
94f2d79
fix(goodreserve-widget): resolve idle status, quote freshness, and er…
Jun 6, 2026
dcecd4f
refactor(goodreserve-widget): move styling onto the theming contract
Jun 6, 2026
f4f6fee
fix(goodreserve-widget): finish theming migration and fix sub-theme c…
Jun 6, 2026
4a1aec2
feat(goodreserve-widget): integrate the real GoodReserve SDK behind a…
Jun 6, 2026
29f54e5
fix(goodreserve-widget): correct price label, explorer URL, and themi…
Jun 6, 2026
cec1290
test(storybook): raise test-runner timeout to avoid cold-start flakes
Jun 6, 2026
9b69939
chore: merge upstream/main into feat/finalize-goodreserve-widget
Jun 12, 2026
5a6576f
chore: update test screenshots from latest test execution
Jun 12, 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
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ GoodWidget/
ui/ # Tamagui tokens, preset, themes, config assembly, manifest, primitives
embed/ # Web Component wrapper + CSS custom property bridge
claim-widget/ # Example widget package using core + ui + embed
goodreserve-widget/ # Reserve swap widget package using core + ui + embed

examples/
react-web/ # React web override and theming demo
Expand Down Expand Up @@ -69,6 +70,7 @@ GoodWidget/
@goodwidget/embed

@goodwidget/claim-widget -> depends on core + ui + embed
@goodwidget/goodreserve-widget -> depends on core + ui + embed
```

`@goodwidget/ui` is the leaf design-system package and must not depend on `@goodwidget/core`.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A cross-platform mini app framework for building web3 widgets that run inside wa
| `@goodwidget/ui` | Tamagui-based themeable component library (React + React Native Web) |
| `@goodwidget/embed` | Web Component wrapper for embedding mini apps in any HTML page |
| `@goodwidget/claim-widget` | Sample publishable widget — React component + Web Component |
| `@goodwidget/goodreserve-widget` | Reserve swap widget package (buy/sell flow on Celo/XDC) |

## Quick Start

Expand Down Expand Up @@ -170,6 +171,7 @@ GoodWidget/
ui/ → @goodwidget/ui (component library, theme system)
embed/ → @goodwidget/embed (Web Component wrapper)
claim-widget/ → @goodwidget/claim-widget (sample publishable widget)
goodreserve-widget/ → @goodwidget/goodreserve-widget (reserve swap widget)
examples/
react-web/ → React demo with style override showcase
html/ → Plain HTML consuming a web component widget
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
1 change: 1 addition & 0 deletions examples/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@goodwidget/ui": "workspace:*",
"@goodwidget/claim-widget-theme-demo": "workspace:*",
"@goodwidget/citizen-claim-widget": "workspace:*",
"@goodwidget/goodreserve-widget": "workspace:*",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-native-web": "^0.19.13",
Expand Down
147 changes: 147 additions & 0 deletions examples/storybook/src/fixtures/goodReserveSdkFake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type {
GoodReserveSDKConstructor,
GoodReserveSDKLike,
ReserveStats,
ReserveTransactionResult,
} from '@goodwidget/goodreserve-widget'
import type { EIP1193Provider } from '@goodwidget/core'

// ---------------------------------------------------------------------------
// Deterministic, CI-safe test doubles for the GoodReserve flow.
//
// These exercise the REAL adapter (quote → confirm → buy/sell → success) via
// the SDK injection seam, with no published @goodsdks/good-reserve package and
// no live RPC. The fake encodes the verified PR #35 contract: getBuyQuote/
// getSellQuote arg order, the buy(tokenIn, amountIn, minReturn, onHash) /
// sell(sellTo, gdAmount, minReturn, onHash) signatures, and the
// { hash, receipt } result shape.
// ---------------------------------------------------------------------------

const STABLE_TOKEN = '0x765DE816845861e75A25fCA122bb6898B8B1282a' as const
const GD_TOKEN = '0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A' as const

// erc20 balanceOf(account) selector + a fixed balance, so the adapter's
// balance reads resolve deterministically through eth_call.
const BALANCE_OF_SELECTOR = '0x70a08231'
// 120 * 1e18 (stable, 18 decimals) — comfortably above the 25 test input.
const STABLE_BALANCE_WEI = (120n * 10n ** 18n).toString(16).padStart(64, '0')

const SUBMITTED_HASH =
'0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as const

/** Fake GoodReserveSDK implementing the PR #35 public surface deterministically. */
export const FakeGoodReserveSDK: GoodReserveSDKConstructor = class FakeGoodReserveSDK
implements GoodReserveSDKLike
{
// Matches the real `new GoodReserveSDK(publicClient, walletClient, env)`.
constructor(
_publicClient: unknown,
_walletClient: unknown,
_env: 'production' | 'development',
) {}

getStableTokenAddress(): `0x${string}` {
return STABLE_TOKEN
}

getGoodDollarAddress(): `0x${string}` {
return GD_TOKEN
}

async getReserveStats(): Promise<ReserveStats> {
return {
goodDollarTotalSupply: 0n,
stableTokenDecimals: 18,
goodDollarDecimals: 2,
poolReserveBalance: null,
poolTokenSupply: null,
reserveRatio: 500_000, // PPM → 50%
exitContribution: 5_000, // PPM → 0.50% (verifies the C1 scaling fix)
}
}

// getBuyQuote(tokenIn, amountIn) → G$ out. ~4.33 G$ per stable for the demo.
async getBuyQuote(_tokenIn: `0x${string}`, amountIn: bigint): Promise<bigint> {
// amountIn is 18-dec stable; output is 2-dec G$. 25e18 → 10825 (108.25 G$).
return (amountIn * 433n) / 10n ** 18n
}

// getSellQuote(gdAmount, sellTo) → stable out.
async getSellQuote(gdAmount: bigint, _sellTo: `0x${string}`): Promise<bigint> {
// gdAmount is 2-dec G$; output is 18-dec stable.
return (gdAmount * 10n ** 18n) / 433n
}

async buy(
_tokenIn: `0x${string}`,
_amountIn: bigint,
_minReturn: bigint,
onHash?: (hash: `0x${string}`) => void,
): Promise<ReserveTransactionResult> {
onHash?.(SUBMITTED_HASH)
return { hash: SUBMITTED_HASH, receipt: { transactionHash: SUBMITTED_HASH } }
}

async sell(
_sellTo: `0x${string}`,
_gdAmount: bigint,
_minReturn: bigint,
onHash?: (hash: `0x${string}`) => void,
): Promise<ReserveTransactionResult> {
onHash?.(SUBMITTED_HASH)
return { hash: SUBMITTED_HASH, receipt: { transactionHash: SUBMITTED_HASH } }
}
}

type EventCallback = (...args: unknown[]) => void

/**
* Minimal deterministic EIP-1193 provider for the live-adapter story. Reports a
* connected account on Celo and answers eth_call balanceOf with a fixed balance.
* No network access — every method resolves locally.
*/
export function createReserveTestProvider(
account = '0x1111111111111111111111111111111111111111',
chainId = 42220,
): EIP1193Provider {
let activeChainId = chainId
const listeners: Record<string, EventCallback[]> = {}

return {
on(event: string, fn: EventCallback) {
;(listeners[event] ??= []).push(fn)
},
removeListener(event: string, fn: EventCallback) {
listeners[event] = (listeners[event] ?? []).filter((cb) => cb !== fn)
},
async request({ method, params }: { method: string; params?: unknown[] }): Promise<unknown> {
switch (method) {
case 'eth_accounts':
case 'eth_requestAccounts':
return [account]
case 'eth_chainId':
return `0x${activeChainId.toString(16)}`
case 'net_version':
return String(activeChainId)
case 'wallet_switchEthereumChain': {
const requested = (params?.[0] as { chainId?: string } | undefined)?.chainId
if (requested) {
activeChainId = Number.parseInt(requested, 16)
for (const cb of listeners['chainChanged'] ?? []) cb(requested)
}
return null
}
case 'eth_call': {
const call = (params?.[0] as { data?: string } | undefined) ?? {}
// balanceOf(account) → fixed balance; anything else → 0.
if (call.data?.startsWith(BALANCE_OF_SELECTOR)) {
return `0x${STABLE_BALANCE_WEI}`
}
return `0x${''.padStart(64, '0')}`
}
default:
return null
}
},
} as EIP1193Provider
}
168 changes: 168 additions & 0 deletions examples/storybook/src/fixtures/goodReserveWidgetMock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import type { ReserveSwapWidgetAdapterState } from '@goodwidget/goodreserve-widget'

// Deterministic reserve widget state fixtures used by Storybook and CI tests.
export const reserveWidgetMockStates: Record<string, Partial<ReserveSwapWidgetAdapterState>> = {
noProvider: {
status: 'no_provider',
hasProvider: false,
chainId: null,
address: null,
},
unsupportedChain: {
status: 'unsupported_chain',
hasProvider: true,
chainId: 8453,
address: '0x1111111111111111111111111111111111111111',
},
sdkInitializing: {
status: 'sdk_initializing',
hasProvider: true,
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
},
idleBuy: {
status: 'idle',
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
hasProvider: true,
tokenInSymbol: 'USDm',
tokenOutSymbol: 'G$',
tokenInBalance: '120.00',
tokenOutBalance: '10340.22',
inputAmount: '',
direction: 'buy',
},
amountEditing: {
status: 'amount_editing',
chainId: 42220,
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',
chainId: 42220,
hasProvider: true,
inputAmount: '25',
tokenInBalance: '120.00',
quote: {
outputAmount: '108.2500',
price: '0.2310',
minimumReceived: '108.1417',
priceImpactPercent: 'N/A',
exitContributionPercent: '0%',
},
},
quoteError: {
status: 'quote_error',
chainId: 42220,
hasProvider: true,
inputAmount: '25',
error: 'Reserve quote failed. Try again in a moment.',
},
insufficientBalance: {
status: 'insufficient_balance',
chainId: 42220,
hasProvider: true,
inputAmount: '9999',
tokenInBalance: '120.00',
warning: 'Input exceeds your available token balance.',
},
slippageSelection: {
status: 'slippage_selection',
chainId: 42220,
hasProvider: true,
slippagePercent: 0.5,
},
confirmDialog: {
status: 'confirm_dialog',
chainId: 42220,
hasProvider: true,
inputAmount: '25',
quote: {
outputAmount: '108.2500',
price: '0.2310',
minimumReceived: '108.1417',
priceImpactPercent: 'N/A',
exitContributionPercent: '0%',
},
},
swapPending: {
status: 'swap_pending',
chainId: 42220,
hasProvider: true,
inputAmount: '25',
quote: {
outputAmount: '108.2500',
price: '0.2310',
minimumReceived: '108.1417',
priceImpactPercent: 'N/A',
exitContributionPercent: '0%',
},
},
swapSuccess: {
status: 'swap_success',
chainId: 42220,
hasProvider: true,
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',
chainId: 42220,
hasProvider: true,
error: 'Swap reverted due to reserve limits.',
},
sellQuoteReady: {
status: 'quote_ready',
chainId: 42220,
hasProvider: true,
direction: 'sell',
tokenInSymbol: 'G$',
tokenOutSymbol: 'USDm',
tokenInBalance: '300.00',
tokenOutBalance: '84.00',
inputAmount: '40',
quote: {
outputAmount: '8.9231',
price: '4.4820',
minimumReceived: '8.9142',
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: '0.2310',
minimumReceived: '216.2835',
priceImpactPercent: 'N/A',
exitContributionPercent: '0%',
},
},
}
13 changes: 10 additions & 3 deletions examples/storybook/src/stories/design-system/Drawer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/
import React, { useState } from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import { expect, within, userEvent } from '@storybook/test'
import { expect, within, userEvent, waitFor } from '@storybook/test'
import { Drawer, Card, Heading, Text, Button, ButtonText, YStack } from '@goodwidget/ui'

const meta: Meta = {
Expand Down Expand Up @@ -45,7 +45,14 @@ export const Default: Story = {
const canvas = within(canvasElement)
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 Sheet renders into a portal at the document root, so query the whole
// document (not just canvasElement) and wait for the open animation.
const screen = within(canvasElement.ownerDocument.body)
await waitFor(
async () => {
await expect(screen.getByRole('button', { name: /close/i })).toBeVisible()
},
{ timeout: 5000 },
)
},
}
Loading