diff --git a/.github/ISSUE_TEMPLATE/goodwidget-spec-template.yml b/.github/ISSUE_TEMPLATE/goodwidget-spec-template.yml index 6e40695..f02392c 100644 --- a/.github/ISSUE_TEMPLATE/goodwidget-spec-template.yml +++ b/.github/ISSUE_TEMPLATE/goodwidget-spec-template.yml @@ -69,7 +69,7 @@ body: - Title format: `[DRAFT][PLAN] ` - Type: `Task` - - Description must start with: `[DRAFT] ` + - Description must start with: `` - Link the sub-issue back to this parent issue. Instructions when creating the plan: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1d06715..c8c0c7e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -28,7 +28,7 @@ The expected flow a GoodWidget issue planning should follow is: 2. there will be an assignment of the original issue to start 'planning' the implementation. 3. this should result in a 'sub-issue' of the original issue, with title `[DRAFT][PLAN] ` (no changes to the original issue) -- sub-issue should have a type 'Task' - -- sub-issue should start the issue description with: [DRAFT] + -- sub-issue should start the issue description with: -- then follow the requested format from the persisted `Create the plan` section of the original issue. 4. Once the planning is done it should be requested to review the plan (no execution of the task or any pull-request should be opened at this stage). 5. Only issues assigned that have `[PLAN]` in their title and don't have `[DRAFT]` anymore can be executed and have pull-requests opened for them. diff --git a/.gitignore b/.gitignore index 2953eb7..659c359 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ playwright-report/ **/storybook-static/ **/.yalc **/yalc.lock +.bounties/contributor-bounties +local-reference-assets diff --git a/agent-next-steps/governance-design-md/DESIGN.md b/agent-next-steps/governance-design-md/DESIGN.md new file mode 100644 index 0000000..51ebf47 --- /dev/null +++ b/agent-next-steps/governance-design-md/DESIGN.md @@ -0,0 +1,193 @@ +## GoodDapp Civic (stitch) + +name: GoodDapp Civic +colors: +surface: '#FFFFFF' +surface-dim: '#d8dadc' +surface-bright: '#f8f9fb' +surface-container-lowest: '#ffffff' +surface-container-low: '#f2f4f6' +surface-container: '#eceef0' +surface-container-high: '#e6e8ea' +surface-container-highest: '#e0e3e5' +on-surface: '#191c1e' +on-surface-variant: '#3e4851' +inverse-surface: '#2d3133' +inverse-on-surface: '#eff1f3' +outline: '#6e7882' +outline-variant: '#bdc8d3' +surface-tint: '#006493' +primary: '#006493' +on-primary: '#ffffff' +primary-container: '#00b0ff' +on-primary-container: '#004060' +inverse-primary: '#8dcdff' +secondary: '#006b5f' +on-secondary: '#ffffff' +secondary-container: '#6cf9e4' +on-secondary-container: '#007165' +tertiary: '#585d79' +on-tertiary: '#ffffff' +tertiary-container: '#a0a5c4' +on-tertiary-container: '#363a55' +error: '#F00505' +on-error: '#ffffff' +error-container: '#ffdad6' +on-error-container: '#93000a' +primary-fixed: '#cae6ff' +primary-fixed-dim: '#8dcdff' +on-primary-fixed: '#001e30' +on-primary-fixed-variant: '#004b70' +secondary-fixed: '#6cf9e4' +secondary-fixed-dim: '#49dcc8' +on-secondary-fixed: '#00201c' +on-secondary-fixed-variant: '#005047' +tertiary-fixed: '#dde1ff' +tertiary-fixed-dim: '#c0c5e5' +on-tertiary-fixed: '#151a33' +on-tertiary-fixed-variant: '#404560' +background: '#f8f9fb' +on-background: '#191c1e' +surface-variant: '#e0e3e5' +surface-alt: '#EDF5FC' +text-primary: '#0D182D' +text-secondary: '#4F606F' +text-muted: '#8F9BB3' +border: '#D0D9E4' +success: '#13C636' +warning: '#FFB020' +typography: +headline-xl: +fontFamily: DM Sans +fontSize: 40px +fontWeight: '700' +lineHeight: 48px +letterSpacing: -0.02em +headline-lg: +fontFamily: DM Sans +fontSize: 32px +fontWeight: '700' +lineHeight: 40px +letterSpacing: -0.01em +headline-md: +fontFamily: DM Sans +fontSize: 24px +fontWeight: '700' +lineHeight: 32px +headline-sm: +fontFamily: DM Sans +fontSize: 20px +fontWeight: '700' +lineHeight: 28px +body-lg: +fontFamily: DM Sans +fontSize: 18px +fontWeight: '400' +lineHeight: 28px +body-md: +fontFamily: DM Sans +fontSize: 16px +fontWeight: '400' +lineHeight: 24px +label-md: +fontFamily: DM Sans +fontSize: 14px +fontWeight: '500' +lineHeight: 20px +label-sm: +fontFamily: DM Sans +fontSize: 12px +fontWeight: '600' +lineHeight: 16px +letterSpacing: 0.02em +headline-lg-mobile: +fontFamily: DM Sans +fontSize: 28px +fontWeight: '700' +lineHeight: 36px +rounded: +sm: 0.25rem +DEFAULT: 0.5rem +md: 0.75rem +lg: 1rem +xl: 1.5rem +full: 9999px +spacing: +gap-xs: 4px +gap-sm: 8px +gap-md: 16px +gap-lg: 24px +gap-xl: 32px +margin-page: 40px +max-width-content: 1200px + +--- + +## Brand & Style + +The design system is rooted in a **Modern / Corporate** aesthetic that leans into "Civic Tech" — a blend of operational efficiency and public accessibility. It prioritizes clarity, trust, and inclusivity, ensuring that governance participation feels like a meaningful public service rather than a complex financial transaction. + +The visual language uses expansive white space, a bright and optimistic primary blue, and structured information density to evoke a sense of organized accountability. It avoids the dark, high-contrast, or "neon" tropes of traditional DeFi, opting instead for a "SaaS-lite" feel that is friendly to non-technical community members. + +## Colors + +The palette is anchored by **Vibrant Blue (#00B0FF)**, used exclusively for primary actions, progress indicators, and active navigation states. To ensure a clean, "civic" atmosphere, the system utilizes a tiered background strategy: pure white surfaces for cards and content containers, and a light blue-tinted secondary background for sidebars and informational panels. + +- **Primary:** High-energy blue for "Claim," "Vote," and "Submit." +- **Functional Accents:** Green is reserved for success and positive participation signals; Red is used sparingly for ineligible states or critical errors. +- **Surface Alt:** The specific light blue tint (#EDF5FC) provides subtle depth and distinguishes between the main reading area and supporting operational tools. + +## Typography + +The design system exclusively uses **DM Sans**. This choice provides a geometric, modern, and highly legible foundation that works across both dense data tables and long-form proposal descriptions. + +- **Hierarchy:** Use bold weights (700) for all headlines to maintain a strong "editorial" feel for governance titles. +- **Labels:** Use medium (500) and semi-bold (600) weights for labels and metadata to ensure they remain distinct from body copy. +- **Responsive:** Headlines scale down on mobile to prevent awkward line breaks in long proposal titles while maintaining their relative visual weight. + +## Layout & Spacing + +The layout follows a **Fixed-Fluid Hybrid** model. The main content area is capped at a comfortable reading width, while the background and header extend to the edge of the viewport. + +- **Grid Model:** A 12-column grid is used for desktop. +- **The "Two-Thirds" Rule:** Active governance pages use a primary 8-column main area for proposal content and a 4-column sidebar for "Action & Stats" (voter eligibility, deadline countdown, etc.). +- **Spacing Rhythm:** Use 24px and 32px for major sectional gaps. 16px is the standard for internal card padding and between related UI elements like input fields and their labels. +- **Mobile Reflow:** The sidebar stacks below the main content, with the "Action CTA" pinned to a bottom sticky bar to ensure the voting trigger is always accessible. + +## Elevation & Depth + +This design system uses **Tonal Layering** supplemented by extremely soft, low-contrast shadows. + +- **Base Layer:** The Neutral background (#F6F8FA) serves as the canvas. +- **Secondary Layer:** Sidebars and supporting panels use Surface Alt (#EDF5FC) to create structural zones without the use of heavy borders. +- **Primary Elevation:** Cards and Modals use Surface white (#FFFFFF) with a very diffuse shadow (e.g., `0px 4px 20px rgba(0, 176, 255, 0.05)`). Note the subtle blue tint in the shadow to maintain color harmony. +- **Interactive States:** Buttons and interactive cards may lift slightly on hover, increasing the shadow spread by 4-8px. + +## Shapes + +The shape language is friendly but disciplined. Rounded corners are used to soften the "institutional" feel of governance. + +- **Cards & Containers:** Use a consistent 12px (`rounded-lg`) radius. +- **Primary Buttons:** Use a more pronounced 20px radius to make them stand out as the primary interactive touchpoints. +- **Pills & Tags:** Status chips (e.g., "Active," "Passed") use a full pill radius for immediate visual categorization as non-interactive status markers. +- **Inputs:** Follow the standard 8px-12px radius to match container styles. + +## Components + +### Buttons & Chips + +- **Primary Button:** Solid #00B0FF with white text. 20px roundedness. Bold DM Sans. +- **Secondary Button:** Ghost or outlined using #00B0FF, or a solid tint using #EDF5FC with blue text. +- **Status Chips:** Full pill radius. Use Success, Warning, or Error colors at 10-15% opacity with high-contrast text of the same hue. + +### Governance Specifics + +- **Proposal Hero:** Uses the H1 typography level. Includes a metadata row directly beneath (Date, Author, Status) separated by dot dividers. +- **Voting Steppers:** Clean numeric input fields framed by plus/minus buttons. Use Surface Alt for the background of the stepper unit to distinguish it from the card. +- **Eligibility Banner:** A full-width component at the top of the action sidebar. If ineligible, it uses a Warning background; if eligible, it uses a soft Secondary (#1FC2AF) tint. +- **Results Progress Bars:** Thick, 12px height bars with rounded caps. The primary "Winning" option uses the Primary Blue, while others use Text Muted or Border colors to create clear hierarchy. + +### Inputs & Cards + +- **Input Fields:** 1px border (#D0D9E4), turning Primary Blue on focus. Labels always sit above the field in Label-sm typography. +- **Member Cards:** Minimalist blocks with 12px radius. Prioritize the user's avatar and "Points" count as the primary visual anchors. diff --git a/agent-next-steps/governance-design-md/MERGED-PRESET-GAPS.md b/agent-next-steps/governance-design-md/MERGED-PRESET-GAPS.md new file mode 100644 index 0000000..4bd4699 --- /dev/null +++ b/agent-next-steps/governance-design-md/MERGED-PRESET-GAPS.md @@ -0,0 +1,42 @@ +# Merged Light Preset Gaps + +This note compares `local-reference-assets/governance-ui/DESIGN.md` against the current merged light path in `packages/ui/src/presets.ts`. + +The goal is not to restate everything that is already implemented. The goal is to call out the parts of the governance design direction that are still only partially represented, approximated, or not yet encoded as first-class preset values. + +## What the merged light preset already reflects + +- `background`, `surface`, `surfaceAlt`, `border`, `primary`, `success`, `warning`, and the primary text colors are broadly aligned to the civic palette. +- `DM Sans` is already represented in the governance preset typography direction. +- The general visual language is present: white cards, pale-blue secondary surfaces, soft shadows, and a blue primary action. +- The current `ClaimWidget` screenshot path now renders in a light civic direction rather than the legacy dark wallet look. + +## Likely missing or only partially encoded + +- The full surface ladder from `DESIGN.md` is not yet encoded as distinct preset values. +- `surface-dim`, `surface-bright`, `surface-container-lowest`, `surface-container-low`, `surface-container`, `surface-container-high`, and `surface-container-highest` are still mostly collapsed into a smaller set of equivalent light surfaces. +- The semantic `on-*` palette is not fully represented as theme-level tokens. +- `on-surface`, `on-surface-variant`, `on-primary`, `on-primary-container`, `on-secondary`, `on-secondary-container`, `on-tertiary`, `on-tertiary-container`, `on-error`, and the fixed-color variants are not fully modeled as first-class preset keys. +- `inverse-surface`, `inverse-on-surface`, and `inverse-primary` are not yet explicitly encoded in the merged light branch. +- `secondary-container`, `tertiary`, `tertiary-container`, `error-container`, and the fixed accent colors from the design reference are not mapped as a complete semantic system. +- `text-primary`, `text-secondary`, and `text-muted` exist conceptually in the merged light direction, but the preset still does not expose every naming variant from the design reference as a dedicated semantic alias. +- The merged preset still uses a smaller typography contract than the design document’s full role set. +- The design reference defines `headline-xl`, `headline-lg`, `headline-md`, `headline-sm`, `body-lg`, `body-md`, `label-md`, `label-sm`, and a mobile headline variant, while the merged preset currently exposes only the base body and heading scales. +- The design reference’s spacing language is more explicit than the current preset contract. +- `gap-xs`, `gap-sm`, `gap-md`, `gap-lg`, `gap-xl`, and `margin-page` are not exposed as named design tokens. +- `max-width-content: 1200px` from the design reference is not mirrored as a dedicated governance-specific content width token. +- The shape system is only partially aligned. +- `rounded-sm`, `rounded`, `rounded-md`, `rounded-lg`, `rounded-xl`, and `full` are not modeled as the full governance shape vocabulary from the design reference. +- Component-specific design directions like proposal hero layout, voting steppers, eligibility banners, and results progress bars are still widget-structure guidance rather than preset-level primitives. + +## Interpretation + +- The merged light preset is good enough for current widget rendering and review. +- It is not yet a complete mechanical transcription of `DESIGN.md`. +- In practice, that means the governance preset is still a useful reference target even if the merged preset now covers the main visual path. +- The remaining work is mostly about semantic completeness, not about redoing the current screenshot path. + +## Suggested follow-up + +- Add the missing semantic surface aliases only if current or upcoming widgets actually consume them. +- Keep the design document as the source of truth for brand direction, and use this gap list as the implementation checklist for the preset contract. diff --git a/docs/contributor-bounty-template.md b/docs/contributor-bounty-template.md new file mode 100644 index 0000000..40b6c47 --- /dev/null +++ b/docs/contributor-bounty-template.md @@ -0,0 +1,77 @@ +# Contributor Bounty Template + +Use this when an AI-generated PR already exists and a contributor needs to make it +production-ready before human review. + +Contributor role reference: +https://docs.gooddollar.org/for-developers/contributing/open-source-contributors/contributor-role + +````md +# [GoodBounty] Finalize PR + +## Summary + +Pick up the existing AI-generated PR for ``, run it locally, +fix implementation gaps, update tests/evidence, and prepare it for human review + +## Contributor task + +- Claim the bounty with an ETA. +- Check out the PR branch and run it locally. +- Compare the implementation against the parent issue, plan issue, and repo docs. +- Fix concrete gaps in behavior, tests, code quality, or UI. +- Update screenshots/videos when UI changes. +- Leave a handoff comment with what changed, what was tested, and remaining risks. + +## Scope checks + +_Breakdown and update with specifics towards a feature and execution plan_ + +- [ ] The PR solves the issue requirements. +- [ ] The implementation follows the plan or explains any deviation. +- [ ] Existing repo patterns and package boundaries are respected. +- [ ] Main happy path and important edge/error states work. +- [ ] Storybook covers the expected states. +- [ ] Playwright covers the main flows and includes current screenshots. +- [ ] Desktop and mobile layouts are usable. +- [ ] The PR description links the source issues and includes test evidence. + +## Required commands + +```sh +pnpm install +pnpm build +pnpm lint +pnpm test:demo tests/widgets/ +``` +```` + +If a command cannot be run, document why. + +## Handoff comment + +```md +Contributor: @ +PR: # +Commit: + +Fixed: + +- + +Verified: + +- : + +Evidence: + +- + +Remaining risks: + +- +``` + +``` + +``` diff --git a/examples/expo/app/_layout.tsx b/examples/expo/app/_layout.tsx index 830555a..aa3e7ce 100644 --- a/examples/expo/app/_layout.tsx +++ b/examples/expo/app/_layout.tsx @@ -10,7 +10,7 @@ import { GoodWidgetProvider } from '@goodwidget/core' */ export default function RootLayout() { return ( - + ) diff --git a/examples/react-web/src/App.tsx b/examples/react-web/src/App.tsx index 39d93fa..1e87794 100644 --- a/examples/react-web/src/App.tsx +++ b/examples/react-web/src/App.tsx @@ -506,7 +506,7 @@ function OverrideShowcase() { export function App() { return ( - + ) diff --git a/examples/storybook/.storybook/preview.tsx b/examples/storybook/.storybook/preview.tsx index 5a2d747..e4ea369 100644 --- a/examples/storybook/.storybook/preview.tsx +++ b/examples/storybook/.storybook/preview.tsx @@ -5,8 +5,15 @@ import React from 'react' import type { Preview } from '@storybook/react' import { GoodWidgetProvider } from '@goodwidget/core' +import type { GoodWidgetConfig } from '@goodwidget/ui' import { MiniAppShell } from '@goodwidget/ui' +interface StoryGoodWidgetParameters { + config?: GoodWidgetConfig + defaultTheme?: 'light' | 'dark' + useShell?: boolean +} + const preview: Preview = { parameters: { layout: 'centered', @@ -16,13 +23,22 @@ const preview: Preview = { }, }, decorators: [ - (Story) => ( - - - - - - ), + (Story, context) => { + const params = (context.parameters.goodWidgetProvider ?? {}) as StoryGoodWidgetParameters + const story = + + return ( + + {params.useShell === false ? ( + story + ) : ( + + {story} + + )} + + ) + }, ], } diff --git a/examples/storybook/src/fixtures/mockEip1193.ts b/examples/storybook/src/fixtures/mockEip1193.ts index d4bd906..4aa9a23 100644 --- a/examples/storybook/src/fixtures/mockEip1193.ts +++ b/examples/storybook/src/fixtures/mockEip1193.ts @@ -35,7 +35,7 @@ type EventCallback = (...args: unknown[]) => void * ```tsx * import { createMockEip1193Provider } from '../fixtures/mockEip1193' * - * + * * * * ``` diff --git a/examples/storybook/src/stories/design-system/ClaimWidget.stories.tsx b/examples/storybook/src/stories/design-system/ClaimWidget.stories.tsx index 06e3444..a9615a3 100644 --- a/examples/storybook/src/stories/design-system/ClaimWidget.stories.tsx +++ b/examples/storybook/src/stories/design-system/ClaimWidget.stories.tsx @@ -10,8 +10,9 @@ */ import React from 'react' import type { Meta, StoryObj } from '@storybook/react' +import { GoodWidgetProvider } from '@goodwidget/core' import { ClaimWidget } from '@goodwidget/claim-widget-theme-demo' -import { YStack } from '@goodwidget/ui' +import { MiniAppShell, YStack } from '@goodwidget/ui' import { createMockEip1193Provider } from '../../fixtures/mockEip1193' // Stable mock provider — created once at module level to prevent re-render churn. @@ -54,10 +55,13 @@ const tealOverrides = { } const meta: Meta = { - title: 'Theme/ClaimWidgetThemeDemo', + title: 'Theme/ClaimWidgetThemeDemo-Light', component: ClaimWidget, tags: ['autodocs'], parameters: { layout: 'padded' }, + goodWidgetProvider: { + useShell: false, + }, } export default meta type Story = StoryObj @@ -65,26 +69,12 @@ type Story = StoryObj /** Default preset — no overrides, GoodWalletV2 baseline. */ export const Default: Story = { render: () => ( - - - - ), -} - -/** Cobalt brand — token + component theme overrides via themeOverrides. */ -export const CobaltBrand: Story = { - render: () => ( - - - - ), -} - -/** Teal brand — a different brand palette via themeOverrides. */ -export const TealBrand: Story = { - render: () => ( - - - + + + + + + + ), } diff --git a/examples/storybook/src/stories/design-system/ThemePlayground.stories.tsx b/examples/storybook/src/stories/design-system/ThemePlayground.stories.tsx index 8e84af2..bfea555 100644 --- a/examples/storybook/src/stories/design-system/ThemePlayground.stories.tsx +++ b/examples/storybook/src/stories/design-system/ThemePlayground.stories.tsx @@ -2,11 +2,10 @@ * ThemePlayground — interactive exploration of GoodWidget's override system. * * This story group covers the four supported override layers in order of precedence: - * 1. Default preset (GoodWalletV2 — no overrides) + * 1. Default preset (GoodWalletV2 dark baseline — no overrides) * 2. Token overrides — broad brand/palette/scale changes * 3. Component sub-theme overrides — targeted named-component skinning * 4. Host themeOverrides — runtime integrator overrides (merged last) - * * Each story mounts a single ClaimWidget to avoid Tamagui theme-key clashing. * * See docs/demo-environment.md for the full override precedence model. @@ -28,7 +27,7 @@ export default meta type Story = StoryObj /** - * Default preset — the GoodWalletV2 base design system, no runtime overrides. + * Default preset — the GoodWalletV2 dark base design system, no runtime overrides. * This is what every widget instance looks like out of the box. */ export const DefaultPreset: Story = { @@ -37,9 +36,9 @@ export const DefaultPreset: Story = { - + ), } @@ -61,8 +60,8 @@ export const TokenOverride: Story = { /> How it works - - {` + {` How it works - - {` + {``} @@ -120,12 +120,13 @@ export const ComponentThemeOverride: Story = { @@ -150,8 +151,8 @@ export const HostOverrideCobalt: Story = { /> How it works - - {` + {` @@ -194,16 +196,17 @@ export const HostOverrideTeal: Story = { /> diff --git a/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx b/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx index 12520e8..0f7da91 100644 --- a/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx +++ b/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx @@ -1,6 +1,17 @@ import React from 'react' import type { Meta, StoryObj } from '@storybook/react' -import { StreamingWidget } from '@goodwidget/streaming-widget' +import { + STREAMING_CHAINS, + StreamingWidget, + StreamingWidgetPreview, + type PoolMembershipItem, + type SetStreamFormState, + type StreamingWidgetAdapterActions, + type StreamingWidgetAdapterResult, + type StreamingWidgetAdapterState, + type StreamingWidgetTab, + type StreamListItem, +} from '@goodwidget/streaming-widget' import { YStack } from '@goodwidget/ui' import { getInjectedEip1193Provider, @@ -8,10 +19,202 @@ import { } from '../../fixtures/injectedEip1193' import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' -// --------------------------------------------------------------------------- -// Story shell — renders the widget inside a fixed-width container that mirrors -// the GoodWalletV2 sidebar / bottom-sheet form factor. -// --------------------------------------------------------------------------- +const DEMO_ADDRESS = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' +const DEMO_RECEIVER = '0x1111111111111111111111111111111111111111' +const DEMO_SENDER = '0x2222222222222222222222222222222222222222' +const DEMO_TOKEN = '0x3333333333333333333333333333333333333333' +const DEMO_POOL = '0x4444444444444444444444444444444444444444' +const DEMO_RESERVE_LOCKER = '0x8888888888888888888888888888888888888888' + +const defaultForm: SetStreamFormState = { + receiver: '', + amount: '', + timeUnit: 'month', + flowRate: null, + validationError: null, +} + +const validForm: SetStreamFormState = { + receiver: DEMO_RECEIVER, + amount: '42', + timeUnit: 'month', + flowRate: 16203703703703n, + validationError: null, +} + +const invalidForm: SetStreamFormState = { + receiver: '0x123', + amount: '0', + timeUnit: 'month', + flowRate: null, + validationError: 'Recipient must be a valid Ethereum address (0x...).', +} + +const sampleStreams: StreamListItem[] = [ + { + id: 'outgoing-demo-stream', + sender: DEMO_ADDRESS, + receiver: DEMO_RECEIVER, + token: DEMO_TOKEN, + flowRate: 38580246913580n, + streamedSoFar: 15000000000000000000n, + createdAtTimestamp: 1767225600, + updatedAtTimestamp: 1767312000, + direction: 'outgoing', + }, + { + id: 'incoming-demo-stream', + sender: DEMO_SENDER, + receiver: DEMO_ADDRESS, + token: DEMO_TOKEN, + flowRate: 19290123456790n, + streamedSoFar: 7800000000000000000n, + createdAtTimestamp: 1767139200, + updatedAtTimestamp: 1767312000, + direction: 'incoming', + }, +] + +// Mirrors the current SDK-backed adapter; diverge this once past-stream history is fetched separately. +const sampleStreamHistory: StreamListItem[] = [ + ...sampleStreams, + { + id: 'history-outgoing-demo-stream-2', + sender: DEMO_ADDRESS, + receiver: '0x5555555555555555555555555555555555555555', + token: DEMO_TOKEN, + flowRate: 9645061728395n, + streamedSoFar: 4300000000000000000n, + createdAtTimestamp: 1767052800, + updatedAtTimestamp: 1767139200, + direction: 'outgoing', + }, + { + id: 'history-incoming-demo-stream-2', + sender: '0x6666666666666666666666666666666666666666', + receiver: DEMO_ADDRESS, + token: DEMO_TOKEN, + flowRate: 5787037037037n, + streamedSoFar: 2200000000000000000n, + createdAtTimestamp: 1766966400, + updatedAtTimestamp: 1767052800, + direction: 'incoming', + }, + { + id: 'history-outgoing-demo-stream-3', + sender: DEMO_ADDRESS, + receiver: '0x7777777777777777777777777777777777777777', + token: DEMO_TOKEN, + flowRate: 3858024691358n, + streamedSoFar: 1400000000000000000n, + createdAtTimestamp: 1766880000, + updatedAtTimestamp: 1766966400, + direction: 'outgoing', + }, +] + +const samplePools: PoolMembershipItem[] = [ + { + poolId: DEMO_POOL, + poolToken: DEMO_TOKEN, + totalUnits: 250000000000000000000n, + claimableAmount: 12500000000000000000n, + claimableAmountError: false, + totalAmountClaimed: 48000000000000000000n, + isConnected: false, + }, +] + +function createAdapter( + stateOverrides: Partial = {}, + actionOverrides: Partial = {}, +): StreamingWidgetAdapterResult { + const baseState: StreamingWidgetAdapterState = { + isConnected: true, + address: DEMO_ADDRESS, + chainId: STREAMING_CHAINS.CELO, + isWrongChain: false, + streams: sampleStreams, + streamsLoading: false, + streamsError: null, + streamHistory: sampleStreamHistory, + streamHistoryLoading: false, + streamHistoryError: null, + pools: samplePools, + poolsLoading: false, + poolsError: null, + superTokenBalance: '128.50', + balanceLoading: false, + balanceError: null, + supTokenBalance: '24.25', + supBalanceLoading: false, + supBalanceError: null, + supReserveBalance: null, + supReserveLockers: [], + supReserveLoading: false, + supReserveError: null, + setStreamForm: defaultForm, + setStreamStatus: 'idle', + setStreamError: null, + setStreamTxHash: null, + poolConnectStatus: {}, + poolConnectError: {}, + poolClaimStatus: {}, + poolClaimError: {}, + } + + const actions: StreamingWidgetAdapterActions = { + connect: async () => {}, + switchChain: async () => {}, + refreshStreams: async () => {}, + refreshStreamHistory: async () => {}, + refreshPools: async () => {}, + refreshBalance: async () => {}, + updateSetStreamForm: () => {}, + submitSetStream: async () => {}, + resetSetStream: () => {}, + connectToPool: async () => {}, + disconnectFromPool: async () => {}, + claimFromPool: async () => {}, + ...actionOverrides, + } + + return { + state: { ...baseState, ...stateOverrides }, + actions, + } +} + +function PreviewStoryShell({ + adapter, + dataTestId, + initialTab = 'streams', + initialStreamsFormOpen = false, +}: { + adapter: StreamingWidgetAdapterResult + dataTestId: string + initialTab?: StreamingWidgetTab + initialStreamsFormOpen?: boolean +}) { + return ( + + + + ) +} + function StreamingWidgetStoryShell({ provider, dataTestId, @@ -20,8 +223,16 @@ function StreamingWidgetStoryShell({ dataTestId: string }) { return ( - - + + ) } @@ -36,22 +247,25 @@ const meta: Meta = { export default meta type Story = StoryObj -// --------------------------------------------------------------------------- -// Injected wallet story — uses window.ethereum if present in the browser -// --------------------------------------------------------------------------- function InjectedWalletStory() { const injectedProvider = getInjectedEip1193Provider() const usableProvider = isInjectedProviderUsable(injectedProvider) if (!usableProvider) { return ( - - No injected wallet found - - Install/enable MetaMask (or another EIP-1193 wallet) in this browser, then refresh - Storybook. The widget supports Celo (G$) and Base (SUP). - - + ) } @@ -63,9 +277,6 @@ function InjectedWalletStory() { ) } -// --------------------------------------------------------------------------- -// Custodial fixture story — uses the pre-configured test wallet from the fixture -// --------------------------------------------------------------------------- function CustodialLocalFixtureStory() { try { const provider = createCustodialEip1193Provider() @@ -77,7 +288,10 @@ function CustodialLocalFixtureStory() { ) } catch (error: unknown) { return ( - + Custodial fixture not configured {error instanceof Error @@ -89,37 +303,327 @@ function CustodialLocalFixtureStory() { } } -// --------------------------------------------------------------------------- -// No-wallet story — demonstrates the connect-prompt state -// --------------------------------------------------------------------------- -function NoWalletStory() { +function PoolClaimableAmountErrorStory() { + const [retrying, setRetrying] = React.useState(false) + return ( - { + setRetrying(true) + }, + }, + )} + dataTestId="StreamingWidget-pool-claimable-amount-error" + initialTab="pools" /> ) } -// --------------------------------------------------------------------------- -// Story exports -// --------------------------------------------------------------------------- +function CreateUpdateFormStory() { + const [form, setForm] = React.useState(validForm) + + return ( + { + setForm((current) => ({ + ...current, + ...partial, + validationError: null, + })) + }, + }, + )} + dataTestId="StreamingWidget-create-update-form" + initialStreamsFormOpen + /> + ) +} -/** Uses window.ethereum if available — shows the full connected experience. */ export const InjectedWallet: Story = { render: () => , } -/** - * Uses a pre-configured custodial test wallet backed by a local private key. - * Starts on Celo (chain 42220) and uses the development environment. - * The test key has no on-chain streaming history, so streams/pools lists will be empty. - */ export const CustodialLocalFixture: Story = { render: () => , } -/** No provider — shows the wallet-connection prompt for both Streams and Pools tabs. */ export const NoWallet: Story = { - render: () => , + render: () => ( + + ), +} + +export const WrongChain: Story = { + render: () => ( + + ), +} + +export const LoadingState: Story = { + render: () => ( + + ), +} + +export const EmptyState: Story = { + render: () => ( + + ), +} + +export const ErrorState: Story = { + render: () => ( + + ), +} + +export const PopulatedState: Story = { + render: () => ( + + ), +} + +export const CreateUpdateForm: Story = { + render: () => , +} + +export const CreateUpdateInvalidInput: Story = { + render: () => ( + + ), +} + +export const CreateUpdatePending: Story = { + render: () => ( + + ), +} + +export const CreateUpdateSuccess: Story = { + render: () => ( + + ), +} + +export const CreateUpdateFailure: Story = { + render: () => ( + + ), +} + +export const PoolClaimState: Story = { + render: () => ( + + ), +} + +export const PoolConnectedState: Story = { + render: () => ( + + ), +} + +// Claim lifecycle stories use isConnected: true so write status badges render correctly. +export const PoolClaimPending: Story = { + render: () => ( + + ), +} + +export const PoolClaimSuccess: Story = { + render: () => ( + + ), +} + +export const PoolClaimError: Story = { + render: () => ( + + ), +} + +export const PoolClaimableAmountError: Story = { + render: () => , +} + +export const BaseSupBalanceAndReserve: Story = { + render: () => ( + + ), +} + +export const NonBaseSupReserveDisabled: Story = { + render: () => ( + + ), } diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index 783f5a0..c03c78e 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -529,7 +529,7 @@ export function CitizenClaimWidget({ environment = 'production', themeOverrides, config, - defaultTheme = 'light', + defaultTheme = 'dark', onClaimSuccess, onClaimError, }: CitizenClaimWidgetProps) { diff --git a/packages/citizen-claim-widget/src/element.ts b/packages/citizen-claim-widget/src/element.ts index 13ee737..409c141 100644 --- a/packages/citizen-claim-widget/src/element.ts +++ b/packages/citizen-claim-widget/src/element.ts @@ -20,7 +20,7 @@ export const CitizenClaimWidgetElement = createMiniAppElement( CitizenClaimWidget as React.ComponentType>, { shadow: true, - defaultTheme: 'light', + defaultTheme: 'dark', events: ['claim-success', 'claim-error'], }, ) diff --git a/packages/claim-widget-theme-demo/src/ClaimWidget.tsx b/packages/claim-widget-theme-demo/src/ClaimWidget.tsx index a7bf96d..aa6f6b6 100644 --- a/packages/claim-widget-theme-demo/src/ClaimWidget.tsx +++ b/packages/claim-widget-theme-demo/src/ClaimWidget.tsx @@ -63,16 +63,16 @@ const ClaimActionButton = createComponent(ButtonFrame, { const ClaimActionGlow = createComponent(YStack, { name: 'ClaimActionGlow', position: 'absolute', - top: -16, - right: -16, - bottom: -16, - left: -16, + top: '$glowOffset', + right: '$glowOffset', + bottom: '$glowOffset', + left: '$glowOffset', borderRadius: 9999, - backgroundColor: '$primary', + backgroundColor: '$backgroundColor', hoverStyle: { backgroundColor: '$primaryLight', }, - opacity: 0.45, + opacity: '$glowOpacity', }) const ClaimActionRing = createComponent(YStack, { @@ -169,8 +169,8 @@ function ClaimInner() { > {claiming ? ( - - Claiming... + + Claiming... ) : ( {address ? 'Claim' : 'Connect'} @@ -253,7 +253,7 @@ export function ClaimWidget({ provider, themeOverrides, config, // We are exposing it to our demo apps but ideally config overrides should be done by widget authors and any host-level overrides done through themeOverrides - defaultTheme = 'light', + defaultTheme = 'dark', }: ClaimWidgetProps) { return ( >, { shadow: true, - defaultTheme: 'light', + defaultTheme: 'dark', events: ['claim-success', 'claim-error'], }, ) diff --git a/packages/core/src/provider.tsx b/packages/core/src/provider.tsx index 0aa38f2..e1958bc 100644 --- a/packages/core/src/provider.tsx +++ b/packages/core/src/provider.tsx @@ -57,7 +57,7 @@ export function GoodWidgetProvider({ provider: explicitProvider, config: authorConfig, themeOverrides, - defaultTheme = 'light', + defaultTheme = 'dark', children, }: GoodWidgetProviderProps) { const [resolvedProvider, setResolvedProvider] = useState( diff --git a/packages/embed/src/createMiniAppElement.tsx b/packages/embed/src/createMiniAppElement.tsx index 5362c7b..7357a07 100644 --- a/packages/embed/src/createMiniAppElement.tsx +++ b/packages/embed/src/createMiniAppElement.tsx @@ -68,7 +68,7 @@ export function createMiniAppElement( shadow = true, props: propDefs = {}, events = [], - defaultTheme = 'light', + defaultTheme = 'dark', defaultConfig, } = options const normalizedProps = normalizePropDefs(propDefs) diff --git a/packages/streaming-widget/src/StreamingWidget.tsx b/packages/streaming-widget/src/StreamingWidget.tsx index f837009..419599d 100644 --- a/packages/streaming-widget/src/StreamingWidget.tsx +++ b/packages/streaming-widget/src/StreamingWidget.tsx @@ -1,706 +1,58 @@ -import React, { useState } from 'react' +import React from 'react' import { GoodWidgetProvider } from '@goodwidget/core' import type { EIP1193Provider } from '@goodwidget/core' -import { - createComponent, - Card, - Heading, - Text, - Button, - ButtonText, - Spinner, - Separator, - ToastContainer, - XStack, - YStack, - Input, - Select, - Badge, - BadgeText, - AddressDisplay, - TokenAmount, - WidgetTabs, -} from '@goodwidget/ui' -import type { Address } from 'viem' -import { formatUnits } from 'viem' +import { ToastContainer } from '@goodwidget/ui' import { useStreamingAdapter } from './adapter' +import { StreamingWidgetView } from './components/StreamingWidgetView' import type { + StreamingWidgetAdapterResult, StreamingWidgetProps, StreamingWidgetTab, - StreamDirection, - StreamListItem, - PoolMembershipItem, - StreamTimeUnit, - WriteStatus, } from './widgetRuntimeContract' -import { STREAMING_CHAINS } from './widgetRuntimeContract' -// --------------------------------------------------------------------------- -// Named styled sub-components — participate in the component sub-theme system. -// Integrators can override via themeOverrides. -// --------------------------------------------------------------------------- - -/** Outer shell for each tab's content area */ -const StreamingTabContent = createComponent(YStack, { - name: 'StreamingTabContent', - flex: 1, - gap: '$3', - paddingVertical: '$3', -}) - -/** Row card for a single stream entry */ -const StreamRow = createComponent(Card, { - name: 'StreamRow', - padding: '$3', - gap: '$2', -}) - -/** Row card for a single pool membership entry */ -const PoolRow = createComponent(Card, { - name: 'PoolRow', - padding: '$3', - gap: '$2', -}) - -/** Card displayed when a list is empty */ -const EmptyStateCard = createComponent(Card, { - name: 'EmptyStateCard', - padding: '$6', - alignItems: 'center' as const, - justifyContent: 'center' as const, - gap: '$3', -}) - -/** Card displayed for inline error states */ -const ErrorStateCard = createComponent(Card, { - name: 'ErrorStateCard', - padding: '$4', - gap: '$2', -}) - -/** Card for the create/update stream form */ -const SetStreamFormCard = createComponent(Card, { - name: 'SetStreamFormCard', - padding: '$4', - gap: '$3', -}) - -/** Card for balance display */ -const BalanceCard = createComponent(Card, { - name: 'BalanceCard', - padding: '$4', - gap: '$2', -}) - -// --------------------------------------------------------------------------- -// Utility helpers -// --------------------------------------------------------------------------- - -const TIME_UNIT_OPTIONS: Array<{ value: StreamTimeUnit; label: string }> = [ - { value: 'second', label: 'per second' }, - { value: 'minute', label: 'per minute' }, - { value: 'hour', label: 'per hour' }, - { value: 'day', label: 'per day' }, - { value: 'week', label: 'per week' }, - { value: 'month', label: 'per month' }, - { value: 'year', label: 'per year' }, -] - -/** Formats a flow rate bigint (wei/s) into a human-readable per-period amount */ -function formatFlowRateDisplay(flowRate: bigint, decimals = 18): string { - if (flowRate === 0n) return '0' - // Convert wei/s → per-month amount for display - const perMonth = flowRate * BigInt(30 * 24 * 60 * 60) - return formatUnits(perMonth, decimals) -} - -/** Formats a unix timestamp (seconds) to a short locale date string */ -function formatTimestamp(unixSeconds: number): string { - if (!unixSeconds) return '—' - return new Date(unixSeconds * 1000).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }) -} - -/** Short-form chain name display */ -function chainName(chainId: number): string { - if (chainId === STREAMING_CHAINS.CELO) return 'Celo' - if (chainId === STREAMING_CHAINS.BASE) return 'Base' - return `Chain ${chainId}` -} - -// --------------------------------------------------------------------------- -// Write-status badge helper -// --------------------------------------------------------------------------- -function WriteStatusBadge({ status }: { status: WriteStatus }) { - if (status === 'idle') return null - if (status === 'pending') return - if (status === 'success') - return ( - - Done - - ) - return ( - - Failed - - ) -} - -// --------------------------------------------------------------------------- -// Not-connected / wrong-chain prompt -// --------------------------------------------------------------------------- -function WalletGate({ - isConnected, - isWrongChain, - onConnect, - onSwitchChain, -}: { - isConnected: boolean - isWrongChain: boolean - onConnect: () => void - onSwitchChain: (chainId: number) => void -}) { - if (!isConnected) { - return ( - - - Wallet not connected - - - Connect your wallet to view streams, pools, and balances. - - - - ) - } - - if (isWrongChain) { - return ( - - - Unsupported network - - - Switch to Celo or Base to use the streaming widget. - - - - - - - ) - } - - return null -} - -// --------------------------------------------------------------------------- -// Set-stream form — create or update an outgoing stream -// --------------------------------------------------------------------------- -function SetStreamForm({ - form, - status, - error, - txHash, - onUpdate, - onSubmit, - onReset, -}: { - form: ReturnType['state']['setStreamForm'] - status: WriteStatus - error: string | null - txHash: string | null - onUpdate: (partial: Partial) => void - onSubmit: () => void - onReset: () => void -}) { - const isSubmitting = status === 'pending' - - return ( - - {form.receiver ? 'Update Stream' : 'Create Stream'} - - {/* Recipient address */} - - Recipient address - onUpdate({ receiver: v })} - editable={!isSubmitting} - /> - - - {/* Amount + time unit */} - - - Amount - onUpdate({ amount: v })} - keyboardType="decimal-pad" - editable={!isSubmitting} - /> - - - Period - onUpdate({ receiver: value })} + editable={!isSubmitting} + /> + + + + + Amount ({token}) + onUpdate({ amount: value })} + keyboardType="decimal-pad" + editable={!isSubmitting} + /> + + + Period +