feat(ui): the Part Inspector - right-click dialog merged with the picker card
The right-click dialog was a 250px column: every pin as a text row (an ESP32
means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info
card (datasheet, brand, Buy) was a separate surface you could only reach by
hovering the catalogue. One dialog now serves both:
- ~560px, two panes: live preview + actions on the left, Properties/Datasheet
tabs on the right. The Datasheet tab IS the picker card's body -
ComponentInfoPanel was split into an embeddable ComponentInfoBody (+
useComponentDoc, and its .cip-* styles moved to their own stylesheet) so
both surfaces render one implementation.
- "Show pins" (off by default): the preview is the REAL web component,
mounted and scaled, with pin markers at their true pinInfo positions and
labels flanking the side each pin lives on. pinInspectorLayout is pure
geometry - nearest-edge classification, per-side label stacking with a
minimum spacing and leader lines when nudged, interior pins as
dot+tooltip - driven only by pinInfo plus the measured element size, so
every part and board, present or future, works with no per-part data.
Dots are tinted by the pin's declared signal (i2c/spi/power/...).
- Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so
wires start with the same undo, colors and running-state gating.
- The two open gestures finally agree on coordinates: right-click always
passed viewport coords while touch passed canvas-local ones; the dialog is
now portaled to body in viewport space and the touch site converts.
- Keybind capture, the SD Card panel, the two-step delete, Escape and
click-outside all migrate unchanged. Mobile keeps the full-sheet layout.
Geometry covered by pin-inspector-layout.test.ts with coordinate tables
copied verbatim from the shipped parts (ESP32 two-column board, LED, an
interior-pin case).
2026-08-04 02:44:46 +07:00
|
|
|
/**
|
|
|
|
|
* pinInspectorLayout — the Part Inspector's spatial pin geometry.
|
|
|
|
|
*
|
|
|
|
|
* The coordinate tables here are copied VERBATIM from the real elements (the
|
|
|
|
|
* file/line each block cites), not invented: the point of the suite is that
|
|
|
|
|
* the classifier reads actual shipped parts sensibly. If a part's pinInfo
|
|
|
|
|
* changes upstream, refresh the copy.
|
|
|
|
|
*/
|
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
|
import {
|
|
|
|
|
layoutInspectorPins,
|
|
|
|
|
signalKindOf,
|
|
|
|
|
type InspectorPinInput,
|
|
|
|
|
} from '../utils/pinInspectorLayout';
|
|
|
|
|
|
|
|
|
|
// components/velxio-components/Esp32Element.ts:31-68 (PINS_ESP32), board
|
|
|
|
|
// natural size 141x265 (BOARD_CONFIGS.esp32). Two vertical columns: left
|
|
|
|
|
// x=6, right x=134. Silk aliases (16/RX2, 17/TX2) share coordinates.
|
|
|
|
|
const ESP32_PINS: InspectorPinInput[] = [
|
|
|
|
|
{ name: 'EN', x: 6, y: 29 },
|
|
|
|
|
{ name: 'VN', x: 6, y: 42 },
|
|
|
|
|
{ name: 'VP', x: 6, y: 54 },
|
|
|
|
|
{ name: '34', x: 6, y: 67 },
|
|
|
|
|
{ name: '35', x: 6, y: 80 },
|
|
|
|
|
{ name: '32', x: 6, y: 93 },
|
|
|
|
|
{ name: '33', x: 6, y: 105 },
|
|
|
|
|
{ name: '25', x: 6, y: 118 },
|
|
|
|
|
{ name: '26', x: 6, y: 131 },
|
|
|
|
|
{ name: '27', x: 6, y: 143 },
|
|
|
|
|
{ name: '14', x: 6, y: 156 },
|
|
|
|
|
{ name: '12', x: 6, y: 169 },
|
|
|
|
|
{ name: '13', x: 6, y: 181 },
|
|
|
|
|
{ name: 'GND', x: 6, y: 194 },
|
|
|
|
|
{ name: 'VIN', x: 6, y: 207 },
|
|
|
|
|
{ name: '3V3', x: 134, y: 207 },
|
|
|
|
|
{ name: 'GND2', x: 134, y: 194 },
|
|
|
|
|
{ name: '15', x: 134, y: 181 },
|
|
|
|
|
{ name: '2', x: 134, y: 169 },
|
|
|
|
|
{ name: '4', x: 134, y: 156 },
|
|
|
|
|
{ name: 'RX2', x: 134, y: 143 },
|
|
|
|
|
{ name: 'TX2', x: 134, y: 131 },
|
|
|
|
|
{ name: '5', x: 134, y: 118 },
|
|
|
|
|
{ name: '18', x: 134, y: 105 },
|
|
|
|
|
{ name: '19', x: 134, y: 93 },
|
|
|
|
|
{ name: '21', x: 134, y: 80 },
|
|
|
|
|
{ name: 'RX0', x: 134, y: 67 },
|
|
|
|
|
{ name: 'TX0', x: 134, y: 54 },
|
|
|
|
|
{ name: '22', x: 134, y: 42 },
|
|
|
|
|
{ name: '23', x: 134, y: 29 },
|
|
|
|
|
];
|
|
|
|
|
const ESP32_SIZE = { width: 141, height: 265 };
|
|
|
|
|
|
|
|
|
|
// @wokwi/elements led-element.js:28-34 (unflipped), element box 40x50.
|
|
|
|
|
const LED_PINS: InspectorPinInput[] = [
|
|
|
|
|
{ name: 'A', x: 25, y: 42 },
|
|
|
|
|
{ name: 'C', x: 15, y: 42 },
|
|
|
|
|
];
|
|
|
|
|
const LED_SIZE = { width: 40, height: 50 };
|
|
|
|
|
|
|
|
|
|
describe('layoutInspectorPins — the ESP32 board (the 30-pin scroll case)', () => {
|
|
|
|
|
const r = layoutInspectorPins(ESP32_PINS, ESP32_SIZE);
|
|
|
|
|
|
|
|
|
|
it('classifies every pin left or right, none interior', () => {
|
|
|
|
|
for (const p of r.pins) {
|
|
|
|
|
expect(['left', 'right']).toContain(p.edge);
|
|
|
|
|
}
|
|
|
|
|
expect(r.pins.filter((p) => p.edge === 'left')).toHaveLength(15);
|
|
|
|
|
expect(r.pins.filter((p) => p.edge === 'right')).toHaveLength(15);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps a minimum spacing between labels on the same side', () => {
|
|
|
|
|
for (const edge of ['left', 'right'] as const) {
|
|
|
|
|
const ys = r.pins
|
|
|
|
|
.filter((p) => p.edge === edge)
|
|
|
|
|
.map((p) => p.labelY)
|
|
|
|
|
.sort((a, b) => a - b);
|
|
|
|
|
for (let i = 1; i < ys.length; i++) {
|
|
|
|
|
expect(ys[i] - ys[i - 1]).toBeGreaterThanOrEqual(16 - 1e-6);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('preserves the dot y-order in the label order per side', () => {
|
|
|
|
|
for (const edge of ['left', 'right'] as const) {
|
|
|
|
|
const group = r.pins.filter((p) => p.edge === edge).sort((a, b) => a.dotY - b.dotY);
|
|
|
|
|
for (let i = 1; i < group.length; i++) {
|
|
|
|
|
expect(group[i].labelY).toBeGreaterThan(group[i - 1].labelY);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reserves side gutters and no top/bottom gutters', () => {
|
|
|
|
|
expect(r.padLeft).toBeGreaterThan(0);
|
|
|
|
|
expect(r.padRight).toBeGreaterThan(0);
|
|
|
|
|
expect(r.padTop).toBe(0);
|
|
|
|
|
expect(r.padBottom).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('scales the art into the default box', () => {
|
|
|
|
|
expect(r.artHeight).toBeLessThanOrEqual(260);
|
|
|
|
|
expect(r.scale).toBeCloseTo(260 / 265, 5);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('layoutInspectorPins — small parts', () => {
|
|
|
|
|
it('puts the LED legs on the bottom edge', () => {
|
|
|
|
|
const r = layoutInspectorPins(LED_PINS, LED_SIZE);
|
|
|
|
|
for (const p of r.pins) expect(p.edge).toBe('bottom');
|
|
|
|
|
expect(r.padBottom).toBeGreaterThan(0);
|
|
|
|
|
expect(r.padLeft).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('caps upscaling at 2x so a tiny part is not a blur', () => {
|
|
|
|
|
const r = layoutInspectorPins(LED_PINS, { width: 20, height: 10 });
|
|
|
|
|
expect(r.scale).toBe(2);
|
|
|
|
|
expect(r.artWidth).toBe(40);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles zero pins and one pin', () => {
|
|
|
|
|
const none = layoutInspectorPins([], LED_SIZE);
|
|
|
|
|
expect(none.pins).toHaveLength(0);
|
|
|
|
|
expect(none.padLeft + none.padRight + none.padTop + none.padBottom).toBe(0);
|
|
|
|
|
|
|
|
|
|
const one = layoutInspectorPins([{ name: 'OUT', x: 2, y: 25 }], { width: 50, height: 50 });
|
|
|
|
|
expect(one.pins[0].edge).toBe('left');
|
|
|
|
|
expect(one.pins[0].needsLeader).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-04 03:04:43 +07:00
|
|
|
describe('layoutInspectorPins — a crowded header fits its edge', () => {
|
|
|
|
|
it('packs 8 top pins inside the art width (they are drawn as vertical text)', () => {
|
|
|
|
|
// The SSD1306's 8-pin header measured 240px wide in the dialog. With
|
|
|
|
|
// horizontal labels this demanded ~288px and the labels escaped the
|
|
|
|
|
// dialog; vertical labels use the side spacing instead.
|
|
|
|
|
const pins: InspectorPinInput[] = Array.from({ length: 8 }, (_, i) => ({
|
|
|
|
|
name: ['DATA', 'CLK', 'DC', 'RST', 'CS', '3V3', 'VIN', 'GND'][i],
|
|
|
|
|
x: 14 + i * 13,
|
|
|
|
|
y: 2,
|
|
|
|
|
}));
|
|
|
|
|
const r = layoutInspectorPins(pins, { width: 128, height: 119 });
|
|
|
|
|
for (const p of r.pins) expect(p.edge).toBe('top');
|
|
|
|
|
const xs = r.pins.map((p) => p.labelX).sort((a, b) => a - b);
|
|
|
|
|
expect(xs[0]).toBeGreaterThanOrEqual(0);
|
|
|
|
|
expect(xs[xs.length - 1]).toBeLessThanOrEqual(r.artWidth);
|
|
|
|
|
for (let i = 1; i < xs.length; i++) {
|
|
|
|
|
expect(xs[i] - xs[i - 1]).toBeGreaterThanOrEqual(16 - 1e-6);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-04 04:04:37 +07:00
|
|
|
describe('layoutInspectorPins — inset columns (the ReSpeaker Lite case)', () => {
|
|
|
|
|
// pro/.../SeeedElements.ts ReSpeakerLiteElement.pinInfo, body 178x437.
|
|
|
|
|
// The XIAO socket is TWO INSET COLUMNS (x=50.5 / 126.5 on a 178-wide body)
|
|
|
|
|
// plus two breakout pad columns at x=10. A nearest-edge rule called most of
|
|
|
|
|
// these "interior" and printed no label, so the part looked like it was
|
|
|
|
|
// missing pins. Every pin must get a label.
|
|
|
|
|
const RESPEAKER: InspectorPinInput[] = [
|
|
|
|
|
...['D0', 'D1', 'D2', 'D3', 'D4', 'D5', 'D6'].map((name, i) => ({
|
|
|
|
|
name,
|
|
|
|
|
x: 50.5,
|
|
|
|
|
y: [43, 55, 68, 81, 93, 106, 119][i],
|
|
|
|
|
})),
|
|
|
|
|
...['D7', 'D8', 'D9', 'D10', '3V3', 'GND', '5V'].map((name, i) => ({
|
|
|
|
|
name,
|
|
|
|
|
x: 126.5,
|
|
|
|
|
y: [43, 55, 68, 81, 93, 106, 119][i],
|
|
|
|
|
})),
|
|
|
|
|
...['BTN1', 'BTN2', 'BTN3'].map((name, i) => ({ name, x: 10, y: 214 + i * 15 })),
|
|
|
|
|
...['IO1', 'IO2', 'IO3', 'IO4'].map((name, i) => ({ name, x: 10, y: 289 + i * 13 })),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const r = layoutInspectorPins(RESPEAKER, { width: 178, height: 437 });
|
|
|
|
|
|
|
|
|
|
it('labels every single pin — none silently dropped', () => {
|
|
|
|
|
expect(r.pins).toHaveLength(RESPEAKER.length);
|
|
|
|
|
const named = new Set(r.pins.map((p) => p.name));
|
|
|
|
|
for (const p of RESPEAKER) expect(named.has(p.name)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps each inset column on the side of the body it sits on', () => {
|
|
|
|
|
const sideOf = (n: string) => r.pins.find((p) => p.name === n)!.edge;
|
|
|
|
|
for (const n of ['D0', 'D3', 'D6']) expect(sideOf(n)).toBe('left');
|
|
|
|
|
for (const n of ['D7', '3V3', '5V']) expect(sideOf(n)).toBe('right');
|
|
|
|
|
for (const n of ['BTN1', 'IO4']) expect(sideOf(n)).toBe('left');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('draws a leader from every inset dot to its gutter label', () => {
|
|
|
|
|
for (const n of ['D0', 'D7', '3V3']) {
|
|
|
|
|
expect(r.pins.find((p) => p.name === n)!.needsLeader).toBe(true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('never puts a column end-pin in the top or bottom gutter', () => {
|
|
|
|
|
expect(r.pins.some((p) => p.edge === 'top' || p.edge === 'bottom')).toBe(false);
|
feat(ui): the Part Inspector - right-click dialog merged with the picker card
The right-click dialog was a 250px column: every pin as a text row (an ESP32
means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info
card (datasheet, brand, Buy) was a separate surface you could only reach by
hovering the catalogue. One dialog now serves both:
- ~560px, two panes: live preview + actions on the left, Properties/Datasheet
tabs on the right. The Datasheet tab IS the picker card's body -
ComponentInfoPanel was split into an embeddable ComponentInfoBody (+
useComponentDoc, and its .cip-* styles moved to their own stylesheet) so
both surfaces render one implementation.
- "Show pins" (off by default): the preview is the REAL web component,
mounted and scaled, with pin markers at their true pinInfo positions and
labels flanking the side each pin lives on. pinInspectorLayout is pure
geometry - nearest-edge classification, per-side label stacking with a
minimum spacing and leader lines when nudged, interior pins as
dot+tooltip - driven only by pinInfo plus the measured element size, so
every part and board, present or future, works with no per-part data.
Dots are tinted by the pin's declared signal (i2c/spi/power/...).
- Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so
wires start with the same undo, colors and running-state gating.
- The two open gestures finally agree on coordinates: right-click always
passed viewport coords while touch passed canvas-local ones; the dialog is
now portaled to body in viewport space and the touch site converts.
- Keybind capture, the SD Card panel, the two-step delete, Escape and
click-outside all migrate unchanged. Mobile keeps the full-sheet layout.
Geometry covered by pin-inspector-layout.test.ts with coordinate tables
copied verbatim from the shipped parts (ESP32 two-column board, LED, an
interior-pin case).
2026-08-04 02:44:46 +07:00
|
|
|
});
|
2026-08-04 04:20:49 +07:00
|
|
|
|
|
|
|
|
it('grows the box so a stack taller than the body is not clipped', () => {
|
|
|
|
|
// 13 labels on the left at >=16px need more room than the scaled body,
|
|
|
|
|
// so the first and last used to be cut off by the preview's clip.
|
|
|
|
|
const top = -r.padTop;
|
|
|
|
|
const bottom = r.artHeight + r.padBottom;
|
|
|
|
|
for (const p of r.pins) {
|
|
|
|
|
expect(p.labelY).toBeGreaterThanOrEqual(top);
|
|
|
|
|
expect(p.labelY).toBeLessThanOrEqual(bottom);
|
|
|
|
|
}
|
|
|
|
|
});
|
feat(ui): the Part Inspector - right-click dialog merged with the picker card
The right-click dialog was a 250px column: every pin as a text row (an ESP32
means 30 rows of scrolling), a few editors, Rotate/Delete. The picker's info
card (datasheet, brand, Buy) was a separate surface you could only reach by
hovering the catalogue. One dialog now serves both:
- ~560px, two panes: live preview + actions on the left, Properties/Datasheet
tabs on the right. The Datasheet tab IS the picker card's body -
ComponentInfoPanel was split into an embeddable ComponentInfoBody (+
useComponentDoc, and its .cip-* styles moved to their own stylesheet) so
both surfaces render one implementation.
- "Show pins" (off by default): the preview is the REAL web component,
mounted and scaled, with pin markers at their true pinInfo positions and
labels flanking the side each pin lives on. pinInspectorLayout is pure
geometry - nearest-edge classification, per-side label stacking with a
minimum spacing and leader lines when nudged, interior pins as
dot+tooltip - driven only by pinInfo plus the measured element size, so
every part and board, present or future, works with no per-part data.
Dots are tinted by the pin's declared signal (i2c/spi/power/...).
- Clicking a pin keeps the old contract (onPinSelect -> handlePinClick), so
wires start with the same undo, colors and running-state gating.
- The two open gestures finally agree on coordinates: right-click always
passed viewport coords while touch passed canvas-local ones; the dialog is
now portaled to body in viewport space and the touch site converts.
- Keybind capture, the SD Card panel, the two-step delete, Escape and
click-outside all migrate unchanged. Mobile keeps the full-sheet layout.
Geometry covered by pin-inspector-layout.test.ts with coordinate tables
copied verbatim from the shipped parts (ESP32 two-column board, LED, an
interior-pin case).
2026-08-04 02:44:46 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('signalKindOf', () => {
|
|
|
|
|
it('maps the wokwi signal shapes', () => {
|
|
|
|
|
expect(signalKindOf({ name: 'SDA', x: 0, y: 0, signals: [{ type: 'i2c', signal: 'SDA' }] })).toBe('i2c');
|
|
|
|
|
expect(signalKindOf({ name: 'GND', x: 0, y: 0, signals: [{ type: 'power', signal: 'GND' }] })).toBe('power-gnd');
|
|
|
|
|
expect(signalKindOf({ name: 'VCC', x: 0, y: 0, signals: [{ type: 'power', signal: 'VCC' }] })).toBe('power-vcc');
|
|
|
|
|
expect(signalKindOf({ name: 'D1', x: 0, y: 0 })).toBe('other');
|
|
|
|
|
expect(signalKindOf({ name: 'D1', x: 0, y: 0, signals: [] })).toBe('other');
|
|
|
|
|
});
|
|
|
|
|
});
|