chore: polish vision and practice components

- CalibrationOverlay: formatting fixes
- useDeskViewCamera: add SSR guard and focusMode setting
- ActiveSession: integrate vision mode changes
- DocumentAdjuster/PhotoViewerEditor: component updates
- SummaryClient: updates for practice summary
- Drizzle meta: formatting consistency

🤖 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
2025-12-31 19:07:49 -06:00
parent 44dcb01473
commit 957ff71cf1
8 changed files with 169 additions and 115 deletions

View File

@@ -116,13 +116,9 @@
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -240,9 +236,7 @@
"indexes": {
"arcade_rooms_code_unique": {
"name": "arcade_rooms_code_unique",
"columns": [
"code"
],
"columns": ["code"],
"isUnique": true
}
},
@@ -339,26 +333,18 @@
"arcade_sessions_room_id_arcade_rooms_id_fk": {
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
"tableFrom": "arcade_sessions",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"arcade_sessions_user_id_users_id_fk": {
"name": "arcade_sessions_user_id_users_id_fk",
"tableFrom": "arcade_sessions",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -424,9 +410,7 @@
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": [
"user_id"
],
"columns": ["user_id"],
"isUnique": false
}
},
@@ -434,13 +418,9 @@
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -514,9 +494,7 @@
"indexes": {
"idx_room_members_user_id_unique": {
"name": "idx_room_members_user_id_unique",
"columns": [
"user_id"
],
"columns": ["user_id"],
"isUnique": true
}
},
@@ -524,13 +502,9 @@
"room_members_room_id_arcade_rooms_id_fk": {
"name": "room_members_room_id_arcade_rooms_id_fk",
"tableFrom": "room_members",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -605,13 +579,9 @@
"room_member_history_room_id_arcade_rooms_id_fk": {
"name": "room_member_history_room_id_arcade_rooms_id_fk",
"tableFrom": "room_member_history",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -713,10 +683,7 @@
"indexes": {
"idx_room_invitations_user_room": {
"name": "idx_room_invitations_user_room",
"columns": [
"user_id",
"room_id"
],
"columns": ["user_id", "room_id"],
"isUnique": true
}
},
@@ -724,13 +691,9 @@
"room_invitations_room_id_arcade_rooms_id_fk": {
"name": "room_invitations_room_id_arcade_rooms_id_fk",
"tableFrom": "room_invitations",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -833,13 +796,9 @@
"room_reports_room_id_arcade_rooms_id_fk": {
"name": "room_reports_room_id_arcade_rooms_id_fk",
"tableFrom": "room_reports",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -918,10 +877,7 @@
"indexes": {
"idx_room_bans_user_room": {
"name": "idx_room_bans_user_room",
"columns": [
"user_id",
"room_id"
],
"columns": ["user_id", "room_id"],
"isUnique": true
}
},
@@ -929,13 +885,9 @@
"room_bans_room_id_arcade_rooms_id_fk": {
"name": "room_bans_room_id_arcade_rooms_id_fk",
"tableFrom": "room_bans",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -998,13 +950,9 @@
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@@ -1062,16 +1010,12 @@
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": [
"guest_id"
],
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"columns": ["email"],
"isUnique": true
}
},
@@ -1091,4 +1035,4 @@
"internal": {
"indexes": {}
}
}
}

View File

@@ -381,4 +381,4 @@
"breakpoints": true
}
]
}
}

View File

@@ -294,7 +294,11 @@ export function SummaryClient({
// Handle adjustment confirm - upload cropped + original with corners and rotation, process next
const handleUploadAdjustmentConfirm = useCallback(
async (croppedFile: File, corners: Array<{ x: number; y: number }>, rotation: 0 | 90 | 180 | 270) => {
async (
croppedFile: File,
corners: Array<{ x: number; y: number }>,
rotation: 0 | 90 | 180 | 270
) => {
if (!uploadAdjustmentState) return
// Upload both cropped and original, with corners and rotation for later re-editing
@@ -768,7 +772,6 @@ export function SummaryClient({
</Dialog.Portal>
</Dialog.Root>
)}
</PageWithNav>
</SessionModeBannerProvider>
)
@@ -1028,7 +1031,11 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
// Handle adjustment confirm - pass cropped file, original file, corners, and rotation for later re-editing
const handleAdjustmentConfirm = useCallback(
async (croppedFile: File, corners: Array<{ x: number; y: number }>, rotation: 0 | 90 | 180 | 270) => {
async (
croppedFile: File,
corners: Array<{ x: number; y: number }>,
rotation: 0 | 90 | 180 | 270
) => {
if (!adjustmentMode) return
// Convert source canvas to file for original preservation

View File

@@ -49,6 +49,8 @@ import { PracticeHelpOverlay } from './PracticeHelpOverlay'
import { ProblemDebugPanel } from './ProblemDebugPanel'
import { VerticalProblem } from './VerticalProblem'
import type { ReceivedAbacusControl } from '@/hooks/useSessionBroadcast'
import { AbacusVisionBridge } from '../vision'
import { Z_INDEX } from '@/constants/zIndex'
/**
* Timing data for the current problem attempt
@@ -989,6 +991,9 @@ export function ActiveSession({
// Track previous epoch to detect epoch changes
const prevEpochRef = useRef<number>(0)
// Vision mode state - for physical abacus camera detection
const [isVisionEnabled, setIsVisionEnabled] = useState(false)
// Browse mode state - isBrowseMode is controlled via props
// browseIndex can be controlled (browseIndexProp + onBrowseIndexChange) or internal
const [internalBrowseIndex, setInternalBrowseIndex] = useState(0)
@@ -1314,6 +1319,17 @@ export function ActiveSession({
[setAnswer]
)
// Handle value detected from vision (physical abacus camera)
const handleVisionValueDetected = useCallback(
(value: number) => {
// Update the docked abacus to show the detected value
setDockedValue(value)
// Also set the answer input
setAnswer(String(value))
},
[setDockedValue, setAnswer]
)
// Handle submit
const handleSubmit = useCallback(async () => {
// Allow submitting from inputting, awaitingDisambiguation, or helpMode
@@ -1972,22 +1988,56 @@ export function ActiveSession({
{/* Abacus dock - positioned absolutely so it doesn't affect problem centering */}
{/* Width 100% matches problem width, height matches problem height */}
{currentPart.type === 'abacus' && !showHelpOverlay && (problemHeight ?? 0) > 0 && (
<AbacusDock
id="practice-abacus"
columns={calculateAbacusColumns(attempt.problem.terms)}
interactive={true}
showNumbers={false}
animated={true}
onValueChange={handleAbacusDockValueChange}
className={css({
position: 'absolute',
left: '100%',
top: 0,
width: '100%',
marginLeft: '1.5rem',
})}
style={{ height: problemHeight }}
/>
<>
<AbacusDock
id="practice-abacus"
columns={calculateAbacusColumns(attempt.problem.terms)}
interactive={true}
showNumbers={false}
animated={true}
onValueChange={handleAbacusDockValueChange}
className={css({
position: 'absolute',
left: '100%',
top: 0,
width: '100%',
marginLeft: '1.5rem',
})}
style={{ height: problemHeight }}
/>
{/* Vision mode toggle button */}
<button
type="button"
data-action="toggle-vision"
data-enabled={isVisionEnabled}
onClick={() => setIsVisionEnabled((prev) => !prev)}
className={css({
position: 'absolute',
left: '100%',
bottom: 0,
marginLeft: '1.5rem',
px: 2,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
fontSize: 'xs',
bg: isVisionEnabled ? 'green.600' : isDark ? 'gray.700' : 'gray.200',
color: isVisionEnabled ? 'white' : isDark ? 'gray.300' : 'gray.700',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: isVisionEnabled ? 'green.500' : isDark ? 'gray.600' : 'gray.300',
},
})}
title="Use camera to detect physical abacus"
>
<span>📷</span>
<span>Vision</span>
</button>
</>
)}
</animated.div>
</animated.div>
@@ -2072,6 +2122,27 @@ export function ActiveSession({
/>
)}
{/* Abacus Vision Bridge - floating camera panel for physical abacus detection */}
{isVisionEnabled && currentPart.type === 'abacus' && attempt && (
<div
data-component="vision-panel"
className={css({
position: 'fixed',
top: '200px', // Below main nav (80px) + sub nav (~56px) + mini sub-nav (~60px)
right: '1rem',
zIndex: Z_INDEX.DROPDOWN, // Above content but below modals
boxShadow: 'xl',
borderRadius: 'xl',
})}
>
<AbacusVisionBridge
columnCount={calculateAbacusColumns(attempt.problem.terms)}
onValueDetected={handleVisionValueDetected}
onClose={() => setIsVisionEnabled(false)}
/>
</div>
)}
{/* Session Paused Modal - rendered here as single source of truth */}
<SessionPausedModal
isOpen={isPaused}

View File

@@ -72,11 +72,14 @@ export function DocumentAdjuster({
}, [detectQuadsInImage, sourceCanvas])
// Check if current corners match detected corners
const cornersMatchDetected = !!(detectedCorners && corners.length === 4 && detectedCorners.length === 4 &&
corners.every((c, i) =>
Math.abs(c.x - detectedCorners[i].x) < 1 &&
Math.abs(c.y - detectedCorners[i].y) < 1
))
const cornersMatchDetected = !!(
detectedCorners &&
corners.length === 4 &&
detectedCorners.length === 4 &&
corners.every(
(c, i) => Math.abs(c.x - detectedCorners[i].x) < 1 && Math.abs(c.y - detectedCorners[i].y) < 1
)
)
// Calculate display scale to fit source image in container
useEffect(() => {

View File

@@ -63,7 +63,12 @@ export function PhotoViewerEditor({
// Flag to prevent auto-reload after saving (which clears editState)
const [editCompleted, setEditCompleted] = useState(false)
const { isReady: isDetectionReady, detectQuadsInImage, loadImageToCanvas, cv: opencvRef } = useDocumentDetection()
const {
isReady: isDetectionReady,
detectQuadsInImage,
loadImageToCanvas,
cv: opencvRef,
} = useDocumentDetection()
// Reset state when opening
useEffect(() => {
@@ -151,7 +156,11 @@ export function PhotoViewerEditor({
// Handle edit confirm
const handleEditConfirm = useCallback(
async (croppedFile: File, corners: Array<{ x: number; y: number }>, rotation: 0 | 90 | 180 | 270) => {
async (
croppedFile: File,
corners: Array<{ x: number; y: number }>,
rotation: 0 | 90 | 180 | 270
) => {
if (!currentPhoto) return
setIsSaving(true)

View File

@@ -283,10 +283,22 @@ export function CalibrationOverlay({
// Convert corners to display coordinates (accounting for letterbox offset)
const displayCorners: QuadCorners = {
topLeft: { x: corners.topLeft.x * scale + videoOffsetX, y: corners.topLeft.y * scale + videoOffsetY },
topRight: { x: corners.topRight.x * scale + videoOffsetX, y: corners.topRight.y * scale + videoOffsetY },
bottomLeft: { x: corners.bottomLeft.x * scale + videoOffsetX, y: corners.bottomLeft.y * scale + videoOffsetY },
bottomRight: { x: corners.bottomRight.x * scale + videoOffsetX, y: corners.bottomRight.y * scale + videoOffsetY },
topLeft: {
x: corners.topLeft.x * scale + videoOffsetX,
y: corners.topLeft.y * scale + videoOffsetY,
},
topRight: {
x: corners.topRight.x * scale + videoOffsetX,
y: corners.topRight.y * scale + videoOffsetY,
},
bottomLeft: {
x: corners.bottomLeft.x * scale + videoOffsetX,
y: corners.bottomLeft.y * scale + videoOffsetY,
},
bottomRight: {
x: corners.bottomRight.x * scale + videoOffsetX,
y: corners.bottomRight.y * scale + videoOffsetY,
},
}
// Create SVG path for the quadrilateral

View File

@@ -119,6 +119,9 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn {
video: {
width: { ideal: 1920 },
height: { ideal: 1440 },
// Try to disable face-tracking auto-focus (not all cameras support this)
// @ts-expect-error - focusMode is valid but not in TS types
focusMode: 'continuous',
...(targetDeviceId ? { deviceId: { exact: targetDeviceId } } : {}),
},
audio: false,
@@ -188,6 +191,11 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn {
// Listen for device changes (e.g., iPhone connected/disconnected)
useEffect(() => {
// Guard against SSR or unsupported environments
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
return
}
const handleDeviceChange = () => {
enumerateDevices()
}