Compare commits

..

34 Commits

Author SHA1 Message Date
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
semantic-release-bot
618f5d2cb0 chore(abacus-react): release v1.2.0 [skip ci]
# [1.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.3...abacus-react-v1.2.0) (2025-09-28)

### Bug Fixes

* **abacus-react:** add debugging and explicit authentication for npm publish ([b82e9bb](b82e9bb9d6))
* **abacus-react:** add packages: write permission for GitHub Packages publishing ([8e16487](8e1648737d))
* add missing GameThemeContext file for themed navigation ([d4fbdd1](d4fbdd1463))

### Features

* implement game theming system with context-based navigation chrome ([3fa11c4](3fa11c4fbc))
2025-09-28 17:02:36 +00:00
Thomas Hallock
8e1648737d fix(abacus-react): add packages: write permission for GitHub Packages publishing
- Add missing packages: write permission to workflow permissions
- This is required for publishing to GitHub Packages registry
- Should resolve 403 Forbidden permission_denied error

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:01:57 -05:00
Thomas Hallock
d4fbdd1463 fix: add missing GameThemeContext file for themed navigation
The GameThemeContext.tsx file was referenced in layout.tsx but wasn't properly committed. This context enables games to declare their theming that flows through the navigation chrome.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:00:09 -05:00
Thomas Hallock
b82e9bb9d6 fix(abacus-react): add debugging and explicit authentication for npm publish
- Add debugging output to see .npmrc contents and environment
- Set NODE_AUTH_TOKEN explicitly for npm publish command
- Override NPM_CONFIG_USERCONFIG to use local .npmrc file

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 12:00:01 -05:00
Thomas Hallock
3fa11c4fbc feat: implement game theming system with context-based navigation chrome
Add GameThemeContext to allow games to declare their visual identity (name and background color) that flows through to navigation and layout chrome, creating a cohesive themed experience across games.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 11:59:08 -05:00
semantic-release-bot
342bff739a chore(abacus-react): release v1.1.3 [skip ci]
## [1.1.3](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.2...abacus-react-v1.1.3) (2025-09-28)

### Bug Fixes

* **abacus-react:** force npm to use GitHub Packages registry ([5e6c901](5e6c901f73))
2025-09-28 16:58:37 +00:00
Thomas Hallock
5e6c901f73 fix(abacus-react): force npm to use GitHub Packages registry
- Set publishConfig registry to GitHub Packages in package.json
- Use --registry flag in npm publish command to override default
- This should fix npm trying to publish to registry.npmjs.org instead of GitHub Packages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 11:58:00 -05:00
semantic-release-bot
127cebab69 chore(abacus-react): release v1.1.2 [skip ci]
## [1.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.1...abacus-react-v1.1.2) (2025-09-28)

### Bug Fixes

* **abacus-react:** improve workspace dependency cleanup and add validation ([11fd6f9](11fd6f9b3d))
2025-09-28 16:56:35 +00:00
Thomas Hallock
11fd6f9b3d fix(abacus-react): improve workspace dependency cleanup and add validation
- Set package version directly in Node.js script instead of using npm version
- Add comprehensive workspace dependency cleanup for all dependency types
- Add validation step to ensure no workspace: syntax remains before publishing
- Improved error handling and debugging output

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 11:55:47 -05:00
semantic-release-bot
de4c03e6b2 chore(abacus-react): release v1.1.1 [skip ci]
## [1.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.0...abacus-react-v1.1.1) (2025-09-28)

### Bug Fixes

* **abacus-react:** resolve workspace dependencies before npm publish ([834b062](834b062b2d))
2025-09-28 16:54:15 +00:00
Thomas Hallock
834b062b2d fix(abacus-react): resolve workspace dependencies before npm publish
- Add Node.js script to replace workspace: syntax with actual versions
- Prevent 'Unsupported URL Type workspace:*' error during publishing
- This enables successful GitHub Packages publishing after semantic-release

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 11:53:33 -05:00
semantic-release-bot
5799cc599d chore(abacus-react): release v1.1.0 [skip ci]
# [1.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.0.0...abacus-react-v1.1.0) (2025-09-28)

### Bug Fixes

* **abacus-react:** improve publishing workflow with better version sync ([7a4ecd2](7a4ecd2b59))
* add testing mode for on-screen keyboard and fix toggle functionality ([904074c](904074ca82))
* redesign matching game setup page for StandardGameLayout ([cc1f27f](cc1f27f0f8))
* update memory pairs game to use StandardGameLayout ([8df76c0](8df76c08fd))
* update memory quiz to use StandardGameLayout ([3f86163](3f86163c14))

### Features

* create StandardGameLayout for perfect viewport sizing ([728a920](728a92076a))
* implement innovative dynamic two-panel layout for on-screen keyboard ([4bb8f6d](4bb8f6daf1))
* implement simple fixed bottom keyboard bar ([9ef72d7](9ef72d7e88))
2025-09-28 16:51:32 +00:00
Thomas Hallock
7a4ecd2b59 fix(abacus-react): improve publishing workflow with better version sync
- Add git fetch --tags to ensure latest tags are available
- Extract version from git tag for precise npm version matching
- Improve logging messages for better debugging
- Use --allow-same-version flag for npm version command

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 11:50:45 -05:00
Thomas Hallock
cc1f27f0f8 fix: redesign matching game setup page for StandardGameLayout
- Reduce padding and spacing for better space utilization
- Make start button sticky at bottom to ensure it's always visible
- Add scrolling support for content that exceeds viewport
- Hide game preview on smaller screens to save space
- Ensure start button is never clipped and always accessible

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 11:43:30 -05:00
Thomas Hallock
8df76c08fd fix: update memory pairs game to use StandardGameLayout
- Replace custom layout with StandardGameLayout
- Ensures navigation never covers game elements
- Perfect viewport sizing with no scrolling issues
- Maintains existing game functionality and styling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 11:21:25 -05:00
Thomas Hallock
3f86163c14 fix: update memory quiz to use StandardGameLayout
- Replace FullscreenGameLayout with StandardGameLayout
- Ensures navigation never covers game elements
- Perfect viewport sizing with no scrolling issues
- Maintains existing game functionality and styling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 11:21:16 -05:00
Thomas Hallock
728a92076a feat: create StandardGameLayout for perfect viewport sizing
- Ensures exact 100vh height with no scrolling (vertical or horizontal)
- Navigation never covers game elements with safe area padding (80px top)
- Perfect viewport fit on all devices
- Consistent experience across all games

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 11:21:06 -05:00
Thomas Hallock
9ef72d7e88 feat: implement simple fixed bottom keyboard bar
Replace complex dynamic layout with simple, reliable solution:
- Fixed bottom bar with number buttons (0-9) + delete
- Automatic keyboard detection (with testing mode option)
- No hiding of game elements - proper padding ensures visibility
- Clean horizontal layout with touch-friendly buttons
- No state management complexity or component remounting issues

This pragmatic approach eliminates all previous UI conflicts while
providing an excellent mobile experience for keyboard-less devices.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 11:00:36 -05:00
Thomas Hallock
5d0dacbee5 debug: identify root cause of keyboard hiding issue
Found the actual problem: InputPhase component is being conditionally rendered,
causing React to unmount/remount it on every parent state change. This resets
all internal state including showOnScreenKeyboard.

Current debugging shows:
- State management works correctly
- Toggle button works correctly
- Keyboard briefly appears then disappears
- Issue is component lifecycle, not state logic

Next: Move keyboard state to parent level to persist across re-renders.
2025-09-28 10:48:57 -05:00
Thomas Hallock
904074ca82 fix: add testing mode for on-screen keyboard and fix toggle functionality
- Add testing mode checkbox to enable keyboard on devices with physical keyboards
- Update keyboard visibility conditions to include testing mode
- Show keyboard detection status for debugging
- Fix toggle button hiding issue on desktop devices
- Enable demonstration of dynamic two-panel layout on all devices

Now users can check "Test on-screen keyboard (for demo)" to see the
innovative dynamic layout in action, regardless of their device type.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 10:42:25 -05:00
Thomas Hallock
19b14e9440 Merge branch 'main' of github.com:antialias/soroban-abacus-flashcards 2025-09-28 10:39:21 -05:00
Thomas Hallock
4bb8f6daf1 feat: implement innovative dynamic two-panel layout for on-screen keyboard
Completely eliminates spatial conflict between keyboard and abacus tiles by:

- Dynamic layout resizing: tile grid adjusts to 60% height when keyboard active
- Dedicated keyboard panel: takes bottom 40% as part of layout flow (not overlay)
- Smooth CSS transitions between layout states
- Intelligent keyboard detection with 3-second fallback
- Floating toggle button for keyboard show/hide
- Touch-friendly button design with visual feedback
- No more UI overlap - both elements remain fully accessible

This innovative approach solves the core design problem by fundamentally
redesigning the layout rather than attempting overlay positioning.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-28 10:39:01 -05:00
20 changed files with 942 additions and 382 deletions

View File

@@ -12,6 +12,7 @@ permissions:
contents: write
issues: write
pull-requests: write
packages: write
id-token: write
jobs:
@@ -76,9 +77,74 @@ jobs:
working-directory: packages/abacus-react
run: |
# Only publish if semantic-release created a new version
git fetch --tags
if git tag --list | grep -q "abacus-react-v"; then
echo "Publishing to GitHub Packages..."
npm publish
echo "Found abacus-react version tag. Publishing to GitHub Packages..."
# Update package.json version to match the tag
LATEST_TAG=$(git tag --list "abacus-react-v*" | sort -V | tail -1)
VERSION=${LATEST_TAG#abacus-react-v}
echo "Publishing version: $VERSION"
# Create a clean package.json for publishing by updating version and cleaning workspace references
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
// Set the correct version
pkg.version = '$VERSION';
// Clean workspace dependencies function
const cleanWorkspaceDeps = (deps) => {
if (!deps) return deps;
const result = {};
for (const [name, version] of Object.entries(deps)) {
if (typeof version === 'string' && version.startsWith('workspace:')) {
// Replace workspace: syntax with actual version or latest
result[name] = version.replace('workspace:', '') || '*';
} else {
result[name] = version;
}
}
return result;
};
// Clean all dependency types
if (pkg.dependencies) pkg.dependencies = cleanWorkspaceDeps(pkg.dependencies);
if (pkg.devDependencies) pkg.devDependencies = cleanWorkspaceDeps(pkg.devDependencies);
if (pkg.peerDependencies) pkg.peerDependencies = cleanWorkspaceDeps(pkg.peerDependencies);
if (pkg.optionalDependencies) pkg.optionalDependencies = cleanWorkspaceDeps(pkg.optionalDependencies);
// Set publishConfig for GitHub Packages
pkg.publishConfig = {
access: 'public',
registry: 'https://npm.pkg.github.com'
};
// Write the clean package.json
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
console.log('Created clean package.json for version:', pkg.version);
"
# Verify the package.json is clean
echo "Package.json version: $(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version)")"
# Check for any remaining workspace dependencies
if grep -q "workspace:" package.json; then
echo "ERROR: Still found workspace dependencies in package.json:"
grep "workspace:" package.json
exit 1
fi
# Debug and publish to GitHub Packages
echo "Contents of .npmrc file:"
cat .npmrc
echo "Environment variables for npm:"
echo "NPM_CONFIG_USERCONFIG: $NPM_CONFIG_USERCONFIG"
echo "NODE_AUTH_TOKEN is set: $([ -n "$NODE_AUTH_TOKEN" ] && echo "yes" || echo "no")"
# Set authentication and registry for GitHub Packages
echo "Publishing with explicit authentication..."
NPM_CONFIG_USERCONFIG=.npmrc NODE_AUTH_TOKEN="${{ secrets.GITHUB_TOKEN }}" npm publish --registry=https://npm.pkg.github.com
else
echo "No new version to publish"
echo "No new abacus-react version tag found, skipping publish"
fi

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

@@ -6,6 +6,7 @@ import { useFullscreen } from '../../../../contexts/FullscreenContext'
import { SetupPhase } from './SetupPhase'
import { GamePhase } from './GamePhase'
import { ResultsPhase } from './ResultsPhase'
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
import { css } from '../../../../../styled-system/css'
export function MemoryPairsGame() {
@@ -22,52 +23,59 @@ export function MemoryPairsGame() {
}, [setFullscreenElement])
return (
<div
ref={gameRef}
className={css({
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: { base: '12px', sm: '16px', md: '20px' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative'
})}>
{/* Note: Fullscreen restore prompt removed - client-side navigation preserves fullscreen */}
<header className={css({
textAlign: 'center',
marginBottom: { base: '8px', sm: '12px', md: '16px' },
px: { base: '4', md: '0' },
display: { base: 'none', sm: 'block' }
})}>
<h1 className={css({
fontSize: { base: '16px', sm: '20px', md: '24px' },
fontWeight: 'bold',
color: 'white',
textShadow: '1px 1px 2px rgba(0,0,0,0.3)',
marginBottom: 0
<StandardGameLayout
theme={{
gameName: "Memory Pairs",
backgroundColor: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
}}
>
<div
ref={gameRef}
className={css({
flex: 1,
padding: { base: '12px', sm: '16px', md: '20px' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
overflow: 'auto'
})}>
Memory Pairs
</h1>
</header>
{/* Note: Fullscreen restore prompt removed - client-side navigation preserves fullscreen */}
<main className={css({
width: '100%',
maxWidth: '1200px',
background: 'rgba(255,255,255,0.95)',
borderRadius: { base: '12px', md: '20px' },
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
})}>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <GamePhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</main>
</div>
<header className={css({
textAlign: 'center',
marginBottom: { base: '8px', sm: '12px', md: '16px' },
px: { base: '4', md: '0' },
display: { base: 'none', sm: 'block' }
})}>
<h1 className={css({
fontSize: { base: '16px', sm: '20px', md: '24px' },
fontWeight: 'bold',
color: 'white',
textShadow: '1px 1px 2px rgba(0,0,0,0.3)',
marginBottom: 0
})}>
Memory Pairs
</h1>
</header>
<main className={css({
width: '100%',
maxWidth: '1200px',
background: 'rgba(255,255,255,0.95)',
borderRadius: { base: '12px', md: '20px' },
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
})}>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <GamePhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</main>
</div>
</StandardGameLayout>
)
}

View File

@@ -130,16 +130,17 @@ export function SetupPhase() {
return (
<div className={css({
textAlign: 'center',
padding: { base: '20px 16px', sm: '24px 20px', md: '40px 20px' },
padding: { base: '12px 16px', sm: '16px 20px', md: '20px' },
maxWidth: '800px',
margin: '0 auto',
height: '100%',
display: 'flex',
flexDirection: 'column'
flexDirection: 'column',
minHeight: 0, // Allow shrinking
overflow: 'auto' // Enable scrolling if needed
})}>
<h2 className={css({
fontSize: { base: '20px', sm: '24px', md: '32px', lg: '36px' },
marginBottom: { base: '12px', md: '16px' },
fontSize: { base: '18px', sm: '20px', md: '24px' },
marginBottom: { base: '8px', md: '12px' },
color: 'gray.800',
fontWeight: 'bold'
})}>
@@ -147,20 +148,21 @@ export function SetupPhase() {
</h2>
<p className={css({
fontSize: { base: '14px', sm: '16px', md: '18px' },
fontSize: { base: '13px', sm: '14px', md: '16px' },
color: 'gray.600',
marginBottom: { base: '16px', sm: '20px', md: '32px' },
marginBottom: { base: '12px', sm: '16px', md: '20px' },
lineHeight: '1.4',
display: { base: 'none', md: 'block' }
display: { base: 'none', sm: 'block' }
})}>
Configure your memory challenge. Choose your preferred mode, game type, and difficulty level.
</p>
<div className={css({
display: 'grid',
gap: { base: '12px', sm: '16px', md: '24px' },
gap: { base: '8px', sm: '12px', md: '16px' },
margin: '0 auto',
flex: 1
flex: 1,
minHeight: 0 // Allow shrinking
})}>
{/* Current Player Setup */}
@@ -389,24 +391,34 @@ export function SetupPhase() {
</div>
)}
{/* Start Game Button */}
<div className={css({ marginTop: { base: '16px', md: '20px' } })}>
{/* Start Game Button - Sticky at bottom */}
<div className={css({
marginTop: 'auto', // Push to bottom
paddingTop: { base: '12px', md: '16px' },
position: 'sticky',
bottom: 0,
background: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(10px)',
borderTop: '1px solid rgba(0,0,0,0.1)',
margin: '0 -16px -12px -16px', // Extend to edges
padding: { base: '12px 16px', md: '16px' }
})}>
<button
className={css({
background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
color: 'white',
border: 'none',
borderRadius: { base: '20px', sm: '30px', md: '60px' },
padding: { base: '16px 32px', sm: '18px 40px', md: '20px 60px' },
fontSize: { base: '18px', sm: '22px', md: '28px' },
borderRadius: { base: '16px', sm: '20px', md: '24px' },
padding: { base: '14px 28px', sm: '16px 32px', md: '18px 36px' },
fontSize: { base: '16px', sm: '18px', md: '20px' },
fontWeight: 'black',
cursor: 'pointer',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 10px 30px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
boxShadow: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
position: 'relative',
overflow: 'hidden',
width: { base: '100%', sm: 'auto' },
width: '100%',
_before: {
content: '""',
position: 'absolute',
@@ -418,8 +430,8 @@ export function SetupPhase() {
transition: 'left 0.6s ease',
},
_hover: {
transform: { base: 'translateY(-2px)', md: 'translateY(-5px) scale(1.05)' },
boxShadow: '0 15px 40px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
transform: { base: 'translateY(-2px)', md: 'translateY(-3px) scale(1.02)' },
boxShadow: '0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
background: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
_before: {
left: '100%'
@@ -434,16 +446,16 @@ export function SetupPhase() {
<div className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '8px', md: '12px' },
gap: { base: '6px', md: '8px' },
justifyContent: 'center'
})}>
<span className={css({
fontSize: { base: '20px', sm: '24px', md: '32px' },
fontSize: { base: '18px', sm: '20px', md: '24px' },
animation: 'bounce 2s infinite'
})}>🚀</span>
<span>START GAME</span>
<span className={css({
fontSize: { base: '20px', sm: '24px', md: '32px' },
fontSize: { base: '18px', sm: '20px', md: '24px' },
animation: 'bounce 2s infinite',
animationDelay: '0.5s'
})}>🎮</span>
@@ -451,13 +463,13 @@ export function SetupPhase() {
</button>
</div>
{/* Game Preview - Hidden on mobile */}
{/* Game Preview - Hidden on mobile and small screens */}
<div className={css({
background: 'gray.50',
borderRadius: '12px',
padding: '16px',
marginTop: '16px',
display: { base: 'none', md: 'block' }
display: { base: 'none', lg: 'block' } // Only show on large screens
})}>
<h3 className={css({
fontSize: '16px',

View File

@@ -6,7 +6,7 @@ import { css } from '../../../../styled-system/css'
import { AbacusReact } from '@soroban/abacus-react'
import { useAbacusConfig } from '../../../contexts/AbacusDisplayContext'
import { isPrefix } from '../../../lib/memory-quiz-utils'
import { FullscreenGameLayout } from '../../../components/FullscreenGameLayout'
import { StandardGameLayout } from '../../../components/StandardGameLayout'
interface QuizCard {
@@ -42,6 +42,11 @@ interface SorobanQuizState {
id: string
timestamp: number
}>
// Keyboard state (moved from InputPhase to persist across re-renders)
hasPhysicalKeyboard: boolean | null
testingMode: boolean
showOnScreenKeyboard: boolean
}
type QuizAction =
@@ -60,6 +65,9 @@ type QuizAction =
| { type: 'SET_PREFIX_TIMEOUT'; timeout: NodeJS.Timeout | null }
| { type: 'SHOW_RESULTS' }
| { type: 'RESET_QUIZ' }
| { type: 'SET_PHYSICAL_KEYBOARD'; hasKeyboard: boolean | null }
| { type: 'SET_TESTING_MODE'; enabled: boolean }
| { type: 'TOGGLE_ONSCREEN_KEYBOARD' }
const initialState: SorobanQuizState = {
cards: [],
@@ -76,7 +84,11 @@ const initialState: SorobanQuizState = {
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
wrongGuessAnimations: []
wrongGuessAnimations: [],
// Keyboard state (persistent across re-renders)
hasPhysicalKeyboard: null,
testingMode: false,
showOnScreenKeyboard: false
}
function quizReducer(state: SorobanQuizState, action: QuizAction): SorobanQuizState {
@@ -145,8 +157,18 @@ function quizReducer(state: SorobanQuizState, action: QuizAction): SorobanQuizSt
cards: state.cards, // Preserve generated cards
displayTime: state.displayTime,
selectedCount: state.selectedCount,
selectedDifficulty: state.selectedDifficulty
selectedDifficulty: state.selectedDifficulty,
// Preserve keyboard state across resets
hasPhysicalKeyboard: state.hasPhysicalKeyboard,
testingMode: state.testingMode,
showOnScreenKeyboard: state.showOnScreenKeyboard
}
case 'SET_PHYSICAL_KEYBOARD':
return { ...state, hasPhysicalKeyboard: action.hasKeyboard }
case 'SET_TESTING_MODE':
return { ...state, testingMode: action.enabled }
case 'TOGGLE_ONSCREEN_KEYBOARD':
return { ...state, showOnScreenKeyboard: !state.showOnScreenKeyboard }
default:
return state
}
@@ -949,13 +971,35 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
const containerRef = useRef<HTMLDivElement>(null)
const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>('neutral')
// Keyboard detection state
const [hasPhysicalKeyboard, setHasPhysicalKeyboard] = useState<boolean | null>(null)
const [keyboardDetectionAttempted, setKeyboardDetectionAttempted] = useState(false)
const [showOnScreenKeyboard, setShowOnScreenKeyboard] = useState(false)
// Use keyboard state from parent state instead of local state
const { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard } = state
// Detect physical keyboard availability
// Debug: Log state changes and detect what's causing re-renders
useEffect(() => {
console.log('🔍 Keyboard state changed:', { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard })
console.trace('🔍 State change trace:')
}, [hasPhysicalKeyboard, testingMode, showOnScreenKeyboard])
// Debug: Monitor for unexpected state resets
useEffect(() => {
if (showOnScreenKeyboard) {
const timer = setTimeout(() => {
if (!showOnScreenKeyboard) {
console.error('🚨 Keyboard was unexpectedly hidden!')
}
}, 1000)
return () => clearTimeout(timer)
}
}, [showOnScreenKeyboard])
// Detect physical keyboard availability (disabled when testing mode is active)
useEffect(() => {
// Skip keyboard detection entirely when testing mode is enabled
if (testingMode) {
console.log('🧪 Testing mode enabled - skipping keyboard detection')
return
}
let detectionTimer: NodeJS.Timeout | null = null
const detectKeyboard = () => {
@@ -975,16 +1019,17 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
// - It's a touch device AND has mobile viewport AND lacks precise pointer
const likelyNoKeyboard = isTouchDevice && isMobileViewport && !hasKeyboardSupport
setHasPhysicalKeyboard(!likelyNoKeyboard)
setKeyboardDetectionAttempted(true)
console.log('⌨️ Keyboard detection result:', !likelyNoKeyboard)
dispatch({ type: 'SET_PHYSICAL_KEYBOARD', hasKeyboard: !likelyNoKeyboard })
}
// Test for actual keyboard input within 3 seconds
let keyboardDetected = false
const handleFirstKeyPress = (e: KeyboardEvent) => {
if (/^[0-9]$/.test(e.key)) {
console.log('⌨️ Physical keyboard detected via keypress')
keyboardDetected = true
setHasPhysicalKeyboard(true)
dispatch({ type: 'SET_PHYSICAL_KEYBOARD', hasKeyboard: true })
document.removeEventListener('keypress', handleFirstKeyPress)
if (detectionTimer) clearTimeout(detectionTimer)
}
@@ -996,6 +1041,7 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
// Fallback to heuristic detection after 3 seconds
detectionTimer = setTimeout(() => {
if (!keyboardDetected) {
console.log('⌨️ Using fallback keyboard detection')
detectKeyboard()
}
document.removeEventListener('keypress', handleFirstKeyPress)
@@ -1009,7 +1055,7 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
if (detectionTimer) clearTimeout(detectionTimer)
clearTimeout(initialDetection)
}
}, [])
}, [testingMode])
const acceptCorrectNumber = useCallback((number: number) => {
dispatch({ type: 'ACCEPT_NUMBER', number })
@@ -1143,14 +1189,13 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
<div style={{
textAlign: 'center',
padding: '12px',
paddingBottom: '12px', // Removed extra space since keyboard is now toggleable
paddingBottom: (hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0 ? '100px' : '12px', // Add space for keyboard
maxWidth: '800px',
margin: '0 auto',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
minHeight: '100vh' // Ensure container can scroll if needed
justifyContent: 'flex-start'
}}>
<h3 style={{ marginBottom: '16px', color: '#1f2937', fontSize: '18px', fontWeight: '600' }}>Enter the Numbers You Remember</h3>
<div style={{
@@ -1232,6 +1277,25 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
: '⌨️ Type the numbers you remember'
}
</div>
{/* Testing control - remove in production */}
<div style={{
fontSize: '10px',
color: '#9ca3af',
marginBottom: '4px'
}}>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', justifyContent: 'center' }}>
<input
type="checkbox"
checked={testingMode}
onChange={(e) => dispatch({ type: 'SET_TESTING_MODE', enabled: e.target.checked })}
/>
Test on-screen keyboard (for demo)
</label>
<div style={{ fontSize: '9px', opacity: 0.7 }}>
Keyboard detected: {hasPhysicalKeyboard === null ? 'detecting...' : hasPhysicalKeyboard ? 'yes' : 'no'}
</div>
</div>
<div
style={{
minHeight: '50px',
@@ -1342,153 +1406,43 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
))}
</div>
{/* Toggle button for on-screen keyboard (only shown when no physical keyboard detected) */}
{hasPhysicalKeyboard === false && state.guessesRemaining > 0 && (
{/* Simple fixed keyboard bar - appears when needed, no hiding of game elements */}
{(hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0 && (
<div style={{
position: 'fixed',
bottom: '16px',
right: '16px',
zIndex: 1000
bottom: 0,
left: 0,
right: 0,
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
borderTop: '2px solid #3b82f6',
padding: '12px',
zIndex: 1000,
display: 'flex',
gap: '8px',
justifyContent: 'center',
flexWrap: 'wrap',
boxShadow: '0 -4px 12px rgba(0, 0, 0, 0.1)'
}}>
<button
style={{
width: '56px',
height: '56px',
borderRadius: '50%',
border: '2px solid #3b82f6',
background: showOnScreenKeyboard ? '#3b82f6' : 'white',
color: showOnScreenKeyboard ? 'white' : '#3b82f6',
fontSize: '24px',
cursor: 'pointer',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={() => setShowOnScreenKeyboard(!showOnScreenKeyboard)}
onMouseDown={(e) => e.currentTarget.style.transform = 'scale(0.95)'}
onMouseUp={(e) => e.currentTarget.style.transform = 'scale(1)'}
onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'}
>
</button>
</div>
)}
{/* Collapsible on-screen number pad */}
{hasPhysicalKeyboard === false && state.guessesRemaining > 0 && showOnScreenKeyboard && (
<div style={{
position: 'fixed',
bottom: '80px',
left: '50%',
transform: 'translateX(-50%)',
padding: '16px',
background: 'white',
borderRadius: '16px',
border: '2px solid #3b82f6',
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.2)',
zIndex: 999,
maxWidth: '300px',
width: 'calc(100vw - 32px)',
animation: 'slideUp 0.2s ease-out forwards'
}}>
<div style={{
textAlign: 'center',
marginBottom: '12px',
fontSize: '14px',
color: '#3b82f6',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<span>📱 Tap to enter numbers</span>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map(digit => (
<button
key={digit}
style={{
background: 'none',
border: 'none',
fontSize: '18px',
color: '#6b7280',
cursor: 'pointer',
padding: '4px'
}}
onClick={() => setShowOnScreenKeyboard(false)}
>
</button>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '8px',
marginBottom: '8px'
}}>
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(digit => (
<button
key={digit}
style={{
padding: '16px 12px',
border: '2px solid #e5e7eb',
borderRadius: '12px',
background: 'white',
fontSize: '20px',
fontWeight: 'bold',
color: '#1f2937',
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)'
}}
onMouseDown={(e) => {
e.currentTarget.style.transform = 'scale(0.95)'
e.currentTarget.style.background = '#f3f4f6'
e.currentTarget.style.borderColor = '#3b82f6'
}}
onMouseUp={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.background = 'white'
e.currentTarget.style.borderColor = '#e5e7eb'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.background = 'white'
e.currentTarget.style.borderColor = '#e5e7eb'
}}
onTouchStart={(e) => {
e.currentTarget.style.transform = 'scale(0.95)'
e.currentTarget.style.background = '#f3f4f6'
e.currentTarget.style.borderColor = '#3b82f6'
}}
onTouchEnd={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.background = 'white'
e.currentTarget.style.borderColor = '#e5e7eb'
}}
onClick={() => handleKeyboardInput(digit.toString())}
>
{digit}
</button>
))}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 2fr',
gap: '8px'
}}>
<button
style={{
padding: '16px 12px',
padding: '12px 16px',
border: '2px solid #e5e7eb',
borderRadius: '12px',
borderRadius: '8px',
background: 'white',
fontSize: '20px',
fontSize: '18px',
fontWeight: 'bold',
color: '#1f2937',
cursor: 'pointer',
transition: 'all 0.15s ease',
minWidth: '50px',
minHeight: '50px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)'
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
transition: 'all 0.15s ease'
}}
onMouseDown={(e) => {
e.currentTarget.style.transform = 'scale(0.95)'
@@ -1505,64 +1459,34 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
e.currentTarget.style.background = 'white'
e.currentTarget.style.borderColor = '#e5e7eb'
}}
onTouchStart={(e) => {
e.currentTarget.style.transform = 'scale(0.95)'
e.currentTarget.style.background = '#f3f4f6'
e.currentTarget.style.borderColor = '#3b82f6'
}}
onTouchEnd={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.background = 'white'
e.currentTarget.style.borderColor = '#e5e7eb'
}}
onClick={() => handleKeyboardInput('0')}
onClick={() => handleKeyboardInput(digit.toString())}
>
0
{digit}
</button>
<button
style={{
padding: '16px 12px',
border: '2px solid #dc2626',
borderRadius: '12px',
background: state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb',
fontSize: '16px',
fontWeight: 'bold',
color: state.currentInput.length > 0 ? '#dc2626' : '#9ca3af',
cursor: state.currentInput.length > 0 ? 'pointer' : 'not-allowed',
transition: 'all 0.15s ease',
userSelect: 'none',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)'
}}
disabled={state.currentInput.length === 0}
onMouseDown={(e) => {
if (state.currentInput.length > 0) {
e.currentTarget.style.transform = 'scale(0.95)'
e.currentTarget.style.background = '#fee2e2'
}
}}
onMouseUp={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.background = state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.background = state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb'
}}
onTouchStart={(e) => {
if (state.currentInput.length > 0) {
e.currentTarget.style.transform = 'scale(0.95)'
e.currentTarget.style.background = '#fee2e2'
}
}}
onTouchEnd={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.background = state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb'
}}
onClick={handleKeyboardBackspace}
>
Delete
</button>
</div>
))}
<button
style={{
padding: '12px 16px',
border: '2px solid #dc2626',
borderRadius: '8px',
background: state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb',
fontSize: '14px',
fontWeight: 'bold',
color: state.currentInput.length > 0 ? '#dc2626' : '#9ca3af',
cursor: state.currentInput.length > 0 ? 'pointer' : 'not-allowed',
minWidth: '70px',
minHeight: '50px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none',
transition: 'all 0.15s ease'
}}
disabled={state.currentInput.length === 0}
onClick={handleKeyboardBackspace}
>
</button>
</div>
)}
@@ -1807,15 +1731,19 @@ export default function MemoryQuizPage() {
}, [state.prefixAcceptanceTimeout])
return (
<FullscreenGameLayout title="Memory Lightning">
<StandardGameLayout
theme={{
gameName: "Memory Lightning",
backgroundColor: "linear-gradient(to bottom right, #f0fdf4, #eff6ff)"
}}
>
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
<div
style={{
minHeight: '100vh',
background: 'linear-gradient(to bottom right, #f0fdf4, #eff6ff)',
padding: '8px',
height: '100vh',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto'
}}
>
@@ -1824,7 +1752,7 @@ export default function MemoryQuizPage() {
maxWidth: '100%',
margin: '0 auto',
padding: '0 8px',
height: '100%',
flex: 1,
display: 'flex',
flexDirection: 'column'
}}
@@ -1869,12 +1797,12 @@ export default function MemoryQuizPage() {
})}>
{state.gamePhase === 'setup' && <SetupPhase state={state} dispatch={dispatch} />}
{state.gamePhase === 'display' && <DisplayPhase state={state} dispatch={dispatch} />}
{state.gamePhase === 'input' && <InputPhase state={state} dispatch={dispatch} />}
{state.gamePhase === 'input' && <InputPhase key="input-phase" state={state} dispatch={dispatch} />}
{state.gamePhase === 'results' && <ResultsPhase state={state} dispatch={dispatch} />}
</div>
</div>
</div>
</div>
</FullscreenGameLayout>
</StandardGameLayout>
)
}

View File

@@ -1,40 +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 { 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>
<AppNavBar />
{children}
</FullscreenProvider>
</GameModeProvider>
</UserProfileProvider>
</AbacusDisplayProvider>
<ClientProviders>
<AppNav>{nav}</AppNav>
{children}
</ClientProviders>
</body>
</html>
)

View File

@@ -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,63 @@
'use client'
import { ReactNode, useEffect } from 'react'
import { css } from '../../styled-system/css'
import { useGameTheme } from '../contexts/GameThemeContext'
interface StandardGameLayoutProps {
children: ReactNode
className?: string
theme?: {
gameName: string
backgroundColor: string
}
}
/**
* Standard game layout that ensures:
* 1. Exact 100vh height with no scrolling (vertical or horizontal)
* 2. Navigation never covers game elements (safe area padding)
* 3. Perfect viewport fit on all devices
* 4. Consistent experience across all games
*/
export function StandardGameLayout({ children, className, theme }: StandardGameLayoutProps) {
const { setTheme } = useGameTheme()
// Set the theme when component mounts and clean up on unmount
useEffect(() => {
if (theme) {
setTheme(theme)
}
return () => {
setTheme(null)
}
}, [theme, setTheme])
return (
<div className={css({
// Exact viewport sizing - no scrolling ever
height: '100vh',
width: '100vw',
overflow: 'hidden',
// Safe area for navigation (fixed at top: 4px, right: 4px)
// Navigation is ~60px tall, so we need padding-top of ~80px to be safe
paddingTop: '80px',
paddingRight: '4px', // Ensure nav doesn't overlap content on right side
paddingBottom: '4px',
paddingLeft: '4px',
// Box sizing to include padding in dimensions
boxSizing: 'border-box',
// Flex container for game content
display: 'flex',
flexDirection: 'column',
// Apply the theme background if provided
background: theme?.backgroundColor || 'transparent'
}, className)}>
{children}
</div>
)
}

View File

@@ -12,6 +12,8 @@ export interface AbacusDisplayConfig {
hideInactiveBeads: boolean
coloredNumerals: boolean
scaleFactor: number
soundEnabled: boolean
soundVolume: number
}
export interface AbacusDisplayContextType {
@@ -26,7 +28,9 @@ const DEFAULT_CONFIG: AbacusDisplayConfig = {
beadShape: 'diamond',
hideInactiveBeads: false,
coloredNumerals: false,
scaleFactor: 1.0 // Normalized for display, can be scaled per component
scaleFactor: 1.0, // Normalized for display, can be scaled per component
soundEnabled: true,
soundVolume: 0.8
}
const STORAGE_KEY = 'soroban-abacus-display-config'
@@ -50,7 +54,11 @@ function loadConfigFromStorage(): AbacusDisplayConfig {
coloredNumerals: typeof parsed.coloredNumerals === 'boolean'
? parsed.coloredNumerals : DEFAULT_CONFIG.coloredNumerals,
scaleFactor: typeof parsed.scaleFactor === 'number' && parsed.scaleFactor > 0
? parsed.scaleFactor : DEFAULT_CONFIG.scaleFactor
? parsed.scaleFactor : DEFAULT_CONFIG.scaleFactor,
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

@@ -0,0 +1,39 @@
'use client'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
export interface GameTheme {
gameName: string
backgroundColor: string
}
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, isHydrated }}>
{children}
</GameThemeContext.Provider>
)
}
export function useGameTheme() {
const context = useContext(GameThemeContext)
if (context === undefined) {
throw new Error('useGameTheme must be used within a GameThemeProvider')
}
return context
}

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,86 @@
# [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)
### Bug Fixes
* **abacus-react:** add debugging and explicit authentication for npm publish ([b82e9bb](https://github.com/antialias/soroban-abacus-flashcards/commit/b82e9bb9d6adf3793065067f96c6fbbfd1a78bca))
* **abacus-react:** add packages: write permission for GitHub Packages publishing ([8e16487](https://github.com/antialias/soroban-abacus-flashcards/commit/8e1648737de9305f82872cb9b86b98b5045f77a7))
* add missing GameThemeContext file for themed navigation ([d4fbdd1](https://github.com/antialias/soroban-abacus-flashcards/commit/d4fbdd14630e2f2fcdbc0de23ccc4ccd9eb74b48))
### Features
* implement game theming system with context-based navigation chrome ([3fa11c4](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa11c4fbcbeabeb3bdd0db38374fb9a13cbb754))
## [1.1.3](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.2...abacus-react-v1.1.3) (2025-09-28)
### Bug Fixes
* **abacus-react:** force npm to use GitHub Packages registry ([5e6c901](https://github.com/antialias/soroban-abacus-flashcards/commit/5e6c901f73a68b60ec05f19c4a991ca8affc1589))
## [1.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.1...abacus-react-v1.1.2) (2025-09-28)
### Bug Fixes
* **abacus-react:** improve workspace dependency cleanup and add validation ([11fd6f9](https://github.com/antialias/soroban-abacus-flashcards/commit/11fd6f9b3deb1122d3788a7e0698de891eeb0f3a))
## [1.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.0...abacus-react-v1.1.1) (2025-09-28)
### Bug Fixes
* **abacus-react:** resolve workspace dependencies before npm publish ([834b062](https://github.com/antialias/soroban-abacus-flashcards/commit/834b062b2d22356b9d96bb9c3c444eccaa51d793))
# [1.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.0.0...abacus-react-v1.1.0) (2025-09-28)
### Bug Fixes
* **abacus-react:** improve publishing workflow with better version sync ([7a4ecd2](https://github.com/antialias/soroban-abacus-flashcards/commit/7a4ecd2b5970ed8b6bfde8938b36917f8e7a7176))
* add testing mode for on-screen keyboard and fix toggle functionality ([904074c](https://github.com/antialias/soroban-abacus-flashcards/commit/904074ca821b62cd6b1e129354eb36c5dd4b5e7f))
* redesign matching game setup page for StandardGameLayout ([cc1f27f](https://github.com/antialias/soroban-abacus-flashcards/commit/cc1f27f0f82256f9344531814e8b965fa547d555))
* update memory pairs game to use StandardGameLayout ([8df76c0](https://github.com/antialias/soroban-abacus-flashcards/commit/8df76c08fdf4108b88ce95de252cb8bd559fc5e4))
* update memory quiz to use StandardGameLayout ([3f86163](https://github.com/antialias/soroban-abacus-flashcards/commit/3f86163c142e577a64adfb3bf262656d2e100ced))
### Features
* create StandardGameLayout for perfect viewport sizing ([728a920](https://github.com/antialias/soroban-abacus-flashcards/commit/728a92076a6ac9ef71f0c75d2e9503575881130a))
* implement innovative dynamic two-panel layout for on-screen keyboard ([4bb8f6d](https://github.com/antialias/soroban-abacus-flashcards/commit/4bb8f6daf1f3eecb5cbaf31bf4057f43e43aeb07))
* implement simple fixed bottom keyboard bar ([9ef72d7](https://github.com/antialias/soroban-abacus-flashcards/commit/9ef72d7e88a6a9b30cfd7a7d3944197cc1e0037a))
# 1.0.0 (2025-09-28)

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 {
@@ -1335,7 +1336,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: contextConfig.soundEnabled,
soundVolume: contextConfig.soundVolume
};
// Calculate effective columns first, without depending on columnStates
const effectiveColumns = useMemo(() => {
@@ -1435,6 +1438,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 +1470,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 +1522,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 +1540,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()
}
}
}