Compare commits

...

10 Commits

Author SHA1 Message Date
semantic-release-bot
44f8b27fa1 chore(abacus-react): release v2.1.0 [skip ci]
# [2.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.0.0...abacus-react-v2.1.0) (2025-10-20)

### Bug Fixes

* **levels:** add fixed height to entire level display pane ([200b26c](200b26c2cd))
* **levels:** increase container height to prevent abacus clipping ([cd5c15a](cd5c15aeb2))
* **levels:** only animate abacus, not container with background/border ([c80477d](c80477d248))
* **levels:** reduce Dan scale and container height to prevent clipping ([563136f](563136fb79))
* **levels:** reduce max scale factor to allow more compact container ([ead9ee9](ead9ee9589))
* **levels:** reduce scale factor variation to minimize margin differences ([abb647c](abb647ce40))
* **levels:** revert delayed column change, keep overflow hidden ([22f00f5](22f00f59f5))
* **levels:** stabilize slider position and prevent abacus clipping ([09004dc](09004dc2c0))

### Features

* **abacus-react:** export StandaloneBead component wired to AbacusDisplayContext ([0146ce1](0146ce1e67))
* **levels:** add hover interaction and smooth React Spring transitions ([fd2b633](fd2b6338a8))
* **levels:** redesign slider with abacus-themed beads ([f3dce84](f3dce84532))
* **levels:** replace slider thumb with diamond-shaped abacus beads ([0fbde53](0fbde53039))
2025-10-20 14:31:30 +00:00
Thomas Hallock
0146ce1e67 feat(abacus-react): export StandaloneBead component wired to AbacusDisplayContext
feat(levels): use StandaloneBead for slider thumb and decorative tick beads

fix(levels): make slider background transparent to prevent abacus clipping

Created StandaloneBead component that integrates with the abacus style context,
replacing hardcoded SVG diamonds with proper context-aware bead rendering.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 09:28:41 -05:00
semantic-release-bot
acfb0dac0a chore(release): 4.35.0 [skip ci]
## [4.35.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.34.0...v4.35.0) (2025-10-20)

### Features

* **levels:** replace slider thumb with diamond-shaped abacus beads ([0fbde53](0fbde53039))
2025-10-20 14:21:41 +00:00
Thomas Hallock
0fbde53039 feat(levels): replace slider thumb with diamond-shaped abacus beads
Replaced circular slider elements with proper diamond-shaped beads that
match the authentic abacus bead style:

**Slider Thumb (Drag Handle)**:
- Diamond SVG polygon matching AbacusReact bead geometry
- 28x28px size for easy grabbing
- Two-layer design: outer diamond + inner highlight for depth
- Black stroke (0.8px) matching abacus bead styling
- Color-coded: violet for Dan levels, green for Kyu levels
- Maintains grab/grabbing cursor states

**Decorative Tick Beads**:
- All 40 level markers now use diamond shapes instead of circles
- Sized 8px (inactive) to 14px (active)
- Same color scheme and styling as main beads
- Proper stroke colors matching active/inactive states
- Drop-shadow filter for active bead glow effect

This creates a cohesive visual language connecting the interactive
slider to the abacus display, making the page feel more integrated
and true to the abacus aesthetic.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 09:20:33 -05:00
semantic-release-bot
90bbe6fbb7 chore(release): 4.34.0 [skip ci]
## [4.34.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.8...v4.34.0) (2025-10-20)

### Features

* **levels:** redesign slider with abacus-themed beads ([f3dce84](f3dce84532))

### Code Refactoring

* **levels:** convert to Radix UI Slider with abacus theme ([a03e73c](a03e73c849))
2025-10-20 14:16:55 +00:00
Thomas Hallock
a03e73c849 refactor(levels): convert to Radix UI Slider with abacus theme
Replaced custom HTML input slider with Radix UI Slider for better
accessibility and consistency with the rest of the application.

Key changes:
- Integrated @radix-ui/react-slider for proper a11y support
- Maintained abacus-themed visual design with bead ticks
- Larger interactive area (h: '12' / 48px) for easier interaction
- Color-coded beads (green for Kyu, violet for Dan)
- Interactive thumb styled as a prominent bead with grab cursor
- Decorative beads for all 40 levels with pointer-events: none
- Smooth transitions and hover effects on thumb
- Removed custom hover handler in favor of Radix's built-in interaction

This provides a more robust and accessible slider while maintaining
the unique abacus aesthetic.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 09:15:46 -05:00
Thomas Hallock
f3dce84532 feat(levels): redesign slider with abacus-themed beads
Replaced standard slider with custom abacus-themed design:
- Added bead-like circular ticks for all 40 levels
- Color-coded beads (green for Kyu, violet for Dan)
- Active bead glows and scales up (16px) with shadow effect
- Inactive beads are semi-transparent (12px)
- Increased hit target with vertical padding (py: '6')
- Added horizontal "reckoning bar" as the track
- Smooth transitions on bead state changes

This creates a more forgiving hover/drag experience and prevents
confusion from minor cursor deviations while interacting.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 09:13:26 -05:00
semantic-release-bot
3b6284ae18 chore(release): 4.33.8 [skip ci]
## [4.33.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.7...v4.33.8) (2025-10-20)

### Bug Fixes

* **levels:** reduce Dan scale and container height to prevent clipping ([563136f](563136fb79))
* **levels:** reduce max scale factor to allow more compact container ([ead9ee9](ead9ee9589))
2025-10-20 14:13:05 +00:00
Thomas Hallock
563136fb79 fix(levels): reduce Dan scale and container height to prevent clipping
Changed minimum scale factor from 1.5 to 1.2 for 30-column Dan abacuses
to prevent leftmost/rightmost columns from being clipped. Also reduced
container height from 900px to 700px to provide better visual balance
without excessive whitespace around the largest Kyu abacus.

Scale factor range is now 1.2 to 2.0, creating a 1.67x size difference.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 09:11:59 -05:00
Thomas Hallock
ead9ee9589 fix(levels): reduce max scale factor to allow more compact container
Changed maximum scale factor from 3.0 to 2.0 for small Kyu abacuses.
This allows for a more compact fixed-height container while still
providing appropriate visual scaling across all levels.

Scale factor range is now 1.5 to 2.0, creating a 1.33x size difference
instead of the previous 2.0x difference.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 09:10:20 -05:00
6 changed files with 670 additions and 248 deletions

View File

@@ -1,3 +1,30 @@
## [4.35.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.34.0...v4.35.0) (2025-10-20)
### Features
* **levels:** replace slider thumb with diamond-shaped abacus beads ([0fbde53](https://github.com/antialias/soroban-abacus-flashcards/commit/0fbde53039d3ea000c6a3be492b733479e7bf47c))
## [4.34.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.8...v4.34.0) (2025-10-20)
### Features
* **levels:** redesign slider with abacus-themed beads ([f3dce84](https://github.com/antialias/soroban-abacus-flashcards/commit/f3dce84532fa706e4ec9551facde2055a060ee13))
### Code Refactoring
* **levels:** convert to Radix UI Slider with abacus theme ([a03e73c](https://github.com/antialias/soroban-abacus-flashcards/commit/a03e73c849c5da4337f26a74b8f12b617c66068e))
## [4.33.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.7...v4.33.8) (2025-10-20)
### Bug Fixes
* **levels:** reduce Dan scale and container height to prevent clipping ([563136f](https://github.com/antialias/soroban-abacus-flashcards/commit/563136fb79fa10b2af3a119bf0f861e3b0812b2e))
* **levels:** reduce max scale factor to allow more compact container ([ead9ee9](https://github.com/antialias/soroban-abacus-flashcards/commit/ead9ee9589aa4d7376e9385da5da53a6b444858a))
## [4.33.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.6...v4.33.7) (2025-10-20)

View File

@@ -1,220 +1,280 @@
'use client'
"use client";
import { useRef, useState } from 'react'
import { useSpring, animated } from '@react-spring/web'
import { AbacusReact } from '@soroban/abacus-react'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../styled-system/css'
import { container, stack } from '../../../styled-system/patterns'
import { useState } from "react";
import { useSpring, animated } from "@react-spring/web";
import * as Slider from "@radix-ui/react-slider";
import {
AbacusReact,
StandaloneBead,
AbacusDisplayProvider,
} from "@soroban/abacus-react";
import { PageWithNav } from "@/components/PageWithNav";
import { css } from "../../../styled-system/css";
import { container, stack } from "../../../styled-system/patterns";
// 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',
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',
emoji: "🧙",
color: "amber",
digits: 30,
type: 'dan' as const,
type: "dan" as const,
},
{
level: '1st Dan',
name: 'Shodan',
level: "1st Dan",
name: "Shodan",
minScore: 100,
emoji: '🧙',
color: 'amber',
emoji: "🧙",
color: "amber",
digits: 30,
type: 'dan' as const,
type: "dan" as const,
},
{
level: '2nd Dan',
name: 'Nidan',
level: "2nd Dan",
name: "Nidan",
minScore: 120,
emoji: '🧙‍♂️',
color: 'amber',
emoji: "🧙‍♂️",
color: "amber",
digits: 30,
type: 'dan' as const,
type: "dan" as const,
},
{
level: '3rd Dan',
name: 'Sandan',
level: "3rd Dan",
name: "Sandan",
minScore: 140,
emoji: '🧙‍♂️',
color: 'amber',
emoji: "🧙‍♂️",
color: "amber",
digits: 30,
type: 'dan' as const,
type: "dan" as const,
},
{
level: '4th Dan',
name: 'Yondan',
level: "4th Dan",
name: "Yondan",
minScore: 160,
emoji: '🧙‍♀️',
color: 'amber',
emoji: "🧙‍♀️",
color: "amber",
digits: 30,
type: 'dan' as const,
type: "dan" as const,
},
{
level: '5th Dan',
name: 'Godan',
level: "5th Dan",
name: "Godan",
minScore: 180,
emoji: '🧙‍♀️',
color: 'amber',
emoji: "🧙‍♀️",
color: "amber",
digits: 30,
type: 'dan' as const,
type: "dan" as const,
},
{
level: '6th Dan',
name: 'Rokudan',
level: "6th Dan",
name: "Rokudan",
minScore: 200,
emoji: '🧝',
color: 'amber',
emoji: "🧝",
color: "amber",
digits: 30,
type: 'dan' as const,
type: "dan" as const,
},
{
level: '7th Dan',
name: 'Nanadan',
level: "7th Dan",
name: "Nanadan",
minScore: 220,
emoji: '🧝',
color: 'amber',
emoji: "🧝",
color: "amber",
digits: 30,
type: 'dan' as const,
type: "dan" as const,
},
{
level: '8th Dan',
name: 'Hachidan',
level: "8th Dan",
name: "Hachidan",
minScore: 250,
emoji: '🧝‍♂️',
color: 'amber',
emoji: "🧝‍♂️",
color: "amber",
digits: 30,
type: 'dan' as const,
type: "dan" as const,
},
{
level: '9th Dan',
name: 'Kudan',
level: "9th Dan",
name: "Kudan",
minScore: 270,
emoji: '🧝‍♀️',
color: 'amber',
emoji: "🧝‍♀️",
color: "amber",
digits: 30,
type: 'dan' as const,
type: "dan" as const,
},
{
level: '10th Dan',
name: 'Judan',
level: "10th Dan",
name: "Judan",
minScore: 290,
emoji: '👑',
color: 'amber',
emoji: "👑",
color: "amber",
digits: 30,
type: 'dan' as const,
type: "dan" as const,
},
] as const
] as const;
export default function LevelsPage() {
const [currentIndex, setCurrentIndex] = useState(0)
const sliderRef = useRef<HTMLInputElement>(null)
const currentLevel = allLevels[currentIndex]
const [currentIndex, setCurrentIndex] = useState(0);
const currentLevel = allLevels[currentIndex];
// Calculate scale factor based on number of columns to fit the page
// Use constrained range to prevent huge size differences between levels
// Min 1.5 (for 30-column Dan levels) to Max 3.0 (for 2-column Kyu levels)
const scaleFactor = Math.max(1.5, Math.min(3.0, 20 / currentLevel.digits))
// 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: 280, friction: 60 },
})
// Handle mouse/touch move over slider for hover interaction
const handleSliderHover = (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
) => {
if (!sliderRef.current) return
const rect = sliderRef.current.getBoundingClientRect()
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
const x = clientX - rect.left
const percentage = Math.max(0, Math.min(1, x / rect.width))
const newIndex = Math.round(percentage * (allLevels.length - 1))
if (newIndex !== currentIndex) {
setCurrentIndex(newIndex)
}
}
});
// Generate an interesting non-zero number to display on the abacus
// Use a suffix pattern so rightmost digits stay constant as columns increase
// This prevents beads from shifting: ones always 9, tens always 8, etc.
const digitPattern = '123456789'
const digitPattern = "123456789";
// Use BigInt for numbers > 15 digits (Dan levels with 30 columns)
const repeatedPattern = digitPattern.repeat(Math.ceil(currentLevel.digits / digitPattern.length))
const digitString = repeatedPattern.slice(-currentLevel.digits)
const repeatedPattern = digitPattern.repeat(
Math.ceil(currentLevel.digits / digitPattern.length),
);
const digitString = repeatedPattern.slice(-currentLevel.digits);
// Use BigInt for large numbers to get full 30-digit precision
const displayValue =
currentLevel.digits > 15 ? BigInt(digitString) : Number.parseInt(digitString, 10)
currentLevel.digits > 15
? BigInt(digitString)
: Number.parseInt(digitString, 10);
// Dark theme styles matching the homepage
const darkStyles = {
columnPosts: {
fill: 'rgba(255, 255, 255, 0.3)',
stroke: 'rgba(255, 255, 255, 0.2)',
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)',
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' })}>
<div className={css({ bg: "gray.900", minHeight: "100vh", pb: "12" })}>
{/* Hero Section */}
<div
className={css({
background:
'linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(124, 58, 237, 0.3) 50%, rgba(17, 24, 39, 1) 100%)',
color: 'white',
py: { base: '12', md: '16' },
position: 'relative',
overflow: 'hidden',
"linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(124, 58, 237, 0.3) 50%, rgba(17, 24, 39, 1) 100%)",
color: "white",
py: { base: "12", md: "16" },
position: "relative",
overflow: "hidden",
})}
>
<div
className={css({
position: 'absolute',
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',
"radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)",
backgroundSize: "40px 40px",
})}
/>
<div className={container({ maxW: '6xl', px: '4', position: 'relative' })}>
<div className={css({ textAlign: 'center', maxW: '5xl', mx: 'auto' })}>
<div
className={container({
maxW: "6xl",
px: "4",
position: "relative",
})}
>
<div
className={css({ textAlign: "center", maxW: "5xl", mx: "auto" })}
>
<h1
className={css({
fontSize: { base: '3xl', md: '5xl', lg: '6xl' },
fontWeight: 'bold',
mb: '4',
lineHeight: 'tight',
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
backgroundClip: 'text',
color: 'transparent',
fontSize: { base: "3xl", md: "5xl", lg: "6xl" },
fontWeight: "bold",
mb: "4",
lineHeight: "tight",
background:
"linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)",
backgroundClip: "text",
color: "transparent",
})}
>
Understanding Kyu & Dan Levels
@@ -222,12 +282,12 @@ export default function LevelsPage() {
<p
className={css({
fontSize: { base: 'lg', md: 'xl' },
color: 'gray.300',
mb: '6',
maxW: '3xl',
mx: 'auto',
lineHeight: '1.6',
fontSize: { base: "lg", md: "xl" },
color: "gray.300",
mb: "6",
maxW: "3xl",
mx: "auto",
lineHeight: "1.6",
})}
>
Slide through the complete progression from beginner to master
@@ -237,108 +297,205 @@ export default function LevelsPage() {
</div>
{/* Main content */}
<div className={container({ maxW: '6xl', px: '4', py: '12' })}>
<section className={stack({ gap: '8' })}>
<div className={container({ maxW: "6xl", px: "4", py: "12" })}>
<section className={stack({ gap: "8" })}>
{/* Current Level Display */}
<div
className={css({
bg: 'rgba(0, 0, 0, 0.4)',
border: '2px solid',
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: '900px' },
display: 'flex',
flexDirection: 'column',
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",
})}
>
{/* Level Info */}
<div
className={css({
textAlign: 'center',
mb: '4',
height: '160px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
textAlign: "center",
mb: "4",
height: "160px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
})}
>
<div className={css({ fontSize: '5xl', mb: '3' })}>{currentLevel.emoji}</div>
<div className={css({ fontSize: "5xl", mb: "3" })}>
{currentLevel.emoji}
</div>
<h2
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
fontSize: { base: "2xl", md: "3xl" },
fontWeight: "bold",
color:
currentLevel.color === 'green'
? 'green.400'
: currentLevel.color === 'blue'
? 'blue.400'
: currentLevel.color === 'violet'
? 'violet.400'
: 'amber.400',
mb: '2',
currentLevel.color === "green"
? "green.400"
: currentLevel.color === "blue"
? "blue.400"
: currentLevel.color === "violet"
? "violet.400"
: "amber.400",
mb: "2",
})}
>
{currentLevel.level}
</h2>
{'name' in currentLevel && (
<div className={css({ fontSize: 'md', color: 'gray.400', mb: '1' })}>
{"name" in currentLevel && (
<div
className={css({
fontSize: "md",
color: "gray.400",
mb: "1",
})}
>
{currentLevel.name}
</div>
)}
{'minScore' in currentLevel && (
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
{"minScore" in currentLevel && (
<div className={css({ fontSize: "sm", color: "gray.500" })}>
Minimum Score: {currentLevel.minScore} points
</div>
)}
</div>
{/* Range Slider with hover support */}
<div className={css({ mb: '6', px: { base: '2', md: '8' } })}>
<div className={css({ mb: '3', textAlign: 'center' })}>
<p className={css({ fontSize: 'sm', color: 'gray.400' })}>
Hover, drag, or touch the slider to explore all levels
{/* Abacus-themed Radix Slider */}
<div className={css({ mb: "6", px: { base: "2", md: "8" } })}>
<div className={css({ mb: "3", textAlign: "center" })}>
<p className={css({ fontSize: "sm", color: "gray.400" })}>
Drag or click the beads to explore all levels
</p>
</div>
<div
onMouseMove={handleSliderHover}
onTouchMove={handleSliderHover}
className={css({ position: 'relative' })}
>
<input
ref={sliderRef}
type="range"
min="0"
<div className={css({ position: "relative", py: "6" })}>
<Slider.Root
value={[currentIndex]}
onValueChange={([value]) => setCurrentIndex(value)}
min={0}
max={allLevels.length - 1}
value={currentIndex}
onChange={(e) => setCurrentIndex(Number(e.target.value))}
step={1}
className={css({
w: '100%',
h: '2',
bg: 'gray.700',
rounded: 'full',
outline: 'none',
cursor: 'pointer',
position: "relative",
display: "flex",
alignItems: "center",
userSelect: "none",
touchAction: "none",
w: "full",
h: "12",
cursor: "pointer",
})}
/>
>
{/* Decorative diamond beads for all levels */}
{allLevels.map((level, index) => {
const isActive = index === currentIndex;
const isDan =
"color" in level && level.color === "violet";
const size = isActive ? 14 : 8;
const beadColor = isActive
? isDan
? "#8b5cf6"
: "#22c55e"
: isDan
? "rgba(139, 92, 246, 0.4)"
: "rgba(34, 197, 94, 0.4)";
// Match Radix Slider thumb positioning
// Radix positions thumb center at the value position, accounting for thumb width
const percentage = (index / (allLevels.length - 1)) * 100;
return (
<div
key={index}
style={{
position: "absolute",
top: "50%",
left: `${percentage}%`,
transform: "translate(-50%, -50%)",
transition: "all 0.2s",
pointerEvents: "none",
zIndex: 0,
filter: isActive
? `drop-shadow(0 0 6px ${isDan ? "rgba(139, 92, 246, 0.6)" : "rgba(34, 197, 94, 0.6)"})`
: "none",
}}
>
<StandaloneBead
size={size}
color={beadColor}
shape="diamond"
animated={false}
active={
isActive ||
(beadColor !== "rgba(139, 92, 246, 0.4)" &&
beadColor !== "rgba(34, 197, 94, 0.4)")
}
/>
</div>
);
})}
<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",
w: "28px",
h: "28px",
bg: "transparent",
cursor: "grab",
transition: "all 0.2s",
zIndex: 10,
_hover: { transform: "scale(1.15)" },
_focus: {
outline: "none",
transform: "scale(1.15)",
},
_active: { cursor: "grabbing" },
})}
>
<StandaloneBead
size={28}
color={
currentLevel.color === "violet"
? "#8b5cf6"
: "#22c55e"
}
shape="diamond"
animated={false}
/>
</Slider.Thumb>
</Slider.Root>
</div>
{/* Level Markers */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
mt: '3',
fontSize: 'xs',
color: 'gray.500',
display: "flex",
justifyContent: "space-between",
mt: "1",
fontSize: "xs",
color: "gray.500",
})}
>
<span>10th Kyu</span>
@@ -350,21 +507,23 @@ export default function LevelsPage() {
{/* Abacus Display */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
p: '6',
bg: 'rgba(0, 0, 0, 0.3)',
rounded: 'lg',
border: '1px solid',
borderColor: 'gray.700',
overflow: 'hidden',
display: "flex",
justifyContent: "center",
alignItems: "center",
p: "6",
bg: "rgba(0, 0, 0, 0.3)",
rounded: "lg",
border: "1px solid",
borderColor: "gray.700",
overflow: "hidden",
flex: 1,
})}
>
<animated.div
style={{
transform: animatedProps.scaleFactor.to((s) => `scale(${s / scaleFactor})`),
transform: animatedProps.scaleFactor.to(
(s) => `scale(${s / scaleFactor})`,
),
}}
>
<AbacusReact
@@ -378,46 +537,105 @@ export default function LevelsPage() {
</div>
{/* Digit Count */}
<div className={css({ textAlign: 'center', color: 'gray.400', fontSize: 'sm' })}>
Requires mastery of <strong>{currentLevel.digits}-digit</strong> calculations
<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',
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' })}>
<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' })}>
<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' })}>
<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' })}>
<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>
@@ -426,34 +644,39 @@ export default function LevelsPage() {
{/* Info Section */}
<div
className={css({
bg: 'rgba(0, 0, 0, 0.4)',
border: '1px solid',
borderColor: 'gray.700',
rounded: 'xl',
p: { base: '6', md: '8' },
bg: "rgba(0, 0, 0, 0.4)",
border: "1px solid",
borderColor: "gray.700",
rounded: "xl",
p: { base: "6", md: "8" },
})}
>
<h3
className={css({
fontSize: { base: 'xl', md: '2xl' },
fontWeight: 'bold',
color: 'white',
mb: '4',
fontSize: { base: "xl", md: "2xl" },
fontWeight: "bold",
color: "white",
mb: "4",
})}
>
About This Ranking System
</h3>
<div className={stack({ gap: '4' })}>
<p className={css({ color: 'gray.300', lineHeight: '1.6' })}>
This ranking system is based on the official examination structure used by the{' '}
<strong className={css({ color: 'white' })}>Japan Abacus Federation</strong>. It
represents a standardized progression from beginner (10th Kyu) to master level
(10th Dan), used internationally for soroban proficiency assessment.
<div className={stack({ gap: "4" })}>
<p className={css({ color: "gray.300", lineHeight: "1.6" })}>
This ranking system is based on the official examination
structure used by the{" "}
<strong className={css({ color: "white" })}>
Japan Abacus Federation
</strong>
. It represents a standardized progression from beginner (10th
Kyu) to master level (10th Dan), used internationally for
soroban proficiency assessment.
</p>
<p className={css({ color: 'gray.300', lineHeight: '1.6' })}>
The system is designed to gradually increase in difficulty. Kyu levels progress
from 2-digit calculations at 10th Kyu to 10-digit calculations at 1st Kyu. Dan
levels all require mastery of 30-digit calculations, with ranks awarded based on
<p className={css({ color: "gray.300", lineHeight: "1.6" })}>
The system is designed to gradually increase in difficulty.
Kyu levels progress from 2-digit calculations at 10th Kyu to
10-digit calculations at 1st Kyu. Dan levels all require
mastery of 30-digit calculations, with ranks awarded based on
exam scores.
</p>
</div>
@@ -462,5 +685,5 @@ export default function LevelsPage() {
</div>
</div>
</PageWithNav>
)
);
}

View File

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

View File

@@ -1,3 +1,25 @@
# [2.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.0.0...abacus-react-v2.1.0) (2025-10-20)
### Bug Fixes
* **levels:** add fixed height to entire level display pane ([200b26c](https://github.com/antialias/soroban-abacus-flashcards/commit/200b26c2cd35d1d637ede9dcfc3dbbc7f3f19320))
* **levels:** increase container height to prevent abacus clipping ([cd5c15a](https://github.com/antialias/soroban-abacus-flashcards/commit/cd5c15aeb260c568fe7ad9b6a4f51c4d6498b2b8))
* **levels:** only animate abacus, not container with background/border ([c80477d](https://github.com/antialias/soroban-abacus-flashcards/commit/c80477d24877ddada5f3f4405abbf05e1d753b5d))
* **levels:** reduce Dan scale and container height to prevent clipping ([563136f](https://github.com/antialias/soroban-abacus-flashcards/commit/563136fb79fa10b2af3a119bf0f861e3b0812b2e))
* **levels:** reduce max scale factor to allow more compact container ([ead9ee9](https://github.com/antialias/soroban-abacus-flashcards/commit/ead9ee9589aa4d7376e9385da5da53a6b444858a))
* **levels:** reduce scale factor variation to minimize margin differences ([abb647c](https://github.com/antialias/soroban-abacus-flashcards/commit/abb647ce40b8f9d0c8268ab18c139324ae3195c5))
* **levels:** revert delayed column change, keep overflow hidden ([22f00f5](https://github.com/antialias/soroban-abacus-flashcards/commit/22f00f59f5facc36a846408dcd196ec54ea676b1))
* **levels:** stabilize slider position and prevent abacus clipping ([09004dc](https://github.com/antialias/soroban-abacus-flashcards/commit/09004dc2c055031ee2f71c964ceee6f7b1d42ecd))
### Features
* **abacus-react:** export StandaloneBead component wired to AbacusDisplayContext ([0146ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/0146ce1e67da27a24cbaa8338ba6a1a6befd6bd3))
* **levels:** add hover interaction and smooth React Spring transitions ([fd2b633](https://github.com/antialias/soroban-abacus-flashcards/commit/fd2b6338a84c3bbc683eff216a8da3b155749f0f))
* **levels:** redesign slider with abacus-themed beads ([f3dce84](https://github.com/antialias/soroban-abacus-flashcards/commit/f3dce84532fa706e4ec9551facde2055a060ee13))
* **levels:** replace slider thumb with diamond-shaped abacus beads ([0fbde53](https://github.com/antialias/soroban-abacus-flashcards/commit/0fbde53039d3ea000c6a3be492b733479e7bf47c))
# [2.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.8.0...abacus-react-v2.0.0) (2025-10-20)

View File

@@ -0,0 +1,147 @@
"use client";
import React from "react";
import { useSpring, animated } from "@react-spring/web";
import {
useAbacusConfig,
getDefaultAbacusConfig,
type BeadShape,
} from "./AbacusContext";
export interface StandaloneBeadProps {
/** Size of the bead in pixels */
size?: number;
/** Override the shape from context (diamond, circle, square) */
shape?: BeadShape;
/** Override the color from context */
color?: string;
/** Enable animation */
animated?: boolean;
/** Custom className */
className?: string;
/** Custom style */
style?: React.CSSProperties;
/** Active state for the bead */
active?: boolean;
}
/**
* Standalone Bead component that respects the AbacusDisplayContext.
* This component renders a single abacus bead with styling from the context.
*
* Usage:
* ```tsx
* import { StandaloneBead, AbacusDisplayProvider } from '@soroban/abacus-react';
*
* <AbacusDisplayProvider>
* <StandaloneBead size={28} color="#8b5cf6" />
* </AbacusDisplayProvider>
* ```
*/
export const StandaloneBead: React.FC<StandaloneBeadProps> = ({
size = 28,
shape: shapeProp,
color: colorProp,
animated: animatedProp,
className,
style,
active = true,
}) => {
// Try to use context config, fallback to defaults if no context
let contextConfig;
try {
contextConfig = useAbacusConfig();
} catch {
// No context provider, use defaults
contextConfig = getDefaultAbacusConfig();
}
// Use props if provided, otherwise fall back to context config
const shape = shapeProp ?? contextConfig.beadShape;
const enableAnimation = animatedProp ?? contextConfig.animated;
const color = colorProp ?? "#000000";
const [springs, api] = useSpring(() => ({
scale: 1,
config: { tension: 300, friction: 20 },
}));
React.useEffect(() => {
if (enableAnimation) {
api.start({ scale: 1 });
}
}, [enableAnimation, api]);
const renderShape = () => {
const halfSize = size / 2;
const actualColor = active ? color : "rgb(211, 211, 211)";
switch (shape) {
case "diamond":
return (
<polygon
points={`${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`}
fill={actualColor}
stroke="#000"
strokeWidth="0.5"
/>
);
case "square":
return (
<rect
width={size}
height={size}
fill={actualColor}
stroke="#000"
strokeWidth="0.5"
rx="1"
/>
);
case "circle":
default:
return (
<circle
cx={halfSize}
cy={halfSize}
r={halfSize}
fill={actualColor}
stroke="#000"
strokeWidth="0.5"
/>
);
}
};
const getXOffset = () => {
return shape === "diamond" ? size * 0.7 : size / 2;
};
const getYOffset = () => {
return size / 2;
};
const AnimatedG = animated.g;
return (
<svg
width={size * (shape === "diamond" ? 1.4 : 1)}
height={size}
className={className}
style={style}
>
<AnimatedG
transform={`translate(0, 0)`}
style={
enableAnimation
? {
transform: springs.scale.to((s) => `scale(${s})`),
transformOrigin: "center",
}
: undefined
}
>
{renderShape()}
</AnimatedG>
</svg>
);
};

View File

@@ -14,3 +14,6 @@ export type {
AbacusDisplayConfig,
AbacusDisplayContextType,
} from "./AbacusContext";
export { StandaloneBead } from "./StandaloneBead";
export type { StandaloneBeadProps } from "./StandaloneBead";