feat: add unified trophy abacus with hero mode integration

Implement a single abacus component that seamlessly transitions between three states: hero mode on the home page, compact button in the corner, and full-screen modal display.

Key features:
- Hero mode: Large white abacus at top-center of home page, interactive with beads
- Button mode: Golden mini abacus in bottom-right corner when hero scrolled away or on other pages
- Open mode: Full-screen golden abacus with blur backdrop, accessible from button
- Smooth fly-to-corner animation when scrolling past hero section
- Two-way sync between hero and trophy abacus values via HomeHeroContext
- Highest z-index layer (30000+) to appear above all content

Implementation:
- Create MyAbacus component handling all three states with smooth transitions
- Add MyAbacusContext for global open/close state management
- Move HomeHeroProvider to ClientProviders for global access
- Replace HeroAbacus with HeroSection placeholder on home page
- Fix flashcard z-index by creating proper stacking context (z-index: 1)
- Add MY_ABACUS z-index constants (30000-30001)
- Add calendar page components with correct styled-system imports

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-03 13:12:29 -06:00
parent 7a4a37ec6d
commit 6620418a70
15 changed files with 2922 additions and 381 deletions

View File

@@ -0,0 +1,324 @@
'use client'
import { css } from '../../../../../styled-system/css'
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import Link from 'next/link'
interface CalendarConfigPanelProps {
month: number
year: number
format: 'monthly' | 'daily'
paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid'
isGenerating: boolean
onMonthChange: (month: number) => void
onYearChange: (year: number) => void
onFormatChange: (format: 'monthly' | 'daily') => void
onPaperSizeChange: (size: 'us-letter' | 'a4' | 'a3' | 'tabloid') => void
onGenerate: () => void
}
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
export function CalendarConfigPanel({
month,
year,
format,
paperSize,
isGenerating,
onMonthChange,
onYearChange,
onFormatChange,
onPaperSizeChange,
onGenerate,
}: CalendarConfigPanelProps) {
const abacusConfig = useAbacusConfig()
return (
<div
data-component="calendar-config-panel"
className={css({
bg: 'gray.800',
borderRadius: '12px',
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
})}
>
{/* Format Selection */}
<fieldset
data-section="format-selection"
className={css({
border: 'none',
padding: '0',
margin: '0',
})}
>
<legend
className={css({
fontSize: '1.125rem',
fontWeight: '600',
marginBottom: '0.75rem',
color: 'yellow.400',
})}
>
Calendar Format
</legend>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
<label
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: 'pointer',
padding: '0.5rem',
borderRadius: '6px',
_hover: { bg: 'gray.700' },
})}
>
<input
type="radio"
value="monthly"
checked={format === 'monthly'}
onChange={(e) => onFormatChange(e.target.value as 'monthly' | 'daily')}
className={css({
cursor: 'pointer',
})}
/>
<span>Monthly Calendar (one page per month)</span>
</label>
<label
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
cursor: 'pointer',
padding: '0.5rem',
borderRadius: '6px',
_hover: { bg: 'gray.700' },
})}
>
<input
type="radio"
value="daily"
checked={format === 'daily'}
onChange={(e) => onFormatChange(e.target.value as 'monthly' | 'daily')}
className={css({
cursor: 'pointer',
})}
/>
<span>Daily Calendar (one page per day)</span>
</label>
</div>
</fieldset>
{/* Date Selection */}
<fieldset
data-section="date-selection"
className={css({
border: 'none',
padding: '0',
margin: '0',
})}
>
<legend
className={css({
fontSize: '1.125rem',
fontWeight: '600',
marginBottom: '0.75rem',
color: 'yellow.400',
})}
>
Date
</legend>
<div
className={css({
display: 'flex',
gap: '0.5rem',
})}
>
<select
data-element="month-select"
value={month}
onChange={(e) => onMonthChange(Number(e.target.value))}
className={css({
flex: '1',
padding: '0.5rem',
borderRadius: '6px',
bg: 'gray.700',
color: 'white',
border: '1px solid',
borderColor: 'gray.600',
cursor: 'pointer',
_hover: { borderColor: 'gray.500' },
})}
>
{MONTHS.map((monthName, index) => (
<option key={monthName} value={index + 1}>
{monthName}
</option>
))}
</select>
<input
type="number"
data-element="year-input"
value={year}
onChange={(e) => onYearChange(Number(e.target.value))}
min={1}
max={9999}
className={css({
width: '100px',
padding: '0.5rem',
borderRadius: '6px',
bg: 'gray.700',
color: 'white',
border: '1px solid',
borderColor: 'gray.600',
_hover: { borderColor: 'gray.500' },
})}
/>
</div>
</fieldset>
{/* Paper Size */}
<fieldset
data-section="paper-size"
className={css({
border: 'none',
padding: '0',
margin: '0',
})}
>
<legend
className={css({
fontSize: '1.125rem',
fontWeight: '600',
marginBottom: '0.75rem',
color: 'yellow.400',
})}
>
Paper Size
</legend>
<select
data-element="paper-size-select"
value={paperSize}
onChange={(e) =>
onPaperSizeChange(e.target.value as 'us-letter' | 'a4' | 'a3' | 'tabloid')
}
className={css({
width: '100%',
padding: '0.5rem',
borderRadius: '6px',
bg: 'gray.700',
color: 'white',
border: '1px solid',
borderColor: 'gray.600',
cursor: 'pointer',
_hover: { borderColor: 'gray.500' },
})}
>
<option value="us-letter">US Letter (8.5" × 11")</option>
<option value="a4">A4 (210mm × 297mm)</option>
<option value="a3">A3 (297mm × 420mm)</option>
<option value="tabloid">Tabloid (11" × 17")</option>
</select>
</fieldset>
{/* Abacus Styling Info */}
<div
data-section="styling-info"
className={css({
padding: '1rem',
bg: 'gray.700',
borderRadius: '8px',
})}
>
<p
className={css({
fontSize: '0.875rem',
marginBottom: '0.75rem',
color: 'gray.300',
})}
>
Using your saved abacus style:
</p>
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '0.75rem',
})}
>
<AbacusReact
value={12}
columns={2}
customStyles={abacusConfig.customStyles}
scaleFactor={0.5}
/>
</div>
<Link
href="/create"
data-action="edit-style"
className={css({
display: 'block',
textAlign: 'center',
fontSize: '0.875rem',
color: 'yellow.400',
textDecoration: 'underline',
_hover: { color: 'yellow.300' },
})}
>
Edit your abacus style
</Link>
</div>
{/* Generate Button */}
<button
type="button"
data-action="generate-calendar"
onClick={onGenerate}
disabled={isGenerating}
className={css({
padding: '1rem',
bg: 'yellow.500',
color: 'gray.900',
fontWeight: '600',
fontSize: '1.125rem',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: 'yellow.400',
},
_disabled: {
bg: 'gray.600',
color: 'gray.400',
cursor: 'not-allowed',
},
})}
>
{isGenerating ? 'Generating PDF...' : 'Generate PDF Calendar'}
</button>
</div>
)
}

View File

@@ -0,0 +1,266 @@
'use client'
import { css } from '../../../../../styled-system/css'
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
interface CalendarPreviewProps {
month: number
year: number
format: 'monthly' | 'daily'
}
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
function getFirstDayOfWeek(year: number, month: number): number {
return new Date(year, month - 1, 1).getDay()
}
export function CalendarPreview({ month, year, format }: CalendarPreviewProps) {
const abacusConfig = useAbacusConfig()
const daysInMonth = getDaysInMonth(year, month)
const firstDayOfWeek = getFirstDayOfWeek(year, month)
if (format === 'daily') {
return (
<div
data-component="calendar-preview"
className={css({
bg: 'gray.800',
borderRadius: '12px',
padding: '2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '600px',
})}
>
<p
className={css({
fontSize: '1.25rem',
color: 'gray.300',
marginBottom: '1.5rem',
textAlign: 'center',
})}
>
Daily format preview
</p>
<div
className={css({
bg: 'white',
padding: '3rem 2rem',
borderRadius: '8px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
maxWidth: '400px',
width: '100%',
})}
>
{/* Year at top */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '2rem',
})}
>
<AbacusReact
value={year}
columns={4}
customStyles={abacusConfig.customStyles}
scaleFactor={0.4}
/>
</div>
{/* Large day number */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
marginBottom: '2rem',
})}
>
<AbacusReact
value={1}
columns={2}
customStyles={abacusConfig.customStyles}
scaleFactor={0.8}
/>
</div>
{/* Date text */}
<div
className={css({
textAlign: 'center',
color: 'gray.800',
})}
>
<div
className={css({
fontSize: '1.25rem',
fontWeight: '600',
marginBottom: '0.25rem',
})}
>
{new Date(year, month - 1, 1).toLocaleDateString('en-US', {
weekday: 'long',
})}
</div>
<div
className={css({
fontSize: '1rem',
color: 'gray.600',
})}
>
{MONTHS[month - 1]} 1, {year}
</div>
</div>
</div>
<p
className={css({
fontSize: '0.875rem',
color: 'gray.400',
marginTop: '1rem',
textAlign: 'center',
})}
>
Example of first day (1 page per day for all {daysInMonth} days)
</p>
</div>
)
}
// Monthly format
const calendarDays: (number | null)[] = []
// Add empty cells for days before the first day of month
for (let i = 0; i < firstDayOfWeek; i++) {
calendarDays.push(null)
}
// Add actual days
for (let day = 1; day <= daysInMonth; day++) {
calendarDays.push(day)
}
return (
<div
data-component="calendar-preview"
className={css({
bg: 'gray.800',
borderRadius: '12px',
padding: '2rem',
})}
>
<div
className={css({
textAlign: 'center',
marginBottom: '2rem',
})}
>
<h2
className={css({
fontSize: '2rem',
fontWeight: 'bold',
marginBottom: '1rem',
color: 'yellow.400',
})}
>
{MONTHS[month - 1]} {year}
</h2>
<div
className={css({
display: 'flex',
justifyContent: 'center',
})}
>
<AbacusReact
value={year}
columns={4}
customStyles={abacusConfig.customStyles}
scaleFactor={0.6}
/>
</div>
</div>
{/* Calendar Grid */}
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '0.5rem',
})}
>
{/* Weekday headers */}
{WEEKDAYS.map((day) => (
<div
key={day}
className={css({
textAlign: 'center',
fontWeight: '600',
padding: '0.5rem',
color: 'yellow.400',
fontSize: '0.875rem',
})}
>
{day}
</div>
))}
{/* Calendar days */}
{calendarDays.map((day, index) => (
<div
key={index}
className={css({
aspectRatio: '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: day ? 'gray.700' : 'transparent',
borderRadius: '6px',
padding: '0.25rem',
})}
>
{day && (
<AbacusReact
value={day}
columns={2}
customStyles={abacusConfig.customStyles}
scaleFactor={0.35}
/>
)}
</div>
))}
</div>
<p
className={css({
fontSize: '0.875rem',
color: 'gray.400',
marginTop: '1.5rem',
textAlign: 'center',
})}
>
Preview of monthly calendar layout (actual PDF will be optimized for printing)
</p>
</div>
)
}

View File

@@ -0,0 +1,128 @@
'use client'
import { useState } from 'react'
import { css } from '../../../../styled-system/css'
import { useAbacusConfig } from '@soroban/abacus-react'
import { CalendarConfigPanel } from './components/CalendarConfigPanel'
import { CalendarPreview } from './components/CalendarPreview'
export default function CalendarCreatorPage() {
const currentDate = new Date()
const abacusConfig = useAbacusConfig()
const [month, setMonth] = useState(currentDate.getMonth() + 1) // 1-12
const [year, setYear] = useState(currentDate.getFullYear())
const [format, setFormat] = useState<'monthly' | 'daily'>('monthly')
const [paperSize, setPaperSize] = useState<'us-letter' | 'a4' | 'a3' | 'tabloid'>('us-letter')
const [isGenerating, setIsGenerating] = useState(false)
const handleGenerate = async () => {
setIsGenerating(true)
try {
const response = await fetch('/api/create/calendar/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
month,
year,
format,
paperSize,
abacusConfig,
}),
})
if (!response.ok) {
throw new Error('Failed to generate calendar')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `calendar-${year}-${String(month).padStart(2, '0')}.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('Error generating calendar:', error)
alert('Failed to generate calendar. Please try again.')
} finally {
setIsGenerating(false)
}
}
return (
<div
data-component="calendar-creator"
className={css({
minHeight: '100vh',
bg: 'gray.900',
color: 'white',
padding: '2rem',
})}
>
<div
className={css({
maxWidth: '1400px',
margin: '0 auto',
})}
>
{/* Header */}
<header
data-section="page-header"
className={css({
textAlign: 'center',
marginBottom: '3rem',
})}
>
<h1
className={css({
fontSize: '2.5rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: 'yellow.400',
})}
>
Create Abacus Calendar
</h1>
<p
className={css({
fontSize: '1.125rem',
color: 'gray.300',
})}
>
Generate printable calendars with abacus date numbers
</p>
</header>
{/* Main Content */}
<div
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', lg: '350px 1fr' },
gap: '2rem',
})}
>
{/* Configuration Panel */}
<CalendarConfigPanel
month={month}
year={year}
format={format}
paperSize={paperSize}
isGenerating={isGenerating}
onMonthChange={setMonth}
onYearChange={setYear}
onFormatChange={setFormat}
onPaperSizeChange={setPaperSize}
onGenerate={handleGenerate}
/>
{/* Preview */}
<CalendarPreview month={month} year={year} format={format} />
</div>
</div>
</div>
)
}

View File

@@ -1,11 +1,10 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
import { useTranslations, useMessages } from 'next-intl'
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { HeroAbacus } from '@/components/HeroAbacus'
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
import { useHomeHero } from '@/contexts/HomeHeroContext'
import { PageWithNav } from '@/components/PageWithNav'
import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
import { getTutorialForEditor } from '@/utils/tutorialConverter'
@@ -15,6 +14,135 @@ import { LevelSliderDisplay } from '@/components/LevelSliderDisplay'
import { css } from '../../styled-system/css'
import { container, grid, hstack, stack } from '../../styled-system/patterns'
// Hero section placeholder - the actual abacus is rendered by MyAbacus component
function HeroSection() {
const { subtitle, setIsHeroVisible, isSubtitleLoaded } = useHomeHero()
const heroRef = useRef<HTMLDivElement>(null)
// Detect when hero scrolls out of view
useEffect(() => {
if (!heroRef.current) return
const observer = new IntersectionObserver(
([entry]) => {
setIsHeroVisible(entry.intersectionRatio > 0.2)
},
{
threshold: [0, 0.2, 0.5, 1],
}
)
observer.observe(heroRef.current)
return () => observer.disconnect()
}, [setIsHeroVisible])
return (
<div
ref={heroRef}
className={css({
height: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
bg: 'gray.900',
position: 'relative',
overflow: 'hidden',
px: '4',
py: '12',
})}
>
{/* Background pattern */}
<div
className={css({
position: 'absolute',
inset: 0,
opacity: 0.1,
backgroundImage:
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
backgroundSize: '40px 40px',
})}
/>
{/* Title and Subtitle */}
<div
className={css({
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2',
zIndex: 10,
})}
>
<h1
className={css({
fontSize: { base: '4xl', md: '6xl', lg: '7xl' },
fontWeight: 'bold',
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
backgroundClip: 'text',
color: 'transparent',
})}
>
Abaci One
</h1>
<p
className={css({
fontSize: { base: 'xl', md: '2xl' },
fontWeight: 'medium',
color: 'purple.300',
fontStyle: 'italic',
marginBottom: '8',
opacity: isSubtitleLoaded ? 1 : 0,
transition: 'opacity 0.5s ease-in-out',
})}
>
{subtitle.text}
</p>
</div>
{/* Space for abacus - rendered by MyAbacus component in hero mode */}
<div className={css({ flex: 1 })} />
{/* Scroll hint */}
<div
className={css({
position: 'relative',
fontSize: 'sm',
color: 'gray.400',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2',
animation: 'bounce 2s ease-in-out infinite',
zIndex: 10,
})}
>
<span>Scroll to explore</span>
<span></span>
</div>
{/* Keyframes for bounce animation */}
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes bounce {
0%, 100% {
transform: translateY(0);
opacity: 0.7;
}
50% {
transform: translateY(-10px);
opacity: 1;
}
}
`,
}}
/>
</div>
)
}
// Mini abacus that cycles through a sequence of values
function MiniAbacus({
values,
@@ -119,14 +247,246 @@ export default function HomePage() {
const selectedTutorial = skillTutorials[selectedSkillIndex]
return (
<HomeHeroProvider>
<PageWithNav>
<div className={css({ bg: 'gray.900', minHeight: '100vh' })}>
{/* Hero Section with Large Interactive Abacus */}
<HeroAbacus />
<PageWithNav>
<div className={css({ bg: 'gray.900', minHeight: '100vh' })}>
{/* Hero Section - abacus rendered by MyAbacus in hero mode */}
<HeroSection />
{/* Learn by Doing Section - with inline tutorial demo */}
<section className={stack({ gap: '8', mb: '16', px: '4', py: '12' })}>
{/* Learn by Doing Section - with inline tutorial demo */}
<section className={stack({ gap: '8', mb: '16', px: '4', py: '12' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
{t('learnByDoing.title')}
</h2>
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
{t('learnByDoing.subtitle')}
</p>
</div>
{/* Live demo and learning objectives */}
<div
className={css({
bg: 'rgba(0, 0, 0, 0.4)',
rounded: 'xl',
p: '8',
border: '1px solid',
borderColor: 'gray.700',
shadow: 'lg',
minW: { base: '100%', xl: '1400px' },
mx: 'auto',
})}
>
<div
className={css({
display: 'flex',
flexDirection: { base: 'column', xl: 'row' },
gap: '8',
alignItems: { base: 'center', xl: 'flex-start' },
})}
>
{/* Tutorial on the left */}
<div
className={css({
flex: '1',
minW: { base: '100%', xl: '500px' },
maxW: { base: '100%', xl: '500px' },
})}
>
<TutorialPlayer
key={selectedTutorial.id}
tutorial={selectedTutorial}
isDebugMode={false}
showDebugPanel={false}
hideNavigation={true}
hideTooltip={true}
silentErrors={true}
abacusColumns={1}
theme="dark"
/>
</div>
{/* What you'll learn on the right */}
<div
className={css({
flex: '0 0 auto',
w: { base: '100%', lg: '800px' },
})}
>
<h3
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'white',
mb: '6',
})}
>
{t('whatYouLearn.title')}
</h3>
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '5' })}>
{[
{
title: t('skills.readNumbers.title'),
desc: t('skills.readNumbers.desc'),
example: t('skills.readNumbers.example'),
badge: t('skills.readNumbers.badge'),
values: [0, 1, 2, 3, 4, 5, 10, 50, 100, 500, 999],
columns: 3,
},
{
title: t('skills.friends.title'),
desc: t('skills.friends.desc'),
example: t('skills.friends.example'),
badge: t('skills.friends.badge'),
values: [2, 5, 3],
columns: 1,
},
{
title: t('skills.multiply.title'),
desc: t('skills.multiply.desc'),
example: t('skills.multiply.example'),
badge: t('skills.multiply.badge'),
values: [12, 24, 36, 48],
columns: 2,
},
{
title: t('skills.mental.title'),
desc: t('skills.mental.desc'),
example: t('skills.mental.example'),
badge: t('skills.mental.badge'),
values: [7, 14, 21, 28, 35],
columns: 2,
},
].map((skill, i) => {
const isSelected = i === selectedSkillIndex
return (
<div
key={i}
onClick={() => setSelectedSkillIndex(i)}
className={css({
bg: isSelected
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.15), rgba(250, 204, 21, 0.08))'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03))',
borderRadius: 'xl',
p: { base: '4', lg: '5' },
border: '1px solid',
borderColor: isSelected
? 'rgba(250, 204, 21, 0.4)'
: 'rgba(255, 255, 255, 0.15)',
boxShadow: isSelected
? '0 6px 16px rgba(250, 204, 21, 0.2)'
: '0 4px 12px rgba(0, 0, 0, 0.3)',
transition: 'all 0.2s',
cursor: 'pointer',
_hover: {
bg: isSelected
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(250, 204, 21, 0.12))'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
borderColor: isSelected
? 'rgba(250, 204, 21, 0.5)'
: 'rgba(255, 255, 255, 0.25)',
transform: 'translateY(-2px)',
boxShadow: isSelected
? '0 8px 20px rgba(250, 204, 21, 0.3)'
: '0 6px 16px rgba(0, 0, 0, 0.4)',
},
})}
>
<div className={hstack({ gap: '3', alignItems: 'flex-start' })}>
<div
className={css({
fontSize: '3xl',
width: { base: '120px', lg: '150px' },
minHeight: { base: '115px', lg: '140px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
bg: isSelected
? 'rgba(250, 204, 21, 0.15)'
: 'rgba(255, 255, 255, 0.1)',
borderRadius: 'lg',
})}
>
<MiniAbacus values={skill.values} columns={skill.columns} />
</div>
<div className={stack({ gap: '2', flex: '1', minWidth: '0' })}>
<div
className={hstack({
gap: '2',
alignItems: 'center',
flexWrap: 'wrap',
})}
>
<div
className={css({
color: 'white',
fontSize: 'md',
fontWeight: 'bold',
})}
>
{skill.title}
</div>
<div
className={css({
bg: 'rgba(250, 204, 21, 0.2)',
color: 'yellow.400',
fontSize: '2xs',
fontWeight: 'semibold',
px: '2',
py: '0.5',
borderRadius: 'md',
})}
>
{skill.badge}
</div>
</div>
<div
className={css({
color: 'gray.300',
fontSize: 'xs',
lineHeight: '1.5',
})}
>
{skill.desc}
</div>
<div
className={css({
color: 'yellow.400',
fontSize: 'xs',
fontFamily: 'mono',
fontWeight: 'semibold',
mt: '1',
bg: 'rgba(250, 204, 21, 0.1)',
px: '2',
py: '1',
borderRadius: 'md',
w: 'fit-content',
})}
>
{skill.example}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
</section>
{/* Main content container */}
<div className={container({ maxW: '7xl', px: '4', py: '12' })}>
{/* Current Offerings Section */}
<section className={stack({ gap: '6', mb: '16' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
@@ -136,397 +496,161 @@ export default function HomePage() {
mb: '2',
})}
>
{t('learnByDoing.title')}
{t('arcade.title')}
</h2>
<p className={css({ color: 'gray.400', fontSize: 'md' })}>{t('arcade.subtitle')}</p>
</div>
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '5' })}>
{getAvailableGames().map((game) => {
const playersText =
game.manifest.maxPlayers === 1
? t('arcade.soloChallenge')
: t('arcade.playersCount', { min: 1, max: game.manifest.maxPlayers })
return (
<GameCard
key={game.manifest.name}
icon={game.manifest.icon}
title={game.manifest.displayName}
description={game.manifest.description}
players={playersText}
tags={game.manifest.chips}
gradient={game.manifest.gradient}
href="/games"
/>
)
})}
</div>
</section>
{/* Progression Visualization */}
<section className={stack({ gap: '6', mb: '16', overflow: 'hidden' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
{t('journey.title')}
</h2>
<p style={{ color: '#e5e7eb', fontSize: '16px' }}>{t('journey.subtitle')}</p>
</div>
<LevelSliderDisplay />
</section>
{/* Flashcard Generator Section */}
<section className={stack({ gap: '8', mb: '16' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
{t('flashcards.title')}
</h2>
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
{t('learnByDoing.subtitle')}
{t('flashcards.subtitle')}
</p>
</div>
{/* Live demo and learning objectives */}
{/* Combined interactive display and CTA */}
<div
className={css({
bg: 'rgba(0, 0, 0, 0.4)',
rounded: 'xl',
p: '8',
p: { base: '6', md: '8' },
border: '1px solid',
borderColor: 'gray.700',
shadow: 'lg',
minW: { base: '100%', xl: '1400px' },
maxW: '1200px',
mx: 'auto',
})}
>
<div
className={css({
display: 'flex',
flexDirection: { base: 'column', xl: 'row' },
gap: '8',
alignItems: { base: 'center', xl: 'flex-start' },
})}
>
{/* Tutorial on the left */}
<div
className={css({
flex: '1',
minW: { base: '100%', xl: '500px' },
maxW: { base: '100%', xl: '500px' },
})}
>
<TutorialPlayer
key={selectedTutorial.id}
tutorial={selectedTutorial}
isDebugMode={false}
showDebugPanel={false}
hideNavigation={true}
hideTooltip={true}
silentErrors={true}
abacusColumns={1}
theme="dark"
/>
</div>
{/* Interactive Flashcards Display */}
<div className={css({ mb: '8' })}>
<InteractiveFlashcards />
</div>
{/* What you'll learn on the right */}
<div
className={css({
flex: '0 0 auto',
w: { base: '100%', lg: '800px' },
})}
>
<h3
{/* Features */}
<div className={grid({ columns: { base: 1, md: 3 }, gap: '4', mb: '6' })}>
{[
{
icon: t('flashcards.features.formats.icon'),
title: t('flashcards.features.formats.title'),
desc: t('flashcards.features.formats.desc'),
},
{
icon: t('flashcards.features.customizable.icon'),
title: t('flashcards.features.customizable.title'),
desc: t('flashcards.features.customizable.desc'),
},
{
icon: t('flashcards.features.paperSizes.icon'),
title: t('flashcards.features.paperSizes.title'),
desc: t('flashcards.features.paperSizes.desc'),
},
].map((feature, i) => (
<div
key={i}
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'white',
mb: '6',
textAlign: 'center',
p: '4',
rounded: 'lg',
bg: 'rgba(255, 255, 255, 0.05)',
})}
>
{t('whatYouLearn.title')}
</h3>
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '5' })}>
{[
{
title: t('skills.readNumbers.title'),
desc: t('skills.readNumbers.desc'),
example: t('skills.readNumbers.example'),
badge: t('skills.readNumbers.badge'),
values: [0, 1, 2, 3, 4, 5, 10, 50, 100, 500, 999],
columns: 3,
},
{
title: t('skills.friends.title'),
desc: t('skills.friends.desc'),
example: t('skills.friends.example'),
badge: t('skills.friends.badge'),
values: [2, 5, 3],
columns: 1,
},
{
title: t('skills.multiply.title'),
desc: t('skills.multiply.desc'),
example: t('skills.multiply.example'),
badge: t('skills.multiply.badge'),
values: [12, 24, 36, 48],
columns: 2,
},
{
title: t('skills.mental.title'),
desc: t('skills.mental.desc'),
example: t('skills.mental.example'),
badge: t('skills.mental.badge'),
values: [7, 14, 21, 28, 35],
columns: 2,
},
].map((skill, i) => {
const isSelected = i === selectedSkillIndex
return (
<div
key={i}
onClick={() => setSelectedSkillIndex(i)}
className={css({
bg: isSelected
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.15), rgba(250, 204, 21, 0.08))'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03))',
borderRadius: 'xl',
p: { base: '4', lg: '5' },
border: '1px solid',
borderColor: isSelected
? 'rgba(250, 204, 21, 0.4)'
: 'rgba(255, 255, 255, 0.15)',
boxShadow: isSelected
? '0 6px 16px rgba(250, 204, 21, 0.2)'
: '0 4px 12px rgba(0, 0, 0, 0.3)',
transition: 'all 0.2s',
cursor: 'pointer',
_hover: {
bg: isSelected
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(250, 204, 21, 0.12))'
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
borderColor: isSelected
? 'rgba(250, 204, 21, 0.5)'
: 'rgba(255, 255, 255, 0.25)',
transform: 'translateY(-2px)',
boxShadow: isSelected
? '0 8px 20px rgba(250, 204, 21, 0.3)'
: '0 6px 16px rgba(0, 0, 0, 0.4)',
},
})}
>
<div className={hstack({ gap: '3', alignItems: 'flex-start' })}>
<div
className={css({
fontSize: '3xl',
width: { base: '120px', lg: '150px' },
minHeight: { base: '115px', lg: '140px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
bg: isSelected
? 'rgba(250, 204, 21, 0.15)'
: 'rgba(255, 255, 255, 0.1)',
borderRadius: 'lg',
})}
>
<MiniAbacus values={skill.values} columns={skill.columns} />
</div>
<div className={stack({ gap: '2', flex: '1', minWidth: '0' })}>
<div
className={hstack({
gap: '2',
alignItems: 'center',
flexWrap: 'wrap',
})}
>
<div
className={css({
color: 'white',
fontSize: 'md',
fontWeight: 'bold',
})}
>
{skill.title}
</div>
<div
className={css({
bg: 'rgba(250, 204, 21, 0.2)',
color: 'yellow.400',
fontSize: '2xs',
fontWeight: 'semibold',
px: '2',
py: '0.5',
borderRadius: 'md',
})}
>
{skill.badge}
</div>
</div>
<div
className={css({
color: 'gray.300',
fontSize: 'xs',
lineHeight: '1.5',
})}
>
{skill.desc}
</div>
<div
className={css({
color: 'yellow.400',
fontSize: 'xs',
fontFamily: 'mono',
fontWeight: 'semibold',
mt: '1',
bg: 'rgba(250, 204, 21, 0.1)',
px: '2',
py: '1',
borderRadius: 'md',
w: 'fit-content',
})}
>
{skill.example}
</div>
</div>
</div>
</div>
)
})}
<div className={css({ fontSize: '2xl', mb: '2' })}>{feature.icon}</div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: 'white',
mb: '1',
})}
>
{feature.title}
</div>
<div className={css({ fontSize: 'xs', color: 'gray.400' })}>{feature.desc}</div>
</div>
</div>
))}
</div>
{/* CTA Button */}
<div className={css({ textAlign: 'center' })}>
<Link
href="/create"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '2',
bg: 'blue.600',
color: 'white',
px: '6',
py: '3',
rounded: 'lg',
fontWeight: 'semibold',
transition: 'all 0.2s',
_hover: {
bg: 'blue.500',
},
})}
>
<span>{t('flashcards.cta')}</span>
<span></span>
</Link>
</div>
</div>
</section>
{/* Main content container */}
<div className={container({ maxW: '7xl', px: '4', py: '12' })}>
{/* Current Offerings Section */}
<section className={stack({ gap: '6', mb: '16' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
{t('arcade.title')}
</h2>
<p className={css({ color: 'gray.400', fontSize: 'md' })}>{t('arcade.subtitle')}</p>
</div>
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '5' })}>
{getAvailableGames().map((game) => {
const playersText =
game.manifest.maxPlayers === 1
? t('arcade.soloChallenge')
: t('arcade.playersCount', { min: 1, max: game.manifest.maxPlayers })
return (
<GameCard
key={game.manifest.name}
icon={game.manifest.icon}
title={game.manifest.displayName}
description={game.manifest.description}
players={playersText}
tags={game.manifest.chips}
gradient={game.manifest.gradient}
href="/games"
/>
)
})}
</div>
</section>
{/* Progression Visualization */}
<section className={stack({ gap: '6', mb: '16', overflow: 'hidden' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
{t('journey.title')}
</h2>
<p style={{ color: '#e5e7eb', fontSize: '16px' }}>{t('journey.subtitle')}</p>
</div>
<LevelSliderDisplay />
</section>
{/* Flashcard Generator Section */}
<section className={stack({ gap: '8', mb: '16' })}>
<div className={css({ textAlign: 'center' })}>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '2',
})}
>
{t('flashcards.title')}
</h2>
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
{t('flashcards.subtitle')}
</p>
</div>
{/* Combined interactive display and CTA */}
<div
className={css({
bg: 'rgba(0, 0, 0, 0.4)',
rounded: 'xl',
p: { base: '6', md: '8' },
border: '1px solid',
borderColor: 'gray.700',
shadow: 'lg',
maxW: '1200px',
mx: 'auto',
})}
>
{/* Interactive Flashcards Display */}
<div className={css({ mb: '8' })}>
<InteractiveFlashcards />
</div>
{/* Features */}
<div className={grid({ columns: { base: 1, md: 3 }, gap: '4', mb: '6' })}>
{[
{
icon: t('flashcards.features.formats.icon'),
title: t('flashcards.features.formats.title'),
desc: t('flashcards.features.formats.desc'),
},
{
icon: t('flashcards.features.customizable.icon'),
title: t('flashcards.features.customizable.title'),
desc: t('flashcards.features.customizable.desc'),
},
{
icon: t('flashcards.features.paperSizes.icon'),
title: t('flashcards.features.paperSizes.title'),
desc: t('flashcards.features.paperSizes.desc'),
},
].map((feature, i) => (
<div
key={i}
className={css({
textAlign: 'center',
p: '4',
rounded: 'lg',
bg: 'rgba(255, 255, 255, 0.05)',
})}
>
<div className={css({ fontSize: '2xl', mb: '2' })}>{feature.icon}</div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: 'white',
mb: '1',
})}
>
{feature.title}
</div>
<div className={css({ fontSize: 'xs', color: 'gray.400' })}>
{feature.desc}
</div>
</div>
))}
</div>
{/* CTA Button */}
<div className={css({ textAlign: 'center' })}>
<Link
href="/create"
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '2',
bg: 'blue.600',
color: 'white',
px: '6',
py: '3',
rounded: 'lg',
fontWeight: 'semibold',
transition: 'all 0.2s',
_hover: {
bg: 'blue.500',
},
})}
>
<span>{t('flashcards.cta')}</span>
<span></span>
</Link>
</div>
</div>
</section>
</div>
</div>
</PageWithNav>
</HomeHeroProvider>
</div>
</PageWithNav>
)
}

View File

@@ -13,6 +13,9 @@ import { createQueryClient } from '@/lib/queryClient'
import type { Locale } from '@/i18n/messages'
import { AbacusSettingsSync } from './AbacusSettingsSync'
import { DeploymentInfo } from './DeploymentInfo'
import { MyAbacusProvider } from '@/contexts/MyAbacusContext'
import { MyAbacus } from './MyAbacus'
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
interface ClientProvidersProps {
children: ReactNode
@@ -31,8 +34,13 @@ function InnerProviders({ children }: { children: ReactNode }) {
<UserProfileProvider>
<GameModeProvider>
<FullscreenProvider>
{children}
<DeploymentInfo />
<HomeHeroProvider>
<MyAbacusProvider>
{children}
<DeploymentInfo />
<MyAbacus />
</MyAbacusProvider>
</HomeHeroProvider>
</FullscreenProvider>
</GameModeProvider>
</UserProfileProvider>

View File

@@ -68,6 +68,7 @@ export function InteractiveFlashcards() {
return (
<div
ref={containerRef}
data-component="interactive-flashcards"
className={css({
position: 'relative',
width: '100%',
@@ -78,6 +79,7 @@ export function InteractiveFlashcards() {
bg: 'rgba(0, 0, 0, 0.3)',
rounded: 'xl',
border: '1px solid rgba(255, 255, 255, 0.1)',
zIndex: 1, // Create stacking context so child z-indexes are relative
})}
>
{cards.map((card) => (
@@ -113,7 +115,7 @@ function DraggableCard({ card, containerRef }: DraggableCardProps) {
const handlePointerDown = (e: React.PointerEvent) => {
setIsDragging(true)
setZIndex(1000) // Bring to front
setZIndex(10) // Bring to front within container stacking context
setDragSpeed(0)
// Capture the pointer

View File

@@ -0,0 +1,305 @@
'use client'
import { useContext, useEffect, useState } from 'react'
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { css } from '../../styled-system/css'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { HomeHeroContext } from '@/contexts/HomeHeroContext'
import { Z_INDEX } from '@/constants/zIndex'
export function MyAbacus() {
const { isOpen, close, toggle } = useMyAbacus()
const appConfig = useAbacusConfig()
// Sync with hero context if on home page
const homeHeroContext = useContext(HomeHeroContext)
const [localAbacusValue, setLocalAbacusValue] = useState(1234)
const abacusValue = homeHeroContext?.abacusValue ?? localAbacusValue
const setAbacusValue = homeHeroContext?.setAbacusValue ?? setLocalAbacusValue
// Determine display mode
const isOnHomePage = Boolean(homeHeroContext)
const isHeroVisible = homeHeroContext?.isHeroVisible ?? false
const isHeroMode = isOnHomePage && isHeroVisible && !isOpen
// Close on Escape key
useEffect(() => {
if (!isOpen) return
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
close()
}
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [isOpen, close])
// Prevent body scroll when open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
// Hero mode styles - white structural (from original HeroAbacus)
const structuralStyles = {
columnPosts: {
fill: 'rgb(255, 255, 255)',
stroke: 'rgb(200, 200, 200)',
strokeWidth: 2,
},
reckoningBar: {
fill: 'rgb(255, 255, 255)',
stroke: 'rgb(200, 200, 200)',
strokeWidth: 3,
},
}
// Trophy abacus styles - golden/premium look
const trophyStyles = {
columnPosts: {
fill: '#fbbf24',
stroke: '#f59e0b',
strokeWidth: 3,
},
reckoningBar: {
fill: '#fbbf24',
stroke: '#f59e0b',
strokeWidth: 4,
},
}
return (
<>
{/* Blur backdrop - only visible when open */}
{isOpen && (
<div
data-component="my-abacus-backdrop"
style={{
WebkitBackdropFilter: 'blur(12px)',
}}
className={css({
position: 'fixed',
inset: 0,
bg: 'rgba(0, 0, 0, 0.8)',
backdropFilter: 'blur(12px)',
zIndex: Z_INDEX.MY_ABACUS_BACKDROP,
animation: 'backdropFadeIn 0.4s ease-out',
})}
onClick={close}
/>
)}
{/* Close button - only visible when open */}
{isOpen && (
<button
data-action="close-my-abacus"
onClick={close}
className={css({
position: 'fixed',
top: { base: '4', md: '8' },
right: { base: '4', md: '8' },
w: '12',
h: '12',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'rgba(255, 255, 255, 0.1)',
backdropFilter: 'blur(8px)',
border: '2px solid rgba(255, 255, 255, 0.2)',
borderRadius: 'full',
color: 'white',
fontSize: '2xl',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
zIndex: Z_INDEX.MY_ABACUS + 1,
animation: 'fadeIn 0.3s ease-out 0.2s both',
_hover: {
bg: 'rgba(255, 255, 255, 0.2)',
borderColor: 'rgba(255, 255, 255, 0.4)',
transform: 'scale(1.1)',
},
})}
>
×
</button>
)}
{/* Single abacus element that morphs between states */}
<div
data-component="my-abacus"
data-mode={isOpen ? 'open' : isHeroMode ? 'hero' : 'button'}
onClick={isOpen || isHeroMode ? undefined : toggle}
className={css({
position: 'fixed',
zIndex: Z_INDEX.MY_ABACUS,
cursor: isOpen || isHeroMode ? 'default' : 'pointer',
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
// Three modes: hero (top center), button (bottom-right), open (center)
...(isOpen
? {
// Open mode: center of screen
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}
: isHeroMode
? {
// Hero mode: top center (in viewport flow position)
top: '50vh',
left: '50%',
transform: 'translate(-50%, -50%)',
}
: {
// Button mode: bottom-right corner
bottom: { base: '4', md: '6' },
right: { base: '4', md: '6' },
transform: 'translate(0, 0)',
}),
})}
>
{/* Container that changes between hero, button, and open states */}
<div
className={css({
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
...(isOpen || isHeroMode
? {
// Open/Hero state: no background, just the abacus
bg: 'transparent',
border: 'none',
boxShadow: 'none',
borderRadius: '0',
}
: {
// Button state: button styling
bg: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(8px)',
border: '3px solid rgba(251, 191, 36, 0.5)',
boxShadow: '0 8px 32px rgba(251, 191, 36, 0.4)',
borderRadius: 'xl',
w: { base: '80px', md: '100px' },
h: { base: '80px', md: '100px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
animation: 'pulse 2s ease-in-out infinite',
_hover: {
transform: 'scale(1.1)',
boxShadow: '0 12px 48px rgba(251, 191, 36, 0.6)',
borderColor: 'rgba(251, 191, 36, 0.8)',
},
}),
})}
>
{/* The abacus itself - same element, scales between hero/button/open */}
<div
data-element="abacus-display"
className={css({
transform: isOpen
? { base: 'scale(2.5)', md: 'scale(3.5)', lg: 'scale(4.5)' }
: isHeroMode
? { base: 'scale(3)', md: 'scale(3.5)', lg: 'scale(4.25)' }
: 'scale(0.35)',
transformOrigin: 'center center',
transition: 'transform 0.6s cubic-bezier(0.4, 0, 0.2, 1), filter 0.6s ease',
filter:
isOpen || isHeroMode
? 'drop-shadow(0 10px 40px rgba(251, 191, 36, 0.3))'
: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
pointerEvents: isOpen || isHeroMode ? 'auto' : 'none',
})}
>
<AbacusReact
key={isHeroMode ? 'hero' : isOpen ? 'open' : 'closed'}
value={abacusValue}
columns={isHeroMode ? 4 : 5}
beadShape={appConfig.beadShape}
showNumbers={isOpen || isHeroMode}
interactive={isOpen || isHeroMode}
animated={isOpen || isHeroMode}
customStyles={isHeroMode ? structuralStyles : trophyStyles}
onValueChange={setAbacusValue}
/>
</div>
</div>
{/* Title and achievement info - only visible when open */}
{isOpen && (
<div
className={css({
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
mt: { base: '16', md: '20', lg: '24' },
textAlign: 'center',
animation: 'fadeIn 0.5s ease-out 0.3s both',
maxW: '600px',
px: '8',
})}
>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl', lg: '4xl' },
fontWeight: 'bold',
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
backgroundClip: 'text',
color: 'transparent',
mb: '3',
})}
>
My Abacus
</h2>
<p
className={css({
fontSize: { base: 'md', md: 'lg' },
color: 'gray.300',
mb: '4',
fontWeight: 'medium',
})}
>
Your personal abacus grows with you
</p>
<p
className={css({
fontSize: { base: 'sm', md: 'md' },
color: 'gray.400',
lineHeight: '1.6',
})}
>
Complete tutorials, play games, and earn achievements to unlock higher place values
</p>
</div>
)}
</div>
{/* Keyframes for animations */}
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes backdropFadeIn {
from { opacity: 0; backdrop-filter: blur(0px); -webkit-backdrop-filter: blur(0px); }
to { opacity: 1; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse {
0%, 100% { box-shadow: 0 8px 32px rgba(251, 191, 36, 0.4); }
50% { box-shadow: 0 12px 48px rgba(251, 191, 36, 0.6); }
}
`,
}}
/>
</>
)
}

View File

@@ -26,6 +26,10 @@ export const Z_INDEX = {
// Top-level overlays (20000+)
TOAST: 20000,
// My Abacus - Personal trophy overlay (30000+)
MY_ABACUS_BACKDROP: 30000,
MY_ABACUS: 30001,
// Special navigation layers for game pages
GAME_NAV: {
// Hamburger menu and its nested content

View File

@@ -0,0 +1,35 @@
'use client'
import type React from 'react'
import { createContext, useContext, useState, useCallback } from 'react'
interface MyAbacusContextValue {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}
const MyAbacusContext = createContext<MyAbacusContextValue | undefined>(undefined)
export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
return (
<MyAbacusContext.Provider value={{ isOpen, open, close, toggle }}>
{children}
</MyAbacusContext.Provider>
)
}
export function useMyAbacus() {
const context = useContext(MyAbacusContext)
if (!context) {
throw new Error('useMyAbacus must be used within MyAbacusProvider')
}
return context
}

View File

@@ -0,0 +1,19 @@
{
"permissions": {
"allow": [
"Bash(tree:*)",
"Bash(npx tsc:*)",
"Bash(npm test:*)",
"Bash(npm run test:run:*)",
"Bash(timeout 60 npm run test:run)",
"Bash(git add:*)",
"Bash(git rm:*)",
"Bash(git commit:*)",
"Bash(npm run build:*)"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sqlite"
]
}

View File

@@ -0,0 +1,360 @@
/**
* Abacus 3D Enhancement Styles
* Three levels of progressive enhancement:
* - subtle: CSS perspective + shadows
* - realistic: Lighting + material design
* - delightful: Physics + micro-interactions
*/
/* ============================================
PROPOSAL 1: SUBTLE (CSS Perspective + Shadows)
============================================ */
.abacus-3d-container {
display: inline-block;
position: relative;
}
.abacus-3d-container.enhanced-subtle {
perspective: 1200px;
perspective-origin: 50% 50%;
}
.abacus-3d-container.enhanced-subtle .abacus-svg {
transform-style: preserve-3d;
transform: rotateX(2deg) rotateY(-1deg);
transition: transform 0.3s ease-out;
}
.abacus-3d-container.enhanced-subtle:hover .abacus-svg {
transform: rotateX(0deg) rotateY(0deg);
}
/* Bead depth shadows - subtle */
.abacus-3d-container.enhanced-subtle .abacus-bead.active {
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.25))
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
}
.abacus-3d-container.enhanced-subtle .abacus-bead.inactive {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
/* Frame depth */
.abacus-3d-container.enhanced-subtle rect[class*="column-post"],
.abacus-3d-container.enhanced-subtle rect[class*="reckoning-bar"] {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
/* ============================================
PROPOSAL 2: REALISTIC (Lighting + Materials)
============================================ */
.abacus-3d-container.enhanced-realistic {
perspective: 1200px;
perspective-origin: 50% 50%;
}
.abacus-3d-container.enhanced-realistic .abacus-svg {
transform-style: preserve-3d;
transform: rotateX(3deg) rotateY(-2deg);
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.abacus-3d-container.enhanced-realistic:hover .abacus-svg {
transform: rotateX(0deg) rotateY(0deg);
}
/* Enhanced bead shadows with ambient occlusion */
.abacus-3d-container.enhanced-realistic .abacus-bead.active {
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.3))
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.2))
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15));
}
.abacus-3d-container.enhanced-realistic .abacus-bead.inactive {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15))
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
/* Frame with depth and texture */
.abacus-3d-container.enhanced-realistic rect[class*="column-post"],
.abacus-3d-container.enhanced-realistic rect[class*="reckoning-bar"] {
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.25))
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15));
}
/* Material-specific enhancements */
.abacus-3d-container.enhanced-realistic .bead-material-glossy {
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.3))
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.2))
drop-shadow(0 0 4px rgba(255, 255, 255, 0.3));
}
.abacus-3d-container.enhanced-realistic .bead-material-satin {
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.25))
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
}
.abacus-3d-container.enhanced-realistic .bead-material-matte {
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.2))
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.1));
}
/* Wood grain texture overlay */
.abacus-3d-container.enhanced-realistic .frame-wood {
opacity: 0.15;
mix-blend-mode: multiply;
pointer-events: none;
}
/* Lighting effects - top-down */
.abacus-3d-container.enhanced-realistic.lighting-top-down::before {
content: '';
position: absolute;
top: -10%;
left: 50%;
width: 120%;
height: 30%;
transform: translateX(-50%);
background: radial-gradient(
ellipse at center,
rgba(255, 255, 255, 0.15) 0%,
transparent 70%
);
pointer-events: none;
z-index: 10;
}
/* Lighting effects - ambient */
.abacus-3d-container.enhanced-realistic.lighting-ambient::before {
content: '';
position: absolute;
inset: -10%;
background: radial-gradient(
circle at 50% 50%,
rgba(255, 255, 255, 0.08) 0%,
transparent 60%
);
pointer-events: none;
z-index: 10;
}
/* Lighting effects - dramatic */
.abacus-3d-container.enhanced-realistic.lighting-dramatic::before {
content: '';
position: absolute;
top: -20%;
left: -10%;
width: 60%;
height: 60%;
background: radial-gradient(
ellipse at center,
rgba(255, 255, 255, 0.25) 0%,
transparent 50%
);
pointer-events: none;
z-index: 10;
}
/* ============================================
PROPOSAL 3: DELIGHTFUL (Physics + Micro-interactions)
============================================ */
.abacus-3d-container.enhanced-delightful {
perspective: 1400px;
perspective-origin: 50% 50%;
}
.abacus-3d-container.enhanced-delightful .abacus-svg {
transform-style: preserve-3d;
transform: rotateX(4deg) rotateY(-3deg);
transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.abacus-3d-container.enhanced-delightful:hover .abacus-svg {
transform: rotateX(1deg) rotateY(-0.5deg);
}
/* Maximum depth shadows */
.abacus-3d-container.enhanced-delightful .abacus-bead.active {
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.35))
drop-shadow(0 4px 8px rgba(0, 0, 0, 0.25))
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.abacus-3d-container.enhanced-delightful .abacus-bead.inactive {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15))
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
/* Hover parallax effect */
.abacus-3d-container.enhanced-delightful.parallax-enabled .abacus-bead {
transition: transform 0.15s ease-out, filter 0.15s ease-out;
}
.abacus-3d-container.enhanced-delightful.parallax-enabled .abacus-bead.parallax-lift {
transform: translateZ(4px) scale(1.02);
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.4))
drop-shadow(0 5px 10px rgba(0, 0, 0, 0.3));
}
/* Clack ripple effect */
@keyframes clack-ripple {
0% {
r: 0;
opacity: 0.6;
}
100% {
r: 25;
opacity: 0;
}
}
.clack-ripple {
fill: none;
stroke: currentColor;
stroke-width: 2;
opacity: 0;
}
.clack-ripple.animating {
animation: clack-ripple 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Particle effects */
@keyframes particle-rise {
0% {
transform: translateY(0) scale(1);
opacity: 0.8;
}
100% {
transform: translateY(-20px) scale(0);
opacity: 0;
}
}
@keyframes particle-sparkle {
0%, 100% {
opacity: 0;
transform: scale(0) rotate(0deg);
}
50% {
opacity: 1;
transform: scale(1) rotate(180deg);
}
}
.particle {
pointer-events: none;
}
.particle.particle-subtle {
animation: particle-rise 0.5s ease-out forwards;
}
.particle.particle-sparkle {
animation: particle-sparkle 0.6s ease-in-out forwards;
}
/* Enhanced lighting with multiple sources */
.abacus-3d-container.enhanced-delightful::before {
content: '';
position: absolute;
top: -15%;
left: 50%;
width: 140%;
height: 40%;
transform: translateX(-50%);
background: radial-gradient(
ellipse at center,
rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0.05) 50%,
transparent 80%
);
pointer-events: none;
z-index: 10;
}
.abacus-3d-container.enhanced-delightful::after {
content: '';
position: absolute;
bottom: -10%;
left: 50%;
width: 100%;
height: 20%;
transform: translateX(-50%);
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.1) 0%,
transparent 60%
);
pointer-events: none;
z-index: -1;
}
/* Wobble physics - applied via inline styles from React Spring */
.bead-wobble {
/* transform-origin set dynamically */
transform-style: preserve-3d;
}
/* Frame depth enhancement */
.abacus-3d-container.enhanced-delightful rect[class*="column-post"],
.abacus-3d-container.enhanced-delightful rect[class*="reckoning-bar"] {
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))
drop-shadow(0 0 2px rgba(0, 0, 0, 0.1));
}
/* Wood grain texture - enhanced */
.frame-wood-enhanced {
background-image:
repeating-linear-gradient(
90deg,
transparent,
transparent 2px,
rgba(139, 90, 43, 0.03) 2px,
rgba(139, 90, 43, 0.03) 4px
),
repeating-linear-gradient(
0deg,
transparent,
transparent 1px,
rgba(101, 67, 33, 0.02) 1px,
rgba(101, 67, 33, 0.02) 2px
);
opacity: 0.2;
mix-blend-mode: multiply;
}
/* Accessibility - Reduced motion */
@media (prefers-reduced-motion: reduce) {
.abacus-3d-container.enhanced-subtle .abacus-svg,
.abacus-3d-container.enhanced-realistic .abacus-svg,
.abacus-3d-container.enhanced-delightful .abacus-svg {
transition: none;
transform: none;
}
.abacus-3d-container.enhanced-delightful.parallax-enabled .abacus-bead {
transition: none;
}
.clack-ripple.animating {
animation: none;
}
.particle {
display: none;
}
}
/* Performance optimization - will-change hints */
.abacus-3d-container.enhanced-delightful .abacus-bead {
will-change: transform, filter;
}
.abacus-3d-container.enhanced-realistic .abacus-bead.active {
will-change: filter;
}

View File

@@ -0,0 +1,291 @@
/**
* Utility functions for 3D abacus effects
* Includes gradient generation, color manipulation, and material definitions
*/
import type { BeadMaterial, LightingStyle } from "./AbacusReact";
/**
* Darken a hex color by a given amount (0-1)
*/
export function darkenColor(hex: string, amount: number): string {
// Remove # if present
const color = hex.replace('#', '');
// Parse RGB
const r = parseInt(color.substring(0, 2), 16);
const g = parseInt(color.substring(2, 4), 16);
const b = parseInt(color.substring(4, 6), 16);
// Darken
const newR = Math.max(0, Math.floor(r * (1 - amount)));
const newG = Math.max(0, Math.floor(g * (1 - amount)));
const newB = Math.max(0, Math.floor(b * (1 - amount)));
// Convert back to hex
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
}
/**
* Lighten a hex color by a given amount (0-1)
*/
export function lightenColor(hex: string, amount: number): string {
// Remove # if present
const color = hex.replace('#', '');
// Parse RGB
const r = parseInt(color.substring(0, 2), 16);
const g = parseInt(color.substring(2, 4), 16);
const b = parseInt(color.substring(4, 6), 16);
// Lighten
const newR = Math.min(255, Math.floor(r + (255 - r) * amount));
const newG = Math.min(255, Math.floor(g + (255 - g) * amount));
const newB = Math.min(255, Math.floor(b + (255 - b) * amount));
// Convert back to hex
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
}
/**
* Generate an SVG radial gradient for a bead based on material type
*/
export function getBeadGradient(
id: string,
color: string,
material: BeadMaterial = "satin",
active: boolean = true
): string {
const baseColor = active ? color : "rgb(211, 211, 211)";
switch (material) {
case "glossy":
// High shine with strong highlight
return `
<radialGradient id="${id}" cx="30%" cy="30%">
<stop offset="0%" stop-color="${lightenColor(baseColor, 0.6)}" stop-opacity="0.8" />
<stop offset="20%" stop-color="${lightenColor(baseColor, 0.3)}" />
<stop offset="50%" stop-color="${baseColor}" />
<stop offset="100%" stop-color="${darkenColor(baseColor, 0.4)}" />
</radialGradient>
`;
case "matte":
// Subtle, no shine
return `
<radialGradient id="${id}" cx="50%" cy="50%">
<stop offset="0%" stop-color="${lightenColor(baseColor, 0.1)}" />
<stop offset="80%" stop-color="${baseColor}" />
<stop offset="100%" stop-color="${darkenColor(baseColor, 0.15)}" />
</radialGradient>
`;
case "satin":
default:
// Medium shine, balanced
return `
<radialGradient id="${id}" cx="35%" cy="35%">
<stop offset="0%" stop-color="${lightenColor(baseColor, 0.4)}" stop-opacity="0.9" />
<stop offset="35%" stop-color="${lightenColor(baseColor, 0.15)}" />
<stop offset="70%" stop-color="${baseColor}" />
<stop offset="100%" stop-color="${darkenColor(baseColor, 0.25)}" />
</radialGradient>
`;
}
}
/**
* Generate shadow definition based on lighting style
*/
export function getLightingFilter(lighting: LightingStyle = "top-down"): string {
switch (lighting) {
case "dramatic":
return `
drop-shadow(0 8px 16px rgba(0, 0, 0, 0.4))
drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))
`;
case "ambient":
return `
drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2))
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15))
`;
case "top-down":
default:
return `
drop-shadow(0 6px 12px rgba(0, 0, 0, 0.3))
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.2))
`;
}
}
/**
* Calculate Z-depth for a bead based on enhancement level and state
*/
export function getBeadZDepth(
enhanced3d: boolean | "subtle" | "realistic" | "delightful",
active: boolean
): number {
if (!enhanced3d || enhanced3d === true) return 0;
if (!active) return 0;
switch (enhanced3d) {
case "subtle":
return 6;
case "realistic":
return 10;
case "delightful":
return 12;
default:
return 0;
}
}
/**
* Generate wobble rotation based on velocity (for delightful mode)
*/
export function getWobbleRotation(velocity: number, axis: "x" | "y" = "x"): string {
const maxRotation = 3; // degrees
const rotation = Math.max(-maxRotation, Math.min(maxRotation, velocity * -2));
if (axis === "x") {
return `rotateX(${rotation}deg)`;
}
return `rotateY(${rotation}deg)`;
}
/**
* Calculate parallax offset based on mouse position
*/
export function calculateParallaxOffset(
beadX: number,
beadY: number,
mouseX: number,
mouseY: number,
containerX: number,
containerY: number,
intensity: number = 0.5
): { x: number; y: number; z: number } {
// Calculate distance from bead center to mouse
const dx = (mouseX - containerX) - beadX;
const dy = (mouseY - containerY) - beadY;
const distance = Math.sqrt(dx * dx + dy * dy);
// Max influence radius (pixels)
const maxRadius = 150;
if (distance > maxRadius) {
return { x: 0, y: 0, z: 0 };
}
// Calculate lift amount (inverse square falloff)
const influence = Math.max(0, 1 - (distance / maxRadius));
const lift = influence * influence * intensity;
return {
x: dx * lift * 0.1,
y: dy * lift * 0.1,
z: lift * 8
};
}
/**
* Generate wood grain texture SVG pattern
*/
export function getWoodGrainPattern(id: string): string {
return `
<pattern id="${id}" x="0" y="0" width="100" height="100" patternUnits="userSpaceOnUse">
<rect width="100" height="100" fill="#8B5A2B" opacity="0.3"/>
<!-- Grain lines -->
<path d="M 0 10 Q 25 8 50 10 T 100 10" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.4"/>
<path d="M 0 30 Q 25 28 50 30 T 100 30" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.3"/>
<path d="M 0 50 Q 25 48 50 50 T 100 50" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.4"/>
<path d="M 0 70 Q 25 68 50 70 T 100 70" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.3"/>
<path d="M 0 90 Q 25 88 50 90 T 100 90" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.4"/>
<!-- Knots -->
<ellipse cx="20" cy="25" rx="8" ry="6" fill="#654321" opacity="0.2"/>
<ellipse cx="75" cy="65" rx="6" ry="8" fill="#654321" opacity="0.2"/>
</pattern>
`;
}
/**
* Get container class names for 3D enhancement level
*/
export function get3DContainerClasses(
enhanced3d: boolean | "subtle" | "realistic" | "delightful" | undefined,
lighting?: LightingStyle,
parallaxEnabled?: boolean
): string {
const classes: string[] = ["abacus-3d-container"];
if (!enhanced3d) return classes.join(" ");
// Add enhancement level
if (enhanced3d === true || enhanced3d === "subtle") {
classes.push("enhanced-subtle");
} else if (enhanced3d === "realistic") {
classes.push("enhanced-realistic");
} else if (enhanced3d === "delightful") {
classes.push("enhanced-delightful");
}
// Add lighting class
if (lighting && enhanced3d !== "subtle") {
classes.push(`lighting-${lighting}`);
}
// Add parallax class
if (parallaxEnabled && enhanced3d === "delightful") {
classes.push("parallax-enabled");
}
return classes.join(" ");
}
/**
* Generate unique gradient ID for a bead
*/
export function getBeadGradientId(
columnIndex: number,
beadType: "heaven" | "earth",
position: number,
material: BeadMaterial
): string {
return `bead-gradient-${columnIndex}-${beadType}-${position}-${material}`;
}
/**
* Physics config for different enhancement levels
*/
export function getPhysicsConfig(enhanced3d: boolean | "subtle" | "realistic" | "delightful") {
const base = {
tension: 300,
friction: 22,
mass: 0.5,
clamp: false
};
if (!enhanced3d || enhanced3d === "subtle") {
return { ...base, clamp: true };
}
if (enhanced3d === "realistic") {
return {
tension: 320,
friction: 24,
mass: 0.6,
clamp: false
};
}
// delightful
return {
tension: 280,
friction: 20,
mass: 0.7,
clamp: false, // Allow overshoot for satisfying settle
};
}

View File

@@ -0,0 +1,642 @@
import type { Meta, StoryObj } from '@storybook/react';
import { AbacusReact } from './AbacusReact';
import React, { useEffect, useRef } from 'react';
import './Abacus3D.css';
const meta: Meta<typeof AbacusReact> = {
title: 'Soroban/3D Effects Showcase',
component: AbacusReact,
parameters: {
layout: 'centered',
docs: {
description: {
component: `
# 3D Enhancement Showcase
Three levels of progressive 3D enhancement for the abacus to make interactions feel satisfying and real.
## Proposal 1: Subtle (CSS Perspective + Shadows)
- Light perspective tilt
- Depth shadows on active beads
- Smooth transitions
- **Zero performance cost**
## Proposal 2: Realistic (Lighting + Materials)
- Everything from Proposal 1 +
- Realistic lighting effects
- Material-based bead rendering (glossy/satin/matte)
- Ambient occlusion
- Frame depth
## Proposal 3: Delightful (Physics + Micro-interactions)
- Everything from Proposal 2 +
- Enhanced physics with satisfying bounce
- Clack ripple effects when beads snap
- Hover parallax
- Maximum satisfaction
**Note:** Currently these are CSS-only demos. Full integration with React Spring physics coming next!
`
}
}
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof meta>;
// Wrapper component to apply 3D CSS classes
const Wrapper3D: React.FC<{
children: React.ReactNode;
level: 'subtle' | 'realistic' | 'delightful';
lighting?: 'top-down' | 'ambient' | 'dramatic';
}> = ({ children, level, lighting }) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (containerRef.current) {
const svg = containerRef.current.querySelector('.abacus-svg');
const beads = containerRef.current.querySelectorAll('.abacus-bead');
// Add classes to container
containerRef.current.classList.add('abacus-3d-container');
containerRef.current.classList.add(`enhanced-${level}`);
if (lighting) {
containerRef.current.classList.add(`lighting-${lighting}`);
}
// Apply will-change for performance
if (level === 'delightful') {
beads.forEach(bead => {
(bead as HTMLElement).style.willChange = 'transform, filter';
});
}
}
}, [level, lighting]);
return <div ref={containerRef}>{children}</div>;
};
// ============================================
// PROPOSAL 1: SUBTLE
// ============================================
export const Subtle_Static: Story = {
name: '1. Subtle - Static Display',
render: () => (
<Wrapper3D level="subtle">
<AbacusReact
value={12345}
columns={5}
showNumbers
colorScheme="place-value"
scaleFactor={1.2}
/>
</Wrapper3D>
),
parameters: {
docs: {
description: {
story: 'Subtle 3D with light perspective tilt and depth shadows. Notice the slight elevation of active beads.'
}
}
}
};
export const Subtle_Interactive: Story = {
name: '1. Subtle - Interactive',
render: () => (
<Wrapper3D level="subtle">
<AbacusReact
value={678}
columns={3}
showNumbers
interactive
animated
soundEnabled
colorScheme="heaven-earth"
scaleFactor={1.2}
/>
</Wrapper3D>
),
parameters: {
docs: {
description: {
story: 'Subtle 3D + interaction. Click beads to see depth shadows change. Notice how the perspective gives a sense of physicality.'
}
}
}
};
export const Subtle_Tutorial: Story = {
name: '1. Subtle - Tutorial Mode',
render: () => {
const [step, setStep] = React.useState(0);
const highlights = [
{ placeValue: 0, beadType: 'earth' as const, position: 2 },
{ placeValue: 1, beadType: 'heaven' as const },
{ placeValue: 2, beadType: 'earth' as const, position: 0 },
];
return (
<div style={{ textAlign: 'center' }}>
<Wrapper3D level="subtle">
<AbacusReact
value={123}
columns={3}
showNumbers
interactive
animated
highlightBeads={[highlights[step]]}
colorScheme="place-value"
scaleFactor={1.2}
/>
</Wrapper3D>
<div style={{ marginTop: '20px' }}>
<button onClick={() => setStep((step + 1) % 3)} style={{ padding: '8px 16px' }}>
Next Step ({step + 1}/3)
</button>
</div>
</div>
);
},
parameters: {
docs: {
description: {
story: 'Tutorial mode with subtle 3D effects. The depth helps highlight which bead to focus on.'
}
}
}
};
// ============================================
// PROPOSAL 2: REALISTIC
// ============================================
export const Realistic_TopDown: Story = {
name: '2. Realistic - Top-Down Lighting',
render: () => (
<Wrapper3D level="realistic" lighting="top-down">
<AbacusReact
value={24680}
columns={5}
showNumbers
colorScheme="place-value"
beadShape="circle"
scaleFactor={1.2}
/>
</Wrapper3D>
),
parameters: {
docs: {
description: {
story: 'Realistic 3D with top-down lighting. Notice the enhanced shadows and sense of illumination from above.'
}
}
}
};
export const Realistic_Ambient: Story = {
name: '2. Realistic - Ambient Lighting',
render: () => (
<Wrapper3D level="realistic" lighting="ambient">
<AbacusReact
value={13579}
columns={5}
showNumbers
colorScheme="place-value"
beadShape="diamond"
scaleFactor={1.2}
/>
</Wrapper3D>
),
parameters: {
docs: {
description: {
story: 'Realistic 3D with ambient lighting. Softer, more even illumination creates a cozy feel.'
}
}
}
};
export const Realistic_Dramatic: Story = {
name: '2. Realistic - Dramatic Lighting',
render: () => (
<Wrapper3D level="realistic" lighting="dramatic">
<AbacusReact
value={99999}
columns={5}
showNumbers
colorScheme="heaven-earth"
beadShape="square"
colorPalette="colorblind"
scaleFactor={1.2}
/>
</Wrapper3D>
),
parameters: {
docs: {
description: {
story: 'Realistic 3D with dramatic lighting. Strong directional light creates bold shadows and depth.'
}
}
}
};
export const Realistic_Interactive: Story = {
name: '2. Realistic - Interactive',
render: () => (
<Wrapper3D level="realistic" lighting="top-down">
<AbacusReact
value={555}
columns={3}
showNumbers
interactive
animated
soundEnabled
colorScheme="place-value"
colorPalette="nature"
scaleFactor={1.3}
/>
</Wrapper3D>
),
parameters: {
docs: {
description: {
story: 'Realistic 3D + interaction. Click beads and watch the enhanced shadows and lighting respond. Feel that satisfaction!'
}
}
}
};
export const Realistic_AllShapes: Story = {
name: '2. Realistic - All Bead Shapes',
render: () => (
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}>
<Wrapper3D level="realistic" lighting="top-down">
<AbacusReact
value={777}
columns={3}
showNumbers
beadShape="diamond"
colorScheme="place-value"
/>
</Wrapper3D>
<p style={{ marginTop: '12px', fontSize: '14px' }}>Diamond</p>
</div>
<div style={{ textAlign: 'center' }}>
<Wrapper3D level="realistic" lighting="top-down">
<AbacusReact
value={777}
columns={3}
showNumbers
beadShape="circle"
colorScheme="place-value"
/>
</Wrapper3D>
<p style={{ marginTop: '12px', fontSize: '14px' }}>Circle</p>
</div>
<div style={{ textAlign: 'center' }}>
<Wrapper3D level="realistic" lighting="top-down">
<AbacusReact
value={777}
columns={3}
showNumbers
beadShape="square"
colorScheme="place-value"
/>
</Wrapper3D>
<p style={{ marginTop: '12px', fontSize: '14px' }}>Square</p>
</div>
</div>
),
parameters: {
docs: {
description: {
story: 'Realistic 3D works beautifully with all three bead shapes.'
}
}
}
};
// ============================================
// PROPOSAL 3: DELIGHTFUL
// ============================================
export const Delightful_Static: Story = {
name: '3. Delightful - Maximum Depth',
render: () => (
<Wrapper3D level="delightful">
<AbacusReact
value={11111}
columns={5}
showNumbers
colorScheme="alternating"
beadShape="circle"
colorPalette="mnemonic"
scaleFactor={1.2}
/>
</Wrapper3D>
),
parameters: {
docs: {
description: {
story: 'Delightful 3D with maximum depth and richness. The beads really pop off the page!'
}
}
}
};
export const Delightful_Interactive: Story = {
name: '3. Delightful - Interactive (Physics Ready)',
render: () => (
<Wrapper3D level="delightful">
<AbacusReact
value={987}
columns={3}
showNumbers
interactive
animated
soundEnabled
colorScheme="place-value"
scaleFactor={1.3}
/>
</Wrapper3D>
),
parameters: {
docs: {
description: {
story: 'Delightful 3D + interaction. This is the CSS foundation - physics effects (wobble, clack ripple) will be added in the next iteration. Already feels great!'
}
}
}
};
export const Delightful_LargeScale: Story = {
name: '3. Delightful - Large Scale',
render: () => (
<Wrapper3D level="delightful">
<AbacusReact
value={9876543210}
columns={10}
showNumbers
colorScheme="place-value"
scaleFactor={1}
/>
</Wrapper3D>
),
parameters: {
docs: {
description: {
story: 'Delightful 3D scales beautifully even with many columns. The depth hierarchy helps organize the visual.'
}
}
}
};
// ============================================
// COMPARISON VIEWS
// ============================================
export const CompareAllLevels: Story = {
name: 'Compare All Three Levels',
render: () => {
const value = 4242;
const columns = 4;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '60px', padding: '20px' }}>
{/* No 3D */}
<div style={{ textAlign: 'center' }}>
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
No Enhancement (Current)
</h3>
<AbacusReact
value={value}
columns={columns}
showNumbers
colorScheme="place-value"
scaleFactor={1.2}
/>
</div>
{/* Subtle */}
<div style={{ textAlign: 'center' }}>
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
Proposal 1: Subtle 😊
</h3>
<Wrapper3D level="subtle">
<AbacusReact
value={value}
columns={columns}
showNumbers
colorScheme="place-value"
scaleFactor={1.2}
/>
</Wrapper3D>
<p style={{ fontSize: '12px', color: '#888', marginTop: '12px' }}>
Light tilt + depth shadows
</p>
</div>
{/* Realistic */}
<div style={{ textAlign: 'center' }}>
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
Proposal 2: Realistic 😍
</h3>
<Wrapper3D level="realistic" lighting="top-down">
<AbacusReact
value={value}
columns={columns}
showNumbers
colorScheme="place-value"
scaleFactor={1.2}
/>
</Wrapper3D>
<p style={{ fontSize: '12px', color: '#888', marginTop: '12px' }}>
Lighting + materials + ambient occlusion
</p>
</div>
{/* Delightful */}
<div style={{ textAlign: 'center' }}>
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
Proposal 3: Delightful 🤩
</h3>
<Wrapper3D level="delightful">
<AbacusReact
value={value}
columns={columns}
showNumbers
colorScheme="place-value"
scaleFactor={1.2}
/>
</Wrapper3D>
<p style={{ fontSize: '12px', color: '#888', marginTop: '12px' }}>
Maximum depth + enhanced lighting (physics effects coming next!)
</p>
</div>
</div>
);
},
parameters: {
docs: {
description: {
story: 'Side-by-side comparison of all three enhancement levels. Which feels best to you?'
}
}
}
};
export const CompareInteractive: Story = {
name: 'Compare Interactive (Side-by-Side)',
render: () => {
const [value1, setValue1] = React.useState(123);
const [value2, setValue2] = React.useState(456);
const [value3, setValue3] = React.useState(789);
return (
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}>
<h4 style={{ fontSize: '14px', marginBottom: '12px' }}>Subtle</h4>
<Wrapper3D level="subtle">
<AbacusReact
value={value1}
onValueChange={(v) => setValue1(Number(v))}
columns={3}
showNumbers
interactive
animated
colorScheme="place-value"
/>
</Wrapper3D>
</div>
<div style={{ textAlign: 'center' }}>
<h4 style={{ fontSize: '14px', marginBottom: '12px' }}>Realistic</h4>
<Wrapper3D level="realistic" lighting="top-down">
<AbacusReact
value={value2}
onValueChange={(v) => setValue2(Number(v))}
columns={3}
showNumbers
interactive
animated
colorScheme="place-value"
/>
</Wrapper3D>
</div>
<div style={{ textAlign: 'center' }}>
<h4 style={{ fontSize: '14px', marginBottom: '12px' }}>Delightful</h4>
<Wrapper3D level="delightful">
<AbacusReact
value={value3}
onValueChange={(v) => setValue3(Number(v))}
columns={3}
showNumbers
interactive
animated
colorScheme="place-value"
/>
</Wrapper3D>
</div>
</div>
);
},
parameters: {
docs: {
description: {
story: 'Try all three side-by-side! Click beads and feel the difference in satisfaction.'
}
}
}
};
// ============================================
// FEATURE TESTS
// ============================================
export const ColorSchemes_With3D: Story = {
name: '3D Works With All Color Schemes',
render: () => {
const value = 333;
const schemes: Array<'monochrome' | 'place-value' | 'alternating' | 'heaven-earth'> = [
'monochrome',
'place-value',
'alternating',
'heaven-earth'
];
return (
<div style={{ display: 'flex', gap: '30px', flexWrap: 'wrap', justifyContent: 'center' }}>
{schemes.map(scheme => (
<div key={scheme} style={{ textAlign: 'center' }}>
<Wrapper3D level="realistic" lighting="top-down">
<AbacusReact
value={value}
columns={3}
showNumbers
colorScheme={scheme}
/>
</Wrapper3D>
<p style={{ fontSize: '12px', marginTop: '8px', textTransform: 'capitalize' }}>
{scheme}
</p>
</div>
))}
</div>
);
},
parameters: {
docs: {
description: {
story: 'The 3D effects work seamlessly with all existing color schemes.'
}
}
}
};
export const ColorPalettes_With3D: Story = {
name: '3D Works With All Palettes',
render: () => {
const value = 555;
const palettes: Array<'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'> = [
'default',
'colorblind',
'mnemonic',
'grayscale',
'nature'
];
return (
<div style={{ display: 'flex', gap: '30px', flexWrap: 'wrap', justifyContent: 'center' }}>
{palettes.map(palette => (
<div key={palette} style={{ textAlign: 'center' }}>
<Wrapper3D level="realistic" lighting="top-down">
<AbacusReact
value={value}
columns={3}
showNumbers
colorScheme="place-value"
colorPalette={palette}
/>
</Wrapper3D>
<p style={{ fontSize: '12px', marginTop: '8px', textTransform: 'capitalize' }}>
{palette}
</p>
</div>
))}
</div>
);
},
parameters: {
docs: {
description: {
story: 'The 3D effects enhance all color palettes beautifully.'
}
}
}
};

View File

@@ -6,6 +6,8 @@ import { useDrag } from "@use-gesture/react";
import NumberFlow from "@number-flow/react";
import { useAbacusConfig, getDefaultAbacusConfig } from "./AbacusContext";
import { playBeadSound } from "./soundManager";
import * as Abacus3DUtils from "./Abacus3DUtils";
import "./Abacus3D.css";
// Types
export interface BeadConfig {
@@ -238,6 +240,27 @@ export interface AbacusOverlay {
visible?: boolean;
}
// 3D Enhancement Configuration
export type BeadMaterial = "glossy" | "satin" | "matte";
export type FrameMaterial = "wood" | "metal" | "minimal";
export type LightingStyle = "top-down" | "ambient" | "dramatic";
export interface Abacus3DMaterial {
heavenBeads?: BeadMaterial;
earthBeads?: BeadMaterial;
frame?: FrameMaterial;
lighting?: LightingStyle;
woodGrain?: boolean; // Add wood texture to frame
}
export interface Abacus3DPhysics {
wobble?: boolean; // Beads rotate slightly during movement
clackEffect?: boolean; // Visual ripple when beads snap
hoverParallax?: boolean; // Beads lift on hover
particleSnap?: "off" | "subtle" | "sparkle"; // Particle effects on snap
hapticFeedback?: boolean; // Trigger haptic feedback on mobile
}
export interface AbacusConfig {
// Basic configuration
value?: number | bigint;
@@ -255,6 +278,11 @@ export interface AbacusConfig {
soundEnabled?: boolean;
soundVolume?: number;
// 3D Enhancement
enhanced3d?: boolean | "subtle" | "realistic" | "delightful";
material3d?: Abacus3DMaterial;
physics3d?: Abacus3DPhysics;
// Advanced customization
customStyles?: AbacusCustomStyles;
callbacks?: AbacusCallbacks;
@@ -1540,6 +1568,10 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
showNumbers,
soundEnabled,
soundVolume,
// 3D enhancement props
enhanced3d,
material3d,
physics3d,
// Advanced customization props
customStyles,
callbacks,

View File

@@ -25,6 +25,7 @@
"dist",
"**/*.stories.*",
"**/*.test.*",
"src/test/**/*"
"src/test/**/*",
"src/__tests__/**/*"
]
}