From 5fb4751728d8dd2cefbb1b1492abb3c14bef6e1b Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 11 Dec 2025 13:37:28 -0600 Subject: [PATCH] feat(abacus): add dockable abacus feature for practice sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AbacusDock component that allows the floating MyAbacus to dock into designated areas within the UI: - New AbacusDock component with configurable props (columns, showNumbers, value, defaultValue, onValueChange, interactive, animated) - MyAbacus can now render as: hero, button, open overlay, or docked - Click floating button when dock is visible to dock the abacus - Undock button appears in top-right of docked abacus - Practice sessions use dock for answer input (auto-submit on correct answer) - Dock sizing now matches problem height with responsive widths 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/web/src/components/AbacusDock.tsx | 144 +++++++ apps/web/src/components/MyAbacus.tsx | 382 +++++++++++++----- .../src/components/practice/ActiveSession.tsx | 38 ++ .../practice/StartPracticeModal.tsx | 7 +- apps/web/src/contexts/MyAbacusContext.tsx | 98 ++++- 5 files changed, 559 insertions(+), 110 deletions(-) create mode 100644 apps/web/src/components/AbacusDock.tsx diff --git a/apps/web/src/components/AbacusDock.tsx b/apps/web/src/components/AbacusDock.tsx new file mode 100644 index 00000000..64f5026e --- /dev/null +++ b/apps/web/src/components/AbacusDock.tsx @@ -0,0 +1,144 @@ +'use client' + +import { useEffect, useRef, type CSSProperties, type HTMLAttributes } from 'react' +import { useMyAbacus, type DockConfig } from '@/contexts/MyAbacusContext' + +export interface AbacusDockProps extends Omit, 'children'> { + /** Optional identifier for debugging */ + id?: string + /** Number of columns to display (default: 5) */ + columns?: number + /** Whether the abacus is interactive (default: true) */ + interactive?: boolean + /** Whether to show numbers below columns (default: true) */ + showNumbers?: boolean + /** Whether to animate bead movements (default: true) */ + animated?: boolean + /** Scale factor for the abacus (default: auto-fit to container) */ + scaleFactor?: number + /** Controlled value - when provided, dock controls the abacus value */ + value?: number + /** Default value for uncontrolled mode */ + defaultValue?: number + /** Callback when value changes (for controlled mode) */ + onValueChange?: (newValue: number) => void +} + +/** + * AbacusDock - A container that the global MyAbacus will render into + * + * Place this component anywhere you want the abacus to appear docked. + * When mounted, the global abacus will portal into this container instead + * of showing as a floating button. + * + * @example + * ```tsx + * // Basic usage - abacus will auto-fit to the container + * + * + * // With custom configuration + * + * ``` + */ +export function AbacusDock({ + id, + columns = 5, + interactive = true, + showNumbers = true, + animated = true, + scaleFactor, + value, + defaultValue, + onValueChange, + style, + ...divProps +}: AbacusDockProps) { + const containerRef = useRef(null) + const { registerDock, unregisterDock, updateDockVisibility } = useMyAbacus() + + // Register the dock + useEffect(() => { + const element = containerRef.current + if (!element) return + + const config: DockConfig = { + element, + id, + columns, + interactive, + showNumbers, + animated, + scaleFactor, + value, + defaultValue, + onValueChange, + isVisible: false, // Will be updated by IntersectionObserver + } + + registerDock(config) + + return () => { + unregisterDock(element) + } + }, [ + id, + columns, + interactive, + showNumbers, + animated, + scaleFactor, + value, + defaultValue, + onValueChange, + registerDock, + unregisterDock, + ]) + + // Track visibility with IntersectionObserver + useEffect(() => { + const element = containerRef.current + if (!element) return + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + // Consider visible if at least 20% is in view + updateDockVisibility(element, entry.isIntersecting && entry.intersectionRatio >= 0.2) + } + }, + { + threshold: [0, 0.2, 0.5, 1.0], + } + ) + + observer.observe(element) + + return () => observer.disconnect() + }, [updateDockVisibility]) + + // Default styles for the dock container + const defaultStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + ...style, + } + + return ( +
+ ) +} + +export default AbacusDock diff --git a/apps/web/src/components/MyAbacus.tsx b/apps/web/src/components/MyAbacus.tsx index 10fb4c55..31240fb7 100644 --- a/apps/web/src/components/MyAbacus.tsx +++ b/apps/web/src/components/MyAbacus.tsx @@ -1,6 +1,7 @@ 'use client' import { useContext, useEffect, useState } from 'react' +import { createPortal } from 'react-dom' import { usePathname } from 'next/navigation' import { AbacusReact, useAbacusConfig, ABACUS_THEMES } from '@soroban/abacus-react' import { css } from '../../styled-system/css' @@ -9,18 +10,45 @@ import { HomeHeroContext } from '@/contexts/HomeHeroContext' import { useTheme } from '@/contexts/ThemeContext' export function MyAbacus() { - const { isOpen, close, toggle, isHidden, showInGame } = useMyAbacus() + const { isOpen, close, toggle, isHidden, showInGame, dock, isDockedByUser, dockInto, undock } = + useMyAbacus() const appConfig = useAbacusConfig() const pathname = usePathname() const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' + // Track dock container size for auto-scaling + const [dockSize, setDockSize] = useState<{ width: number; height: number } | null>(null) + // Sync with hero context if on home page const homeHeroContext = useContext(HomeHeroContext) const [localAbacusValue, setLocalAbacusValue] = useState(1234) const abacusValue = homeHeroContext?.abacusValue ?? localAbacusValue const setAbacusValue = homeHeroContext?.setAbacusValue ?? setLocalAbacusValue + // Observe dock container size changes + useEffect(() => { + if (!dock?.element) { + setDockSize(null) + return + } + + const element = dock.element + const updateSize = () => { + const rect = element.getBoundingClientRect() + setDockSize({ width: rect.width, height: rect.height }) + } + + // Initial size + updateSize() + + // Watch for size changes + const resizeObserver = new ResizeObserver(updateSize) + resizeObserver.observe(element) + + return () => resizeObserver.disconnect() + }, [dock?.element]) + // Determine display mode - only hero mode on actual home page const isOnHomePage = pathname === '/' || @@ -32,6 +60,10 @@ export function MyAbacus() { pathname === '/la' const isHeroVisible = homeHeroContext?.isHeroVisible ?? false const isHeroMode = isOnHomePage && isHeroVisible && !isOpen + // Only render in docked mode if user has chosen to dock + const isDocked = isDockedByUser && dock !== null && !isOpen + // Show dockable indicator when dock is visible in viewport but not yet docked + const isDockable = !isDockedByUser && dock?.isVisible && !isOpen && !isHeroMode // Close on Escape key useEffect(() => { @@ -67,12 +99,32 @@ export function MyAbacus() { // This matches /arcade, /arcade/*, and /arcade-rooms/* const isOnGameRoute = pathname?.startsWith('/arcade') + // Calculate scale factor for docked mode + // Base abacus dimensions are approximately 120px wide per column, 200px tall + const calculateDockedScale = () => { + if (!dockSize || !dock) return 1 + if (dock.scaleFactor) return dock.scaleFactor + + const columns = dock.columns ?? 5 + // Approximate base dimensions of AbacusReact at scale 1 + const baseWidth = columns * 24 + 20 // ~24px per column + padding + const baseHeight = 55 // approximate height + + const scaleX = dockSize.width / baseWidth + const scaleY = dockSize.height / baseHeight + // Use the smaller scale to fit within container, with some padding + return Math.min(scaleX, scaleY) * 0.85 + } + + const dockedScale = calculateDockedScale() + // Hide completely when: // 1. isHidden is true (e.g., virtual keyboard is shown on non-game pages) // 2. On a game route and the game hasn't opted in to show it + // 3. NOT docked (docked abacus should always show) // Still allow open state to work (user explicitly opened it) // NOTE: This must come after all hooks to follow React's rules of hooks - if (!isOpen && (isHidden || (isOnGameRoute && !showInGame))) { + if (!isOpen && !isDocked && (isHidden || (isOnGameRoute && !showInGame))) { return null } @@ -133,122 +185,236 @@ export function MyAbacus() { )} - {/* Single abacus element that morphs between states */} -
+ {/* Undock button - positioned at top-right of dock container */} + +
+ { + const numValue = Number(newValue) + // Always update local state so abacus reflects the change + // (unless dock provides its own value prop for full control) + if (dock.value === undefined) { + setAbacusValue(numValue) + } + // Also call dock's callback if provided + if (dock.onValueChange) { + dock.onValueChange(numValue) + } + }} + enhanced3d="realistic" + material3d={{ + heavenBeads: 'glossy', + earthBeads: 'satin', + lighting: 'dramatic', + woodGrain: true, + }} + /> +
+
, + dock.element + )} + + {/* Non-docked modes: hero, button, open */} + {!isDocked && ( +
- {/* Container that changes between hero, button, and open states */} -
- {/* The abacus itself - same element, scales between hero/button/open */} + {/* Container that changes between hero, button, and open states */}
- + {/* The abacus itself - same element, scales between hero/button/open */} +
+ +
-
+ )} {/* Keyframes for animations */}