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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user