feat(abacus): add dockable abacus feature for practice sessions

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-11 13:37:28 -06:00
parent 1a7945dd0b
commit 5fb4751728
5 changed files with 559 additions and 110 deletions

View File

@ -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<HTMLAttributes<HTMLDivElement>, '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
* <AbacusDock className={css({ width: '300px', height: '400px' })} />
*
* // With custom configuration
* <AbacusDock
* columns={4}
* interactive={true}
* showNumbers={true}
* className={css({ width: '250px', height: '350px' })}
* />
* ```
*/
export function AbacusDock({
id,
columns = 5,
interactive = true,
showNumbers = true,
animated = true,
scaleFactor,
value,
defaultValue,
onValueChange,
style,
...divProps
}: AbacusDockProps) {
const containerRef = useRef<HTMLDivElement>(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 (
<div
ref={containerRef}
data-component="abacus-dock"
data-dock-id={id}
style={defaultStyle}
{...divProps}
/>
)
}
export default AbacusDock

View File

@ -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() {
</button>
)}
{/* Single abacus element that morphs between states */}
<div
data-component="my-abacus"
data-mode={isOpen ? 'open' : isHeroMode ? 'hero' : 'button'}
onClick={isOpen || isHeroMode ? undefined : toggle}
className={css({
position: isHeroMode ? 'absolute' : 'fixed',
zIndex: 102,
cursor: isOpen || isHeroMode ? 'default' : 'pointer',
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
// Three modes: hero (absolute - scrolls with document), button (fixed), open (fixed)
...(isOpen
? {
// Open mode: fixed to center of viewport
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}
: isHeroMode
{/* Docked mode: render directly into the dock container via portal */}
{isDocked &&
dock?.element &&
createPortal(
<div
data-component="my-abacus"
data-mode="docked"
data-dock-id={dock.id}
className={css({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
})}
>
{/* Undock button - positioned at top-right of dock container */}
<button
data-action="undock-abacus"
onClick={(e) => {
e.stopPropagation()
undock()
}}
title="Undock abacus"
style={{
position: 'absolute',
top: 0,
right: 0,
margin: '4px',
}}
className={css({
w: '24px',
h: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: 'md',
color: 'white',
fontSize: 'xs',
cursor: 'pointer',
transition: 'all 0.2s',
zIndex: 10,
opacity: 0.7,
_hover: {
bg: 'rgba(0, 0, 0, 0.7)',
opacity: 1,
transform: 'scale(1.1)',
},
})}
>
</button>
<div
data-element="abacus-display"
style={{ transform: `scale(${dockedScale})` }}
className={css({
transformOrigin: 'center center',
transition: 'transform 0.3s ease',
filter: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
})}
>
<AbacusReact
key="docked"
value={dock.value ?? abacusValue}
defaultValue={dock.defaultValue}
columns={dock.columns ?? 5}
beadShape={appConfig.beadShape}
showNumbers={dock.showNumbers ?? true}
interactive={dock.interactive ?? true}
animated={dock.animated ?? true}
customStyles={structuralStyles}
onValueChange={(newValue: number | bigint) => {
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,
}}
/>
</div>
</div>,
dock.element
)}
{/* Non-docked modes: hero, button, open */}
{!isDocked && (
<div
data-component="my-abacus"
data-mode={isOpen ? 'open' : isHeroMode ? 'hero' : 'button'}
data-dockable={isDockable ? 'true' : undefined}
onClick={isOpen || isHeroMode ? undefined : isDockable ? dockInto : toggle}
className={css({
position: isHeroMode ? 'absolute' : 'fixed',
zIndex: 102,
cursor: isOpen || isHeroMode ? 'default' : 'pointer',
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
// Three modes: hero (absolute - scrolls with document), button (fixed), open (fixed)
...(isOpen
? {
// Hero mode: absolute positioning - scrolls naturally with document
top: '60vh',
// Open mode: fixed to center of viewport
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}
: {
// Button mode: fixed to bottom-right corner
bottom: { base: '4', md: '6' },
right: { base: '4', md: '6' },
transform: 'translate(0, 0)',
}),
})}
>
{/* Container that changes between hero, button, and open states */}
<div
className={css({
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
...(isOpen || isHeroMode
? {
// Open/Hero state: no background, just the abacus
bg: 'transparent',
border: 'none',
boxShadow: 'none',
borderRadius: '0',
}
: {
// Button state: button styling
bg: isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(8px)',
border: isDark
? '3px solid rgba(251, 191, 36, 0.5)'
: '3px solid rgba(251, 191, 36, 0.6)',
boxShadow: isDark
? '0 8px 32px rgba(251, 191, 36, 0.4)'
: '0 8px 32px rgba(251, 191, 36, 0.5)',
borderRadius: 'xl',
w: { base: '80px', md: '100px' },
h: { base: '80px', md: '100px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
animation: 'pulse 2s ease-in-out infinite',
_hover: {
transform: 'scale(1.1)',
boxShadow: isDark
? '0 12px 48px rgba(251, 191, 36, 0.6)'
: '0 12px 48px rgba(251, 191, 36, 0.7)',
borderColor: 'rgba(251, 191, 36, 0.8)',
},
}),
: isHeroMode
? {
// Hero mode: absolute positioning - scrolls naturally with document
top: '60vh',
left: '50%',
transform: 'translate(-50%, -50%)',
}
: {
// Button mode: fixed to bottom-right corner
bottom: { base: '4', md: '6' },
right: { base: '4', md: '6' },
transform: 'translate(0, 0)',
}),
})}
>
{/* The abacus itself - same element, scales between hero/button/open */}
{/* Container that changes between hero, button, and open states */}
<div
data-element="abacus-display"
className={css({
transform: isOpen
? { base: 'scale(2.5)', md: 'scale(3.5)', lg: 'scale(4.5)' }
: isHeroMode
? { base: 'scale(3)', md: 'scale(3.5)', lg: 'scale(4.25)' }
: 'scale(0.35)',
transformOrigin: 'center center',
transition: 'transform 0.6s cubic-bezier(0.4, 0, 0.2, 1), filter 0.6s ease',
filter:
isOpen || isHeroMode
? 'drop-shadow(0 10px 40px rgba(251, 191, 36, 0.3))'
: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
pointerEvents: isOpen || isHeroMode ? 'auto' : 'none',
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
...(isOpen || isHeroMode
? {
// Open/Hero state: no background, just the abacus
bg: 'transparent',
border: 'none',
boxShadow: 'none',
borderRadius: '0',
}
: {
// Button state: button styling
// Use cyan/teal when dockable to indicate "dock me" state
bg: isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.9)',
backdropFilter: 'blur(8px)',
border: isDockable
? '3px solid rgba(34, 211, 238, 0.7)'
: isDark
? '3px solid rgba(251, 191, 36, 0.5)'
: '3px solid rgba(251, 191, 36, 0.6)',
boxShadow: isDockable
? '0 8px 32px rgba(34, 211, 238, 0.5)'
: isDark
? '0 8px 32px rgba(251, 191, 36, 0.4)'
: '0 8px 32px rgba(251, 191, 36, 0.5)',
borderRadius: 'xl',
w: { base: '80px', md: '100px' },
h: { base: '80px', md: '100px' },
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
animation: isDockable
? 'pulseDock 1.5s ease-in-out infinite'
: 'pulse 2s ease-in-out infinite',
_hover: {
transform: 'scale(1.1)',
boxShadow: isDockable
? '0 12px 48px rgba(34, 211, 238, 0.7)'
: isDark
? '0 12px 48px rgba(251, 191, 36, 0.6)'
: '0 12px 48px rgba(251, 191, 36, 0.7)',
borderColor: isDockable
? 'rgba(34, 211, 238, 0.9)'
: 'rgba(251, 191, 36, 0.8)',
},
}),
})}
>
<AbacusReact
key={isHeroMode ? 'hero' : isOpen ? 'open' : 'closed'}
value={abacusValue}
columns={isHeroMode ? 4 : 5}
beadShape={appConfig.beadShape}
showNumbers={isOpen || isHeroMode}
interactive={isOpen || isHeroMode}
animated={isOpen || isHeroMode}
customStyles={isHeroMode ? structuralStyles : trophyStyles}
onValueChange={setAbacusValue}
// 3D Enhancement - realistic mode for hero and open states
enhanced3d={isOpen || isHeroMode ? 'realistic' : undefined}
material3d={
isOpen || isHeroMode
? {
heavenBeads: 'glossy',
earthBeads: 'satin',
lighting: 'dramatic',
woodGrain: true,
}
: undefined
}
/>
{/* The abacus itself - same element, scales between hero/button/open */}
<div
data-element="abacus-display"
className={css({
transform: isOpen
? { base: 'scale(2.5)', md: 'scale(3.5)', lg: 'scale(4.5)' }
: isHeroMode
? { base: 'scale(3)', md: 'scale(3.5)', lg: 'scale(4.25)' }
: 'scale(0.35)',
transformOrigin: 'center center',
transition: 'transform 0.6s cubic-bezier(0.4, 0, 0.2, 1), filter 0.6s ease',
filter:
isOpen || isHeroMode
? 'drop-shadow(0 10px 40px rgba(251, 191, 36, 0.3))'
: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
pointerEvents: isOpen || isHeroMode ? 'auto' : 'none',
})}
>
<AbacusReact
key={isHeroMode ? 'hero' : isOpen ? 'open' : 'closed'}
value={abacusValue}
columns={isHeroMode ? 4 : 5}
beadShape={appConfig.beadShape}
showNumbers={isOpen || isHeroMode}
interactive={isOpen || isHeroMode}
animated={isOpen || isHeroMode}
customStyles={isHeroMode ? structuralStyles : trophyStyles}
onValueChange={setAbacusValue}
// 3D Enhancement - realistic mode for hero and open states
enhanced3d={isOpen || isHeroMode ? 'realistic' : undefined}
material3d={
isOpen || isHeroMode
? {
heavenBeads: 'glossy',
earthBeads: 'satin',
lighting: 'dramatic',
woodGrain: true,
}
: undefined
}
/>
</div>
</div>
</div>
</div>
)}
{/* Keyframes for animations */}
<style
@ -266,6 +432,10 @@ export function MyAbacus() {
0%, 100% { box-shadow: 0 8px 32px rgba(251, 191, 36, 0.4); }
50% { box-shadow: 0 12px 48px rgba(251, 191, 36, 0.6); }
}
@keyframes pulseDock {
0%, 100% { box-shadow: 0 8px 32px rgba(34, 211, 238, 0.5); }
50% { box-shadow: 0 12px 48px rgba(34, 211, 238, 0.8); }
}
`,
}}
/>

View File

@ -12,6 +12,7 @@ import type {
SlotResult,
} from '@/db/schema/session-plans'
import { css } from '../../../styled-system/css'
import { AbacusDock } from '../AbacusDock'
import { DecompositionProvider, DecompositionSection } from '../decomposition'
import { generateCoachHint } from './coachHintGenerator'
import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection'
@ -432,6 +433,15 @@ export function ActiveSession({
}, 600) // Brief pause to see success state on abacus
}, [phase.phase, helpContext, setAnswer, clearAnswer, exitHelpMode])
// Handle value change from the docked abacus
const handleAbacusDockValueChange = useCallback(
(newValue: number) => {
// When the abacus shows the correct answer, set it and auto-submit will trigger
setAnswer(String(newValue))
},
[setAnswer]
)
// Handle submit
const handleSubmit = useCallback(async () => {
// Allow submitting from inputting, awaitingDisambiguation, or helpMode
@ -1150,6 +1160,34 @@ export function ActiveSession({
</DecompositionProvider>
</div>
)}
{/* Abacus dock - positioned to the right of the problem when in abacus mode and not in help mode */}
{currentPart.type === 'abacus' && !showHelpOverlay && (
<AbacusDock
id="practice-abacus"
columns={String(Math.abs(attempt.problem.answer)).length}
interactive={true}
showNumbers={false}
animated={true}
onValueChange={handleAbacusDockValueChange}
className={css({
position: 'absolute',
left: '100%',
top: 0,
bottom: 0,
marginLeft: '1rem',
width: '120px',
'@media (min-width: 768px)': {
marginLeft: '1.5rem',
width: '150px',
},
'@media (min-width: 1024px)': {
marginLeft: '2rem',
width: '180px',
},
})}
/>
)}
</animated.div>
</animated.div>
</div>

View File

@ -614,7 +614,8 @@ export function StartPracticeModal({
top: '-8px',
right: '-8px',
minWidth: '22px',
height: '22px',
minHeight: '22px',
aspectRatio: '1 / 1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -622,8 +623,8 @@ export function StartPracticeModal({
fontWeight: 'bold',
color: 'white',
backgroundColor: 'green.500',
borderRadius: '11px',
padding: '0 6px',
borderRadius: '50%',
padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
})}
>

View File

@ -3,6 +3,35 @@
import type React from 'react'
import { createContext, useContext, useState, useCallback } from 'react'
/**
* Configuration for a docked abacus
* Props align with AbacusReact from @soroban/abacus-react
*/
export interface DockConfig {
/** The DOM element to render the abacus into */
element: HTMLElement
/** Optional identifier for debugging */
id?: string
/** Number of columns (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
/** Whether the dock is currently visible in the viewport */
isVisible?: boolean
/** 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
}
interface MyAbacusContextValue {
isOpen: boolean
open: () => void
@ -14,6 +43,20 @@ interface MyAbacusContextValue {
/** Opt-in to show the abacus while in a game (games hide it by default) */
showInGame: boolean
setShowInGame: (show: boolean) => void
/** Currently registered dock (if any) */
dock: DockConfig | null
/** Register a dock for the abacus to render into */
registerDock: (config: DockConfig) => void
/** Unregister a dock (should be called on unmount) */
unregisterDock: (element: HTMLElement) => void
/** Update dock visibility status */
updateDockVisibility: (element: HTMLElement, isVisible: boolean) => void
/** Whether the abacus is currently docked (user chose to dock it) */
isDockedByUser: boolean
/** Dock the abacus into the current dock */
dockInto: () => void
/** Undock the abacus (return to button mode) */
undock: () => void
}
const MyAbacusContext = createContext<MyAbacusContextValue | undefined>(undefined)
@ -22,14 +65,67 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
const [isHidden, setIsHidden] = useState(false)
const [showInGame, setShowInGame] = useState(false)
const [dock, setDock] = useState<DockConfig | null>(null)
const [isDockedByUser, setIsDockedByUser] = useState(false)
const open = useCallback(() => setIsOpen(true), [])
const close = useCallback(() => setIsOpen(false), [])
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
const registerDock = useCallback((config: DockConfig) => {
setDock(config)
}, [])
const unregisterDock = useCallback((element: HTMLElement) => {
setDock((current) => {
if (current?.element === element) {
// Also undock if this dock is being removed
setIsDockedByUser(false)
return null
}
return current
})
}, [])
const updateDockVisibility = useCallback((element: HTMLElement, isVisible: boolean) => {
setDock((current) => {
if (current?.element === element) {
return { ...current, isVisible }
}
return current
})
}, [])
const dockInto = useCallback(() => {
if (dock) {
setIsDockedByUser(true)
setIsOpen(false)
}
}, [dock])
const undock = useCallback(() => {
setIsDockedByUser(false)
}, [])
return (
<MyAbacusContext.Provider
value={{ isOpen, open, close, toggle, isHidden, setIsHidden, showInGame, setShowInGame }}
value={{
isOpen,
open,
close,
toggle,
isHidden,
setIsHidden,
showInGame,
setShowInGame,
dock,
registerDock,
unregisterDock,
updateDockVisibility,
isDockedByUser,
dockInto,
undock,
}}
>
{children}
</MyAbacusContext.Provider>