Compare commits

...

27 Commits

Author SHA1 Message Date
semantic-release-bot
8871050990 chore(release): 4.57.5 [skip ci]
## [4.57.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.4...v4.57.5) (2025-10-21)

### Bug Fixes

* **homepage:** prevent text overflow in skill cards ([a6ac55b](a6ac55b7b1))
2025-10-21 02:23:27 +00:00
Thomas Hallock
a6ac55b7b1 fix(homepage): prevent text overflow in skill cards
Fixed text being cut off on the right side of skill cards by:
- Adding minWidth: '0' to text container to enable proper flex shrinking
- Adding flexWrap: 'wrap' to title/badge row for better wrapping behavior

This fixes the flexbox issue where text was overflowing the card boundaries.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 21:22:17 -05:00
semantic-release-bot
64e2464ec1 chore(release): 4.57.4 [skip ci]
## [4.57.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.3...v4.57.4) (2025-10-21)

### Bug Fixes

* **homepage:** align container width breakpoint with grid columns ([422bf3d](422bf3d968))
* **homepage:** make MiniAbacus fill container properly ([3b5d147](3b5d14765d))
* **homepage:** widen skill cards container to 650px ([bc1ad3a](bc1ad3a43a))
2025-10-21 00:30:13 +00:00
Thomas Hallock
422bf3d968 fix(homepage): align container width breakpoint with grid columns
Changed width breakpoint from md to lg to match when the grid switches
to 2 columns. This prevents overflow on medium-width screens where the
container was 650px wide but still showing 1-column layout.

Now:
- Below lg: 100% width, 1 column
- At lg+: 650px width, 2 columns

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 19:29:05 -05:00
Thomas Hallock
bc1ad3a43a fix(homepage): widen skill cards container to 650px
Increased the "What You'll Learn" container width from 420px to 650px
to give the 2-column grid of skill cards proper breathing room and
prevent cramped layouts.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 19:27:58 -05:00
Thomas Hallock
3b5d14765d fix(homepage): make MiniAbacus fill container properly
Fixed skill card abacus sizing by:
- Changing MiniAbacus to use width/height 100% instead of fixed 75px/80px
- Increased scale from 0.6 to 0.75 for better visibility
- Now properly fills the 120px/150px container set on the wrapper

This fixes the clipping issue by making the component hierarchy work correctly:
outer container (120px/150px) -> MiniAbacus (100%) -> scaled AbacusReact

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 19:26:45 -05:00
semantic-release-bot
9847f8f461 chore(release): 4.57.3 [skip ci]
## [4.57.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.2...v4.57.3) (2025-10-21)

### Bug Fixes

* **homepage:** increase abacus container width to 120px/150px ([57c212f](57c212f4f5))
2025-10-21 00:25:47 +00:00
Thomas Hallock
57c212f4f5 fix(homepage): increase abacus container width to 120px/150px
Increase abacus container width from 95px/110px to 120px (mobile) and
150px (desktop) to properly accommodate 3-column abacus visualizations
without clipping.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 19:24:37 -05:00
semantic-release-bot
b2f5c19ce3 chore(release): 4.57.2 [skip ci]
## [4.57.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.1...v4.57.2) (2025-10-21)

### Bug Fixes

* **homepage:** increase skill card abacus container width ([e65e969](e65e96952f))
2025-10-21 00:23:44 +00:00
Thomas Hallock
e65e96952f fix(homepage): increase skill card abacus container width
Remove overflow:hidden and increase the abacus container width from
75px to responsive widths (95px mobile, 110px desktop) to properly
accommodate the abacus visualizations without clipping.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 19:22:25 -05:00
semantic-release-bot
556a0eb194 chore(release): 4.57.1 [skip ci]
## [4.57.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.0...v4.57.1) (2025-10-21)

### Bug Fixes

* **homepage:** add overflow hidden to skill cards ([5070d8d](5070d8d64f))
2025-10-21 00:22:09 +00:00
Thomas Hallock
5070d8d64f fix(homepage): add overflow hidden to skill cards
Add overflow: hidden to skill card containers to properly contain
content within card bounds and prevent visual overflow issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 19:20:59 -05:00
semantic-release-bot
54cedbe03a chore(release): 4.57.0 [skip ci]
## [4.57.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.56.0...v4.57.0) (2025-10-21)

### Features

* **homepage:** make skills section responsive with emojis ([9ec0a71](9ec0a71546))

### Bug Fixes

* **homepage:** prevent skill card overflow ([fa26acf](fa26acfbae))
2025-10-21 00:19:34 +00:00
Thomas Hallock
fa26acfbae fix(homepage): prevent skill card overflow
Change abacus container height to minHeight to prevent content
overflow in skill cards. This allows the container to grow as
needed while maintaining minimum dimensions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 19:18:23 -05:00
Thomas Hallock
9ec0a71546 feat(homepage): make skills section responsive with emojis
Update "What You'll Learn" section to display in a responsive grid:
- One column on mobile/tablet
- Two columns on larger screens (lg breakpoint)
- Increased padding and height on two-column layout
- Added emojis to skill titles for better visual appeal

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 19:17:26 -05:00
semantic-release-bot
6448249512 chore(release): 4.56.0 [skip ci]
## [4.56.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.55.0...v4.56.0) (2025-10-20)

### Features

* **homepage:** emphasize single-player and observer modes ([a537bc1](a537bc18c3))
2025-10-20 23:52:50 +00:00
Thomas Hallock
a537bc18c3 feat(homepage): emphasize single-player and observer modes
Update arcade section subtitle to highlight both single-player
challenges and multiplayer battles. Also mention the ability to
invite friends to observe games live over the network.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 18:51:34 -05:00
semantic-release-bot
33ab7aaaf0 chore(release): 4.55.0 [skip ci]
## [4.55.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.54.0...v4.55.0) (2025-10-20)

### Features

* **homepage:** update section title to "The Arcade" ([f47b172](f47b172f66))
2025-10-20 23:46:47 +00:00
Thomas Hallock
f47b172f66 feat(homepage): update section title to "The Arcade"
Replace "Available Now" section with "The Arcade" to better describe
the multiplayer room system. Updated subtitle to explain that users
can create or join rooms to play real-time games with friends over
the network.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 18:45:31 -05:00
semantic-release-bot
9d25e1dd35 chore(release): 4.54.0 [skip ci]
## [4.54.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.53.0...v4.54.0) (2025-10-20)

### Features

* **homepage:** add interactive levels slider to replace static progression ([8a2d5ae](8a2d5ae319))

### Code Refactoring

* **levels:** extract levels slider into shared component ([015e30b](015e30b085))
2025-10-20 23:31:37 +00:00
Thomas Hallock
015e30b085 refactor(levels): extract levels slider into shared component
Consolidate the complete levels slider UI from /levels page into a reusable
LevelSliderDisplay component. This eliminates code duplication and provides
a single source of truth for the levels progression display.

Changes:
- Created LevelSliderDisplay component (862 lines) with all levels data,
  state management, animations, and UI
- Updated /levels page to use shared component (959 → 117 lines, 87.8% reduction)
- Updated homepage to use shared component (removed duplicate state/data)
- Deleted incomplete LevelsSlider component from previous commit

Component includes:
- Complete allLevels array (21 levels: 10th Kyu through 10th Dan)
- Interactive Radix slider with emoji tick marks
- StandaloneBead thumb with animated emoji transitions
- Level details for Kyu levels (Add/Sub, Multiply, Divide stats)
- Animated abacus display with speed scaling for Dan levels
- Auto-advance functionality (3s interval, pauses on hover)
- Dark theme styling

Both homepage and /levels page now use the same component with identical
behavior and appearance, eliminating maintenance overhead.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 18:30:22 -05:00
Thomas Hallock
8a2d5ae319 feat(homepage): add interactive levels slider to replace static progression
Replace the static emoji progression display in "Your Journey" section with
an interactive slider component adapted from /levels page. Features:

- Auto-advancing slider (3s intervals, pauses on hover)
- Interactive bead thumb with animated emoji overlay
- Animated abacus display that changes with level
- Shows 8 key milestone levels (10th Kyu through 10th Dan)
- Emoji tick marks above slider for visual navigation
- Dynamic border colors based on level category

Components:
- Created LevelsSlider component to encapsulate the UI
- Reuses existing React Spring animations from /levels page
- Uses Radix Slider for accessible interaction
- Dark theme abacus styling for consistency

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 18:30:22 -05:00
semantic-release-bot
d2ff2c6a29 chore(release): 4.53.0 [skip ci]
## [4.53.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.2...v4.53.0) (2025-10-20)

### Features

* **homepage:** add interactive draggable flashcards with physics ([0744883](074488349a))

### Code Refactoring

* **homepage:** merge flashcard display with create button section ([3cf4f92](3cf4f92643))
2025-10-20 23:12:50 +00:00
Thomas Hallock
3cf4f92643 refactor(homepage): merge flashcard display with create button section
Combine the interactive flashcards demonstration and the "Create
Flashcards" call-to-action into a single unified section. The card
throwing area, feature highlights, and CTA button are now part of
one cohesive pane instead of two separate sections.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 18:11:32 -05:00
Thomas Hallock
074488349a feat(homepage): add interactive draggable flashcards with physics
Add physics-based flashcard component with drag, throw, and momentum:
- Random generation of 8-15 flashcards with abacus visualizations
- Smooth drag interactions with velocity-based rotation
- Decay physics for realistic throwing and sliding
- Transform-origin pivot point based on click position
- Position persistence so cards stay where thrown

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 18:11:32 -05:00
semantic-release-bot
30a5587bca chore(release): 4.52.2 [skip ci]
## [4.52.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.1...v4.52.2) (2025-10-20)

### Bug Fixes

* **homepage:** use actual container dimensions for flashcard positioning ([4082a24](4082a246a3))
2025-10-20 22:44:22 +00:00
Thomas Hallock
4082a246a3 fix(homepage): use actual container dimensions for flashcard positioning
Fix flashcards being positioned outside visible area by using the
container's actual dimensions instead of hardcoded pixel values.

The previous implementation used CONTAINER_WIDTH=800px and
CONTAINER_HEIGHT=500px to position cards, but the actual container
width is 100% which varies by screen size. This caused cards to be
positioned outside the visible area on smaller screens.

Changes:
- Add containerRef to get actual container dimensions
- Calculate card positions based on offsetWidth/offsetHeight
- Remove hardcoded dimension constants
- Ensure cards stay within visible bounds with proper margins

This makes the flashcard positioning responsive to any screen size.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 17:43:06 -05:00
6 changed files with 1222 additions and 1145 deletions

View File

@@ -1,3 +1,97 @@
## [4.57.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.4...v4.57.5) (2025-10-21)
### Bug Fixes
* **homepage:** prevent text overflow in skill cards ([a6ac55b](https://github.com/antialias/soroban-abacus-flashcards/commit/a6ac55b7b161e0dd33a4dd5acc0df647b2a513aa))
## [4.57.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.3...v4.57.4) (2025-10-21)
### Bug Fixes
* **homepage:** align container width breakpoint with grid columns ([422bf3d](https://github.com/antialias/soroban-abacus-flashcards/commit/422bf3d968b67e4683ac7ea7e487a84513f992f9))
* **homepage:** make MiniAbacus fill container properly ([3b5d147](https://github.com/antialias/soroban-abacus-flashcards/commit/3b5d14765dfb2d61a76f66ba3ae09695ce88bb6d))
* **homepage:** widen skill cards container to 650px ([bc1ad3a](https://github.com/antialias/soroban-abacus-flashcards/commit/bc1ad3a43a79570e1f9c61d5118d14ac4c201d71))
## [4.57.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.2...v4.57.3) (2025-10-21)
### Bug Fixes
* **homepage:** increase abacus container width to 120px/150px ([57c212f](https://github.com/antialias/soroban-abacus-flashcards/commit/57c212f4f5be591f712e1c5610e1f323e56e15dd))
## [4.57.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.1...v4.57.2) (2025-10-21)
### Bug Fixes
* **homepage:** increase skill card abacus container width ([e65e969](https://github.com/antialias/soroban-abacus-flashcards/commit/e65e96952f4e631722c73fc56d088fa3ff1ba858))
## [4.57.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.0...v4.57.1) (2025-10-21)
### Bug Fixes
* **homepage:** add overflow hidden to skill cards ([5070d8d](https://github.com/antialias/soroban-abacus-flashcards/commit/5070d8d64f7f58887ff7259bee9ce5166c4f8af8))
## [4.57.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.56.0...v4.57.0) (2025-10-21)
### Features
* **homepage:** make skills section responsive with emojis ([9ec0a71](https://github.com/antialias/soroban-abacus-flashcards/commit/9ec0a71546ee483233ed7866dae97345bf2384d7))
### Bug Fixes
* **homepage:** prevent skill card overflow ([fa26acf](https://github.com/antialias/soroban-abacus-flashcards/commit/fa26acfbaef1a04bb225956b2f684cd5023b56fa))
## [4.56.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.55.0...v4.56.0) (2025-10-20)
### Features
* **homepage:** emphasize single-player and observer modes ([a537bc1](https://github.com/antialias/soroban-abacus-flashcards/commit/a537bc18c34d94ca931e483ea01e497d6f5d4e5b))
## [4.55.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.54.0...v4.55.0) (2025-10-20)
### Features
* **homepage:** update section title to "The Arcade" ([f47b172](https://github.com/antialias/soroban-abacus-flashcards/commit/f47b172f66bee0017c11d8f129f5b83f2ef3dcd9))
## [4.54.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.53.0...v4.54.0) (2025-10-20)
### Features
* **homepage:** add interactive levels slider to replace static progression ([8a2d5ae](https://github.com/antialias/soroban-abacus-flashcards/commit/8a2d5ae319af8fd66010dd5538e4b82f7fb35d40))
### Code Refactoring
* **levels:** extract levels slider into shared component ([015e30b](https://github.com/antialias/soroban-abacus-flashcards/commit/015e30b085ad2ef798ffd6f7f6716269e3256651))
## [4.53.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.2...v4.53.0) (2025-10-20)
### Features
* **homepage:** add interactive draggable flashcards with physics ([0744883](https://github.com/antialias/soroban-abacus-flashcards/commit/074488349a3ec480548223c313006aa1e9e64e5c))
### Code Refactoring
* **homepage:** merge flashcard display with create button section ([3cf4f92](https://github.com/antialias/soroban-abacus-flashcards/commit/3cf4f92643306f055188ede508557515ef5efe98))
## [4.52.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.1...v4.52.2) (2025-10-20)
### Bug Fixes
* **homepage:** use actual container dimensions for flashcard positioning ([4082a24](https://github.com/antialias/soroban-abacus-flashcards/commit/4082a246a33ea67617b762d5b7490a8c9af0ad49))
## [4.52.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.0...v4.52.1) (2025-10-20)

View File

@@ -1,388 +1,11 @@
'use client'
import { useState, useEffect } from 'react'
import { useSpring, useTransition, animated } from '@react-spring/web'
import * as Slider from '@radix-ui/react-slider'
import { AbacusReact, StandaloneBead } from '@soroban/abacus-react'
import { PageWithNav } from '@/components/PageWithNav'
import { LevelSliderDisplay } from '@/components/LevelSliderDisplay'
import { css } from '../../../styled-system/css'
import { container, stack } from '../../../styled-system/patterns'
import { kyuLevelDetails } from '@/data/kyuLevelDetails'
// Combine all levels into one array for the slider
const allLevels = [
{
level: '10th Kyu',
emoji: '🧒',
color: 'green',
digits: 2,
type: 'kyu' as const,
},
{
level: '9th Kyu',
emoji: '🧒',
color: 'green',
digits: 2,
type: 'kyu' as const,
},
{
level: '8th Kyu',
emoji: '🧒',
color: 'green',
digits: 3,
type: 'kyu' as const,
},
{
level: '7th Kyu',
emoji: '🧒',
color: 'green',
digits: 4,
type: 'kyu' as const,
},
{
level: '6th Kyu',
emoji: '🧑',
color: 'blue',
digits: 5,
type: 'kyu' as const,
},
{
level: '5th Kyu',
emoji: '🧑',
color: 'blue',
digits: 6,
type: 'kyu' as const,
},
{
level: '4th Kyu',
emoji: '🧑',
color: 'blue',
digits: 7,
type: 'kyu' as const,
},
{
level: '3rd Kyu',
emoji: '🧔',
color: 'violet',
digits: 8,
type: 'kyu' as const,
},
{
level: '2nd Kyu',
emoji: '🧔',
color: 'violet',
digits: 9,
type: 'kyu' as const,
},
{
level: '1st Kyu',
emoji: '🧔',
color: 'violet',
digits: 10,
type: 'kyu' as const,
},
{
level: 'Pre-1st Dan',
name: 'Jun-Shodan',
minScore: 90,
emoji: '🧙',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '1st Dan',
name: 'Shodan',
minScore: 100,
emoji: '🧙',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '2nd Dan',
name: 'Nidan',
minScore: 120,
emoji: '🧙‍♂️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '3rd Dan',
name: 'Sandan',
minScore: 140,
emoji: '🧙‍♂️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '4th Dan',
name: 'Yondan',
minScore: 160,
emoji: '🧙‍♀️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '5th Dan',
name: 'Godan',
minScore: 180,
emoji: '🧙‍♀️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '6th Dan',
name: 'Rokudan',
minScore: 200,
emoji: '🧝',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '7th Dan',
name: 'Nanadan',
minScore: 220,
emoji: '🧝',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '8th Dan',
name: 'Hachidan',
minScore: 250,
emoji: '🧝‍♂️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '9th Dan',
name: 'Kudan',
minScore: 270,
emoji: '🧝‍♀️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '10th Dan',
name: 'Judan',
minScore: 290,
emoji: '👑',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
] as const
// Helper function to map level names to kyuLevelDetails keys
function getLevelDetailsKey(levelName: string): string | null {
// Convert "10th Kyu" → "10-kyu", "3rd Kyu" → "3-kyu", etc.
const match = levelName.match(/^(\d+)(?:st|nd|rd|th)\s+Kyu$/)
if (match) {
return `${match[1]}-kyu`
}
return null
}
// Parse and format kyu level details into structured sections with icons
function parseKyuDetails(rawText: string) {
const lines = rawText.split('\n').filter((line) => line.trim() && !line.includes('shuzan.jp'))
// Always return sections in consistent order: Add/Sub, Multiply, Divide
const sections: Array<{
type: 'addSub' | 'multiply' | 'divide'
icon: string
label: string
digits: string | null
rows: string | null
chars: string | null
problems: string | null
}> = [
{
type: 'addSub',
icon: '',
label: 'Add/Sub',
digits: null,
rows: null,
chars: null,
problems: null,
},
{
type: 'multiply',
icon: '✖️',
label: 'Multiply',
digits: null,
rows: null,
chars: null,
problems: null,
},
{
type: 'divide',
icon: '➗',
label: 'Divide',
digits: null,
rows: null,
chars: null,
problems: null,
},
]
for (const line of lines) {
if (line.includes('Add/Sub:')) {
const match = line.match(/(\d+)-digit.*?(\d+)口.*?(\d+)字/)
if (match) {
sections[0].digits = match[1]
sections[0].rows = match[2]
sections[0].chars = match[3]
}
} else if (line.includes('×:')) {
const match = line.match(/(\d+) digits.*?\((\d+)/)
if (match) {
sections[1].digits = match[1]
sections[1].problems = match[2]
}
} else if (line.includes('÷:')) {
const match = line.match(/(\d+) digits.*?\((\d+)/)
if (match) {
sections[2].digits = match[1]
sections[2].problems = match[2]
}
}
}
return sections
}
export default function LevelsPage() {
const [currentIndex, setCurrentIndex] = useState(0)
const [isHovering, setIsHovering] = useState(false)
const [isPaneHovered, setIsPaneHovered] = useState(false)
const currentLevel = allLevels[currentIndex]
// State for animated abacus digits
const [animatedDigits, setAnimatedDigits] = useState<string>('')
// Initialize animated digits when level changes
useEffect(() => {
const generateRandomDigits = (numDigits: number) => {
return Array.from({ length: numDigits }, () => Math.floor(Math.random() * 10)).join('')
}
setAnimatedDigits(generateRandomDigits(currentLevel.digits))
}, [currentLevel.digits])
// Animate abacus calculations - speed increases with Dan level
useEffect(() => {
// Calculate animation speed based on level
// Kyu levels: 500ms
// Pre-1st Dan: 500ms
// 1st-10th Dan: interpolate from 500ms to 10ms
const getAnimationInterval = () => {
if (currentIndex < 11) {
// Kyu levels and Pre-1st Dan: constant 500ms
return 500
}
// 1st Dan through 10th Dan: speed up from 500ms to 10ms
// Index 11 (1st Dan) → 500ms
// Index 20 (10th Dan) → 10ms
const danProgress = (currentIndex - 11) / 9 // 0.0 to 1.0
return 500 - danProgress * 490 // 500ms down to 10ms
}
const intervalMs = getAnimationInterval()
const interval = setInterval(() => {
setAnimatedDigits((prev) => {
const digits = prev.split('').map(Number)
const numColumns = digits.length
// Pick 1-3 adjacent columns to change (grouping effect)
const groupSize = Math.floor(Math.random() * 3) + 1
const startCol = Math.floor(Math.random() * (numColumns - groupSize + 1))
// Change the selected columns
for (let i = startCol; i < startCol + groupSize && i < numColumns; i++) {
digits[i] = Math.floor(Math.random() * 10)
}
return digits.join('')
})
}, intervalMs)
return () => clearInterval(interval)
}, [currentIndex])
// Auto-advance slider position every 3 seconds (unless pane is hovered)
useEffect(() => {
if (isPaneHovered) return // Don't auto-advance when mouse is over the pane
const interval = setInterval(() => {
setCurrentIndex((prev) => {
// Cycle back to 0 when reaching the end
return prev >= allLevels.length - 1 ? 0 : prev + 1
})
}, 3000)
return () => clearInterval(interval)
}, [isPaneHovered])
// Handle hover on slider track
const handleSliderHover = (e: React.MouseEvent<HTMLSpanElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const percentage = x / rect.width
const index = Math.round(percentage * (allLevels.length - 1))
setCurrentIndex(Math.max(0, Math.min(allLevels.length - 1, index)))
}
// Calculate scale factor based on number of columns to fit the page
// Use constrained range to prevent huge size differences between levels
// Min 1.2 (for 30-column Dan levels) to Max 2.0 (for 2-column Kyu levels)
const scaleFactor = Math.max(1.2, Math.min(2.0, 20 / currentLevel.digits))
// Animate scale factor with React Spring for smooth transitions
const animatedProps = useSpring({
scaleFactor,
config: { tension: 350, friction: 45 },
})
// Animate emoji with proper cross-fade (old fades out, new fades in)
const emojiTransitions = useTransition(currentLevel.emoji, {
keys: currentIndex,
from: { opacity: 0 },
enter: { opacity: 1 },
leave: { opacity: 0 },
config: { duration: 120 },
})
// Convert animated digits to a number/BigInt for the abacus display
// Use BigInt for large numbers to get full 30-digit precision
const displayValue =
animatedDigits.length > 15
? BigInt(animatedDigits || '0')
: Number.parseInt(animatedDigits || '0', 10)
// Dark theme styles matching the homepage
const darkStyles = {
columnPosts: {
fill: 'rgba(255, 255, 255, 0.3)',
stroke: 'rgba(255, 255, 255, 0.2)',
strokeWidth: 2,
},
reckoningBar: {
fill: 'rgba(255, 255, 255, 0.4)',
stroke: 'rgba(255, 255, 255, 0.25)',
strokeWidth: 3,
},
}
return (
<PageWithNav navTitle="Kyu & Dan Levels" navEmoji="📊">
<div className={css({ bg: 'gray.900', minHeight: '100vh', pb: '12' })}>
@@ -449,471 +72,7 @@ export default function LevelsPage() {
{/* Main content */}
<div className={container({ maxW: '6xl', px: '4', py: '12' })}>
<section className={stack({ gap: '8' })}>
{/* Current Level Display */}
<div
onMouseEnter={() => setIsPaneHovered(true)}
onMouseLeave={() => setIsPaneHovered(false)}
className={css({
bg: 'transparent',
border: '2px solid',
borderColor:
currentLevel.color === 'green'
? 'green.500'
: currentLevel.color === 'blue'
? 'blue.500'
: currentLevel.color === 'violet'
? 'violet.500'
: 'amber.500',
rounded: 'xl',
p: { base: '6', md: '8' },
height: { base: 'auto', md: '700px' },
display: 'flex',
flexDirection: 'column',
})}
>
{/* Abacus-themed Radix Slider */}
<div className={css({ mb: '6', px: { base: '2', md: '8' } })}>
<div className={css({ position: 'relative', py: '12' })}>
{/* Emoji tick marks */}
<div
className={css({
position: 'absolute',
top: '0',
left: '0',
right: '0',
h: 'full',
display: 'flex',
alignItems: 'center',
pointerEvents: 'none',
px: '0', // Use full width for tick spacing
})}
>
<div
className={css({
position: 'relative',
w: 'full',
display: 'flex',
justifyContent: 'space-between',
})}
>
{allLevels.map((level, index) => (
<div
key={index}
onClick={() => setCurrentIndex(index)}
className={css({
fontSize: '4xl',
opacity: index === currentIndex ? '1' : '0.3',
transition: 'all 0.2s',
cursor: 'pointer',
pointerEvents: 'auto',
_hover: { opacity: index === currentIndex ? '1' : '0.6' },
})}
>
{level.emoji}
</div>
))}
</div>
</div>
<Slider.Root
value={[currentIndex]}
onValueChange={([value]) => setCurrentIndex(value)}
min={0}
max={allLevels.length - 1}
step={1}
onMouseMove={handleSliderHover}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={css({
position: 'relative',
display: 'flex',
alignItems: 'center',
userSelect: 'none',
touchAction: 'none',
w: 'full',
h: '32',
cursor: 'pointer',
})}
>
<Slider.Track
className={css({
bg: 'rgba(255, 255, 255, 0.2)',
position: 'relative',
flexGrow: 1,
rounded: 'full',
h: '3px',
})}
>
<Slider.Range className={css({ display: 'none' })} />
</Slider.Track>
<Slider.Thumb
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
w: '180px',
h: '128px',
bg: 'transparent',
cursor: 'grab',
transition: 'transform 0.15s ease-out, left 0.3s ease-out',
zIndex: 10,
_hover: { transform: 'scale(1.15)' },
_focus: {
outline: 'none',
transform: 'scale(1.15)',
},
_active: { cursor: 'grabbing' },
})}
>
<div className={css({ opacity: 0.75 })}>
<StandaloneBead
size={128}
color={currentLevel.color === 'violet' ? '#8b5cf6' : '#22c55e'}
animated={false}
/>
</div>
{emojiTransitions((style, emoji) => (
<animated.div
style={style}
className={css({
position: 'absolute',
fontSize: '9xl',
pointerEvents: 'none',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
})}
>
{emoji}
</animated.div>
))}
{/* Level text as part of the bead display */}
<div
className={css({
position: 'absolute',
bottom: '-80px',
left: '50%',
transform: 'translateX(-50%)',
textAlign: 'center',
pointerEvents: 'none',
whiteSpace: 'nowrap',
})}
>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color:
currentLevel.color === 'green'
? 'green.400'
: currentLevel.color === 'blue'
? 'blue.400'
: currentLevel.color === 'violet'
? 'violet.400'
: 'amber.400',
mb: '0.5',
})}
>
{currentLevel.level}
</h2>
{'name' in currentLevel && (
<div
className={css({
fontSize: 'md',
color: 'gray.300',
mb: '0.5',
})}
>
{currentLevel.name}
</div>
)}
{'minScore' in currentLevel && (
<div className={css({ fontSize: 'sm', color: 'gray.400' })}>
Min: {currentLevel.minScore}pts
</div>
)}
</div>
</Slider.Thumb>
</Slider.Root>
</div>
{/* Level Markers */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
mt: '1',
fontSize: 'xs',
color: 'gray.500',
})}
>
<span>10th Kyu</span>
<span>1st Kyu</span>
<span>10th Dan</span>
</div>
</div>
{/* Abacus Display with Level Details */}
<div
className={css({
display: 'flex',
gap: '4',
p: '6',
bg: 'rgba(0, 0, 0, 0.3)',
rounded: 'lg',
border: '1px solid',
borderColor: 'gray.700',
overflow: 'hidden',
flex: 1,
})}
>
{/* Level Details (only for Kyu levels) */}
{currentLevel.type === 'kyu' &&
(() => {
const detailsKey = getLevelDetailsKey(currentLevel.level)
const rawText = detailsKey
? kyuLevelDetails[detailsKey as keyof typeof kyuLevelDetails]
: null
const sections = rawText ? parseKyuDetails(rawText) : []
// Use consistent sizing across all levels
const sizing = { fontSize: 'md', gap: '3', iconSize: '4xl' }
return sections.length > 0 ? (
<div
className={css({
flex: '0 0 auto',
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '2',
p: '2',
maxW: '400px',
alignContent: 'center',
})}
>
{sections.map((section, idx) => {
const hasData = section.digits !== null
const levelColor =
currentLevel.color === 'green'
? 'green.300'
: currentLevel.color === 'blue'
? 'blue.300'
: 'violet.300'
return (
<div
key={idx}
className={css({
bg: hasData ? 'rgba(0, 0, 0, 0.4)' : 'rgba(0, 0, 0, 0.2)',
border: '1px solid',
borderColor: hasData ? 'gray.700' : 'gray.800',
rounded: 'md',
p: '3',
transition: 'all 0.2s',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5',
opacity: hasData ? 1 : 0.3,
width: '170px',
height: '150px',
_hover: hasData
? {
borderColor: 'gray.500',
transform: 'scale(1.05)',
}
: {},
})}
>
<span className={css({ fontSize: sizing.iconSize, lineHeight: '1' })}>
{section.icon}
</span>
{hasData && section.digits && (
<>
<div
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: levelColor,
})}
>
{section.digits} digits
</div>
{(section.rows || section.chars) && (
<div
className={css({
fontSize: 'sm',
color: 'gray.400',
textAlign: 'center',
})}
>
{section.rows && `${section.rows} rows`}
{section.rows && section.chars && ' • '}
{section.chars && `${section.chars} chars`}
</div>
)}
{section.problems && (
<div
className={css({
fontSize: 'sm',
color: 'gray.400',
textAlign: 'center',
})}
>
{section.problems} problems
</div>
)}
</>
)}
<div
className={css({
fontSize: 'xs',
color: hasData ? 'gray.500' : 'gray.700',
textAlign: 'center',
fontWeight: hasData ? 'normal' : 'bold',
})}
>
{section.label}
</div>
</div>
)
})}
</div>
) : null
})()}
{/* Abacus (right-aligned for Kyu, centered for Dan) */}
<div
className={css({
display: 'flex',
justifyContent: currentLevel.type === 'kyu' ? 'flex-end' : 'center',
alignItems: 'center',
flex: 1,
})}
>
<animated.div
style={{
transform: animatedProps.scaleFactor.to((s) => `scale(${s / scaleFactor})`),
}}
>
<AbacusReact
value={displayValue}
columns={currentLevel.digits}
scaleFactor={scaleFactor}
showNumbers={true}
customStyles={darkStyles}
/>
</animated.div>
</div>
</div>
{/* Digit Count */}
<div
className={css({
textAlign: 'center',
color: 'gray.400',
fontSize: 'sm',
})}
>
Requires mastery of <strong>{currentLevel.digits}-digit</strong> calculations
</div>
</div>
{/* Legend */}
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '6',
justifyContent: 'center',
p: '6',
bg: 'rgba(0, 0, 0, 0.3)',
rounded: 'lg',
border: '1px solid',
borderColor: 'gray.700',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
w: '4',
h: '4',
bg: 'green.500',
rounded: 'sm',
})}
/>
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
Beginner (10-7 Kyu)
</span>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
w: '4',
h: '4',
bg: 'blue.500',
rounded: 'sm',
})}
/>
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
Intermediate (6-4 Kyu)
</span>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
w: '4',
h: '4',
bg: 'violet.500',
rounded: 'sm',
})}
/>
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
Advanced (3-1 Kyu)
</span>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
w: '4',
h: '4',
bg: 'amber.500',
rounded: 'sm',
})}
/>
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
Master (Dan ranks)
</span>
</div>
</div>
<LevelSliderDisplay />
{/* Info Section */}
<div

View File

@@ -10,9 +10,9 @@ import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
import { getTutorialForEditor } from '@/utils/tutorialConverter'
import { getAvailableGames } from '@/lib/arcade/game-registry'
import { InteractiveFlashcards } from '@/components/InteractiveFlashcards'
import { LevelSliderDisplay } from '@/components/LevelSliderDisplay'
import { css } from '../../styled-system/css'
import { container, grid, hstack, stack } from '../../styled-system/patterns'
import { token } from '../../styled-system/tokens'
// Mini abacus that cycles through a sequence of values
function MiniAbacus({
@@ -54,14 +54,14 @@ function MiniAbacus({
return (
<div
className={css({
width: '75px',
height: '80px',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<div className={css({ transform: 'scale(0.6)', transformOrigin: 'center center' })}>
<div className={css({ transform: 'scale(0.75)', transformOrigin: 'center center' })}>
<AbacusReact
value={values[currentIndex] || 0}
columns={columns}
@@ -188,7 +188,7 @@ export default function HomePage() {
<div
className={css({
flex: '0 0 auto',
w: { base: '100%', md: '420px' },
w: { base: '100%', lg: '650px' },
})}
>
<h3
@@ -201,10 +201,10 @@ export default function HomePage() {
>
What You'll Learn
</h3>
<div className={stack({ gap: '5' })}>
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '5' })}>
{[
{
title: 'Read and set numbers',
title: '📖 Read and set numbers',
desc: 'Master abacus number representation from zero to thousands',
example: '0-9999',
badge: 'Foundation',
@@ -212,7 +212,7 @@ export default function HomePage() {
columns: 3,
},
{
title: 'Friends techniques',
title: '🤝 Friends techniques',
desc: 'Add and subtract using complement pairs and mental shortcuts',
example: '5 = 2+3',
badge: 'Core',
@@ -220,7 +220,7 @@ export default function HomePage() {
columns: 1,
},
{
title: 'Multiply & divide',
title: ' Multiply & divide',
desc: 'Fluent multi-digit calculations with advanced techniques',
example: '12×34',
badge: 'Advanced',
@@ -228,7 +228,7 @@ export default function HomePage() {
columns: 2,
},
{
title: 'Mental calculation',
title: '🧠 Mental calculation',
desc: 'Visualize and compute without the physical tool (Anzan)',
example: 'Speed math',
badge: 'Expert',
@@ -246,7 +246,7 @@ export default function HomePage() {
? '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: '4',
p: { base: '4', lg: '5' },
border: '1px solid',
borderColor: isSelected
? 'rgba(250, 204, 21, 0.4)'
@@ -274,8 +274,8 @@ export default function HomePage() {
<div
className={css({
fontSize: '3xl',
width: '75px',
height: '115px',
width: { base: '120px', lg: '150px' },
minHeight: { base: '115px', lg: '140px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -288,8 +288,14 @@ export default function HomePage() {
>
<MiniAbacus values={skill.values} columns={skill.columns} />
</div>
<div className={stack({ gap: '2', flex: '1' })}>
<div className={hstack({ gap: '2', alignItems: 'center' })}>
<div className={stack({ gap: '2', flex: '1', minWidth: '0' })}>
<div
className={hstack({
gap: '2',
alignItems: 'center',
flexWrap: 'wrap',
})}
>
<div
className={css({
color: 'white',
@@ -360,10 +366,11 @@ export default function HomePage() {
mb: '2',
})}
>
Available Now
The Arcade
</h2>
<p className={css({ color: 'gray.400', fontSize: 'md' })}>
Foundation tutorials and reinforcement games ready to use
Single-player challenges and multiplayer battles in networked rooms. Invite
friends to play or watch live.
</p>
</div>
@@ -407,146 +414,7 @@ export default function HomePage() {
</p>
</div>
<Link
href="/levels"
className={css({
bg: 'rgba(0, 0, 0, 0.4)',
border: '1px solid',
borderColor: 'gray.700',
rounded: 'xl',
p: '8',
display: 'block',
transition: 'all 0.2s',
cursor: 'pointer',
position: 'relative',
_hover: {
bg: 'rgba(0, 0, 0, 0.5)',
borderColor: 'violet.500',
transform: 'translateY(-2px)',
boxShadow: '0 8px 16px rgba(124, 58, 237, 0.2)',
},
})}
>
{/* Subtle arrow indicator */}
<div
className={css({
position: 'absolute',
top: '4',
right: '4',
fontSize: 'xl',
color: 'gray.500',
transition: 'all 0.2s',
_groupHover: {
color: 'violet.400',
transform: 'translateX(4px)',
},
})}
>
</div>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '4',
flexWrap: 'wrap',
})}
>
{(
[
{
level: '10 Kyu',
label: 'Beginner',
color: 'colors.green.400',
emoji: '🧒',
},
{
level: '5 Kyu',
label: 'Intermediate',
color: 'colors.blue.400',
emoji: '🧑',
},
{
level: '1 Kyu',
label: 'Advanced',
color: 'colors.violet.400',
emoji: '🧔',
},
{ level: 'Dan', label: 'Master', color: 'colors.amber.400', emoji: '🧙' },
] as const
).map((stage, i) => (
<div
key={i}
className={stack({
gap: '0',
textAlign: 'center',
flex: '1',
position: 'relative',
})}
>
<div
className={css({
fontSize: '5xl',
mb: '0',
})}
>
{stage.emoji}
</div>
<div
className={css({
fontSize: 'xl',
fontWeight: 'bold',
mt: '-2',
})}
style={{ color: token(stage.color) }}
>
{stage.level}
</div>
<div
className={css({
fontSize: 'sm',
color: 'gray.300',
})}
>
{stage.label}
</div>
{i < 3 && (
<div
style={{
position: 'absolute',
left: '100%',
marginLeft: '0.5rem',
top: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '20px',
color: '#9ca3af',
}}
className={css({
display: { base: 'none', md: 'block' },
})}
>
</div>
)}
</div>
))}
</div>
<div
style={{
textAlign: 'center',
fontSize: '14px',
color: '#d1d5db',
fontStyle: 'italic',
}}
className={css({
mt: '6',
})}
>
Click to learn about the official Japanese ranking system →
</div>
</Link>
<LevelSliderDisplay />
</section>
{/* Flashcard Generator Section */}
@@ -567,116 +435,95 @@ export default function HomePage() {
</p>
</div>
{/* Interactive Flashcards Display */}
{/* 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',
mb: '8',
})}
>
<InteractiveFlashcards />
</div>
{/* Interactive Flashcards Display */}
<div className={css({ mb: '8' })}>
<InteractiveFlashcards />
</div>
{/* Features and CTA */}
<Link
href="/create"
className={css({
display: 'block',
transition: 'all 0.3s ease',
_hover: {
transform: 'translateY(-4px)',
},
})}
>
<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',
transition: 'all 0.3s ease',
_hover: {
borderColor: 'blue.500',
shadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
{/* Features */}
<div className={grid({ columns: { base: 1, md: 3 }, gap: '4', mb: '6' })}>
{[
{
icon: '📄',
title: 'Multiple Formats',
desc: 'PDF, PNG, SVG, HTML',
},
})}
>
{/* Features */}
<div className={grid({ columns: { base: 1, md: 3 }, gap: '4', mb: '6' })}>
{[
{
icon: '📄',
title: 'Multiple Formats',
desc: 'PDF, PNG, SVG, HTML',
},
{
icon: '🎨',
title: 'Customizable',
desc: 'Bead shapes, colors, layouts',
},
{
icon: '📐',
title: 'All Paper Sizes',
desc: 'A3, A4, A5, US Letter',
},
].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' })}>
{
icon: '🎨',
title: 'Customizable',
desc: 'Bead shapes, colors, layouts',
},
{
icon: '📐',
title: 'All Paper Sizes',
desc: 'A3, A4, A5, US Letter',
},
].map((feature, i) => (
<div
key={i}
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: '2',
bg: 'blue.600',
color: 'white',
px: '6',
py: '3',
textAlign: 'center',
p: '4',
rounded: 'lg',
fontWeight: 'semibold',
transition: 'all 0.2s',
_hover: {
bg: 'blue.500',
},
bg: 'rgba(255, 255, 255, 0.05)',
})}
>
<span>Create Flashcards</span>
<span>→</span>
<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>
</Link>
{/* 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>Create Flashcards</span>
<span>→</span>
</Link>
</div>
</div>
</section>
</div>
</div>

View File

@@ -2,7 +2,7 @@
import { AbacusReact } from '@soroban/abacus-react'
import { useDrag } from '@use-gesture/react'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { animated, config, to, useSpring } from '@react-spring/web'
import { css } from '../../styled-system/css'
@@ -15,40 +15,67 @@ interface Flashcard {
zIndex: number
}
const CONTAINER_WIDTH = 800
const CONTAINER_HEIGHT = 500
/**
* InteractiveFlashcards - A fun, physics-based flashcard display
* Users can drag and throw flashcards around with realistic momentum
*/
export function InteractiveFlashcards() {
const containerRef = useRef<HTMLDivElement>(null)
// Generate 8-15 random flashcards (client-side only to avoid hydration errors)
const [cards, setCards] = useState<Flashcard[]>([])
useEffect(() => {
const count = Math.floor(Math.random() * 8) + 8 // 8-15 cards
const generated: Flashcard[] = []
if (!containerRef.current) return
for (let i = 0; i < count; i++) {
generated.push({
id: i,
number: Math.floor(Math.random() * 900) + 100, // 100-999
initialX: Math.random() * (CONTAINER_WIDTH - 200) + 100,
initialY: Math.random() * (CONTAINER_HEIGHT - 200) + 100,
initialRotation: Math.random() * 40 - 20, // -20 to 20 degrees
zIndex: i,
// Double rAF pattern - ensures layout is fully complete
const frameId1 = requestAnimationFrame(() => {
const frameId2 = requestAnimationFrame(() => {
if (!containerRef.current) return
const containerWidth = containerRef.current.offsetWidth
const containerHeight = containerRef.current.offsetHeight
// Only generate cards once we have proper dimensions
if (containerWidth < 100 || containerHeight < 100) {
return
}
const count = Math.floor(Math.random() * 8) + 8 // 8-15 cards
const generated: Flashcard[] = []
// Position cards within the actual container bounds
const cardWidth = 120 // approximate card width
const cardHeight = 200 // approximate card height
for (let i = 0; i < count; i++) {
const card = {
id: i,
number: Math.floor(Math.random() * 900) + 100, // 100-999
initialX: Math.random() * (containerWidth - cardWidth - 40) + 20,
initialY: Math.random() * (containerHeight - cardHeight - 40) + 20,
initialRotation: Math.random() * 40 - 20, // -20 to 20 degrees
zIndex: i,
}
generated.push(card)
}
setCards(generated)
})
}
})
setCards(generated)
return () => {
// Note: can't cancel nested rAF properly, but component cleanup will prevent state updates
}
}, [])
return (
<div
ref={containerRef}
className={css({
position: 'relative',
width: '100%',
maxW: '1200px',
mx: 'auto',
height: { base: '400px', md: '500px' },
overflow: 'hidden',
bg: 'rgba(0, 0, 0, 0.3)',
@@ -56,24 +83,6 @@ export function InteractiveFlashcards() {
border: '1px solid rgba(255, 255, 255, 0.1)',
})}
>
{/* Hint text */}
<div
className={css({
position: 'absolute',
top: '4',
left: '50%',
transform: 'translateX(-50%)',
color: 'white',
opacity: '0.6',
fontSize: 'sm',
fontWeight: 'medium',
zIndex: 1000,
pointerEvents: 'none',
})}
>
Drag and throw the flashcards!
</div>
{cards.map((card) => (
<DraggableCard key={card.id} card={card} />
))}
@@ -86,6 +95,13 @@ interface DraggableCardProps {
}
function DraggableCard({ card }: DraggableCardProps) {
// Track the card's current position in state (separate from the animation values)
const currentPositionRef = useRef({
x: card.initialX,
y: card.initialY,
rotation: card.initialRotation,
})
const [{ x, y, rotation, scale }, api] = useSpring(() => ({
x: card.initialX,
y: card.initialY,
@@ -95,28 +111,125 @@ function DraggableCard({ card }: DraggableCardProps) {
}))
const [zIndex, setZIndex] = useState(card.zIndex)
const cardRef = useRef<HTMLDivElement>(null)
const dragOffsetRef = useRef({ x: 0, y: 0 })
const lastVelocityRef = useRef({ vx: 0, vy: 0 })
const velocityHistoryRef = useRef<Array<{ vx: number; vy: number }>>([])
const [transformOrigin, setTransformOrigin] = useState('center center')
const bind = useDrag(
({ down, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy] }) => {
({ down, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy], first, xy }) => {
// Bring card to front when dragging
if (down) {
setZIndex(1000)
}
api.start({
x: down ? card.initialX + mx : card.initialX + mx + vx * 100 * dx,
y: down ? card.initialY + my : card.initialY + my + vy * 100 * dy,
scale: down ? 1.1 : 1,
rotation: down ? card.initialRotation + mx / 20 : card.initialRotation + vx * 10,
immediate: down,
config: down ? config.stiff : config.wobbly,
})
// Calculate drag offset from card center on first touch
if (first && cardRef.current) {
const cardRect = cardRef.current.getBoundingClientRect()
const cardWidth = cardRect.width
const cardHeight = cardRect.height
// Update initial position after release for next drag
if (!down) {
card.initialX = card.initialX + mx + vx * 100 * dx
card.initialY = card.initialY + my + vy * 100 * dy
card.initialRotation = card.initialRotation + vx * 10
// xy is in viewport coordinates, convert to position relative to card
const clickRelativeToCard = {
x: xy[0] - cardRect.left,
y: xy[1] - cardRect.top,
}
// Calculate offset from card center
const cardCenterX = cardWidth / 2
const cardCenterY = cardHeight / 2
const offsetX = clickRelativeToCard.x - cardCenterX
const offsetY = clickRelativeToCard.y - cardCenterY
dragOffsetRef.current = { x: offsetX, y: offsetY }
// Convert offset to transform-origin (50% + offset as percentage of card size)
const originX = 50 + (offsetX / cardWidth) * 100
const originY = 50 + (offsetY / cardHeight) * 100
const transformOriginValue = `${originX}% ${originY}%`
console.log(
`Drag start: click at (${clickRelativeToCard.x.toFixed(0)}, ${clickRelativeToCard.y.toFixed(0)}) in card, offset from center: (${offsetX.toFixed(0)}, ${offsetY.toFixed(0)}), origin: ${transformOriginValue}`
)
setTransformOrigin(transformOriginValue)
velocityHistoryRef.current = []
}
// Smooth velocity by averaging last 3 frames
velocityHistoryRef.current.push({ vx, vy })
if (velocityHistoryRef.current.length > 3) {
velocityHistoryRef.current.shift()
}
const avgVx =
velocityHistoryRef.current.reduce((sum, v) => sum + v.vx, 0) /
velocityHistoryRef.current.length
const avgVy =
velocityHistoryRef.current.reduce((sum, v) => sum + v.vy, 0) /
velocityHistoryRef.current.length
// Calculate rotation based on smoothed velocity and drag offset
const velocityAngle = Math.atan2(avgVy, avgVx) * (180 / Math.PI)
const offsetAngle =
Math.atan2(dragOffsetRef.current.y, dragOffsetRef.current.x) * (180 / Math.PI)
// Card rotates to align with movement direction, offset by where we're grabbing
const targetRotation = velocityAngle - offsetAngle + 90
const speed = Math.sqrt(avgVx * avgVx + avgVy * avgVy)
// Store smoothed velocity for throw
lastVelocityRef.current = { vx: avgVx, vy: avgVy }
const finalRotation = speed > 0.01 ? targetRotation : currentPositionRef.current.rotation
api.start({
x: currentPositionRef.current.x + mx,
y: currentPositionRef.current.y + my,
scale: 1.1,
rotation: finalRotation,
immediate: (key) => key !== 'rotation', // Position immediate, rotation smooth
config: { tension: 200, friction: 30 }, // Smoother rotation spring
})
} else {
// On release, reset transform origin to center
setTransformOrigin('center center')
// On release, apply momentum with decay physics
const throwVelocityX = lastVelocityRef.current.vx * 1000
const throwVelocityY = lastVelocityRef.current.vy * 1000
// Calculate final rotation based on throw direction
const throwAngle = Math.atan2(throwVelocityY, throwVelocityX) * (180 / Math.PI)
api.start({
x: {
from: currentPositionRef.current.x + mx,
velocity: throwVelocityX,
decay: true,
},
y: {
from: currentPositionRef.current.y + my,
velocity: throwVelocityY,
decay: true,
},
scale: 1,
rotation: throwAngle + 90, // Card aligns with throw direction
config: config.wobbly,
onChange: (result) => {
// Update current position as card settles
if (result.value.x !== undefined) {
currentPositionRef.current.x = result.value.x
}
if (result.value.y !== undefined) {
currentPositionRef.current.y = result.value.y
}
if (result.value.rotation !== undefined) {
currentPositionRef.current.rotation = result.value.rotation
}
},
})
}
},
{
@@ -128,6 +241,7 @@ function DraggableCard({ card }: DraggableCardProps) {
return (
<animated.div
ref={cardRef}
{...bind()}
style={{
position: 'absolute',
@@ -137,6 +251,7 @@ function DraggableCard({ card }: DraggableCardProps) {
[x, y, rotation, scale],
(x, y, r, s) => `translate(${x}px, ${y}px) rotate(${r}deg) scale(${s})`
),
transformOrigin,
zIndex,
touchAction: 'none',
cursor: 'grab',

View File

@@ -0,0 +1,862 @@
'use client'
import { useState, useEffect } from 'react'
import { useSpring, useTransition, animated } from '@react-spring/web'
import * as Slider from '@radix-ui/react-slider'
import { AbacusReact, StandaloneBead } from '@soroban/abacus-react'
import { css } from '../../styled-system/css'
import { stack } from '../../styled-system/patterns'
import { kyuLevelDetails } from '@/data/kyuLevelDetails'
// Combine all levels into one array for the slider
const allLevels = [
{
level: '10th Kyu',
emoji: '🧒',
color: 'green',
digits: 2,
type: 'kyu' as const,
},
{
level: '9th Kyu',
emoji: '🧒',
color: 'green',
digits: 2,
type: 'kyu' as const,
},
{
level: '8th Kyu',
emoji: '🧒',
color: 'green',
digits: 3,
type: 'kyu' as const,
},
{
level: '7th Kyu',
emoji: '🧒',
color: 'green',
digits: 4,
type: 'kyu' as const,
},
{
level: '6th Kyu',
emoji: '🧑',
color: 'blue',
digits: 5,
type: 'kyu' as const,
},
{
level: '5th Kyu',
emoji: '🧑',
color: 'blue',
digits: 6,
type: 'kyu' as const,
},
{
level: '4th Kyu',
emoji: '🧑',
color: 'blue',
digits: 7,
type: 'kyu' as const,
},
{
level: '3rd Kyu',
emoji: '🧔',
color: 'violet',
digits: 8,
type: 'kyu' as const,
},
{
level: '2nd Kyu',
emoji: '🧔',
color: 'violet',
digits: 9,
type: 'kyu' as const,
},
{
level: '1st Kyu',
emoji: '🧔',
color: 'violet',
digits: 10,
type: 'kyu' as const,
},
{
level: 'Pre-1st Dan',
name: 'Jun-Shodan',
minScore: 90,
emoji: '🧙',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '1st Dan',
name: 'Shodan',
minScore: 100,
emoji: '🧙',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '2nd Dan',
name: 'Nidan',
minScore: 120,
emoji: '🧙‍♂️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '3rd Dan',
name: 'Sandan',
minScore: 140,
emoji: '🧙‍♂️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '4th Dan',
name: 'Yondan',
minScore: 160,
emoji: '🧙‍♀️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '5th Dan',
name: 'Godan',
minScore: 180,
emoji: '🧙‍♀️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '6th Dan',
name: 'Rokudan',
minScore: 200,
emoji: '🧝',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '7th Dan',
name: 'Nanadan',
minScore: 220,
emoji: '🧝',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '8th Dan',
name: 'Hachidan',
minScore: 250,
emoji: '🧝‍♂️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '9th Dan',
name: 'Kudan',
minScore: 270,
emoji: '🧝‍♀️',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
{
level: '10th Dan',
name: 'Judan',
minScore: 290,
emoji: '👑',
color: 'amber',
digits: 30,
type: 'dan' as const,
},
] as const
// Helper function to map level names to kyuLevelDetails keys
function getLevelDetailsKey(levelName: string): string | null {
// Convert "10th Kyu" → "10-kyu", "3rd Kyu" → "3-kyu", etc.
const match = levelName.match(/^(\d+)(?:st|nd|rd|th)\s+Kyu$/)
if (match) {
return `${match[1]}-kyu`
}
return null
}
// Parse and format kyu level details into structured sections with icons
function parseKyuDetails(rawText: string) {
const lines = rawText.split('\n').filter((line) => line.trim() && !line.includes('shuzan.jp'))
// Always return sections in consistent order: Add/Sub, Multiply, Divide
const sections: Array<{
type: 'addSub' | 'multiply' | 'divide'
icon: string
label: string
digits: string | null
rows: string | null
chars: string | null
problems: string | null
}> = [
{
type: 'addSub',
icon: '',
label: 'Add/Sub',
digits: null,
rows: null,
chars: null,
problems: null,
},
{
type: 'multiply',
icon: '✖️',
label: 'Multiply',
digits: null,
rows: null,
chars: null,
problems: null,
},
{
type: 'divide',
icon: '➗',
label: 'Divide',
digits: null,
rows: null,
chars: null,
problems: null,
},
]
for (const line of lines) {
if (line.includes('Add/Sub:')) {
const match = line.match(/(\d+)-digit.*?(\d+)口.*?(\d+)字/)
if (match) {
sections[0].digits = match[1]
sections[0].rows = match[2]
sections[0].chars = match[3]
}
} else if (line.includes('×:')) {
const match = line.match(/(\d+) digits.*?\((\d+)/)
if (match) {
sections[1].digits = match[1]
sections[1].problems = match[2]
}
} else if (line.includes('÷:')) {
const match = line.match(/(\d+) digits.*?\((\d+)/)
if (match) {
sections[2].digits = match[1]
sections[2].problems = match[2]
}
}
}
return sections
}
// Dark theme styles matching the homepage
const darkStyles = {
columnPosts: {
fill: 'rgba(255, 255, 255, 0.3)',
stroke: 'rgba(255, 255, 255, 0.2)',
strokeWidth: 2,
},
reckoningBar: {
fill: 'rgba(255, 255, 255, 0.4)',
stroke: 'rgba(255, 255, 255, 0.25)',
strokeWidth: 3,
},
}
interface LevelSliderDisplayProps {
initialIndex?: number
autoAdvanceEnabled?: boolean
autoAdvanceInterval?: number
showLegend?: boolean
}
export function LevelSliderDisplay({
initialIndex = 0,
autoAdvanceEnabled = true,
autoAdvanceInterval = 3000,
showLegend = true,
}: LevelSliderDisplayProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex)
const [isHovering, setIsHovering] = useState(false)
const [isPaneHovered, setIsPaneHovered] = useState(false)
const currentLevel = allLevels[currentIndex]
// State for animated abacus digits
const [animatedDigits, setAnimatedDigits] = useState<string>('')
// Initialize animated digits when level changes
useEffect(() => {
const generateRandomDigits = (numDigits: number) => {
return Array.from({ length: numDigits }, () => Math.floor(Math.random() * 10)).join('')
}
setAnimatedDigits(generateRandomDigits(currentLevel.digits))
}, [currentLevel.digits])
// Animate abacus calculations - speed increases with Dan level
useEffect(() => {
// Calculate animation speed based on level
// Kyu levels: 500ms
// Pre-1st Dan: 500ms
// 1st-10th Dan: interpolate from 500ms to 10ms
const getAnimationInterval = () => {
if (currentIndex < 11) {
// Kyu levels and Pre-1st Dan: constant 500ms
return 500
}
// 1st Dan through 10th Dan: speed up from 500ms to 10ms
// Index 11 (1st Dan) → 500ms
// Index 20 (10th Dan) → 10ms
const danProgress = (currentIndex - 11) / 9 // 0.0 to 1.0
return 500 - danProgress * 490 // 500ms down to 10ms
}
const intervalMs = getAnimationInterval()
const interval = setInterval(() => {
setAnimatedDigits((prev) => {
const digits = prev.split('').map(Number)
const numColumns = digits.length
// Pick 1-3 adjacent columns to change (grouping effect)
const groupSize = Math.floor(Math.random() * 3) + 1
const startCol = Math.floor(Math.random() * (numColumns - groupSize + 1))
// Change the selected columns
for (let i = startCol; i < startCol + groupSize && i < numColumns; i++) {
digits[i] = Math.floor(Math.random() * 10)
}
return digits.join('')
})
}, intervalMs)
return () => clearInterval(interval)
}, [currentIndex])
// Auto-advance slider position every 3 seconds (unless pane is hovered)
useEffect(() => {
if (!autoAdvanceEnabled || isPaneHovered) return
const interval = setInterval(() => {
setCurrentIndex((prev) => {
// Cycle back to 0 when reaching the end
return prev >= allLevels.length - 1 ? 0 : prev + 1
})
}, autoAdvanceInterval)
return () => clearInterval(interval)
}, [autoAdvanceEnabled, isPaneHovered, autoAdvanceInterval])
// Handle hover on slider track
const handleSliderHover = (e: React.MouseEvent<HTMLSpanElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const percentage = x / rect.width
const index = Math.round(percentage * (allLevels.length - 1))
setCurrentIndex(Math.max(0, Math.min(allLevels.length - 1, index)))
}
// Calculate scale factor based on number of columns to fit the page
// Use constrained range to prevent huge size differences between levels
// Min 1.2 (for 30-column Dan levels) to Max 2.0 (for 2-column Kyu levels)
const scaleFactor = Math.max(1.2, Math.min(2.0, 20 / currentLevel.digits))
// Animate scale factor with React Spring for smooth transitions
const animatedProps = useSpring({
scaleFactor,
config: { tension: 350, friction: 45 },
})
// Animate emoji with proper cross-fade (old fades out, new fades in)
const emojiTransitions = useTransition(currentLevel.emoji, {
keys: currentIndex,
from: { opacity: 0 },
enter: { opacity: 1 },
leave: { opacity: 0 },
config: { duration: 120 },
})
// Convert animated digits to a number/BigInt for the abacus display
// Use BigInt for large numbers to get full 30-digit precision
const displayValue =
animatedDigits.length > 15
? BigInt(animatedDigits || '0')
: Number.parseInt(animatedDigits || '0', 10)
return (
<div className={stack({ gap: '8' })}>
{/* Current Level Display */}
<div
onMouseEnter={() => setIsPaneHovered(true)}
onMouseLeave={() => setIsPaneHovered(false)}
className={css({
bg: 'transparent',
border: '2px solid',
borderColor:
currentLevel.color === 'green'
? 'green.500'
: currentLevel.color === 'blue'
? 'blue.500'
: currentLevel.color === 'violet'
? 'violet.500'
: 'amber.500',
rounded: 'xl',
p: { base: '6', md: '8' },
height: { base: 'auto', md: '700px' },
display: 'flex',
flexDirection: 'column',
})}
>
{/* Abacus-themed Radix Slider */}
<div className={css({ mb: '6', px: { base: '2', md: '8' } })}>
<div className={css({ position: 'relative', py: '12' })}>
{/* Emoji tick marks */}
<div
className={css({
position: 'absolute',
top: '0',
left: '0',
right: '0',
h: 'full',
display: 'flex',
alignItems: 'center',
pointerEvents: 'none',
px: '0', // Use full width for tick spacing
})}
>
<div
className={css({
position: 'relative',
w: 'full',
display: 'flex',
justifyContent: 'space-between',
})}
>
{allLevels.map((level, index) => (
<div
key={index}
onClick={() => setCurrentIndex(index)}
className={css({
fontSize: '4xl',
opacity: index === currentIndex ? '1' : '0.3',
transition: 'all 0.2s',
cursor: 'pointer',
pointerEvents: 'auto',
_hover: { opacity: index === currentIndex ? '1' : '0.6' },
})}
>
{level.emoji}
</div>
))}
</div>
</div>
<Slider.Root
value={[currentIndex]}
onValueChange={([value]) => setCurrentIndex(value)}
min={0}
max={allLevels.length - 1}
step={1}
onMouseMove={handleSliderHover}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={css({
position: 'relative',
display: 'flex',
alignItems: 'center',
userSelect: 'none',
touchAction: 'none',
w: 'full',
h: '32',
cursor: 'pointer',
})}
>
<Slider.Track
className={css({
bg: 'rgba(255, 255, 255, 0.2)',
position: 'relative',
flexGrow: 1,
rounded: 'full',
h: '3px',
})}
>
<Slider.Range className={css({ display: 'none' })} />
</Slider.Track>
<Slider.Thumb
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
w: '180px',
h: '128px',
bg: 'transparent',
cursor: 'grab',
transition: 'transform 0.15s ease-out, left 0.3s ease-out',
zIndex: 10,
_hover: { transform: 'scale(1.15)' },
_focus: {
outline: 'none',
transform: 'scale(1.15)',
},
_active: { cursor: 'grabbing' },
})}
>
<div className={css({ opacity: 0.75 })}>
<StandaloneBead
size={128}
color={currentLevel.color === 'violet' ? '#8b5cf6' : '#22c55e'}
animated={false}
/>
</div>
{emojiTransitions((style, emoji) => (
<animated.div
style={style}
className={css({
position: 'absolute',
fontSize: '9xl',
pointerEvents: 'none',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
})}
>
{emoji}
</animated.div>
))}
{/* Level text as part of the bead display */}
<div
className={css({
position: 'absolute',
bottom: '-80px',
left: '50%',
transform: 'translateX(-50%)',
textAlign: 'center',
pointerEvents: 'none',
whiteSpace: 'nowrap',
})}
>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color:
currentLevel.color === 'green'
? 'green.400'
: currentLevel.color === 'blue'
? 'blue.400'
: currentLevel.color === 'violet'
? 'violet.400'
: 'amber.400',
mb: '0.5',
})}
>
{currentLevel.level}
</h2>
{'name' in currentLevel && (
<div
className={css({
fontSize: 'md',
color: 'gray.300',
mb: '0.5',
})}
>
{currentLevel.name}
</div>
)}
{'minScore' in currentLevel && (
<div className={css({ fontSize: 'sm', color: 'gray.400' })}>
Min: {currentLevel.minScore}pts
</div>
)}
</div>
</Slider.Thumb>
</Slider.Root>
</div>
{/* Level Markers */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
mt: '1',
fontSize: 'xs',
color: 'gray.500',
})}
>
<span>10th Kyu</span>
<span>1st Kyu</span>
<span>10th Dan</span>
</div>
</div>
{/* Abacus Display with Level Details */}
<div
className={css({
display: 'flex',
gap: '4',
p: '6',
bg: 'rgba(0, 0, 0, 0.3)',
rounded: 'lg',
border: '1px solid',
borderColor: 'gray.700',
overflow: 'hidden',
flex: 1,
})}
>
{/* Level Details (only for Kyu levels) */}
{currentLevel.type === 'kyu' &&
(() => {
const detailsKey = getLevelDetailsKey(currentLevel.level)
const rawText = detailsKey
? kyuLevelDetails[detailsKey as keyof typeof kyuLevelDetails]
: null
const sections = rawText ? parseKyuDetails(rawText) : []
// Use consistent sizing across all levels
const sizing = { fontSize: 'md', gap: '3', iconSize: '4xl' }
return sections.length > 0 ? (
<div
className={css({
flex: '0 0 auto',
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '2',
p: '2',
maxW: '400px',
alignContent: 'center',
})}
>
{sections.map((section, idx) => {
const hasData = section.digits !== null
const levelColor =
currentLevel.color === 'green'
? 'green.300'
: currentLevel.color === 'blue'
? 'blue.300'
: 'violet.300'
return (
<div
key={idx}
className={css({
bg: hasData ? 'rgba(0, 0, 0, 0.4)' : 'rgba(0, 0, 0, 0.2)',
border: '1px solid',
borderColor: hasData ? 'gray.700' : 'gray.800',
rounded: 'md',
p: '3',
transition: 'all 0.2s',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5',
opacity: hasData ? 1 : 0.3,
width: '170px',
height: '150px',
_hover: hasData
? {
borderColor: 'gray.500',
transform: 'scale(1.05)',
}
: {},
})}
>
<span className={css({ fontSize: sizing.iconSize, lineHeight: '1' })}>
{section.icon}
</span>
{hasData && section.digits && (
<>
<div
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: levelColor,
})}
>
{section.digits} digits
</div>
{(section.rows || section.chars) && (
<div
className={css({
fontSize: 'sm',
color: 'gray.400',
textAlign: 'center',
})}
>
{section.rows && `${section.rows} rows`}
{section.rows && section.chars && ' • '}
{section.chars && `${section.chars} chars`}
</div>
)}
{section.problems && (
<div
className={css({
fontSize: 'sm',
color: 'gray.400',
textAlign: 'center',
})}
>
{section.problems} problems
</div>
)}
</>
)}
<div
className={css({
fontSize: 'xs',
color: hasData ? 'gray.500' : 'gray.700',
textAlign: 'center',
fontWeight: hasData ? 'normal' : 'bold',
})}
>
{section.label}
</div>
</div>
)
})}
</div>
) : null
})()}
{/* Abacus (right-aligned for Kyu, centered for Dan) */}
<div
className={css({
display: 'flex',
justifyContent: currentLevel.type === 'kyu' ? 'flex-end' : 'center',
alignItems: 'center',
flex: 1,
})}
>
<animated.div
style={{
transform: animatedProps.scaleFactor.to((s) => `scale(${s / scaleFactor})`),
}}
>
<AbacusReact
value={displayValue}
columns={currentLevel.digits}
scaleFactor={scaleFactor}
showNumbers={true}
customStyles={darkStyles}
/>
</animated.div>
</div>
</div>
{/* Digit Count */}
<div
className={css({
textAlign: 'center',
color: 'gray.400',
fontSize: 'sm',
})}
>
Requires mastery of <strong>{currentLevel.digits}-digit</strong> calculations
</div>
</div>
{/* Legend */}
{showLegend && (
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '6',
justifyContent: 'center',
p: '6',
bg: 'rgba(0, 0, 0, 0.3)',
rounded: 'lg',
border: '1px solid',
borderColor: 'gray.700',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
w: '4',
h: '4',
bg: 'green.500',
rounded: 'sm',
})}
/>
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>Beginner (10-7 Kyu)</span>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
w: '4',
h: '4',
bg: 'blue.500',
rounded: 'sm',
})}
/>
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
Intermediate (6-4 Kyu)
</span>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
w: '4',
h: '4',
bg: 'violet.500',
rounded: 'sm',
})}
/>
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>Advanced (3-1 Kyu)</span>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
})}
>
<div
className={css({
w: '4',
h: '4',
bg: 'amber.500',
rounded: 'sm',
})}
/>
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>Master (Dan ranks)</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "4.52.1",
"version": "4.57.5",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [