refactor(vision): combine setup modal into single draggable experience

- Merge VisionSetupModal and AbacusVisionBridge into unified UI
- Remove two-step configuration process (no more "Configure Camera" button)
- Add vision control props to AbacusVisionBridge:
  - showVisionControls, isVisionEnabled, isVisionSetupComplete
  - onToggleVision, onClearSettings callbacks
- Add Enable/Disable Vision and Clear Settings buttons to bridge footer
- Simplify VisionSetupModal from ~257 to ~93 lines
- Modal is now draggable via framer-motion (built into AbacusVisionBridge)

User experience: Open modal → immediately see camera feed and all controls
in one place. Drag modal anywhere. Configure, enable/disable, close.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2026-01-01 17:30:46 -06:00
parent d90d263b2a
commit 70b363ce88
2 changed files with 137 additions and 220 deletions

View File

@@ -44,6 +44,16 @@ export interface AbacusVisionBridgeProps {
onConfigurationChange?: (config: VisionConfigurationChange) => void
/** Initial camera source to show (defaults to 'local', but should be 'phone' if remote session is active) */
initialCameraSource?: CameraSource
/** Whether to show vision control buttons (enable/disable, clear settings) */
showVisionControls?: boolean
/** Whether vision is currently enabled (for showVisionControls) */
isVisionEnabled?: boolean
/** Whether vision setup is complete (for showVisionControls) */
isVisionSetupComplete?: boolean
/** Called when user toggles vision on/off */
onToggleVision?: () => void
/** Called when user clicks clear settings */
onClearSettings?: () => void
}
/**
@@ -62,6 +72,11 @@ export function AbacusVisionBridge({
onError,
onConfigurationChange,
initialCameraSource = 'local',
showVisionControls = false,
isVisionEnabled = false,
isVisionSetupComplete = false,
onToggleVision,
onClearSettings,
}: AbacusVisionBridgeProps): ReactNode {
const [videoDimensions, setVideoDimensions] = useState<{
width: number
@@ -1339,6 +1354,72 @@ export function AbacusVisionBridge({
{vision.cameraError}
</div>
)}
{/* Vision control buttons (when embedded in modal) */}
{showVisionControls && (
<div
data-element="vision-controls"
className={css({
display: 'flex',
flexDirection: 'column',
gap: 2,
mt: 2,
pt: 3,
borderTop: '1px solid',
borderColor: 'gray.700',
})}
>
{isVisionSetupComplete && (
<button
type="button"
onClick={onToggleVision}
className={css({
px: 4,
py: 3,
bg: isVisionEnabled ? 'red.600' : 'green.600',
color: 'white',
borderRadius: 'lg',
fontWeight: 'semibold',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: isVisionEnabled ? 'red.700' : 'green.700',
transform: 'scale(1.02)',
},
})}
>
{isVisionEnabled ? 'Disable Vision' : 'Enable Vision'}
</button>
)}
{isVisionSetupComplete && (
<button
type="button"
data-action="clear-settings"
onClick={onClearSettings}
className={css({
px: 4,
py: 2,
bg: 'transparent',
color: 'gray.400',
borderRadius: 'lg',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'gray.600',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'gray.500',
color: 'gray.300',
},
})}
>
Clear All Settings
</button>
)}
</div>
)}
</motion.div>
)
}

View File

@@ -1,16 +1,14 @@
'use client'
import { useState } from 'react'
import { useMyAbacus } from '@/contexts/MyAbacusContext'
import { Modal, ModalContent } from '@/components/common/Modal'
import { AbacusVisionBridge } from './AbacusVisionBridge'
import { css } from '../../../styled-system/css'
import { AbacusVisionBridge } from './AbacusVisionBridge'
/**
* Modal for configuring abacus vision settings
*
* Shows status and launches AbacusVisionBridge for configuration.
* AbacusVisionBridge saves camera/calibration to MyAbacusContext.
* Renders AbacusVisionBridge directly in a draggable modal.
* The bridge component handles all camera/calibration configuration.
*/
export function VisionSetupModal() {
const {
@@ -25,9 +23,6 @@ export function VisionSetupModal() {
dock,
} = useMyAbacus()
// State for showing the configuration UI
const [isConfiguring, setIsConfiguring] = useState(false)
const handleClearSettings = () => {
setVisionCamera(null)
setVisionCalibration(null)
@@ -35,223 +30,64 @@ export function VisionSetupModal() {
setVisionEnabled(false)
}
return (
<Modal isOpen={isVisionSetupOpen} onClose={closeVisionSetup}>
<ModalContent
title="📷 Abacus Vision"
description="Use a camera to detect your physical abacus"
borderColor="rgba(34, 211, 238, 0.3)"
titleColor="rgba(34, 211, 238, 1)"
>
<div className={css({ display: 'flex', flexDirection: 'column', gap: 4 })}>
{/* Status display */}
<div
data-element="vision-status"
className={css({
bg: 'rgba(0, 0, 0, 0.3)',
borderRadius: 'lg',
p: 4,
})}
>
<h3 className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'gray.300', mb: 2 })}>
Status
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: 2 })}>
<StatusRow
label="Camera"
value={visionConfig.cameraDeviceId ? 'Configured' : 'Not configured'}
isConfigured={visionConfig.cameraDeviceId !== null}
/>
<StatusRow
label="Calibration"
value={visionConfig.calibration ? 'Configured' : 'Not configured'}
isConfigured={visionConfig.calibration !== null}
/>
<StatusRow
label="Remote Phone"
value={visionConfig.remoteCameraSessionId ? 'Connected' : 'Not connected'}
isConfigured={visionConfig.remoteCameraSessionId !== null}
/>
<StatusRow
label="Vision Mode"
value={visionConfig.enabled ? 'Enabled' : 'Disabled'}
isConfigured={visionConfig.enabled}
/>
</div>
</div>
const handleToggleVision = () => {
setVisionEnabled(!visionConfig.enabled)
}
{/* Actions */}
<div className={css({ display: 'flex', flexDirection: 'column', gap: 2 })}>
{isVisionSetupComplete && (
<button
type="button"
onClick={() => {
setVisionEnabled(!visionConfig.enabled)
}}
className={css({
px: 4,
py: 3,
bg: visionConfig.enabled ? 'red.600' : 'green.600',
color: 'white',
borderRadius: 'lg',
fontWeight: 'semibold',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: visionConfig.enabled ? 'red.700' : 'green.700',
transform: 'scale(1.02)',
},
})}
>
{visionConfig.enabled ? 'Disable Vision' : 'Enable Vision'}
</button>
)}
if (!isVisionSetupOpen) return null
<button
type="button"
data-action="configure-camera"
onClick={() => setIsConfiguring(true)}
className={css({
px: 4,
py: 3,
bg: 'cyan.600',
color: 'white',
borderRadius: 'lg',
fontWeight: 'semibold',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: 'cyan.700',
transform: 'scale(1.02)',
},
})}
>
{isVisionSetupComplete ? 'Reconfigure Camera' : 'Configure Camera & Calibration'}
</button>
{isVisionSetupComplete && (
<button
type="button"
data-action="clear-settings"
onClick={handleClearSettings}
className={css({
px: 4,
py: 2,
bg: 'transparent',
color: 'gray.400',
borderRadius: 'lg',
fontWeight: 'medium',
border: '1px solid',
borderColor: 'gray.600',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'gray.500',
color: 'gray.300',
},
})}
>
Clear All Settings
</button>
)}
</div>
{/* Close button */}
<button
type="button"
data-action="close-modal"
onClick={closeVisionSetup}
className={css({
mt: 2,
px: 4,
py: 2,
bg: 'gray.700',
color: 'white',
borderRadius: 'lg',
border: 'none',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: 'gray.600',
},
})}
>
Close
</button>
</div>
</ModalContent>
{/* AbacusVisionBridge overlay for configuration */}
{isConfiguring && (
<div
data-element="vision-config-overlay"
className={css({
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'rgba(0, 0, 0, 0.8)',
zIndex: 1000,
})}
>
<AbacusVisionBridge
columnCount={dock?.columns ?? 5}
onValueDetected={() => {
// Value detected - configuration is working
}}
onClose={() => setIsConfiguring(false)}
onConfigurationChange={(config) => {
// Save configuration to context as it changes
if (config.cameraDeviceId !== undefined) {
setVisionCamera(config.cameraDeviceId)
}
if (config.calibration !== undefined) {
setVisionCalibration(config.calibration)
}
if (config.remoteCameraSessionId !== undefined) {
setVisionRemoteSession(config.remoteCameraSessionId)
}
}}
// Start with phone camera selected if remote session is configured but no local camera
initialCameraSource={
visionConfig.remoteCameraSessionId && !visionConfig.cameraDeviceId ? 'phone' : 'local'
}
/>
</div>
)}
</Modal>
)
}
/**
* Status row component
*/
function StatusRow({
label,
value,
isConfigured,
}: {
label: string
value: string
isConfigured: boolean
}) {
return (
<div
className={css({ display: 'flex', justifyContent: 'space-between', alignItems: 'center' })}
data-component="vision-setup-modal"
className={css({
position: 'fixed',
inset: 0,
bg: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
})}
onClick={closeVisionSetup}
onKeyDown={(e) => {
if (e.key === 'Escape') {
closeVisionSetup()
}
}}
>
<span className={css({ color: 'gray.400', fontSize: 'sm' })}>{label}</span>
<span
className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: isConfigured ? 'green.400' : 'gray.500',
})}
>
{value}
</span>
{/* AbacusVisionBridge is a motion.div with drag - stopPropagation prevents backdrop close */}
<div onClick={(e) => e.stopPropagation()}>
<AbacusVisionBridge
columnCount={dock?.columns ?? 5}
onValueDetected={() => {
// Value detected - configuration is working
}}
onClose={closeVisionSetup}
onConfigurationChange={(config) => {
// Save configuration to context as it changes
if (config.cameraDeviceId !== undefined) {
setVisionCamera(config.cameraDeviceId)
}
if (config.calibration !== undefined) {
setVisionCalibration(config.calibration)
}
if (config.remoteCameraSessionId !== undefined) {
setVisionRemoteSession(config.remoteCameraSessionId)
}
}}
// Start with phone camera selected if remote session is configured but no local camera
initialCameraSource={
visionConfig.remoteCameraSessionId && !visionConfig.cameraDeviceId ? 'phone' : 'local'
}
// Show enable/disable and clear buttons
showVisionControls={true}
isVisionEnabled={visionConfig.enabled}
isVisionSetupComplete={isVisionSetupComplete}
onToggleVision={handleToggleVision}
onClearSettings={handleClearSettings}
/>
</div>
</div>
)
}