You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
39 KiB
39 KiB
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
// 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
// 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
// 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<void>
onRequestHint: () => Promise<void>
onTimeExpire?: () => void
// UI state
isLoading?: boolean
hint?: string
showHint?: boolean
}
export const GameCard: React.FC<GameCardProps> = ({
question,
theme,
difficulty = 'medium',
timeRemaining,
attemptsLeft,
currentScore,
hintUsed,
onSubmitAnswer,
onRequestHint,
onTimeExpire,
isLoading = false,
hint,
showHint = false,
}) => {
const [answer, setAnswer] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState<boolean>(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 (
<Card size="lg" variant="elevated" m="$4" testID="game-card">
<VStack space="md" p="$6">
{/* Header with theme, difficulty, and timer */}
<HStack justifyContent="space-between" alignItems="center">
<HStack space="sm" alignItems="center">
<Badge variant="solid" bg={difficultyColor} testID="theme-badge">
<Text color="$white" fontSize="$sm" fontWeight="$semibold">
{theme}
</Text>
</Badge>
{difficulty && (
<Badge variant="outline" borderColor={difficultyColor} testID="difficulty-badge">
<Text color={difficultyColor} fontSize="$xs" fontWeight="$medium">
{difficulty.toUpperCase()}
</Text>
</Badge>
)}
</HStack>
<HStack space="sm" alignItems="center">
<Text
fontSize="$sm"
color={timerColor}
fontWeight="$semibold"
testID="timer-display"
>
⏱️ {formatTime(timeRemaining)}
</Text>
</HStack>
</HStack>
{/* Session progress indicator */}
<Progress value={progressValue} size="sm" testID="session-progress">
<ProgressFilledTrack bg="$brand500" />
</Progress>
{/* Question display */}
<Box>
<Text
fontSize="$xl"
fontWeight="$semibold"
lineHeight="$relaxed"
color="$gray900"
testID="question-text"
>
{question}
</Text>
</Box>
{/* Hint display */}
{showHint && hint && (
<Box
bg="$warning50"
p="$4"
rounded="$md"
borderLeftWidth={4}
borderLeftColor="$warning500"
testID="hint-display"
>
<HStack space="sm" alignItems="flex-start">
<Text fontSize="$lg">💡</Text>
<Text fontSize="$md" color="$gray700" flex={1}>
{hint}
</Text>
</HStack>
</Box>
)}
{/* Answer input */}
<Input
size="lg"
isDisabled={isLoading || isSubmitting}
testID="answer-input"
>
<InputField
placeholder="Enter your answer..."
value={answer}
onChangeText={setAnswer}
autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={handleSubmit}
returnKeyType="send"
fontSize="$md"
/>
</Input>
{/* Game statistics */}
<HStack justifyContent="space-between" alignItems="center">
<Text fontSize="$sm" color="$gray600" testID="attempts-counter">
<Text fontWeight="$semibold">{attemptsLeft}</Text> attempts left
</Text>
<Text fontSize="$sm" color="$gray600" testID="score-display">
Score: <Text fontWeight="$semibold" color="$brand600">{currentScore}</Text> points
</Text>
</HStack>
{/* Action buttons */}
<HStack space="sm">
<Button
size="lg"
variant="solid"
flex={1}
onPress={handleSubmit}
isDisabled={!answer.trim() || isLoading || isSubmitting}
testID="submit-button"
>
<ButtonText>
{isSubmitting ? 'Submitting...' : 'Submit Answer'}
</ButtonText>
</Button>
<Button
size="lg"
variant="outline"
onPress={handleHintRequest}
isDisabled={isLoading || isSubmitting || hintUsed}
testID="hint-button"
px="$4"
>
<ButtonText>
{hintUsed ? '💡 Used' : '💡 Hint'}
</ButtonText>
</Button>
</HStack>
{/* Hint usage warning */}
{!hintUsed && (
<Text fontSize="$xs" color="$gray500" textAlign="center">
Using a hint reduces your score to 1 point for this question
</Text>
)}
</VStack>
</Card>
)
}
export default GameCard
Leaderboard Component
// 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<LeaderboardProps> = ({
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 (
<Card size="lg" m="$4" testID="leaderboard-loading">
<VStack space="md" p="$6">
<Text fontSize="$xl" fontWeight="$bold" textAlign="center">
{title}
</Text>
<Text textAlign="center" color="$gray500">
Loading leaderboard...
</Text>
</VStack>
</Card>
)
}
return (
<Card size="lg" m="$4" testID="leaderboard">
<VStack space="md" p="$6">
{/* Header */}
<HStack justifyContent="space-between" alignItems="center">
<Text fontSize="$xl" fontWeight="$bold" testID="leaderboard-title">
{title}
</Text>
{currentUserPosition && (
<Badge variant="subtle" testID="user-position">
<Text fontSize="$sm" fontWeight="$semibold">
Your Rank: #{currentUserPosition}
</Text>
</Badge>
)}
</HStack>
<Divider />
{/* Leaderboard entries */}
<ScrollView maxHeight={400} showsVerticalScrollIndicator={false}>
<VStack space="sm">
{entries.map((entry, index) => (
<LeaderboardRow
key={`${entry.position}-${entry.playerName}`}
entry={entry}
positionColor={getPositionColor(entry.position)}
positionIcon={getPositionIcon(entry.position)}
/>
))}
{entries.length === 0 && (
<Box py="$8">
<Text textAlign="center" color="$gray500" fontSize="$md">
No scores yet. Be the first to play!
</Text>
</Box>
)}
</VStack>
</ScrollView>
</VStack>
</Card>
)
}
interface LeaderboardRowProps {
entry: LeaderboardEntry
positionColor: string
positionIcon: string
}
const LeaderboardRow: React.FC<LeaderboardRowProps> = ({
entry,
positionColor,
positionIcon
}) => {
return (
<Box
bg={entry.isCurrentUser ? '$brand50' : '$white'}
p="$3"
rounded="$md"
borderWidth={entry.isCurrentUser ? 2 : 1}
borderColor={entry.isCurrentUser ? '$brand200' : '$gray100'}
testID={`leaderboard-row-${entry.position}`}
>
<HStack space="md" alignItems="center">
{/* Position */}
<Box minWidth={40}>
<Text
fontSize="$lg"
fontWeight="$bold"
color={positionColor}
textAlign="center"
>
{positionIcon}
</Text>
</Box>
{/* Player info */}
<VStack flex={1} space="xs">
<HStack justifyContent="space-between" alignItems="center">
<Text
fontSize="$md"
fontWeight="$semibold"
color={entry.isCurrentUser ? '$brand700' : '$gray900'}
numberOfLines={1}
ellipsizeMode="tail"
>
{entry.playerName}
{entry.isCurrentUser && (
<Text fontSize="$sm" color="$brand500"> (You)</Text>
)}
</Text>
<Text fontSize="$lg" fontWeight="$bold" color="$brand600">
{entry.score}
</Text>
</HStack>
<HStack justifyContent="space-between">
<Text fontSize="$sm" color="$gray600">
{entry.questionsAnswered} questions
</Text>
<Text fontSize="$sm" color="$gray600">
{formatSuccessRate(entry.successRate)} accuracy
</Text>
</HStack>
</VStack>
</HStack>
</Box>
)
}
export default Leaderboard
Timer Component
// 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<TimerProps> = ({
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 (
<HStack space="xs" alignItems="center" testID="timer-compact">
<Text fontSize="$md">⏱️</Text>
<Text
fontSize={fontSizes[size].time}
fontWeight="$semibold"
color={timerColor}
>
{formatTime(timeRemaining)}
</Text>
</HStack>
)
}
return (
<VStack space="sm" testID="timer-detailed">
{/* Time display */}
<HStack justifyContent="center" alignItems="center" space="sm">
<Text fontSize="$lg">⏱️</Text>
<VStack space="xs" alignItems="center">
<Text
fontSize={fontSizes[size].time}
fontWeight="$bold"
color={timerColor}
testID="timer-display"
>
{formatTime(timeRemaining)}
</Text>
<Text
fontSize={fontSizes[size].label}
color="$gray600"
fontWeight="$medium"
>
Time Remaining
</Text>
</VStack>
</HStack>
{/* Progress bar */}
{showProgress && (
<Box>
<Progress value={progressValue} size="md" testID="timer-progress">
<ProgressFilledTrack bg={progressColor} />
</Progress>
{variant === 'detailed' && (
<HStack justifyContent="space-between" mt="$1">
<Text fontSize="$xs" color="$gray500">
0:00
</Text>
<Text fontSize="$xs" color="$gray500">
{formatTime(totalTime)}
</Text>
</HStack>
)}
</Box>
)}
{/* Warning messages */}
{isTimeCritical && (
<Box
bg="$error50"
p="$2"
rounded="$md"
borderLeftWidth={3}
borderLeftColor="$error500"
>
<Text fontSize="$sm" color="$error700" fontWeight="$semibold" textAlign="center">
⚠️ Less than 1 minute remaining!
</Text>
</Box>
)}
{isTimeWarning && !isTimeCritical && (
<Box
bg="$warning50"
p="$2"
rounded="$md"
borderLeftWidth={3}
borderLeftColor="$warning500"
>
<Text fontSize="$sm" color="$warning700" fontWeight="$semibold" textAlign="center">
⏰ 5 minutes or less remaining
</Text>
</Box>
)}
</VStack>
)
}
export default Timer
Platform-Specific Adaptations
Web-Specific Optimizations
// 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<WebGameCardProps> = ({
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 (
<div className="web-game-card-wrapper">
<GameCard
{...props}
onSubmitAnswer={onSubmitAnswer}
onRequestHint={onRequestHint}
/>
{enableKeyboardShortcuts && (
<div className="keyboard-hints">
<small className="text-gray-500">
Press Enter to submit • Ctrl/Cmd + H for hint
</small>
</div>
)}
</div>
)
}
Mobile-Specific Optimizations
// 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<MobileGameCardProps> = ({
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 (
<GameCard
{...props}
onSubmitAnswer={handleSubmitWithHaptics}
onRequestHint={handleHintWithHaptics}
onTimeExpire={handleTimeExpireWithHaptics}
/>
)
}
Responsive Design Strategy
Breakpoint System
// 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 = <T>(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 (
<Box p={padding}>
<VStack space="lg" flex={1}>
{children}
</VStack>
</Box>
)
}
Testing Strategy for Cross-Platform Components
Component Testing
// 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 }) => (
<GluestackUIProvider config={config}>
{children}
</GluestackUIProvider>
)
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(<GameCard {...defaultProps} />, { 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(<GameCard {...defaultProps} />, { 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(
<GameCard
{...defaultProps}
onSubmitAnswer={mockOnSubmitAnswer}
/>,
{ 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(
<GameCard
{...defaultProps}
onRequestHint={mockOnRequestHint}
/>,
{ 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(<GameCard {...defaultProps} timeRemaining={125} />, { wrapper: TestWrapper })
expect(screen.getByTestId('timer-display')).toHaveTextContent('⏱️ 2:05')
})
it('shows warning colors when time is low', () => {
render(<GameCard {...defaultProps} timeRemaining={30} />, { 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.