# Know Foolery - Cross-Platform UI Strategy with Gluestack UI ## Overview Gluestack UI provides a comprehensive solution for building consistent, performant user interfaces across web (React) and mobile (React Native) platforms. This document outlines the strategy for implementing Know Foolery's cross-platform UI using Gluestack UI with NativeWind/Tailwind CSS. ## Gluestack UI Architecture ### Cross-Platform Component Architecture ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ Shared UI Layer │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Design │ │ Component │ │ Shared │ │ Theme │ │ │ │ Tokens │ │ Library │ │ Logic │ │ System │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ │ Platform Adaptation │ ┌─────────────────────────────────────────────────────────────────────────┐ │ Platform Renderers │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Web DOM │ │ iOS │ │ Android │ │ Desktop │ │ │ │ (React) │ │ (RN iOS) │ │ (RN Android)│ │ (Wails) │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ## Project Structure for Cross-Platform UI ### Recommended Package Organization ``` frontend/ ├── packages/ │ ├── ui-components/ # Shared Gluestack UI components │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── GameCard/ │ │ │ │ │ ├── GameCard.tsx │ │ │ │ │ ├── GameCard.stories.tsx │ │ │ │ │ ├── GameCard.test.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── Leaderboard/ │ │ │ │ ├── Timer/ │ │ │ │ ├── ScoreDisplay/ │ │ │ │ ├── AdminPanel/ │ │ │ │ └── index.ts │ │ │ ├── theme/ │ │ │ │ ├── tokens.ts # Design tokens │ │ │ │ ├── colors.ts │ │ │ │ ├── typography.ts │ │ │ │ ├── spacing.ts │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── package.json │ │ └── gluestack-ui.config.ts │ ├── shared-logic/ # Business logic │ │ ├── src/ │ │ │ ├── hooks/ │ │ │ ├── services/ │ │ │ ├── utils/ │ │ │ └── types/ │ │ └── package.json │ └── shared-types/ # TypeScript types │ ├── src/ │ │ ├── api.ts │ │ ├── game.ts │ │ └── index.ts │ └── package.json ├── apps/ │ ├── web/ # React web app │ │ ├── src/ │ │ │ ├── pages/ │ │ │ ├── components/ # Web-specific components │ │ │ └── main.tsx │ │ ├── vite.config.ts │ │ └── package.json │ ├── mobile/ # React Native app │ │ ├── src/ │ │ │ ├── screens/ │ │ │ ├── components/ # Mobile-specific components │ │ │ └── App.tsx │ │ ├── metro.config.js │ │ └── package.json │ └── desktop/ # Wails desktop app │ ├── frontend/ │ ├── app/ │ └── wails.json └── storybook/ # Component documentation ├── stories/ └── .storybook/ ``` ## Theme System Implementation ### Design Tokens with Quiz Game Branding ```typescript // packages/ui-components/src/theme/tokens.ts export const designTokens = { colors: { // Brand colors for Know Foolery brand: { 50: '#eff6ff', // Very light blue 100: '#dbeafe', // Light blue 200: '#bfdbfe', // Lighter blue 300: '#93c5fd', // Light blue 400: '#60a5fa', // Medium blue 500: '#3b82f6', // Primary blue (main brand) 600: '#2563eb', // Darker blue 700: '#1d4ed8', // Dark blue 800: '#1e40af', // Very dark blue 900: '#1e3a8a', // Darkest blue }, // Semantic colors for game states success: { 50: '#f0fdf4', // Correct answer background 500: '#22c55e', // Correct answer green 600: '#16a34a', // Darker correct green }, error: { 50: '#fef2f2', // Wrong answer background 500: '#ef4444', // Wrong answer red 600: '#dc2626', // Darker wrong red }, warning: { 50: '#fffbeb', // Hint background 500: '#f59e0b', // Hint orange/yellow 600: '#d97706', // Darker hint color }, // Neutral colors gray: { 50: '#f9fafb', 100: '#f3f4f6', 200: '#e5e7eb', 300: '#d1d5db', 400: '#9ca3af', 500: '#6b7280', 600: '#4b5563', 700: '#374151', 800: '#1f2937', 900: '#111827', }, }, spacing: { 0: '0px', 1: '4px', 2: '8px', 3: '12px', 4: '16px', 5: '20px', 6: '24px', 8: '32px', 10: '40px', 12: '48px', 16: '64px', 20: '80px', 24: '96px', }, typography: { fontSizes: { xs: '12px', sm: '14px', md: '16px', lg: '18px', xl: '20px', '2xl': '24px', '3xl': '30px', '4xl': '36px', }, fontWeights: { normal: '400', medium: '500', semibold: '600', bold: '700', extrabold: '800', }, lineHeights: { tight: '1.25', normal: '1.5', relaxed: '1.75', }, letterSpacings: { tight: '-0.025em', normal: '0em', wide: '0.025em', }, }, borderRadius: { none: '0px', sm: '4px', md: '8px', lg: '12px', xl: '16px', full: '9999px', }, shadows: { sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)', md: '0 4px 6px -1px rgb(0 0 0 / 0.1)', lg: '0 10px 15px -3px rgb(0 0 0 / 0.1)', xl: '0 20px 25px -5px rgb(0 0 0 / 0.1)', }, } // Custom semantic tokens for game elements export const gameTokens = { // Question difficulty indicators difficulty: { easy: designTokens.colors.success[500], medium: designTokens.colors.warning[500], hard: designTokens.colors.error[500], }, // Timer states timer: { normal: designTokens.colors.gray[600], warning: designTokens.colors.warning[500], // 5 minutes left critical: designTokens.colors.error[500], // 1 minute left }, // Score indicators score: { excellent: designTokens.colors.success[500], // 80%+ correct good: designTokens.colors.brand[500], // 60-80% correct average: designTokens.colors.warning[500], // 40-60% correct poor: designTokens.colors.error[500], // <40% correct }, // Leaderboard positions leaderboard: { first: '#ffd700', // Gold second: '#c0c0c0', // Silver third: '#cd7f32', // Bronze other: designTokens.colors.gray[600], }, } // Export combined theme export const knowFooleryTheme = { ...designTokens, game: gameTokens, } ``` ### Gluestack UI Configuration ```typescript // packages/ui-components/gluestack-ui.config.ts import { createConfig } from '@gluestack-ui/themed' import { knowFooleryTheme } from './src/theme' export const config = createConfig({ aliases: { bg: 'backgroundColor', p: 'padding', m: 'margin', w: 'width', h: 'height', }, tokens: { colors: knowFooleryTheme.colors, space: knowFooleryTheme.spacing, fontSizes: knowFooleryTheme.typography.fontSizes, fontWeights: knowFooleryTheme.typography.fontWeights, lineHeights: knowFooleryTheme.typography.lineHeights, letterSpacings: knowFooleryTheme.typography.letterSpacings, radii: knowFooleryTheme.borderRadius, shadows: knowFooleryTheme.shadows, }, globalStyle: { variants: { hardShadow: { shadowColor: '$backgroundLight800', shadowOffset: { width: 2, height: 2, }, shadowOpacity: 0.6, shadowRadius: 8, elevation: 10, }, }, }, // Custom component variants components: { Button: { theme: { variants: { solid: { 'bg': '$brand500', '_text': { 'color': '$white', 'fontWeight': '$semibold', }, '_hover': { 'bg': '$brand600', }, '_pressed': { 'bg': '$brand700', }, }, outline: { 'borderWidth': 2, 'borderColor': '$brand500', '_text': { 'color': '$brand500', 'fontWeight': '$semibold', }, '_hover': { 'bg': '$brand50', }, }, ghost: { '_text': { 'color': '$brand500', }, '_hover': { 'bg': '$brand50', }, }, }, sizes: { sm: { 'px': '$3', 'py': '$2', '_text': { 'fontSize': '$sm', }, }, md: { 'px': '$4', 'py': '$3', '_text': { 'fontSize': '$md', }, }, lg: { 'px': '$6', 'py': '$4', '_text': { 'fontSize': '$lg', }, }, }, }, }, Card: { theme: { 'bg': '$white', 'rounded': '$lg', 'shadowColor': '$backgroundLight800', 'shadowOffset': { width: 0, height: 2, }, 'shadowOpacity': 0.1, 'shadowRadius': 8, 'elevation': 3, variants: { elevated: { 'shadowOpacity': 0.15, 'elevation': 6, }, outlined: { 'borderWidth': 1, 'borderColor': '$gray200', 'shadowOpacity': 0, 'elevation': 0, }, }, }, }, Badge: { theme: { 'rounded': '$full', 'px': '$3', 'py': '$1', variants: { solid: { 'bg': '$brand500', '_text': { 'color': '$white', 'fontSize': '$sm', 'fontWeight': '$semibold', }, }, outline: { 'borderWidth': 1, 'borderColor': '$brand500', '_text': { 'color': '$brand500', 'fontSize': '$sm', 'fontWeight': '$semibold', }, }, subtle: { 'bg': '$brand50', '_text': { 'color': '$brand700', 'fontSize': '$sm', 'fontWeight': '$semibold', }, }, }, }, }, }, }) export type Config = typeof config ``` ## Core Game Components ### GameCard Component - The Heart of the Quiz ```typescript // packages/ui-components/src/components/GameCard/GameCard.tsx import React, { useState, useEffect, useCallback } from 'react' import { Card, VStack, HStack, Text, Input, InputField, Button, ButtonText, Badge, Progress, ProgressFilledTrack, Box, Pressable, } from '@gluestack-ui/themed' import { knowFooleryTheme } from '../../theme' export interface GameCardProps { // Game data question: string theme: string difficulty?: 'easy' | 'medium' | 'hard' // Game state timeRemaining: number attemptsLeft: number currentScore: number hintUsed: boolean // Interaction handlers onSubmitAnswer: (answer: string) => Promise onRequestHint: () => Promise onTimeExpire?: () => void // UI state isLoading?: boolean hint?: string showHint?: boolean } export const GameCard: React.FC = ({ question, theme, difficulty = 'medium', timeRemaining, attemptsLeft, currentScore, hintUsed, onSubmitAnswer, onRequestHint, onTimeExpire, isLoading = false, hint, showHint = false, }) => { const [answer, setAnswer] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) // Timer formatting const formatTime = useCallback((seconds: number): string => { const minutes = Math.floor(seconds / 60) const remainingSeconds = seconds % 60 return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}` }, []) // Handle answer submission const handleSubmit = useCallback(async () => { if (!answer.trim() || isSubmitting || isLoading) return setIsSubmitting(true) try { await onSubmitAnswer(answer.trim()) setAnswer('') // Clear input after successful submission } catch (error) { // Error handling managed by parent component console.error('Failed to submit answer:', error) } finally { setIsSubmitting(false) } }, [answer, isSubmitting, isLoading, onSubmitAnswer]) // Handle hint request const handleHintRequest = useCallback(async () => { if (isLoading || isSubmitting || hintUsed) return try { await onRequestHint() } catch (error) { console.error('Failed to request hint:', error) } }, [isLoading, isSubmitting, hintUsed, onRequestHint]) // Time expiration effect useEffect(() => { if (timeRemaining === 0 && onTimeExpire) { onTimeExpire() } }, [timeRemaining, onTimeExpire]) // Computed values const progressValue = ((30 * 60 - timeRemaining) / (30 * 60)) * 100 const isTimeWarning = timeRemaining <= 300 // 5 minutes const isTimeCritical = timeRemaining <= 60 // 1 minute const difficultyColor = knowFooleryTheme.game.difficulty[difficulty] const timerColor = isTimeCritical ? knowFooleryTheme.game.timer.critical : isTimeWarning ? knowFooleryTheme.game.timer.warning : knowFooleryTheme.game.timer.normal return ( {/* Header with theme, difficulty, and timer */} {theme} {difficulty && ( {difficulty.toUpperCase()} )} ⏱️ {formatTime(timeRemaining)} {/* Session progress indicator */} {/* Question display */} {question} {/* Hint display */} {showHint && hint && ( 💡 {hint} )} {/* Answer input */} {/* Game statistics */} {attemptsLeft} attempts left Score: {currentScore} points {/* Action buttons */} {/* Hint usage warning */} {!hintUsed && ( Using a hint reduces your score to 1 point for this question )} ) } export default GameCard ``` ### Leaderboard Component ```typescript // packages/ui-components/src/components/Leaderboard/Leaderboard.tsx import React from 'react' import { Card, VStack, HStack, Text, Box, Badge, Divider, ScrollView, } from '@gluestack-ui/themed' import { knowFooleryTheme } from '../../theme' export interface LeaderboardEntry { position: number playerName: string score: number questionsAnswered: number successRate: number completedAt: string isCurrentUser?: boolean } export interface LeaderboardProps { entries: LeaderboardEntry[] currentUserPosition?: number isLoading?: boolean title?: string } export const Leaderboard: React.FC = ({ entries, currentUserPosition, isLoading = false, title = "🏆 Leaderboard", }) => { const getPositionColor = (position: number) => { switch (position) { case 1: return knowFooleryTheme.game.leaderboard.first case 2: return knowFooleryTheme.game.leaderboard.second case 3: return knowFooleryTheme.game.leaderboard.third default: return knowFooleryTheme.game.leaderboard.other } } const getPositionIcon = (position: number) => { switch (position) { case 1: return '🥇' case 2: return '🥈' case 3: return '🥉' default: return `#${position}` } } const formatSuccessRate = (rate: number) => `${Math.round(rate)}%` if (isLoading) { return ( {title} Loading leaderboard... ) } return ( {/* Header */} {title} {currentUserPosition && ( Your Rank: #{currentUserPosition} )} {/* Leaderboard entries */} {entries.map((entry, index) => ( ))} {entries.length === 0 && ( No scores yet. Be the first to play! )} ) } interface LeaderboardRowProps { entry: LeaderboardEntry positionColor: string positionIcon: string } const LeaderboardRow: React.FC = ({ entry, positionColor, positionIcon }) => { return ( {/* Position */} {positionIcon} {/* Player info */} {entry.playerName} {entry.isCurrentUser && ( (You) )} {entry.score} {entry.questionsAnswered} questions {formatSuccessRate(entry.successRate)} accuracy ) } export default Leaderboard ``` ### Timer Component ```typescript // packages/ui-components/src/components/Timer/Timer.tsx import React, { useEffect, useState } from 'react' import { HStack, VStack, Text, Box, Progress, ProgressFilledTrack, } from '@gluestack-ui/themed' import { knowFooleryTheme } from '../../theme' export interface TimerProps { timeRemaining: number // in seconds totalTime?: number // in seconds, defaults to 30 minutes onTimeExpire?: () => void onWarning?: (timeLeft: number) => void showProgress?: boolean size?: 'sm' | 'md' | 'lg' variant?: 'compact' | 'detailed' } export const Timer: React.FC = ({ timeRemaining, totalTime = 30 * 60, // 30 minutes default onTimeExpire, onWarning, showProgress = true, size = 'md', variant = 'detailed', }) => { const [hasWarned5Min, setHasWarned5Min] = useState(false) const [hasWarned1Min, setHasWarned1Min] = useState(false) // Format time display const formatTime = (seconds: number): string => { const minutes = Math.floor(seconds / 60) const remainingSeconds = seconds % 60 return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}` } // Calculate progress percentage const progressValue = ((totalTime - timeRemaining) / totalTime) * 100 // Determine timer state and colors const isTimeCritical = timeRemaining <= 60 // 1 minute const isTimeWarning = timeRemaining <= 300 // 5 minutes const timerColor = isTimeCritical ? knowFooleryTheme.game.timer.critical : isTimeWarning ? knowFooleryTheme.game.timer.warning : knowFooleryTheme.game.timer.normal const progressColor = isTimeCritical ? '$error500' : isTimeWarning ? '$warning500' : '$brand500' // Font sizes based on size prop const fontSizes = { sm: { time: '$md', label: '$xs' }, md: { time: '$lg', label: '$sm' }, lg: { time: '$2xl', label: '$md' }, } // Handle warnings and expiration useEffect(() => { if (timeRemaining === 0 && onTimeExpire) { onTimeExpire() } else if (timeRemaining <= 60 && !hasWarned1Min && onWarning) { setHasWarned1Min(true) onWarning(timeRemaining) } else if (timeRemaining <= 300 && !hasWarned5Min && onWarning) { setHasWarned5Min(true) onWarning(timeRemaining) } }, [timeRemaining, onTimeExpire, onWarning, hasWarned1Min, hasWarned5Min]) if (variant === 'compact') { return ( ⏱️ {formatTime(timeRemaining)} ) } return ( {/* Time display */} ⏱️ {formatTime(timeRemaining)} Time Remaining {/* Progress bar */} {showProgress && ( {variant === 'detailed' && ( 0:00 {formatTime(totalTime)} )} )} {/* Warning messages */} {isTimeCritical && ( ⚠️ Less than 1 minute remaining! )} {isTimeWarning && !isTimeCritical && ( ⏰ 5 minutes or less remaining )} ) } export default Timer ``` ## Platform-Specific Adaptations ### Web-Specific Optimizations ```typescript // apps/web/src/components/WebGameCard.tsx import React from 'react' import { GameCard, GameCardProps } from '@knowfoolery/ui-components' import { useHotkeys } from 'react-hotkeys-hook' interface WebGameCardProps extends GameCardProps { enableKeyboardShortcuts?: boolean } export const WebGameCard: React.FC = ({ enableKeyboardShortcuts = true, onSubmitAnswer, onRequestHint, ...props }) => { // Keyboard shortcuts for web useHotkeys('enter', () => { const answerInput = document.querySelector('[data-testid="answer-input"] input') as HTMLInputElement if (answerInput && answerInput.value.trim()) { onSubmitAnswer(answerInput.value.trim()) } }, { enabled: enableKeyboardShortcuts }) useHotkeys('ctrl+h, cmd+h', (event) => { event.preventDefault() onRequestHint() }, { enabled: enableKeyboardShortcuts }) return (
{enableKeyboardShortcuts && (
Press Enter to submit • Ctrl/Cmd + H for hint
)}
) } ``` ### Mobile-Specific Optimizations ```typescript // apps/mobile/src/components/MobileGameCard.tsx import React from 'react' import { Vibration, Platform } from 'react-native' import { GameCard, GameCardProps } from '@knowfoolery/ui-components' interface MobileGameCardProps extends GameCardProps { enableHaptics?: boolean } export const MobileGameCard: React.FC = ({ enableHaptics = true, onSubmitAnswer, onRequestHint, onTimeExpire, ...props }) => { const handleSubmitWithHaptics = async (answer: string) => { if (enableHaptics && Platform.OS === 'ios') { // Use iOS haptics const { ImpactFeedbackGenerator } = require('expo-haptics') ImpactFeedbackGenerator.impactAsync(ImpactFeedbackGenerator.ImpactFeedbackStyle.Medium) } else if (enableHaptics && Platform.OS === 'android') { // Use Android vibration Vibration.vibrate(50) } await onSubmitAnswer(answer) } const handleHintWithHaptics = async () => { if (enableHaptics) { if (Platform.OS === 'ios') { const { NotificationFeedbackGenerator } = require('expo-haptics') NotificationFeedbackGenerator.notificationAsync( NotificationFeedbackGenerator.NotificationFeedbackType.Warning ) } else if (Platform.OS === 'android') { Vibration.vibrate([0, 100, 50, 100]) } } await onRequestHint() } const handleTimeExpireWithHaptics = () => { if (enableHaptics) { if (Platform.OS === 'ios') { const { NotificationFeedbackGenerator } = require('expo-haptics') NotificationFeedbackGenerator.notificationAsync( NotificationFeedbackGenerator.NotificationFeedbackType.Error ) } else if (Platform.OS === 'android') { Vibration.vibrate([0, 200, 100, 200, 100, 200]) } } onTimeExpire?.() } return ( ) } ``` ## Responsive Design Strategy ### Breakpoint System ```typescript // packages/ui-components/src/theme/breakpoints.ts export const breakpoints = { sm: 640, // Mobile landscape md: 768, // Tablet portrait lg: 1024, // Tablet landscape / Desktop xl: 1280, // Large desktop '2xl': 1536, // Extra large desktop } export const useResponsiveValue = (values: { base: T sm?: T md?: T lg?: T xl?: T }) => { const [screenWidth, setScreenWidth] = useState( typeof window !== 'undefined' ? window.innerWidth : breakpoints.lg ) useEffect(() => { if (typeof window === 'undefined') return const handleResize = () => setScreenWidth(window.innerWidth) window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) }, []) if (screenWidth >= breakpoints.xl && values.xl !== undefined) return values.xl if (screenWidth >= breakpoints.lg && values.lg !== undefined) return values.lg if (screenWidth >= breakpoints.md && values.md !== undefined) return values.md if (screenWidth >= breakpoints.sm && values.sm !== undefined) return values.sm return values.base } // Usage example export const ResponsiveGameLayout: React.FC = ({ children }) => { const padding = useResponsiveValue({ base: '$4', md: '$6', lg: '$8', }) const columns = useResponsiveValue({ base: 1, lg: 2, }) return ( {children} ) } ``` ## Testing Strategy for Cross-Platform Components ### Component Testing ```typescript // packages/ui-components/src/components/GameCard/GameCard.test.tsx import React from 'react' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { GluestackUIProvider } from '@gluestack-ui/themed' import { config } from '../../../gluestack-ui.config' import { GameCard, GameCardProps } from './GameCard' const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} ) const defaultProps: GameCardProps = { question: "What is the capital of France?", theme: "Geography", difficulty: "medium", timeRemaining: 1800, attemptsLeft: 3, currentScore: 0, hintUsed: false, onSubmitAnswer: jest.fn(), onRequestHint: jest.fn(), } describe('GameCard Component', () => { beforeEach(() => { jest.clearAllMocks() }) describe('Cross-Platform Rendering', () => { it('renders correctly with all required props', () => { render(, { wrapper: TestWrapper }) expect(screen.getByTestId('game-card')).toBeInTheDocument() expect(screen.getByTestId('question-text')).toHaveTextContent(defaultProps.question) expect(screen.getByTestId('theme-badge')).toHaveTextContent(defaultProps.theme) expect(screen.getByTestId('timer-display')).toBeInTheDocument() }) it('handles different screen sizes appropriately', () => { // Mock different viewport sizes const viewports = [ { width: 375, height: 667 }, // Mobile { width: 768, height: 1024 }, // Tablet { width: 1440, height: 900 }, // Desktop ] viewports.forEach(viewport => { // Mock window dimensions Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: viewport.width, }) render(, { wrapper: TestWrapper }) const gameCard = screen.getByTestId('game-card') expect(gameCard).toBeInTheDocument() // Component should maintain functionality across all sizes expect(screen.getByTestId('answer-input')).toBeInTheDocument() expect(screen.getByTestId('submit-button')).toBeInTheDocument() }) }) }) describe('Interactive Functionality', () => { it('submits answer when submit button is pressed', async () => { const mockOnSubmitAnswer = jest.fn().mockResolvedValue(undefined) render( , { wrapper: TestWrapper } ) const input = screen.getByTestId('answer-input') const submitButton = screen.getByTestId('submit-button') // Enter answer fireEvent.changeText(input, 'Paris') // Submit answer fireEvent.press(submitButton) await waitFor(() => { expect(mockOnSubmitAnswer).toHaveBeenCalledWith('Paris') }) }) it('requests hint when hint button is pressed', async () => { const mockOnRequestHint = jest.fn().mockResolvedValue(undefined) render( , { wrapper: TestWrapper } ) const hintButton = screen.getByTestId('hint-button') fireEvent.press(hintButton) await waitFor(() => { expect(mockOnRequestHint).toHaveBeenCalled() }) }) }) describe('Timer and State Management', () => { it('displays timer with correct formatting', () => { render(, { wrapper: TestWrapper }) expect(screen.getByTestId('timer-display')).toHaveTextContent('⏱️ 2:05') }) it('shows warning colors when time is low', () => { render(, { wrapper: TestWrapper }) const timer = screen.getByTestId('timer-display') // Check if timer has warning/critical styling applied expect(timer).toHaveStyle({ color: expect.stringContaining('#ef4444') }) }) }) }) ``` This comprehensive Gluestack UI strategy ensures Know Foolery delivers a consistent, performant, and maintainable user experience across all platforms while leveraging the power of a unified component system.