diff --git a/examples/storybook/package.json b/examples/storybook/package.json
index 8188cc93..7fbfd22a 100644
--- a/examples/storybook/package.json
+++ b/examples/storybook/package.json
@@ -13,6 +13,7 @@
"@goodwidget/ui": "workspace:*",
"@goodwidget/claim-widget-theme-demo": "workspace:*",
"@goodwidget/citizen-claim-widget": "workspace:*",
+ "@goodwidget/connect-a-wallet-widget": "workspace:*",
"@goodwidget/goodreserve-widget": "workspace:*",
"@goodwidget/governance-widget": "workspace:*",
"@goodwidget/streaming-widget": "workspace:*",
diff --git a/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidget.mdx b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidget.mdx
new file mode 100644
index 00000000..df57b478
--- /dev/null
+++ b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidget.mdx
@@ -0,0 +1,78 @@
+import { Canvas, Meta } from '@storybook/blocks';
+import * as ShowcaseStories from './ConnectAWalletWidgetShowcase.stories';
+import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection } from '../docs/DocsLayout';
+
+
+
+
+
+
+
+ Gates on `GoodWidgetProvider`'s wallet connection before anything else is shown.
+
+
+ Validates the typed address, then loads its wallet-link status on Fuse, Celo, and XDC via
+ `IdentitySDK.checkConnectedStatus`.
+
+
+ Each chain row always shows either Connect or Disconnect — never hidden — with a loading
+ indicator while the transaction is in flight. `IdentitySDK.connectAccount` /
+ `disconnectAccount` drive the on-chain call, switching the host wallet to that chain first.
+
+
+
+
+
+
+
+
+
+
+ Wallet gate, connecting, no-input, address-checking, mixed per-chain row statuses,
+ unsupported-network warning, and top-level error/retry states live in
+ `QA / ConnectAWalletWidget / Runtime Fixtures`.
+
+
+
+
+
+
+ Manual review in a browser with an injected wallet, exercising the real
+ `useConnectAWalletAdapter` hook end to end.
+
+
+ Controlled adapter states for screenshots, automation, and debugging without live runtime
+ dependencies.
+
+
+
+
+
+
+ Use the showcase story for product-facing wallet-link checks. Use the QA fixtures when you
+ need repeatable screenshots, per-chain row-status coverage, or debugging.
+
+
+
diff --git a/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetQA.stories.tsx b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetQA.stories.tsx
new file mode 100644
index 00000000..01469801
--- /dev/null
+++ b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetQA.stories.tsx
@@ -0,0 +1,49 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import { ConnectAWalletWidget } from '@goodwidget/connect-a-wallet-widget'
+import {
+ CheckingAddressStory,
+ ConnectedNoInputStory,
+ ConnectingStory,
+ NotConnectedStory,
+ ReadyMixedRowStatusesStory,
+ TopLevelErrorWithRetryStory,
+ UnsupportedNetworkStory,
+} from '../helpers/connectAWalletWidgetStories'
+
+const meta: Meta = {
+ title: 'QA/ConnectAWalletWidget/Runtime Fixtures',
+ component: ConnectAWalletWidget,
+ tags: ['autodocs', 'qa'],
+ parameters: { layout: 'padded' },
+}
+
+export default meta
+type Story = StoryObj
+
+export const NotConnected: Story = {
+ render: () => ,
+}
+
+export const Connecting: Story = {
+ render: () => ,
+}
+
+export const ConnectedNoInput: Story = {
+ render: () => ,
+}
+
+export const CheckingAddress: Story = {
+ render: () => ,
+}
+
+export const ReadyMixedRowStatuses: Story = {
+ render: () => ,
+}
+
+export const UnsupportedNetwork: Story = {
+ render: () => ,
+}
+
+export const TopLevelErrorWithRetry: Story = {
+ render: () => ,
+}
diff --git a/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetShowcase.stories.tsx b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetShowcase.stories.tsx
new file mode 100644
index 00000000..cc80b0b4
--- /dev/null
+++ b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetShowcase.stories.tsx
@@ -0,0 +1,17 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import { ConnectAWalletWidget } from '@goodwidget/connect-a-wallet-widget'
+import { InjectedWalletStory } from '../helpers/connectAWalletWidgetStories'
+
+const meta: Meta = {
+ title: 'Widgets/ConnectAWalletWidget/Showcase',
+ component: ConnectAWalletWidget,
+ tags: ['integrator', 'manual', 'showcase'],
+ parameters: { layout: 'padded' },
+}
+
+export default meta
+type Story = StoryObj
+
+export const InjectedWallet: Story = {
+ render: () => ,
+}
diff --git a/examples/storybook/src/stories/helpers/connectAWalletWidgetStories.tsx b/examples/storybook/src/stories/helpers/connectAWalletWidgetStories.tsx
new file mode 100644
index 00000000..4901d978
--- /dev/null
+++ b/examples/storybook/src/stories/helpers/connectAWalletWidgetStories.tsx
@@ -0,0 +1,265 @@
+import React from 'react'
+import {
+ CONNECT_A_WALLET_CHAINS,
+ ConnectAWalletWidget,
+ ConnectAWalletWidgetPreview,
+ type ConnectAWalletChainId,
+ type ConnectAWalletChainLinkState,
+ type ConnectAWalletWidgetAdapterActions,
+ type ConnectAWalletWidgetAdapterResult,
+ type ConnectAWalletWidgetAdapterState,
+} from '@goodwidget/connect-a-wallet-widget'
+import { MiniAppShell, YStack } from '@goodwidget/ui'
+import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193'
+import { getInjectedEip1193Provider, isInjectedProviderUsable } from '../../fixtures/injectedEip1193'
+
+// Fixed demo addresses so QA screenshots and Playwright text assertions never
+// depend on a live wallet or network response.
+const DEMO_HOST_WALLET_ADDRESS = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
+const DEMO_SECONDARY_ADDRESS = '0x1111111111111111111111111111111111111111'
+
+const CHAIN_NAMES: Record = {
+ 122: 'Fuse',
+ 42220: 'Celo',
+ 50: 'XDC',
+}
+
+// One row per supported chain, all 'connected' by default — overridden per
+// story to exercise the mixed-status / loading / disconnecting cases.
+const readyChainLinks: ConnectAWalletChainLinkState[] = CONNECT_A_WALLET_CHAINS.map((chainId) => ({
+ chainId,
+ chainName: CHAIN_NAMES[chainId],
+ status: 'connected',
+}))
+
+const checkingChainLinks: ConnectAWalletChainLinkState[] = CONNECT_A_WALLET_CHAINS.map((chainId) => ({
+ chainId,
+ chainName: CHAIN_NAMES[chainId],
+ status: 'checking',
+}))
+
+/**
+ * Adapter fixture factory. Defaults to a fully connected, ready state so each
+ * story only needs to override the handful of fields relevant to the state
+ * it demonstrates — mirrors streaming-widget's createAdapter pattern.
+ */
+function createAdapter(
+ stateOverrides: Partial = {},
+ actionOverrides: Partial = {},
+): ConnectAWalletWidgetAdapterResult {
+ const baseState: ConnectAWalletWidgetAdapterState = {
+ isWalletConnected: true,
+ walletAddress: DEMO_HOST_WALLET_ADDRESS,
+ activeChainId: 42220,
+ isActiveChainSupported: true,
+ status: 'ready',
+ error: null,
+ secondaryAddressInput: DEMO_SECONDARY_ADDRESS,
+ secondaryAddress: DEMO_SECONDARY_ADDRESS,
+ chainLinks: readyChainLinks,
+ }
+
+ const actions: ConnectAWalletWidgetAdapterActions = {
+ connectWallet: async () => {},
+ setSecondaryAddressInput: () => {},
+ checkSecondaryAddress: async () => {},
+ connectChain: async () => {},
+ disconnectChain: async () => {},
+ ...actionOverrides,
+ }
+
+ return {
+ state: { ...baseState, ...stateOverrides },
+ actions,
+ }
+}
+
+function StoryShell({ children, dataTestId }: { children: React.ReactNode; dataTestId: string }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function PreviewStoryShell({
+ adapter,
+ dataTestId,
+}: {
+ adapter: ConnectAWalletWidgetAdapterResult
+ dataTestId: string
+}) {
+ return (
+
+
+
+ )
+}
+
+function ConnectAWalletWidgetStoryShell({ provider, dataTestId }: { provider: unknown; dataTestId: string }) {
+ return (
+
+
+
+ )
+}
+
+export function InjectedWalletStory() {
+ const injectedProvider = getInjectedEip1193Provider()
+ const usableProvider = isInjectedProviderUsable(injectedProvider)
+
+ if (!usableProvider) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+export function CustodialLocalFixtureStory() {
+ try {
+ const provider = createCustodialEip1193Provider()
+ return (
+
+ )
+ } catch (error: unknown) {
+ return (
+
+ Custodial fixture not configured
+
+ {error instanceof Error ? error.message : 'Set a local private key in custodialEip1193.ts'}
+
+
+ )
+ }
+}
+
+export function NotConnectedStory() {
+ return (
+
+ )
+}
+
+export function ConnectingStory() {
+ return (
+
+ )
+}
+
+export function ConnectedNoInputStory() {
+ return (
+
+ )
+}
+
+export function CheckingAddressStory() {
+ return (
+
+ )
+}
+
+// Proves the "always show Connect or Disconnect, loading indicator while
+// pending, never hidden" requirement: one row of each possible per-chain
+// status rendered simultaneously.
+export function ReadyMixedRowStatusesStory() {
+ return (
+
+ )
+}
+
+export function UnsupportedNetworkStory() {
+ return (
+
+ )
+}
+
+export function TopLevelErrorWithRetryStory() {
+ return (
+
+ )
+}
diff --git a/packages/connect-a-wallet-widget/package.json b/packages/connect-a-wallet-widget/package.json
new file mode 100644
index 00000000..529628b8
--- /dev/null
+++ b/packages/connect-a-wallet-widget/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@goodwidget/connect-a-wallet-widget",
+ "version": "0.1.0-beta",
+ "description": "GoodWidget for linking/unlinking secondary wallets to a GoodDollar identity across Celo, Fuse, and XDC",
+ "type": "module",
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ },
+ "./element": {
+ "types": "./dist/element.d.ts",
+ "import": "./dist/element.js",
+ "require": "./dist/element.cjs"
+ },
+ "./register": {
+ "types": "./dist/register.d.ts",
+ "import": "./dist/register.js",
+ "require": "./dist/register.cjs"
+ }
+ },
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "lint": "eslint src/",
+ "clean": "rm -rf dist .turbo"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0",
+ "react-dom": ">=18.0.0"
+ },
+ "dependencies": {
+ "@goodsdks/citizen-sdk": "1.2.7",
+ "@goodwidget/core": "workspace:*",
+ "@goodwidget/embed": "workspace:*",
+ "@goodwidget/ui": "workspace:*",
+ "viem": "^2.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "tsup": "^8.4.0",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/packages/connect-a-wallet-widget/src/ConnectAWalletWidget.tsx b/packages/connect-a-wallet-widget/src/ConnectAWalletWidget.tsx
new file mode 100644
index 00000000..3d3ddd2c
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/ConnectAWalletWidget.tsx
@@ -0,0 +1,89 @@
+import React from 'react'
+import { GoodWidgetProvider } from '@goodwidget/core'
+import type { EIP1193Provider } from '@goodwidget/core'
+import { GoodWidgetDialog, ToastContainer } from '@goodwidget/ui'
+import { useConnectAWalletAdapter } from './adapter'
+import { ConnectAWalletWidgetView } from './components/ConnectAWalletWidgetView'
+import type {
+ ConnectAWalletWidgetAdapterResult,
+ ConnectAWalletWidgetProps,
+} from './widgetRuntimeContract'
+
+/**
+ * Thin entry point: wires the runtime adapter to the view. All visual
+ * composition lives under components/, mirroring streaming-widget's
+ * decomposition — a standing rule for GoodWidget packages going forward.
+ */
+interface ConnectAWalletWidgetRuntimeProps {
+ environment: ConnectAWalletWidgetProps['environment']
+ onLinkSuccess: ConnectAWalletWidgetProps['onLinkSuccess']
+ onLinkError: ConnectAWalletWidgetProps['onLinkError']
+ onUnlinkSuccess: ConnectAWalletWidgetProps['onUnlinkSuccess']
+ 'data-testid'?: string
+}
+
+function ConnectAWalletWidgetRuntime({
+ environment,
+ onLinkSuccess,
+ onLinkError,
+ onUnlinkSuccess,
+ 'data-testid': dataTestId,
+}: ConnectAWalletWidgetRuntimeProps) {
+ const adapter = useConnectAWalletAdapter({ environment, onLinkSuccess, onLinkError, onUnlinkSuccess })
+
+ return
+}
+
+export interface ConnectAWalletWidgetPreviewProps
+ extends Pick {
+ adapter: ConnectAWalletWidgetAdapterResult
+ 'data-testid'?: string
+}
+
+// Deterministic render path for Storybook/test fixtures that provide a fixed adapter.
+export function ConnectAWalletWidgetPreview({
+ adapter,
+ themeOverrides,
+ config,
+ defaultTheme = 'dark',
+ 'data-testid': dataTestId,
+}: ConnectAWalletWidgetPreviewProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export function ConnectAWalletWidget({
+ provider,
+ environment = 'production',
+ onLinkSuccess,
+ onLinkError,
+ onUnlinkSuccess,
+ themeOverrides,
+ config,
+ defaultTheme = 'dark',
+ 'data-testid': dataTestId,
+}: ConnectAWalletWidgetProps) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/packages/connect-a-wallet-widget/src/adapter.ts b/packages/connect-a-wallet-widget/src/adapter.ts
new file mode 100644
index 00000000..2253574d
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/adapter.ts
@@ -0,0 +1,412 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useWallet } from '@goodwidget/core'
+import { createPublicClient, createWalletClient, custom, isAddress, type Chain, type PublicClient } from 'viem'
+import { IdentitySDK, SupportedChains } from '@goodsdks/citizen-sdk'
+import { createDialog, closeDialog, createToast, updateToast } from '@goodwidget/ui'
+import {
+ CONNECT_A_WALLET_CHAINS,
+ type ConnectAWalletChainId,
+ type ConnectAWalletChainLinkState,
+ type ConnectAWalletLinkErrorDetail,
+ type ConnectAWalletLinkEventDetail,
+ type ConnectAWalletWidgetAdapterActions,
+ type ConnectAWalletWidgetAdapterResult,
+ type ConnectAWalletWidgetAdapterState,
+ type ConnectAWalletWidgetEnvironment,
+ type ConnectAWalletWidgetStatus,
+} from './widgetRuntimeContract'
+
+// ---------------------------------------------------------------------------
+// Minimal viem chain descriptors — same RPC endpoints as citizen-claim-widget,
+// kept in sync since both packages target the same citizen-sdk deployment.
+// ---------------------------------------------------------------------------
+const CHAIN_CONFIGS: Record = {
+ [SupportedChains.FUSE]: {
+ id: SupportedChains.FUSE,
+ name: 'Fuse',
+ nativeCurrency: { name: 'Fuse', symbol: 'FUSE', decimals: 18 },
+ rpcUrls: { default: { http: ['https://rpc.fuse.io'] } },
+ } as Chain,
+ [SupportedChains.CELO]: {
+ id: SupportedChains.CELO,
+ name: 'Celo',
+ nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 },
+ rpcUrls: { default: { http: ['https://forno.celo.org'] } },
+ } as Chain,
+ [SupportedChains.XDC]: {
+ id: SupportedChains.XDC,
+ name: 'XDC Network',
+ nativeCurrency: { name: 'XDC', symbol: 'XDC', decimals: 18 },
+ rpcUrls: { default: { http: ['https://rpc.ankr.com/xdc'] } },
+ } as Chain,
+}
+
+const CHAIN_NAMES: Record = {
+ [SupportedChains.FUSE]: 'Fuse',
+ [SupportedChains.CELO]: 'Celo',
+ [SupportedChains.XDC]: 'XDC',
+}
+
+const DEFAULT_CHAIN: ConnectAWalletChainId = SupportedChains.CELO
+
+function humanReadableError(err: unknown): string {
+ console.error('[ConnectAWalletWidget]', err)
+ if (!(err instanceof Error)) return 'Something went wrong. Please try again.'
+ const msg = err.message
+ if (msg.includes('User rejected') || msg.includes('user rejected') || msg.includes('4001')) {
+ return 'Rejected by wallet.'
+ }
+ if (msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('ECONNREFUSED')) {
+ return 'Unable to reach the network. Check your connection and try again.'
+ }
+ return 'Something went wrong. Please try again.'
+}
+
+/**
+ * Opens a blocking GoodWidgetDialog and resolves once the user accepts or
+ * rejects it. Mirrors citizen-sdk's WalletLinkOptions.onSecurityMessage
+ * contract: resolve(true) to proceed with the transaction, resolve(false) to
+ * cancel it. Confirmed by Bounty Lead sign-off: this must block before the
+ * transaction executes, using packages/ui's Dialog.
+ */
+function confirmSecurityMessageViaDialog(message: string): Promise {
+ return new Promise((resolve) => {
+ createDialog({
+ title: 'Security Confirmation',
+ body: message,
+ acceptLabel: 'Confirm',
+ rejectLabel: 'Cancel',
+ onAccept: () => {
+ resolve(true)
+ },
+ onReject: () => {
+ resolve(false)
+ },
+ })
+ })
+}
+
+export interface UseConnectAWalletAdapterOptions {
+ environment?: ConnectAWalletWidgetEnvironment
+ onLinkSuccess?: (detail: ConnectAWalletLinkEventDetail) => void
+ onLinkError?: (detail: ConnectAWalletLinkErrorDetail) => void
+ onUnlinkSuccess?: (detail: ConnectAWalletLinkEventDetail) => void
+}
+
+/**
+ * Core adapter hook: bridges @goodsdks/citizen-sdk's wallet-link API to
+ * GoodWidget state/actions.
+ *
+ * Runtime path:
+ * host provider → GoodWidgetProvider → useWallet() → this adapter → citizen-sdk
+ *
+ * Calls IdentitySDK.connectAccount/disconnectAccount/checkConnectedStatus
+ * directly — no @goodsdks/react-hooks, since those hooks require a Wagmi v2
+ * provider tree and GoodWidget's provider-first architecture only exposes a
+ * raw EIP-1193 provider via useWallet(). Confirmed against every other widget
+ * in this repo: none of them pull in react-hooks/Wagmi either.
+ */
+export function useConnectAWalletAdapter(
+ options: UseConnectAWalletAdapterOptions = {},
+): ConnectAWalletWidgetAdapterResult {
+ const { onLinkSuccess, onLinkError, onUnlinkSuccess } = options
+ const env = options.environment ?? 'production'
+ const { address, chainId, isConnected, provider, connect } = useWallet()
+
+ const isActiveChainSupported =
+ chainId !== null && (CONNECT_A_WALLET_CHAINS as readonly number[]).includes(chainId)
+
+ const [isConnectingWallet, setIsConnectingWallet] = useState(false)
+ const [secondaryAddressInput, setSecondaryAddressInputState] = useState('')
+ const [secondaryAddress, setSecondaryAddress] = useState<`0x${string}` | null>(null)
+ const [status, setStatus] = useState('not_connected')
+ const [error, setError] = useState(null)
+ const [chainLinks, setChainLinks] = useState(
+ CONNECT_A_WALLET_CHAINS.map((id) => ({
+ chainId: id,
+ chainName: CHAIN_NAMES[id],
+ status: 'checking',
+ })),
+ )
+
+ const mountedRef = useRef(true)
+ useEffect(() => {
+ mountedRef.current = true
+ return () => {
+ mountedRef.current = false
+ }
+ }, [])
+
+ // ---------------------------------------------------------------------------
+ // Client + SDK factory for one chain. The wallet client's account/chain are
+ // only used when actually signing (connect/disconnect); reads pass an
+ // explicit publicClients map so they never depend on the host wallet's
+ // currently active chain.
+ // ---------------------------------------------------------------------------
+ const createIdentitySdkForChain = useCallback(
+ (targetChainId: ConnectAWalletChainId) => {
+ if (!provider || !address) return null
+ const chain = CHAIN_CONFIGS[targetChainId]
+ const transport = custom(provider as Parameters[0])
+ const publicClient = createPublicClient({ chain, transport })
+ const walletClient = createWalletClient({
+ account: address as `0x${string}`,
+ chain,
+ transport,
+ })
+ return new IdentitySDK({ publicClient, walletClient, env })
+ },
+ [provider, address, env],
+ )
+
+ const publicClientsByChain = useMemo(
+ () =>
+ CONNECT_A_WALLET_CHAINS.reduce(
+ (acc, id) => {
+ const chain = CHAIN_CONFIGS[id]
+ acc[id] = createPublicClient({ chain, transport: custom(provider as Parameters[0]) })
+ return acc
+ },
+ {} as Record,
+ ),
+ [provider],
+ )
+
+ // ---------------------------------------------------------------------------
+ // checkSecondaryAddress — validates the typed-in address, then loads its
+ // wallet-link status on all 3 supported chains in a single SDK call.
+ // ---------------------------------------------------------------------------
+ const checkSecondaryAddress = useCallback(async (): Promise => {
+ const trimmed = secondaryAddressInput.trim()
+ if (!isAddress(trimmed)) {
+ setStatus('connected_no_input')
+ setError('Enter a valid wallet address.')
+ return
+ }
+
+ setSecondaryAddress(trimmed)
+ setStatus('checking_address')
+ setError(null)
+ setChainLinks(CONNECT_A_WALLET_CHAINS.map((id) => ({ chainId: id, chainName: CHAIN_NAMES[id], status: 'checking' })))
+
+ const sdk = createIdentitySdkForChain(isActiveChainSupported ? (chainId as ConnectAWalletChainId) : DEFAULT_CHAIN)
+ if (!sdk) {
+ setStatus('error')
+ setError('Wallet not connected.')
+ return
+ }
+
+ try {
+ const statuses = await sdk.checkConnectedStatus(trimmed, undefined, publicClientsByChain)
+ if (!mountedRef.current) return
+ setChainLinks(
+ CONNECT_A_WALLET_CHAINS.map((id) => {
+ const found = statuses.find((entry: { chainId: number }) => entry.chainId === id)
+ return {
+ chainId: id,
+ chainName: CHAIN_NAMES[id],
+ status: found?.isConnected ? 'connected' : 'not_connected',
+ }
+ }),
+ )
+ setStatus('ready')
+ } catch (err: unknown) {
+ if (!mountedRef.current) return
+ setStatus('error')
+ setError(humanReadableError(err))
+ }
+ }, [secondaryAddressInput, createIdentitySdkForChain, isActiveChainSupported, chainId, publicClientsByChain])
+
+ const setSecondaryAddressInput = useCallback((value: string) => {
+ setSecondaryAddressInputState(value)
+ }, [])
+
+ const handleConnectWallet = useCallback(async (): Promise => {
+ setIsConnectingWallet(true)
+ try {
+ await connect()
+ } finally {
+ if (mountedRef.current) setIsConnectingWallet(false)
+ }
+ }, [connect])
+
+ // ---------------------------------------------------------------------------
+ // connectChain / disconnectChain — link or unlink `secondaryAddress` on one
+ // chain. Switches the host wallet to that chain first (EIP-3326), same
+ // pattern as citizen-claim-widget's per-chain claim action. onSecurityMessage
+ // blocks on a Dialog; tx result surfaces via Toast, never an inline error.
+ // ---------------------------------------------------------------------------
+ const setRowStatus = useCallback((targetChainId: ConnectAWalletChainId, rowStatus: ConnectAWalletChainLinkState['status']) => {
+ setChainLinks((prev) =>
+ prev.map((row) => (row.chainId === targetChainId ? { ...row, status: rowStatus } : row)),
+ )
+ }, [])
+
+ const switchWalletToChain = useCallback(
+ async (targetChainId: ConnectAWalletChainId) => {
+ if (!provider) throw new Error('No wallet provider available')
+ await (
+ provider as { request: (args: { method: string; params: unknown[] }) => Promise }
+ ).request({
+ method: 'wallet_switchEthereumChain',
+ params: [{ chainId: `0x${targetChainId.toString(16)}` }],
+ })
+ },
+ [provider],
+ )
+
+ const connectChain = useCallback(
+ async (targetChainId: ConnectAWalletChainId): Promise => {
+ if (!secondaryAddress) return
+ const previousStatus = chainLinks.find((row) => row.chainId === targetChainId)?.status ?? 'not_connected'
+ setRowStatus(targetChainId, 'connecting')
+
+ const toastId = createToast({
+ message: `Linking on ${CHAIN_NAMES[targetChainId]}…`,
+ status: 'pending',
+ duration: 0,
+ })
+
+ try {
+ await switchWalletToChain(targetChainId)
+ const sdk = createIdentitySdkForChain(targetChainId)
+ if (!sdk) throw new Error('Unable to initialize SDK clients for target chain')
+
+ let transactionHash: string | undefined
+ await sdk.connectAccount(secondaryAddress, {
+ onSecurityMessage: confirmSecurityMessageViaDialog,
+ onHash: (hash: `0x${string}`) => {
+ transactionHash = hash
+ },
+ })
+
+ if (!mountedRef.current) return
+ closeDialog()
+ setRowStatus(targetChainId, 'connected')
+ updateToast(toastId, {
+ message: `Linked on ${CHAIN_NAMES[targetChainId]}`,
+ status: 'success',
+ duration: 3200,
+ })
+ onLinkSuccess?.({ address: secondaryAddress, chainId: targetChainId, transactionHash })
+ } catch (err: unknown) {
+ if (!mountedRef.current) return
+ closeDialog()
+ setRowStatus(targetChainId, previousStatus)
+ updateToast(toastId, {
+ message: `Failed to link on ${CHAIN_NAMES[targetChainId]}: ${humanReadableError(err)}`,
+ status: 'error',
+ duration: 0,
+ })
+ onLinkError?.({
+ address: secondaryAddress,
+ chainId: targetChainId,
+ message: humanReadableError(err),
+ })
+ }
+ },
+ [secondaryAddress, chainLinks, setRowStatus, switchWalletToChain, createIdentitySdkForChain, onLinkSuccess, onLinkError],
+ )
+
+ const disconnectChain = useCallback(
+ async (targetChainId: ConnectAWalletChainId): Promise => {
+ if (!secondaryAddress) return
+ const previousStatus = chainLinks.find((row) => row.chainId === targetChainId)?.status ?? 'connected'
+ setRowStatus(targetChainId, 'disconnecting')
+
+ const toastId = createToast({
+ message: `Unlinking on ${CHAIN_NAMES[targetChainId]}…`,
+ status: 'pending',
+ duration: 0,
+ })
+
+ try {
+ await switchWalletToChain(targetChainId)
+ const sdk = createIdentitySdkForChain(targetChainId)
+ if (!sdk) throw new Error('Unable to initialize SDK clients for target chain')
+
+ let transactionHash: string | undefined
+ await sdk.disconnectAccount(secondaryAddress, {
+ onSecurityMessage: confirmSecurityMessageViaDialog,
+ onHash: (hash: `0x${string}`) => {
+ transactionHash = hash
+ },
+ })
+
+ if (!mountedRef.current) return
+ closeDialog()
+ setRowStatus(targetChainId, 'not_connected')
+ updateToast(toastId, {
+ message: `Unlinked on ${CHAIN_NAMES[targetChainId]}`,
+ status: 'success',
+ duration: 3200,
+ })
+ onUnlinkSuccess?.({ address: secondaryAddress, chainId: targetChainId, transactionHash })
+ } catch (err: unknown) {
+ if (!mountedRef.current) return
+ closeDialog()
+ setRowStatus(targetChainId, previousStatus)
+ updateToast(toastId, {
+ message: `Failed to unlink on ${CHAIN_NAMES[targetChainId]}: ${humanReadableError(err)}`,
+ status: 'error',
+ duration: 0,
+ })
+ onLinkError?.({
+ address: secondaryAddress,
+ chainId: targetChainId,
+ message: humanReadableError(err),
+ })
+ }
+ },
+ [secondaryAddress, chainLinks, setRowStatus, switchWalletToChain, createIdentitySdkForChain, onUnlinkSuccess, onLinkError],
+ )
+
+ // Reset to not_connected / connected_no_input whenever wallet identity changes.
+ useEffect(() => {
+ if (!isConnected) {
+ setStatus('not_connected')
+ setSecondaryAddress(null)
+ return
+ }
+ setStatus('connected_no_input')
+ }, [isConnected, address])
+
+ const state: ConnectAWalletWidgetAdapterState = useMemo(
+ () => ({
+ isWalletConnected: isConnected,
+ walletAddress: address ?? null,
+ activeChainId: chainId ?? null,
+ isActiveChainSupported,
+ status: isConnectingWallet ? 'connecting' : status,
+ error,
+ secondaryAddressInput,
+ secondaryAddress,
+ chainLinks,
+ }),
+ [
+ isConnected,
+ address,
+ chainId,
+ isActiveChainSupported,
+ isConnectingWallet,
+ status,
+ error,
+ secondaryAddressInput,
+ secondaryAddress,
+ chainLinks,
+ ],
+ )
+
+ const actions: ConnectAWalletWidgetAdapterActions = useMemo(
+ () => ({
+ connectWallet: handleConnectWallet,
+ setSecondaryAddressInput,
+ checkSecondaryAddress,
+ connectChain,
+ disconnectChain,
+ }),
+ [handleConnectWallet, setSecondaryAddressInput, checkSecondaryAddress, connectChain, disconnectChain],
+ )
+
+ return { state, actions }
+}
diff --git a/packages/connect-a-wallet-widget/src/components/AddressLinkForm.tsx b/packages/connect-a-wallet-widget/src/components/AddressLinkForm.tsx
new file mode 100644
index 00000000..6e7ef366
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/components/AddressLinkForm.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import { ButtonText, Heading, Input, Spinner, Text, XStack, YStack } from '@goodwidget/ui'
+import { ActionButton, AddressFormCard } from './shared'
+
+interface AddressLinkFormProps {
+ addressInput: string
+ isChecking: boolean
+ onChangeAddressInput: (value: string) => void
+ onCheckAddress: () => void
+}
+
+export function AddressLinkForm({
+ addressInput,
+ isChecking,
+ onChangeAddressInput,
+ onCheckAddress,
+}: AddressLinkFormProps) {
+ return (
+
+
+ Wallet address to link
+
+
+ Enter the address you want to connect to or disconnect from your GoodID, then check its
+ status on each supported chain.
+
+ {/* Input and action button share a single row so the pair reads as one
+ validate bar, matching the design reference's compact address field. */}
+
+
+
+
+
+ {isChecking ? : Check address}
+
+
+
+ )
+}
diff --git a/packages/connect-a-wallet-widget/src/components/ChainLinkRow.tsx b/packages/connect-a-wallet-widget/src/components/ChainLinkRow.tsx
new file mode 100644
index 00000000..b1a63452
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/components/ChainLinkRow.tsx
@@ -0,0 +1,72 @@
+import React from 'react'
+import {
+ AddressDisplay,
+ Badge,
+ BadgeText,
+ ButtonText,
+ ChainBadge,
+ Spinner,
+ Text,
+ XStack,
+ createComponent,
+} from '@goodwidget/ui'
+import type { ConnectAWalletChainLinkState } from '../widgetRuntimeContract'
+import { chainLinkRowPresentation } from './format'
+import { ActionButton, ChainRowCard } from './shared'
+
+interface ChainLinkRowProps {
+ row: ConnectAWalletChainLinkState
+ address: `0x${string}`
+ onConnect: () => void
+ onDisconnect: () => void
+}
+
+const ChainAvatar = createComponent(XStack, {
+ name: 'ChainAvatar',
+ width: 28,
+ height: 28,
+ borderRadius: '$full',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '$infoMuted',
+})
+
+/**
+ * One row per supported chain. Always renders exactly one of Connect /
+ * Disconnect (never hidden) with a Spinner while a status is in flight, per
+ * Bounty Lead sign-off on the human-reviewer checklist.
+ */
+export function ChainLinkRow({ row, address, onConnect, onDisconnect }: ChainLinkRowProps) {
+ const { actionLabel, isBusy, isDisabled } = chainLinkRowPresentation(row.status)
+ const handlePress = actionLabel === 'Connect' ? onConnect : onDisconnect
+ const isDisconnectAction = actionLabel === 'Disconnect'
+
+ return (
+
+
+
+
+ {row.chainName.charAt(0).toUpperCase()}
+
+
+
+
+
+
+ {row.status === 'checking' ? 'checking…' : row.status.replace('_', ' ')}
+
+
+ {isBusy ? (
+
+ ) : (
+ {actionLabel}
+ )}
+
+
+ )
+}
diff --git a/packages/connect-a-wallet-widget/src/components/ConnectAWalletWidgetView.tsx b/packages/connect-a-wallet-widget/src/components/ConnectAWalletWidgetView.tsx
new file mode 100644
index 00000000..06f67652
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/components/ConnectAWalletWidgetView.tsx
@@ -0,0 +1,107 @@
+import React from 'react'
+import { Alert, ButtonText, Heading, Spinner, WidgetTabs } from '@goodwidget/ui'
+import type { ConnectAWalletWidgetAdapterResult } from '../widgetRuntimeContract'
+import { AddressLinkForm } from './AddressLinkForm'
+import { ChainLinkRow } from './ChainLinkRow'
+import { PrimaryIdentityCard } from './PrimaryIdentityCard'
+import { ActionButton, EmptyStateCard, MultiWalletNotice, SupportedNetworksFooter, WidgetContent } from './shared'
+import { WalletGate } from './WalletGate'
+
+interface ConnectAWalletWidgetViewProps {
+ adapter: ConnectAWalletWidgetAdapterResult
+ 'data-testid'?: string
+}
+
+export function ConnectAWalletWidgetView({
+ adapter,
+ 'data-testid': dataTestId = 'connect-a-wallet-widget',
+}: ConnectAWalletWidgetViewProps) {
+ const { state, actions } = adapter
+
+ // Reuse the shared WidgetTabs component (already used by every other
+ // sibling widget) instead of hand-rolling the header — this also resolves
+ // the active chain to its friendly display name for free, matching the
+ // #113 design reference's "Connect identity" tab header.
+ const header = (
+ {}}
+ chainId={state.activeChainId ?? undefined}
+ />
+ )
+
+ if (!state.isWalletConnected) {
+ return (
+
+ {header}
+
+
+ )
+ }
+
+ return (
+
+ {header}
+
+
+
+
+
+ {!state.isActiveChainSupported && (
+
+ )}
+
+ {state.status === 'error' && (
+
+
+ Couldn't load link status
+
+
+
+ Retry
+
+
+ )}
+
+ {state.status !== 'error' && (
+
+ )}
+
+ {state.status === 'checking_address' && (
+
+
+
+ )}
+
+ {state.status === 'ready' && state.secondaryAddress && (
+ <>
+ {state.chainLinks.map((row) => (
+ actions.connectChain(row.chainId)}
+ onDisconnect={() => actions.disconnectChain(row.chainId)}
+ />
+ ))}
+ >
+ )}
+
+
+
+ )
+}
diff --git a/packages/connect-a-wallet-widget/src/components/ConnectPrompt.tsx b/packages/connect-a-wallet-widget/src/components/ConnectPrompt.tsx
new file mode 100644
index 00000000..099ffa8f
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/components/ConnectPrompt.tsx
@@ -0,0 +1,24 @@
+import React from 'react'
+import { ButtonText, Heading, Spinner, Text } from '@goodwidget/ui'
+import { ActionButton, EmptyStateCard } from './shared'
+
+interface ConnectPromptProps {
+ isConnecting: boolean
+ onConnect: () => void
+}
+
+export function ConnectPrompt({ isConnecting, onConnect }: ConnectPromptProps) {
+ return (
+
+
+ Wallet not connected
+
+
+ Connect your wallet to link additional addresses to your GoodID.
+
+
+ {isConnecting ? : Connect Wallet}
+
+
+ )
+}
diff --git a/packages/connect-a-wallet-widget/src/components/PrimaryIdentityCard.tsx b/packages/connect-a-wallet-widget/src/components/PrimaryIdentityCard.tsx
new file mode 100644
index 00000000..cc7f1a05
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/components/PrimaryIdentityCard.tsx
@@ -0,0 +1,57 @@
+import React from 'react'
+import { AddressDisplay, Icon, Text, XStack, YStack, createComponent } from '@goodwidget/ui'
+
+const IdentityIconBadge = createComponent(YStack, {
+ name: 'PrimaryIdentityIconBadge',
+ width: 36,
+ height: 36,
+ borderRadius: '$full',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '$infoMuted',
+})
+
+const IdentityCard = createComponent(XStack, {
+ name: 'PrimaryIdentityCard',
+ alignItems: 'center',
+ gap: '$3',
+ padding: '$3',
+ borderRadius: '$3',
+ borderWidth: 1,
+ borderColor: '$borderColor',
+})
+
+interface PrimaryIdentityCardProps {
+ walletAddress: string | null
+}
+
+/**
+ * Shows which address the per-chain rows below are linking *from* — the
+ * connected host wallet, i.e. the user's GoodID primary identity. Mirrors
+ * the "Primary Verified Identity" card in the #113 design reference.
+ */
+export function PrimaryIdentityCard({ walletAddress }: PrimaryIdentityCardProps) {
+ if (!walletAddress) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+ Primary verified identity
+
+
+
+
+ )
+}
diff --git a/packages/connect-a-wallet-widget/src/components/WalletGate.tsx b/packages/connect-a-wallet-widget/src/components/WalletGate.tsx
new file mode 100644
index 00000000..1c9d2002
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/components/WalletGate.tsx
@@ -0,0 +1,23 @@
+import React from 'react'
+import { ConnectPrompt } from './ConnectPrompt'
+
+interface WalletGateProps {
+ isWalletConnected: boolean
+ isConnecting: boolean
+ onConnect: () => void
+}
+
+/**
+ * Gates the widget on host-wallet connection only. Unsupported-chain
+ * handling is intentionally NOT gated here — per Bounty Lead sign-off it is
+ * a non-blocking warning shown alongside the per-chain rows, since a
+ * connected wallet on an unsupported chain can still switch chains inline
+ * from each row's connect/disconnect action.
+ */
+export function WalletGate({ isWalletConnected, isConnecting, onConnect }: WalletGateProps) {
+ if (!isWalletConnected) {
+ return
+ }
+
+ return null
+}
diff --git a/packages/connect-a-wallet-widget/src/components/format.ts b/packages/connect-a-wallet-widget/src/components/format.ts
new file mode 100644
index 00000000..860ff4be
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/components/format.ts
@@ -0,0 +1,27 @@
+import type { ConnectAWalletChainLinkStatus } from '../widgetRuntimeContract'
+
+/**
+ * Per-chain row button label and busy state. Extracted because every
+ * ChainLinkRow needs the same status → (label, busy, disabled) mapping, and
+ * getting it wrong would violate the "always show Connect or Disconnect,
+ * never hide it" requirement confirmed by the Bounty Lead.
+ */
+export function chainLinkRowPresentation(status: ConnectAWalletChainLinkStatus): {
+ actionLabel: 'Connect' | 'Disconnect'
+ isBusy: boolean
+ isDisabled: boolean
+} {
+ switch (status) {
+ case 'connected':
+ return { actionLabel: 'Disconnect', isBusy: false, isDisabled: false }
+ case 'disconnecting':
+ return { actionLabel: 'Disconnect', isBusy: true, isDisabled: true }
+ case 'connecting':
+ return { actionLabel: 'Connect', isBusy: true, isDisabled: true }
+ case 'checking':
+ return { actionLabel: 'Connect', isBusy: true, isDisabled: true }
+ case 'not_connected':
+ default:
+ return { actionLabel: 'Connect', isBusy: false, isDisabled: false }
+ }
+}
diff --git a/packages/connect-a-wallet-widget/src/components/shared.tsx b/packages/connect-a-wallet-widget/src/components/shared.tsx
new file mode 100644
index 00000000..eba68ee6
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/components/shared.tsx
@@ -0,0 +1,87 @@
+import React from 'react'
+import { Button, type ButtonProps, Card, Icon, Text, XStack, YStack, createComponent } from '@goodwidget/ui'
+
+export const WidgetContent = createComponent(YStack, {
+ name: 'ConnectAWalletWidgetContent',
+ flex: 1,
+ gap: '$3',
+ paddingVertical: '$3',
+})
+
+export const EmptyStateCard = createComponent(Card, {
+ name: 'EmptyStateCard',
+ padding: '$6',
+ alignItems: 'center' as const,
+ justifyContent: 'center' as const,
+ gap: '$3',
+})
+
+export const ChainRowCard = createComponent(Card, {
+ name: 'ChainRowCard',
+ padding: '$3',
+ flexDirection: 'row' as const,
+ alignItems: 'center' as const,
+ justifyContent: 'space-between' as const,
+ gap: '$2',
+})
+
+export const AddressFormCard = createComponent(Card, {
+ name: 'AddressFormCard',
+ padding: '$4',
+ gap: '$3',
+})
+
+export function ActionButton({ children, minWidth = 108, size = 'sm', ...props }: ButtonProps) {
+ return (
+
+ )
+}
+
+const MultiWalletNoticeFrame = createComponent(XStack, {
+ name: 'MultiWalletNoticeFrame',
+ alignItems: 'flex-start',
+ gap: '$2',
+ padding: '$3',
+ borderRadius: '$2',
+ borderWidth: 1,
+ backgroundColor: '$infoMuted',
+ borderColor: '$primary',
+})
+
+/**
+ * Multi-wallet / shared daily-claim notice from the #113 design reference.
+ * Built locally rather than reusing Alert because Alert only takes a plain
+ * message string and this copy requires a bold second sentence.
+ */
+export function MultiWalletNotice() {
+ return (
+
+
+
+ You can connect multiple wallet addresses.{' '}
+
+ However, only one claim per day is available, shared between the connected accounts.
+
+
+
+ )
+}
+
+const SupportedNetworksFooterFrame = createComponent(XStack, {
+ name: 'SupportedNetworksFooterFrame',
+ justifyContent: 'center',
+ paddingTop: '$2',
+})
+
+/** Static footer naming every chain the widget supports, per the design reference. */
+export function SupportedNetworksFooter() {
+ return (
+
+
+ Supported Networks: Celo, XDC, Fuse
+
+
+ )
+}
diff --git a/packages/connect-a-wallet-widget/src/element.ts b/packages/connect-a-wallet-widget/src/element.ts
new file mode 100644
index 00000000..370148cb
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/element.ts
@@ -0,0 +1,26 @@
+import { createMiniAppElement } from '@goodwidget/embed'
+import { ConnectAWalletWidget } from './ConnectAWalletWidget'
+import type React from 'react'
+
+/**
+ * A Custom Element class wrapping the ConnectAWalletWidget React component.
+ *
+ * Register it with any tag name:
+ * customElements.define('gw-connect-a-wallet', ConnectAWalletWidgetElement)
+ *
+ * Then use in HTML:
+ *
+ *
+ * Set the wallet provider and theme overrides via JS properties:
+ * const el = document.querySelector('gw-connect-a-wallet')
+ * el.provider = window.ethereum
+ * el.themeOverrides = { tokens: { color: { primary: '#00AFFE' } } }
+ */
+export const ConnectAWalletWidgetElement = createMiniAppElement(
+ ConnectAWalletWidget as React.ComponentType>,
+ {
+ shadow: true,
+ defaultTheme: 'dark',
+ events: ['link-success', 'link-error', 'unlink-success'],
+ },
+)
diff --git a/packages/connect-a-wallet-widget/src/index.ts b/packages/connect-a-wallet-widget/src/index.ts
new file mode 100644
index 00000000..7450cd09
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/index.ts
@@ -0,0 +1,27 @@
+// Integration metadata (links this widget to the citizen-sdk capability manifest)
+export { connectAWalletIntegration } from './integration'
+export type { ConnectAWalletIntegration } from './integration'
+
+// Adapter contract types
+export type {
+ ConnectAWalletChainId,
+ ConnectAWalletChainLinkState,
+ ConnectAWalletChainLinkStatus,
+ ConnectAWalletLinkErrorDetail,
+ ConnectAWalletLinkEventDetail,
+ ConnectAWalletWidgetAdapterActions,
+ ConnectAWalletWidgetAdapterResult,
+ ConnectAWalletWidgetAdapterState,
+ ConnectAWalletWidgetEnvironment,
+ ConnectAWalletWidgetProps,
+ ConnectAWalletWidgetStatus,
+} from './widgetRuntimeContract'
+export { CONNECT_A_WALLET_CHAINS } from './widgetRuntimeContract'
+
+// Adapter hook
+export { useConnectAWalletAdapter } from './adapter'
+export type { UseConnectAWalletAdapterOptions } from './adapter'
+
+// Widget component
+export { ConnectAWalletWidget, ConnectAWalletWidgetPreview } from './ConnectAWalletWidget'
+export type { ConnectAWalletWidgetPreviewProps } from './ConnectAWalletWidget'
diff --git a/packages/connect-a-wallet-widget/src/integration.ts b/packages/connect-a-wallet-widget/src/integration.ts
new file mode 100644
index 00000000..35b73322
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/integration.ts
@@ -0,0 +1,18 @@
+export const connectAWalletIntegration = {
+ id: 'connect-a-wallet',
+ sdk: '@goodsdks/citizen-sdk',
+ capabilitySource: 'citizenSdkCapabilities',
+ uses: ['connectAccount', 'disconnectAccount', 'checkConnectedStatus'],
+ chains: [122, 42220, 50],
+ states: [
+ 'not_connected',
+ 'connecting',
+ 'connected_no_input',
+ 'checking_address',
+ 'ready',
+ 'error',
+ ],
+ events: ['link-success', 'link-error', 'unlink-success'],
+} as const
+
+export type ConnectAWalletIntegration = typeof connectAWalletIntegration
diff --git a/packages/connect-a-wallet-widget/src/register.ts b/packages/connect-a-wallet-widget/src/register.ts
new file mode 100644
index 00000000..73e3b665
--- /dev/null
+++ b/packages/connect-a-wallet-widget/src/register.ts
@@ -0,0 +1,27 @@
+import { ConnectAWalletWidgetElement } from './element'
+
+const DEFAULT_TAG_NAME = 'gw-connect-a-wallet'
+
+/**
+ * Register the custom element.
+ *
+ * Call once at the top of your app or in a