Skip to content

Commit 96845f6

Browse files
author
Xian Zheng
committed
Merge feat/chatbox-integration: Chatbox src integration, K-2 apps, UX polish
2 parents 30d9a80 + 7df77b1 commit 96845f6

60 files changed

Lines changed: 2921 additions & 270 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/abc-letters/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>ABC Letters</title></head>
4+
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
5+
</html>

apps/abc-letters/package.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "@chatbridge/abc-letters",
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+
}
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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+
// ─── Letter data with pictures ──────────────────────────────────────
9+
const LETTER_DATA: { letter: string; word: string; emoji: string }[] = [
10+
{ letter: 'A', word: 'Apple', emoji: '🍎' },
11+
{ letter: 'B', word: 'Bear', emoji: '🐻' },
12+
{ letter: 'C', word: 'Cat', emoji: '🐱' },
13+
{ letter: 'D', word: 'Dog', emoji: '🐶' },
14+
{ letter: 'E', word: 'Elephant', emoji: '🐘' },
15+
{ letter: 'F', word: 'Fish', emoji: '🐟' },
16+
{ letter: 'G', word: 'Grapes', emoji: '🍇' },
17+
{ letter: 'H', word: 'House', emoji: '🏠' },
18+
{ letter: 'I', word: 'Ice cream', emoji: '🍦' },
19+
{ letter: 'J', word: 'Juice', emoji: '🧃' },
20+
{ letter: 'K', word: 'Kite', emoji: '🪁' },
21+
{ letter: 'L', word: 'Lion', emoji: '🦁' },
22+
{ letter: 'M', word: 'Moon', emoji: '🌙' },
23+
{ letter: 'N', word: 'Nest', emoji: '🪺' },
24+
{ letter: 'O', word: 'Orange', emoji: '🍊' },
25+
{ letter: 'P', word: 'Penguin', emoji: '🐧' },
26+
{ letter: 'Q', word: 'Queen', emoji: '👑' },
27+
{ letter: 'R', word: 'Rainbow', emoji: '🌈' },
28+
{ letter: 'S', word: 'Sun', emoji: '☀️' },
29+
{ letter: 'T', word: 'Tree', emoji: '🌳' },
30+
{ letter: 'U', word: 'Umbrella', emoji: '☂️' },
31+
{ letter: 'V', word: 'Violin', emoji: '🎻' },
32+
{ letter: 'W', word: 'Whale', emoji: '🐳' },
33+
{ letter: 'X', word: 'Xylophone', emoji: '🎵' },
34+
{ letter: 'Y', word: 'Yarn', emoji: '🧶' },
35+
{ letter: 'Z', word: 'Zebra', emoji: '🦓' },
36+
]
37+
38+
type Mode = 'menu' | 'find-letter' | 'what-starts' | 'uppercase-lowercase' | 'result'
39+
40+
interface Question {
41+
prompt: string
42+
display: string // big visual (emoji or letter)
43+
options: string[]
44+
correct: string
45+
}
46+
47+
function shuffle<T>(arr: T[]): T[] {
48+
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
49+
}
50+
51+
function pickRandom<T>(arr: T[], exclude?: T): T {
52+
const filtered = exclude ? arr.filter(x => x !== exclude) : arr
53+
return filtered[Math.floor(Math.random() * filtered.length)]
54+
}
55+
56+
function generateQuestion(mode: 'find-letter' | 'what-starts' | 'uppercase-lowercase'): Question {
57+
const item = LETTER_DATA[Math.floor(Math.random() * LETTER_DATA.length)]
58+
59+
if (mode === 'find-letter') {
60+
// Show emoji, ask "What letter does WORD start with?"
61+
const wrongA = pickRandom(LETTER_DATA, item).letter
62+
const wrongB = pickRandom(LETTER_DATA.filter(l => l.letter !== item.letter && l.letter !== wrongA)).letter
63+
return {
64+
prompt: `What letter does "${item.word}" start with?`,
65+
display: item.emoji,
66+
options: shuffle([item.letter, wrongA, wrongB]),
67+
correct: item.letter,
68+
}
69+
}
70+
71+
if (mode === 'what-starts') {
72+
// Show a letter, ask which picture starts with it
73+
const wrongA = pickRandom(LETTER_DATA, item)
74+
const wrongB = pickRandom(LETTER_DATA.filter(l => l.letter !== item.letter && l.letter !== wrongA.letter))
75+
return {
76+
prompt: `Which one starts with the letter ${item.letter}?`,
77+
display: item.letter,
78+
options: shuffle([`${item.emoji} ${item.word}`, `${wrongA.emoji} ${wrongA.word}`, `${wrongB.emoji} ${wrongB.word}`]),
79+
correct: `${item.emoji} ${item.word}`,
80+
}
81+
}
82+
83+
// uppercase-lowercase: match upper to lower
84+
const wrongA = pickRandom(LETTER_DATA, item).letter.toLowerCase()
85+
const wrongB = pickRandom(LETTER_DATA.filter(l => l.letter !== item.letter && l.letter.toLowerCase() !== wrongA)).letter.toLowerCase()
86+
return {
87+
prompt: `Which is the lowercase version of ${item.letter}?`,
88+
display: item.letter,
89+
options: shuffle([item.letter.toLowerCase(), wrongA, wrongB]),
90+
correct: item.letter.toLowerCase(),
91+
}
92+
}
93+
94+
const ROUNDS_PER_GAME = 5
95+
const GAME_MODES: { id: 'find-letter' | 'what-starts' | 'uppercase-lowercase'; label: string; emoji: string; desc: string }[] = [
96+
{ id: 'find-letter', label: 'Find the Letter', emoji: '🔤', desc: 'What letter does this start with?' },
97+
{ id: 'what-starts', label: 'Match the Picture', emoji: '🖼️', desc: 'Which picture starts with this letter?' },
98+
{ id: 'uppercase-lowercase', label: 'Big & Small Letters', emoji: '🔡', desc: 'Match uppercase to lowercase!' },
99+
]
100+
101+
export default function ABCLetters() {
102+
const [mode, setMode] = useState<Mode>('menu')
103+
const [gameMode, setGameMode] = useState<'find-letter' | 'what-starts' | 'uppercase-lowercase'>('find-letter')
104+
const [question, setQuestion] = useState<Question | null>(null)
105+
const [round, setRound] = useState(0)
106+
const [score, setScore] = useState(0)
107+
const [feedback, setFeedback] = useState<{ correct: boolean; message: string } | null>(null)
108+
const [streak, setStreak] = useState(0)
109+
110+
useEffect(() => { sendToPlatform('ui_ready', '', {}) }, [])
111+
112+
useEffect(() => {
113+
function handleMessage(event: MessageEvent) {
114+
const msg = event.data
115+
if (!msg || msg.type !== 'tool_invoke') return
116+
const { correlationId, tool } = msg
117+
if (tool === 'get_state') {
118+
sendToPlatform('tool_result', correlationId, { tool: 'get_state', mode, round, score, streak })
119+
} else if (tool === 'restore_state') {
120+
sendToPlatform('tool_result', correlationId, { tool: 'restore_state', message: 'Restored' })
121+
} else {
122+
sendToPlatform('error', correlationId, { message: `Unknown tool: ${tool}` })
123+
}
124+
}
125+
window.addEventListener('message', handleMessage)
126+
return () => window.removeEventListener('message', handleMessage)
127+
}, [mode, round, score, streak])
128+
129+
const startGame = useCallback((m: 'find-letter' | 'what-starts' | 'uppercase-lowercase') => {
130+
playPop()
131+
setGameMode(m)
132+
setRound(0)
133+
setScore(0)
134+
setStreak(0)
135+
setFeedback(null)
136+
setQuestion(generateQuestion(m))
137+
setMode(m)
138+
sendToPlatform('state_update', '', { type: 'game_start', mode: m })
139+
}, [])
140+
141+
function handleAnswer(answer: string) {
142+
if (!question || feedback) return
143+
const correct = answer === question.correct
144+
if (correct) {
145+
setScore(s => s + 1)
146+
setStreak(s => s + 1)
147+
playCorrect()
148+
const msgs = ['Awesome! 🎉', 'You know your letters! ⭐', 'Great job! 🌟', 'So smart! 🧠', 'Perfect! 🎸']
149+
setFeedback({ correct: true, message: msgs[Math.floor(Math.random() * msgs.length)] })
150+
} else {
151+
setStreak(0)
152+
playWrong()
153+
setFeedback({ correct: false, message: `It's "${question.correct}"! You'll get it next time! 💪` })
154+
}
155+
}
156+
157+
function nextRound() {
158+
if (round + 1 >= ROUNDS_PER_GAME) {
159+
playCelebration()
160+
setMode('result')
161+
sendToPlatform('completion', '', { summary: `ABC game complete! Score: ${score}/${ROUNDS_PER_GAME}` })
162+
return
163+
}
164+
setRound(r => r + 1)
165+
setFeedback(null)
166+
setQuestion(generateQuestion(gameMode))
167+
}
168+
169+
const font = 'system-ui, -apple-system, sans-serif'
170+
171+
// ─── Menu ────────────────────────────────────────────────────
172+
if (mode === 'menu') {
173+
return (
174+
<div style={{ padding: '24px 16px', maxWidth: '400px', margin: '0 auto', fontFamily: font, textAlign: 'center' }}>
175+
<div style={{ fontSize: '48px', marginBottom: '8px' }}>🔤</div>
176+
<div style={{ fontSize: '24px', fontWeight: 700, color: '#111827', marginBottom: '4px' }}>ABC Letters</div>
177+
<div style={{ fontSize: '16px', color: '#6b7280', marginBottom: '24px' }}>Learn your letters!</div>
178+
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
179+
{GAME_MODES.map(m => (
180+
<button key={m.id} onClick={() => startGame(m.id)} style={{
181+
display: 'flex', alignItems: 'center', gap: '16px', padding: '20px',
182+
background: 'white', border: '2px solid #e5e7eb', borderRadius: '16px',
183+
cursor: 'pointer', textAlign: 'left', fontSize: '16px', transition: 'all 0.15s',
184+
}}
185+
onMouseEnter={e => { e.currentTarget.style.borderColor = '#93c5fd'; e.currentTarget.style.background = '#eff6ff' }}
186+
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'white' }}
187+
>
188+
<span style={{ fontSize: '36px' }}>{m.emoji}</span>
189+
<div>
190+
<div style={{ fontWeight: 700, color: '#111827' }}>{m.label}</div>
191+
<div style={{ fontSize: '13px', color: '#6b7280' }}>{m.desc}</div>
192+
</div>
193+
</button>
194+
))}
195+
</div>
196+
</div>
197+
)
198+
}
199+
200+
// ─── Result ──────────────────────────────────────────────────
201+
if (mode === 'result') {
202+
const pct = Math.round((score / ROUNDS_PER_GAME) * 100)
203+
const emoji = pct === 100 ? '🏆' : pct >= 60 ? '🌟' : '💪'
204+
return (
205+
<div style={{ padding: '24px 16px', maxWidth: '400px', margin: '0 auto', fontFamily: font, textAlign: 'center' }}>
206+
<div style={{ fontSize: '64px', marginBottom: '8px' }}>{emoji}</div>
207+
<div style={{ fontSize: '28px', fontWeight: 700, color: '#111827' }}>
208+
{pct === 100 ? 'Perfect! Amazing!' : pct >= 60 ? 'Great job!' : 'Good try!'}
209+
</div>
210+
<div style={{ fontSize: '48px', fontWeight: 700, color: '#2563eb', margin: '16px 0' }}>
211+
{score} <span style={{ fontSize: '24px', color: '#9ca3af' }}>/ {ROUNDS_PER_GAME}</span>
212+
</div>
213+
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', marginTop: '24px', flexWrap: 'wrap' }}>
214+
<button onClick={() => startGame(gameMode)} style={{ padding: '16px 28px', fontSize: '20px', fontWeight: 700, color: 'white', background: '#3b82f6', border: 'none', borderRadius: '16px', cursor: 'pointer' }}>🔄 Play Again</button>
215+
<button onClick={() => setMode('menu')} style={{ padding: '16px 28px', fontSize: '20px', fontWeight: 700, color: 'white', background: '#6b7280', border: 'none', borderRadius: '16px', cursor: 'pointer' }}>🏠 Menu</button>
216+
</div>
217+
</div>
218+
)
219+
}
220+
221+
// ─── Game round ──────────────────────────────────────────────
222+
if (!question) return null
223+
224+
return (
225+
<div style={{ padding: '20px 16px', maxWidth: '420px', margin: '0 auto', fontFamily: font }}>
226+
{/* Progress */}
227+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
228+
<span style={{ fontSize: '14px', color: '#6b7280' }}>{round + 1} / {ROUNDS_PER_GAME}</span>
229+
<span style={{ fontSize: '14px', fontWeight: 600, color: '#2563eb' }}>{score}</span>
230+
{streak >= 2 && <span style={{ fontSize: '13px', color: '#f59e0b', fontWeight: 600 }}>🔥 {streak} in a row!</span>}
231+
</div>
232+
233+
{/* Progress bar */}
234+
<div style={{ height: '8px', background: '#e5e7eb', borderRadius: '4px', marginBottom: '20px', overflow: 'hidden' }}>
235+
<div style={{ height: '100%', background: '#8b5cf6', borderRadius: '4px', width: `${((round + (feedback ? 1 : 0)) / ROUNDS_PER_GAME) * 100}%`, transition: 'width 0.3s' }} />
236+
</div>
237+
238+
{/* Question */}
239+
<div style={{ fontSize: '22px', fontWeight: 700, color: '#111827', textAlign: 'center', marginBottom: '16px' }}>
240+
{question.prompt}
241+
</div>
242+
243+
{/* Big display */}
244+
<div style={{
245+
padding: '24px', borderRadius: '20px', background: 'white', border: '2px solid #e5e7eb',
246+
textAlign: 'center', fontSize: question.display.length <= 2 ? '80px' : '64px',
247+
marginBottom: '20px', minHeight: '120px', display: 'flex', alignItems: 'center', justifyContent: 'center',
248+
fontWeight: 700,
249+
}}>
250+
{question.display}
251+
</div>
252+
253+
{/* Feedback */}
254+
{feedback && (
255+
<div style={{
256+
padding: '14px', borderRadius: '14px', marginBottom: '16px', textAlign: 'center',
257+
fontSize: '18px', fontWeight: 600,
258+
background: feedback.correct ? '#dcfce7' : '#fee2e2',
259+
color: feedback.correct ? '#166534' : '#991b1b',
260+
}}>
261+
{feedback.message}
262+
</div>
263+
)}
264+
265+
{/* Answer buttons */}
266+
{!feedback ? (
267+
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
268+
{question.options.map((opt, i) => (
269+
<button key={i} onClick={() => handleAnswer(opt)} style={{
270+
padding: '18px 24px', fontSize: '22px', fontWeight: 700,
271+
color: '#111827', background: 'white', border: '3px solid #d1d5db',
272+
borderRadius: '16px', cursor: 'pointer', transition: 'all 0.1s',
273+
boxShadow: '0 2px 8px rgba(0,0,0,0.08)', textAlign: 'center',
274+
}}
275+
onMouseEnter={e => { e.currentTarget.style.borderColor = '#8b5cf6'; e.currentTarget.style.background = '#f5f3ff' }}
276+
onMouseLeave={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.background = 'white' }}
277+
onMouseDown={e => (e.currentTarget.style.transform = 'scale(0.95)')}
278+
onMouseUp={e => (e.currentTarget.style.transform = 'scale(1)')}
279+
>
280+
{opt}
281+
</button>
282+
))}
283+
</div>
284+
) : (
285+
<div style={{ textAlign: 'center' }}>
286+
<button onClick={nextRound} style={{ padding: '16px 28px', fontSize: '20px', fontWeight: 700, color: 'white', background: '#8b5cf6', border: 'none', borderRadius: '16px', cursor: 'pointer' }}>
287+
{round + 1 >= ROUNDS_PER_GAME ? '🎉 See Results!' : 'Next ➡️'}
288+
</button>
289+
</div>
290+
)}
291+
</div>
292+
)
293+
}

apps/abc-letters/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 ABCLetters from './ABCLetters'
3+
createRoot(document.getElementById('root')!).render(<ABCLetters />)

apps/abc-letters/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/abc-letters/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/abc-letters/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/abc-letters/',
7+
})

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+
}

0 commit comments

Comments
 (0)