Compare commits

..

29 Commits

Author SHA1 Message Date
semantic-release-bot
dd9f688a32 chore(abacus-react): release v1.5.0 [skip ci]
# [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](18af9730ff))
* resolve mini navigation game name persistence across all routes ([3fa314a](3fa314aaa5))
* update pnpm version to 8.15.6 to resolve ERR_INVALID_THIS error in workflow ([0b9bfed](0b9bfed12d))
* update tutorial tests to use consolidated AbacusDisplayProvider ([899fc69](899fc6975f))

### Features

* **abacus-react:** update description to mention GitHub Packages support ([af77256](af7725622e))
* add comprehensive E2E testing with Playwright ([d58053f](d58053fad3))
* add comprehensive Storybook stories for PlayerStatusBar ([8973241](8973241297))
* add consecutive match tracking system for escalating celebrations ([111c0ce](111c0ced71))
* add PlayerStatusBar with escalating celebration animations ([7f8c90a](7f8c90acea))
* add sound settings support to AbacusReact component ([90b9ffa](90b9ffa0d8))
* implement cozy sound effects for abacus with variable intensity ([c95be1d](c95be1df6d))
* integrate user profiles with PlayerStatusBar and game results ([beff646](beff64652c))
2025-09-29 15:58:53 +00:00
Thomas Hallock
0b9bfed12d fix: update pnpm version to 8.15.6 to resolve ERR_INVALID_THIS error in workflow 2025-09-29 10:58:18 -05:00
Thomas Hallock
af7725622e feat(abacus-react): update description to mention GitHub Packages support 2025-09-29 10:55:49 -05:00
Thomas Hallock
18af9730ff fix: remove frozen lockfile flag from publishing workflow to resolve dependency installation issues 2025-09-29 10:55:34 -05:00
Thomas Hallock
beff64652c feat: integrate user profiles with PlayerStatusBar and game results
Complete PlayerStatusBar integration across the matching game:

- Update ResultsPhase to use proper display names and emojis from profiles
- Add missing UserProfile integration for multiplayer results display
- Ensure consistent player presentation throughout game lifecycle
- Remove old debugging logs from page component

Players now see their custom names and emojis consistently from
arcade selection through game completion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:50:28 -05:00
Thomas Hallock
c4d6691715 refactor: replace bulky MemoryGrid stats with compact progress display
Replace large stats panel above card grid with streamlined inline display:

- Convert from 3-column stats grid to single-line format
- Use color-coded inline text: "3 matched • 12 moves • 75% complete"
- Reduce visual weight and padding significantly
- Remove redundant "Total Pairs" information (static, not dynamic)
- Keep essential dynamic information: matched pairs, moves, completion %
- Maintain responsive behavior for mobile/desktop

Recovers ~80px of vertical space while preserving all essential
game progress information in a more elegant format.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:50:16 -05:00
Thomas Hallock
dcefa74902 refactor: streamline GamePhase header and integrate PlayerStatusBar
Clean up duplicate information and improve screen real estate usage:

- Replace verbose header with minimal game mode indicator
- Remove redundant static information (difficulty, player count details)
- Integrate new PlayerStatusBar for comprehensive player state display
- Reduce header padding and visual weight
- Show helpful tips only at game start and hide after first move
- Maintain essential controls (New Game button) in cleaner layout

Recovers significant screen space while maintaining all essential
functionality and improving visual hierarchy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:50:01 -05:00
Thomas Hallock
8973241297 feat: add comprehensive Storybook stories for PlayerStatusBar
Create detailed Storybook documentation showcasing the celebration
animation system with:

- Individual stories for each celebration level (Normal, Great, Epic, Legendary)
- Complete animation showcase with side-by-side comparison
- Proper CSS animation injection for Storybook environment
- Mock player card components that demonstrate all states
- Comprehensive documentation explaining the celebration system
- Visual examples of consecutive match progression

Stories allow designers and developers to see all animation states
and understand the escalating celebration system.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:49:47 -05:00
Thomas Hallock
7f8c90acea feat: add PlayerStatusBar with escalating celebration animations
Create comprehensive PlayerStatusBar component that displays game state
and provides escalating visual celebrations based on consecutive matches:

- Single player mode with epic styling and progress indicators
- Multiplayer mode with competitive grid layout for 1-4 players
- Escalating celebration levels:
  - Great (2+ matches): Green celebration with gentle scaling
  - Epic (3+ matches): Orange celebration with rotation effects
  - Legendary (5+ matches): Purple/gold with dramatic scaling
- Real-time turn indicators with subtle life-like animations
- Streak counters with pulsing effects for active players
- Responsive design with proper mobile/desktop layouts
- Remove overflow clipping to show full glow effects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:49:31 -05:00
Thomas Hallock
111c0ced71 feat: add consecutive match tracking system for escalating celebrations
Add consecutive match tracking to memory pairs game context to enable
escalating celebration effects based on player streaks:

- Add consecutiveMatches state field to track streaks per player
- Increment consecutive matches on successful pairs
- Reset consecutive matches when player turn switches (failed match)
- Initialize consecutive matches for all players on game start

This provides the foundation for visual celebration escalation based
on player performance streaks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:49:15 -05:00
Thomas Hallock
876ace50ec chore: update Claude Code permissions for development workflow
- Add new localhost ports for testing and development
- Include additional bash commands for E2E testing and debugging
- Enable Playwright testing and browser automation permissions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:44:55 -05:00
Thomas Hallock
d58053fad3 feat: add comprehensive E2E testing with Playwright
- Add Playwright configuration for cross-browser testing
- Implement navigation slot persistence tests
- Add sound settings persistence E2E tests
- Ensure robust testing of navigation state management
- Cover edge cases for mini-nav behavior across routes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:44:36 -05:00
Thomas Hallock
899fc6975f fix: update tutorial tests to use consolidated AbacusDisplayProvider
- Update test imports to use centralized abacus-react provider
- Ensure test compatibility with consolidated context management
- Maintain test coverage for tutorial celebration functionality

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:44:17 -05:00
Thomas Hallock
c95be1df6d feat: implement cozy sound effects for abacus with variable intensity
- Add sound intensity based on bead movement distance in games
- Implement gentle sound feedback for abacus interactions
- Update game components to use centralized AbacusDisplayProvider
- Enhance user experience with audio feedback during gameplay

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:43:50 -05:00
Thomas Hallock
a387b030fa refactor: consolidate abacus display context management
- Remove duplicate AbacusDisplayContext in favor of centralized abacus-react provider
- Update all components to use useAbacusDisplay and useAbacusConfig hooks from @soroban/abacus-react
- Create ClientProviders component to centralize provider setup
- Simplify context management across the application

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:43:35 -05:00
Thomas Hallock
90b9ffa0d8 feat: add sound settings support to AbacusReact component
- Add soundEnabled and soundVolume props to AbacusConfig interface
- Allow direct prop overrides for sound settings in component
- Maintain backward compatibility with context-based configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:42:57 -05:00
Thomas Hallock
3fa314aaa5 fix: resolve mini navigation game name persistence across all routes
Add @nav parallel route slots for all major routes to prevent game names
from persisting when navigating between different sections of the app.

- Add @nav/page.tsx (home)
- Add @nav/create/page.tsx (create page)
- Add @nav/guide/page.tsx (guide page)
- Add @nav/games/page.tsx (games listing)
- Keep @nav/games/matching/page.tsx (Memory Pairs game)
- Keep @nav/games/memory-quiz/page.tsx (Memory Lightning game)
- Add comprehensive e2e test covering navigation persistence scenarios

All non-game routes return null (no game name in mini nav).
Only game routes show their specific game names.

Fixes issue where navigating Game → Guide → Games would show stale game name.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 09:22:33 -05:00
semantic-release-bot
0c46f3a7ba chore(abacus-react): release v1.4.0 [skip ci]
# [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](423ba55350))
* migrate viewport from metadata to separate viewport export ([1fe12c4](1fe12c4837))

### Features

* add middleware for pathname header support in [@nav](https://github.com/nav) fallback ([b7e7c4b](b7e7c4beff))
* implement [@nav](https://github.com/nav) parallel routes for game name display in mini navigation ([885fc72](885fc725dc))
2025-09-29 13:54:44 +00:00
Thomas Hallock
1fe12c4837 fix: migrate viewport from metadata to separate viewport export
Move viewport configuration from metadata export to dedicated viewport
export in root layout, following Next.js App Router best practices.

Resolves deprecation warning:
"Unsupported metadata viewport is configured in metadata export"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 08:54:01 -05:00
Thomas Hallock
b7e7c4beff feat: add middleware for pathname header support in @nav fallback
Add Next.js middleware to set x-pathname header on all requests,
enabling Server Components to access pathname for route-based
navigation fallback when @nav slots are not available.

This supports the AppNav component's fallback mechanism for
routes that don't have specific @nav parallel route definitions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 08:54:01 -05:00
Thomas Hallock
423ba55350 fix: export missing hooks and types from @soroban/abacus-react package
- Export useAbacusConfig and useAbacusDisplay hooks from AbacusContext
- Export getDefaultAbacusConfig function and AbacusDisplayProvider component
- Export ColorScheme, BeadShape, ColorPalette, AbacusDisplayConfig, and AbacusDisplayContextType types

Resolves import errors in web components that were trying to import these
hooks but they weren't being exported from the package's main index file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 08:54:01 -05:00
Thomas Hallock
885fc725dc feat: implement @nav parallel routes for game name display in mini navigation
- Add @nav/default.tsx for fallback nav content
- Add @nav/games/matching/page.tsx for Memory Pairs game nav
- Add @nav/games/memory-quiz/page.tsx for Memory Lightning game nav
- Update AppNav to use @nav slot content with header-based fallback
- Remove debug logging from navigation components

The @nav parallel routes pattern allows each game route to declare its own
navigation content server-side, keeping nav content colocated with routes
while avoiding client-side state management or lazy loading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 08:54:01 -05:00
semantic-release-bot
0311b0fe03 chore(abacus-react): release v1.3.0 [skip ci]
# [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](9191b12493))
* implement route-based theme detection for page reload persistence ([3dcff2f](3dcff2ff88))
* improve navigation chrome background color extraction from gradients ([00bfcbc](00bfcbcdee))
* resolve SSR/client hydration mismatch for themed navigation ([301e65d](301e65dfa6))

### Features

* complete themed navigation system with game-specific chrome ([0a4bf17](0a4bf1765c))
* implement cozy sound effects for abacus with variable intensity ([cea5fad](cea5fadbe4))
2025-09-29 11:31:33 +00:00
Thomas Hallock
cea5fadbe4 feat: implement cozy sound effects for abacus with variable intensity
- Add realistic bead click sounds using Web Audio API synthesis
- Support variable intensity based on number of beads moved (1-5)
- Include app-wide sound controls in Style dropdown (enable/disable + volume)
- Settings persist in localStorage with existing style preferences
- SSR-safe implementation with graceful fallback
- Performance optimized with proper audio node cleanup

Sound characteristics:
- Dual-oscillator design (warm thock + sharp click)
- Sub-harmonic richness for multi-bead movements
- Exponential decay envelope for natural sound
- Lower frequencies and longer duration for heavier movements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 06:30:49 -05:00
Thomas Hallock
9191b12493 fix: ensure game names persist in navigation on page reload
Remove hydration dependency for route-based theme detection:
- Game names now display immediately on page load/reload
- Route-based theme backgrounds apply without waiting for hydration
- Maintains SSR compatibility while fixing reload persistence

Game names and themed navigation now work consistently across all scenarios.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:23:37 -05:00
Thomas Hallock
3dcff2ff88 fix: implement route-based theme detection for page reload persistence
Add route-based theme detection as fallback to ensure themed navigation works on direct page loads and reloads:

- Add getRouteBasedTheme() function that detects game themes by pathname
- Use currentTheme that combines context theme with route-based fallback
- Convert navigation chrome to inline styles to bypass Panda CSS caching issues
- Game names and themed backgrounds now persist through page reloads
- Clean up debugging console logs

Navigation theming now works reliably for both navigation events and direct page loads.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:20:57 -05:00
Thomas Hallock
301e65dfa6 fix: resolve SSR/client hydration mismatch for themed navigation
Add hydration state tracking to GameThemeContext to prevent flash of unstyled content:
- Track isHydrated state in GameThemeContext
- Only apply themed backgrounds and game names after client hydration
- Prevents Next.js hydration mismatch where server renders default styles but client overwrites with themed styles
- Eliminates the brief flash where themed navigation appears then reverts to default

Navigation theming now applies consistently without visual flashing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:15:49 -05:00
Thomas Hallock
00bfcbcdee fix: improve navigation chrome background color extraction from gradients
Enhanced getThemedBackground function to properly extract colors from linear gradients:
- Extract hex colors from gradient definitions
- Fall back to RGB value extraction for complex gradients
- Ensure navigation chrome has distinct themed backgrounds instead of inheriting page background
- Maintain proper opacity levels for fullscreen and windowed modes

Now navigation elements display proper themed backgrounds derived from game gradient colors.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:13:34 -05:00
Thomas Hallock
0a4bf1765c feat: complete themed navigation system with game-specific chrome
Implement comprehensive game theming system where games declare their visual identity (name + background) that flows through to navigation chrome:

- Update AppNavBar with GameThemeContext integration and dynamic color calculation
- Enhance StandardGameLayout to accept and apply theme props
- Configure Memory Lightning with green-blue gradient theme
- Configure Memory Pairs with purple gradient theme
- Enable themed navigation backgrounds in fullscreen and non-fullscreen modes
- Display game names in mini navigation instead of generic labels

Games now have cohesive visual branding that extends from background through navigation chrome.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:08:03 -05:00
52 changed files with 2823 additions and 1839 deletions

View File

@@ -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": []

View File

@@ -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

View 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()
})
})

View 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)
})
})

View 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)
})
})

View File

@@ -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",

View 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,
},
})

View File

@@ -0,0 +1,3 @@
export default function CreateNav() {
return null
}

View File

@@ -0,0 +1,3 @@
export default function DefaultNav() {
return null // No navigation content for routes without specific @nav slots
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
export default function GamesNav() {
return null
}

View File

@@ -0,0 +1,3 @@
export default function GuideNav() {
return null
}

View File

@@ -0,0 +1,3 @@
export default function HomeNav() {
return null
}

View 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()
})
})

View File

@@ -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 {

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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',

View File

@@ -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'
}
}

View 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)
}

View File

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

View File

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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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',

View File

@@ -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'

View File

@@ -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>
)

View File

@@ -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>

View 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} />
}

View File

@@ -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'}

View 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>
)
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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

View 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()
})
})

View File

@@ -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()
})
})

View 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()
})
})

View File

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

View File

@@ -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 = {

View File

@@ -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', () => ({

View File

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

View File

@@ -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>
)

View 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).*)',
],
}

View File

@@ -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)

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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(() => {

View File

@@ -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';

View 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

File diff suppressed because it is too large Load Diff