fix(canvas): stack pins with their component + pickers above floating panels

Two stacking bugs:

1. Pin overlays used a global z-index 30 while component bodies sit at
   z 0-5, all in .canvas-world's single stacking context — so a covered
   component's pins painted on top of whatever covered it (arduino pins
   showing through a breadboard). Each component/board group is now a
   zero-size positioned wrapper that forms its own stacking context
   (boards z 0, components z 1, selected z 2): pins stay above their own
   body but are hidden together with it. .components-area becomes
   pointer-events: none so board pins/drag overlays (now trapped at z 0)
   keep receiving clicks through it; component groups re-enable their own.

2. The Add Component / board picker overlays (z 1000/2000) rendered
   behind the pro AI chat panel (z 8000). Both now portal to <body> at
   z 9000.

Verified in-app: board drag, component drag, wire creation from board
pin to LED, covered pins hidden (0/14 leak), covering component's pins
still clickable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-07-17 18:44:58 +02:00
parent a2df942d74
commit b6ce131b03
7 changed files with 57 additions and 11 deletions

View File

@ -12,7 +12,8 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
/* Above every floating panel, including the pro AI chat (8000/8001). */
z-index: 9000;
animation: fadeIn 0.2s ease;
}

View File

@ -10,6 +10,7 @@
*/
import React, { useState, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { ComponentRegistry } from '../services/ComponentRegistry';
import type { ComponentMetadata, ComponentCategory } from '../types/component-metadata';
@ -153,7 +154,9 @@ export const ComponentPickerModal: React.FC<ComponentPickerModalProps> = ({
if (!isOpen) return null;
return (
// Portal to <body>: the picker must escape the canvas subtree so no ancestor
// stacking context can pin it below floating panels (e.g. the AI chat).
return createPortal(
<div className="component-picker-overlay" onClick={onClose}>
<div className="component-picker-modal" onClick={(e) => e.stopPropagation()}>
{/* Header */}
@ -312,7 +315,8 @@ export const ComponentPickerModal: React.FC<ComponentPickerModalProps> = ({
</>
)}
</div>
</div>
</div>,
document.body
);
};

View File

@ -158,7 +158,11 @@ export const BoardOnCanvas = ({
})();
return (
<>
// Zero-size positioned wrapper: children keep their absolute canvas
// coords, but board + pins now share ONE stacking context, so this
// board's pins can never paint above a component/board covering it.
// z 0 keeps every board below components (their groups use z 1/2).
<div style={{ position: 'absolute', left: 0, top: 0, zIndex: 0 }}>
{boardEl}
{/* Active board highlight ring */}
@ -231,6 +235,6 @@ export const BoardOnCanvas = ({
wrapperOffsetY={0}
zoom={zoom}
/>
</>
</div>
);
};

View File

@ -1,4 +1,5 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { BoardKind } from '../../types/board';
import { BOARD_KIND_LABELS } from '../../types/board';
@ -58,7 +59,9 @@ export const BoardPickerModal = ({ isOpen, onClose, onSelectBoard }: BoardPicker
const { t } = useTranslation();
if (!isOpen) return null;
return (
// Portal to <body>: escape the canvas subtree so no ancestor stacking
// context can pin the dialog below floating panels (e.g. the AI chat).
return createPortal(
<div
style={{
position: 'fixed',
@ -67,7 +70,8 @@ export const BoardPickerModal = ({ isOpen, onClose, onSelectBoard }: BoardPicker
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
// Above every floating panel, including the pro AI chat (8000/8001).
zIndex: 9000,
}}
onClick={onClose}
>
@ -147,6 +151,7 @@ export const BoardPickerModal = ({ isOpen, onClose, onSelectBoard }: BoardPicker
Cancel
</button>
</div>
</div>
</div>,
document.body
);
};

View File

@ -122,7 +122,11 @@ export const PinOverlay: React.FC<PinOverlayProps> = ({
left: `${componentX + wrapperOffsetX}px`,
top: `${componentY + wrapperOffsetY}px`,
pointerEvents: 'none',
zIndex: 30, // Above wires (20) and components, below modals/dialogs (1000+)
// Local to the owning component's stacking context (its wrapper in
// SimulatorCanvas/BoardOnCanvas sets position + z-index): above the
// component's own body/overlays only — a covering component hides
// these pins along with the body.
zIndex: 30,
}}
>
{pins.map((pin, index) => {

View File

@ -245,6 +245,11 @@
left: 0;
right: 0;
bottom: 0;
/* Hit-test transparent: this full-canvas div sits later in the DOM than
the boards, so with pointer events it would swallow clicks meant for
board pins / board drag overlays (their z-index is local to each
board's stacking context). Component groups re-enable their own. */
pointer-events: none;
}
/* ── Zoom controls ───────────────────────────────── */

View File

@ -1969,7 +1969,18 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
onMouseLeave={() =>
setHoveredComponentId((curr) => (curr === component.id ? null : curr))
}
style={{ display: 'contents' }}
// Zero-size positioned wrapper: children keep their absolute canvas
// coords, but body + pins now share ONE stacking context, so this
// component's pins can never paint above a component covering it.
// pointerEvents re-enables hit-testing under .components-area's
// pointer-events: none.
style={{
position: 'absolute',
left: 0,
top: 0,
zIndex: isSelected ? 2 : 1,
pointerEvents: 'auto',
}}
>
<InstrumentComponent
id={component.id}
@ -2021,7 +2032,19 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
onMouseLeave={() =>
setHoveredComponentId((curr) => (curr === component.id ? null : curr))
}
style={{ display: 'contents' }}
// Zero-size positioned wrapper: children keep their absolute canvas
// coords, but body + pins now share ONE stacking context, so this
// component's pins can never paint above a component covering it.
// Selected components (z 2) still raise above unselected ones (z 1).
// pointerEvents re-enables hit-testing under .components-area's
// pointer-events: none.
style={{
position: 'absolute',
left: 0,
top: 0,
zIndex: isSelected ? 2 : 1,
pointerEvents: 'auto',
}}
>
<DynamicComponent
id={component.id}