Compare commits

...

22 Commits

Author SHA1 Message Date
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
8 changed files with 426 additions and 288 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

@@ -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,55 @@ 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>
<div
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',
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,15 @@ export default function MemoryQuizPage() {
}, [state.prefixAcceptanceTimeout])
return (
<FullscreenGameLayout title="Memory Lightning">
<StandardGameLayout>
<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 +1748,7 @@ export default function MemoryQuizPage() {
maxWidth: '100%',
margin: '0 auto',
padding: '0 8px',
height: '100%',
flex: 1,
display: 'flex',
flexDirection: 'column'
}}
@@ -1869,12 +1793,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

@@ -4,6 +4,7 @@ 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'
export const metadata: Metadata = {
@@ -29,8 +30,10 @@ export default function RootLayout({
<UserProfileProvider>
<GameModeProvider>
<FullscreenProvider>
<AppNavBar />
{children}
<GameThemeProvider>
<AppNavBar />
{children}
</GameThemeProvider>
</FullscreenProvider>
</GameModeProvider>
</UserProfileProvider>

View File

@@ -0,0 +1,43 @@
'use client'
import { ReactNode } from 'react'
import { css } from '../../styled-system/css'
interface StandardGameLayoutProps {
children: ReactNode
className?: 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 }: StandardGameLayoutProps) {
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'
}, className)}>
{children}
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
export interface GameTheme {
gameName: string
backgroundColor: string
}
interface GameThemeContextType {
theme: GameTheme | null
setTheme: (theme: GameTheme | null) => void
}
const GameThemeContext = createContext<GameThemeContextType | undefined>(undefined)
export function GameThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<GameTheme | null>(null)
return (
<GameThemeContext.Provider value={{ theme, setTheme }}>
{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

@@ -1,3 +1,56 @@
# [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)