feat(settings): add settings page with live abacus preview

- Create /settings page with tabs for General, Abacus Style, and MCP Keys
- Extract AbacusStylePanel as shared component for nav dropdown and settings
- Add AbacusDock integration with auto-dock/undock on tab mount/unmount
- Add hideUndock prop to prevent manual undocking in settings preview
- Add updateDockConfig to update dock props without re-registration
- Fix requestDock stability using ref to prevent animation on config changes
- Add ThemeToggle back to AppNavBar while keeping it in settings
- Add navSlot prop to PageWithNav for vision training contextual nav
- Create VisionTrainingNavSlot for model selection in vision training pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2026-01-15 15:23:37 -06:00
parent bf5de17e22
commit 9f483b142e
9 changed files with 1682 additions and 389 deletions

View File

@@ -0,0 +1,922 @@
'use client'
import { useAbacusDisplay } from '@soroban/abacus-react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Key, Languages, Palette, Settings as SettingsIcon } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { AbacusStylePanel } from '@/components/AbacusDisplayDropdown'
import { AbacusDock } from '@/components/AbacusDock'
import { LanguageSelector } from '@/components/LanguageSelector'
import { PageWithNav } from '@/components/PageWithNav'
import { ThemeToggle } from '@/components/ThemeToggle'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { useTheme } from '@/contexts/ThemeContext'
import { api } from '@/lib/queryClient'
import { css } from '../../../styled-system/css'
type TabId = 'general' | 'abacus' | 'mcp-keys'
interface Tab {
id: TabId
label: string
icon: React.ReactNode
}
const TABS: Tab[] = [
{ id: 'general', label: 'General', icon: <SettingsIcon size={16} /> },
{ id: 'abacus', label: 'Abacus Style', icon: <Palette size={16} /> },
{ id: 'mcp-keys', label: 'MCP Keys', icon: <Key size={16} /> },
]
/**
* User settings page for managing preferences across the app.
*/
export default function SettingsPage() {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const [activeTab, setActiveTab] = useState<TabId>('general')
return (
<PageWithNav>
<main
data-component="settings-page"
className={css({
minHeight: '100vh',
backgroundColor: isDark ? 'gray.900' : 'gray.50',
padding: '2rem',
})}
>
<div className={css({ maxWidth: '700px', margin: '0 auto' })}>
{/* Header */}
<header data-element="settings-header" className={css({ marginBottom: '1.5rem' })}>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '0.5rem',
})}
>
<SettingsIcon
size={28}
className={css({ color: isDark ? 'purple.400' : 'purple.600' })}
/>
<h1
className={css({
fontSize: '1.75rem',
fontWeight: 'bold',
color: isDark ? 'white' : 'gray.800',
})}
>
Settings
</h1>
</div>
<p
className={css({
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Customize your Abaci One experience
</p>
</header>
{/* Tab Navigation */}
<div
data-element="tab-navigation"
className={css({
display: 'flex',
gap: '0.5rem',
marginBottom: '1.5rem',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
paddingBottom: '0',
})}
>
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
data-tab={tab.id}
data-active={activeTab === tab.id}
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
fontSize: '0.875rem',
fontWeight: '500',
color:
activeTab === tab.id
? isDark
? 'purple.400'
: 'purple.600'
: isDark
? 'gray.400'
: 'gray.600',
backgroundColor: 'transparent',
border: 'none',
borderBottom: '2px solid',
borderColor:
activeTab === tab.id ? (isDark ? 'purple.400' : 'purple.600') : 'transparent',
marginBottom: '-1px',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
color: isDark ? 'purple.300' : 'purple.700',
},
})}
>
{tab.icon}
<span>{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'general' && <GeneralTab isDark={isDark} />}
{activeTab === 'abacus' && <AbacusTab isDark={isDark} />}
{activeTab === 'mcp-keys' && <McpKeysTab isDark={isDark} />}
</div>
</main>
</PageWithNav>
)
}
/**
* General settings tab - Theme, Language
*/
function GeneralTab({ isDark }: { isDark: boolean }) {
return (
<div data-section="general-tab">
{/* Appearance Section */}
<section className={css({ marginBottom: '1.5rem' })}>
<SectionCard isDark={isDark}>
<SectionHeader icon={<Palette size={18} />} title="Appearance" isDark={isDark} />
<SettingRow
label="Theme"
description="Choose between light and dark mode"
isDark={isDark}
noBorder
>
<ThemeToggle />
</SettingRow>
</SectionCard>
</section>
{/* Language Section */}
<section className={css({ marginBottom: '1.5rem' })}>
<SectionCard isDark={isDark}>
<SectionHeader icon={<Languages size={18} />} title="Language" isDark={isDark} />
<SettingRow
label="Display Language"
description="Choose your preferred language"
isDark={isDark}
noBorder
>
<LanguageSelector variant="inline" />
</SettingRow>
</SectionCard>
</section>
</div>
)
}
/**
* Abacus Style tab - Full abacus customization
*/
function AbacusTab({ isDark }: { isDark: boolean }) {
const { requestDock, undock, dock } = useMyAbacus()
const { config } = useAbacusDisplay()
// Auto-dock when this tab mounts, auto-undock when it unmounts
useEffect(() => {
// Small delay to ensure dock is registered before requesting
const timer = setTimeout(() => {
requestDock()
}, 100)
return () => {
clearTimeout(timer)
undock()
}
}, [requestDock, undock])
return (
<div
data-section="abacus-tab"
className={css({
display: 'flex',
flexDirection: { base: 'column', lg: 'row' },
gap: '1.5rem',
alignItems: { base: 'stretch', lg: 'flex-start' },
})}
>
{/* Settings Panel - Primary content */}
<div className={css({ flex: 1, minWidth: 0 })}>
<SectionCard isDark={isDark}>
<div className={css({ padding: '1rem 0' })}>
<AbacusStylePanel isDark={isDark} showHeader={true} />
</div>
</SectionCard>
</div>
{/* Live Preview - Sticky sidebar with AbacusDock */}
<div
className={css({
width: { base: '100%', lg: '220px' },
flexShrink: 0,
position: 'sticky',
top: '5rem',
alignSelf: 'flex-start',
order: { base: -1, lg: 0 },
})}
>
<div
data-element="abacus-preview"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.75rem',
padding: '1rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<span
className={css({
fontSize: '0.625rem',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isDark ? 'gray.500' : 'gray.400',
})}
>
Preview
</span>
<AbacusDock
id="settings-preview"
columns={config.physicalAbacusColumns}
interactive
showNumbers={false}
hideUndock
className={css({
width: '180px',
height: '240px',
})}
/>
{!dock && (
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.400',
textAlign: 'center',
})}
>
Abacus will dock here
</p>
)}
</div>
</div>
</div>
)
}
// ============================================
// MCP Keys Tab - API Key Management
// ============================================
interface ApiKey {
id: string
name: string
keyPreview: string
createdAt: string
lastUsedAt: string | null
isRevoked: boolean
}
interface NewKeyResponse {
id: string
name: string
key: string
createdAt: string
message: string
}
async function fetchApiKeys(): Promise<{ keys: ApiKey[] }> {
const res = await api('settings/mcp-keys')
if (!res.ok) throw new Error('Failed to fetch API keys')
return res.json()
}
async function createApiKey(name: string): Promise<NewKeyResponse> {
const res = await api('settings/mcp-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!res.ok) throw new Error('Failed to create API key')
return res.json()
}
async function revokeApiKey(keyId: string): Promise<void> {
const res = await api(`settings/mcp-keys/${keyId}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Failed to revoke API key')
}
function McpKeysTab({ isDark }: { isDark: boolean }) {
const queryClient = useQueryClient()
// Form state
const [newKeyName, setNewKeyName] = useState('')
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
// Fetch existing keys
const { data, isLoading } = useQuery({
queryKey: ['mcp-api-keys'],
queryFn: fetchApiKeys,
})
// Create key mutation
const createMutation = useMutation({
mutationFn: createApiKey,
onSuccess: (data) => {
setNewlyCreatedKey(data.key)
setNewKeyName('')
queryClient.invalidateQueries({ queryKey: ['mcp-api-keys'] })
},
})
// Revoke key mutation
const revokeMutation = useMutation({
mutationFn: revokeApiKey,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp-api-keys'] })
},
})
const handleCreate = useCallback(() => {
if (newKeyName.trim()) {
createMutation.mutate(newKeyName.trim())
}
}, [newKeyName, createMutation])
const handleCopy = useCallback(async () => {
if (newlyCreatedKey) {
await navigator.clipboard.writeText(newlyCreatedKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [newlyCreatedKey])
const handleDismissNewKey = useCallback(() => {
setNewlyCreatedKey(null)
setCopied(false)
}, [])
const activeKeys = data?.keys.filter((k) => !k.isRevoked) ?? []
const revokedKeys = data?.keys.filter((k) => k.isRevoked) ?? []
return (
<div data-section="mcp-keys-tab">
{/* Description */}
<p
className={css({
color: isDark ? 'gray.400' : 'gray.600',
marginBottom: '1.5rem',
fontSize: '0.875rem',
})}
>
Manage API keys for external tools like Claude Code to access student skill data.
</p>
{/* Newly Created Key Banner */}
{newlyCreatedKey && (
<div
className={css({
backgroundColor: isDark ? 'green.900/50' : 'green.50',
border: '1px solid',
borderColor: isDark ? 'green.700' : 'green.200',
borderRadius: '12px',
padding: '1.5rem',
marginBottom: '1.5rem',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '1rem',
})}
>
<h3
className={css({
fontWeight: '600',
color: isDark ? 'green.300' : 'green.800',
})}
>
API Key Created
</h3>
<button
type="button"
onClick={handleDismissNewKey}
className={css({
color: isDark ? 'gray.400' : 'gray.500',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.25rem',
lineHeight: 1,
})}
>
×
</button>
</div>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'green.200' : 'green.700',
marginBottom: '0.75rem',
})}
>
Copy this key now - it won't be shown again!
</p>
<div
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'center',
})}
>
<code
className={css({
flex: 1,
backgroundColor: isDark ? 'gray.800' : 'white',
padding: '0.75rem',
borderRadius: '6px',
fontSize: '0.875rem',
fontFamily: 'monospace',
color: isDark ? 'gray.200' : 'gray.800',
overflowX: 'auto',
whiteSpace: 'nowrap',
})}
>
{newlyCreatedKey}
</code>
<button
type="button"
onClick={handleCopy}
className={css({
padding: '0.75rem 1rem',
backgroundColor: copied ? 'green.500' : 'blue.500',
color: 'white',
borderRadius: '6px',
border: 'none',
fontWeight: '600',
cursor: 'pointer',
whiteSpace: 'nowrap',
_hover: { backgroundColor: copied ? 'green.600' : 'blue.600' },
})}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
)}
{/* Create New Key Card */}
<SectionCard isDark={isDark}>
<h2
className={css({
fontWeight: '600',
color: isDark ? 'white' : 'gray.800',
padding: '1rem 0',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
Generate New Key
</h2>
<div
className={css({
display: 'flex',
gap: '0.75rem',
padding: '1rem 0',
})}
>
<input
type="text"
placeholder="Key name (e.g., Claude Code)"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
className={css({
flex: 1,
padding: '0.75rem',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
backgroundColor: isDark ? 'gray.700' : 'white',
color: isDark ? 'white' : 'gray.800',
_placeholder: { color: isDark ? 'gray.500' : 'gray.400' },
})}
/>
<button
type="button"
onClick={handleCreate}
disabled={!newKeyName.trim() || createMutation.isPending}
className={css({
padding: '0.75rem 1.5rem',
backgroundColor: newKeyName.trim() ? 'blue.500' : isDark ? 'gray.700' : 'gray.300',
color: newKeyName.trim() ? 'white' : isDark ? 'gray.500' : 'gray.500',
borderRadius: '6px',
border: 'none',
fontWeight: '600',
cursor: newKeyName.trim() ? 'pointer' : 'not-allowed',
_hover: newKeyName.trim() ? { backgroundColor: 'blue.600' } : {},
})}
>
{createMutation.isPending ? 'Creating...' : 'Generate'}
</button>
</div>
</SectionCard>
{/* Active Keys List */}
<div className={css({ marginTop: '1.5rem' })}>
<SectionCard isDark={isDark}>
<h2
className={css({
fontWeight: '600',
color: isDark ? 'white' : 'gray.800',
padding: '1rem 0',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
Active Keys
</h2>
<div className={css({ padding: '1rem 0' })}>
{isLoading ? (
<p className={css({ color: isDark ? 'gray.500' : 'gray.500' })}>Loading...</p>
) : activeKeys.length === 0 ? (
<p
className={css({
color: isDark ? 'gray.500' : 'gray.500',
fontStyle: 'italic',
})}
>
No active API keys. Generate one above to get started.
</p>
) : (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '0.75rem' })}>
{activeKeys.map((key) => (
<KeyRow
key={key.id}
apiKey={key}
isDark={isDark}
onRevoke={() => revokeMutation.mutate(key.id)}
isRevoking={revokeMutation.isPending}
/>
))}
</div>
)}
</div>
</SectionCard>
</div>
{/* Revoked Keys (collapsed by default) */}
{revokedKeys.length > 0 && (
<details
className={css({
marginTop: '1.5rem',
backgroundColor: isDark ? 'gray.800/50' : 'gray.100',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
padding: '1rem 1.5rem',
})}
>
<summary
className={css({
fontWeight: '600',
color: isDark ? 'gray.400' : 'gray.600',
cursor: 'pointer',
_hover: { color: isDark ? 'gray.300' : 'gray.700' },
})}
>
Revoked Keys ({revokedKeys.length})
</summary>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
marginTop: '1rem',
})}
>
{revokedKeys.map((key) => (
<div
key={key.id}
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.5rem 0',
opacity: 0.6,
})}
>
<div>
<span
className={css({
color: isDark ? 'gray.400' : 'gray.600',
textDecoration: 'line-through',
})}
>
{key.name}
</span>
<span
className={css({
marginLeft: '0.5rem',
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
fontFamily: 'monospace',
})}
>
{key.keyPreview}
</span>
</div>
</div>
))}
</div>
</details>
)}
{/* Usage Instructions */}
<div
className={css({
marginTop: '1.5rem',
padding: '1rem',
backgroundColor: isDark ? 'gray.800/50' : 'blue.50',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'blue.100',
})}
>
<h3
className={css({
fontWeight: '600',
color: isDark ? 'blue.300' : 'blue.800',
marginBottom: '0.5rem',
})}
>
Usage with Claude Code
</h3>
<p
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.700',
marginBottom: '0.75rem',
})}
>
Add this to your <code>.mcp.json</code>:
</p>
<pre
className={css({
backgroundColor: isDark ? 'gray.900' : 'white',
padding: '1rem',
borderRadius: '6px',
fontSize: '0.75rem',
fontFamily: 'monospace',
overflowX: 'auto',
color: isDark ? 'gray.200' : 'gray.800',
})}
>
{JSON.stringify(
{
mcpServers: {
abaci: {
type: 'http',
url: `${typeof window !== 'undefined' ? window.location.origin : 'https://abaci.one'}/api/mcp`,
headers: {
Authorization: 'Bearer YOUR_API_KEY',
},
},
},
},
null,
2
)}
</pre>
</div>
</div>
)
}
/**
* Single API key row
*/
function KeyRow({
apiKey,
isDark,
onRevoke,
isRevoking,
}: {
apiKey: ApiKey
isDark: boolean
onRevoke: () => void
isRevoking: boolean
}) {
const [confirmRevoke, setConfirmRevoke] = useState(false)
const handleRevokeClick = useCallback(() => {
if (confirmRevoke) {
onRevoke()
setConfirmRevoke(false)
} else {
setConfirmRevoke(true)
setTimeout(() => setConfirmRevoke(false), 3000)
}
}, [confirmRevoke, onRevoke])
const createdDate = new Date(apiKey.createdAt).toLocaleDateString()
const lastUsed = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).toLocaleDateString() : 'Never'
return (
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.75rem',
backgroundColor: isDark ? 'gray.700/50' : 'gray.50',
borderRadius: '8px',
})}
>
<div>
<div
className={css({
fontWeight: '600',
color: isDark ? 'white' : 'gray.800',
})}
>
{apiKey.name}
</div>
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
marginTop: '0.25rem',
})}
>
<span className={css({ fontFamily: 'monospace' })}>{apiKey.keyPreview}</span>
<span className={css({ marginLeft: '1rem' })}>Created: {createdDate}</span>
<span className={css({ marginLeft: '1rem' })}>Last used: {lastUsed}</span>
</div>
</div>
<button
type="button"
onClick={handleRevokeClick}
disabled={isRevoking}
className={css({
padding: '0.5rem 1rem',
backgroundColor: confirmRevoke ? 'red.500' : 'transparent',
color: confirmRevoke ? 'white' : isDark ? 'red.400' : 'red.600',
borderRadius: '6px',
border: '1px solid',
borderColor: confirmRevoke ? 'red.500' : isDark ? 'red.400/50' : 'red.200',
cursor: 'pointer',
fontSize: '0.875rem',
_hover: {
backgroundColor: confirmRevoke ? 'red.600' : isDark ? 'red.900/30' : 'red.50',
},
})}
>
{isRevoking ? 'Revoking...' : confirmRevoke ? 'Confirm Revoke' : 'Revoke'}
</button>
</div>
)
}
// ============================================
// Shared Components
// ============================================
/**
* Card wrapper for a section
*/
function SectionCard({ children, isDark }: { children: React.ReactNode; isDark: boolean }) {
return (
<div
className={css({
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
padding: '0 1.5rem',
})}
>
{children}
</div>
)
}
/**
* Section header with icon
*/
function SectionHeader({
icon,
title,
isDark,
}: {
icon: React.ReactNode
title: string
isDark: boolean
}) {
return (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '1rem 0',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<span className={css({ color: isDark ? 'purple.400' : 'purple.600' })}>{icon}</span>
<h2
className={css({
fontSize: '0.875rem',
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{title}
</h2>
</div>
)
}
/**
* Individual setting row
*/
function SettingRow({
label,
description,
children,
isDark,
noBorder = false,
}: {
label: string
description?: string
children: React.ReactNode
isDark: boolean
noBorder?: boolean
}) {
return (
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
padding: '1rem 0',
borderBottom: noBorder ? 'none' : '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<div className={css({ flex: 1 })}>
<div
className={css({
fontWeight: '500',
color: isDark ? 'white' : 'gray.800',
marginBottom: description ? '0.25rem' : 0,
})}
>
{label}
</div>
{description && (
<div
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{description}
</div>
)}
</div>
<div>{children}</div>
</div>
)
}

View File

@@ -3,11 +3,10 @@
import type { ReactNode } from 'react'
import { notFound } from 'next/navigation'
import { css } from '../../../../styled-system/css'
import { VisionTrainingNav } from '../components/VisionTrainingNav'
import { PageWithNav } from '@/components/PageWithNav'
import { VisionTrainingNavSlot } from '../components/VisionTrainingNavSlot'
import { isValidModelType } from '../hooks/useModelType'
const NAV_HEIGHT = 56
interface VisionTrainingLayoutProps {
children: ReactNode
params: { model: string }
@@ -19,12 +18,11 @@ interface VisionTrainingLayoutProps {
* Layout wrapper for all vision training pages under /vision-training/[model]/.
*
* Provides:
* - Fixed nav bar via VisionTrainingNav
* - CSS custom property --nav-height for child pages to use
* - Minimal nav bar with hamburger menu via PageWithNav
* - Contextual nav content (model selector + tabs) via VisionTrainingNavSlot
* - Model param validation (redirects to 404 if invalid)
*
* The --nav-height variable allows child pages with absolute/fixed positioning
* to correctly offset their content below the nav bar.
* Uses the same PageWithNav + navSlot pattern as arcade pages for consistent UX.
*/
export default function VisionTrainingLayout({ children, params }: VisionTrainingLayoutProps) {
// Validate model param - show 404 for invalid models
@@ -33,28 +31,17 @@ export default function VisionTrainingLayout({ children, params }: VisionTrainin
}
return (
<div
data-component="vision-training-layout"
style={{ '--nav-height': `${NAV_HEIGHT}px` } as React.CSSProperties}
className={css({
minHeight: '100vh',
bg: 'gray.900',
color: 'gray.100',
})}
>
{/* Fixed nav - always at top */}
<VisionTrainingNav />
{/* Content area - pushed below nav via padding */}
<main
data-element="vision-content"
<PageWithNav navSlot={<VisionTrainingNavSlot />}>
<div
data-component="vision-training-layout"
className={css({
minHeight: '100vh',
bg: 'gray.900',
color: 'gray.100',
})}
style={{ paddingTop: 'var(--nav-height)' }}
>
{children}
</main>
</div>
<main data-element="vision-content">{children}</main>
</div>
</PageWithNav>
)
}

View File

@@ -0,0 +1,158 @@
'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useCallback } from 'react'
import { css } from '../../../../styled-system/css'
import { getValidModelTypes, useModelType } from '../hooks/useModelType'
import { getModelEntry, TABS, type TabId } from '../registry'
import type { ModelType } from '../train/components/wizard/types'
/**
* Vision Training Nav Slot
*
* Content for the minimal nav's center slot when on vision training pages.
* Contains:
* - Model selector dropdown
* - Tab links (Data, Train, Test, Sessions)
*
* Used with PageWithNav's navSlot prop.
*/
export function VisionTrainingNavSlot() {
const router = useRouter()
const pathname = usePathname()
const modelType = useModelType()
const allModelTypes = getValidModelTypes()
// Determine active tab from pathname
const getActiveTab = (): TabId => {
if (pathname.includes('/train')) return 'train'
if (pathname.includes('/test')) return 'test'
if (pathname.includes('/sessions')) return 'sessions'
return 'data'
}
const activeTab = getActiveTab()
// Handle model change - preserve current tab
const handleModelChange = useCallback(
(newModel: ModelType) => {
// Get current path suffix (e.g., /train, /test, /sessions)
const pathParts = pathname.split('/')
const modelIndex = pathParts.indexOf(modelType)
const suffix = pathParts.slice(modelIndex + 1).join('/')
// Navigate to new model path with same suffix
const newPath = suffix
? `/vision-training/${newModel}/${suffix}`
: `/vision-training/${newModel}`
router.push(newPath)
},
[pathname, modelType, router]
)
// Get tab path
const getTabPath = (tabId: TabId): string => {
const basePath = `/vision-training/${modelType}`
if (tabId === 'data') return basePath
return `${basePath}/${tabId}`
}
return (
<div
data-component="vision-training-nav-slot"
className={css({
display: 'flex',
alignItems: 'center',
gap: 3,
})}
>
{/* Title - hidden on small screens */}
<span
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: 'gray.500',
display: { base: 'none', md: 'block' },
whiteSpace: 'nowrap',
})}
>
Vision Training
</span>
{/* Model selector - dark accent dropdown */}
<select
value={modelType}
onChange={(e) => handleModelChange(e.target.value as ModelType)}
data-action="select-model"
className={css({
px: 3,
py: 1.5,
bg: 'gray.800',
color: 'white',
border: 'none',
borderRadius: 'lg',
fontSize: 'sm',
fontWeight: 'semibold',
cursor: 'pointer',
boxShadow: 'sm',
_hover: { bg: 'gray.700' },
_focus: { outline: 'none', ring: '2px', ringColor: 'blue.500', ringOffset: '2px' },
})}
>
{allModelTypes.map((type) => (
<option key={type} value={type}>
{getModelEntry(type).label}
</option>
))}
</select>
{/* Separator */}
<span className={css({ color: 'gray.300', display: { base: 'none', sm: 'block' } })}>|</span>
{/* Tab links */}
<div
data-element="tab-links"
className={css({
display: 'flex',
gap: 0,
})}
>
{TABS.map((tab) => {
const isActive = activeTab === tab.id
return (
<Link
key={tab.id}
href={getTabPath(tab.id)}
data-action={`tab-${tab.id}`}
data-active={isActive ? 'true' : 'false'}
className={css({
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: 2.5,
py: 1.5,
color: isActive ? 'blue.600' : 'gray.600',
textDecoration: 'none',
fontWeight: isActive ? 'semibold' : 'medium',
fontSize: 'sm',
borderBottom: '2px solid',
borderColor: isActive ? 'blue.600' : 'transparent',
transition: 'all 0.15s ease',
_hover: {
color: isActive ? 'blue.600' : 'gray.900',
bg: isActive ? 'transparent' : 'gray.100',
borderRadius: isActive ? '0' : 'md',
},
})}
>
<span className={css({ display: { base: 'none', sm: 'block' } })}>{tab.icon}</span>
<span>{tab.label}</span>
</Link>
)
})}
</div>
</div>
)
}

View File

@@ -6,25 +6,286 @@ import * as RadioGroup from '@radix-ui/react-radio-group'
import * as Switch from '@radix-ui/react-switch'
import { type BeadShape, type ColorScheme, useAbacusDisplay } from '@soroban/abacus-react'
import { useState } from 'react'
import { Z_INDEX } from '../constants/zIndex'
import { css } from '../../styled-system/css'
import { hstack, stack } from '../../styled-system/patterns'
import { useAbacusSettings, useUpdateAbacusSettings } from '../hooks/useAbacusSettings'
import { Z_INDEX } from '../constants/zIndex'
import { useTheme } from '../contexts/ThemeContext'
import { useAbacusSettings, useUpdateAbacusSettings } from '../hooks/useAbacusSettings'
interface AbacusDisplayDropdownProps {
isFullscreen?: boolean
onOpenChange?: (open: boolean) => void
}
/**
* Standalone panel for abacus style settings.
* Can be used directly on settings pages or inside dropdowns.
*/
export function AbacusStylePanel({
isFullscreen = false,
isDark = false,
showHeader = true,
}: {
isFullscreen?: boolean
isDark?: boolean
showHeader?: boolean
}) {
const { config, updateConfig, resetToDefaults } = useAbacusDisplay()
const { data: abacusSettings } = useAbacusSettings()
const { mutate: updateAbacusSettings } = useUpdateAbacusSettings()
return (
<div className={stack({ gap: '6' })}>
{/* Header */}
{showHeader && (
<div className={stack({ gap: '1' })}>
<div
className={hstack({
justify: 'space-between',
alignItems: 'center',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'semibold',
color: isFullscreen ? 'white' : isDark ? 'gray.100' : 'gray.900',
})}
>
🎨 Abacus Style
</h3>
<button
type="button"
onClick={resetToDefaults}
className={css({
fontSize: 'xs',
color: isFullscreen ? 'gray.300' : isDark ? 'gray.400' : 'gray.500',
background: 'none',
border: 'none',
cursor: 'pointer',
_hover: {
color: isFullscreen ? 'white' : isDark ? 'gray.200' : 'gray.700',
},
})}
>
Reset to Defaults
</button>
</div>
<p
className={css({
fontSize: 'sm',
color: isFullscreen ? 'gray.300' : isDark ? 'gray.400' : 'gray.600',
})}
>
Configure display across the entire app
</p>
</div>
)}
{/* Color Scheme */}
<FormField label="Color Scheme" isFullscreen={isFullscreen} isDark={isDark}>
<RadioGroupField
value={config.colorScheme}
onValueChange={(value) => updateConfig({ colorScheme: value as ColorScheme })}
options={[
{ value: 'monochrome', label: 'Monochrome' },
{ value: 'place-value', label: 'Place Value' },
{ value: 'heaven-earth', label: 'Heaven-Earth' },
{ value: 'alternating', label: 'Alternating' },
]}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
{/* Bead Shape */}
<FormField label="Bead Shape" isFullscreen={isFullscreen} isDark={isDark}>
<RadioGroupField
value={config.beadShape}
onValueChange={(value) => updateConfig({ beadShape: value as BeadShape })}
options={[
{ value: 'diamond', label: '💎 Diamond' },
{ value: 'circle', label: '⭕ Circle' },
{ value: 'square', label: '⬜ Square' },
]}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
{/* Toggle Options */}
<div className={stack({ gap: '4' })}>
<FormField label="Hide Inactive Beads" isFullscreen={isFullscreen} isDark={isDark}>
<SwitchField
checked={config.hideInactiveBeads}
onCheckedChange={(checked) => updateConfig({ hideInactiveBeads: checked })}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
<FormField label="Colored Numerals" isFullscreen={isFullscreen} isDark={isDark}>
<SwitchField
checked={config.coloredNumerals}
onCheckedChange={(checked) => updateConfig({ coloredNumerals: checked })}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
<FormField label="Sound Effects" isFullscreen={isFullscreen} isDark={isDark}>
<SwitchField
checked={config.soundEnabled}
onCheckedChange={(checked) => updateConfig({ soundEnabled: checked })}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
{config.soundEnabled && (
<FormField
label={`Volume: ${Math.round(config.soundVolume * 100)}%`}
isFullscreen={isFullscreen}
isDark={isDark}
>
<input
type="range"
min="0"
max="1"
step="0.1"
value={config.soundVolume}
onChange={(e) => updateConfig({ soundVolume: parseFloat(e.target.value) })}
className={css({
w: 'full',
h: '2',
bg: isFullscreen ? 'rgba(255, 255, 255, 0.2)' : isDark ? 'gray.700' : 'gray.200',
rounded: 'full',
appearance: 'none',
cursor: 'pointer',
_focusVisible: {
outline: 'none',
ring: '2px',
ringColor: isFullscreen ? 'blue.400' : 'brand.500',
},
'&::-webkit-slider-thumb': {
appearance: 'none',
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
cursor: 'pointer',
transition: 'all',
_hover: {
bg: isFullscreen ? 'blue.500' : 'brand.700',
transform: 'scale(1.1)',
},
},
'&::-moz-range-thumb': {
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
border: 'none',
cursor: 'pointer',
},
})}
/>
</FormField>
)}
<FormField label="Native Abacus Numbers" isFullscreen={isFullscreen} isDark={isDark}>
<SwitchField
checked={abacusSettings?.nativeAbacusNumbers ?? false}
onCheckedChange={(checked) => updateAbacusSettings({ nativeAbacusNumbers: checked })}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
<FormField
label={`Physical Abacus Columns: ${config.physicalAbacusColumns}`}
isFullscreen={isFullscreen}
isDark={isDark}
>
<div className={hstack({ gap: '2', alignItems: 'center' })}>
<input
type="range"
min="1"
max="21"
step="1"
value={config.physicalAbacusColumns}
onChange={(e) =>
updateConfig({
physicalAbacusColumns: parseInt(e.target.value, 10),
})
}
className={css({
flex: 1,
h: '2',
bg: isFullscreen ? 'rgba(255, 255, 255, 0.2)' : isDark ? 'gray.700' : 'gray.200',
rounded: 'full',
appearance: 'none',
cursor: 'pointer',
_focusVisible: {
outline: 'none',
ring: '2px',
ringColor: isFullscreen ? 'blue.400' : 'brand.500',
},
'&::-webkit-slider-thumb': {
appearance: 'none',
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
cursor: 'pointer',
transition: 'all',
_hover: {
bg: isFullscreen ? 'blue.500' : 'brand.700',
transform: 'scale(1.1)',
},
},
'&::-moz-range-thumb': {
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
border: 'none',
cursor: 'pointer',
},
})}
/>
<span
className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: isFullscreen ? 'white' : isDark ? 'gray.200' : 'gray.700',
minW: '6',
textAlign: 'right',
})}
>
{config.physicalAbacusColumns}
</span>
</div>
<p
className={css({
fontSize: 'xs',
color: isFullscreen ? 'gray.400' : isDark ? 'gray.500' : 'gray.500',
mt: '1',
})}
>
For camera vision detection
</p>
</FormField>
</div>
</div>
)
}
export function AbacusDisplayDropdown({
isFullscreen = false,
onOpenChange: onOpenChangeProp,
}: AbacusDisplayDropdownProps) {
const [open, setOpen] = useState(false)
const { config, updateConfig, resetToDefaults } = useAbacusDisplay()
const { data: abacusSettings } = useAbacusSettings()
const { mutate: updateAbacusSettings } = useUpdateAbacusSettings()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
@@ -38,6 +299,7 @@ export function AbacusDisplayDropdown({
<DropdownMenu.Root open={open} onOpenChange={handleOpenChange}>
<DropdownMenu.Trigger asChild>
<button
type="button"
className={css({
display: 'flex',
alignItems: 'center',
@@ -120,255 +382,7 @@ export function AbacusDisplayDropdown({
sideOffset={8}
align="start"
>
<div className={stack({ gap: '6' })}>
{/* Header */}
<div className={stack({ gap: '1' })}>
<div
className={hstack({
justify: 'space-between',
alignItems: 'center',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'semibold',
color: isFullscreen ? 'white' : isDark ? 'gray.100' : 'gray.900',
})}
>
🎨 Abacus Style
</h3>
<button
onClick={resetToDefaults}
className={css({
fontSize: 'xs',
color: isFullscreen ? 'gray.300' : isDark ? 'gray.400' : 'gray.500',
_hover: {
color: isFullscreen ? 'white' : isDark ? 'gray.200' : 'gray.700',
},
})}
>
Reset
</button>
</div>
<p
className={css({
fontSize: 'sm',
color: isFullscreen ? 'gray.300' : isDark ? 'gray.400' : 'gray.600',
})}
>
Configure display across the entire app
</p>
</div>
{/* Color Scheme */}
<FormField label="Color Scheme" isFullscreen={isFullscreen} isDark={isDark}>
<RadioGroupField
value={config.colorScheme}
onValueChange={(value) => updateConfig({ colorScheme: value as ColorScheme })}
options={[
{ value: 'monochrome', label: 'Monochrome' },
{ value: 'place-value', label: 'Place Value' },
{ value: 'heaven-earth', label: 'Heaven-Earth' },
{ value: 'alternating', label: 'Alternating' },
]}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
{/* Bead Shape */}
<FormField label="Bead Shape" isFullscreen={isFullscreen} isDark={isDark}>
<RadioGroupField
value={config.beadShape}
onValueChange={(value) => updateConfig({ beadShape: value as BeadShape })}
options={[
{ value: 'diamond', label: '💎 Diamond' },
{ value: 'circle', label: '⭕ Circle' },
{ value: 'square', label: '⬜ Square' },
]}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
{/* Toggle Options */}
<div className={stack({ gap: '4' })}>
<FormField label="Hide Inactive Beads" isFullscreen={isFullscreen} isDark={isDark}>
<SwitchField
checked={config.hideInactiveBeads}
onCheckedChange={(checked) => updateConfig({ hideInactiveBeads: checked })}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
<FormField label="Colored Numerals" isFullscreen={isFullscreen} isDark={isDark}>
<SwitchField
checked={config.coloredNumerals}
onCheckedChange={(checked) => updateConfig({ coloredNumerals: checked })}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
<FormField label="Sound Effects" isFullscreen={isFullscreen} isDark={isDark}>
<SwitchField
checked={config.soundEnabled}
onCheckedChange={(checked) => updateConfig({ soundEnabled: checked })}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
{config.soundEnabled && (
<FormField
label={`Volume: ${Math.round(config.soundVolume * 100)}%`}
isFullscreen={isFullscreen}
isDark={isDark}
>
<input
type="range"
min="0"
max="1"
step="0.1"
value={config.soundVolume}
onChange={(e) => updateConfig({ soundVolume: parseFloat(e.target.value) })}
className={css({
w: 'full',
h: '2',
bg: isFullscreen
? 'rgba(255, 255, 255, 0.2)'
: isDark
? 'gray.700'
: 'gray.200',
rounded: 'full',
appearance: 'none',
cursor: 'pointer',
_focusVisible: {
outline: 'none',
ring: '2px',
ringColor: isFullscreen ? 'blue.400' : 'brand.500',
},
'&::-webkit-slider-thumb': {
appearance: 'none',
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
cursor: 'pointer',
transition: 'all',
_hover: {
bg: isFullscreen ? 'blue.500' : 'brand.700',
transform: 'scale(1.1)',
},
},
'&::-moz-range-thumb': {
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
border: 'none',
cursor: 'pointer',
},
})}
onClick={(e) => e.stopPropagation()} // Prevent dropdown close
/>
</FormField>
)}
<FormField label="Native Abacus Numbers" isFullscreen={isFullscreen} isDark={isDark}>
<SwitchField
checked={abacusSettings?.nativeAbacusNumbers ?? false}
onCheckedChange={(checked) =>
updateAbacusSettings({ nativeAbacusNumbers: checked })
}
isFullscreen={isFullscreen}
isDark={isDark}
/>
</FormField>
<FormField
label={`Physical Abacus Columns: ${config.physicalAbacusColumns}`}
isFullscreen={isFullscreen}
isDark={isDark}
>
<div className={hstack({ gap: '2', alignItems: 'center' })}>
<input
type="range"
min="1"
max="21"
step="1"
value={config.physicalAbacusColumns}
onChange={(e) =>
updateConfig({
physicalAbacusColumns: parseInt(e.target.value, 10),
})
}
className={css({
flex: 1,
h: '2',
bg: isFullscreen
? 'rgba(255, 255, 255, 0.2)'
: isDark
? 'gray.700'
: 'gray.200',
rounded: 'full',
appearance: 'none',
cursor: 'pointer',
_focusVisible: {
outline: 'none',
ring: '2px',
ringColor: isFullscreen ? 'blue.400' : 'brand.500',
},
'&::-webkit-slider-thumb': {
appearance: 'none',
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
cursor: 'pointer',
transition: 'all',
_hover: {
bg: isFullscreen ? 'blue.500' : 'brand.700',
transform: 'scale(1.1)',
},
},
'&::-moz-range-thumb': {
w: '4',
h: '4',
bg: isFullscreen ? 'blue.400' : 'brand.600',
rounded: 'full',
border: 'none',
cursor: 'pointer',
},
})}
onClick={(e) => e.stopPropagation()}
/>
<span
className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: isFullscreen ? 'white' : isDark ? 'gray.200' : 'gray.700',
minW: '6',
textAlign: 'right',
})}
>
{config.physicalAbacusColumns}
</span>
</div>
<p
className={css({
fontSize: 'xs',
color: isFullscreen ? 'gray.400' : isDark ? 'gray.500' : 'gray.500',
mt: '1',
})}
>
For camera vision detection
</p>
</FormField>
</div>
</div>
<AbacusStylePanel isFullscreen={isFullscreen} isDark={isDark} showHeader={true} />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, type CSSProperties, type HTMLAttributes } from 'react'
import { useMyAbacus, type DockConfig } from '@/contexts/MyAbacusContext'
import { type CSSProperties, type HTMLAttributes, useEffect, useRef } from 'react'
import { type DockConfig, useMyAbacus } from '@/contexts/MyAbacusContext'
export interface AbacusDockProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
/** Optional identifier for debugging */
@@ -22,6 +22,8 @@ export interface AbacusDockProps extends Omit<HTMLAttributes<HTMLDivElement>, 'c
defaultValue?: number
/** Callback when value changes (for controlled mode) */
onValueChange?: (newValue: number) => void
/** Hide the undock button (default: false) */
hideUndock?: boolean
}
/**
@@ -55,13 +57,14 @@ export function AbacusDock({
value,
defaultValue,
onValueChange,
hideUndock = false,
style,
...divProps
}: AbacusDockProps) {
const containerRef = useRef<HTMLDivElement>(null)
const { registerDock, unregisterDock, updateDockVisibility } = useMyAbacus()
const { registerDock, unregisterDock, updateDockVisibility, updateDockConfig } = useMyAbacus()
// Register the dock
// Register the dock on mount, unregister on unmount
useEffect(() => {
const element = containerRef.current
if (!element) return
@@ -77,6 +80,7 @@ export function AbacusDock({
value,
defaultValue,
onValueChange,
hideUndock,
isVisible: false, // Will be updated by IntersectionObserver
}
@@ -85,6 +89,27 @@ export function AbacusDock({
return () => {
unregisterDock(element)
}
// Only register/unregister on mount/unmount - config updates happen separately
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [registerDock, unregisterDock])
// Update dock config when props change (without re-registering)
useEffect(() => {
const element = containerRef.current
if (!element) return
updateDockConfig(element, {
id,
columns,
interactive,
showNumbers,
animated,
scaleFactor,
value,
defaultValue,
onValueChange,
hideUndock,
})
}, [
id,
columns,
@@ -95,8 +120,8 @@ export function AbacusDock({
value,
defaultValue,
onValueChange,
registerDock,
unregisterDock,
hideUndock,
updateDockConfig,
])
// Track visibility with IntersectionObserver

View File

@@ -2,7 +2,6 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import * as Tooltip from '@radix-ui/react-tooltip'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import React, { useContext, useMemo, useState } from 'react'
@@ -17,16 +16,9 @@ import { useVisualDebug } from '../contexts/VisualDebugContext'
// Import HomeHeroContext for optional usage
import type { Subtitle } from '../data/abaciOneSubtitles'
import { getRandomSubtitle } from '../data/abaciOneSubtitles'
import { LanguageSelector } from './LanguageSelector'
import { AbacusDisplayDropdown } from './AbacusDisplayDropdown'
import { ThemeToggle } from './ThemeToggle'
// Lazy load AbacusDisplayDropdown - it imports @soroban/abacus-react and multiple Radix components
// Only loaded when user actually opens the settings menu
const AbacusDisplayDropdown = dynamic(
() => import('./AbacusDisplayDropdown').then((m) => m.AbacusDisplayDropdown),
{ ssr: false }
)
type HomeHeroContextValue = {
subtitle: Subtitle
isHeroVisible: boolean
@@ -64,7 +56,6 @@ function MenuContent({
toggleFullscreen,
router,
onNavigate,
handleNestedDropdownChange,
isMobile,
resolvedTheme,
}: {
@@ -74,7 +65,6 @@ function MenuContent({
toggleFullscreen: () => void
router: any
onNavigate?: () => void
handleNestedDropdownChange?: (isOpen: boolean) => void
isMobile?: boolean
resolvedTheme?: 'light' | 'dark'
}) {
@@ -297,32 +287,73 @@ function MenuContent({
)}
</div>
{/* Column 2: Style + Language + Theme */}
{/* Column 2: Settings + Developer */}
<div>
{/* Style Section */}
<div style={sectionHeaderStyle}>Abacus Style</div>
{/* Settings Link */}
<div style={sectionHeaderStyle}>Settings</div>
<div style={{ padding: '0 6px' }}>
<AbacusDisplayDropdown
isFullscreen={isFullscreen}
onOpenChange={handleNestedDropdownChange}
/>
<Link
href="/settings"
onClick={
isMobile
? (e) => {
e.preventDefault()
handleLinkClick('/settings')
}
: undefined
}
style={linkStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark
? 'rgba(139, 92, 246, 0.2)'
: 'rgba(139, 92, 246, 0.1)'
e.currentTarget.style.color = isDark
? 'rgba(196, 181, 253, 1)'
: 'rgba(109, 40, 217, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = isDark
? 'rgba(209, 213, 219, 1)'
: 'rgba(55, 65, 81, 1)'
}}
>
<span style={{ fontSize: '18px' }}></span>
<span>Preferences</span>
</Link>
{/* Theme Toggle - Quick Access */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: isMobile ? '10px' : '10px',
padding: isMobile ? '8px 12px' : '10px 14px',
}}
>
<ThemeToggle />
</div>
<div style={separatorStyle} />
{/* Language Section */}
<div style={sectionHeaderStyle}>Language</div>
<LanguageSelector variant="dropdown-item" isFullscreen={isFullscreen} />
<div style={separatorStyle} />
{/* Theme Section */}
<div style={sectionHeaderStyle}>Theme</div>
<div style={{ padding: '0 6px' }}>
<ThemeToggle />
{/* Abacus Style - Quick Access */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: isMobile ? '10px' : '10px',
padding: isMobile ? '8px 12px' : '10px 14px',
}}
>
<span style={{ fontSize: isMobile ? '18px' : '16px' }}>🧮</span>
<span
style={{
fontSize: isMobile ? '14px' : '14px',
fontWeight: 500,
color: isDark ? 'rgba(209, 213, 219, 1)' : 'rgba(55, 65, 81, 1)',
}}
>
Abacus Style
</span>
<AbacusDisplayDropdown />
</div>
{/* Developer Section - shown in dev or when ?debug=1 is used */}
@@ -330,6 +361,58 @@ function MenuContent({
<>
<div style={separatorStyle} />
<div style={sectionHeaderStyle}>Developer</div>
<Link
href="/debug"
data-action="debug-hub-link"
onClick={(e) => {
e.preventDefault()
handleLinkClick('/debug')
}}
style={linkStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark
? 'rgba(234, 179, 8, 0.2)'
: 'rgba(234, 179, 8, 0.1)'
e.currentTarget.style.color = isDark
? 'rgba(253, 224, 71, 1)'
: 'rgba(161, 98, 7, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = isDark
? 'rgba(209, 213, 219, 1)'
: 'rgba(55, 65, 81, 1)'
}}
>
<span style={{ fontSize: '18px' }}>🛠</span>
<span>Debug Hub</span>
</Link>
<Link
href="/vision-training"
data-action="vision-training-link"
onClick={(e) => {
e.preventDefault()
handleLinkClick('/vision-training')
}}
style={linkStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark
? 'rgba(234, 179, 8, 0.2)'
: 'rgba(234, 179, 8, 0.1)'
e.currentTarget.style.color = isDark
? 'rgba(253, 224, 71, 1)'
: 'rgba(161, 98, 7, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = isDark
? 'rgba(209, 213, 219, 1)'
: 'rgba(55, 65, 81, 1)'
}}
>
<span style={{ fontSize: '18px' }}>👁</span>
<span>Vision Training</span>
</Link>
<div
data-setting="visual-debug"
onClick={() => {
@@ -424,37 +507,120 @@ function MenuContent({
<DropdownMenu.Separator style={separatorStyle} />
{/* Style Section */}
<div style={sectionHeaderStyle}>Abacus Style</div>
{/* Settings Section */}
<div style={sectionHeaderStyle}>Settings</div>
<div style={{ padding: '0 6px' }}>
<AbacusDisplayDropdown
isFullscreen={isFullscreen}
onOpenChange={handleNestedDropdownChange}
/>
<DropdownMenu.Item asChild>
<Link
href="/settings"
style={linkStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark
? 'rgba(139, 92, 246, 0.2)'
: 'rgba(139, 92, 246, 0.1)'
e.currentTarget.style.color = isDark
? 'rgba(196, 181, 253, 1)'
: 'rgba(109, 40, 217, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = isDark
? 'rgba(209, 213, 219, 1)'
: 'rgba(55, 65, 81, 1)'
}}
>
<span style={{ fontSize: '16px' }}></span>
<span>Preferences</span>
</Link>
</DropdownMenu.Item>
{/* Theme Toggle - Quick Access */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
}}
>
<ThemeToggle />
</div>
<DropdownMenu.Separator style={separatorStyle} />
{/* Language Section */}
<div style={sectionHeaderStyle}>Language</div>
<LanguageSelector variant="dropdown-item" isFullscreen={isFullscreen} />
<DropdownMenu.Separator style={separatorStyle} />
{/* Theme Section */}
<div style={sectionHeaderStyle}>Theme</div>
<DropdownMenu.Item onSelect={(e) => e.preventDefault()} style={{ padding: '0 6px' }}>
<ThemeToggle />
</DropdownMenu.Item>
{/* Abacus Style - Quick Access */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
}}
>
<span style={{ fontSize: '16px' }}>🧮</span>
<span
style={{
fontSize: '14px',
fontWeight: 500,
color: isDark ? 'rgba(209, 213, 219, 1)' : 'rgba(55, 65, 81, 1)',
}}
>
Abacus Style
</span>
<AbacusDisplayDropdown />
</div>
{/* Developer Section - shown in dev or when ?debug=1 is used */}
{isDebugAllowed && (
<>
<DropdownMenu.Separator style={separatorStyle} />
<div style={sectionHeaderStyle}>Developer</div>
<DropdownMenu.Item asChild>
<Link
href="/debug"
data-action="debug-hub-link"
style={linkStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark
? 'rgba(234, 179, 8, 0.2)'
: 'rgba(234, 179, 8, 0.1)'
e.currentTarget.style.color = isDark
? 'rgba(253, 224, 71, 1)'
: 'rgba(161, 98, 7, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = isDark
? 'rgba(209, 213, 219, 1)'
: 'rgba(55, 65, 81, 1)'
}}
>
<span style={{ fontSize: '16px' }}>🛠</span>
<span>Debug Hub</span>
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<Link
href="/vision-training"
data-action="vision-training-link"
style={linkStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark
? 'rgba(234, 179, 8, 0.2)'
: 'rgba(234, 179, 8, 0.1)'
e.currentTarget.style.color = isDark
? 'rgba(253, 224, 71, 1)'
: 'rgba(161, 98, 7, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = isDark
? 'rgba(209, 213, 219, 1)'
: 'rgba(55, 65, 81, 1)'
}}
>
<span style={{ fontSize: '16px' }}>👁</span>
<span>Vision Training</span>
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item
data-setting="visual-debug"
onSelect={toggleVisualDebug}
@@ -499,10 +665,8 @@ function HamburgerMenu({
router: any
}) {
const [open, setOpen] = useState(false)
const [nestedDropdownOpen, setNestedDropdownOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const { resolvedTheme } = useTheme()
const { open: openDeploymentInfo } = useDeploymentInfo()
// Detect mobile viewport - check the smaller dimension to catch landscape orientation
React.useEffect(() => {
@@ -516,17 +680,10 @@ function HamburgerMenu({
return () => window.removeEventListener('resize', checkMobile)
}, [])
// Open on click OR if nested dropdown is open
const isOpen = open || nestedDropdownOpen
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen)
}
const handleNestedDropdownChange = (isNestedOpen: boolean) => {
setNestedDropdownOpen(isNestedOpen)
}
const handleClose = () => {
setOpen(false)
}
@@ -637,7 +794,6 @@ function HamburgerMenu({
toggleFullscreen={toggleFullscreen}
router={router}
onNavigate={handleClose}
handleNestedDropdownChange={handleNestedDropdownChange}
isMobile={true}
resolvedTheme={resolvedTheme}
/>
@@ -666,7 +822,7 @@ function HamburgerMenu({
// Desktop dropdown menu
return (
<DropdownMenu.Root open={isOpen} onOpenChange={handleOpenChange}>
<DropdownMenu.Root open={open} onOpenChange={handleOpenChange}>
<DropdownMenu.Trigger asChild>
<button
type="button"
@@ -747,7 +903,6 @@ function HamburgerMenu({
pathname={pathname}
toggleFullscreen={toggleFullscreen}
router={router}
handleNestedDropdownChange={handleNestedDropdownChange}
isMobile={false}
resolvedTheme={resolvedTheme}
/>
@@ -884,6 +1039,7 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
const pathname = usePathname()
const router = useRouter()
const isArcadePage = pathname?.startsWith('/arcade')
const isVisionTrainingPage = pathname?.startsWith('/vision-training')
const isHomePage = pathname === '/'
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
const { open: openDeploymentInfo } = useDeploymentInfo()
@@ -900,8 +1056,9 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
const showBranding = !isHomePage || !homeHero || !homeHero.isHeroVisible
// Auto-detect variant based on context
// Only arcade pages (not /games) should use minimal nav
const actualVariant = variant === 'full' && isArcadePage ? 'minimal' : variant
// Arcade and vision training pages use minimal nav with hamburger + centered content
const actualVariant =
variant === 'full' && (isArcadePage || isVisionTrainingPage) ? 'minimal' : variant
// Mini nav for games/arcade (both fullscreen and non-fullscreen)
if (actualVariant === 'minimal') {

View File

@@ -608,36 +608,38 @@ export function MyAbacus() {
{/* Left side: Vision indicator */}
<VisionIndicator size="small" position="inline" />
{/* Right side: Undock button */}
<button
data-action="undock-abacus"
onClick={(e) => {
e.stopPropagation()
handleUndockClick()
}}
title="Undock abacus"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
w: '24px',
h: '24px',
bg: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: 'md',
color: 'gray.400',
fontSize: 'xs',
cursor: 'pointer',
transition: 'all 0.15s',
_hover: {
bg: 'blue.600',
borderColor: 'blue.600',
color: 'white',
},
})}
>
</button>
{/* Right side: Undock button (hidden when dock.hideUndock is true) */}
{!dock.hideUndock && (
<button
data-action="undock-abacus"
onClick={(e) => {
e.stopPropagation()
handleUndockClick()
}}
title="Undock abacus"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
w: '24px',
h: '24px',
bg: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: 'md',
color: 'gray.400',
fontSize: 'xs',
cursor: 'pointer',
transition: 'all 0.15s',
_hover: {
bg: 'blue.600',
borderColor: 'blue.600',
color: 'white',
},
})}
>
</button>
)}
</div>
)}
</div>,

View File

@@ -40,6 +40,8 @@ interface PageWithNavProps {
onSetup?: () => void
onNewGame?: () => void
children: React.ReactNode
// Custom nav slot content (for non-game pages like vision-training)
navSlot?: React.ReactNode
// Game state for turn indicator
currentPlayerId?: string
playerScores?: Record<string, number>
@@ -93,6 +95,7 @@ export function PageWithNav({
customModeLabel,
customModeEmoji,
customModeColor,
navSlot,
}: PageWithNavProps) {
// In preview mode, render just the children without navigation
const previewMode = useContext(PreviewModeContext)
@@ -134,9 +137,10 @@ export function PageWithNav({
// For non-game pages, render just the AppNavBar without game features
// This avoids loading GameModeContext, useRoomData, etc.
// Pass navSlot if provided (e.g., VisionTrainingNavSlot for vision-training pages)
return (
<>
<AppNavBar navSlot={null} />
<AppNavBar navSlot={navSlot ?? null} />
{children}
</>
)

View File

@@ -10,9 +10,9 @@ import {
useRef,
useState,
} from 'react'
import type { CalibrationGrid } from '@/types/vision'
import type { ColumnImageData } from '@/lib/vision/trainingData'
import { imageDataToBase64Png } from '@/lib/vision/trainingData'
import type { CalibrationGrid } from '@/types/vision'
/**
* Camera source type for vision
@@ -119,6 +119,8 @@ export interface DockConfig {
defaultValue?: number
/** Callback when value changes (for controlled mode) */
onValueChange?: (newValue: number) => void
/** Hide the undock button (default: false) */
hideUndock?: boolean
}
/**
@@ -195,6 +197,8 @@ interface MyAbacusContextValue {
unregisterDock: (element: HTMLElement) => void
/** Update dock visibility status */
updateDockVisibility: (element: HTMLElement, isVisible: boolean) => void
/** Update dock configuration without re-registering */
updateDockConfig: (element: HTMLElement, config: Partial<Omit<DockConfig, 'element'>>) => void
/** Whether the abacus is currently docked (user chose to dock it) */
isDockedByUser: boolean
/** Dock the abacus into the current dock */
@@ -279,6 +283,12 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
const [pendingDockRequest, setPendingDockRequest] = useState(false)
const [abacusValue, setAbacusValue] = useState(0)
// Ref to track dock existence for stable requestDock callback
const dockRef = useRef<DockConfig | null>(null)
useEffect(() => {
dockRef.current = dock
}, [dock])
// Vision state
const [visionConfig, setVisionConfig] = useState<VisionConfig>(DEFAULT_VISION_CONFIG)
const [isVisionSetupOpen, setIsVisionSetupOpen] = useState(false)
@@ -319,6 +329,18 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
})
}, [])
const updateDockConfig = useCallback(
(element: HTMLElement, config: Partial<Omit<DockConfig, 'element'>>) => {
setDock((current) => {
if (current?.element === element) {
return { ...current, ...config }
}
return current
})
},
[]
)
const dockInto = useCallback(() => {
if (dock) {
setIsDockedByUser(true)
@@ -351,11 +373,12 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
}, [])
// Request to dock with animation (triggers MyAbacus to animate into dock)
// Uses ref to avoid recreating callback when dock config changes
const requestDock = useCallback(() => {
if (dock) {
if (dockRef.current) {
setPendingDockRequest(true)
}
}, [dock])
}, [])
// Clear the pending dock request after MyAbacus handles it
const clearDockRequest = useCallback(() => {
@@ -538,6 +561,7 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
registerDock,
unregisterDock,
updateDockVisibility,
updateDockConfig,
isDockedByUser,
dockInto,
undock,