From 0146ce1e67da27a24cbaa8338ba6a1a6befd6bd3 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 20 Oct 2025 09:28:16 -0500 Subject: [PATCH] feat(abacus-react): export StandaloneBead component wired to AbacusDisplayContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/app/levels/page.tsx | 714 +++++++++++-------- packages/abacus-react/src/StandaloneBead.tsx | 147 ++++ packages/abacus-react/src/index.ts | 3 + 3 files changed, 578 insertions(+), 286 deletions(-) create mode 100644 packages/abacus-react/src/StandaloneBead.tsx diff --git a/apps/web/src/app/levels/page.tsx b/apps/web/src/app/levels/page.tsx index 552e2859..cc316077 100644 --- a/apps/web/src/app/levels/page.tsx +++ b/apps/web/src/app/levels/page.tsx @@ -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 ( -
+
{/* Hero Section */}
-
-
+
+

Understanding Kyu & Dan Levels @@ -205,12 +282,12 @@ export default function LevelsPage() {

Slide through the complete progression from beginner to master @@ -220,78 +297,86 @@ export default function LevelsPage() {

{/* Main content */} -
-
+
+
{/* Current Level Display */}
{/* Level Info */}
-
{currentLevel.emoji}
+
+ {currentLevel.emoji} +

{currentLevel.level}

- {'name' in currentLevel && ( -
+ {"name" in currentLevel && ( +
{currentLevel.name}
)} - {'minScore' in currentLevel && ( -
+ {"minScore" in currentLevel && ( +
Minimum Score: {currentLevel.minScore} points
)}
{/* Abacus-themed Radix Slider */} -
-
-

+

+
+

Drag or click the beads to explore all levels

-
+
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 (
- - - +
- ) + ); })} - + - - {/* Diamond bead matching abacus style */} - - {/* Inner highlight for depth */} - - +
@@ -415,11 +491,11 @@ export default function LevelsPage() { {/* Level Markers */}
10th Kyu @@ -431,21 +507,23 @@ export default function LevelsPage() { {/* Abacus Display */}
`scale(${s / scaleFactor})`), + transform: animatedProps.scaleFactor.to( + (s) => `scale(${s / scaleFactor})`, + ), }} > {/* Digit Count */} -
- Requires mastery of {currentLevel.digits}-digit calculations +
+ Requires mastery of {currentLevel.digits}-digit{" "} + calculations
{/* Legend */}
-
-
- +
+
+ Beginner (10-7 Kyu)
-
-
- +
+
+ Intermediate (6-4 Kyu)
-
-
- +
+
+ Advanced (3-1 Kyu)
-
-
- +
+
+ Master (Dan ranks)
@@ -507,34 +644,39 @@ export default function LevelsPage() { {/* Info Section */}

About This Ranking System

-
-

- This ranking system is based on the official examination structure used by the{' '} - Japan Abacus Federation. It - represents a standardized progression from beginner (10th Kyu) to master level - (10th Dan), used internationally for soroban proficiency assessment. +

+

+ This ranking system is based on the official examination + structure used by the{" "} + + Japan Abacus Federation + + . It represents a standardized progression from beginner (10th + Kyu) to master level (10th Dan), used internationally for + soroban proficiency assessment.

-

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

+ 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.

@@ -543,5 +685,5 @@ export default function LevelsPage() {
- ) + ); } diff --git a/packages/abacus-react/src/StandaloneBead.tsx b/packages/abacus-react/src/StandaloneBead.tsx new file mode 100644 index 00000000..f4c97ba0 --- /dev/null +++ b/packages/abacus-react/src/StandaloneBead.tsx @@ -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'; + * + * + * + * + * ``` + */ +export const StandaloneBead: React.FC = ({ + 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 ( + + ); + case "square": + return ( + + ); + case "circle": + default: + return ( + + ); + } + }; + + const getXOffset = () => { + return shape === "diamond" ? size * 0.7 : size / 2; + }; + + const getYOffset = () => { + return size / 2; + }; + + const AnimatedG = animated.g; + + return ( + + `scale(${s})`), + transformOrigin: "center", + } + : undefined + } + > + {renderShape()} + + + ); +}; diff --git a/packages/abacus-react/src/index.ts b/packages/abacus-react/src/index.ts index 38e447dd..6a1ee6e1 100644 --- a/packages/abacus-react/src/index.ts +++ b/packages/abacus-react/src/index.ts @@ -14,3 +14,6 @@ export type { AbacusDisplayConfig, AbacusDisplayContextType, } from "./AbacusContext"; + +export { StandaloneBead } from "./StandaloneBead"; +export type { StandaloneBeadProps } from "./StandaloneBead";