Compare commits

...

21 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
19 changed files with 634 additions and 115 deletions

View File

@@ -12,6 +12,7 @@ permissions:
contents: write
issues: write
pull-requests: write
packages: write
id-token: write
jobs:
@@ -84,34 +85,66 @@ jobs:
VERSION=${LATEST_TAG#abacus-react-v}
echo "Publishing version: $VERSION"
# Set correct version in package.json
npm version $VERSION --no-git-tag-version --allow-same-version
# Resolve any workspace dependencies before publishing
# Replace "workspace:*" with actual version numbers from package.json files
# 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'));
const replaceWorkspace = (deps) => {
// 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 (version.startsWith('workspace:')) {
// For now, replace with '*' since we don't have other workspace packages to reference
result[name] = version.replace('workspace:', '');
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;
};
pkg.dependencies = replaceWorkspace(pkg.dependencies);
pkg.devDependencies = replaceWorkspace(pkg.devDependencies);
pkg.peerDependencies = replaceWorkspace(pkg.peerDependencies);
// 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);
"
npm publish
# 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 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

@@ -23,12 +23,16 @@ export function MemoryPairsGame() {
}, [setFullscreenElement])
return (
<StandardGameLayout>
<StandardGameLayout
theme={{
gameName: "Memory Pairs",
backgroundColor: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
}}
>
<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',

View File

@@ -1731,12 +1731,16 @@ export default function MemoryQuizPage() {
}, [state.prefixAcceptanceTimeout])
return (
<StandardGameLayout>
<StandardGameLayout
theme={{
gameName: "Memory Lightning",
backgroundColor: "linear-gradient(to bottom right, #f0fdf4, #eff6ff)"
}}
>
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
<div
style={{
background: 'linear-gradient(to bottom right, #f0fdf4, #eff6ff)',
flex: 1,
display: 'flex',
flexDirection: 'column',

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

@@ -1,11 +1,16 @@
'use client'
import { ReactNode } from 'react'
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
}
}
/**
@@ -15,7 +20,19 @@ interface StandardGameLayoutProps {
* 3. Perfect viewport fit on all devices
* 4. Consistent experience across all games
*/
export function StandardGameLayout({ children, className }: StandardGameLayoutProps) {
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
@@ -35,7 +52,10 @@ export function StandardGameLayout({ children, className }: StandardGameLayoutPr
// Flex container for game content
display: 'flex',
flexDirection: 'column'
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,61 @@
# [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)

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