merge: sinkronkan upstream v3.2 ke elemes-v3.2

- ambil 4 fix upstream: socket latch saat drag, run-state board, toleransi
  posisi board di socket, koreksi kartu gesture sensor di picker
- pertahankan custom Elemes: EmbedBridge, DeployProgress, backend auth/projects,
  nginx.conf
- kembalikan EditorToolbar.tsx/.css + SimulatorCanvas.css (masih di-import
  oleh EditorPage/SimulatorCanvas; baseline sebelumnya broken build)
This commit is contained in:
a2nr 2026-08-01 14:07:51 +00:00
commit 2b1747041b
7 changed files with 2953 additions and 39 deletions

View File

@ -0,0 +1,434 @@
/* Toolbar container
* Single-row layout: [left actions] [file tabs flex] [right actions]
* The center slot (file tabs) flex-grows and scrolls horizontally so the
* action icons on either side stay pinned and visible no matter how narrow
* the editor pane gets when the user drags the editor/canvas divider.
*
* Uses CSS container queries so the Libraries label collapses based on the
* toolbar's *own* width (not the viewport) which is what changes when the
* user drags the editor/canvas divider.
*/
.editor-toolbar-wrapper {
container-type: inline-size;
container-name: editor-toolbar;
}
.editor-toolbar {
display: flex;
align-items: stretch;
justify-content: flex-start;
padding: 0 6px;
height: 38px;
background: #252526;
border-bottom: 1px solid #333;
flex-shrink: 0;
gap: 4px;
min-width: 0;
}
/* ── Groups ─────────────────────────────────────── */
.toolbar-group {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
}
.toolbar-group-right {
gap: 4px;
}
/* Center slot a flexible spacer between the left and right action groups
that keeps the right icons pinned to the far right. Normally empty. */
.toolbar-center-slot {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: stretch;
overflow: hidden;
}
/* ── Divider ─────────────────────────────────────── */
.tb-divider {
width: 1px;
height: 18px;
background: #3a3a3a;
margin: 0 3px;
align-self: center;
}
/* ── Icon buttons ───────────────────────────────── */
.tb-btn {
/* 28px the one control height across the whole editor strip (view-mode
segments, Libraries, language select, canvas controls all match). */
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 4px;
cursor: pointer;
background: transparent;
color: #9d9d9d;
transition:
background 0.12s,
color 0.12s;
flex-shrink: 0;
align-self: center;
}
.tb-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* Compile — accent blue (token, not neon) */
.tb-btn-compile {
color: var(--color-action-primary);
}
.tb-btn-compile:hover:not(:disabled) {
background: rgba(0, 113, 227, 0.14);
color: var(--color-action-primary-hover);
}
.tb-btn-compile:active:not(:disabled) {
background: rgba(0, 113, 227, 0.22);
}
/* Run — semantic success green (token) */
.tb-btn-run {
color: var(--color-feedback-success);
}
.tb-btn-run:hover:not(:disabled) {
background: rgba(48, 209, 88, 0.14);
color: var(--color-feedback-success);
}
/* Stop — semantic error red (token) */
.tb-btn-stop {
color: var(--color-feedback-error);
}
.tb-btn-stop:hover:not(:disabled) {
background: rgba(255, 69, 58, 0.14);
color: var(--color-feedback-error);
}
/* Reset */
.tb-btn-reset:hover:not(:disabled) {
background: #2a2d2e;
color: #ccc;
}
/* Libraries button (always visible, with label) */
.tb-btn-libraries {
display: flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 8px 0 7px;
border: none;
border-radius: 5px;
cursor: pointer;
background: var(--color-action-primary);
color: #fff;
font-size: 11px;
font-weight: 500;
font-family: inherit;
white-space: nowrap;
transition:
background 0.15s,
color 0.15s;
flex-shrink: 0;
align-self: center;
}
.tb-btn-libraries:hover {
background: var(--color-action-primary-hover);
color: #fff;
}
.tb-libraries-label {
line-height: 1;
}
/* Hide the label only when the toolbar is genuinely cramped keep the
"Libraries" text visible in the default 50/50 split (~376px toolbar) so
the button reads clearly. Below 360px the icon alone keeps it reachable
without stealing horizontal space from the file tabs. The container query
reacts to the toolbar's own width, so it triggers when the user drags the
editor/canvas divider very narrow, not just on small viewports. */
@container editor-toolbar (max-width: 360px) {
.tb-libraries-label {
display: none;
}
.tb-btn-libraries {
padding: 0 6px;
}
}
/* When even narrower, also collapse the board pill label. */
@container editor-toolbar (max-width: 460px) {
.tb-board-pill-label {
display: none;
}
.tb-board-pill {
padding: 3px 6px;
}
}
/* Import / Export moved to the File menu in the header (EditorMenuBar):
their inline buttons and the responsive overflow twins are gone, which
is ~70px the row no longer has to find on laptop widths. */
/* Legacy lib icon-only button (for overflow items) */
.tb-btn-lib {
color: var(--color-action-primary);
}
.tb-btn-lib:hover {
background: rgba(0, 113, 227, 0.12);
color: var(--color-action-primary-hover);
}
/* Missing library hint banner */
.tb-lib-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
background: rgba(255, 159, 10, 0.1);
border-bottom: 1px solid rgba(255, 159, 10, 0.25);
color: var(--color-feedback-warning);
font-size: 12px;
font-family: inherit;
}
.tb-lib-hint svg {
flex-shrink: 0;
color: var(--color-feedback-warning);
}
.tb-lib-hint-btn {
background: var(--color-action-primary);
border: none;
border-radius: 4px;
color: #fff;
font-size: 12px;
font-weight: 600;
font-family: inherit;
padding: 2px 8px;
cursor: pointer;
transition: background 0.12s;
}
.tb-lib-hint-btn:hover {
background: var(--color-action-primary-hover);
color: #fff;
}
.tb-lib-hint-close {
margin-left: auto;
background: none;
border: none;
color: #888;
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.tb-lib-hint-close:hover {
color: #ccc;
}
/* Output Console toggle */
.tb-btn-output {
color: #9d9d9d;
}
.tb-btn-output:hover {
background: rgba(255, 255, 255, 0.06);
color: #ccc;
}
.tb-btn-output-active {
color: var(--color-action-primary);
background: rgba(0, 113, 227, 0.12);
}
.tb-btn-output-active:hover {
background: rgba(0, 113, 227, 0.2);
color: var(--color-action-primary-hover);
}
/* ── Spin animation (compiling) ─────────────────── */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spin {
animation: spin 0.8s linear infinite;
}
/* ── Status inline message ──────────────────────── */
.tb-status {
display: flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 4px;
font-size: 12px;
max-width: 280px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tb-status-success {
color: var(--color-feedback-success);
background: rgba(48, 209, 88, 0.1);
}
.tb-status-error {
color: var(--color-feedback-error);
background: rgba(255, 69, 58, 0.1);
}
.tb-status-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Compile All / Run All buttons ─────────────── */
.tb-btn-compile-all {
color: var(--color-action-primary);
}
.tb-btn-compile-all:hover:not(:disabled) {
background: rgba(0, 113, 227, 0.14);
color: var(--color-action-primary-hover);
}
.tb-btn-run-all {
color: var(--color-feedback-success);
}
.tb-btn-run-all:hover:not(:disabled) {
background: rgba(48, 209, 88, 0.14);
color: var(--color-feedback-success);
}
/* ── Board context pill ─────────────────────────── */
.tb-board-pill {
display: flex;
align-items: center;
gap: 5px;
padding: 3px 9px 3px 7px;
border-radius: 20px;
border: 1px solid;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
opacity: 0.85;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.04);
max-width: 170px;
overflow: hidden;
}
.tb-board-pill-icon {
font-size: 9px;
flex-shrink: 0;
}
.tb-board-pill-label {
overflow: hidden;
text-overflow: ellipsis;
}
.tb-board-pill-running {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-feedback-success);
flex-shrink: 0;
animation: pulse-run 1.4s ease-in-out infinite;
}
@keyframes pulse-run {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
/* ── Overflow (…) menu ──────────────────────────── */
/* ── Run split-button (multi-board: primary Run = all boards + menu) ──── */
.tb-run-split {
position: relative;
display: inline-flex;
align-items: center;
}
.tb-btn-run-caret {
width: 16px;
color: var(--color-feedback-success);
}
.tb-btn-run-caret:hover:not(:disabled) {
background: rgba(48, 209, 88, 0.14);
color: var(--color-feedback-success);
}
.tb-run-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 200;
background: #2a2a2c;
border: 1px solid #3a3a3c;
border-radius: 10px;
padding: 6px;
min-width: 200px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
gap: 2px;
}
.tb-run-menu-item {
display: flex;
align-items: center;
width: 100%;
padding: 8px 10px;
background: transparent;
border: none;
border-radius: 7px;
color: #ccc;
font-size: 13px;
font-family: inherit;
cursor: pointer;
text-align: left;
white-space: nowrap;
transition:
background 0.12s,
color 0.12s;
}
.tb-run-menu-item:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.tb-run-menu-item:disabled {
opacity: 0.5;
cursor: default;
}
/* Pro pill signals premium items in the overflow menu BEFORE the user
clicks. Used in place of a hidden gate so free users aren't surprised
by an upgrade prompt on what looked like a free button. */
/* ── Error detail bar (for long compiler errors) ── */
.toolbar-error-detail {
background: #1a0000;
border-bottom: 1px solid #5a1a1a;
color: #f48fb1;
font-size: 11px;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
padding: 4px 12px;
max-height: 80px;
overflow-y: auto;
white-space: pre-wrap;
line-height: 1.4;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,585 @@
.simulator-canvas-container {
display: flex;
height: 100%;
position: relative;
}
.simulator-canvas {
flex: 1;
background-color: #1a1a1a;
color: #e0e0e0;
display: flex;
flex-direction: column;
}
/* ── Canvas header ───────────────────────────────── */
.canvas-header {
height: 46px;
padding: 0 10px;
background: #252526;
border-bottom: 1px solid #333;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
/* When portaled into the unified top toolbar, drop the standalone chrome
(border + background) and shrink to match the editor toolbar height so
the two halves read as a single bar. Also drop space-between so the
left/right groups sit adjacent to each other inside the slot. */
.canvas-header--portaled {
height: 38px;
padding: 0 8px;
background: transparent;
border-bottom: none;
justify-content: flex-start;
gap: 8px;
}
.canvas-header-left,
.canvas-header-right {
display: flex;
align-items: center;
gap: 8px;
}
/* ── Status LED dot ──────────────────────────────── */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
transition:
background 0.3s,
box-shadow 0.3s;
}
.status-dot.running {
background: #4caf50;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.7);
animation: pulse-green 2s ease-in-out infinite;
}
.status-dot.stopped {
background: #555;
box-shadow: none;
}
@keyframes pulse-green {
0%,
100% {
box-shadow: 0 0 4px rgba(76, 175, 80, 0.5);
}
50% {
box-shadow: 0 0 10px rgba(76, 175, 80, 0.9);
}
}
/* ── Board Selector ──────────────────────────────── */
.board-selector {
padding: 4px 8px;
background: #1e2a38;
color: #c9d1d9;
border: 1px solid #3a4a5a;
border-radius: 5px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: border-color 0.15s;
height: 28px;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 6px center;
padding-right: 22px;
}
.board-selector:hover:not(:disabled) {
border-color: var(--color-action-primary);
}
.board-selector:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Serial Monitor button ───────────────────────── */
.canvas-serial-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 0 10px;
height: 28px;
background: transparent;
border: 1px solid #3a4a5a;
border-radius: 5px;
color: #9d9d9d;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
color 0.15s;
white-space: nowrap;
}
.canvas-serial-btn:hover {
border-color: var(--color-action-primary);
color: #ccc;
}
.canvas-serial-btn-active {
background: #0e3a5a;
border-color: var(--color-action-primary);
color: var(--color-action-primary);
}
/* ── Canvas icon-only buttons (undo/redo, …) ─────── */
.canvas-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: 1px solid #3a4a5a;
border-radius: 5px;
color: #9d9d9d;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
color 0.15s,
opacity 0.15s;
flex-shrink: 0;
}
.canvas-icon-btn:hover:not(:disabled) {
border-color: var(--color-action-primary);
color: #ccc;
}
.canvas-icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Component count ─────────────────────────────── */
.component-count {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #666;
}
/* ── Add Component button ────────────────────────── */
.add-component-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 0 10px;
height: 28px;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: 5px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.add-component-btn:hover:not(:disabled) {
background: var(--color-action-primary-hover);
}
.add-component-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* ── Canvas content (viewport) ───────────────────── */
.canvas-content {
flex: 1;
position: relative;
overflow: hidden;
background-color: #1a1a1a;
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 19px,
rgba(255, 255, 255, 0.025) 19px,
rgba(255, 255, 255, 0.025) 20px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 19px,
rgba(255, 255, 255, 0.025) 19px,
rgba(255, 255, 255, 0.025) 20px
);
user-select: none;
touch-action: none; /* Disable browser scroll/zoom so JS handlers take full control */
}
/* ── Infinite canvas world ───────────────────────── */
.canvas-world {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
width: 4000px;
height: 3000px;
}
/* ── Components area ─────────────────────────────── */
.components-area {
position: absolute;
top: 0;
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 ───────────────────────────────── */
.zoom-controls {
display: flex;
align-items: center;
gap: 2px;
background: #1e1e1e;
border: 1px solid #3a3a3a;
border-radius: 5px;
padding: 0 2px;
height: 28px;
}
.zoom-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
border-radius: 3px;
color: #9d9d9d;
cursor: pointer;
flex-shrink: 0;
}
.zoom-btn:hover {
background: #3a3a3a;
color: #ccc;
}
.zoom-level {
min-width: 42px;
text-align: center;
background: transparent;
border: none;
color: #9d9d9d;
font-size: 11px;
font-family: monospace;
cursor: pointer;
padding: 0 2px;
border-radius: 3px;
}
.zoom-level:hover {
background: #3a3a3a;
color: #ccc;
}
.component-label {
font-size: 11px;
background-color: #252526;
padding: 3px 8px;
border-radius: 3px;
white-space: nowrap;
margin-top: 5px;
text-align: center;
color: #aaa;
}
/* ── Wire creation mode banner ──────────────────── */
.wire-mode-banner {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
background: rgba(0, 113, 227, 0.92);
color: #fff;
padding: 8px 16px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
white-space: nowrap;
pointer-events: auto;
}
.wire-mode-banner button {
background: rgba(255, 255, 255, 0.2);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 4px;
padding: 4px 14px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: background 0.15s;
}
.wire-mode-banner button:hover {
background: rgba(255, 255, 255, 0.35);
}
/* ── Mobile-friendly adjustments ────────────────── */
@media (max-width: 768px) {
.canvas-header {
height: 42px;
padding: 0 6px;
}
.canvas-header-left,
.canvas-header-right {
gap: 4px;
}
.canvas-serial-btn {
padding: 0 6px;
font-size: 0; /* hide text, show icon only */
}
.canvas-serial-btn svg {
width: 18px;
height: 18px;
}
.add-component-btn {
padding: 0 8px;
font-size: 0; /* hide text, show icon only */
}
.add-component-btn svg {
width: 18px;
height: 18px;
}
.board-selector {
font-size: 11px;
max-width: 90px;
padding: 4px 20px 4px 6px;
}
.zoom-controls {
display: none; /* Use pinch-to-zoom on mobile */
}
.component-count {
display: none;
}
.wire-mode-banner {
font-size: 12px;
padding: 6px 12px;
gap: 8px;
bottom: 8px;
}
}
/* Narrow shared toolbar (desktop, AI chat open)
When the canvas header is portaled into the unified top toolbar, it shares
one row with the editor actions. As that *bar* narrows (mainly when the
right-docked AI chat opens), collapse the canvas controls the same way the
mobile block does but keyed to the bar's own width via a container query,
not the viewport, so it triggers on a wide screen with the chat open too.
Priority: labels -> icons, then drop the non-interactive count, then the
zoom buttons (mouse-wheel zoom still works). Mirrors the thresholds the
editor toolbar and view-mode toggle collapse at, so the whole bar degrades
together and nothing overlaps. */
@container unified-toolbar (max-width: 1010px) {
.canvas-serial-btn {
padding: 0 8px;
font-size: 0; /* hide "Serial" / "Scope" text, keep the icon */
}
.canvas-serial-btn svg {
width: 18px;
height: 18px;
}
.add-component-btn {
padding: 0 9px;
font-size: 0; /* hide "Add" text, keep the icon */
}
.add-component-btn svg {
width: 18px;
height: 18px;
}
.board-selector {
max-width: 120px;
text-overflow: ellipsis;
overflow: hidden;
}
}
@container unified-toolbar (max-width: 905px) {
.component-count {
display: none;
}
.board-selector {
max-width: 96px;
}
}
@container unified-toolbar (max-width: 835px) {
.zoom-controls {
display: none; /* mouse-wheel / trackpad zoom still available */
}
}
/* ── WiFi / BLE status badges ─────────────────────────────────────────── */
.canvas-wifi-badge,
.canvas-ble-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 4px;
cursor: default;
vertical-align: middle;
}
.canvas-wifi-badge svg,
.canvas-ble-badge svg {
display: block;
}
/* WiFi status colours */
.canvas-wifi-initializing {
color: #f59e0b;
}
.canvas-wifi-connected {
color: #f59e0b;
}
.canvas-wifi-got_ip {
color: #22c55e;
}
.canvas-wifi-disconnected {
color: #6b7280;
}
.canvas-wifi-clickable {
cursor: pointer !important;
transition:
transform 0.2s,
filter 0.2s;
}
.canvas-wifi-clickable:hover {
transform: scale(1.15);
filter: drop-shadow(0 0 5px currentColor);
}
.canvas-wifi-clickable:active {
transform: scale(0.95);
}
/* BLE status colours */
.canvas-ble-initialized {
color: var(--color-action-primary);
}
.canvas-ble-advertising {
color: #6366f1;
}
/* ── Selection action bar (floating toolbar for selected wire/component/board) ─── */
.selection-action-bar button:hover {
background: #2a2d2e !important;
border-color: #3c3c3c !important;
}
.selection-action-bar button:active {
transform: scale(0.97);
}
.selection-action-bar button:focus-visible {
outline: 2px solid var(--color-action-primary);
outline-offset: 1px;
}
/* Hide button labels on narrow viewports — keep just the icons */
@media (max-width: 520px) {
.selection-action-bar .selection-action-bar__label {
display: none;
}
}
/* Runtime burnout (P4): a component destroyed mid-simulation renders charred,
with a red dashed outline + a smoke badge (the badge is a sibling of the
container, so it stays full-colour). Cleared on Reset. */
.velxio-burnt .web-component-container {
filter: grayscale(0.85) brightness(0.4) sepia(0.5) contrast(1.15);
transition: filter 0.25s ease;
}
.velxio-burnt {
outline: 1px dashed rgba(220, 38, 38, 0.65);
outline-offset: 1px;
border-radius: 4px;
}
/* Selected component: marching ants.
A newly added part lands in a corner of the viewport and the previous
flat dashed border was easy to miss on a busy canvas a moving outline
is what the eye actually catches. Drawn on a pseudo-element just OUTSIDE
the body so it never covers the artwork, and pointer-events: none so it
never steals a click or a pin.
Animating a dashed border is not possible (dash offset is not an
animatable property), hence the four gradient edges with a moving
background-position the standard recipe. */
.velxio-ants::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 5px;
pointer-events: none;
z-index: 6;
background-image:
linear-gradient(90deg, var(--ant-color) 50%, transparent 0),
linear-gradient(90deg, var(--ant-color) 50%, transparent 0),
linear-gradient(0deg, var(--ant-color) 50%, transparent 0),
linear-gradient(0deg, var(--ant-color) 50%, transparent 0);
background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
background-size: 10px 2px, 10px 2px, 2px 10px, 2px 10px;
background-position: 0 0, 0 100%, 0 0, 100% 0;
animation: velxio-ants 0.55s linear infinite;
}
.velxio-ants {
--ant-color: #2f9bff;
}
@keyframes velxio-ants {
to {
background-position: 10px 0, -10px 100%, 0 -10px, 100% 10px;
}
}
/* Respect the OS setting: the outline still marks the selection, it just
stops moving. */
@media (prefers-reduced-motion: reduce) {
.velxio-ants::after {
animation: none;
}
}

View File

@ -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);
}
}
@ -371,6 +370,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<string | null>(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 +938,22 @@ 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) {
// 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 =
boardRunning && b
? st.components.find((c) => isBoardSeated(b.id, b.boardKind, b.x, b.y, [c]))
: 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 +1616,23 @@ 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) {
// 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 =
boardRunning && b
? st.components.find((c) => isBoardSeated(b.id, b.boardKind, b.x, b.y, [c]))
: 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 +1964,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
recalculateAllWirePositions();
setDraggedComponentId(null);
raisedThisDragRef.current = null;
carriedSocketRef.current = null;
}
};
@ -2911,6 +2936,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
setPan({ ...panRef.current });
setDraggedComponentId(null);
raisedThisDragRef.current = null;
carriedSocketRef.current = null;
}}
onContextMenu={(e) => {
e.preventDefault();

View File

@ -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: '<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="8" y="12" width="50" height="40" rx="6" fill="#1d3a5f"/><circle cx="36" cy="32" r="12" fill="#0c0d10" stroke="#3a3f49" stroke-width="2"/><circle cx="36" cy="32" r="6" fill="#1b1030"/></svg>',
thumbnailSvg: '<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect x="17" y="4" width="30" height="56" rx="5" fill="#12518f" stroke="#0d3c6b"/><circle cx="34" cy="18" r="8" fill="#0a0b0e" stroke="#3a3f49"/><circle cx="34" cy="18" r="4" fill="#1b1030"/><rect x="21" y="30" width="7" height="24" rx="1.5" fill="#15171c"/><rect x="38" y="30" width="7" height="24" rx="1.5" fill="#15171c"/><rect x="8" y="12" width="8" height="14" rx="2" fill="#e8e8ec"/></svg>',
},
{
id: 'respeaker-lite',

View File

@ -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<SimulatorState>((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 };

View File

@ -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;
}