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,