From 276ca5a04a8f1c52cb1f05c14b6e80a96528acae Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 27 Jul 2026 20:46:42 -0700 Subject: [PATCH] [eric] theme: knob turns relative with seam-safe delta + stuck-drag sealed; minimize parks in 250ms --- .../src/app/components/theme/WashDials.tsx | 50 ++++++++++++------- .../app/pages/Dashboard/cards/BrowserCard.tsx | 3 +- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/components/theme/WashDials.tsx b/frontend/src/app/components/theme/WashDials.tsx index be05fdf6..0e74f1cb 100644 --- a/frontend/src/app/components/theme/WashDials.tsx +++ b/frontend/src/app/components/theme/WashDials.tsx @@ -5,7 +5,7 @@ import React, { useCallback, useRef } from 'react'; export const SquiggleSlider: React.FC<{ value: number; onChange: (v: number) => void; width?: number }> = ({ value, onChange, width = 170 }) => { const ref = useRef(null); - const dragging = useRef(false); + const [dragging, setDragging] = React.useState(false); const apply = useCallback((clientX: number) => { const el = ref.current; if (!el) return; @@ -22,12 +22,14 @@ export const SquiggleSlider: React.FC<{ value: number; onChange: (v: number) =>
{ dragging.current = true; (e.target as HTMLElement).setPointerCapture?.(e.pointerId); apply(e.clientX); }} - onPointerMove={(e) => { if (dragging.current) apply(e.clientX); }} - onPointerUp={() => { dragging.current = false; }} - style={{ position: 'relative', width, height: H, cursor: 'pointer', touchAction: 'none', flexShrink: 0 }} + onPointerDown={(e) => { setDragging(true); e.currentTarget.setPointerCapture?.(e.pointerId); apply(e.clientX); }} + onPointerMove={(e) => { if (dragging) apply(e.clientX); }} + onPointerUp={() => setDragging(false)} + onPointerCancel={() => setDragging(false)} + onLostPointerCapture={() => setDragging(false)} + style={{ position: 'relative', width, height: H, cursor: dragging ? 'grabbing' : 'grab', touchAction: 'none', flexShrink: 0 }} > - +
export const Knob: React.FC<{ value: number; onChange: (v: number) => void; size?: number }> = ({ value, onChange, size = 34 }) => { const ref = useRef(null); const [grabbing, setGrabbing] = React.useState(false); + // A real knob turns RELATIVE to where you grabbed it; jumping to the grab angle feels broken. + const start = useRef<{ deg: number; value: number } | null>(null); const angle = -135 + value * 270; - // Turn like a physical knob: the indicator chases the pointer's angle around the center. - const applyAngle = useCallback((clientX: number, clientY: number) => { + const pointerDeg = useCallback((clientX: number, clientY: number): number => { const el = ref.current; - if (!el) return; + if (!el) return 0; const r = el.getBoundingClientRect(); - const deg = Math.atan2(clientX - (r.left + r.width / 2), (r.top + r.height / 2) - clientY) * (180 / Math.PI); - onChange(Math.min(1, Math.max(0, (Math.max(-135, Math.min(135, deg)) + 135) / 270))); - }, [onChange]); + return Math.atan2(clientX - (r.left + r.width / 2), (r.top + r.height / 2) - clientY) * (180 / Math.PI); + }, []); + const end = (): void => { setGrabbing(false); start.current = null; }; return (
{ setGrabbing(true); (e.target as HTMLElement).setPointerCapture?.(e.pointerId); applyAngle(e.clientX, e.clientY); }} - onPointerMove={(e) => { if (grabbing) applyAngle(e.clientX, e.clientY); }} - onPointerUp={() => setGrabbing(false)} + onPointerDown={(e) => { + setGrabbing(true); + e.currentTarget.setPointerCapture?.(e.pointerId); + start.current = { deg: pointerDeg(e.clientX, e.clientY), value }; + }} + onPointerMove={(e) => { + if (!grabbing || !start.current) return; + let delta = pointerDeg(e.clientX, e.clientY) - start.current.deg; + // Crossing the +-180 seam must not spin the knob a full turn. + if (delta > 180) delta -= 360; + if (delta < -180) delta += 360; + onChange(Math.min(1, Math.max(0, start.current.value + delta / 270))); + }} + onPointerUp={end} + onPointerCancel={end} + onLostPointerCapture={end} style={{ position: 'relative', width: size + 10, height: size + 10, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: grabbing ? 'grabbing' : 'grab', touchAction: 'none', flexShrink: 0, }} > -
-
+
+
diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 8e1f3fed..ac25bd0b 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -559,7 +559,8 @@ const BrowserCard: React.FC = ({ const park = (): void => { if (parked) return; parked = true; dispatch(toggleMinimizeCard({ cardId: browserId })); }; if (capture && typeof (capture as Promise).then === 'function') { // capturePage can hang forever on off-screen guests (Electron 42); the timer guarantees the park. - window.setTimeout(park, 800); + // 250ms: captures land in ~100-200ms when visible, and a snappy minimize beats a perfect thumbnail. + window.setTimeout(park, 250); (capture as Promise<{ toDataURL(): string }>) .then((img) => { saveMinimizedShot(browserId, img.toDataURL()); }) .catch(() => undefined)