diff --git a/CLAUDE.md b/CLAUDE.md index d2def2fc..33de214b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -307,6 +307,61 @@ declare global { } ``` +### 6a. Boards/components MUST be Web Components, not React SVG ⚠️ + +The wire system reads pin coordinates via `element.pinInfo` from the rendered +DOM node (`frontend/src/utils/pinPositionCalculator.ts:38`). This **only** +works for real DOM custom elements (Web Components) — a plain React `` +component has no `pinInfo`, so every wire endpoint silently falls back to +`(0, 0)` of the board and visually attaches to the **corner** instead of the +pin. The user has reported this exact symptom multiple times. + +**Rule:** any board or component that needs wire connections must be a Web +Component (`class Foo extends HTMLElement`) with a `pinInfo` getter. The +React `.tsx` file is a thin wrapper. + +Reference implementations: +- `frontend/src/components/velxio-components/Esp32Element.ts` (board) +- `frontend/src/components/velxio-components/PiPicoWElement.ts` (board) +- `frontend/src/components/velxio-components/Attiny85Element.ts` (board) +- `frontend/src/components/velxio-components/Bmp280Element.ts` (component) + +Required shape: +```ts +class FooElement extends HTMLElement { + constructor() { super(); this.attachShadow({ mode: 'open' }); } + connectedCallback() { this.render(); } + get pinInfo() { + // Pin tip coordinates in CSS pixels relative to element top-left. + // `name` must match what examples reference in wires. + return [ + { name: 'GP0', x: 6, y: 24, description: 'UART0 TX' }, + // … + ]; + } + private render() { /* shadowRoot.innerHTML = ... */ } +} +if (!customElements.get('velxio-foo')) { + customElements.define('velxio-foo', FooElement); +} +``` + +The `.tsx` wrapper: +```tsx +import './FooElement'; +declare global { + namespace JSX { interface IntrinsicElements { 'velxio-foo': any; } } +} +export const Foo = ({ id, x, y }: Props) => ( + +); +``` + +**Verification before claiming a board/component is done:** load an example +that wires to it, confirm wires terminate on the pin tips (not the corner), +and add the pin coords to `BoardOnCanvas.tsx`'s `BOARD_SIZE` table if it's a +board. + ### 7. Pre-existing TypeScript Errors There are known pre-existing TS errors that do NOT block the app from running: diff --git a/frontend/src/components/velxio-components/Attiny85.tsx b/frontend/src/components/velxio-components/Attiny85.tsx index 995d6df6..1a48796d 100644 --- a/frontend/src/components/velxio-components/Attiny85.tsx +++ b/frontend/src/components/velxio-components/Attiny85.tsx @@ -1,162 +1,42 @@ /** - * ATtiny85 visual component — DIP-8 package (Digispark-style layout) + * ATtiny85 — React wrapper around the `velxio-attiny85` Web Component. * - * Pin layout (DIP-8): - * Left side (top to bottom): PB5/RST, PB3, PB4, GND - * Right side (bottom to top): PB0, PB1, PB2, VCC - * - * Built-in LED on PB1 (standard Digispark LED pin). + * The wire system reads `pinInfo` directly from the rendered DOM element, + * so the actual SVG + pin coordinates live in `Attiny85Element.ts`. This + * file just renders the custom element and forwards the `led1` prop. */ +import './Attiny85Element'; +import { useEffect, useRef } from 'react'; interface Attiny85Props { id?: string; x?: number; y?: number; - /** State of PB1 (built-in LED pin on Digispark) */ + /** State of PB1 (built-in LED on Digispark) */ led1?: boolean; } -// DIP-8 dimensions -const W = 160; // total SVG width -const H = 100; // total SVG height -const BX = 30; // chip body left -const BY = 10; // chip body top -const BW = 100; // chip body width -const BH = 80; // chip body height -const PIN_W = 28; // pin stub length (horizontal) -const PIN_SPACING = 20; // vertical spacing between pins - -// 4 pins on each side, centred vertically in the body -const PIN_STARTS_Y = [BY + 10, BY + 30, BY + 50, BY + 70]; - -const PIN_LABELS_LEFT = ['PB5/RST', 'PB3', 'PB4', 'GND']; -const PIN_LABELS_RIGHT = ['VCC', 'PB2', 'PB1', 'PB0']; +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace JSX { + interface IntrinsicElements { + 'velxio-attiny85': any; + } + } +} export const Attiny85 = ({ id = 'attiny85', x = 0, y = 0, led1 = false }: Attiny85Props) => { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) (ref.current as any).led1 = led1; + }, [led1]); + return ( - - {/* Pin stubs — left side */} - {PIN_STARTS_Y.map((py, i) => ( - - ))} - - {/* Pin stubs — right side (reversed: bottom=PB0) */} - {PIN_STARTS_Y.map((py, i) => ( - - ))} - - {/* IC body */} - - - {/* Orientation notch (top centre of body) */} - - - {/* Pin 1 dot (bottom-left corner of body → PB5/RST) */} - - - {/* Chip label */} - - ATtiny85 - - - 8-bit AVR - - - {/* Built-in LED (PB1) — right side, 3rd pin from top = index 2 reversed */} - {/* Right pins are bottom-to-top: index 3=PB0, 2=PB1, 1=PB2, 0=VCC */} - - - {/* Pin labels — left */} - {PIN_LABELS_LEFT.map((label, i) => ( - - {label} - - ))} - - {/* Pin labels — right */} - {PIN_LABELS_RIGHT.map((label, i) => ( - - {label} - - ))} - + ref={ref} + style={{ position: 'absolute', left: `${x}px`, top: `${y}px` }} + /> ); }; diff --git a/frontend/src/components/velxio-components/Attiny85Element.ts b/frontend/src/components/velxio-components/Attiny85Element.ts new file mode 100644 index 00000000..4d2ab8d9 --- /dev/null +++ b/frontend/src/components/velxio-components/Attiny85Element.ts @@ -0,0 +1,176 @@ +/** + * ATtiny85 — Web Component (DIP-8 package, Digispark-style) + * + * Exposes the standard `pinInfo` array used by the wire system to position + * wire endpoints on real pins instead of the component corner. + * + * Pin layout (DIP-8): + * Left side (top to bottom): PB5 (RST), PB3, PB4, GND + * Right side (top to bottom): VCC, PB2, PB1, PB0 + * + * The visual layout matches the older React component + * (`Attiny85.tsx` → `Attiny85`), but here we own the DOM so wires can read + * `element.pinInfo`. The `led1` attribute mirrors the PB1 (Digispark LED) + * pin state. + */ + +// DIP-8 dimensions (must match BoardOnCanvas BOARD_SIZE = 160 × 100) +const W = 160; +const H = 100; +const BX = 30; // chip body left +const BY = 10; // chip body top +const BW = 100; // chip body width +const BH = 80; // chip body height +const PIN_W = 28; // pin stub length + +// Vertical pin centres +const PIN_STARTS_Y = [BY + 10, BY + 30, BY + 50, BY + 70]; + +const PIN_LABELS_LEFT = ['PB5', 'PB3', 'PB4', 'GND']; +const PIN_LABELS_RIGHT = ['VCC', 'PB2', 'PB1', 'PB0']; + +/** + * Pin tip coordinates (where wires must attach), in SVG pixels relative to + * the element's top-left corner. The wire system reads these via the + * `pinInfo` property on the rendered DOM element. + */ +const PIN_INFO: ReadonlyArray<{ name: string; x: number; y: number; description?: string }> = [ + // Left column — pin tip = BX - PIN_W + { name: 'PB5', x: BX - PIN_W, y: PIN_STARTS_Y[0], description: 'PB5 / RST' }, + { name: 'PB3', x: BX - PIN_W, y: PIN_STARTS_Y[1] }, + { name: 'PB4', x: BX - PIN_W, y: PIN_STARTS_Y[2] }, + { name: 'GND', x: BX - PIN_W, y: PIN_STARTS_Y[3] }, + // Right column — pin tip = BX + BW + PIN_W + { name: 'VCC', x: BX + BW + PIN_W, y: PIN_STARTS_Y[0] }, + { name: 'PB2', x: BX + BW + PIN_W, y: PIN_STARTS_Y[1] }, + { name: 'PB1', x: BX + BW + PIN_W, y: PIN_STARTS_Y[2], description: 'PB1 / built-in LED' }, + { name: 'PB0', x: BX + BW + PIN_W, y: PIN_STARTS_Y[3] }, +]; + +class Attiny85Element extends HTMLElement { + static get observedAttributes(): string[] { + return ['led1']; + } + + private _led1 = false; + + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.render(); + } + + attributeChangedCallback(name: string, _old: string | null, value: string | null) { + if (name === 'led1') { + this._led1 = value !== null && value !== 'false'; + this.updateLed(); + } + } + + /** Wires use this to find pin coordinates. */ + get pinInfo() { + return PIN_INFO; + } + + /** Property setter so React's `useEffect` path can drive the LED. */ + set led1(v: boolean) { + this._led1 = !!v; + this.updateLed(); + } + get led1(): boolean { + return this._led1; + } + + private updateLed() { + const led = this.shadowRoot?.getElementById('attiny-led1') as unknown as SVGElement | null; + if (!led) return; + if (this._led1) { + led.setAttribute('fill', '#ffee44'); + led.setAttribute('stroke', '#ffcc00'); + led.style.filter = 'drop-shadow(0 0 4px #ffcc00)'; + } else { + led.setAttribute('fill', '#333'); + led.setAttribute('stroke', '#555'); + led.style.filter = 'none'; + } + } + + private render() { + if (!this.shadowRoot) return; + + // Build pin stubs + labels + const leftStubs = PIN_STARTS_Y.map( + (py) => ``, + ).join(''); + const rightStubs = PIN_STARTS_Y.map( + (py) => ``, + ).join(''); + const leftLabels = PIN_LABELS_LEFT.map( + (label, i) => + `${label === 'PB5' ? 'PB5/RST' : label}`, + ).join(''); + const rightLabels = PIN_LABELS_RIGHT.map( + (label, i) => + `${label}`, + ).join(''); + + // Built-in LED on PB1 (right side, 3rd from top → PIN_STARTS_Y[2]) + const ledFill = this._led1 ? '#ffee44' : '#333'; + const ledStroke = this._led1 ? '#ffcc00' : '#555'; + const ledFilter = this._led1 ? 'drop-shadow(0 0 4px #ffcc00)' : 'none'; + + this.shadowRoot.innerHTML = ` + + + + ${leftStubs} + ${rightStubs} + + + + + + + + + + + + ATtiny85 + 8-bit AVR + + + + + + ${leftLabels} + ${rightLabels} + + `; + } +} + +if (!customElements.get('velxio-attiny85')) { + customElements.define('velxio-attiny85', Attiny85Element); +} + +export {};