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>
This commit is contained in:
Thomas Hallock
2025-10-20 09:28:16 -05:00
parent acfb0dac0a
commit 0146ce1e67
3 changed files with 578 additions and 286 deletions

View File

@@ -1,203 +1,280 @@
'use client'
"use client";
import { useState } from 'react'
import { useSpring, animated } from '@react-spring/web'
import * as Slider from '@radix-ui/react-slider'
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 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.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))
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 },
})
});
// 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
@@ -205,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
@@ -220,78 +297,86 @@ 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: '700px' },
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>
{/* 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' })}>
<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 className={css({ position: 'relative', py: '6' })}>
<div className={css({ position: "relative", py: "6" })}>
<Slider.Root
value={[currentIndex]}
onValueChange={([value]) => setCurrentIndex(value)}
@@ -299,115 +384,106 @@ export default function LevelsPage() {
max={allLevels.length - 1}
step={1}
className={css({
position: 'relative',
display: 'flex',
alignItems: 'center',
userSelect: 'none',
touchAction: 'none',
w: 'full',
h: '12',
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 isActive = index === currentIndex;
const isDan =
"color" in level && level.color === "violet";
const size = isActive ? 14 : 8;
const beadColor = isActive
? isDan
? '#8b5cf6'
: '#22c55e'
? "#8b5cf6"
: "#22c55e"
: isDan
? 'rgba(139, 92, 246, 0.4)'
: 'rgba(34, 197, 94, 0.4)'
? "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: `${(index / (allLevels.length - 1)) * 100}%`,
transform: 'translate(-50%, -50%)',
width: `${size}px`,
height: `${size}px`,
transition: 'all 0.2s',
pointerEvents: 'none',
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',
? `drop-shadow(0 0 6px ${isDan ? "rgba(139, 92, 246, 0.6)" : "rgba(34, 197, 94, 0.6)"})`
: "none",
}}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<polygon
points={`0,${size / 2} ${size / 2},0 ${size},${size / 2} ${size / 2},${size}`}
fill={beadColor}
stroke={
isActive
? isDan
? '#c4b5fd'
: '#86efac'
: 'rgba(255, 255, 255, 0.3)'
}
strokeWidth="0.5"
/>
</svg>
<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',
bg: "rgba(255, 255, 255, 0.2)",
position: "relative",
flexGrow: 1,
rounded: 'full',
h: '3px',
rounded: "full",
h: "3px",
})}
>
<Slider.Range className={css({ display: 'none' })} />
<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',
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)' },
_hover: { transform: "scale(1.15)" },
_focus: {
outline: 'none',
transform: 'scale(1.15)',
outline: "none",
transform: "scale(1.15)",
},
_active: { cursor: 'grabbing' },
_active: { cursor: "grabbing" },
})}
>
<svg width="28" height="28" viewBox="0 0 28 28">
{/* Diamond bead matching abacus style */}
<polygon
points="0,14 14,0 28,14 14,28"
fill={currentLevel.color === 'violet' ? '#8b5cf6' : '#22c55e'}
stroke="#000"
strokeWidth="0.8"
/>
{/* Inner highlight for depth */}
<polygon
points="3,14 14,3 25,14 14,25"
fill={
currentLevel.color === 'violet'
? 'rgba(139, 92, 246, 0.3)'
: 'rgba(34, 197, 94, 0.3)'
}
stroke="none"
/>
</svg>
<StandaloneBead
size={28}
color={
currentLevel.color === "violet"
? "#8b5cf6"
: "#22c55e"
}
shape="diamond"
animated={false}
/>
</Slider.Thumb>
</Slider.Root>
</div>
@@ -415,11 +491,11 @@ export default function LevelsPage() {
{/* Level Markers */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
mt: '1',
fontSize: 'xs',
color: 'gray.500',
display: "flex",
justifyContent: "space-between",
mt: "1",
fontSize: "xs",
color: "gray.500",
})}
>
<span>10th Kyu</span>
@@ -431,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
@@ -459,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>
@@ -507,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>
@@ -543,5 +685,5 @@ export default function LevelsPage() {
</div>
</div>
</PageWithNav>
)
);
}

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";