Skip to content

Commit 7df77b1

Browse files
author
Xian Zheng
committed
feat: add Shapes & Animals apps, restrict kindergarten to K-2 apps only
New button-only apps for K-2: - Shapes & Colors: name shapes (CSS-rendered), identify colors (swatch buttons), count sides — 10 shapes with distinct colors - Animal Friends: animal sounds, habitats (farm/ocean/jungle/etc), baby animal names — 15 animals with emoji Grade gating tightened: - K-2 students (emma_k) now ONLY see: Counting Game, ABC Letters, Shapes & Colors, Animal Friends (4 apps) - Chess, Math Helper, Reading & Vocabulary, Level Up Life, Flashcards, Weather all require grade 3+ minimum - K-2 apps hidden from grade 3+ (APP_MAX_GRADE = 2)
1 parent 1ced075 commit 7df77b1

19 files changed

Lines changed: 857 additions & 4 deletions

apps/animals/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Animals</title></head>
4+
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
5+
</html>

apps/animals/package.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "@chatbridge/animals",
3+
"private": true,
4+
"version": "0.0.1",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "tsc -b && vite build",
9+
"preview": "vite preview"
10+
},
11+
"dependencies": {
12+
"react": "^18.3.1",
13+
"react-dom": "^18.3.1"
14+
},
15+
"devDependencies": {
16+
"@types/react": "^18.3.12",
17+
"@types/react-dom": "^18.3.1",
18+
"@vitejs/plugin-react": "4.3.4",
19+
"typescript": "^5.6.3",
20+
"vite": "^5.4.11"
21+
}
22+
}

apps/animals/src/AnimalsGame.tsx

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
import { useState, useEffect, useCallback } from 'react'
2+
import { playCorrect, playWrong, playCelebration, playPop } from './sounds'
3+
4+
function sendToPlatform(type: string, correlationId: string, data: Record<string, unknown>) {
5+
window.parent.postMessage({ type, correlationId, data }, '*')
6+
}
7+
8+
// ─── Animal definitions ─────────────────────────────────────────────
9+
interface AnimalDef {
10+
emoji: string
11+
name: string
12+
sound: string
13+
habitat: string
14+
baby: string
15+
}
16+
17+
const ANIMALS: AnimalDef[] = [
18+
{ emoji: '🐶', name: 'Dog', sound: 'Woof!', habitat: 'Farm', baby: 'Puppy' },
19+
{ emoji: '🐱', name: 'Cat', sound: 'Meow!', habitat: 'Farm', baby: 'Kitten' },
20+
{ emoji: '🐮', name: 'Cow', sound: 'Moo!', habitat: 'Farm', baby: 'Calf' },
21+
{ emoji: '🐷', name: 'Pig', sound: 'Oink!', habitat: 'Farm', baby: 'Piglet' },
22+
{ emoji: '🐔', name: 'Chicken', sound: 'Cluck!', habitat: 'Farm', baby: 'Chick' },
23+
{ emoji: '🐴', name: 'Horse', sound: 'Neigh!', habitat: 'Farm', baby: 'Foal' },
24+
{ emoji: '🐸', name: 'Frog', sound: 'Ribbit!', habitat: 'Pond', baby: 'Tadpole' },
25+
{ emoji: '🦁', name: 'Lion', sound: 'Roar!', habitat: 'Jungle', baby: 'Cub' },
26+
{ emoji: '🐘', name: 'Elephant', sound: 'Trumpet!', habitat: 'Jungle', baby: 'Calf' },
27+
{ emoji: '🐍', name: 'Snake', sound: 'Hiss!', habitat: 'Desert', baby: 'Hatchling' },
28+
{ emoji: '🐧', name: 'Penguin', sound: 'Squawk!', habitat: 'Arctic', baby: 'Chick' },
29+
{ emoji: '🐳', name: 'Whale', sound: 'Song!', habitat: 'Ocean', baby: 'Calf' },
30+
{ emoji: '🐠', name: 'Fish', sound: 'Blub!', habitat: 'Ocean', baby: 'Fry' },
31+
{ emoji: '🦊', name: 'Fox', sound: 'Yip!', habitat: 'Forest', baby: 'Kit' },
32+
{ emoji: '🐻', name: 'Bear', sound: 'Growl!', habitat: 'Forest', baby: 'Cub' },
33+
]
34+
35+
const ALL_SOUNDS = [...new Set(ANIMALS.map(a => a.sound))]
36+
const ALL_HABITATS = [...new Set(ANIMALS.map(a => a.habitat))]
37+
const ALL_BABIES = [...new Set(ANIMALS.map(a => a.baby))]
38+
39+
type GameMode = 'sounds' | 'habitat' | 'baby'
40+
type Mode = 'menu' | GameMode | 'result'
41+
42+
interface Question {
43+
animal: AnimalDef
44+
options: string[]
45+
correct: string
46+
prompt: string
47+
}
48+
49+
function shuffle<T>(arr: T[]): T[] {
50+
const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]] }; return a
51+
}
52+
53+
function generateQuestion(gameMode: GameMode): Question {
54+
const animal = ANIMALS[Math.floor(Math.random() * ANIMALS.length)]
55+
56+
if (gameMode === 'sounds') {
57+
const correct = animal.sound
58+
const wrongs = shuffle(ALL_SOUNDS.filter(s => s !== correct)).slice(0, 2)
59+
return { animal, correct, options: shuffle([correct, ...wrongs]), prompt: `What sound does the ${animal.name} make?` }
60+
}
61+
62+
if (gameMode === 'habitat') {
63+
const correct = animal.habitat
64+
const wrongs = shuffle(ALL_HABITATS.filter(h => h !== correct)).slice(0, 2)
65+
return { animal, correct, options: shuffle([correct, ...wrongs]), prompt: `Where does the ${animal.name} live?` }
66+
}
67+
68+
// baby
69+
const correct = animal.baby
70+
const wrongs = shuffle(ALL_BABIES.filter(b => b !== correct)).slice(0, 2)
71+
return { animal, correct, options: shuffle([correct, ...wrongs]), prompt: `What is a baby ${animal.name} called?` }
72+
}
73+
74+
const ROUNDS_PER_GAME = 5
75+
const MODES_LIST: { id: GameMode; label: string; emoji: string; desc: string }[] = [
76+
{ id: 'sounds', label: 'Animal Sounds', emoji: '🔊', desc: 'What sound does it make?' },
77+
{ id: 'habitat', label: 'Where Do I Live?', emoji: '🏠', desc: 'Find the animal\'s home!' },
78+
{ id: 'baby', label: 'Baby Animals', emoji: '🍼', desc: 'Name the baby animal!' },
79+
]
80+
81+
const HABITAT_EMOJIS: Record<string, string> = {
82+
Farm: '🏡', Ocean: '🌊', Jungle: '🌴', Forest: '🌲', Arctic: '🧊', Desert: '🏜️', Pond: '🪷',
83+
}
84+
85+
export default function AnimalsGame() {
86+
const [mode, setMode] = useState<Mode>('menu')
87+
const [gameMode, setGameMode] = useState<GameMode>('sounds')
88+
const [question, setQuestion] = useState<Question | null>(null)
89+
const [round, setRound] = useState(0)
90+
const [score, setScore] = useState(0)
91+
const [feedback, setFeedback] = useState<{ correct: boolean; message: string } | null>(null)
92+
const [streak, setStreak] = useState(0)
93+
94+
useEffect(() => { sendToPlatform('ui_ready', '', {}) }, [])
95+
96+
useEffect(() => {
97+
function handleMessage(event: MessageEvent) {
98+
const msg = event.data
99+
if (!msg || msg.type !== 'tool_invoke') return
100+
const { correlationId, tool } = msg
101+
if (tool === 'get_state') {
102+
sendToPlatform('tool_result', correlationId, { tool: 'get_state', mode, round, score, streak })
103+
} else if (tool === 'restore_state') {
104+
sendToPlatform('tool_result', correlationId, { tool: 'restore_state', message: 'Restored' })
105+
} else {
106+
sendToPlatform('error', correlationId, { message: `Unknown tool: ${tool}` })
107+
}
108+
}
109+
window.addEventListener('message', handleMessage)
110+
return () => window.removeEventListener('message', handleMessage)
111+
}, [mode, round, score, streak])
112+
113+
const startGame = useCallback((m: GameMode) => {
114+
playPop()
115+
setGameMode(m)
116+
setRound(0)
117+
setScore(0)
118+
setStreak(0)
119+
setFeedback(null)
120+
setQuestion(generateQuestion(m))
121+
setMode(m)
122+
sendToPlatform('state_update', '', { type: 'game_start', mode: m })
123+
}, [])
124+
125+
function handleAnswer(answer: string) {
126+
if (!question || feedback) return
127+
const correct = answer === question.correct
128+
if (correct) {
129+
setScore(s => s + 1)
130+
setStreak(s => s + 1)
131+
playCorrect()
132+
const msgs = ['Amazing! 🎉', 'You got it! ⭐', 'Wow, great job! 🌟', 'Super smart! 🧠', 'Yes! You rock! 🎸']
133+
setFeedback({ correct: true, message: msgs[Math.floor(Math.random() * msgs.length)] })
134+
} else {
135+
setStreak(0)
136+
playWrong()
137+
setFeedback({ correct: false, message: `The answer is ${question.correct}! Let's keep going! 💪` })
138+
}
139+
}
140+
141+
function nextRound() {
142+
if (round + 1 >= ROUNDS_PER_GAME) {
143+
playCelebration()
144+
setMode('result')
145+
sendToPlatform('completion', '', { summary: `Animals game complete! Score: ${score}/${ROUNDS_PER_GAME}` })
146+
return
147+
}
148+
setRound(r => r + 1)
149+
setFeedback(null)
150+
setQuestion(generateQuestion(gameMode))
151+
}
152+
153+
const font = 'system-ui, -apple-system, sans-serif'
154+
const bigBtn = (onClick: () => void, children: React.ReactNode, color = '#3b82f6'): React.ReactNode => (
155+
<button onClick={onClick} style={{
156+
padding: '16px 28px', fontSize: '20px', fontWeight: 700, color: 'white',
157+
background: color, border: 'none', borderRadius: '16px', cursor: 'pointer',
158+
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', transition: 'transform 0.1s',
159+
minWidth: '80px',
160+
}}
161+
onMouseDown={e => (e.currentTarget.style.transform = 'scale(0.95)')}
162+
onMouseUp={e => (e.currentTarget.style.transform = 'scale(1)')}
163+
>
164+
{children}
165+
</button>
166+
)
167+
168+
// ─── Menu ────────────────────────────────────────────────────
169+
if (mode === 'menu') {
170+
return (
171+
<div style={{ padding: '24px 16px', maxWidth: '400px', margin: '0 auto', fontFamily: font, textAlign: 'center' }}>
172+
<div style={{ fontSize: '48px', marginBottom: '8px' }}>🐾</div>
173+
<div style={{ fontSize: '24px', fontWeight: 700, color: '#111827', marginBottom: '4px' }}>Animals Game</div>
174+
<div style={{ fontSize: '16px', color: '#6b7280', marginBottom: '24px' }}>Tap the right answer!</div>
175+
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
176+
{MODES_LIST.map(m => (
177+
<button key={m.id} onClick={() => startGame(m.id)} style={{
178+
display: 'flex', alignItems: 'center', gap: '16px', padding: '20px',
179+
background: 'white', border: '2px solid #e5e7eb', borderRadius: '16px',
180+
cursor: 'pointer', textAlign: 'left', fontSize: '16px', transition: 'all 0.15s',
181+
}}
182+
onMouseEnter={e => { e.currentTarget.style.borderColor = '#93c5fd'; e.currentTarget.style.background = '#eff6ff' }}
183+
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'white' }}
184+
>
185+
<span style={{ fontSize: '36px' }}>{m.emoji}</span>
186+
<div>
187+
<div style={{ fontWeight: 700, color: '#111827' }}>{m.label}</div>
188+
<div style={{ fontSize: '13px', color: '#6b7280' }}>{m.desc}</div>
189+
</div>
190+
</button>
191+
))}
192+
</div>
193+
</div>
194+
)
195+
}
196+
197+
// ─── Result ──────────────────────────────────────────────────
198+
if (mode === 'result') {
199+
const pct = Math.round((score / ROUNDS_PER_GAME) * 100)
200+
const emoji = pct === 100 ? '🏆' : pct >= 60 ? '🌟' : '💪'
201+
return (
202+
<div style={{ padding: '24px 16px', maxWidth: '400px', margin: '0 auto', fontFamily: font, textAlign: 'center' }}>
203+
<div style={{ fontSize: '64px', marginBottom: '8px' }}>{emoji}</div>
204+
<div style={{ fontSize: '28px', fontWeight: 700, color: '#111827' }}>
205+
{pct === 100 ? 'Perfect! You did it!' : pct >= 60 ? 'Great job!' : 'Good try!'}
206+
</div>
207+
<div style={{ fontSize: '48px', fontWeight: 700, color: '#2563eb', margin: '16px 0' }}>
208+
{score} <span style={{ fontSize: '24px', color: '#9ca3af' }}>/ {ROUNDS_PER_GAME}</span>
209+
</div>
210+
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', marginTop: '24px', flexWrap: 'wrap' }}>
211+
{bigBtn(() => startGame(gameMode), '🔄 Play Again')}
212+
{bigBtn(() => setMode('menu'), '🏠 Pick New Game', '#6b7280')}
213+
</div>
214+
</div>
215+
)
216+
}
217+
218+
// ─── Game round ──────────────────────────────────────────────
219+
if (!question) return null
220+
221+
return (
222+
<div style={{ padding: '20px 16px', maxWidth: '420px', margin: '0 auto', fontFamily: font }}>
223+
{/* Progress */}
224+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
225+
<span style={{ fontSize: '14px', color: '#6b7280' }}>
226+
{round + 1} / {ROUNDS_PER_GAME}
227+
</span>
228+
<span style={{ fontSize: '14px', fontWeight: 600, color: '#2563eb' }}>
229+
{score}
230+
</span>
231+
{streak >= 2 && (
232+
<span style={{ fontSize: '13px', color: '#f59e0b', fontWeight: 600 }}>
233+
🔥 {streak} in a row!
234+
</span>
235+
)}
236+
</div>
237+
238+
{/* Progress bar */}
239+
<div style={{ height: '8px', background: '#e5e7eb', borderRadius: '4px', marginBottom: '20px', overflow: 'hidden' }}>
240+
<div style={{ height: '100%', background: '#3b82f6', borderRadius: '4px', width: `${((round + (feedback ? 1 : 0)) / ROUNDS_PER_GAME) * 100}%`, transition: 'width 0.3s' }} />
241+
</div>
242+
243+
{/* Question prompt */}
244+
<div style={{ fontSize: '22px', fontWeight: 700, color: '#111827', textAlign: 'center', marginBottom: '16px' }}>
245+
{question.prompt}
246+
</div>
247+
248+
{/* Animal display */}
249+
<div style={{
250+
padding: '24px', borderRadius: '20px', background: 'white', border: '2px solid #e5e7eb',
251+
textAlign: 'center', marginBottom: '20px',
252+
minHeight: '140px', display: 'flex', alignItems: 'center', justifyContent: 'center',
253+
}}>
254+
<span style={{ fontSize: '96px' }}>{question.animal.emoji}</span>
255+
</div>
256+
257+
{/* Feedback */}
258+
{feedback && (
259+
<div style={{
260+
padding: '14px', borderRadius: '14px', marginBottom: '16px', textAlign: 'center',
261+
fontSize: '18px', fontWeight: 600,
262+
background: feedback.correct ? '#dcfce7' : '#fee2e2',
263+
color: feedback.correct ? '#166534' : '#991b1b',
264+
}}>
265+
{feedback.message}
266+
</div>
267+
)}
268+
269+
{/* Answer buttons */}
270+
{!feedback ? (
271+
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
272+
{question.options.map((opt, i) => {
273+
// For habitat mode, show emoji + label
274+
if (gameMode === 'habitat') {
275+
const habitatEmoji = HABITAT_EMOJIS[opt] || '🏠'
276+
return (
277+
<button key={i} onClick={() => handleAnswer(opt)} style={{
278+
minWidth: '90px', height: '90px', fontSize: '14px', fontWeight: 700,
279+
color: '#111827', background: 'white', border: '3px solid #d1d5db',
280+
borderRadius: '16px', cursor: 'pointer', transition: 'all 0.1s',
281+
boxShadow: '0 2px 8px rgba(0,0,0,0.08)', display: 'flex', flexDirection: 'column',
282+
alignItems: 'center', justifyContent: 'center', gap: '4px', padding: '8px',
283+
}}
284+
onMouseEnter={e => { e.currentTarget.style.borderColor = '#3b82f6'; e.currentTarget.style.background = '#eff6ff' }}
285+
onMouseLeave={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.background = 'white' }}
286+
onMouseDown={e => (e.currentTarget.style.transform = 'scale(0.9)')}
287+
onMouseUp={e => (e.currentTarget.style.transform = 'scale(1)')}
288+
>
289+
<span style={{ fontSize: '32px' }}>{habitatEmoji}</span>
290+
<span>{opt}</span>
291+
</button>
292+
)
293+
}
294+
295+
return (
296+
<button key={i} onClick={() => handleAnswer(opt)} style={{
297+
minWidth: '80px', height: '80px', fontSize: '18px', fontWeight: 700,
298+
color: '#111827', background: 'white', border: '3px solid #d1d5db',
299+
borderRadius: '16px', cursor: 'pointer', transition: 'all 0.1s',
300+
boxShadow: '0 2px 8px rgba(0,0,0,0.08)', padding: '8px 16px',
301+
display: 'flex', alignItems: 'center', justifyContent: 'center',
302+
}}
303+
onMouseEnter={e => { e.currentTarget.style.borderColor = '#3b82f6'; e.currentTarget.style.background = '#eff6ff' }}
304+
onMouseLeave={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.background = 'white' }}
305+
onMouseDown={e => (e.currentTarget.style.transform = 'scale(0.9)')}
306+
onMouseUp={e => (e.currentTarget.style.transform = 'scale(1)')}
307+
>
308+
{opt}
309+
</button>
310+
)
311+
})}
312+
</div>
313+
) : (
314+
<div style={{ textAlign: 'center' }}>
315+
{bigBtn(nextRound, round + 1 >= ROUNDS_PER_GAME ? '🎉 See Results!' : 'Next ➡️')}
316+
</div>
317+
)}
318+
</div>
319+
)
320+
}

apps/animals/src/main.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createRoot } from 'react-dom/client'
2+
import AnimalsGame from './AnimalsGame'
3+
createRoot(document.getElementById('root')!).render(<AnimalsGame />)

apps/animals/src/sounds.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
let audioCtx: AudioContext | null = null
2+
function getCtx(): AudioContext { if (!audioCtx) audioCtx = new AudioContext(); return audioCtx }
3+
function playTone(freq: number, dur: number, vol: number, type: OscillatorType = 'sine') {
4+
try { const ctx = getCtx(); const o = ctx.createOscillator(); const g = ctx.createGain(); o.type = type; o.frequency.value = freq; g.gain.value = vol; g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur); o.connect(g); g.connect(ctx.destination); o.start(); o.stop(ctx.currentTime + dur) } catch {}
5+
}
6+
export function playCorrect() { playTone(523, 0.1, 0.3); setTimeout(() => playTone(659, 0.12, 0.3), 80); setTimeout(() => playTone(784, 0.15, 0.25), 160) }
7+
export function playWrong() { playTone(250, 0.12, 0.2, 'triangle'); setTimeout(() => playTone(220, 0.15, 0.18, 'triangle'), 100) }
8+
export function playCelebration() { playTone(523, 0.15, 0.3); setTimeout(() => playTone(659, 0.15, 0.3), 120); setTimeout(() => playTone(784, 0.15, 0.3), 240); setTimeout(() => playTone(1047, 0.3, 0.25), 360) }
9+
export function playPop() { playTone(800, 0.06, 0.25, 'triangle') }

apps/animals/tsconfig.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"useDefineForClassFields": true,
5+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
6+
"module": "ESNext",
7+
"skipLibCheck": true,
8+
"moduleResolution": "bundler",
9+
"allowImportingTsExtensions": true,
10+
"isolatedModules": true,
11+
"moduleDetection": "force",
12+
"noEmit": true,
13+
"jsx": "react-jsx",
14+
"strict": true,
15+
"noUnusedLocals": false,
16+
"noUnusedParameters": false
17+
},
18+
"include": ["src"]
19+
}

apps/animals/vite.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'vite'
2+
import react from '@vitejs/plugin-react'
3+
4+
export default defineConfig({
5+
plugins: [react()],
6+
base: '/apps/animals/',
7+
})

apps/shapes/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Shapes</title></head>
4+
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
5+
</html>

0 commit comments

Comments
 (0)