From f13d2da0ddf84ad5c65a103a98a40b925edda75c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 23 Apr 2026 10:19:29 +0000
Subject: [PATCH 1/5] Initial plan
From e41f5c6097d83fefcd1945186a0c7bcbbbbf0ea8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 23 Apr 2026 10:31:21 +0000
Subject: [PATCH 2/5] feat: implement GoodWalletV2 UI component mapping
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add colorSoft/colorDim semantic theme keys to light/dark themes (presets.ts, theme.ts)
- Add THEME_KEY_NAMES entries for colorSoft/colorDim (createComponent.ts)
- Add light_Toast/dark_Toast, light_Dialog/dark_Dialog component themes to presets.ts and theme.ts
- Add full radius and icon* size tokens to defaultTokenValues (theme.ts)
- Create components/Text.ts with truncate, noWrap, colorSoft, colorDim variants
- Create components/Button.tsx with pill, icon, text, list variants; default radius → $full
- Create components/Separator.ts using Tamagui Separator base with size/color variants
- Create components/Icon.tsx with inline SVG registry, size/color/spin/round props
- Create components/Dialog.tsx with imperative store (createDialog, updateDialogStatus, closeDialog, useDialog)
- Create components/Toast.tsx with ToastContainer, queue store, status variant, progress bar
- Update index.ts to export all new components from components/"
Agent-Logs-Url: https://github.com/GoodDollar/GoodWidget/sessions/5517c699-b8e4-49fc-b0e8-d993b89f2d7f
Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com>
---
packages/ui/src/components/Button.tsx | 217 ++++++++++++++++
packages/ui/src/components/Dialog.tsx | 323 +++++++++++++++++++++++
packages/ui/src/components/Icon.tsx | 192 ++++++++++++++
packages/ui/src/components/Separator.ts | 49 ++++
packages/ui/src/components/Text.ts | 66 +++++
packages/ui/src/components/Toast.tsx | 330 ++++++++++++++++++++++++
packages/ui/src/createComponent.ts | 2 +
packages/ui/src/index.ts | 36 ++-
packages/ui/src/presets.ts | 36 +++
packages/ui/src/theme.ts | 52 ++++
10 files changed, 1298 insertions(+), 5 deletions(-)
create mode 100644 packages/ui/src/components/Button.tsx
create mode 100644 packages/ui/src/components/Dialog.tsx
create mode 100644 packages/ui/src/components/Icon.tsx
create mode 100644 packages/ui/src/components/Separator.ts
create mode 100644 packages/ui/src/components/Text.ts
create mode 100644 packages/ui/src/components/Toast.tsx
diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx
new file mode 100644
index 0000000..ec8fd7e
--- /dev/null
+++ b/packages/ui/src/components/Button.tsx
@@ -0,0 +1,217 @@
+import React from 'react'
+import type { ReactNode } from 'react'
+import { styled, Stack, Text as TamaguiText, Theme } from 'tamagui'
+import { createComponent } from '../createComponent'
+
+/**
+ * ButtonFrame — the styled base for all Button variants.
+ *
+ * Named 'Button' so Tamagui resolves the light_Button / dark_Button
+ * component sub-themes automatically.
+ *
+ * Design values come from the active theme and token scales — no
+ * hardcoded hex colors here. The preset drives all visual changes.
+ *
+ * Variant inventory:
+ * variant → primary | secondary | outline | ghost | pill | text | list
+ * size → sm | md | lg (standard interactive sizes)
+ * iconSize → sm | md | lg | larger (for icon-only buttons)
+ * disabled → true
+ * fullWidth → true
+ *
+ * Default radius uses $full (pill) to match GoodWalletV2 brand language.
+ * Override per-instance with explicit borderRadius prop if needed.
+ */
+export const ButtonFrame = createComponent(Stack, {
+ name: 'Button',
+ tag: 'button',
+ role: 'button',
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: '$background',
+ // GoodWalletV2 brand: solid buttons use pill radius by default
+ borderRadius: '$full',
+ paddingHorizontal: '$4',
+ height: '$10',
+ gap: '$2',
+ cursor: 'pointer',
+ borderWidth: 0,
+
+ hoverStyle: {
+ backgroundColor: '$backgroundHover',
+ },
+ pressStyle: {
+ backgroundColor: '$backgroundPress',
+ opacity: 0.9,
+ },
+ focusStyle: {
+ backgroundColor: '$backgroundFocus',
+ outlineStyle: 'solid',
+ outlineWidth: 2,
+ outlineColor: '$borderColorFocus',
+ },
+
+ variants: {
+ variant: {
+ // Solid filled — driven by light_Button / dark_Button component theme
+ primary: {},
+ // Transparent with border — resets theme so border uses parent text color
+ secondary: {
+ backgroundColor: '$backgroundTransparent',
+ borderWidth: 1,
+ borderColor: '$borderColor',
+ },
+ // Transparent with colored border
+ outline: {
+ backgroundColor: '$backgroundTransparent',
+ borderWidth: 1,
+ borderColor: '$color',
+ },
+ // Transparent, no border — hover changes bg only
+ ghost: {
+ backgroundColor: '$backgroundTransparent',
+ },
+ // Badge/chip style — pill shape, muted bg, colored text, uppercase 11px
+ pill: {
+ height: 35,
+ borderRadius: '$full',
+ paddingHorizontal: '$3',
+ backgroundColor: '$backgroundPress',
+ borderWidth: 0,
+ gap: '$1',
+ },
+ // Full-width text link — no bg change on hover, opacity instead
+ text: {
+ backgroundColor: '$backgroundTransparent',
+ borderWidth: 0,
+ paddingHorizontal: 0,
+ height: 'auto',
+ hoverStyle: { opacity: 0.7, backgroundColor: '$backgroundTransparent' },
+ pressStyle: { opacity: 0.5, backgroundColor: '$backgroundTransparent' },
+ },
+ // Icon + label row — full width, left-aligned, 42px min height
+ list: {
+ backgroundColor: '$backgroundTransparent',
+ borderWidth: 0,
+ justifyContent: 'flex-start',
+ width: '100%',
+ minHeight: 42,
+ paddingHorizontal: '$3',
+ borderRadius: '$2',
+ gap: '$3',
+ hoverStyle: { backgroundColor: '$backgroundHover' },
+ pressStyle: { backgroundColor: '$backgroundPress', opacity: 1 },
+ },
+ },
+
+ // Standard interactive sizes
+ size: {
+ sm: { height: '$8', paddingHorizontal: '$3', gap: '$1' },
+ md: { height: '$10', paddingHorizontal: '$4', gap: '$2' },
+ lg: { height: '$11', paddingHorizontal: '$5', gap: '$2' },
+ },
+
+ // Icon-only sizes (transparent bg, square, no padding)
+ iconSize: {
+ sm: {
+ width: '$iconXs',
+ height: '$iconXs',
+ padding: 0,
+ paddingHorizontal: 0,
+ backgroundColor: '$backgroundTransparent',
+ borderWidth: 0,
+ borderRadius: '$2',
+ },
+ md: {
+ width: '$iconSm',
+ height: '$iconSm',
+ padding: 0,
+ paddingHorizontal: 0,
+ backgroundColor: '$backgroundTransparent',
+ borderWidth: 0,
+ borderRadius: '$2',
+ },
+ lg: {
+ width: '$iconMd',
+ height: '$iconMd',
+ padding: 0,
+ paddingHorizontal: 0,
+ backgroundColor: '$backgroundTransparent',
+ borderWidth: 0,
+ borderRadius: '$2',
+ },
+ larger: {
+ width: '$iconLg',
+ height: '$iconLg',
+ padding: 0,
+ paddingHorizontal: 0,
+ backgroundColor: '$backgroundTransparent',
+ borderWidth: 0,
+ borderRadius: '$2',
+ },
+ },
+
+ disabled: {
+ true: { opacity: 0.5, cursor: 'not-allowed', pointerEvents: 'none' },
+ },
+ fullWidth: {
+ true: { width: '100%' },
+ },
+ } as const,
+
+ defaultVariants: {
+ variant: 'primary',
+ size: 'md',
+ },
+})
+
+export const ButtonText = styled(TamaguiText, {
+ name: 'ButtonText',
+ fontFamily: '$body',
+ fontSize: '$3',
+ fontWeight: '600',
+ color: '$color',
+ userSelect: 'none',
+})
+
+/** Pill label text — uppercase 11px, tracking wide */
+export const PillText = styled(TamaguiText, {
+ name: 'PillText',
+ fontFamily: '$body',
+ fontSize: 11,
+ fontWeight: '600',
+ letterSpacing: 0.5,
+ textTransform: 'uppercase',
+ color: '$color',
+ userSelect: 'none',
+})
+
+export interface ButtonProps {
+ variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'pill' | 'text' | 'list'
+ size?: 'sm' | 'md' | 'lg'
+ iconSize?: 'sm' | 'md' | 'lg' | 'larger'
+ disabled?: boolean
+ fullWidth?: boolean
+ onPress?: () => void
+ children?: ReactNode
+ [key: string]: unknown
+}
+
+/**
+ * The light_Button theme sets color to white for primary buttons on a colored
+ * background. For secondary/outline/ghost/text variants the background is
+ * transparent, so we reset the theme so children (ButtonText) pick up the
+ * parent theme's text color instead.
+ *
+ * Loading state: GoodWidget uses Spinner rather than V2's shimmer overlay.
+ * Pass a Spinner as children when showing a loading state.
+ */
+export function Button({ variant = 'primary', children, ...props }: ButtonProps) {
+ const needsReset = variant !== 'primary'
+ return (
+
+ {needsReset ? {children} : children}
+
+ )
+}
diff --git a/packages/ui/src/components/Dialog.tsx b/packages/ui/src/components/Dialog.tsx
new file mode 100644
index 0000000..aa2b067
--- /dev/null
+++ b/packages/ui/src/components/Dialog.tsx
@@ -0,0 +1,323 @@
+import React, { useEffect, useState } from 'react'
+import { Dialog as TamaguiDialog, Stack, Text as TamaguiText, styled } from 'tamagui'
+import { createComponent } from '../createComponent'
+import { Icon } from './Icon'
+import { Spinner } from '../components-test/Spinner'
+
+// ---------------------------------------------------------------------------
+// Dialog store — module-level queue for imperative dialog management.
+// Only one dialog is shown at a time; subsequent createDialog calls replace
+// the current state (queue management can be layered on top if needed).
+// ---------------------------------------------------------------------------
+
+export type DialogStatus = 'idle' | 'pending' | 'success' | 'error'
+
+export interface DialogConfig {
+ title?: string
+ body?: string
+ /** Optional image URL displayed above the title */
+ image?: string
+ acceptLabel?: string
+ rejectLabel?: string
+ onAccept?: () => void | Promise
+ onReject?: () => void
+ /** Whether to show the close (×) icon button in the top-right corner */
+ showClose?: boolean
+}
+
+interface DialogState extends DialogConfig {
+ isOpen: boolean
+ status: DialogStatus
+}
+
+type DialogListener = (state: DialogState) => void
+
+let _state: DialogState = { isOpen: false, status: 'idle' }
+const _listeners: Set = new Set()
+
+function _notify() {
+ _listeners.forEach((l) => l({ ..._state }))
+}
+
+/** Open the dialog with the given config. Replaces any open dialog. */
+export function createDialog(config: DialogConfig) {
+ _state = { ...config, isOpen: true, status: 'idle' }
+ _notify()
+}
+
+/** Update the status of the currently open dialog (e.g. after async accept). */
+export function updateDialogStatus(status: DialogStatus) {
+ _state = { ..._state, status }
+ _notify()
+}
+
+/** Imperatively close the dialog. */
+export function closeDialog() {
+ _state = { isOpen: false, status: 'idle' }
+ _notify()
+}
+
+/** React hook — subscribes to dialog state; re-renders on changes. */
+export function useDialog(): DialogState {
+ const [state, setState] = useState({ ..._state })
+
+ useEffect(() => {
+ // Sync immediately in case state changed between render and effect
+ setState({ ..._state })
+ _listeners.add(setState)
+ return () => {
+ _listeners.delete(setState)
+ }
+ }, [])
+
+ return state
+}
+
+// ---------------------------------------------------------------------------
+// Styled sub-parts — each is registered in the manifest via createComponent.
+// ---------------------------------------------------------------------------
+
+/**
+ * DialogOverlay — full-screen backdrop behind the modal.
+ * Named 'DialogOverlay' so host can override via light_DialogOverlay theme.
+ */
+const DialogOverlay = createComponent(TamaguiDialog.Overlay as any, {
+ name: 'DialogOverlay',
+ backgroundColor: '$backgroundOverlay',
+ animation: ['medium', { opacity: { overshootClamping: true } }] as any,
+ enterStyle: { opacity: 0 },
+ exitStyle: { opacity: 0 },
+})
+
+/**
+ * DialogFrame — the modal content container.
+ * Named 'Dialog' so Tamagui resolves light_Dialog / dark_Dialog component themes.
+ */
+const DialogFrame = createComponent(TamaguiDialog.Content as any, {
+ name: 'Dialog',
+ backgroundColor: '$background',
+ borderRadius: '$4',
+ padding: '$8',
+ width: 345,
+ maxWidth: '92%',
+ borderWidth: 1,
+ borderColor: '$borderColor',
+ shadowColor: '$shadowColor',
+ shadowRadius: 24,
+ shadowOpacity: 1,
+ shadowOffset: { width: 0, height: 8 },
+ gap: '$4',
+ alignSelf: 'center',
+ animation: ['medium', { opacity: { overshootClamping: true } }] as any,
+ enterStyle: { opacity: 0, scale: 0.97 },
+ exitStyle: { opacity: 0, scale: 0.97 },
+})
+
+const DialogTitle = styled(TamaguiText, {
+ name: 'DialogTitle',
+ fontFamily: '$body',
+ fontSize: 20,
+ fontWeight: '600',
+ color: '$color',
+ textAlign: 'center',
+})
+
+const DialogBody = styled(TamaguiText, {
+ name: 'DialogBody',
+ fontFamily: '$body',
+ fontSize: '$1',
+ fontWeight: '400',
+ color: '$placeholderColor',
+ textAlign: 'center',
+ lineHeight: '$1',
+})
+
+// ---------------------------------------------------------------------------
+// GoodWidgetDialog — reactive dialog driven by the imperative store above.
+// Mount once at the widget boundary; it renders itself
+// reactively based on createDialog / updateDialogStatus calls.
+// ---------------------------------------------------------------------------
+
+interface GoodWidgetDialogProps {
+ /** Optional custom accept button renderer */
+ renderAccept?: (onPress: () => void, label: string) => React.ReactNode
+ /** Optional custom reject button renderer */
+ renderReject?: (onPress: () => void, label: string) => React.ReactNode
+}
+
+/**
+ * GoodWidgetDialog — mounts once at the widget boundary.
+ *
+ * Open imperatively with `createDialog({ title, body, onAccept, ... })`.
+ * Track async operations by calling `updateDialogStatus('pending' | 'success' | 'error')`.
+ *
+ * Visual spec (GoodWalletV2):
+ * - Centered modal, $backgroundOverlay backdrop
+ * - $background (Dialog theme) container
+ * - borderRadius $4 (16px), width 345px, padding $8 (32px)
+ * - Optional image, title (20px/600w), body (12px/400w)
+ * - Accept + optional reject CTAs
+ * - Optional close icon button
+ * - Enter/exit opacity animation
+ */
+export function GoodWidgetDialog({ renderAccept, renderReject }: GoodWidgetDialogProps = {}) {
+ const state = useDialog()
+
+ function handleClose() {
+ if (state.onReject) state.onReject()
+ closeDialog()
+ }
+
+ async function handleAccept() {
+ if (!state.onAccept) return
+ const result = state.onAccept()
+ if (result && typeof result.then === 'function') {
+ updateDialogStatus('pending')
+ try {
+ await result
+ updateDialogStatus('success')
+ } catch {
+ updateDialogStatus('error')
+ }
+ } else {
+ closeDialog()
+ }
+ }
+
+ const isPending = state.status === 'pending'
+
+ return (
+ {
+ if (!open) handleClose()
+ }}
+ modal
+ >
+
+
+
+ {/* Close button */}
+ {state.showClose && (
+
+
+
+
+
+ )}
+
+ {/* Optional image */}
+ {state.image ? (
+
+
+
+ ) : null}
+
+ {/* Status feedback icon */}
+ {state.status === 'pending' && (
+
+
+
+ )}
+ {state.status === 'success' && (
+
+
+
+ )}
+ {state.status === 'error' && (
+
+
+
+ )}
+
+ {/* Title */}
+ {state.title ? (
+
+ {state.title}
+
+ ) : null}
+
+ {/* Body */}
+ {state.body ? (
+
+ {state.body}
+
+ ) : null}
+
+ {/* CTAs */}
+
+ {state.onAccept &&
+ (renderAccept ? (
+ renderAccept(handleAccept, state.acceptLabel ?? 'Accept')
+ ) : (
+
+
+ {state.acceptLabel ?? 'Accept'}
+
+
+ ))}
+
+ {state.onReject &&
+ (renderReject ? (
+ renderReject(handleClose, state.rejectLabel ?? 'Cancel')
+ ) : (
+
+
+ {state.rejectLabel ?? 'Cancel'}
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/packages/ui/src/components/Icon.tsx b/packages/ui/src/components/Icon.tsx
new file mode 100644
index 0000000..168d6b1
--- /dev/null
+++ b/packages/ui/src/components/Icon.tsx
@@ -0,0 +1,192 @@
+import React from 'react'
+import { Stack, useTheme } from 'tamagui'
+import { createComponent } from '../createComponent'
+
+/**
+ * IconFrame — styled container for Icon content.
+ * Named 'Icon' so a light_Icon / dark_Icon component sub-theme can target it.
+ */
+const IconFrame = createComponent(Stack, {
+ name: 'Icon',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+})
+
+// ---------------------------------------------------------------------------
+// Inline SVG path registry
+// Add new entries here as needed. Paths are drawn on a 24×24 viewBox.
+// ---------------------------------------------------------------------------
+const SVG_PATHS: Record = {
+ check: 'M20 6L9 17L4 12',
+ x: 'M18 6L6 18M6 6l12 12',
+ close: 'M18 6L6 18M6 6l12 12',
+ 'alert-circle': 'M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-.997-6.5h2v2h-2v-2zm0-8h2v6h-2v-6z',
+ 'alert-triangle': 'M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0zM12 9v4M12 17h.01',
+ info: 'M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-.997-4h2v-6h-2v6zm0-8h2V8h-2v2z',
+ // Spinning loader — 8 spokes radiating from center
+ loader: [
+ 'M12 2v4',
+ 'M12 18v4',
+ 'M4.93 4.93l2.83 2.83',
+ 'M16.24 16.24l2.83 2.83',
+ 'M2 12h4',
+ 'M18 12h4',
+ 'M4.93 19.07l2.83-2.83',
+ 'M16.24 7.76l2.83-2.83',
+ ],
+ spinner: [
+ 'M12 2v4',
+ 'M12 18v4',
+ 'M4.93 4.93l2.83 2.83',
+ 'M16.24 16.24l2.83 2.83',
+ 'M2 12h4',
+ 'M18 12h4',
+ 'M4.93 19.07l2.83-2.83',
+ 'M16.24 7.76l2.83-2.83',
+ ],
+ 'chevron-down': 'M6 9l6 6 6-6',
+ 'chevron-up': 'M18 15l-6-6-6 6',
+ 'chevron-left': 'M15 18l-6-6 6-6',
+ 'chevron-right': 'M9 18l6-6-6-6',
+ 'arrow-left': 'M19 12H5M12 19l-7-7 7-7',
+ 'arrow-right': 'M5 12h14M12 5l7 7-7 7',
+ copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
+ wallet: 'M20 7H4a2 2 0 00-2 2v10a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2zM16 14a1 1 0 110-2 1 1 0 010 2zM4 7V5a2 2 0 012-2h12a2 2 0 012 2v2',
+ 'external-link': 'M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3',
+ search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
+ settings: 'M12 15a3 3 0 100-6 3 3 0 000 6zM19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z',
+}
+
+// ---------------------------------------------------------------------------
+// Public types
+// ---------------------------------------------------------------------------
+
+export type IconName = keyof typeof SVG_PATHS
+
+/**
+ * Icon size — maps to the icon size token scale defined in the preset.
+ * 2xs=12 xs=16 sm=20 md=24 lg=32 xl=48 2xl=64
+ */
+export type IconSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
+
+/**
+ * Semantic color roles for icons — resolved via the active theme.
+ * 'inherit' falls back to CSS currentColor (inherits from parent element).
+ */
+export type IconColor = 'primary' | 'text' | 'muted' | 'error' | 'success' | 'inherit'
+
+/** Raw px values for each named size — mirrors the preset icon token scale. */
+const SIZE_PX: Record = {
+ '2xs': 12,
+ xs: 16,
+ sm: 20,
+ md: 24,
+ lg: 32,
+ xl: 48,
+ '2xl': 64,
+}
+
+/**
+ * Maps semantic color role to the Tamagui theme token that should drive it.
+ * 'inherit' uses 'currentColor' and relies on CSS cascade; other roles are
+ * resolved via the active theme.
+ */
+const COLOR_THEME_KEY: Record = {
+ primary: '$primary',
+ text: '$color',
+ muted: '$placeholderColor',
+ error: '$error',
+ success: '$success',
+ inherit: 'currentColor',
+}
+
+export interface IconProps {
+ /** Icon name from the built-in SVG registry */
+ name: IconName
+ /** Size key from the preset icon token scale (default: md = 24px) */
+ size?: IconSize
+ /** Semantic color role; resolved against the active theme (default: text) */
+ color?: IconColor
+ /** Spin the icon continuously — useful for loader/spinner icons */
+ spin?: boolean
+ /** Render a round background behind the icon (uses borderRadius $full) */
+ round?: boolean
+ [key: string]: unknown
+}
+
+/**
+ * Icon — renders a named SVG icon from the built-in registry.
+ *
+ * Size maps to the preset `icon*` token scale so spacing stays consistent
+ * with the rest of the design system. Color resolves through the active
+ * Tamagui theme so icons adapt automatically to theme and preset changes.
+ *
+ * Spin: uses a CSS keyframe animation injected once on first render (web only).
+ * Round: adds a $full borderRadius and light background pad for icon-in-badge use.
+ */
+export function Icon({
+ name,
+ size = 'md',
+ color = 'text',
+ spin = false,
+ round = false,
+ ...rest
+}: IconProps) {
+ const theme = useTheme()
+ const px = SIZE_PX[size]
+ const paths = SVG_PATHS[name]
+
+ // Resolve the stroke color: prefer theme value, fall back to currentColor
+ let strokeColor = 'currentColor'
+ if (color === 'inherit') {
+ strokeColor = 'currentColor'
+ } else {
+ const themeKey = COLOR_THEME_KEY[color].replace('$', '')
+ const themeVal = theme[themeKey as keyof typeof theme]
+ if (themeVal && typeof themeVal === 'object' && 'val' in themeVal) {
+ strokeColor = String(themeVal.val)
+ }
+ }
+
+ // Inject the spin keyframe once into the document head (web only)
+ if (spin && typeof document !== 'undefined') {
+ const styleId = 'gw-icon-spin'
+ if (!document.getElementById(styleId)) {
+ const style = document.createElement('style')
+ style.id = styleId
+ style.textContent = '@keyframes gw-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }'
+ document.head.appendChild(style)
+ }
+ }
+
+ const pathArray = Array.isArray(paths) ? paths : paths ? [paths] : []
+
+ return (
+
+
+
+ )
+}
diff --git a/packages/ui/src/components/Separator.ts b/packages/ui/src/components/Separator.ts
new file mode 100644
index 0000000..c048ce6
--- /dev/null
+++ b/packages/ui/src/components/Separator.ts
@@ -0,0 +1,49 @@
+import { Separator as TamaguiSeparator } from 'tamagui'
+import { createComponent } from '../createComponent'
+
+/**
+ * Separator — thin divider line.
+ *
+ * Uses Tamagui's native Separator primitive for semantic correctness.
+ * All visual values come from the active theme and token scales.
+ *
+ * Variants:
+ * size → sm (1px) | md (2px) | lg (4px)
+ * color → default ($borderColor) | muted ($borderColor, 0.4 opacity) | primary ($primary token)
+ * vertical → true (switches from horizontal to vertical orientation)
+ */
+export const Separator = createComponent(TamaguiSeparator as any, {
+ name: 'Separator',
+ // Default: horizontal 1px rule, full-width
+ width: '100%',
+ backgroundColor: '$borderColor',
+ borderWidth: 0,
+ borderColor: 'transparent',
+
+ variants: {
+ size: {
+ sm: { height: 1 },
+ md: { height: 2 },
+ lg: { height: 4 },
+ },
+ color: {
+ // Uses the current theme borderColor — adapts to light/dark and presets
+ default: { backgroundColor: '$borderColor' },
+ // Reduced-opacity divider — softer visual separation
+ muted: { backgroundColor: '$borderColor', opacity: 0.4 },
+ // Brand-primary divider — uses $primary color token
+ primary: { backgroundColor: '$primary' },
+ },
+ vertical: {
+ true: {
+ height: '100%',
+ width: 1,
+ },
+ },
+ } as const,
+
+ defaultVariants: {
+ size: 'sm',
+ color: 'default',
+ },
+})
diff --git a/packages/ui/src/components/Text.ts b/packages/ui/src/components/Text.ts
new file mode 100644
index 0000000..6229601
--- /dev/null
+++ b/packages/ui/src/components/Text.ts
@@ -0,0 +1,66 @@
+import { Text as TamaguiText } from 'tamagui'
+import { createComponent } from '../createComponent'
+
+/**
+ * GoodWidget Text primitive.
+ *
+ * Color variants use semantic theme keys so they respond correctly
+ * to the active preset and light/dark context:
+ * - default → $color (base text, inherits from active theme)
+ * - secondary → $placeholderColor (muted / secondary text)
+ * - soft → $colorSoft (mid-level soft; between default and secondary)
+ * - dim → $colorDim (tertiary / helper text)
+ *
+ * Layout helpers:
+ * - truncate → single-line ellipsis
+ * - noWrap → prevent text from wrapping
+ */
+export const Text = createComponent(TamaguiText, {
+ name: 'GWText',
+ fontFamily: '$body',
+ color: '$color',
+ fontSize: '$3',
+ lineHeight: '$3',
+
+ variants: {
+ variant: {
+ body: { fontSize: '$3', lineHeight: '$3' },
+ caption: { fontSize: '$1', lineHeight: '$1', color: '$placeholderColor' },
+ label: { fontSize: '$2', lineHeight: '$2', fontWeight: '500' },
+ large: { fontSize: '$5', lineHeight: '$5' },
+ },
+ // Named color levels, all resolved against the active theme
+ color: {
+ default: { color: '$color' },
+ secondary: { color: '$placeholderColor' },
+ soft: { color: '$colorSoft' },
+ dim: { color: '$colorDim' },
+ },
+ // Kept for backward compatibility
+ secondary: {
+ true: { color: '$placeholderColor' },
+ },
+ bold: {
+ true: { fontWeight: '700' },
+ },
+ center: {
+ true: { textAlign: 'center' },
+ },
+ // Single-line ellipsis — clips overflow text with "…"
+ truncate: {
+ true: {
+ numberOfLines: 1,
+ overflow: 'hidden' as const,
+ textOverflow: 'ellipsis' as const,
+ whiteSpace: 'nowrap' as const,
+ },
+ },
+ // Prevent line-wrapping entirely
+ noWrap: {
+ true: {
+ whiteSpace: 'nowrap' as const,
+ flexShrink: 0,
+ },
+ },
+ } as const,
+})
diff --git a/packages/ui/src/components/Toast.tsx b/packages/ui/src/components/Toast.tsx
new file mode 100644
index 0000000..a875682
--- /dev/null
+++ b/packages/ui/src/components/Toast.tsx
@@ -0,0 +1,330 @@
+import React, { useEffect, useRef, useState } from 'react'
+import { Stack, Text as TamaguiText } from 'tamagui'
+import { createComponent } from '../createComponent'
+import { Icon } from './Icon'
+import { Spinner } from '../components-test/Spinner'
+
+// ---------------------------------------------------------------------------
+// Toast store — module-level queue for imperative toast management.
+// Multiple toasts can be visible at once; each is identified by a unique id.
+// ---------------------------------------------------------------------------
+
+export type ToastStatus = 'pending' | 'success' | 'error' | 'info'
+
+export interface ToastConfig {
+ message: string
+ /** Semantic status — drives the status icon and optional border accent */
+ status?: ToastStatus
+ /**
+ * Auto-close duration in ms.
+ * Set to 0 for persistent toasts (requires manual removeToast call).
+ */
+ duration?: number
+}
+
+export interface ToastItem extends ToastConfig {
+ id: string
+}
+
+type ToastListener = (toasts: ToastItem[]) => void
+
+let _toasts: ToastItem[] = []
+const _listeners: Set = new Set()
+
+function _notify() {
+ const snapshot = [..._toasts]
+ _listeners.forEach((l) => l(snapshot))
+}
+
+/** Add a new toast to the queue. Returns the toast id for later updates. */
+export function createToast(config: ToastConfig): string {
+ const id = Math.random().toString(36).slice(2, 9)
+ _toasts = [..._toasts, { duration: 4000, ...config, id }]
+ _notify()
+ return id
+}
+
+/** Update an existing toast by id (e.g. change status after async operation). */
+export function updateToast(id: string, update: Partial>) {
+ _toasts = _toasts.map((t) => (t.id === id ? { ...t, ...update } : t))
+ _notify()
+}
+
+/** Remove a toast from the queue by id. */
+export function removeToast(id: string) {
+ _toasts = _toasts.filter((t) => t.id !== id)
+ _notify()
+}
+
+/** React hook — subscribes to the toast queue; re-renders on changes. */
+export function useToast(): ToastItem[] {
+ const [toasts, setToasts] = useState([..._toasts])
+
+ useEffect(() => {
+ // Sync immediately in case queue changed between render and effect
+ setToasts([..._toasts])
+ _listeners.add(setToasts)
+ return () => {
+ _listeners.delete(setToasts)
+ }
+ }, [])
+
+ return toasts
+}
+
+// ---------------------------------------------------------------------------
+// Styled sub-parts
+// ---------------------------------------------------------------------------
+
+/**
+ * ToastFrame — the visible notification surface.
+ * Named 'Toast' so Tamagui resolves light_Toast / dark_Toast component themes.
+ *
+ * Status variant adjusts the border accent color to communicate the toast type:
+ * pending → primary (blue)
+ * success → success (green)
+ * error → error (red)
+ * info → primary (blue)
+ */
+const ToastFrame = createComponent(Stack, {
+ name: 'Toast',
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: '$4',
+ paddingVertical: '$3',
+ borderRadius: '$3',
+ backgroundColor: '$background',
+ borderWidth: 1,
+ borderColor: '$borderColor',
+ shadowColor: '$shadowColor',
+ shadowRadius: 8,
+ shadowOpacity: 1,
+ shadowOffset: { width: 0, height: 4 },
+ gap: '$2',
+ overflow: 'hidden',
+
+ variants: {
+ status: {
+ pending: { borderColor: '$primary' },
+ success: { borderColor: '$success' },
+ error: { borderColor: '$error' },
+ info: { borderColor: '$primary' },
+ },
+ } as const,
+})
+
+// ---------------------------------------------------------------------------
+// ProgressBar — animated bottom bar for auto-close toasts
+// ---------------------------------------------------------------------------
+
+interface ProgressBarProps {
+ duration: number
+}
+
+/**
+ * ProgressBar — thin bottom bar that shrinks from 100% to 0% over `duration` ms.
+ * Uses CSS animation via a style tag injected once into the document head.
+ */
+function ProgressBar({ duration }: ProgressBarProps) {
+ // Inject the shrink keyframe once
+ if (typeof document !== 'undefined') {
+ const styleId = 'gw-toast-progress'
+ if (!document.getElementById(styleId)) {
+ const style = document.createElement('style')
+ style.id = styleId
+ style.textContent = [
+ '@keyframes gw-toast-progress {',
+ ' from { width: 100%; }',
+ ' to { width: 0%; }',
+ '}',
+ ].join(' ')
+ document.head.appendChild(style)
+ }
+ }
+
+ return (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Status icon helper
+// ---------------------------------------------------------------------------
+
+function StatusIcon({ status }: { status?: ToastStatus }) {
+ if (!status) return null
+ switch (status) {
+ case 'pending':
+ return
+ case 'success':
+ return
+ case 'error':
+ return
+ case 'info':
+ return
+ default:
+ return null
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Single Toast item component
+// ---------------------------------------------------------------------------
+
+interface ToastItemProps extends ToastItem {
+ onDismiss: (id: string) => void
+}
+
+function ToastItemComponent({ id, message, status, duration = 4000, onDismiss }: ToastItemProps) {
+ const [visible, setVisible] = useState(true)
+ const timerRef = useRef | null>(null)
+
+ // Auto-dismiss after duration (0 = persistent)
+ useEffect(() => {
+ if (duration <= 0) return
+ timerRef.current = setTimeout(() => {
+ setVisible(false)
+ // Allow exit animation before removing from queue
+ setTimeout(() => onDismiss(id), 200)
+ }, duration)
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }
+ }, [id, duration, onDismiss])
+
+ if (!visible) return null
+
+ return (
+
+
+
+
+ {message}
+
+
+ {
+ setVisible(false)
+ setTimeout(() => onDismiss(id), 200)
+ }}
+ >
+
+ ✕
+
+
+
+ {/* Progress bar for auto-close toasts */}
+ {duration > 0 && }
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// ToastContainer — fixed wrapper at the widget boundary
+// ---------------------------------------------------------------------------
+
+/**
+ * ToastContainer — renders the active toast queue.
+ *
+ * Mount once at the widget boundary (e.g. inside GoodWidgetProvider).
+ * Position: fixed at the bottom of the widget viewport, centered, max-width 768px.
+ * z-index: 1000 (above all widget content).
+ *
+ * Toasts are added imperatively via `createToast(...)` and auto-dismissed
+ * after their `duration` (default 4s). Use `removeToast(id)` for manual removal.
+ */
+export function ToastContainer() {
+ const toasts = useToast()
+
+ if (toasts.length === 0) return null
+
+ return (
+
+ {toasts.map((toast) => (
+
+ ))}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Legacy single-toast component (backward-compatible)
+// ---------------------------------------------------------------------------
+
+interface ToastProps {
+ message: string
+ status?: ToastStatus
+ duration?: number
+ onDismiss?: () => void
+ visible?: boolean
+}
+
+/**
+ * Toast — single stateful toast (legacy / controlled usage).
+ *
+ * For new code, prefer the imperative `createToast` + `ToastContainer` pattern
+ * which handles queuing automatically.
+ */
+export function Toast({ message, status, duration = 3000, onDismiss, visible = true }: ToastProps) {
+ const [show, setShow] = useState(visible)
+
+ useEffect(() => {
+ setShow(visible)
+ }, [visible])
+
+ useEffect(() => {
+ if (!show || duration <= 0) return
+ const timer = setTimeout(() => {
+ setShow(false)
+ onDismiss?.()
+ }, duration)
+ return () => clearTimeout(timer)
+ }, [show, duration, onDismiss])
+
+ if (!show) return null
+
+ return (
+
+
+
+ {message}
+
+ {
+ setShow(false)
+ onDismiss?.()
+ }}
+ >
+
+ ✕
+
+
+ {duration > 0 && }
+
+ )
+}
diff --git a/packages/ui/src/createComponent.ts b/packages/ui/src/createComponent.ts
index e5f1382..d9d2a99 100644
--- a/packages/ui/src/createComponent.ts
+++ b/packages/ui/src/createComponent.ts
@@ -11,6 +11,8 @@ const THEME_KEY_NAMES = [
'colorHover',
'colorPress',
'colorFocus',
+ 'colorSoft',
+ 'colorDim',
'borderColor',
'borderColorHover',
'borderColorPress',
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index e08f355..3938591 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -37,16 +37,19 @@ export { Container } from './components-test/Container'
export { Card } from './components/Card'
export { GlowCard } from './components/GlowCard'
export { XStack, YStack, ZStack } from './components-test/Stacks'
-export { Separator } from './components-test/Separator'
+// Separator — now in components/ with size and color variants
+export { Separator } from './components/Separator'
export { ScrollArea } from './components-test/ScrollArea'
// Typography
export { Heading } from './components-test/Heading'
-export { Text } from './components-test/Text'
+// Text — now in components/ with truncate, noWrap, colorSoft, colorDim variants
+export { Text } from './components/Text'
// Inputs
-export { Button, ButtonFrame, ButtonText } from './components-test/Button'
-export type { ButtonProps } from './components-test/Button'
+// Button — now in components/ with pill, icon, text, list variants
+export { Button, ButtonFrame, ButtonText, PillText } from './components/Button'
+export type { ButtonProps } from './components/Button'
export { Input, InputFrame, InputLabel, InputError } from './components-test/Input'
export type { InputProps } from './components-test/Input'
export { Select } from './components-test/Select'
@@ -56,11 +59,34 @@ export { Switch } from './components-test/Switch'
// Feedback
export { Spinner } from './components-test/Spinner'
-export { Toast } from './components-test/Toast'
+// Toast — now in components/ with ToastContainer, toastStore, status variant
+export {
+ Toast,
+ ToastContainer,
+ createToast,
+ updateToast,
+ removeToast,
+ useToast,
+} from './components/Toast'
+export type { ToastStatus, ToastConfig, ToastItem } from './components/Toast'
export { Alert } from './components-test/Alert'
export { Badge, BadgeText } from './components-test/Badge'
export { Drawer } from './components/Drawer'
+// Icon — new component with inline SVG registry and semantic color/size props
+export { Icon } from './components/Icon'
+export type { IconName, IconSize, IconColor, IconProps } from './components/Icon'
+
+// Dialog — new component backed by imperative store
+export {
+ GoodWidgetDialog,
+ createDialog,
+ updateDialogStatus,
+ closeDialog,
+ useDialog,
+} from './components/Dialog'
+export type { DialogConfig, DialogStatus } from './components/Dialog'
+
// Web3
export { AddressDisplay } from './components-test/AddressDisplay'
export { TokenAmount } from './components/TokenAmount'
diff --git a/packages/ui/src/presets.ts b/packages/ui/src/presets.ts
index b8566fc..bed2d61 100644
--- a/packages/ui/src/presets.ts
+++ b/packages/ui/src/presets.ts
@@ -155,6 +155,10 @@ export const goodWalletV2Preset: WidgetDesignPreset = {
colorPress: color.white,
colorFocus: color.white,
colorTransparent: color.transparent,
+ // Soft text — between primary text and muted (#CCC / grey350)
+ colorSoft: color.grey350,
+ // Dim text — below secondary; tertiary labels (#4B5563 / grey600)
+ colorDim: color.grey600,
borderColor: color.border,
borderColorHover: color.borderLight,
@@ -182,6 +186,10 @@ export const goodWalletV2Preset: WidgetDesignPreset = {
colorPress: color.textDark,
colorFocus: color.textDark,
colorTransparent: color.transparent,
+ // Soft text — same as light for dark-only GW preset
+ colorSoft: color.grey350,
+ // Dim text — same as light for dark-only GW preset
+ colorDim: color.grey600,
borderColor: color.borderDark,
borderColorHover: color.borderLight,
@@ -306,6 +314,34 @@ export const goodWalletV2Preset: WidgetDesignPreset = {
color: color.white,
secondaryColor: color.grey350,
},
+
+ // Toast — elevated notification surface inheriting from the surface token
+ light_Toast: {
+ background: color.surface,
+ color: color.white,
+ borderColor: color.border,
+ shadowColor: 'rgba(5, 10, 24, 0.45)',
+ },
+ dark_Toast: {
+ background: color.surface,
+ color: color.white,
+ borderColor: color.border,
+ shadowColor: 'rgba(3, 7, 18, 0.6)',
+ },
+
+ // Dialog — modal container surface
+ light_Dialog: {
+ background: color.backgroundRaised,
+ color: color.white,
+ borderColor: color.border,
+ shadowColor: 'rgba(5, 10, 24, 0.7)',
+ },
+ dark_Dialog: {
+ background: color.backgroundRaised,
+ color: color.white,
+ borderColor: color.border,
+ shadowColor: 'rgba(3, 7, 18, 0.8)',
+ },
},
typography: {
body: {
diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts
index 5b1452f..2d67046 100644
--- a/packages/ui/src/theme.ts
+++ b/packages/ui/src/theme.ts
@@ -31,6 +31,12 @@ export const defaultTokenValues = {
textDark: '#E0E0E0',
textSecondary: '#71727A',
textSecondaryDark: '#A0A0A0',
+ // Mid-level soft text — between primary text and muted/secondary text
+ textSoft: '#AAAAAA',
+ textSoftDark: '#AAAAAA',
+ // Dim text — below secondary, used for tertiary labels
+ textDim: '#666666',
+ textDimDark: '#666666',
border: '#E0E0E0',
borderDark: '#333333',
overlay: 'rgba(0,0,0,0.5)',
@@ -53,6 +59,14 @@ export const defaultTokenValues = {
13: 64,
14: 80,
maxContentWidth: 768,
+ // Icon sizes — mirrors the preset icon token scale
+ icon2xs: 12,
+ iconXs: 16,
+ iconSm: 20,
+ iconMd: 24,
+ iconLg: 32,
+ iconXl: 48,
+ icon2xl: 64,
true: 40,
},
space: {
@@ -77,6 +91,8 @@ export const defaultTokenValues = {
4: 16,
5: 20,
6: 24,
+ // Full pill radius — used by Button default and round variants
+ full: 9999,
true: 8,
},
zIndex: {
@@ -119,6 +135,10 @@ export function createThemeValues(
colorPress: color.textSecondary,
colorFocus: color.text,
colorTransparent: color.transparent,
+ // Soft text — between primary text and muted; for secondary labels
+ colorSoft: color.textSoft,
+ // Dim text — below secondary; for tertiary/helper labels
+ colorDim: color.textDim,
borderColor: color.border,
borderColorHover: color.primary,
@@ -147,6 +167,10 @@ export function createThemeValues(
colorPress: color.textSecondaryDark,
colorFocus: color.textDark,
colorTransparent: color.transparent,
+ // Soft text — between primary text and muted; for secondary labels
+ colorSoft: color.textSoftDark ?? color.textSoft,
+ // Dim text — below secondary; for tertiary/helper labels
+ colorDim: color.textDimDark ?? color.textDim,
borderColor: color.borderDark,
borderColorHover: color.primary,
@@ -305,5 +329,33 @@ export function createThemeValues(
shadowColorPress: 'rgba(0,0,0,0.15)',
shadowColorFocus: 'rgba(0,174,255,0.12)',
},
+
+ // Toast component theme — inherits from Card but scoped to notification context
+ light_Toast: {
+ background: color.surface ?? color.white,
+ color: color.text,
+ borderColor: color.border,
+ shadowColor: 'rgba(0,0,0,0.12)',
+ },
+ dark_Toast: {
+ background: color.surfaceDark,
+ color: color.textDark,
+ borderColor: color.borderDark,
+ shadowColor: 'rgba(0,0,0,0.32)',
+ },
+
+ // Dialog component theme — modal overlay container
+ light_Dialog: {
+ background: color.surface ?? color.white,
+ color: color.text,
+ borderColor: color.border,
+ shadowColor: 'rgba(0,0,0,0.16)',
+ },
+ dark_Dialog: {
+ background: color.surfaceDark,
+ color: color.textDark,
+ borderColor: color.borderDark,
+ shadowColor: 'rgba(0,0,0,0.4)',
+ },
}
}
From a1148c62f6f20e32a04f51e17020bdd96e26150b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 23 Apr 2026 10:36:25 +0000
Subject: [PATCH 3/5] fix: address code review issues
- Use distinct textSoft/textDim colors for light vs dark modes in theme.ts
- Update presets.ts comment to explain dark-only GW preset contrast rationale
- Fix Toast ID generation: use monotonic counter instead of Math.random()
- Fix keyframe injection: use module-level flags instead of repeated DOM lookups
- Fix Promise detection in Dialog: use instanceof instead of duck-typing
- Clarify Button variant radius inheritance with explicit comments and borderRadius
Agent-Logs-Url: https://github.com/GoodDollar/GoodWidget/sessions/5517c699-b8e4-49fc-b0e8-d993b89f2d7f
Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com>
---
packages/ui/src/components/Button.tsx | 18 +++++++-------
packages/ui/src/components/Dialog.tsx | 2 +-
packages/ui/src/components/Icon.tsx | 25 +++++++++++---------
packages/ui/src/components/Toast.tsx | 34 +++++++++++++--------------
packages/ui/src/presets.ts | 5 ++--
packages/ui/src/theme.ts | 12 ++++++----
6 files changed, 53 insertions(+), 43 deletions(-)
diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx
index ec8fd7e..7e8aa3f 100644
--- a/packages/ui/src/components/Button.tsx
+++ b/packages/ui/src/components/Button.tsx
@@ -54,25 +54,26 @@ export const ButtonFrame = createComponent(Stack, {
variants: {
variant: {
- // Solid filled — driven by light_Button / dark_Button component theme
+ // Solid filled — driven by light_Button / dark_Button component theme.
+ // Inherits the $full pill radius from ButtonFrame default.
primary: {},
- // Transparent with border — resets theme so border uses parent text color
+ // Transparent with border — inherits $full pill radius for brand consistency.
secondary: {
backgroundColor: '$backgroundTransparent',
borderWidth: 1,
borderColor: '$borderColor',
},
- // Transparent with colored border
+ // Transparent with colored border — inherits $full pill radius.
outline: {
backgroundColor: '$backgroundTransparent',
borderWidth: 1,
borderColor: '$color',
},
- // Transparent, no border — hover changes bg only
+ // Transparent, no border — inherits $full pill radius; hover changes bg only.
ghost: {
backgroundColor: '$backgroundTransparent',
},
- // Badge/chip style — pill shape, muted bg, colored text, uppercase 11px
+ // Badge/chip style — explicit $full radius, muted bg, colored text
pill: {
height: 35,
borderRadius: '$full',
@@ -81,24 +82,25 @@ export const ButtonFrame = createComponent(Stack, {
borderWidth: 0,
gap: '$1',
},
- // Full-width text link — no bg change on hover, opacity instead
+ // Full-width text link — no bg change on hover; no visible radius needed.
text: {
backgroundColor: '$backgroundTransparent',
borderWidth: 0,
+ borderRadius: 0,
paddingHorizontal: 0,
height: 'auto',
hoverStyle: { opacity: 0.7, backgroundColor: '$backgroundTransparent' },
pressStyle: { opacity: 0.5, backgroundColor: '$backgroundTransparent' },
},
- // Icon + label row — full width, left-aligned, 42px min height
+ // Icon + label row — smaller $2 radius for a rectangular row appearance.
list: {
backgroundColor: '$backgroundTransparent',
borderWidth: 0,
+ borderRadius: '$2',
justifyContent: 'flex-start',
width: '100%',
minHeight: 42,
paddingHorizontal: '$3',
- borderRadius: '$2',
gap: '$3',
hoverStyle: { backgroundColor: '$backgroundHover' },
pressStyle: { backgroundColor: '$backgroundPress', opacity: 1 },
diff --git a/packages/ui/src/components/Dialog.tsx b/packages/ui/src/components/Dialog.tsx
index aa2b067..34c5724 100644
--- a/packages/ui/src/components/Dialog.tsx
+++ b/packages/ui/src/components/Dialog.tsx
@@ -171,7 +171,7 @@ export function GoodWidgetDialog({ renderAccept, renderReject }: GoodWidgetDialo
async function handleAccept() {
if (!state.onAccept) return
const result = state.onAccept()
- if (result && typeof result.then === 'function') {
+ if (result instanceof Promise) {
updateDialogStatus('pending')
try {
await result
diff --git a/packages/ui/src/components/Icon.tsx b/packages/ui/src/components/Icon.tsx
index 168d6b1..42da4a4 100644
--- a/packages/ui/src/components/Icon.tsx
+++ b/packages/ui/src/components/Icon.tsx
@@ -101,6 +101,17 @@ const COLOR_THEME_KEY: Record = {
inherit: 'currentColor',
}
+// Inject the spin keyframe once at module load (web only)
+let _spinStyleInjected = false
+function _ensureSpinStyle() {
+ if (_spinStyleInjected || typeof document === 'undefined') return
+ _spinStyleInjected = true
+ const style = document.createElement('style')
+ style.id = 'gw-icon-spin'
+ style.textContent = '@keyframes gw-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }'
+ document.head.appendChild(style)
+}
+
export interface IconProps {
/** Icon name from the built-in SVG registry */
name: IconName
@@ -122,7 +133,7 @@ export interface IconProps {
* with the rest of the design system. Color resolves through the active
* Tamagui theme so icons adapt automatically to theme and preset changes.
*
- * Spin: uses a CSS keyframe animation injected once on first render (web only).
+ * Spin: uses a CSS keyframe animation injected once at module load time (web only).
* Round: adds a $full borderRadius and light background pad for icon-in-badge use.
*/
export function Icon({
@@ -149,16 +160,8 @@ export function Icon({
}
}
- // Inject the spin keyframe once into the document head (web only)
- if (spin && typeof document !== 'undefined') {
- const styleId = 'gw-icon-spin'
- if (!document.getElementById(styleId)) {
- const style = document.createElement('style')
- style.id = styleId
- style.textContent = '@keyframes gw-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }'
- document.head.appendChild(style)
- }
- }
+ // Inject the spin keyframe once (module-level flag avoids repeated DOM lookups)
+ if (spin) _ensureSpinStyle()
const pathArray = Array.isArray(paths) ? paths : paths ? [paths] : []
diff --git a/packages/ui/src/components/Toast.tsx b/packages/ui/src/components/Toast.tsx
index a875682..ac7b3eb 100644
--- a/packages/ui/src/components/Toast.tsx
+++ b/packages/ui/src/components/Toast.tsx
@@ -30,6 +30,8 @@ type ToastListener = (toasts: ToastItem[]) => void
let _toasts: ToastItem[] = []
const _listeners: Set = new Set()
+// Monotonically increasing counter — avoids Math.random() collisions
+let _toastCounter = 0
function _notify() {
const snapshot = [..._toasts]
@@ -38,7 +40,7 @@ function _notify() {
/** Add a new toast to the queue. Returns the toast id for later updates. */
export function createToast(config: ToastConfig): string {
- const id = Math.random().toString(36).slice(2, 9)
+ const id = String(++_toastCounter)
_toasts = [..._toasts, { duration: 4000, ...config, id }]
_notify()
return id
@@ -117,30 +119,26 @@ const ToastFrame = createComponent(Stack, {
// ProgressBar — animated bottom bar for auto-close toasts
// ---------------------------------------------------------------------------
+// Inject the progress keyframe once at module load (web only)
+let _progressStyleInjected = false
+function _ensureProgressStyle() {
+ if (_progressStyleInjected || typeof document === 'undefined') return
+ _progressStyleInjected = true
+ const style = document.createElement('style')
+ style.id = 'gw-toast-progress'
+ style.textContent = '@keyframes gw-toast-progress { from { width: 100%; } to { width: 0%; } }'
+ document.head.appendChild(style)
+}
+
interface ProgressBarProps {
duration: number
}
/**
* ProgressBar — thin bottom bar that shrinks from 100% to 0% over `duration` ms.
- * Uses CSS animation via a style tag injected once into the document head.
*/
function ProgressBar({ duration }: ProgressBarProps) {
- // Inject the shrink keyframe once
- if (typeof document !== 'undefined') {
- const styleId = 'gw-toast-progress'
- if (!document.getElementById(styleId)) {
- const style = document.createElement('style')
- style.id = styleId
- style.textContent = [
- '@keyframes gw-toast-progress {',
- ' from { width: 100%; }',
- ' to { width: 0%; }',
- '}',
- ].join(' ')
- document.head.appendChild(style)
- }
- }
+ _ensureProgressStyle()
return (
)
}
+
+
diff --git a/packages/ui/src/presets.ts b/packages/ui/src/presets.ts
index bed2d61..ab7f888 100644
--- a/packages/ui/src/presets.ts
+++ b/packages/ui/src/presets.ts
@@ -186,9 +186,10 @@ export const goodWalletV2Preset: WidgetDesignPreset = {
colorPress: color.textDark,
colorFocus: color.textDark,
colorTransparent: color.transparent,
- // Soft text — same as light for dark-only GW preset
+ // GoodWalletV2 is dark-only; soft/dim text must remain readable against
+ // the dark background (#13151C). grey350 (#CCC) and grey600 (#4B5563)
+ // provide the correct contrast levels for soft and dim content respectively.
colorSoft: color.grey350,
- // Dim text — same as light for dark-only GW preset
colorDim: color.grey600,
borderColor: color.borderDark,
diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts
index 2d67046..f6fa9bf 100644
--- a/packages/ui/src/theme.ts
+++ b/packages/ui/src/theme.ts
@@ -32,11 +32,15 @@ export const defaultTokenValues = {
textSecondary: '#71727A',
textSecondaryDark: '#A0A0A0',
// Mid-level soft text — between primary text and muted/secondary text
- textSoft: '#AAAAAA',
- textSoftDark: '#AAAAAA',
+ // Light mode: medium gray readable on white backgrounds
+ textSoft: '#888888',
+ // Dark mode: lighter gray for readability on dark backgrounds
+ textSoftDark: '#BBBBBB',
// Dim text — below secondary, used for tertiary labels
- textDim: '#666666',
- textDimDark: '#666666',
+ // Light mode: readable but subdued on white backgrounds
+ textDim: '#555555',
+ // Dark mode: mid-level gray on dark backgrounds
+ textDimDark: '#888888',
border: '#E0E0E0',
borderDark: '#333333',
overlay: 'rgba(0,0,0,0.5)',
From 10eb4ec503548f00099ff43e7979465166d8ede3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 23 Apr 2026 10:39:25 +0000
Subject: [PATCH 4/5] fix: second round of code review improvements
- Rename Text component from GWText to Text for naming consistency
- Expand Button variant comment to explain Theme reset rationale
- Extract resolveIconStrokeColor helper function for testability
- Fix cross-realm Promise detection with Promise.resolve() identity check
- Add imageAlt to DialogConfig for image accessibility
- Update Dialog image rendering to use imageAlt
Agent-Logs-Url: https://github.com/GoodDollar/GoodWidget/sessions/5517c699-b8e4-49fc-b0e8-d993b89f2d7f
Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com>
---
packages/ui/src/components/Button.tsx | 14 ++++++-------
packages/ui/src/components/Dialog.tsx | 10 ++++++---
packages/ui/src/components/Icon.tsx | 30 +++++++++++++++++----------
packages/ui/src/components/Text.ts | 2 +-
4 files changed, 34 insertions(+), 22 deletions(-)
diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx
index 7e8aa3f..09fafe4 100644
--- a/packages/ui/src/components/Button.tsx
+++ b/packages/ui/src/components/Button.tsx
@@ -201,13 +201,13 @@ export interface ButtonProps {
}
/**
- * The light_Button theme sets color to white for primary buttons on a colored
- * background. For secondary/outline/ghost/text variants the background is
- * transparent, so we reset the theme so children (ButtonText) pick up the
- * parent theme's text color instead.
- *
- * Loading state: GoodWidget uses Spinner rather than V2's shimmer overlay.
- * Pass a Spinner as children when showing a loading state.
+ * The light_Button / dark_Button component theme sets `color: white` for
+ * primary buttons rendered on the brand-colored background. Non-primary
+ * variants (secondary, outline, ghost, text, list) use transparent or no
+ * background, so the inherited white would be invisible. `Theme reset`
+ * discards the Button component theme, letting children (ButtonText) pick
+ * up the parent theme's text color — which is the correct readable color
+ * for transparent-background buttons.
*/
export function Button({ variant = 'primary', children, ...props }: ButtonProps) {
const needsReset = variant !== 'primary'
diff --git a/packages/ui/src/components/Dialog.tsx b/packages/ui/src/components/Dialog.tsx
index 34c5724..2345bd2 100644
--- a/packages/ui/src/components/Dialog.tsx
+++ b/packages/ui/src/components/Dialog.tsx
@@ -17,6 +17,8 @@ export interface DialogConfig {
body?: string
/** Optional image URL displayed above the title */
image?: string
+ /** Accessible alt text for the image — required when image is provided */
+ imageAlt?: string
acceptLabel?: string
rejectLabel?: string
onAccept?: () => void | Promise
@@ -171,7 +173,9 @@ export function GoodWidgetDialog({ renderAccept, renderReject }: GoodWidgetDialo
async function handleAccept() {
if (!state.onAccept) return
const result = state.onAccept()
- if (result instanceof Promise) {
+ // Use Promise.resolve() identity check to reliably detect thenables
+ // across realms (iframes, different execution contexts)
+ if (result !== undefined && result !== null && Promise.resolve(result) === result) {
updateDialogStatus('pending')
try {
await result
@@ -212,12 +216,12 @@ export function GoodWidgetDialog({ renderAccept, renderReject }: GoodWidgetDialo
)}
- {/* Optional image */}
+ {/* Optional image — provide imageAlt in DialogConfig for accessibility */}
{state.image ? (
diff --git a/packages/ui/src/components/Icon.tsx b/packages/ui/src/components/Icon.tsx
index 42da4a4..fd63649 100644
--- a/packages/ui/src/components/Icon.tsx
+++ b/packages/ui/src/components/Icon.tsx
@@ -101,6 +101,24 @@ const COLOR_THEME_KEY: Record = {
inherit: 'currentColor',
}
+/**
+ * Resolves a semantic icon color role to a concrete CSS color string by
+ * looking up the corresponding key in the active Tamagui theme.
+ * Falls back to 'currentColor' if the theme value cannot be determined.
+ */
+function resolveIconStrokeColor(
+ theme: ReturnType,
+ color: IconColor,
+): string {
+ if (color === 'inherit') return 'currentColor'
+ const themeKey = COLOR_THEME_KEY[color].replace('$', '')
+ const themeVal = theme[themeKey as keyof typeof theme]
+ if (themeVal && typeof themeVal === 'object' && 'val' in themeVal) {
+ return String(themeVal.val)
+ }
+ return 'currentColor'
+}
+
// Inject the spin keyframe once at module load (web only)
let _spinStyleInjected = false
function _ensureSpinStyle() {
@@ -148,17 +166,7 @@ export function Icon({
const px = SIZE_PX[size]
const paths = SVG_PATHS[name]
- // Resolve the stroke color: prefer theme value, fall back to currentColor
- let strokeColor = 'currentColor'
- if (color === 'inherit') {
- strokeColor = 'currentColor'
- } else {
- const themeKey = COLOR_THEME_KEY[color].replace('$', '')
- const themeVal = theme[themeKey as keyof typeof theme]
- if (themeVal && typeof themeVal === 'object' && 'val' in themeVal) {
- strokeColor = String(themeVal.val)
- }
- }
+ const strokeColor = resolveIconStrokeColor(theme, color)
// Inject the spin keyframe once (module-level flag avoids repeated DOM lookups)
if (spin) _ensureSpinStyle()
diff --git a/packages/ui/src/components/Text.ts b/packages/ui/src/components/Text.ts
index 6229601..fcd507a 100644
--- a/packages/ui/src/components/Text.ts
+++ b/packages/ui/src/components/Text.ts
@@ -16,7 +16,7 @@ import { createComponent } from '../createComponent'
* - noWrap → prevent text from wrapping
*/
export const Text = createComponent(TamaguiText, {
- name: 'GWText',
+ name: 'Text',
fontFamily: '$body',
color: '$color',
fontSize: '$3',
From 22b00e13a02deafe7ee9b2b6c401894393f540cf Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 23 Apr 2026 11:07:46 +0000
Subject: [PATCH 5/5] refactor: remove promoted components-test files and
migration comments
- Delete components-test/Button.tsx (replaced by components/Button.tsx)
- Delete components-test/Text.ts (replaced by components/Text.ts)
- Delete components-test/Separator.ts (replaced by components/Separator.ts)
- Delete components-test/Toast.tsx (replaced by components/Toast.tsx)
- Remove "now in components/" migration comments from index.ts
Agent-Logs-Url: https://github.com/GoodDollar/GoodWidget/sessions/b666b2f0-148c-4526-a177-60b4d1b654d4
Co-authored-by: L03TJ3 <6606028+L03TJ3@users.noreply.github.com>
---
packages/ui/src/components-test/Button.tsx | 104 -------------------
packages/ui/src/components-test/Separator.ts | 18 ----
packages/ui/src/components-test/Text.ts | 28 -----
packages/ui/src/components-test/Toast.tsx | 73 -------------
packages/ui/src/index.ts | 4 -
5 files changed, 227 deletions(-)
delete mode 100644 packages/ui/src/components-test/Button.tsx
delete mode 100644 packages/ui/src/components-test/Separator.ts
delete mode 100644 packages/ui/src/components-test/Text.ts
delete mode 100644 packages/ui/src/components-test/Toast.tsx
diff --git a/packages/ui/src/components-test/Button.tsx b/packages/ui/src/components-test/Button.tsx
deleted file mode 100644
index 388cd4c..0000000
--- a/packages/ui/src/components-test/Button.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import React from 'react'
-import type { ReactNode } from 'react'
-import { styled, Stack, Text as TamaguiText, Theme } from 'tamagui'
-import type { StackStyleBase } from '@tamagui/core'
-import { createComponent } from '../createComponent'
-
-export const ButtonFrame = createComponent(Stack, {
- name: 'Button',
- tag: 'button',
- role: 'button',
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: '$background',
- borderRadius: '$2',
- paddingHorizontal: '$4',
- height: '$10',
- gap: '$2',
- cursor: 'pointer',
- borderWidth: 0,
-
- hoverStyle: {
- backgroundColor: '$backgroundHover',
- },
- pressStyle: {
- backgroundColor: '$backgroundPress',
- opacity: 0.9,
- },
- focusStyle: {
- backgroundColor: '$backgroundFocus',
- outlineStyle: 'solid',
- outlineWidth: 2,
- outlineColor: '$borderColorFocus',
- },
-
- variants: {
- variant: {
- primary: {},
- secondary: {
- backgroundColor: '$backgroundTransparent',
- borderWidth: 1,
- borderColor: '$borderColor',
- },
- outline: {
- backgroundColor: '$backgroundTransparent',
- borderWidth: 1,
- borderColor: '$color',
- },
- ghost: {
- backgroundColor: '$backgroundTransparent',
- },
- },
- size: {
- sm: { height: '$8', paddingHorizontal: '$3', gap: '$1' },
- md: { height: '$10', paddingHorizontal: '$4', gap: '$2' },
- lg: { height: '$11', paddingHorizontal: '$5', gap: '$2' },
- },
- disabled: {
- true: { opacity: 0.5, cursor: 'not-allowed', pointerEvents: 'none' },
- },
- fullWidth: {
- true: { width: '100%' },
- },
- } as const,
-
- defaultVariants: {
- variant: 'primary',
- size: 'md',
- },
-})
-
-export const ButtonText = styled(TamaguiText, {
- name: 'ButtonText',
- fontFamily: '$body',
- fontSize: '$3',
- fontWeight: '600',
- color: '$color',
- userSelect: 'none',
-})
-
-export interface ButtonProps {
- variant?: 'primary' | 'secondary' | 'outline' | 'ghost'
- size?: 'sm' | 'md' | 'lg'
- disabled?: boolean
- fullWidth?: boolean
- onPress?: () => void
- children?: ReactNode
- [key: string]: unknown
-}
-
-/**
- * The light_Button theme sets color to white for primary buttons on a colored
- * background. For secondary/outline/ghost variants the background is
- * transparent, so we reset the theme so that children (ButtonText) pick up
- * the parent theme's dark text color instead.
- */
-export function Button({ variant = 'primary', children, ...props }: ButtonProps) {
- const needsReset = variant !== 'primary'
- return (
-
- {needsReset ? {children} : children}
-
- )
-}
diff --git a/packages/ui/src/components-test/Separator.ts b/packages/ui/src/components-test/Separator.ts
deleted file mode 100644
index bedd616..0000000
--- a/packages/ui/src/components-test/Separator.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { YStack } from 'tamagui'
-import { createComponent } from '../createComponent'
-
-export const Separator = createComponent(YStack, {
- name: 'Separator',
- height: 1,
- width: '100%',
- backgroundColor: '$borderColor',
-
- variants: {
- vertical: {
- true: {
- height: '100%',
- width: 1,
- },
- },
- } as const,
-})
diff --git a/packages/ui/src/components-test/Text.ts b/packages/ui/src/components-test/Text.ts
deleted file mode 100644
index 03043ac..0000000
--- a/packages/ui/src/components-test/Text.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { Text as TamaguiText } from 'tamagui'
-import { createComponent } from '../createComponent'
-
-export const Text = createComponent(TamaguiText, {
- name: 'GWText',
- fontFamily: '$body',
- color: '$color',
- fontSize: '$3',
- lineHeight: '$3',
-
- variants: {
- variant: {
- body: { fontSize: '$3', lineHeight: '$3' },
- caption: { fontSize: '$1', lineHeight: '$1', color: '$placeholderColor' },
- label: { fontSize: '$2', lineHeight: '$2', fontWeight: '500' },
- large: { fontSize: '$5', lineHeight: '$5' },
- },
- secondary: {
- true: { color: '$placeholderColor' },
- },
- bold: {
- true: { fontWeight: '700' },
- },
- center: {
- true: { textAlign: 'center' },
- },
- } as const,
-})
diff --git a/packages/ui/src/components-test/Toast.tsx b/packages/ui/src/components-test/Toast.tsx
deleted file mode 100644
index 1dc5ec6..0000000
--- a/packages/ui/src/components-test/Toast.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import React, { useEffect, useState, useCallback } from 'react'
-import { Stack, Text as TamaguiText } from 'tamagui'
-import { createComponent } from '../createComponent'
-
-const ToastFrame = createComponent(Stack, {
- name: 'Toast',
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: '$4',
- paddingVertical: '$3',
- borderRadius: '$2',
- backgroundColor: '$background',
- borderWidth: 1,
- borderColor: '$borderColor',
- shadowColor: '$shadowColor',
- shadowRadius: 8,
- shadowOpacity: 1,
- shadowOffset: { width: 0, height: 4 },
- gap: '$2',
-
- variants: {
- type: {
- success: { borderColor: '$green8' },
- error: { borderColor: '$red8' },
- warning: { borderColor: '$yellow8' },
- info: { borderColor: '$blue8' },
- },
- } as const,
-
- defaultVariants: {
- type: 'info',
- },
-})
-
-interface ToastProps {
- message: string
- type?: 'success' | 'error' | 'warning' | 'info'
- duration?: number
- onDismiss?: () => void
- visible?: boolean
-}
-
-export function Toast({ message, type = 'info', duration = 3000, onDismiss, visible = true }: ToastProps) {
- const [show, setShow] = useState(visible)
-
- useEffect(() => {
- setShow(visible)
- }, [visible])
-
- useEffect(() => {
- if (!show || duration <= 0) return
- const timer = setTimeout(() => {
- setShow(false)
- onDismiss?.()
- }, duration)
- return () => clearTimeout(timer)
- }, [show, duration, onDismiss])
-
- if (!show) return null
-
- return (
-
-
- {message}
-
- { setShow(false); onDismiss?.() }}>
-
- ✕
-
-
-
- )
-}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 3938591..eb2a654 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -37,17 +37,14 @@ export { Container } from './components-test/Container'
export { Card } from './components/Card'
export { GlowCard } from './components/GlowCard'
export { XStack, YStack, ZStack } from './components-test/Stacks'
-// Separator — now in components/ with size and color variants
export { Separator } from './components/Separator'
export { ScrollArea } from './components-test/ScrollArea'
// Typography
export { Heading } from './components-test/Heading'
-// Text — now in components/ with truncate, noWrap, colorSoft, colorDim variants
export { Text } from './components/Text'
// Inputs
-// Button — now in components/ with pill, icon, text, list variants
export { Button, ButtonFrame, ButtonText, PillText } from './components/Button'
export type { ButtonProps } from './components/Button'
export { Input, InputFrame, InputLabel, InputError } from './components-test/Input'
@@ -59,7 +56,6 @@ export { Switch } from './components-test/Switch'
// Feedback
export { Spinner } from './components-test/Spinner'
-// Toast — now in components/ with ToastContainer, toastStore, status variant
export {
Toast,
ToastContainer,