Compare commits

..

7 Commits

Author SHA1 Message Date
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
11 changed files with 430 additions and 60 deletions

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

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

@@ -6,6 +6,7 @@ import { css } from '../../styled-system/css'
import { container, hstack } from '../../styled-system/patterns'
import { AbacusDisplayDropdown } from './AbacusDisplayDropdown'
import { useFullscreen } from '../contexts/FullscreenContext'
import { useGameTheme } from '../contexts/GameThemeContext'
interface AppNavBarProps {
variant?: 'full' | 'minimal'
@@ -17,6 +18,74 @@ export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
const isGamePage = pathname?.startsWith('/games')
const isArcadePage = pathname?.startsWith('/arcade')
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
const { theme: gameTheme, isHydrated } = useGameTheme()
// Route-based theme detection as fallback for page reloads
const getRouteBasedTheme = () => {
if (pathname === '/games/memory-quiz') {
return {
gameName: "Memory Lightning",
backgroundColor: "linear-gradient(to bottom right, #f0fdf4, #eff6ff)"
}
}
if (pathname === '/games/matching') {
return {
gameName: "Memory Pairs",
backgroundColor: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
}
}
return null
}
// Use context theme if available, otherwise fall back to route-based detection
const currentTheme = gameTheme || getRouteBasedTheme()
// Helper function to get themed background colors
const getThemedBackground = (opacity: number = 0.85) => {
// Apply theming for route-based themes immediately, or after hydration for context themes
if (currentTheme?.backgroundColor && (getRouteBasedTheme() || isHydrated)) {
const color = currentTheme.backgroundColor
if (color.startsWith('#')) {
// Convert hex to rgba
const hex = color.slice(1)
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${opacity})`
} else if (color.startsWith('rgb')) {
// Handle rgb/rgba formats
const match = color.match(/rgba?\(([^)]+)\)/)
if (match) {
const values = match[1].split(',').map(v => v.trim())
if (values.length >= 3) {
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${opacity})`
}
}
} else if (color.startsWith('linear-gradient')) {
// Extract colors from gradient and use dominant color
const hexMatch = color.match(/#[0-9a-fA-F]{6}/g)
if (hexMatch && hexMatch.length > 0) {
// Use the first color from the gradient
const hex = hexMatch[0].slice(1)
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${opacity})`
}
// Fallback: try to extract rgb values
const rgbMatch = color.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/g)
if (rgbMatch && rgbMatch.length > 0) {
const values = rgbMatch[0].match(/\d+/g)
if (values && values.length >= 3) {
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${opacity})`
}
}
// Final fallback for gradients
return isFullscreen ? `rgba(0, 0, 0, ${opacity})` : `rgba(255, 255, 255, ${opacity})`
}
}
return isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white'
}
// Auto-detect variant based on context
const actualVariant = variant === 'full' && (isGamePage || isArcadePage) ? 'minimal' : variant
@@ -34,28 +103,29 @@ export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
transition: 'all 0.3s ease'
})}>
<div className={hstack({ gap: '2' })}>
{/* Arcade branding (fullscreen only) */}
{/* Game 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)'
})}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '8px 16px',
background: currentTheme ? getThemedBackground(0.85) : 'rgba(0, 0, 0, 0.85)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '8px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
backdropFilter: 'blur(15px)'
}}
>
<h1 className={css({
fontSize: 'lg',
fontWeight: 'bold',
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
background: gameTheme ? 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)' : 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
backgroundClip: 'text',
color: 'transparent'
})}>
🕹 {isArcadePage ? 'Arcade' : 'Game'}
🕹 {currentTheme?.gameName || (isArcadePage ? 'Arcade' : 'Game')}
</h1>
<div className={css({
px: '2',
@@ -73,19 +143,19 @@ export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
)}
{/* 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: currentTheme ? getThemedBackground(isFullscreen ? 0.85 : 1) : (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 +192,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: currentTheme ? getThemedBackground(isFullscreen ? 0.85 : 1) : (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

@@ -1,6 +1,6 @@
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
export interface GameTheme {
gameName: string
@@ -10,15 +10,21 @@ export interface GameTheme {
interface GameThemeContextType {
theme: GameTheme | null
setTheme: (theme: GameTheme | null) => void
isHydrated: boolean
}
const GameThemeContext = createContext<GameThemeContextType | undefined>(undefined)
export function GameThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<GameTheme | null>(null)
const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => {
setIsHydrated(true)
}, [])
return (
<GameThemeContext.Provider value={{ theme, setTheme }}>
<GameThemeContext.Provider value={{ theme, setTheme, isHydrated }}>
{children}
</GameThemeContext.Provider>
)

View File

@@ -1,3 +1,19 @@
# [1.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.2.0...abacus-react-v1.3.0) (2025-09-29)
### Bug Fixes
* ensure game names persist in navigation on page reload ([9191b12](https://github.com/antialias/soroban-abacus-flashcards/commit/9191b124934b9a5577a91f67e8fb6f83b173cc4f))
* implement route-based theme detection for page reload persistence ([3dcff2f](https://github.com/antialias/soroban-abacus-flashcards/commit/3dcff2ff888558d7b746a732cfd53a1897c2b1df))
* improve navigation chrome background color extraction from gradients ([00bfcbc](https://github.com/antialias/soroban-abacus-flashcards/commit/00bfcbcdee28d63094c09a4ae0359789ebcf4a22))
* resolve SSR/client hydration mismatch for themed navigation ([301e65d](https://github.com/antialias/soroban-abacus-flashcards/commit/301e65dfa66d0de6b6efbbfbd09b717308ab57f1))
### Features
* complete themed navigation system with game-specific chrome ([0a4bf17](https://github.com/antialias/soroban-abacus-flashcards/commit/0a4bf1765cbd86bf6f67fb3b99c577cfe3cce075))
* implement cozy sound effects for abacus with variable intensity ([cea5fad](https://github.com/antialias/soroban-abacus-flashcards/commit/cea5fadbe4b4d5ae9e0ee988e9b1c4db09f21ba6))
# [1.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.3...abacus-react-v1.2.0) (2025-09-28)

View File

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

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