Compare commits

...

15 Commits

Author SHA1 Message Date
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
6 changed files with 350 additions and 285 deletions

View File

@@ -76,9 +76,59 @@ 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..."
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);
// 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
npm publish
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

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

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