From faed282135c09a5ef3ff3a9bb14a64c1a66bb403 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sat, 1 Aug 2026 05:32:46 +0200 Subject: [PATCH 1/4] fix(canvas): latch the socket at drag start so the stack cannot come apart --- .../components/simulator/SimulatorCanvas.tsx | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index e779351a..5a3a9685 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -371,6 +371,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // Which drag session already raised its item (drag-to-front fires once per // drag, on the first mousemove). const raisedThisDragRef = useRef(null); + /** + * The socket a dragged board was plugged into when the drag STARTED. + * Latched once per drag: "is it seated?" is only true at the seat, so + * re-asking after the first pixel of movement answers no and the stack + * would come apart one frame in. + */ + const carriedSocketRef = useRef<{ dragId: string; sockId: string } | null>(null); // Captures (x, y) of the dragged component at mousedown so a drag-end // can record the diff as a single undoable Move. Boards are intentionally // skipped — board moves don't go through component history. @@ -932,14 +939,20 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const st = useSimulatorStore.getState(); const b = st.boards.find((bb) => bb.id === boardId); // Same stack rule as the mouse path: while simulating, a seated - // board drags its socket along instead of unplugging. - const sock = - st.running && b - ? st.components.find((c) => { - const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]); - return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; - }) - : undefined; + // board drags its socket along instead of unplugging. Latched at + // drag start for the same reason (see carriedSocketRef). + if (carriedSocketRef.current?.dragId !== touchId) { + const seatedOn = + st.running && b + ? st.components.find((c) => { + const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]); + return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; + }) + : undefined; + carriedSocketRef.current = { dragId: touchId, sockId: seatedOn?.id ?? '' }; + } + const sockId = carriedSocketRef.current.sockId; + const sock = sockId ? st.components.find((c) => c.id === sockId) : undefined; if (b && sock) { updateComponent(sock.id, { x: sock.x + (nb.x - b.x), @@ -1602,13 +1615,21 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // While SIMULATING, a seated stack moves as one piece: dragging the // board drags its socket along instead of unplugging it. Unplugging // is an edit-mode gesture (carrySeatedBoards is the other half). - const sock = - st.running && b - ? st.components.find((c) => { - const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]); - return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; - }) - : undefined; + if (carriedSocketRef.current?.dragId !== draggedComponentId) { + const seatedOn = + st.running && b + ? st.components.find((c) => { + const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]); + return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; + }) + : undefined; + carriedSocketRef.current = { + dragId: draggedComponentId, + sockId: seatedOn?.id ?? '', + }; + } + const sockId = carriedSocketRef.current.sockId; + const sock = sockId ? st.components.find((c) => c.id === sockId) : undefined; if (b && sock) { updateComponent(sock.id, { x: sock.x + (nb.x - b.x), @@ -1940,6 +1961,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { recalculateAllWirePositions(); setDraggedComponentId(null); raisedThisDragRef.current = null; + carriedSocketRef.current = null; } }; @@ -2911,6 +2933,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { setPan({ ...panRef.current }); setDraggedComponentId(null); raisedThisDragRef.current = null; + carriedSocketRef.current = null; }} onContextMenu={(e) => { e.preventDefault(); From f2e6464c36ca46af6de4dbce52c075fa39d05510 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sat, 1 Aug 2026 05:55:35 +0200 Subject: [PATCH 2/4] fix(canvas): the dragged board's own run state decides if the stack holds --- .../src/components/simulator/SimulatorCanvas.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 5a3a9685..7d63b029 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -942,8 +942,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // board drags its socket along instead of unplugging. Latched at // drag start for the same reason (see carriedSocketRef). if (carriedSocketRef.current?.dragId !== touchId) { + // Ask the DRAGGED board whether it is running, not the store's + // top-level flag — that one mirrors the ACTIVE board only, so a + // running non-active board read as stopped and its stack came + // apart mid-simulation. + const boardRunning = !!(b && (b.running || st.running)); const seatedOn = - st.running && b + boardRunning && b ? st.components.find((c) => { const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]); return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; @@ -1616,8 +1621,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // board drags its socket along instead of unplugging it. Unplugging // is an edit-mode gesture (carrySeatedBoards is the other half). if (carriedSocketRef.current?.dragId !== draggedComponentId) { + // Ask the DRAGGED board whether it is running, not the store's + // top-level flag — that one mirrors the ACTIVE board only, so a + // running non-active board read as stopped and its stack came + // apart mid-simulation. + const boardRunning = !!(b && (b.running || st.running)); const seatedOn = - st.running && b + boardRunning && b ? st.components.find((c) => { const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]); return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; From 13a8403d6d4b3ba2cec9dc281f7b38ffd5501be8 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sat, 1 Aug 2026 06:14:43 +0200 Subject: [PATCH 3/4] fix(sockets): a board a hair off the seat is still plugged in --- .../components/simulator/SimulatorCanvas.tsx | 15 +++-------- frontend/src/store/useSimulatorStore.ts | 15 +++-------- frontend/src/utils/socketSnap.ts | 26 ++++++++++++++----- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 7d63b029..9a920220 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -50,7 +50,7 @@ import { seatOnDrop, snapPositionToBreadboard, } from '../../utils/breadboardSnap'; -import { snapBoardToSocket } from '../../utils/socketSnap'; +import { snapBoardToSocket, isBoardSeated } from '../../utils/socketSnap'; import { findWireNearPoint, findSegmentNearPoint, @@ -138,8 +138,7 @@ function carrySeatedBoards( for (const b of st.boards) { // Restricting the candidate list to the dragged component asks the // narrow question: is this board seated on THIS socket? - const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [dragged]); - if (seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5) { + if (isBoardSeated(b.id, b.boardKind, b.x, b.y, [dragged])) { st.setBoardPosition({ x: b.x + dx, y: b.y + dy }, b.id); } } @@ -949,10 +948,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const boardRunning = !!(b && (b.running || st.running)); const seatedOn = boardRunning && b - ? st.components.find((c) => { - const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]); - return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; - }) + ? st.components.find((c) => isBoardSeated(b.id, b.boardKind, b.x, b.y, [c])) : undefined; carriedSocketRef.current = { dragId: touchId, sockId: seatedOn?.id ?? '' }; } @@ -1628,10 +1624,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { const boardRunning = !!(b && (b.running || st.running)); const seatedOn = boardRunning && b - ? st.components.find((c) => { - const seat = snapBoardToSocket(b.id, b.boardKind, b.x, b.y, [c]); - return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; - }) + ? st.components.find((c) => isBoardSeated(b.id, b.boardKind, b.x, b.y, [c])) : undefined; carriedSocketRef.current = { dragId: draggedComponentId, diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index b8901794..24ba269f 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -52,7 +52,7 @@ import { collectWireSegments, } from '../utils/wireAutoRoute'; import { isBreadboard } from '../utils/breadboardNets'; -import { snapBoardToSocket } from '../utils/socketSnap'; +import { isBoardSeated } from '../utils/socketSnap'; import { computeSeating } from '../utils/breadboardSnap'; import { createSerialBatcher } from './serialBatcher'; import { @@ -3169,16 +3169,9 @@ export const useSimulatorStore = create((set, get) => { // above — raising the socket alone buried its own seated board, and a // buried board cannot be grabbed to unplug it. const seatedOnIt = s.components.some((c) => c.id === id) - ? s.boards.filter((b) => { - const seat = snapBoardToSocket( - b.id, - b.boardKind, - b.x, - b.y, - s.components.filter((c) => c.id === id), - ); - return !!seat && Math.hypot(seat.x - b.x, seat.y - b.y) < 0.5; - }) + ? s.boards.filter((b) => + isBoardSeated(b.id, b.boardKind, b.x, b.y, s.components.filter((c) => c.id === id)), + ) : []; let top = s.zTop; const zOrders = { ...s.zOrders, [id]: ++top }; diff --git a/frontend/src/utils/socketSnap.ts b/frontend/src/utils/socketSnap.ts index a2ae90f9..ab7a0f62 100644 --- a/frontend/src/utils/socketSnap.ts +++ b/frontend/src/utils/socketSnap.ts @@ -95,12 +95,24 @@ export function snapBoardToSocket( } /** - * True when the board's CURRENT position IS a socket seat (within half a - * pixel). This is the z-order question, not the drag question: a seated - * board must paint above its socket component, an unseated one must stay - * below components like every other board — the blanket zIndex bump that - * preceded this check hid a resistor behind an Arduino in every ordinary - * example. + * How far off the exact seat a board may sit and still count as plugged in. + * Not zero, and deliberately far below SOCKET_SNAP_TOLERANCE: a stack the + * magnet built lands exact, but one an EXAMPLE declares (or a project saved + * before a socket's art was nudged) can be a fraction of a pixel out. At the + * old half-pixel bar such a board looked seated on screen while every seat + * test said otherwise — so it got no electrical connection and its socket + * did not travel with it. A couple of pixels is invisible to the eye and + * still nowhere near the next hole. + */ +const SEATED_EPSILON = 2; + +/** + * True when the board's CURRENT position IS a socket seat. This is the + * "is it plugged in?" question, asked by z-order (a seated board must paint + * above its socket, an unseated one stays below components like every other + * board — a blanket zIndex bump once hid a resistor behind an Arduino), + * by the electrical hop that makes seating mean connection, and by the drag + * rules that keep a plugged stack together. */ export function isBoardSeated( boardId: string, @@ -110,5 +122,5 @@ export function isBoardSeated( components: ComponentLike[], ): boolean { const seat = snapBoardToSocket(boardId, boardKind, x, y, components); - return !!seat && Math.hypot(seat.x - x, seat.y - y) < 0.5; + return !!seat && Math.hypot(seat.x - x, seat.y - y) <= SEATED_EPSILON; } From 32e5e9c50b2e15d1f197a8b61cbbc6b00fa44124 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sat, 1 Aug 2026 08:26:16 +0200 Subject: [PATCH 4/4] ui(picker): the gesture sensor's card shows what it really is --- frontend/src/lib/onlineOnlyBoards.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/onlineOnlyBoards.ts b/frontend/src/lib/onlineOnlyBoards.ts index a99607e8..6992e07f 100644 --- a/frontend/src/lib/onlineOnlyBoards.ts +++ b/frontend/src/lib/onlineOnlyBoards.ts @@ -189,10 +189,10 @@ export const ONLINE_ONLY_COMPONENT_ADS: OnlineOnlyComponentAd[] = [ }, { id: 'grove-gesture-pag7660', - label: 'Grove Smart IR Gesture (PAG7660)', - description: 'IR gesture recognition (rotate/push/tap/...) - available in the online editor', + label: 'Grove Smart IR Gesture (PAG7661QN)', + description: 'IR camera gesture sensor on a XIAO carrier: seat a XIAO on its socket and read rotate/tap/grab/pinch/swipe over I2C - available in the online editor', category: 'input', - thumbnailSvg: '', + thumbnailSvg: '', }, { id: 'respeaker-lite',