Compare commits
29 Commits
abacus-rea
...
abacus-rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd9f688a32 | ||
|
|
0b9bfed12d | ||
|
|
af7725622e | ||
|
|
18af9730ff | ||
|
|
beff64652c | ||
|
|
c4d6691715 | ||
|
|
dcefa74902 | ||
|
|
8973241297 | ||
|
|
7f8c90acea | ||
|
|
111c0ced71 | ||
|
|
876ace50ec | ||
|
|
d58053fad3 | ||
|
|
899fc6975f | ||
|
|
c95be1df6d | ||
|
|
a387b030fa | ||
|
|
90b9ffa0d8 | ||
|
|
3fa314aaa5 | ||
|
|
0c46f3a7ba | ||
|
|
1fe12c4837 | ||
|
|
b7e7c4beff | ||
|
|
423ba55350 | ||
|
|
885fc725dc | ||
|
|
0311b0fe03 | ||
|
|
cea5fadbe4 | ||
|
|
9191b12493 | ||
|
|
3dcff2ff88 | ||
|
|
301e65dfa6 | ||
|
|
00bfcbcdee | ||
|
|
0a4bf1765c |
@@ -151,7 +151,20 @@
|
||||
"Bash(git pull:*)",
|
||||
"WebFetch(domain:antialias.github.io)",
|
||||
"Bash(open http://localhost:3006/games/matching)",
|
||||
"Bash(gh api:*)"
|
||||
"Bash(gh api:*)",
|
||||
"Bash(npx playwright test:*)",
|
||||
"Bash(open http://localhost:3001/games/matching)",
|
||||
"Bash(open http://localhost:3001/games/memory-quiz)",
|
||||
"Bash(open http://localhost:3002)",
|
||||
"Bash(open \"data:text/html,<script>console.log(''Current localStorage:'', localStorage.getItem(''soroban-abacus-display-config'')); localStorage.setItem(''soroban-abacus-display-config'', JSON.stringify({colorScheme: ''place-value'', beadShape: ''diamond'', hideInactiveBeads: false, coloredNumerals: false, scaleFactor: 1.0, soundEnabled: true, soundVolume: 0.8})); console.log(''After test save:'', localStorage.getItem(''soroban-abacus-display-config''));</script><h1>Check browser console</h1>\")",
|
||||
"Bash(npx playwright:*)",
|
||||
"Bash(open \"data:text/html,<script>\nconsole.log(''Current localStorage:'', localStorage.getItem(''soroban-abacus-display-config'')); \nlocalStorage.setItem(''soroban-abacus-display-config'', JSON.stringify({\n colorScheme: ''place-value'', \n beadShape: ''diamond'', \n hideInactiveBeads: false, \n coloredNumerals: false, \n scaleFactor: 1.0, \n soundEnabled: true, \n soundVolume: 0.8\n})); \nconsole.log(''After test save:'', localStorage.getItem(''soroban-abacus-display-config''));\n</script><h1>Check browser console</h1>\")",
|
||||
"Bash(xargs sed:*)",
|
||||
"Bash(open http://localhost:3003/games/matching)",
|
||||
"Bash(open http://localhost:3003/arcade/matching)",
|
||||
"Bash(open http://localhost:3000)",
|
||||
"Bash(open http://localhost:3003/games/memory-quiz)",
|
||||
"Bash(open http://localhost:3001)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
7
.github/workflows/publish-abacus-react.yml
vendored
7
.github/workflows/publish-abacus-react.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8.0.0
|
||||
version: 8.15.6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
@@ -52,7 +52,10 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: |
|
||||
echo "Installing dependencies..."
|
||||
pnpm install
|
||||
echo "Dependencies installed successfully"
|
||||
|
||||
- name: Build abacus-react package
|
||||
run: pnpm --filter @soroban/abacus-react build
|
||||
|
||||
111
apps/web/e2e/mini-nav-persistence.spec.ts
Normal file
111
apps/web/e2e/mini-nav-persistence.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Mini Navigation Game Name Persistence', () => {
|
||||
test('should not show game name when navigating back to games page from a specific game', async ({ page }) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
// Start at home page
|
||||
await page.goto(baseURL)
|
||||
|
||||
// Navigate to games page - should not have game name in mini nav
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// Check that mini nav doesn't show game name initially
|
||||
const initialGameName = page.locator('[data-testid="mini-nav-game-name"]')
|
||||
await expect(initialGameName).not.toBeVisible()
|
||||
|
||||
// Navigate to Memory Pairs game
|
||||
await page.click('a[href="/games/matching"]')
|
||||
await page.waitForURL('/games/matching')
|
||||
|
||||
// Verify game name appears in mini nav
|
||||
const memoryPairsName = page.locator('text=🧩 Memory Pairs')
|
||||
await expect(memoryPairsName).toBeVisible()
|
||||
|
||||
// Navigate back to games page using mini nav
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// BUG: Game name should disappear but it persists
|
||||
// This test should FAIL initially, demonstrating the bug
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Also test with Memory Lightning game
|
||||
await page.click('a[href="/games/memory-quiz"]')
|
||||
await page.waitForURL('/games/memory-quiz')
|
||||
|
||||
// Verify Memory Lightning name appears
|
||||
const memoryLightningName = page.locator('text=🧠 Memory Lightning')
|
||||
await expect(memoryLightningName).toBeVisible()
|
||||
|
||||
// Navigate back to games page
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
|
||||
// Game name should disappear
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should show correct game name when switching between different games', async ({ page }) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
// Start at Memory Pairs
|
||||
await page.goto(`${baseURL}/games/matching`)
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).toBeVisible()
|
||||
|
||||
// Switch to Memory Lightning
|
||||
await page.click('a[href="/games/memory-quiz"]')
|
||||
await page.waitForURL('/games/memory-quiz')
|
||||
|
||||
// Should show Memory Lightning and NOT Memory Pairs
|
||||
await expect(page.locator('text=🧠 Memory Lightning')).toBeVisible()
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).not.toBeVisible()
|
||||
|
||||
// Switch back to Memory Pairs
|
||||
await page.click('a[href="/games/matching"]')
|
||||
await page.waitForURL('/games/matching')
|
||||
|
||||
// Should show Memory Pairs and NOT Memory Lightning
|
||||
await expect(page.locator('text=🧩 Memory Pairs')).toBeVisible()
|
||||
await expect(page.locator('text=🧠 Memory Lightning')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not persist game name when navigating through intermediate pages', async ({ page }) => {
|
||||
// Override baseURL for this test to match running dev server
|
||||
const baseURL = 'http://localhost:3000'
|
||||
|
||||
// Start at Memory Pairs game - should show game name
|
||||
await page.goto(`${baseURL}/games/matching`)
|
||||
const memoryPairsName = page.locator('text=🧩 Memory Pairs')
|
||||
await expect(memoryPairsName).toBeVisible()
|
||||
|
||||
// Navigate to Guide page - game name should disappear
|
||||
await page.click('a[href="/guide"]')
|
||||
await page.waitForURL('/guide')
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Navigate to Games page - game name should still be gone
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
|
||||
// Test another path: Game -> Create -> Games
|
||||
await page.goto(`${baseURL}/games/memory-quiz`)
|
||||
const memoryLightningName = page.locator('text=🧠 Memory Lightning')
|
||||
await expect(memoryLightningName).toBeVisible()
|
||||
|
||||
// Navigate to Create page
|
||||
await page.click('a[href="/create"]')
|
||||
await page.waitForURL('/create')
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
|
||||
// Navigate to Games page - should not show any game name
|
||||
await page.click('a[href="/games"]')
|
||||
await page.waitForURL('/games')
|
||||
await expect(memoryLightningName).not.toBeVisible()
|
||||
await expect(memoryPairsName).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
73
apps/web/e2e/nav-slot.spec.ts
Normal file
73
apps/web/e2e/nav-slot.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Game navigation slots', () => {
|
||||
test('should show Memory Pairs game name in nav when navigating to matching game', async ({ page }) => {
|
||||
await page.goto('/games/matching')
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for the game name in the navigation
|
||||
const gameNav = page.locator('[data-testid="nav-slot"], h1:has-text("Memory Pairs")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Pairs')
|
||||
})
|
||||
|
||||
test('should show Memory Lightning game name in nav when navigating to memory quiz', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Look for the game name in the navigation
|
||||
const gameNav = page.locator('[data-testid="nav-slot"], h1:has-text("Memory Lightning")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Lightning')
|
||||
})
|
||||
|
||||
test('should maintain game name in nav after page reload', async ({ page }) => {
|
||||
// Navigate to matching game
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Verify game name appears
|
||||
const gameNav = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(gameNav).toBeVisible()
|
||||
|
||||
// Reload the page
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Verify game name still appears after reload
|
||||
await expect(gameNav).toBeVisible()
|
||||
await expect(gameNav).toContainText('Memory Pairs')
|
||||
})
|
||||
|
||||
test('should show different game names when navigating between games', async ({ page }) => {
|
||||
// Start with matching game
|
||||
await page.goto('/games/matching')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const matchingNav = page.locator('h1:has-text("Memory Pairs")')
|
||||
await expect(matchingNav).toBeVisible()
|
||||
|
||||
// Navigate to memory quiz
|
||||
await page.goto('/games/memory-quiz')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const quizNav = page.locator('h1:has-text("Memory Lightning")')
|
||||
await expect(quizNav).toBeVisible()
|
||||
|
||||
// Verify the matching game name is gone
|
||||
await expect(matchingNav).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should not show game name on non-game pages', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Should not see any game names on the home page
|
||||
const gameNavs = page.locator('h1:has-text("Memory Pairs"), h1:has-text("Memory Lightning")')
|
||||
await expect(gameNavs).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
119
apps/web/e2e/sound-settings-persistence.spec.ts
Normal file
119
apps/web/e2e/sound-settings-persistence.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Sound Settings Persistence', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear localStorage before each test
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => localStorage.clear())
|
||||
})
|
||||
|
||||
test('should persist sound enabled setting to localStorage', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Open style dropdown
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
// Find and toggle the sound switch (should be off by default)
|
||||
const soundSwitch = page.locator('[role="switch"]').filter({ hasText: /sound/i }).or(
|
||||
page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })
|
||||
).or(
|
||||
page.getByLabel(/sound/i)
|
||||
).or(
|
||||
page.locator('button').filter({ hasText: /sound/i })
|
||||
).first()
|
||||
|
||||
await soundSwitch.click()
|
||||
|
||||
// Check localStorage was updated
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
|
||||
// Reload page and verify setting persists
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
const soundSwitchAfterReload = page.locator('[role="switch"]').filter({ hasText: /sound/i }).or(
|
||||
page.locator('input[type="checkbox"]').filter({ hasText: /sound/i })
|
||||
).or(
|
||||
page.getByLabel(/sound/i)
|
||||
).or(
|
||||
page.locator('button').filter({ hasText: /sound/i })
|
||||
).first()
|
||||
|
||||
await expect(soundSwitchAfterReload).toBeChecked()
|
||||
})
|
||||
|
||||
test('should persist sound volume setting to localStorage', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Open style dropdown
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
// Find volume slider
|
||||
const volumeSlider = page.locator('input[type="range"]').or(
|
||||
page.locator('[role="slider"]')
|
||||
).first()
|
||||
|
||||
// Set volume to a specific value (e.g., 0.6)
|
||||
await volumeSlider.fill('60') // Assuming 0-100 range
|
||||
|
||||
// Check localStorage was updated
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundVolume).toBeCloseTo(0.6, 1)
|
||||
|
||||
// Reload page and verify setting persists
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: /style/i }).click()
|
||||
|
||||
const volumeSliderAfterReload = page.locator('input[type="range"]').or(
|
||||
page.locator('[role="slider"]')
|
||||
).first()
|
||||
|
||||
const volumeValue = await volumeSliderAfterReload.inputValue()
|
||||
expect(parseFloat(volumeValue)).toBeCloseTo(60, 0) // Allow for some variance
|
||||
})
|
||||
|
||||
test('should load default sound settings when localStorage is empty', async ({ page }) => {
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Check that default settings are loaded
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
// Should have default values: soundEnabled: true, soundVolume: 0.8
|
||||
expect(storedConfig).toBeTruthy()
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
expect(storedConfig.soundVolume).toBe(0.8)
|
||||
})
|
||||
|
||||
test('should handle invalid localStorage data gracefully', async ({ page }) => {
|
||||
// Set invalid localStorage data
|
||||
await page.goto('/')
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('soroban-abacus-display-config', 'invalid-json')
|
||||
})
|
||||
|
||||
await page.goto('/games/memory-quiz')
|
||||
|
||||
// Should fall back to defaults and not crash
|
||||
const storedConfig = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('soroban-abacus-display-config')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
expect(storedConfig.soundEnabled).toBe(true)
|
||||
expect(storedConfig.soundVolume).toBe(0.8)
|
||||
})
|
||||
})
|
||||
@@ -50,19 +50,20 @@
|
||||
"lucide-react": "^0.294.0",
|
||||
"next": "^14.2.32",
|
||||
"python-bridge": "^1.1.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable-layout": "^0.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@storybook/addon-docs": "^9.1.7",
|
||||
"@storybook/addon-onboarding": "^9.1.7",
|
||||
"@storybook/nextjs": "^9.1.7",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"concurrently": "^8.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
|
||||
27
apps/web/playwright.config.ts
Normal file
27
apps/web/playwright.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3002',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:3002',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
3
apps/web/src/app/@nav/create/page.tsx
Normal file
3
apps/web/src/app/@nav/create/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function CreateNav() {
|
||||
return null
|
||||
}
|
||||
3
apps/web/src/app/@nav/default.tsx
Normal file
3
apps/web/src/app/@nav/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function DefaultNav() {
|
||||
return null // No navigation content for routes without specific @nav slots
|
||||
}
|
||||
14
apps/web/src/app/@nav/games/matching/page.tsx
Normal file
14
apps/web/src/app/@nav/games/matching/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function MatchingNav() {
|
||||
return (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧩 Memory Pairs
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
14
apps/web/src/app/@nav/games/memory-quiz/page.tsx
Normal file
14
apps/web/src/app/@nav/games/memory-quiz/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function MemoryQuizNav() {
|
||||
return (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧠 Memory Lightning
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
3
apps/web/src/app/@nav/games/page.tsx
Normal file
3
apps/web/src/app/@nav/games/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function GamesNav() {
|
||||
return null
|
||||
}
|
||||
3
apps/web/src/app/@nav/guide/page.tsx
Normal file
3
apps/web/src/app/@nav/guide/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function GuideNav() {
|
||||
return null
|
||||
}
|
||||
3
apps/web/src/app/@nav/page.tsx
Normal file
3
apps/web/src/app/@nav/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function HomeNav() {
|
||||
return null
|
||||
}
|
||||
62
apps/web/src/app/__tests__/layout.nav.test.tsx
Normal file
62
apps/web/src/app/__tests__/layout.nav.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import RootLayout from '../layout'
|
||||
|
||||
// Mock AppNavBar to verify it receives the nav prop
|
||||
const MockAppNavBar = ({ navSlot }: { navSlot?: React.ReactNode }) => (
|
||||
<div data-testid="app-nav-bar">
|
||||
{navSlot && <div data-testid="nav-slot-content">{navSlot}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
jest.mock('../../components/AppNavBar', () => ({
|
||||
AppNavBar: MockAppNavBar,
|
||||
}))
|
||||
|
||||
// Mock all context providers
|
||||
jest.mock('../../contexts/AbacusDisplayContext', () => ({
|
||||
AbacusDisplayProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('../../contexts/UserProfileContext', () => ({
|
||||
UserProfileProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('../../contexts/GameModeContext', () => ({
|
||||
GameModeProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('../../contexts/FullscreenContext', () => ({
|
||||
FullscreenProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
describe('RootLayout with nav slot', () => {
|
||||
it('passes nav slot to AppNavBar', () => {
|
||||
const navContent = <div>Memory Lightning</div>
|
||||
const pageContent = <div>Page content</div>
|
||||
|
||||
render(
|
||||
<RootLayout nav={navContent}>
|
||||
{pageContent}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('app-nav-bar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('nav-slot-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('Memory Lightning')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('works without nav slot', () => {
|
||||
const pageContent = <div>Page content</div>
|
||||
|
||||
render(
|
||||
<RootLayout nav={null}>
|
||||
{pageContent}
|
||||
</RootLayout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('app-nav-bar')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-slot-content')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Page content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import { ConfigurationFormWithoutGenerate } from '@/components/ConfigurationForm
|
||||
import { LivePreview } from '@/components/LivePreview'
|
||||
import { GenerationProgress } from '@/components/GenerationProgress'
|
||||
import { StyleControls } from '@/components/StyleControls'
|
||||
import { useAbacusConfig } from '@/contexts/AbacusDisplayContext'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
|
||||
// Complete, validated configuration ready for generation
|
||||
export interface FlashcardConfig {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useAbacusConfig } from '../../../../contexts/AbacusDisplayContext'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useUserProfile } from '../../../../contexts/UserProfileContext'
|
||||
import type { GameCardProps } from '../context/types'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { MemoryGrid } from './MemoryGrid'
|
||||
import { PlayerStatusBar } from './PlayerStatusBar'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
export function GamePhase() {
|
||||
@@ -22,170 +23,84 @@ export function GamePhase() {
|
||||
flexDirection: 'column'
|
||||
})}>
|
||||
|
||||
{/* Game Header - Compact on mobile */}
|
||||
{/* Minimal Game Header */}
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(118, 75, 162, 0.1))',
|
||||
padding: { base: '8px 12px', sm: '12px 16px', md: '16px 20px' },
|
||||
borderRadius: { base: '8px', md: '12px' },
|
||||
marginBottom: { base: '8px', sm: '12px', md: '16px' },
|
||||
border: '1px solid rgba(102, 126, 234, 0.2)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: { base: '8px 12px', sm: '10px 16px', md: '12px 20px' },
|
||||
background: 'linear-gradient(135deg, rgba(102, 126, 234, 0.08), rgba(118, 75, 162, 0.08))',
|
||||
borderRadius: '12px',
|
||||
marginBottom: { base: '12px', sm: '16px', md: '20px' },
|
||||
border: '1px solid rgba(102, 126, 234, 0.15)',
|
||||
flexShrink: 0
|
||||
})}>
|
||||
|
||||
{/* Game Mode Indicator - Compact */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: { base: '8px', sm: '12px', md: '16px' }
|
||||
gap: '8px',
|
||||
fontSize: { base: '14px', sm: '15px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
|
||||
{/* Game Type & Difficulty Info - Hidden on mobile */}
|
||||
<div className={css({
|
||||
display: { base: 'none', sm: 'flex' },
|
||||
alignItems: 'center',
|
||||
gap: { base: '8px', md: '16px' }
|
||||
})}>
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
})}>
|
||||
<span className={css({ fontSize: '20px' })}>
|
||||
{state.gameType === 'abacus-numeral' ? '🧮🔢' : '🤝➕'}
|
||||
</span>
|
||||
<span className={css({ fontWeight: 'bold', color: 'gray.700' })}>
|
||||
{state.gameType === 'abacus-numeral' ? 'Abacus-Numeral' : 'Complement Pairs'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
})}>
|
||||
<span className={css({ fontSize: '20px' })}>
|
||||
{state.difficulty === 6 ? '🌱' : state.difficulty === 8 ? '⚡' : state.difficulty === 12 ? '🔥' : '💀'}
|
||||
</span>
|
||||
<span className={css({ fontWeight: 'bold', color: 'gray.700' })}>
|
||||
{state.difficulty} pairs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{state.gameMode === 'multiplayer' && (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
})}>
|
||||
<span className={css({ fontSize: '20px' })}>⚔️</span>
|
||||
<span className={css({ fontWeight: 'bold', color: 'gray.700' })}>
|
||||
{activePlayers.length} Players
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Game Controls */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
})}>
|
||||
|
||||
{/* Restart Button */}
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ffeaa7, #fab1a0)',
|
||||
color: '#2d3436',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '10px 16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 8px rgba(255, 234, 167, 0.4)',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 12px rgba(255, 234, 167, 0.6)',
|
||||
background: 'linear-gradient(135deg, #fdcb6e, #e17055)'
|
||||
}
|
||||
})}
|
||||
onClick={resetGame}
|
||||
>
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
})}>
|
||||
<span>🔄</span>
|
||||
<span>New Game</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Timer (if multiplayer mode) */}
|
||||
{state.gameMode === 'multiplayer' && (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
})}>
|
||||
<span className={css({ fontSize: '16px' })}>⏰</span>
|
||||
<span className={css({ fontWeight: 'bold', color: 'gray.700' })}>
|
||||
{state.turnTimer}s per turn
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={css({ fontSize: { base: '16px', sm: '18px' } })}>
|
||||
{state.gameType === 'abacus-numeral' ? '🧮' : '🤝'}
|
||||
</span>
|
||||
<span className={css({ display: { base: 'none', sm: 'inline' } })}>
|
||||
{state.gameType === 'abacus-numeral' ? 'Abacus Match' : 'Complement Pairs'}
|
||||
</span>
|
||||
{state.gameMode === 'multiplayer' && (
|
||||
<>
|
||||
<span className={css({ color: 'gray.400' })}>•</span>
|
||||
<span>⚔️ {activePlayers.length}P</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Player Indicator (Multiplayer Mode) - Compact on mobile */}
|
||||
{state.gameMode === 'multiplayer' && currentPlayerData && (
|
||||
<div className={css({
|
||||
marginTop: { base: '8px', md: '12px' },
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<div className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '6px', sm: '8px', md: '12px' },
|
||||
padding: { base: '6px 12px', sm: '8px 16px', md: '12px 24px' },
|
||||
background: `linear-gradient(135deg, ${currentPlayerData.color}, ${currentPlayerData.color}dd)`,
|
||||
color: 'white',
|
||||
borderRadius: { base: '12px', md: '20px' },
|
||||
fontSize: { base: '12px', sm: '14px', md: '16px' },
|
||||
{/* Game Controls */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
})}>
|
||||
{/* New Game Button */}
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ffeaa7, #fab1a0)',
|
||||
color: '#2d3436',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
padding: { base: '8px 12px', sm: '10px 16px' },
|
||||
fontSize: { base: '13px', sm: '14px' },
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)'
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 6px rgba(255, 234, 167, 0.3)',
|
||||
_hover: {
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 3px 8px rgba(255, 234, 167, 0.5)',
|
||||
background: 'linear-gradient(135deg, #fdcb6e, #e17055)'
|
||||
}
|
||||
})}
|
||||
onClick={resetGame}
|
||||
>
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px'
|
||||
})}>
|
||||
<span className={css({ fontSize: { base: '20px', sm: '28px', md: '36px' } })}>
|
||||
{currentPlayerData.emoji}
|
||||
</span>
|
||||
<span className={css({ display: { base: 'none', sm: 'inline' } })}>{currentPlayerData.name}'s Turn</span>
|
||||
<span className={css({ display: { base: 'inline', sm: 'none' } })}>Turn</span>
|
||||
<span className={css({ fontSize: { base: '16px', sm: '20px', md: '24px' } })}>
|
||||
🎯
|
||||
</span>
|
||||
<span>🔄</span>
|
||||
<span>New Game</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Status Bar */}
|
||||
<PlayerStatusBar />
|
||||
|
||||
{/* Memory Grid - The main game area */}
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
@@ -197,39 +112,31 @@ export function GamePhase() {
|
||||
<MemoryGrid />
|
||||
</div>
|
||||
|
||||
{/* Helpful Instructions - Hidden on mobile */}
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
marginTop: { base: '8px', md: '16px' },
|
||||
padding: { base: '8px', md: '12px' },
|
||||
background: 'rgba(248, 250, 252, 0.8)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(226, 232, 240, 0.8)',
|
||||
display: { base: 'none', md: 'block' },
|
||||
flexShrink: 0
|
||||
})}>
|
||||
<p className={css({
|
||||
fontSize: '14px',
|
||||
color: 'gray.600',
|
||||
margin: 0,
|
||||
lineHeight: '1.4'
|
||||
{/* Quick Tip - Only show when game is starting and on larger screens */}
|
||||
{state.moves === 0 && (
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
marginTop: '12px',
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(248, 250, 252, 0.7)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(226, 232, 240, 0.6)',
|
||||
display: { base: 'none', lg: 'block' },
|
||||
flexShrink: 0
|
||||
})}>
|
||||
{state.gameType === 'abacus-numeral'
|
||||
? 'Match abacus representations with their numerical values! Look for patterns and remember card positions.'
|
||||
: 'Find pairs of numbers that add up to 5 or 10! These are called "complement pairs" or "number friends".'
|
||||
}
|
||||
</p>
|
||||
|
||||
{state.gameMode === 'multiplayer' && (
|
||||
<p className={css({
|
||||
fontSize: '12px',
|
||||
color: 'gray.500',
|
||||
margin: '6px 0 0 0'
|
||||
fontSize: '13px',
|
||||
color: 'gray.600',
|
||||
margin: 0,
|
||||
fontWeight: 'medium'
|
||||
})}>
|
||||
Take turns finding matches. The player with the most pairs wins!
|
||||
💡 {state.gameType === 'abacus-numeral'
|
||||
? 'Match abacus beads with numbers'
|
||||
: 'Find pairs that add to 5 or 10'
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { useUserProfile } from '../../../../contexts/UserProfileContext'
|
||||
import { GameCard } from './GameCard'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import { getGridConfiguration } from '../utils/cardGeneration'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
@@ -81,14 +79,12 @@ function useGridDimensions(gridConfig: any, totalCards: number) {
|
||||
|
||||
export function MemoryGrid() {
|
||||
const { state, flipCard } = useMemoryPairs()
|
||||
const { profile, updatePlayerEmoji } = useUserProfile()
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<{ player: 1 | 2 } | null>(null)
|
||||
|
||||
if (!state.gameCards.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const gridConfig = getGridConfiguration(state.difficulty)
|
||||
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
|
||||
const gridDimensions = useGridDimensions(gridConfig, state.gameCards.length)
|
||||
|
||||
|
||||
@@ -96,16 +92,6 @@ export function MemoryGrid() {
|
||||
flipCard(cardId)
|
||||
}
|
||||
|
||||
const handlePlayerClick = (player: 1 | 2) => {
|
||||
setShowEmojiPicker({ player })
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
if (showEmojiPicker) {
|
||||
updatePlayerEmoji(showEmojiPicker.player, emoji)
|
||||
setShowEmojiPicker(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css({
|
||||
@@ -116,164 +102,35 @@ export function MemoryGrid() {
|
||||
gap: { base: '12px', sm: '16px', md: '20px' }
|
||||
})}>
|
||||
|
||||
{/* Game Info Header */}
|
||||
{/* Compact Game Progress */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
padding: { base: '12px 16px', sm: '14px 20px', md: '16px 24px' },
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.9), rgba(248,250,252,0.9))',
|
||||
borderRadius: '16px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
border: '1px solid rgba(255,255,255,0.8)'
|
||||
gap: { base: '12px', sm: '16px', md: '24px' },
|
||||
padding: { base: '8px 12px', sm: '10px 16px', md: '12px 20px' },
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.95), rgba(248,250,252,0.95))',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
border: '1px solid rgba(255,255,255,0.9)',
|
||||
fontSize: { base: '14px', sm: '15px', md: '16px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700'
|
||||
})}>
|
||||
|
||||
<div className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: 'repeat(3, 1fr)', sm: 'repeat(3, auto)' },
|
||||
gap: { base: '8px', sm: '12px', md: '20px' },
|
||||
justifyContent: { base: 'stretch', sm: 'center' },
|
||||
width: { base: '100%', sm: 'auto' }
|
||||
})}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: { base: '18px', sm: '20px', md: '24px' }, fontWeight: 'bold', color: 'blue.600' })}>
|
||||
{state.matchedPairs}
|
||||
</div>
|
||||
<div className={css({ fontSize: { base: '10px', sm: '11px', md: '12px' }, color: 'gray.600' })}>
|
||||
Matched
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: { base: '18px', sm: '20px', md: '24px' }, fontWeight: 'bold', color: 'purple.600' })}>
|
||||
{state.moves}
|
||||
</div>
|
||||
<div className={css({ fontSize: { base: '10px', sm: '11px', md: '12px' }, color: 'gray.600' })}>
|
||||
Moves
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<div className={css({ fontSize: { base: '18px', sm: '20px', md: '24px' }, fontWeight: 'bold', color: 'green.600' })}>
|
||||
{state.totalPairs}
|
||||
</div>
|
||||
<div className={css({ fontSize: { base: '10px', sm: '11px', md: '12px' }, color: 'gray.600' })}>
|
||||
Total Pairs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multiplayer Scores */}
|
||||
{state.gameMode === 'multiplayer' && (
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '24px' })}>
|
||||
<button
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '16px',
|
||||
background: state.currentPlayer === 1 ? 'blue.100' : 'gray.100',
|
||||
border: '3px solid',
|
||||
borderColor: state.currentPlayer === 1 ? 'blue.400' : 'gray.300',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}
|
||||
})}
|
||||
onClick={() => handlePlayerClick(1)}
|
||||
>
|
||||
<div className={css({
|
||||
fontSize: '40px',
|
||||
marginBottom: '4px',
|
||||
transition: 'transform 0.2s ease',
|
||||
_hover: { transform: 'scale(1.1)' }
|
||||
})}>
|
||||
{profile.player1Emoji}
|
||||
</div>
|
||||
<div className={css({ fontSize: '28px', fontWeight: 'bold', color: 'blue.600' })}>
|
||||
{state.scores[1] || 0}
|
||||
</div>
|
||||
<div className={css({ fontSize: '12px', color: 'gray.600', marginTop: '4px' })}>
|
||||
Click to change character
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className={css({
|
||||
fontSize: '24px',
|
||||
color: 'gray.500',
|
||||
fontWeight: 'bold'
|
||||
})}>
|
||||
VS
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '12px 20px',
|
||||
borderRadius: '16px',
|
||||
background: state.currentPlayer === 2 ? 'red.100' : 'gray.100',
|
||||
border: '3px solid',
|
||||
borderColor: state.currentPlayer === 2 ? 'red.400' : 'gray.300',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}
|
||||
})}
|
||||
onClick={() => handlePlayerClick(2)}
|
||||
>
|
||||
<div className={css({
|
||||
fontSize: '40px',
|
||||
marginBottom: '4px',
|
||||
transition: 'transform 0.2s ease',
|
||||
_hover: { transform: 'scale(1.1)' }
|
||||
})}>
|
||||
{profile.player2Emoji}
|
||||
</div>
|
||||
<div className={css({ fontSize: '28px', fontWeight: 'bold', color: 'red.600' })}>
|
||||
{state.scores[2] || 0}
|
||||
</div>
|
||||
<div className={css({ fontSize: '12px', color: 'gray.600', marginTop: '4px' })}>
|
||||
Click to change character
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single Player Progress */}
|
||||
<span className={css({ color: 'blue.600' })}>
|
||||
{state.matchedPairs} matched
|
||||
</span>
|
||||
<span className={css({ color: 'gray.400' })}>•</span>
|
||||
<span className={css({ color: 'purple.600' })}>
|
||||
{state.moves} moves
|
||||
</span>
|
||||
{state.gameMode === 'single' && (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px'
|
||||
})}>
|
||||
<div className={css({
|
||||
width: '120px',
|
||||
height: '8px',
|
||||
background: 'gray.200',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
})}>
|
||||
<div className={css({
|
||||
width: `${(state.matchedPairs / state.totalPairs) * 100}%`,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2)',
|
||||
transition: 'width 0.3s ease',
|
||||
borderRadius: '4px'
|
||||
})} />
|
||||
</div>
|
||||
<span className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
{Math.round((state.matchedPairs / state.totalPairs) * 100)}%
|
||||
<>
|
||||
<span className={css({ color: 'gray.400' })}>•</span>
|
||||
<span className={css({ color: 'green.600' })}>
|
||||
{Math.round((state.matchedPairs / state.totalPairs) * 100)}% complete
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -390,15 +247,6 @@ export function MemoryGrid() {
|
||||
})} />
|
||||
)}
|
||||
|
||||
{/* Emoji Picker Modal */}
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker
|
||||
currentEmoji={showEmojiPicker.player === 1 ? profile.player1Emoji : profile.player2Emoji}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
onClose={() => setShowEmojiPicker(null)}
|
||||
playerNumber={showEmojiPicker.player}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ export function MemoryPairsGame() {
|
||||
ref={gameRef}
|
||||
className={css({
|
||||
flex: 1,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: { base: '12px', sm: '16px', md: '20px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
// Inject the celebration animations for Storybook
|
||||
const celebrationAnimations = `
|
||||
@keyframes gentle-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.3), 0 12px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.5), 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-sway {
|
||||
0%, 100% { transform: rotate(-2deg) scale(1); }
|
||||
50% { transform: rotate(2deg) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes turn-entrance {
|
||||
0% {
|
||||
transform: scale(0.8) rotate(-10deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes streak-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes great-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.12) translateY(-6px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 8px #22c55e60, 0 15px 35px rgba(34,197,94,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes epic-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes legendary-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.2) translateY(-12px) rotate(5deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.18) translateY(-10px) rotate(-3deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 10px #a855f7, 0 20px 45px rgba(168,85,247,0.4);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.22) translateY(-14px) rotate(3deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-1deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 8px #a855f7, 0 18px 40px rgba(168,85,247,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Component to inject animations
|
||||
const AnimationProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined' && !document.getElementById('celebration-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'celebration-animations'
|
||||
style.textContent = celebrationAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Games/Matching/PlayerStatusBar',
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The PlayerStatusBar component displays the current state of players in the matching game.
|
||||
It shows different layouts for single player vs multiplayer modes and includes escalating
|
||||
celebration effects for consecutive matching pairs.
|
||||
|
||||
## Features
|
||||
- Single player mode with epic styling
|
||||
- Multiplayer mode with competitive grid layout
|
||||
- Escalating celebration animations based on consecutive matches:
|
||||
- 2+ matches: Great celebration (green)
|
||||
- 3+ matches: Epic celebration (orange)
|
||||
- 5+ matches: Legendary celebration (purple with gold accents)
|
||||
- Real-time turn indicators
|
||||
- Score tracking and progress display
|
||||
- Responsive design for mobile and desktop
|
||||
|
||||
## Animation Preview
|
||||
The animations demonstrate different celebration levels that activate when players get consecutive matches.
|
||||
`
|
||||
}
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AnimationProvider>
|
||||
<div className={css({
|
||||
width: '800px',
|
||||
maxWidth: '90vw',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
|
||||
minHeight: '400px'
|
||||
})}>
|
||||
<Story />
|
||||
</div>
|
||||
</AnimationProvider>
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Create a mock player card component that showcases the animations
|
||||
const MockPlayerCard = ({
|
||||
emoji,
|
||||
name,
|
||||
score,
|
||||
consecutiveMatches,
|
||||
isCurrentPlayer = true,
|
||||
celebrationLevel
|
||||
}: {
|
||||
emoji: string
|
||||
name: string
|
||||
score: number
|
||||
consecutiveMatches: number
|
||||
isCurrentPlayer?: boolean
|
||||
celebrationLevel: 'normal' | 'great' | 'epic' | 'legendary'
|
||||
}) => {
|
||||
const playerColor = celebrationLevel === 'legendary' ? '#a855f7' :
|
||||
celebrationLevel === 'epic' ? '#f97316' :
|
||||
celebrationLevel === 'great' ? '#22c55e' : '#3b82f6'
|
||||
|
||||
return (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '3', md: '4' },
|
||||
p: isCurrentPlayer ? { base: '4', md: '6' } : { base: '2', md: '3' },
|
||||
rounded: isCurrentPlayer ? '2xl' : 'lg',
|
||||
background: isCurrentPlayer
|
||||
? `linear-gradient(135deg, ${playerColor}15, ${playerColor}25, ${playerColor}15)`
|
||||
: 'white',
|
||||
border: isCurrentPlayer ? '4px solid' : '2px solid',
|
||||
borderColor: isCurrentPlayer ? playerColor : 'gray.200',
|
||||
boxShadow: isCurrentPlayer
|
||||
? `0 0 0 2px white, 0 0 0 6px ${playerColor}40, 0 12px 32px rgba(0,0,0,0.2)`
|
||||
: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
position: 'relative',
|
||||
transform: isCurrentPlayer ? 'scale(1.08) translateY(-4px)' : 'scale(1)',
|
||||
zIndex: isCurrentPlayer ? 10 : 1,
|
||||
animation: isCurrentPlayer
|
||||
? (celebrationLevel === 'legendary' ? 'legendary-celebration 0.8s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'epic' ? 'epic-celebration 0.7s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'great' ? 'great-celebration 0.6s ease-out, turn-entrance 0.6s ease-out'
|
||||
: 'turn-entrance 0.6s ease-out')
|
||||
: 'none'
|
||||
})}>
|
||||
|
||||
{/* Player emoji */}
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: '3xl', md: '5xl' } : { base: 'lg', md: 'xl' },
|
||||
flexShrink: 0,
|
||||
animation: isCurrentPlayer
|
||||
? 'float 3s ease-in-out infinite'
|
||||
: 'breathe 5s ease-in-out infinite',
|
||||
transform: isCurrentPlayer ? 'scale(1.2)' : 'scale(1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
textShadow: isCurrentPlayer ? '0 0 20px currentColor' : 'none'
|
||||
})}>
|
||||
{emoji}
|
||||
</div>
|
||||
|
||||
{/* Player info */}
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: 'md', md: 'lg' } : { base: 'xs', md: 'sm' },
|
||||
fontWeight: 'black',
|
||||
color: isCurrentPlayer ? 'gray.900' : 'gray.700',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
|
||||
})}>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'md' } : { base: '2xs', md: 'xs' },
|
||||
color: isCurrentPlayer ? playerColor : 'gray.500',
|
||||
fontWeight: isCurrentPlayer ? 'black' : 'semibold'
|
||||
})}>
|
||||
{score} pairs
|
||||
{isCurrentPlayer && (
|
||||
<span className={css({
|
||||
color: 'red.600',
|
||||
fontWeight: 'black',
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
|
||||
textShadow: '0 0 15px currentColor'
|
||||
})}>
|
||||
{' • Your turn'}
|
||||
</span>
|
||||
)}
|
||||
{consecutiveMatches > 1 && (
|
||||
<div className={css({
|
||||
fontSize: { base: '2xs', md: 'xs' },
|
||||
color: celebrationLevel === 'legendary' ? 'purple.600' :
|
||||
celebrationLevel === 'epic' ? 'orange.600' :
|
||||
celebrationLevel === 'great' ? 'green.600' : 'gray.500',
|
||||
fontWeight: 'black',
|
||||
animation: isCurrentPlayer ? 'streak-pulse 1s ease-in-out infinite' : 'none',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
|
||||
})}>
|
||||
🔥 {consecutiveMatches} streak!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Epic score display */}
|
||||
{isCurrentPlayer && (
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
|
||||
color: 'white',
|
||||
px: { base: '3', md: '4' },
|
||||
py: { base: '2', md: '3' },
|
||||
rounded: 'xl',
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
fontWeight: 'black',
|
||||
boxShadow: '0 4px 15px rgba(238, 90, 36, 0.4)',
|
||||
animation: 'gentle-bounce 1.5s ease-in-out infinite',
|
||||
textShadow: '0 0 10px rgba(255,255,255,0.8)'
|
||||
})}>
|
||||
⚡{score}⚡
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Normal celebration level
|
||||
export const NormalPlayer: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="🚀"
|
||||
name="Solo Champion"
|
||||
score={3}
|
||||
consecutiveMatches={0}
|
||||
celebrationLevel="normal"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Great celebration level
|
||||
export const GreatStreak: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="🎯"
|
||||
name="Streak Master"
|
||||
score={5}
|
||||
consecutiveMatches={2}
|
||||
celebrationLevel="great"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Epic celebration level
|
||||
export const EpicStreak: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="🔥"
|
||||
name="Epic Matcher"
|
||||
score={7}
|
||||
consecutiveMatches={4}
|
||||
celebrationLevel="epic"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Legendary celebration level
|
||||
export const LegendaryStreak: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="👑"
|
||||
name="Legend"
|
||||
score={8}
|
||||
consecutiveMatches={6}
|
||||
celebrationLevel="legendary"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// All levels showcase
|
||||
export const AllCelebrationLevels: Story = {
|
||||
render: () => (
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '20px' })}>
|
||||
<h3 className={css({ textAlign: 'center', fontSize: '24px', fontWeight: 'bold', marginBottom: '20px' })}>
|
||||
Consecutive Match Celebration Levels
|
||||
</h3>
|
||||
|
||||
<div className={css({ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))', gap: '20px' })}>
|
||||
{/* Normal */}
|
||||
<div>
|
||||
<h4 className={css({ textAlign: 'center', marginBottom: '10px', fontSize: '16px', fontWeight: 'bold' })}>
|
||||
Normal (0-1 matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="🚀"
|
||||
name="Solo Champion"
|
||||
score={3}
|
||||
consecutiveMatches={0}
|
||||
celebrationLevel="normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Great */}
|
||||
<div>
|
||||
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'green.600', fontSize: '16px', fontWeight: 'bold' })}>
|
||||
Great (2+ matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="🎯"
|
||||
name="Streak Master"
|
||||
score={5}
|
||||
consecutiveMatches={2}
|
||||
celebrationLevel="great"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Epic */}
|
||||
<div>
|
||||
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'orange.600', fontSize: '16px', fontWeight: 'bold' })}>
|
||||
Epic (3+ matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="🔥"
|
||||
name="Epic Matcher"
|
||||
score={7}
|
||||
consecutiveMatches={4}
|
||||
celebrationLevel="epic"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legendary */}
|
||||
<div>
|
||||
<h4 className={css({ textAlign: 'center', marginBottom: '10px', color: 'purple.600', fontSize: '16px', fontWeight: 'bold' })}>
|
||||
Legendary (5+ matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="👑"
|
||||
name="Legend"
|
||||
score={8}
|
||||
consecutiveMatches={6}
|
||||
celebrationLevel="legendary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
marginTop: '20px',
|
||||
padding: '16px',
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(0,0,0,0.1)'
|
||||
})}>
|
||||
<p className={css({ fontSize: '14px', color: 'gray.700', margin: 0 })}>
|
||||
These animations trigger when a player gets consecutive matching pairs in the memory matching game.
|
||||
The celebrations get more intense as the streak grows, providing visual feedback and excitement!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
}
|
||||
}
|
||||
514
apps/web/src/app/games/matching/components/PlayerStatusBar.tsx
Normal file
514
apps/web/src/app/games/matching/components/PlayerStatusBar.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useUserProfile } from '../../../../contexts/UserProfileContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
|
||||
interface PlayerStatusBarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
const { players } = useGameMode()
|
||||
const { profile } = useUserProfile()
|
||||
const { state } = useMemoryPairs()
|
||||
|
||||
// Get active players with their profile data
|
||||
const activePlayers = players
|
||||
.filter(player => player.isActive)
|
||||
.map(player => ({
|
||||
...player,
|
||||
displayName: player.id === 1 ? profile.player1Name :
|
||||
player.id === 2 ? profile.player2Name :
|
||||
player.name,
|
||||
displayEmoji: player.id === 1 ? profile.player1Emoji :
|
||||
player.id === 2 ? profile.player2Emoji :
|
||||
player.emoji,
|
||||
score: state.scores[player.id] || 0,
|
||||
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0
|
||||
}))
|
||||
|
||||
// Get celebration level based on consecutive matches
|
||||
const getCelebrationLevel = (consecutiveMatches: number) => {
|
||||
if (consecutiveMatches >= 5) return 'legendary'
|
||||
if (consecutiveMatches >= 3) return 'epic'
|
||||
if (consecutiveMatches >= 2) return 'great'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
if (activePlayers.length <= 1) {
|
||||
// Epic single player mode
|
||||
return (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
rounded: '2xl',
|
||||
p: { base: '4', md: '6' },
|
||||
border: '3px solid',
|
||||
borderColor: 'purple.300',
|
||||
mb: { base: '3', md: '4' },
|
||||
boxShadow: '0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.4), 0 12px 32px rgba(0,0,0,0.2)',
|
||||
animation: 'gentle-pulse 3s ease-in-out infinite',
|
||||
position: 'relative'
|
||||
}, className)}>
|
||||
{/* Subtle glow effect */}
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(45deg, transparent 40%, rgba(255,255,255,0.1) 50%, transparent 60%)',
|
||||
pointerEvents: 'none'
|
||||
})} />
|
||||
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '4', md: '6' },
|
||||
position: 'relative',
|
||||
zIndex: 2
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: { base: '3xl', md: '5xl' },
|
||||
animation: 'gentle-sway 2s ease-in-out infinite',
|
||||
textShadow: '0 0 20px currentColor',
|
||||
transform: 'scale(1.2)'
|
||||
})}>
|
||||
{activePlayers[0]?.displayEmoji || '🚀'}
|
||||
</div>
|
||||
<div>
|
||||
<div className={css({
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
fontWeight: 'black',
|
||||
color: 'white',
|
||||
color: 'white',
|
||||
textShadow: '0 0 15px rgba(255,255,255,0.8)'
|
||||
})}>
|
||||
{activePlayers[0]?.displayName || 'Player 1'}
|
||||
</div>
|
||||
<div className={css({
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
fontWeight: 'bold',
|
||||
color: 'rgba(255,255,255,0.9)'
|
||||
})}>
|
||||
Solo Challenge • {state.moves} moves
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Epic progress indicator */}
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
|
||||
color: 'white',
|
||||
px: { base: '3', md: '4' },
|
||||
py: { base: '2', md: '3' },
|
||||
rounded: 'xl',
|
||||
fontSize: { base: 'md', md: 'lg' },
|
||||
fontWeight: 'black',
|
||||
boxShadow: '0 4px 15px rgba(238, 90, 36, 0.4)',
|
||||
animation: 'gentle-bounce 2s ease-in-out infinite',
|
||||
textShadow: '0 0 10px rgba(255,255,255,0.8)'
|
||||
})}>
|
||||
⚡{state.matchedPairs}/{state.totalPairs}⚡
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For multiplayer, show competitive status bar
|
||||
return (
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
|
||||
rounded: 'xl',
|
||||
p: { base: '2', md: '3' },
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.200',
|
||||
mb: { base: '3', md: '4' }
|
||||
}, className)}>
|
||||
<div className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: activePlayers.length <= 2
|
||||
? 'repeat(2, 1fr)'
|
||||
: activePlayers.length === 3
|
||||
? 'repeat(3, 1fr)'
|
||||
: 'repeat(2, 1fr) repeat(2, 1fr)',
|
||||
gap: { base: '2', md: '3' },
|
||||
alignItems: 'center'
|
||||
})}>
|
||||
{activePlayers.map((player, index) => {
|
||||
const isCurrentPlayer = player.id === state.currentPlayer
|
||||
const isLeading = player.score === Math.max(...activePlayers.map(p => p.score)) && player.score > 0
|
||||
const celebrationLevel = getCelebrationLevel(player.consecutiveMatches)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={player.id}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '3', md: '4' },
|
||||
p: isCurrentPlayer ? { base: '4', md: '6' } : { base: '2', md: '3' },
|
||||
rounded: isCurrentPlayer ? '2xl' : 'lg',
|
||||
background: isCurrentPlayer
|
||||
? `linear-gradient(135deg, ${player.color || '#3b82f6'}15, ${player.color || '#3b82f6'}25, ${player.color || '#3b82f6'}15)`
|
||||
: 'white',
|
||||
border: isCurrentPlayer ? '4px solid' : '2px solid',
|
||||
borderColor: isCurrentPlayer
|
||||
? (player.color || '#3b82f6')
|
||||
: 'gray.200',
|
||||
boxShadow: isCurrentPlayer
|
||||
? '0 0 0 2px white, 0 0 0 6px ' + (player.color || '#3b82f6') + '40, 0 12px 32px rgba(0,0,0,0.2)'
|
||||
: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
position: 'relative',
|
||||
transform: isCurrentPlayer ? 'scale(1.08) translateY(-4px)' : 'scale(1)',
|
||||
zIndex: isCurrentPlayer ? 10 : 1,
|
||||
animation: isCurrentPlayer
|
||||
? (celebrationLevel === 'legendary' ? 'legendary-celebration 0.8s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'epic' ? 'epic-celebration 0.7s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'great' ? 'great-celebration 0.6s ease-out, turn-entrance 0.6s ease-out'
|
||||
: 'turn-entrance 0.6s ease-out')
|
||||
: 'none'
|
||||
})}
|
||||
>
|
||||
|
||||
{/* Leading crown with sparkle */}
|
||||
{isLeading && (
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
top: isCurrentPlayer ? '-3' : '-1',
|
||||
right: isCurrentPlayer ? '-3' : '-1',
|
||||
background: 'linear-gradient(135deg, #ffd700, #ffaa00)',
|
||||
rounded: 'full',
|
||||
w: isCurrentPlayer ? '10' : '6',
|
||||
h: isCurrentPlayer ? '10' : '6',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: isCurrentPlayer ? 'lg' : 'xs',
|
||||
zIndex: 10,
|
||||
animation: 'none',
|
||||
boxShadow: '0 0 20px rgba(255, 215, 0, 0.6)'
|
||||
})}>
|
||||
👑
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle turn indicator */}
|
||||
{isCurrentPlayer && (
|
||||
<div className={css({
|
||||
position: 'absolute',
|
||||
top: '-2',
|
||||
left: '-2',
|
||||
background: player.color || '#3b82f6',
|
||||
rounded: 'full',
|
||||
w: '4',
|
||||
h: '4',
|
||||
animation: 'gentle-sway 2s ease-in-out infinite',
|
||||
zIndex: 5
|
||||
})} />
|
||||
)}
|
||||
|
||||
{/* Living, breathing player emoji */}
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: '3xl', md: '5xl' } : { base: 'lg', md: 'xl' },
|
||||
flexShrink: 0,
|
||||
animation: isCurrentPlayer
|
||||
? 'float 3s ease-in-out infinite'
|
||||
: 'breathe 5s ease-in-out infinite',
|
||||
transform: isCurrentPlayer ? 'scale(1.2)' : 'scale(1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
textShadow: isCurrentPlayer ? '0 0 20px currentColor' : 'none',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
transform: isCurrentPlayer ? 'scale(1.3)' : 'scale(1.1)',
|
||||
animation: 'gentle-sway 1s ease-in-out infinite'
|
||||
}
|
||||
})}>
|
||||
{player.displayEmoji}
|
||||
</div>
|
||||
|
||||
{/* Enhanced player info */}
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: 'md', md: 'lg' } : { base: 'xs', md: 'sm' },
|
||||
fontWeight: 'black',
|
||||
color: isCurrentPlayer ? 'gray.900' : 'gray.700',
|
||||
animation: 'none',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
|
||||
})}>
|
||||
{player.displayName}
|
||||
</div>
|
||||
<div className={css({
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'md' } : { base: '2xs', md: 'xs' },
|
||||
color: isCurrentPlayer ? (player.color || '#3b82f6') : 'gray.500',
|
||||
fontWeight: isCurrentPlayer ? 'black' : 'semibold',
|
||||
animation: 'none'
|
||||
})}>
|
||||
{player.score} pairs
|
||||
{isCurrentPlayer && (
|
||||
<span className={css({
|
||||
color: 'red.600',
|
||||
fontWeight: 'black',
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
|
||||
animation: 'none',
|
||||
textShadow: '0 0 15px currentColor'
|
||||
})}>
|
||||
{' • Your turn'}
|
||||
</span>
|
||||
)}
|
||||
{player.consecutiveMatches > 1 && (
|
||||
<div className={css({
|
||||
fontSize: { base: '2xs', md: 'xs' },
|
||||
color: celebrationLevel === 'legendary' ? 'purple.600' :
|
||||
celebrationLevel === 'epic' ? 'orange.600' :
|
||||
celebrationLevel === 'great' ? 'green.600' : 'gray.500',
|
||||
fontWeight: 'black',
|
||||
animation: isCurrentPlayer ? 'streak-pulse 1s ease-in-out infinite' : 'none',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none'
|
||||
})}>
|
||||
🔥 {player.consecutiveMatches} streak!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Epic score display for current player */}
|
||||
{isCurrentPlayer && (
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
|
||||
color: 'white',
|
||||
px: { base: '3', md: '4' },
|
||||
py: { base: '2', md: '3' },
|
||||
rounded: 'xl',
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
fontWeight: 'black',
|
||||
boxShadow: '0 4px 15px rgba(238, 90, 36, 0.4)',
|
||||
animation: 'super-bounce 1.5s ease-in-out infinite',
|
||||
textShadow: '0 0 10px rgba(255,255,255,0.8)'
|
||||
})}>
|
||||
⚡{player.score}⚡
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Game progress */}
|
||||
<div className={css({
|
||||
mt: '3',
|
||||
pt: '2',
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: { base: 'xs', md: 'sm' },
|
||||
color: 'gray.600',
|
||||
fontWeight: 'medium'
|
||||
})}>
|
||||
{state.matchedPairs} of {state.totalPairs} pairs found • {state.moves} total moves
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Epic animations for extreme emphasis
|
||||
const epicAnimations = `
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.3), 0 12px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.5), 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-sway {
|
||||
0%, 100% { transform: rotate(-2deg) scale(1); }
|
||||
50% { transform: rotate(2deg) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes turn-entrance {
|
||||
0% {
|
||||
transform: scale(0.8) rotate(-10deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes turn-exit {
|
||||
0% {
|
||||
transform: scale(1.08);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spotlight {
|
||||
0%, 100% {
|
||||
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.3) 50%, transparent 70%);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.6) 50%, transparent 70%);
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes neon-flicker {
|
||||
0%, 100% {
|
||||
text-shadow: 0 0 5px currentColor, 0 0 10px currentColor, 0 0 15px currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 2px currentColor, 0 0 5px currentColor, 0 0 8px currentColor;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes crown-sparkle {
|
||||
0%, 100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
filter: brightness(1);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-5deg) scale(1.1);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(5deg) scale(1.1);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes streak-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes great-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.12) translateY(-6px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 8px #22c55e60, 0 15px 35px rgba(34,197,94,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes epic-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes legendary-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.2) translateY(-12px) rotate(5deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.18) translateY(-10px) rotate(-3deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 10px #a855f7, 0 20px 45px rgba(168,85,247,0.4);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.22) translateY(-14px) rotate(3deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-1deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 8px #a855f7, 0 18px 40px rgba(168,85,247,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Inject animation styles
|
||||
if (typeof document !== 'undefined' && !document.getElementById('player-status-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'player-status-animations'
|
||||
style.textContent = epicAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useUserProfile } from '../../../../contexts/UserProfileContext'
|
||||
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
@@ -10,9 +11,20 @@ export function ResultsPhase() {
|
||||
const router = useRouter()
|
||||
const { state, resetGame, activePlayers } = useMemoryPairs()
|
||||
const { players } = useGameMode()
|
||||
const { profile } = useUserProfile()
|
||||
|
||||
// Get active player data
|
||||
const activePlayerData = players.filter(p => activePlayers.includes(p.id))
|
||||
// Get active player data with profile information
|
||||
const activePlayerData = players
|
||||
.filter(p => activePlayers.includes(p.id))
|
||||
.map(player => ({
|
||||
...player,
|
||||
displayName: player.id === 1 ? profile.player1Name :
|
||||
player.id === 2 ? profile.player2Name :
|
||||
player.name,
|
||||
displayEmoji: player.id === 1 ? profile.player1Emoji :
|
||||
player.id === 2 ? profile.player2Emoji :
|
||||
player.emoji
|
||||
}))
|
||||
|
||||
const gameTime = state.gameEndTime && state.gameStartTime
|
||||
? state.gameEndTime - state.gameStartTime
|
||||
@@ -64,7 +76,7 @@ export function ResultsPhase() {
|
||||
color: 'blue.600',
|
||||
fontWeight: 'bold'
|
||||
})}>
|
||||
🏆 {activePlayerData.find(p => p.id === multiplayerResult.winners[0])?.name || `Player ${multiplayerResult.winners[0]}`} Wins!
|
||||
🏆 {activePlayerData.find(p => p.id === multiplayerResult.winners[0])?.displayName || `Player ${multiplayerResult.winners[0]}`} Wins!
|
||||
</p>
|
||||
) : (
|
||||
<p className={css({
|
||||
@@ -192,10 +204,10 @@ export function ResultsPhase() {
|
||||
minWidth: '150px'
|
||||
})}>
|
||||
<div className={css({ fontSize: '48px', marginBottom: '8px' })}>
|
||||
{player.emoji}
|
||||
{player.displayEmoji}
|
||||
</div>
|
||||
<div className={css({ fontSize: '14px', marginBottom: '4px', opacity: 0.9 })}>
|
||||
{player.name}
|
||||
{player.displayName}
|
||||
</div>
|
||||
<div className={css({ fontSize: '36px', fontWeight: 'bold' })}>
|
||||
{score}
|
||||
|
||||
@@ -34,6 +34,7 @@ const initialState: MemoryPairsState = {
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
consecutiveMatches: {},
|
||||
|
||||
// Timing
|
||||
gameStartTime: null,
|
||||
@@ -73,10 +74,12 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
}
|
||||
|
||||
case 'START_GAME':
|
||||
// Initialize scores for all active players
|
||||
// Initialize scores and consecutive matches for all active players
|
||||
const scores: PlayerScore = {}
|
||||
const consecutiveMatches: { [playerId: number]: number } = {}
|
||||
action.activePlayers.forEach(playerId => {
|
||||
scores[playerId] = 0
|
||||
consecutiveMatches[playerId] = 0
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -88,6 +91,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores,
|
||||
consecutiveMatches,
|
||||
activePlayers: action.activePlayers,
|
||||
currentPlayer: action.activePlayers[0] || 1,
|
||||
gameStartTime: Date.now(),
|
||||
@@ -135,6 +139,11 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1
|
||||
}
|
||||
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1
|
||||
}
|
||||
|
||||
// Check if game is complete
|
||||
const isGameComplete = newMatchedPairs === state.totalPairs
|
||||
|
||||
@@ -143,6 +152,7 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
gameCards: updatedCards,
|
||||
matchedPairs: newMatchedPairs,
|
||||
scores: newScores,
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
flippedCards: [],
|
||||
moves: state.moves + 1,
|
||||
lastMatchedPair: action.cardIds,
|
||||
@@ -169,9 +179,17 @@ function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction):
|
||||
// Cycle through all active players
|
||||
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
|
||||
const nextIndex = (currentIndex + 1) % state.activePlayers.length
|
||||
|
||||
// Reset consecutive matches for the player who failed
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: 0
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentPlayer: state.activePlayers[nextIndex] || state.activePlayers[0]
|
||||
currentPlayer: state.activePlayers[nextIndex] || state.activePlayers[0],
|
||||
consecutiveMatches: newConsecutiveMatches
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface MemoryPairsState {
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
consecutiveMatches: { [playerId: number]: number } // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
gameStartTime: number | null
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { MemoryPairsProvider } from './context/MemoryPairsContext'
|
||||
import { UserProfileProvider } from '../../../contexts/UserProfileContext'
|
||||
import { GameModeProvider } from '../../../contexts/GameModeContext'
|
||||
import { FullscreenProvider } from '../../../contexts/FullscreenContext'
|
||||
import { MemoryPairsGame } from './components/MemoryPairsGame'
|
||||
|
||||
export default function MatchingPage() {
|
||||
return (
|
||||
<FullscreenProvider>
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<MemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</MemoryPairsProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
</FullscreenProvider>
|
||||
<MemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</MemoryPairsProvider>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Link from 'next/link'
|
||||
import React, { useEffect, useReducer, useRef, useCallback, useMemo, useState } from 'react'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useAbacusConfig } from '../../../contexts/AbacusDisplayContext'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { isPrefix } from '../../../lib/memory-quiz-utils'
|
||||
import { StandardGameLayout } from '../../../components/StandardGameLayout'
|
||||
|
||||
@@ -223,6 +223,8 @@ const generateQuizCards = (count: number, difficulty: DifficultyLevel, appConfig
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
soundEnabled={appConfig.soundEnabled}
|
||||
soundVolume={appConfig.soundVolume}
|
||||
/>,
|
||||
element: null
|
||||
}))
|
||||
@@ -1736,7 +1738,6 @@ export default function MemoryQuizPage() {
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom right, #f0fdf4, #eff6ff)',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -7,7 +7,7 @@ import { container, stack, hstack, grid } from '../../../styled-system/patterns'
|
||||
import { TypstSoroban } from '@/components/TypstSoroban'
|
||||
import { InteractiveAbacus } from '@/components/InteractiveAbacus'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useAbacusConfig } from '@/contexts/AbacusDisplayContext'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
|
||||
import { getTutorialForEditor } from '@/utils/tutorialConverter'
|
||||
|
||||
|
||||
@@ -1,43 +1,34 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import './globals.css'
|
||||
import { AbacusDisplayProvider } from '@/contexts/AbacusDisplayContext'
|
||||
import { UserProfileProvider } from '@/contexts/UserProfileContext'
|
||||
import { GameModeProvider } from '@/contexts/GameModeContext'
|
||||
import { FullscreenProvider } from '@/contexts/FullscreenContext'
|
||||
import { GameThemeProvider } from '@/contexts/GameThemeContext'
|
||||
import { AppNavBar } from '@/components/AppNavBar'
|
||||
import { ClientProviders } from '@/components/ClientProviders'
|
||||
import { AppNav } from '@/components/AppNav'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Soroban Flashcard Generator',
|
||||
description: 'Create beautiful, educational soroban flashcards with authentic Japanese abacus representations',
|
||||
viewport: {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
nav,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
nav: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<AbacusDisplayProvider>
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<FullscreenProvider>
|
||||
<GameThemeProvider>
|
||||
<AppNavBar />
|
||||
{children}
|
||||
</GameThemeProvider>
|
||||
</FullscreenProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
</AbacusDisplayProvider>
|
||||
<ClientProviders>
|
||||
<AppNav>{nav}</AppNav>
|
||||
{children}
|
||||
</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as RadioGroup from '@radix-ui/react-radio-group'
|
||||
import * as Switch from '@radix-ui/react-switch'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { stack, hstack } from '../../styled-system/patterns'
|
||||
import { useAbacusDisplay, ColorScheme, BeadShape } from '@/contexts/AbacusDisplayContext'
|
||||
import { useAbacusDisplay, ColorScheme, BeadShape } from '@soroban/abacus-react'
|
||||
|
||||
interface AbacusDisplayDropdownProps {
|
||||
isFullscreen?: boolean
|
||||
@@ -169,6 +169,62 @@ export function AbacusDisplayDropdown({ isFullscreen = false }: AbacusDisplayDro
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Sound Effects" isFullscreen={isFullscreen}>
|
||||
<SwitchField
|
||||
checked={config.soundEnabled}
|
||||
onCheckedChange={(checked) => updateConfig({ soundEnabled: checked })}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{config.soundEnabled && (
|
||||
<FormField label={`Volume: ${Math.round(config.soundVolume * 100)}%`} isFullscreen={isFullscreen}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={config.soundVolume}
|
||||
onChange={(e) => updateConfig({ soundVolume: parseFloat(e.target.value) })}
|
||||
className={css({
|
||||
w: 'full',
|
||||
h: '2',
|
||||
bg: isFullscreen ? 'rgba(255, 255, 255, 0.2)' : 'gray.200',
|
||||
rounded: 'full',
|
||||
appearance: 'none',
|
||||
cursor: 'pointer',
|
||||
_focusVisible: {
|
||||
outline: 'none',
|
||||
ring: '2px',
|
||||
ringColor: isFullscreen ? 'blue.400' : 'brand.500'
|
||||
},
|
||||
'&::-webkit-slider-thumb': {
|
||||
appearance: 'none',
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
bg: isFullscreen ? 'blue.500' : 'brand.700',
|
||||
transform: 'scale(1.1)'
|
||||
}
|
||||
},
|
||||
'&::-moz-range-thumb': {
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent dropdown close
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
|
||||
52
apps/web/src/components/AppNav.tsx
Normal file
52
apps/web/src/components/AppNav.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { headers } from 'next/headers'
|
||||
import { AppNavBar } from './AppNavBar'
|
||||
|
||||
interface AppNavProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function getNavContentForPath(pathname: string): React.ReactNode {
|
||||
// Route-based nav content - no lazy loading needed
|
||||
if (pathname === '/games/matching' || pathname.startsWith('/arcade') && pathname.includes('matching')) {
|
||||
return (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧩 Memory Pairs
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
||||
if (pathname === '/games/memory-quiz' || pathname.startsWith('/arcade') && pathname.includes('memory-quiz')) {
|
||||
return (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧠 Memory Lightning
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function AppNav({ children }: AppNavProps) {
|
||||
const headersList = headers()
|
||||
const pathname = headersList.get('x-pathname') || ''
|
||||
|
||||
// Use @nav slot content if available, otherwise fall back to route-based detection
|
||||
const navContent = children || getNavContentForPath(pathname)
|
||||
|
||||
return <AppNavBar navSlot={navContent} />
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { css } from '../../styled-system/css'
|
||||
@@ -9,15 +10,18 @@ import { useFullscreen } from '../contexts/FullscreenContext'
|
||||
|
||||
interface AppNavBarProps {
|
||||
variant?: 'full' | 'minimal'
|
||||
navSlot?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
|
||||
export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const isGamePage = pathname?.startsWith('/games')
|
||||
const isArcadePage = pathname?.startsWith('/arcade')
|
||||
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
|
||||
|
||||
|
||||
|
||||
// Auto-detect variant based on context
|
||||
const actualVariant = variant === 'full' && (isGamePage || isArcadePage) ? 'minimal' : variant
|
||||
|
||||
@@ -34,58 +38,53 @@ export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
|
||||
transition: 'all 0.3s ease'
|
||||
})}>
|
||||
<div className={hstack({ gap: '2' })}>
|
||||
{/* Arcade branding (fullscreen only) */}
|
||||
{isFullscreen && (isArcadePage || isGamePage) && (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3',
|
||||
px: '4',
|
||||
py: '2',
|
||||
bg: 'rgba(0, 0, 0, 0.85)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
backdropFilter: 'blur(15px)'
|
||||
})}>
|
||||
<h1 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent'
|
||||
})}>
|
||||
🕹️ {isArcadePage ? 'Arcade' : 'Game'}
|
||||
</h1>
|
||||
<div className={css({
|
||||
px: '2',
|
||||
py: '1',
|
||||
background: 'rgba(34, 197, 94, 0.2)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
rounded: 'full',
|
||||
fontSize: 'xs',
|
||||
color: 'green.300',
|
||||
fontWeight: 'semibold'
|
||||
})}>
|
||||
✨ FULLSCREEN
|
||||
</div>
|
||||
{/* Game branding from slot */}
|
||||
{navSlot && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '8px 16px',
|
||||
background: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: isFullscreen ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
}}
|
||||
>
|
||||
{navSlot}
|
||||
{isFullscreen && (
|
||||
<div className={css({
|
||||
px: '2',
|
||||
py: '1',
|
||||
background: 'rgba(34, 197, 94, 0.2)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
rounded: 'full',
|
||||
fontSize: 'xs',
|
||||
color: 'green.300',
|
||||
fontWeight: 'semibold'
|
||||
})}>
|
||||
✨ FULLSCREEN
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isFullscreen ? 'rgba(255, 255, 255, 0.1)' : 'gray.200',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
})}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
background: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: isFullscreen ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className={css({
|
||||
@@ -122,19 +121,19 @@ export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Controls */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isFullscreen ? 'rgba(255, 255, 255, 0.1)' : 'gray.200',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
})}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
background: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: isFullscreen ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
|
||||
|
||||
25
apps/web/src/components/ClientProviders.tsx
Normal file
25
apps/web/src/components/ClientProviders.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import { AbacusDisplayProvider } from '@soroban/abacus-react'
|
||||
import { UserProfileProvider } from '@/contexts/UserProfileContext'
|
||||
import { GameModeProvider } from '@/contexts/GameModeContext'
|
||||
import { FullscreenProvider } from '@/contexts/FullscreenContext'
|
||||
|
||||
interface ClientProvidersProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ClientProviders({ children }: ClientProvidersProps) {
|
||||
return (
|
||||
<AbacusDisplayProvider>
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<FullscreenProvider>
|
||||
{children}
|
||||
</FullscreenProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
</AbacusDisplayProvider>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ interface StandardGameLayoutProps {
|
||||
* 4. Consistent experience across all games
|
||||
*/
|
||||
export function StandardGameLayout({ children, className }: StandardGameLayoutProps) {
|
||||
|
||||
return (
|
||||
<div className={css({
|
||||
// Exact viewport sizing - no scrolling ever
|
||||
@@ -35,7 +36,10 @@ export function StandardGameLayout({ children, className }: StandardGameLayoutPr
|
||||
|
||||
// Flex container for game content
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
flexDirection: 'column',
|
||||
|
||||
// Transparent background - themes will be applied at nav level
|
||||
background: 'transparent'
|
||||
}, className)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as Switch from '@radix-ui/react-switch'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { stack, hstack, grid } from '../../styled-system/patterns'
|
||||
import { FlashcardFormState } from '@/app/create/page'
|
||||
import { useAbacusDisplay } from '@/contexts/AbacusDisplayContext'
|
||||
import { useAbacusDisplay } from '@soroban/abacus-react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface StyleControlsProps {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { generateSorobanSVG, getWasmStatus, triggerWasmPreload, type SorobanConfig } from '@/lib/typst-soroban'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useAbacusConfig } from '@/contexts/AbacusDisplayContext'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
|
||||
interface TypstSorobanProps {
|
||||
number: number
|
||||
|
||||
120
apps/web/src/components/__tests__/AppNavBar.integration.test.tsx
Normal file
120
apps/web/src/components/__tests__/AppNavBar.integration.test.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { Suspense } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { AppNavBar } from '../AppNavBar'
|
||||
|
||||
// Mock Next.js hooks
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/games/matching',
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock contexts
|
||||
vi.mock('../../contexts/FullscreenContext', () => ({
|
||||
useFullscreen: () => ({
|
||||
isFullscreen: false,
|
||||
toggleFullscreen: vi.fn(),
|
||||
exitFullscreen: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock AbacusDisplayDropdown
|
||||
vi.mock('../AbacusDisplayDropdown', () => ({
|
||||
AbacusDisplayDropdown: () => <div data-testid="abacus-dropdown">Dropdown</div>,
|
||||
}))
|
||||
|
||||
describe('AppNavBar Nav Slot Integration', () => {
|
||||
it('renders actual nav slot content from lazy component', async () => {
|
||||
// Create a lazy component that simulates the @nav slot behavior
|
||||
const MatchingNavContent = () => (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧩 Memory Pairs
|
||||
</h1>
|
||||
)
|
||||
|
||||
const LazyMatchingNav = React.lazy(() => Promise.resolve({ default: MatchingNavContent }))
|
||||
|
||||
const navSlot = (
|
||||
<Suspense fallback={<div data-testid="nav-loading">Loading...</div>}>
|
||||
<LazyMatchingNav />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
// Initially should show loading fallback
|
||||
expect(screen.getByTestId('nav-loading')).toBeInTheDocument()
|
||||
|
||||
// Wait for lazy component to load and render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🧩 Memory Pairs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Verify loading state is gone
|
||||
expect(screen.queryByTestId('nav-loading')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reproduces the issue: lazy component without Suspense boundary fails to render', async () => {
|
||||
// This test reproduces the actual issue - lazy components need Suspense
|
||||
const MatchingNavContent = () => (
|
||||
<h1>🧩 Memory Pairs</h1>
|
||||
)
|
||||
|
||||
const LazyMatchingNav = React.lazy(() => Promise.resolve({ default: MatchingNavContent }))
|
||||
|
||||
// This is what's happening in the actual app - lazy component without Suspense
|
||||
const navSlot = <LazyMatchingNav />
|
||||
|
||||
// This should throw an error or not render properly
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
// The lazy component should not render without Suspense
|
||||
expect(screen.queryByText('🧩 Memory Pairs')).not.toBeInTheDocument()
|
||||
} catch (error) {
|
||||
// Expected to fail - lazy components need Suspense boundary
|
||||
expect(error.message).toContain('Suspense')
|
||||
}
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('simulates Next.js App Router parallel route slot structure', async () => {
|
||||
// This mimics the actual navSlot structure from Next.js App Router
|
||||
const mockParallelRouteSlot = {
|
||||
$$typeof: Symbol.for('react.element'),
|
||||
type: {
|
||||
$$typeof: Symbol.for('react.lazy'),
|
||||
_payload: Promise.resolve({
|
||||
default: () => <h1>🧩 Memory Pairs</h1>
|
||||
}),
|
||||
_init: (payload: any) => payload.then((module: any) => module.default),
|
||||
},
|
||||
key: null,
|
||||
ref: null,
|
||||
props: {
|
||||
parallelRouterKey: 'nav',
|
||||
segmentPath: ['nav'],
|
||||
template: {},
|
||||
notFoundStyles: []
|
||||
},
|
||||
}
|
||||
|
||||
// This is the structure we're actually receiving from Next.js
|
||||
render(<AppNavBar navSlot={mockParallelRouteSlot as any} />)
|
||||
|
||||
// This should fail to render the content without proper handling
|
||||
expect(screen.queryByText('🧩 Memory Pairs')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { Suspense } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { AppNavBar } from '../AppNavBar'
|
||||
|
||||
// Mock Next.js hooks
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/games/matching',
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock contexts
|
||||
vi.mock('../../contexts/FullscreenContext', () => ({
|
||||
useFullscreen: () => ({
|
||||
isFullscreen: false,
|
||||
toggleFullscreen: vi.fn(),
|
||||
exitFullscreen: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock AbacusDisplayDropdown
|
||||
vi.mock('../AbacusDisplayDropdown', () => ({
|
||||
AbacusDisplayDropdown: () => <div data-testid="abacus-dropdown">Dropdown</div>,
|
||||
}))
|
||||
|
||||
describe('AppNavBar Suspense Fix', () => {
|
||||
it('renders nav slot content with Suspense boundary (FIXED)', async () => {
|
||||
// Simulate the exact structure that Next.js App Router provides
|
||||
const MatchingNavContent = () => (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧩 Memory Pairs
|
||||
</h1>
|
||||
)
|
||||
|
||||
// Create a lazy component like Next.js does
|
||||
const LazyMatchingNav = React.lazy(() => Promise.resolve({ default: MatchingNavContent }))
|
||||
|
||||
// This is what Next.js App Router passes to our component
|
||||
const navSlot = <LazyMatchingNav />
|
||||
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
// Should show loading state briefly
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
|
||||
// Wait for the lazy component to load and render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🧩 Memory Pairs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Loading state should be gone
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
|
||||
|
||||
// Game name should be visible in the nav
|
||||
expect(screen.getByText('🧩 Memory Pairs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('demonstrates the original issue was fixed', async () => {
|
||||
// This test shows that without our Suspense fix, this would have failed
|
||||
const MemoryQuizContent = () => <h1>🧠 Memory Lightning</h1>
|
||||
const LazyMemoryQuizNav = React.lazy(() => Promise.resolve({ default: MemoryQuizContent }))
|
||||
|
||||
const navSlot = <LazyMemoryQuizNav />
|
||||
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
// Without Suspense boundary in AppNavBar, this would fail to render
|
||||
// But now it works because we wrap navSlot in Suspense
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('🧠 Memory Lightning')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows that lazy components need Suspense to render', () => {
|
||||
// This test shows what happens without Suspense - it should fail
|
||||
const TestContent = () => <h1>Test Content</h1>
|
||||
const LazyTest = React.lazy(() => Promise.resolve({ default: TestContent }))
|
||||
|
||||
// Trying to render lazy component without Suspense should fail
|
||||
expect(() => render(<LazyTest />)).toThrow()
|
||||
})
|
||||
|
||||
it('handles nav slot gracefully when null or undefined', () => {
|
||||
render(<AppNavBar navSlot={null} />)
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
|
||||
|
||||
render(<AppNavBar navSlot={undefined} />)
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
67
apps/web/src/components/__tests__/AppNavBar.test.tsx
Normal file
67
apps/web/src/components/__tests__/AppNavBar.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { AppNavBar } from '../AppNavBar'
|
||||
|
||||
// Mock Next.js hooks
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/games/matching',
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock contexts
|
||||
vi.mock('../../contexts/FullscreenContext', () => ({
|
||||
useFullscreen: () => ({
|
||||
isFullscreen: false,
|
||||
toggleFullscreen: vi.fn(),
|
||||
exitFullscreen: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock AbacusDisplayDropdown
|
||||
vi.mock('../AbacusDisplayDropdown', () => ({
|
||||
AbacusDisplayDropdown: () => <div data-testid="abacus-dropdown">Dropdown</div>,
|
||||
}))
|
||||
|
||||
describe('AppNavBar', () => {
|
||||
it('renders navSlot when provided', () => {
|
||||
const navSlot = <div data-testid="nav-slot">🧩 Memory Pairs</div>
|
||||
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
expect(screen.getByTestId('nav-slot')).toBeInTheDocument()
|
||||
expect(screen.getByText('🧩 Memory Pairs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render nav branding when navSlot is null', () => {
|
||||
render(<AppNavBar navSlot={null} />)
|
||||
|
||||
expect(screen.queryByTestId('nav-slot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render nav branding when navSlot is undefined', () => {
|
||||
render(<AppNavBar />)
|
||||
|
||||
expect(screen.queryByTestId('nav-slot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders minimal variant for game pages', () => {
|
||||
const navSlot = <div data-testid="nav-slot">Game Name</div>
|
||||
|
||||
render(<AppNavBar variant="full" navSlot={navSlot} />)
|
||||
|
||||
// Should auto-detect minimal variant for /games/matching path
|
||||
expect(screen.getByTestId('nav-slot')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders fullscreen toggle button', () => {
|
||||
const navSlot = <div data-testid="nav-slot">Game Name</div>
|
||||
|
||||
render(<AppNavBar navSlot={navSlot} />)
|
||||
|
||||
// Check that fullscreen toggle button is present
|
||||
expect(screen.getByTitle('Enter Fullscreen')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,7 @@ import { TutorialUIProvider } from './TutorialUIContext'
|
||||
import { CoachBar } from './CoachBar/CoachBar'
|
||||
import { PedagogicalDecompositionDisplay } from './PedagogicalDecompositionDisplay'
|
||||
import { DecompositionWithReasons } from './DecompositionWithReasons'
|
||||
import { useAbacusDisplay } from '@/contexts/AbacusDisplayContext'
|
||||
import { useAbacusDisplay } from '@soroban/abacus-react'
|
||||
import './CoachBar/coachbar.css'
|
||||
|
||||
// Helper function to find the topmost bead with arrows
|
||||
@@ -1308,6 +1308,8 @@ function TutorialPlayerContent({
|
||||
colorScheme={abacusConfig.colorScheme}
|
||||
beadShape={abacusConfig.beadShape}
|
||||
hideInactiveBeads={abacusConfig.hideInactiveBeads}
|
||||
soundEnabled={abacusConfig.soundEnabled}
|
||||
soundVolume={abacusConfig.soundVolume}
|
||||
highlightBeads={currentStep.highlightBeads}
|
||||
stepBeadHighlights={currentStepBeads}
|
||||
currentStep={currentMultiStep}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { vi } from 'vitest'
|
||||
import { TutorialProvider, useTutorialContext } from '../TutorialContext'
|
||||
import { TutorialPlayer } from '../TutorialPlayer'
|
||||
import { Tutorial, TutorialStep } from '../../../types/tutorial'
|
||||
import { AbacusDisplayProvider } from '@/contexts/AbacusDisplayContext'
|
||||
import { AbacusDisplayProvider } from '@soroban/abacus-react'
|
||||
|
||||
// Mock tutorial data
|
||||
const mockTutorial: Tutorial = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { vi } from 'vitest'
|
||||
import { TutorialProvider } from '../TutorialContext'
|
||||
import { TutorialPlayer } from '../TutorialPlayer'
|
||||
import { Tutorial } from '../../../types/tutorial'
|
||||
import { AbacusDisplayProvider } from '@/contexts/AbacusDisplayContext'
|
||||
import { AbacusDisplayProvider } from '@soroban/abacus-react'
|
||||
|
||||
// Mock the AbacusReact component to make testing easier
|
||||
vi.mock('@soroban/abacus-react', () => ({
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'
|
||||
|
||||
// Abacus display configuration types
|
||||
export type ColorScheme = 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
|
||||
export type BeadShape = 'diamond' | 'circle' | 'square'
|
||||
|
||||
export interface AbacusDisplayConfig {
|
||||
colorScheme: ColorScheme
|
||||
beadShape: BeadShape
|
||||
hideInactiveBeads: boolean
|
||||
coloredNumerals: boolean
|
||||
scaleFactor: number
|
||||
}
|
||||
|
||||
export interface AbacusDisplayContextType {
|
||||
config: AbacusDisplayConfig
|
||||
updateConfig: (updates: Partial<AbacusDisplayConfig>) => void
|
||||
resetToDefaults: () => void
|
||||
}
|
||||
|
||||
// Default configuration - matches current create page defaults
|
||||
const DEFAULT_CONFIG: AbacusDisplayConfig = {
|
||||
colorScheme: 'place-value',
|
||||
beadShape: 'diamond',
|
||||
hideInactiveBeads: false,
|
||||
coloredNumerals: false,
|
||||
scaleFactor: 1.0 // Normalized for display, can be scaled per component
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'soroban-abacus-display-config'
|
||||
|
||||
// Load config from localStorage with fallback to defaults
|
||||
function loadConfigFromStorage(): AbacusDisplayConfig {
|
||||
if (typeof window === 'undefined') return DEFAULT_CONFIG
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
// Validate that all required fields are present and have valid values
|
||||
return {
|
||||
colorScheme: ['monochrome', 'place-value', 'heaven-earth', 'alternating'].includes(parsed.colorScheme)
|
||||
? parsed.colorScheme : DEFAULT_CONFIG.colorScheme,
|
||||
beadShape: ['diamond', 'circle', 'square'].includes(parsed.beadShape)
|
||||
? parsed.beadShape : DEFAULT_CONFIG.beadShape,
|
||||
hideInactiveBeads: typeof parsed.hideInactiveBeads === 'boolean'
|
||||
? parsed.hideInactiveBeads : DEFAULT_CONFIG.hideInactiveBeads,
|
||||
coloredNumerals: typeof parsed.coloredNumerals === 'boolean'
|
||||
? parsed.coloredNumerals : DEFAULT_CONFIG.coloredNumerals,
|
||||
scaleFactor: typeof parsed.scaleFactor === 'number' && parsed.scaleFactor > 0
|
||||
? parsed.scaleFactor : DEFAULT_CONFIG.scaleFactor
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load abacus config from localStorage:', error)
|
||||
}
|
||||
|
||||
return DEFAULT_CONFIG
|
||||
}
|
||||
|
||||
// Save config to localStorage
|
||||
function saveConfigToStorage(config: AbacusDisplayConfig): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.warn('Failed to save abacus config to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const AbacusDisplayContext = createContext<AbacusDisplayContextType | null>(null)
|
||||
|
||||
export function useAbacusDisplay() {
|
||||
const context = useContext(AbacusDisplayContext)
|
||||
if (!context) {
|
||||
throw new Error('useAbacusDisplay must be used within an AbacusDisplayProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface AbacusDisplayProviderProps {
|
||||
children: ReactNode
|
||||
initialConfig?: Partial<AbacusDisplayConfig>
|
||||
}
|
||||
|
||||
export function AbacusDisplayProvider({
|
||||
children,
|
||||
initialConfig = {}
|
||||
}: AbacusDisplayProviderProps) {
|
||||
const [config, setConfig] = useState<AbacusDisplayConfig>(() => {
|
||||
// Always start with defaults to ensure server/client consistency
|
||||
return { ...DEFAULT_CONFIG, ...initialConfig }
|
||||
})
|
||||
|
||||
// Load from localStorage only after hydration
|
||||
useEffect(() => {
|
||||
const stored = loadConfigFromStorage()
|
||||
setConfig(stored)
|
||||
}, [])
|
||||
|
||||
// Save to localStorage whenever config changes
|
||||
useEffect(() => {
|
||||
saveConfigToStorage(config)
|
||||
}, [config])
|
||||
|
||||
const updateConfig = useCallback((updates: Partial<AbacusDisplayConfig>) => {
|
||||
setConfig(prev => {
|
||||
const newConfig = { ...prev, ...updates }
|
||||
return newConfig
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resetToDefaults = useCallback(() => {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
}, [])
|
||||
|
||||
const value: AbacusDisplayContextType = {
|
||||
config,
|
||||
updateConfig,
|
||||
resetToDefaults
|
||||
}
|
||||
|
||||
return (
|
||||
<AbacusDisplayContext.Provider value={value}>
|
||||
{children}
|
||||
</AbacusDisplayContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Convenience hook for components that need specific config values
|
||||
export function useAbacusConfig() {
|
||||
const { config } = useAbacusDisplay()
|
||||
return config
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, ReactNode } from 'react'
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
|
||||
export interface GameTheme {
|
||||
gameName: string
|
||||
@@ -10,15 +10,21 @@ export interface GameTheme {
|
||||
interface GameThemeContextType {
|
||||
theme: GameTheme | null
|
||||
setTheme: (theme: GameTheme | null) => void
|
||||
isHydrated: boolean
|
||||
}
|
||||
|
||||
const GameThemeContext = createContext<GameThemeContextType | undefined>(undefined)
|
||||
|
||||
export function GameThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<GameTheme | null>(null)
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsHydrated(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<GameThemeContext.Provider value={{ theme, setTheme }}>
|
||||
<GameThemeContext.Provider value={{ theme, setTheme, isHydrated }}>
|
||||
{children}
|
||||
</GameThemeContext.Provider>
|
||||
)
|
||||
|
||||
21
apps/web/src/middleware.ts
Normal file
21
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// Add pathname to headers so Server Components can access it
|
||||
const response = NextResponse.next()
|
||||
response.headers.set('x-pathname', request.nextUrl.pathname)
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
}
|
||||
@@ -1,3 +1,55 @@
|
||||
# [1.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.4.0...abacus-react-v1.5.0) (2025-09-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove frozen lockfile flag from publishing workflow to resolve dependency installation issues ([18af973](https://github.com/antialias/soroban-abacus-flashcards/commit/18af9730ffbcd822da292161815ffd09ad97f66c))
|
||||
* resolve mini navigation game name persistence across all routes ([3fa314a](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa314aaa5de7b9c26a5390a52996c7d5ef9ea51))
|
||||
* update pnpm version to 8.15.6 to resolve ERR_INVALID_THIS error in workflow ([0b9bfed](https://github.com/antialias/soroban-abacus-flashcards/commit/0b9bfed12dfd48d9eacae69b378e28e188d3f2b1))
|
||||
* update tutorial tests to use consolidated AbacusDisplayProvider ([899fc69](https://github.com/antialias/soroban-abacus-flashcards/commit/899fc6975f1fa14ddb42b2ead03524c9389e7c38))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** update description to mention GitHub Packages support ([af77256](https://github.com/antialias/soroban-abacus-flashcards/commit/af7725622e15801f9e56af12930c4e14c5e67c53))
|
||||
* add comprehensive E2E testing with Playwright ([d58053f](https://github.com/antialias/soroban-abacus-flashcards/commit/d58053fad3ab06b9884b46dbb6807e938426dbb5))
|
||||
* add comprehensive Storybook stories for PlayerStatusBar ([8973241](https://github.com/antialias/soroban-abacus-flashcards/commit/8973241297d50604028bde95b9ebbf033688db89))
|
||||
* add consecutive match tracking system for escalating celebrations ([111c0ce](https://github.com/antialias/soroban-abacus-flashcards/commit/111c0ced715be7cade006387d01f4e2f52c59be9))
|
||||
* add PlayerStatusBar with escalating celebration animations ([7f8c90a](https://github.com/antialias/soroban-abacus-flashcards/commit/7f8c90acea84b208df0e3e23e80a02cf425c0950))
|
||||
* add sound settings support to AbacusReact component ([90b9ffa](https://github.com/antialias/soroban-abacus-flashcards/commit/90b9ffa0d8659891bfe8062217e45245bbff5d5a))
|
||||
* implement cozy sound effects for abacus with variable intensity ([c95be1d](https://github.com/antialias/soroban-abacus-flashcards/commit/c95be1df6dbe74aad08b9a1feb1f33688212be0b))
|
||||
* integrate user profiles with PlayerStatusBar and game results ([beff646](https://github.com/antialias/soroban-abacus-flashcards/commit/beff64652c72a5cd0c008891b6dc2f5167e28b62))
|
||||
|
||||
# [1.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.3.0...abacus-react-v1.4.0) (2025-09-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* export missing hooks and types from @soroban/abacus-react package ([423ba55](https://github.com/antialias/soroban-abacus-flashcards/commit/423ba5535023928f1e0198b2bd01c3c6cf7ee848))
|
||||
* migrate viewport from metadata to separate viewport export ([1fe12c4](https://github.com/antialias/soroban-abacus-flashcards/commit/1fe12c4837b1229d0f0ab93c55d0ffb504eb8721))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add middleware for pathname header support in [@nav](https://github.com/nav) fallback ([b7e7c4b](https://github.com/antialias/soroban-abacus-flashcards/commit/b7e7c4beff1e37e90e9e20a890c5af7a134a7fca))
|
||||
* implement [@nav](https://github.com/nav) parallel routes for game name display in mini navigation ([885fc72](https://github.com/antialias/soroban-abacus-flashcards/commit/885fc725dc0bb41bbb5e500c2c907c6182192854))
|
||||
|
||||
# [1.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.2.0...abacus-react-v1.3.0) (2025-09-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ensure game names persist in navigation on page reload ([9191b12](https://github.com/antialias/soroban-abacus-flashcards/commit/9191b124934b9a5577a91f67e8fb6f83b173cc4f))
|
||||
* implement route-based theme detection for page reload persistence ([3dcff2f](https://github.com/antialias/soroban-abacus-flashcards/commit/3dcff2ff888558d7b746a732cfd53a1897c2b1df))
|
||||
* improve navigation chrome background color extraction from gradients ([00bfcbc](https://github.com/antialias/soroban-abacus-flashcards/commit/00bfcbcdee28d63094c09a4ae0359789ebcf4a22))
|
||||
* resolve SSR/client hydration mismatch for themed navigation ([301e65d](https://github.com/antialias/soroban-abacus-flashcards/commit/301e65dfa66d0de6b6efbbfbd09b717308ab57f1))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* complete themed navigation system with game-specific chrome ([0a4bf17](https://github.com/antialias/soroban-abacus-flashcards/commit/0a4bf1765cbd86bf6f67fb3b99c577cfe3cce075))
|
||||
* implement cozy sound effects for abacus with variable intensity ([cea5fad](https://github.com/antialias/soroban-abacus-flashcards/commit/cea5fadbe4b4d5ae9e0ee988e9b1c4db09f21ba6))
|
||||
|
||||
# [1.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.3...abacus-react-v1.2.0) (2025-09-28)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@soroban/abacus-react",
|
||||
"version": "0.1.0",
|
||||
"description": "Interactive React abacus component with animations, place value editing, and automated semantic versioning",
|
||||
"description": "Interactive React abacus component with animations, place value editing, and automated semantic versioning for GitHub Packages",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.es.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface AbacusDisplayConfig {
|
||||
animated: boolean
|
||||
interactive: boolean
|
||||
gestures: boolean
|
||||
soundEnabled: boolean
|
||||
soundVolume: number
|
||||
}
|
||||
|
||||
export interface AbacusDisplayContextType {
|
||||
@@ -37,7 +39,9 @@ const DEFAULT_CONFIG: AbacusDisplayConfig = {
|
||||
showNumbers: true,
|
||||
animated: true,
|
||||
interactive: false,
|
||||
gestures: false
|
||||
gestures: false,
|
||||
soundEnabled: true,
|
||||
soundVolume: 0.8
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'soroban-abacus-display-config'
|
||||
@@ -71,7 +75,11 @@ function loadConfigFromStorage(): AbacusDisplayConfig {
|
||||
interactive: typeof parsed.interactive === 'boolean'
|
||||
? parsed.interactive : DEFAULT_CONFIG.interactive,
|
||||
gestures: typeof parsed.gestures === 'boolean'
|
||||
? parsed.gestures : DEFAULT_CONFIG.gestures
|
||||
? parsed.gestures : DEFAULT_CONFIG.gestures,
|
||||
soundEnabled: typeof parsed.soundEnabled === 'boolean'
|
||||
? parsed.soundEnabled : DEFAULT_CONFIG.soundEnabled,
|
||||
soundVolume: typeof parsed.soundVolume === 'number' && parsed.soundVolume >= 0 && parsed.soundVolume <= 1
|
||||
? parsed.soundVolume : DEFAULT_CONFIG.soundVolume
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSpring, animated, config, to } from '@react-spring/web';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
import NumberFlow from '@number-flow/react';
|
||||
import { useAbacusConfig, getDefaultAbacusConfig } from './AbacusContext';
|
||||
import { playBeadSound } from './soundManager';
|
||||
|
||||
// Types
|
||||
export interface BeadConfig {
|
||||
@@ -233,6 +234,8 @@ export interface AbacusConfig {
|
||||
interactive?: boolean;
|
||||
gestures?: boolean;
|
||||
showNumbers?: boolean;
|
||||
soundEnabled?: boolean;
|
||||
soundVolume?: number;
|
||||
|
||||
// Advanced customization
|
||||
customStyles?: AbacusCustomStyles;
|
||||
@@ -1301,6 +1304,8 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
interactive,
|
||||
gestures,
|
||||
showNumbers,
|
||||
soundEnabled,
|
||||
soundVolume,
|
||||
// Advanced customization props
|
||||
customStyles,
|
||||
callbacks,
|
||||
@@ -1335,7 +1340,9 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
animated: animated ?? contextConfig.animated,
|
||||
interactive: interactive ?? contextConfig.interactive,
|
||||
gestures: gestures ?? contextConfig.gestures,
|
||||
showNumbers: showNumbers ?? contextConfig.showNumbers
|
||||
showNumbers: showNumbers ?? contextConfig.showNumbers,
|
||||
soundEnabled: soundEnabled ?? contextConfig.soundEnabled,
|
||||
soundVolume: soundVolume ?? contextConfig.soundVolume
|
||||
};
|
||||
// Calculate effective columns first, without depending on columnStates
|
||||
const effectiveColumns = useMemo(() => {
|
||||
@@ -1435,6 +1442,21 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate how many beads will change to determine sound intensity
|
||||
const currentState = getPlaceState(bead.placeValue);
|
||||
let beadMovementCount = 1; // Default for single bead movements
|
||||
|
||||
if (bead.type === 'earth') {
|
||||
if (bead.active) {
|
||||
// Deactivating: count beads from this position to end of active beads
|
||||
beadMovementCount = currentState.earthActive - bead.position;
|
||||
} else {
|
||||
// Activating: count beads from current active count to this position + 1
|
||||
beadMovementCount = (bead.position + 1) - currentState.earthActive;
|
||||
}
|
||||
}
|
||||
// Heaven bead always moves just 1 bead
|
||||
|
||||
// Create enhanced event object
|
||||
const beadClickEvent: BeadClickEvent = {
|
||||
bead,
|
||||
@@ -1452,13 +1474,33 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
// Legacy callback for backward compatibility
|
||||
onClick?.(bead);
|
||||
|
||||
// Play sound if enabled with intensity based on bead movement count
|
||||
if (finalConfig.soundEnabled) {
|
||||
playBeadSound(finalConfig.soundVolume, beadMovementCount);
|
||||
}
|
||||
|
||||
// Toggle the bead - NO MORE EFFECTIVECOLUMNS THREADING!
|
||||
toggleBead(bead);
|
||||
}, [onClick, callbacks, toggleBead, disabledColumns, disabledBeads]);
|
||||
}, [onClick, callbacks, toggleBead, disabledColumns, disabledBeads, finalConfig.soundEnabled, finalConfig.soundVolume, getPlaceState]);
|
||||
|
||||
const handleGestureToggle = useCallback((bead: BeadConfig, direction: 'activate' | 'deactivate') => {
|
||||
const currentState = getPlaceState(bead.placeValue);
|
||||
|
||||
// Calculate bead movement count for sound intensity
|
||||
let beadMovementCount = 1;
|
||||
if (bead.type === 'earth') {
|
||||
if (direction === 'activate') {
|
||||
beadMovementCount = Math.max(0, (bead.position + 1) - currentState.earthActive);
|
||||
} else {
|
||||
beadMovementCount = Math.max(0, currentState.earthActive - bead.position);
|
||||
}
|
||||
}
|
||||
|
||||
// Play sound if enabled with intensity
|
||||
if (finalConfig.soundEnabled) {
|
||||
playBeadSound(finalConfig.soundVolume, beadMovementCount);
|
||||
}
|
||||
|
||||
if (bead.type === 'heaven') {
|
||||
// Heaven bead: directly set the state based on direction
|
||||
const newHeavenActive = direction === 'activate';
|
||||
@@ -1484,7 +1526,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
earthActive: newEarthActive
|
||||
});
|
||||
}
|
||||
}, [getPlaceState, setPlaceState]);
|
||||
}, [getPlaceState, setPlaceState, finalConfig.soundEnabled, finalConfig.soundVolume]);
|
||||
|
||||
// Place value editing - FRESH IMPLEMENTATION
|
||||
const [activeColumn, setActiveColumn] = React.useState<number | null>(null);
|
||||
@@ -1502,12 +1544,28 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
|
||||
// Convert column index to place value
|
||||
const placeValue = (effectiveColumns - 1 - columnIndex) as ValidPlaceValues;
|
||||
const currentState = getPlaceState(placeValue);
|
||||
|
||||
// Calculate how many beads change for sound intensity
|
||||
const currentValue = (currentState.heavenActive ? 5 : 0) + currentState.earthActive;
|
||||
const newHeavenActive = digit >= 5;
|
||||
const newEarthActive = digit % 5;
|
||||
|
||||
// Count bead movements: heaven bead + earth bead changes
|
||||
let beadMovementCount = 0;
|
||||
if (currentState.heavenActive !== newHeavenActive) beadMovementCount += 1;
|
||||
beadMovementCount += Math.abs(currentState.earthActive - newEarthActive);
|
||||
|
||||
// Play sound if enabled with intensity based on bead changes
|
||||
if (finalConfig.soundEnabled && beadMovementCount > 0) {
|
||||
playBeadSound(finalConfig.soundVolume, beadMovementCount);
|
||||
}
|
||||
|
||||
setPlaceState(placeValue, {
|
||||
heavenActive: digit >= 5,
|
||||
earthActive: digit % 5
|
||||
heavenActive: newHeavenActive,
|
||||
earthActive: newEarthActive
|
||||
});
|
||||
}, [setPlaceState, effectiveColumns]);
|
||||
}, [setPlaceState, effectiveColumns, finalConfig.soundEnabled, finalConfig.soundVolume, getPlaceState]);
|
||||
|
||||
// Keyboard handler - only active when interactive
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -3,4 +3,18 @@ export type {
|
||||
AbacusConfig,
|
||||
BeadConfig,
|
||||
AbacusDimensions
|
||||
} from './AbacusReact';
|
||||
} from './AbacusReact';
|
||||
|
||||
export {
|
||||
useAbacusConfig,
|
||||
useAbacusDisplay,
|
||||
getDefaultAbacusConfig,
|
||||
AbacusDisplayProvider
|
||||
} from './AbacusContext';
|
||||
export type {
|
||||
ColorScheme,
|
||||
BeadShape,
|
||||
ColorPalette,
|
||||
AbacusDisplayConfig,
|
||||
AbacusDisplayContextType
|
||||
} from './AbacusContext';
|
||||
124
packages/abacus-react/src/soundManager.ts
Normal file
124
packages/abacus-react/src/soundManager.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
// AudioContext manager for generating abacus bead click sounds
|
||||
let audioCtx: AudioContext | null = null
|
||||
|
||||
/**
|
||||
* Gets or creates the global AudioContext instance
|
||||
* SSR-safe - returns null in server environment
|
||||
*/
|
||||
export function getAudioContext(): AudioContext | null {
|
||||
// SSR guard - only initialize on client
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
if (!audioCtx) {
|
||||
// Support older Safari versions with webkit prefix
|
||||
const AudioCtxClass = window.AudioContext || (window as any).webkitAudioContext
|
||||
try {
|
||||
audioCtx = new AudioCtxClass()
|
||||
} catch (e) {
|
||||
console.warn('AudioContext could not be initialized:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return audioCtx
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a realistic "cozy" bead click sound using Web Audio API
|
||||
* Generates sound on-the-fly with no external assets
|
||||
* @param volume - Volume level from 0.0 to 1.0
|
||||
* @param intensity - Number of beads moved (1-5) to adjust sound heft
|
||||
*/
|
||||
export function playBeadSound(volume: number, intensity: number = 1): void {
|
||||
const ctx = getAudioContext()
|
||||
if (!ctx) return // No audio context available (SSR or initialization failed)
|
||||
|
||||
// Clamp volume to valid range
|
||||
const clampedVolume = Math.max(0, Math.min(1, volume))
|
||||
if (clampedVolume === 0) return // Skip if volume is zero
|
||||
|
||||
// Clamp intensity to reasonable range (1-5 beads)
|
||||
const clampedIntensity = Math.max(1, Math.min(5, intensity))
|
||||
|
||||
const now = ctx.currentTime
|
||||
|
||||
// Calculate sound characteristics based on intensity
|
||||
const intensityFactor = Math.sqrt(clampedIntensity) // Square root for natural scaling
|
||||
const volumeMultiplier = 0.8 + (intensityFactor - 1) * 0.3 // 0.8 to 1.4 range
|
||||
const durationMultiplier = 0.8 + (intensityFactor - 1) * 0.4 // Longer decay for more beads
|
||||
const lowFreqBoost = 1 + (intensityFactor - 1) * 0.3 // Lower frequency for more heft
|
||||
|
||||
// Create gain node for volume envelope
|
||||
const gainNode = ctx.createGain()
|
||||
gainNode.connect(ctx.destination)
|
||||
|
||||
// Create primary oscillator for the warm "thock" sound
|
||||
const lowOsc = ctx.createOscillator()
|
||||
lowOsc.type = 'triangle' // Warmer than sine, less harsh than square
|
||||
lowOsc.frequency.setValueAtTime(220 / lowFreqBoost, now) // Lower frequency for more heft
|
||||
|
||||
// Create secondary oscillator for the sharp "click" component
|
||||
const highOsc = ctx.createOscillator()
|
||||
highOsc.type = 'sine'
|
||||
highOsc.frequency.setValueAtTime(1400, now) // Higher frequency for the tap clarity
|
||||
|
||||
// Optional third oscillator for extra richness on multi-bead movements
|
||||
let richOsc: OscillatorNode | null = null
|
||||
let richGain: GainNode | null = null
|
||||
if (clampedIntensity > 2) {
|
||||
richOsc = ctx.createOscillator()
|
||||
richOsc.type = 'triangle'
|
||||
richOsc.frequency.setValueAtTime(110, now) // Sub-harmonic for richness
|
||||
richGain = ctx.createGain()
|
||||
richGain.gain.setValueAtTime(clampedVolume * volumeMultiplier * 0.2 * (intensityFactor - 1), now)
|
||||
richOsc.connect(richGain)
|
||||
richGain.connect(gainNode)
|
||||
}
|
||||
|
||||
// Create separate gain nodes for mixing the two main components
|
||||
const lowGain = ctx.createGain()
|
||||
const highGain = ctx.createGain()
|
||||
|
||||
lowGain.gain.setValueAtTime(clampedVolume * volumeMultiplier * 0.7, now) // Primary component
|
||||
highGain.gain.setValueAtTime(clampedVolume * volumeMultiplier * 0.3, now) // Secondary accent
|
||||
|
||||
// Connect oscillators through their gain nodes to the main envelope
|
||||
lowOsc.connect(lowGain)
|
||||
highOsc.connect(highGain)
|
||||
lowGain.connect(gainNode)
|
||||
highGain.connect(gainNode)
|
||||
|
||||
// Calculate duration based on intensity
|
||||
const baseDuration = 0.08 // 80ms base duration
|
||||
const actualDuration = baseDuration * durationMultiplier
|
||||
|
||||
// Create exponential decay envelope for natural sound
|
||||
gainNode.gain.setValueAtTime(1.0, now)
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, now + actualDuration)
|
||||
|
||||
// Start oscillators
|
||||
lowOsc.start(now)
|
||||
highOsc.start(now)
|
||||
if (richOsc) richOsc.start(now)
|
||||
|
||||
// Stop oscillators at end of envelope
|
||||
const stopTime = now + actualDuration
|
||||
lowOsc.stop(stopTime)
|
||||
highOsc.stop(stopTime)
|
||||
if (richOsc) richOsc.stop(stopTime)
|
||||
|
||||
// Cleanup: disconnect nodes when sound finishes to prevent memory leaks
|
||||
lowOsc.onended = () => {
|
||||
lowOsc.disconnect()
|
||||
highOsc.disconnect()
|
||||
lowGain.disconnect()
|
||||
highGain.disconnect()
|
||||
gainNode.disconnect()
|
||||
if (richOsc && richGain) {
|
||||
richOsc.disconnect()
|
||||
richGain.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
1642
pnpm-lock.yaml
generated
1642
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user