├── client ├── js │ ├── dom-ready.js │ ├── sim │ │ ├── constants.js │ │ ├── geometry.js │ │ ├── custom-gate-utils.js │ │ ├── vhdl.js │ │ └── snapshot.js │ ├── help-loader.js │ ├── websocket-bridge.js │ └── status-controller.js ├── resources │ ├── gates │ │ ├── input.svg │ │ ├── output.svg │ │ ├── buffer.svg │ │ ├── and.svg │ │ ├── not.svg │ │ ├── nand.svg │ │ ├── or.svg │ │ ├── xor.svg │ │ └── nor.svg │ └── rotate.svg ├── gate-config.json ├── app.js ├── custom-gates │ ├── README.md │ ├── 3AND.json │ └── SR Latch.json ├── initial_state.json ├── index.html ├── help-modal.js ├── help-content-template.html ├── gate-registry.js ├── logic-sim.css └── bespoke.css ├── .gitignore ├── user.vhdl ├── package.json ├── state.json ├── scripts └── export-task-state.js ├── LICENSE ├── test-integration.html ├── AGENTS.md ├── README.md ├── vhdl-serializer.js ├── server.js └── circuit-report.js /client/js/dom-ready.js: -------------------------------------------------------------------------------- 1 | export function onDocumentReady(callback) { 2 | if (document.readyState === 'loading') { 3 | document.addEventListener('DOMContentLoaded', callback, { once: true }); 4 | } else { 5 | callback(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # http://www.westwind.com/reference/os-x/invisibles.html 2 | .DS_Store 3 | .Trashes 4 | *.swp 5 | 6 | logs 7 | *.log 8 | npm-debug.log* 9 | 10 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 11 | node_modules -------------------------------------------------------------------------------- /client/resources/gates/input.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /client/resources/gates/output.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /client/resources/gates/buffer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /client/resources/rotate.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/resources/gates/and.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/resources/gates/not.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /user.vhdl: -------------------------------------------------------------------------------- 1 | -- Logic Circuit Lab export 2 | 3 | library IEEE; 4 | use IEEE.STD_LOGIC_1164.ALL; 5 | 6 | entity logic_circuit_lab is 7 | port ( 8 | test : out STD_LOGIC 9 | ); 10 | end entity logic_circuit_lab; 11 | 12 | architecture behavioral of logic_circuit_lab is 13 | signal and_g1_0 : STD_LOGIC; 14 | signal a : STD_LOGIC; 15 | signal b : STD_LOGIC; 16 | begin 17 | and_g1_0 <= (a) and (b); -- AND 18 | a <= '1'; -- Input A 19 | b <= '0'; -- Input B 20 | test <= and_g1_0; -- Output Test 21 | end architecture behavioral; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logic-circuit-lab", 3 | "version": "0.0.1", 4 | "description": "Embeddable Bespoke-powered logic circuit playground with VHDL exports", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "dev": "node server.js", 9 | "export:task": "node scripts/export-task-state.js" 10 | }, 11 | "keywords": [ 12 | "logic", 13 | "circuit", 14 | "playground", 15 | "bespoke", 16 | "vhdl" 17 | ], 18 | "author": "", 19 | "license": "MIT", 20 | "dependencies": { 21 | "ws": "^8.14.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/resources/gates/nand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /client/gate-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultZoom": 1.5, 3 | "paletteOrder": [ 4 | "input", 5 | "output", 6 | "buffer", 7 | "not", 8 | "and", 9 | "nand", 10 | "or", 11 | "nor", 12 | "xor" 13 | ], 14 | "exportReport": { 15 | "enabled": true, 16 | "sections": { 17 | "summary": true, 18 | "gateCounts": true, 19 | "gatePositions": true, 20 | "spatialMetrics": true, 21 | "connectionSummary": true, 22 | "floatingPins": true, 23 | "truthTable": true 24 | }, 25 | "truthTable": { 26 | "maxInputs": 6, 27 | "maxRows": 64 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/resources/gates/or.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/resources/gates/xor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /client/resources/gates/nor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /client/app.js: -------------------------------------------------------------------------------- 1 | import { onDocumentReady } from './js/dom-ready.js'; 2 | import { createStatusController } from './js/status-controller.js'; 3 | import { initializeHelpModal } from './js/help-loader.js'; 4 | import { initializeWebSocketBridge } from './js/websocket-bridge.js'; 5 | 6 | import './gate-registry.js'; 7 | import './logic-sim.js'; 8 | 9 | const statusController = createStatusController(); 10 | window.setStatus = statusController.setStatus; 11 | 12 | onDocumentReady(async () => { 13 | statusController.attach(document.getElementById('status')); 14 | 15 | await initializeHelpModal({ 16 | triggerSelector: '#btn-help', 17 | statusController 18 | }); 19 | 20 | initializeWebSocketBridge({ 21 | onExportRequest: () => { 22 | if (window.logicSim && typeof window.logicSim.exportToVhdl === 'function') { 23 | window.logicSim.exportToVhdl(); 24 | } 25 | } 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /client/js/sim/constants.js: -------------------------------------------------------------------------------- 1 | export const STORAGE_KEY = 'logic-circuit-lab-state-v1'; 2 | export const BASE_ICON_SIZE = 64; 3 | export const WORKSPACE_SIZE = 6000; 4 | export const DEFAULT_SCALE = 1.5; 5 | export const MIN_SCALE = 0.5; 6 | export const MAX_SCALE = 2.75; 7 | export const PORT_SIZE = 8; 8 | export const GRID_SIZE = 16; 9 | export const SAVE_DEBOUNCE = 500; 10 | export const RETRY_DELAY = 3000; 11 | export const GATE_SCALE = 1; 12 | export const GATE_PIXEL_SIZE = BASE_ICON_SIZE * GATE_SCALE; 13 | export const HALF_WORKSPACE = WORKSPACE_SIZE / 2; 14 | export const WORLD_MIN_X = -HALF_WORKSPACE; 15 | export const WORLD_MAX_X = HALF_WORKSPACE - GATE_PIXEL_SIZE; 16 | export const WORLD_MIN_Y = -HALF_WORKSPACE; 17 | export const WORLD_MAX_Y = HALF_WORKSPACE - GATE_PIXEL_SIZE; 18 | export const COORDINATE_VERSION = 2; 19 | export const ROTATION_STEP = 90; 20 | export const DEFAULT_GATE_DIMENSIONS = { width: BASE_ICON_SIZE, height: BASE_ICON_SIZE }; 21 | export const CUSTOM_GATE_PORT_SPACING = GRID_SIZE; 22 | -------------------------------------------------------------------------------- /client/custom-gates/README.md: -------------------------------------------------------------------------------- 1 | # Custom Gates Folder 2 | 3 | Drop reusable JSON snapshots (same schema as `state.json`) here to make them available in the Logic Circuit Lab palette. 4 | 5 | - Each file must include the normal `gates`, `connections`, and `nextId` fields. 6 | - Use `input` gates to define the custom gate’s pins (labels will become the port names) and `output` gates to expose the outputs. 7 | - Files are discovered automatically when the client loads, so placing a new JSON file here instantly registers a new reusable custom gate without having to rebuild it on the canvas. 8 | - Add an optional `description` string at the top level to control the text shown in the inspector/context menu for that gate (otherwise the app generates one automatically). 9 | - You can also include an optional `customVhdl` string to describe how this gate should be emitted during a VHDL export. Use template placeholders like `{{input:0}}`, `{{output:0}}`, `{{gateId}}`, `{{label}}`, and `{{type}}` to reference the resolved signal names. 10 | -------------------------------------------------------------------------------- /client/js/help-loader.js: -------------------------------------------------------------------------------- 1 | import HelpModal from '../help-modal.js'; 2 | 3 | async function fetchHelpContent() { 4 | const response = await fetch('./help-content-template.html', { cache: 'no-store' }); 5 | if (!response.ok) { 6 | throw new Error(`Unexpected response: ${response.status}`); 7 | } 8 | return response.text(); 9 | } 10 | 11 | export async function initializeHelpModal({ triggerSelector = '#btn-help', statusController } = {}) { 12 | const setStatus = statusController?.setStatus || (() => {}); 13 | setStatus('Loading...'); 14 | 15 | try { 16 | const content = await fetchHelpContent(); 17 | HelpModal.init({ 18 | triggerSelector, 19 | content, 20 | theme: 'auto' 21 | }); 22 | setStatus('Ready'); 23 | } catch (error) { 24 | console.error('Failed to load help content:', error); 25 | HelpModal.init({ 26 | triggerSelector, 27 | content: '

Help content could not be loaded. Please try again later.

', 28 | theme: 'auto' 29 | }); 30 | setStatus('Failed to load data', { revertDelay: 3000 }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/js/websocket-bridge.js: -------------------------------------------------------------------------------- 1 | function getWebSocketUrl() { 2 | const isSecure = window.location.protocol === 'https:'; 3 | const protocol = isSecure ? 'wss' : 'ws'; 4 | return `${protocol}://${window.location.host}`; 5 | } 6 | 7 | export function initializeWebSocketBridge({ onExportRequest } = {}) { 8 | if (typeof window.WebSocket !== 'function') { 9 | console.warn('WebSocket is not supported in this browser; export bridge disabled.'); 10 | return null; 11 | } 12 | 13 | let socket; 14 | try { 15 | socket = new WebSocket(getWebSocketUrl()); 16 | } catch (error) { 17 | console.error('Failed to establish WebSocket connection:', error); 18 | return null; 19 | } 20 | 21 | socket.addEventListener('message', (event) => { 22 | try { 23 | const payload = JSON.parse(event.data); 24 | if (payload?.type === 'logic-sim:export-vhdl' && typeof onExportRequest === 'function') { 25 | onExportRequest(); 26 | } 27 | } catch (error) { 28 | console.error('Failed to handle WebSocket message:', error); 29 | } 30 | }); 31 | 32 | socket.addEventListener('error', (error) => { 33 | console.error('WebSocket error:', error); 34 | }); 35 | 36 | return () => { 37 | if (socket && socket.readyState === WebSocket.OPEN) { 38 | socket.close(); 39 | } 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /client/js/status-controller.js: -------------------------------------------------------------------------------- 1 | const allowedStatuses = new Set([ 2 | 'Ready', 3 | 'Loading...', 4 | 'Saving...', 5 | 'Changes saved', 6 | 'Save failed (will retry)', 7 | 'Failed to load data', 8 | 'Auto-save initialized' 9 | ]); 10 | 11 | export function createStatusController(initialElement = null) { 12 | let statusElement = initialElement; 13 | let revertTimer = null; 14 | let lastMessage = 'Ready'; 15 | 16 | const applyStatus = (message) => { 17 | lastMessage = message; 18 | if (statusElement) { 19 | statusElement.textContent = message; 20 | } 21 | }; 22 | 23 | const setStatus = (message, options = {}) => { 24 | if (!allowedStatuses.has(message)) { 25 | console.warn(`Ignoring unsupported status message: ${message}`); 26 | return; 27 | } 28 | 29 | if (revertTimer) { 30 | window.clearTimeout(revertTimer); 31 | revertTimer = null; 32 | } 33 | 34 | applyStatus(message); 35 | 36 | if (message !== 'Ready' && typeof options.revertDelay === 'number') { 37 | revertTimer = window.setTimeout(() => { 38 | applyStatus('Ready'); 39 | revertTimer = null; 40 | }, options.revertDelay); 41 | } 42 | }; 43 | 44 | const attach = (element) => { 45 | statusElement = element || null; 46 | if (statusElement) { 47 | statusElement.textContent = lastMessage; 48 | } 49 | }; 50 | 51 | return { setStatus, attach }; 52 | } 53 | -------------------------------------------------------------------------------- /state.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "origin": "center", 4 | "nextId": 11, 5 | "gates": [ 6 | { 7 | "id": "g1", 8 | "type": "and", 9 | "x": 8, 10 | "y": -152, 11 | "label": "", 12 | "state": 0 13 | }, 14 | { 15 | "id": "g6", 16 | "type": "input", 17 | "x": -120, 18 | "y": -200, 19 | "label": "A", 20 | "state": 1 21 | }, 22 | { 23 | "id": "g7", 24 | "type": "input", 25 | "x": -120, 26 | "y": -104, 27 | "label": "B", 28 | "state": 0 29 | }, 30 | { 31 | "id": "g8", 32 | "type": "output", 33 | "x": 136, 34 | "y": -152, 35 | "label": "Test", 36 | "state": 0 37 | } 38 | ], 39 | "connections": [ 40 | { 41 | "id": "cmhm63wgts4zi", 42 | "from": { 43 | "gateId": "g6", 44 | "portIndex": 0 45 | }, 46 | "to": { 47 | "gateId": "g1", 48 | "portIndex": 0 49 | } 50 | }, 51 | { 52 | "id": "cmhm63xtqzdqc", 53 | "from": { 54 | "gateId": "g7", 55 | "portIndex": 0 56 | }, 57 | "to": { 58 | "gateId": "g1", 59 | "portIndex": 1 60 | } 61 | }, 62 | { 63 | "id": "cmhm63zqffhpw", 64 | "from": { 65 | "gateId": "g1", 66 | "portIndex": 0 67 | }, 68 | "to": { 69 | "gateId": "g8", 70 | "portIndex": 0 71 | } 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /client/initial_state.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "origin": "center", 4 | "nextId": 11, 5 | "gates": [ 6 | { 7 | "id": "g1", 8 | "type": "and", 9 | "x": 8, 10 | "y": -152, 11 | "label": "", 12 | "state": 0 13 | }, 14 | { 15 | "id": "g6", 16 | "type": "input", 17 | "x": -120, 18 | "y": -200, 19 | "label": "A", 20 | "state": 1 21 | }, 22 | { 23 | "id": "g7", 24 | "type": "input", 25 | "x": -120, 26 | "y": -104, 27 | "label": "B", 28 | "state": 0 29 | }, 30 | { 31 | "id": "g8", 32 | "type": "output", 33 | "x": 136, 34 | "y": -152, 35 | "label": "Test", 36 | "state": 0 37 | } 38 | ], 39 | "connections": [ 40 | { 41 | "id": "cmhm63wgts4zi", 42 | "from": { 43 | "gateId": "g6", 44 | "portIndex": 0 45 | }, 46 | "to": { 47 | "gateId": "g1", 48 | "portIndex": 0 49 | } 50 | }, 51 | { 52 | "id": "cmhm63xtqzdqc", 53 | "from": { 54 | "gateId": "g7", 55 | "portIndex": 0 56 | }, 57 | "to": { 58 | "gateId": "g1", 59 | "portIndex": 1 60 | } 61 | }, 62 | { 63 | "id": "cmhm63zqffhpw", 64 | "from": { 65 | "gateId": "g1", 66 | "portIndex": 0 67 | }, 68 | "to": { 69 | "gateId": "g8", 70 | "portIndex": 0 71 | } 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /client/custom-gates/3AND.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "description": "Outputs 1 only when inputs A, B, and C are all high.", 4 | "origin": "center", 5 | "nextId": 7, 6 | "gates": [ 7 | { 8 | "id": "g1", 9 | "type": "input", 10 | "x": -320, 11 | "y": -64, 12 | "label": "A", 13 | "state": 0 14 | }, 15 | { 16 | "id": "g2", 17 | "type": "input", 18 | "x": -320, 19 | "y": 0, 20 | "label": "B", 21 | "state": 0 22 | }, 23 | { 24 | "id": "g3", 25 | "type": "input", 26 | "x": -320, 27 | "y": 64, 28 | "label": "C", 29 | "state": 0 30 | }, 31 | { 32 | "id": "g4", 33 | "type": "and", 34 | "x": -128, 35 | "y": -32, 36 | "label": "" 37 | }, 38 | { 39 | "id": "g5", 40 | "type": "and", 41 | "x": 32, 42 | "y": -32, 43 | "label": "" 44 | }, 45 | { 46 | "id": "g6", 47 | "type": "output", 48 | "x": 224, 49 | "y": -32, 50 | "label": "OUT" 51 | } 52 | ], 53 | "connections": [ 54 | { 55 | "id": "c1", 56 | "from": { "gateId": "g1", "portIndex": 0 }, 57 | "to": { "gateId": "g4", "portIndex": 0 } 58 | }, 59 | { 60 | "id": "c2", 61 | "from": { "gateId": "g2", "portIndex": 0 }, 62 | "to": { "gateId": "g4", "portIndex": 1 } 63 | }, 64 | { 65 | "id": "c3", 66 | "from": { "gateId": "g4", "portIndex": 0 }, 67 | "to": { "gateId": "g5", "portIndex": 0 } 68 | }, 69 | { 70 | "id": "c4", 71 | "from": { "gateId": "g3", "portIndex": 0 }, 72 | "to": { "gateId": "g5", "portIndex": 1 } 73 | }, 74 | { 75 | "id": "c5", 76 | "from": { "gateId": "g5", "portIndex": 0 }, 77 | "to": { "gateId": "g6", "portIndex": 0 } 78 | } 79 | ] 80 | } 81 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Logic Circuit Lab 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |

Logic Circuit Lab

16 | 17 |
18 |
Ready
19 | 20 |
21 | 22 |
23 | 30 | 31 |
32 |
33 |
34 |

Connect outputs to inputs to build circuits. Toggle inputs to test behavior.

35 |
36 | 37 |
38 |
39 | 40 |
41 |
42 |
43 |
44 |
45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /client/custom-gates/SR Latch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "description": "Cross-coupled NOR latch with S and R inputs and complementary outputs.", 4 | "origin": "center", 5 | "nextId": 7, 6 | "gates": [ 7 | { 8 | "id": "g1", 9 | "type": "nor", 10 | "x": 112, 11 | "y": -160, 12 | "state": 0, 13 | "label": "" 14 | }, 15 | { 16 | "id": "g2", 17 | "type": "nor", 18 | "x": 112, 19 | "y": -64, 20 | "state": 0, 21 | "label": "" 22 | }, 23 | { 24 | "id": "g3", 25 | "type": "output", 26 | "x": 256, 27 | "y": -160, 28 | "state": 1, 29 | "label": "Q" 30 | }, 31 | { 32 | "id": "g4", 33 | "type": "output", 34 | "x": 256, 35 | "y": -64, 36 | "state": 0, 37 | "label": "notQ" 38 | }, 39 | { 40 | "id": "g6", 41 | "type": "input", 42 | "x": -48, 43 | "y": -64, 44 | "state": 0, 45 | "label": "S" 46 | }, 47 | { 48 | "id": "g5", 49 | "type": "input", 50 | "x": -48, 51 | "y": -160, 52 | "state": 0, 53 | "label": "R" 54 | } 55 | ], 56 | "connections": [ 57 | { 58 | "id": "cmi2wumztcnh1", 59 | "from": { 60 | "gateId": "g5", 61 | "portIndex": 0 62 | }, 63 | "to": { 64 | "gateId": "g1", 65 | "portIndex": 0 66 | } 67 | }, 68 | { 69 | "id": "cmi2wuop9yrkl", 70 | "from": { 71 | "gateId": "g6", 72 | "portIndex": 0 73 | }, 74 | "to": { 75 | "gateId": "g2", 76 | "portIndex": 0 77 | } 78 | }, 79 | { 80 | "id": "cmi2wuvccream", 81 | "from": { 82 | "gateId": "g2", 83 | "portIndex": 0 84 | }, 85 | "to": { 86 | "gateId": "g1", 87 | "portIndex": 1 88 | } 89 | }, 90 | { 91 | "id": "cmi2wuwo0b9bk", 92 | "from": { 93 | "gateId": "g1", 94 | "portIndex": 0 95 | }, 96 | "to": { 97 | "gateId": "g2", 98 | "portIndex": 1 99 | } 100 | }, 101 | { 102 | "id": "cmi2wuz1614pe", 103 | "from": { 104 | "gateId": "g1", 105 | "portIndex": 0 106 | }, 107 | "to": { 108 | "gateId": "g3", 109 | "portIndex": 0 110 | } 111 | }, 112 | { 113 | "id": "cmi2wv0dyq6ex", 114 | "from": { 115 | "gateId": "g2", 116 | "portIndex": 0 117 | }, 118 | "to": { 119 | "gateId": "g4", 120 | "portIndex": 0 121 | } 122 | } 123 | ] 124 | } 125 | -------------------------------------------------------------------------------- /scripts/export-task-state.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs/promises'); 4 | const path = require('path'); 5 | const { serializeSnapshotToVhdl } = require('../vhdl-serializer'); 6 | const { printCircuitReport } = require('../circuit-report'); 7 | 8 | const ROOT_DIR = path.resolve(__dirname, '..'); 9 | const GATE_CONFIG_PATH = path.join(ROOT_DIR, 'client', 'gate-config.json'); 10 | 11 | async function loadJSON(filePath) { 12 | const raw = await fs.readFile(filePath, 'utf8'); 13 | return JSON.parse(raw); 14 | } 15 | 16 | async function loadGateConfig() { 17 | try { 18 | return await loadJSON(GATE_CONFIG_PATH); 19 | } catch (error) { 20 | if (error.code !== 'ENOENT') { 21 | console.warn(`Failed to load gate-config.json: ${error.message}`); 22 | } 23 | return {}; 24 | } 25 | } 26 | 27 | function resolvePath(inputPath) { 28 | if (path.isAbsolute(inputPath)) { 29 | return inputPath; 30 | } 31 | return path.join(ROOT_DIR, inputPath); 32 | } 33 | 34 | function buildVhdlDestination(jsonPath) { 35 | const dir = path.dirname(jsonPath); 36 | const base = path.basename(jsonPath, path.extname(jsonPath) || '.json'); 37 | return path.join(dir, `${base}.vhdl`); 38 | } 39 | 40 | async function exportSnapshot(jsonInputPath, gateConfig) { 41 | const state = await loadJSON(jsonInputPath); 42 | const vhdl = serializeSnapshotToVhdl(state); 43 | const vhdlPath = buildVhdlDestination(jsonInputPath); 44 | 45 | await Promise.all([ 46 | fs.writeFile(jsonInputPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'), 47 | fs.writeFile(vhdlPath, `${vhdl.trimEnd()}\n`, 'utf8') 48 | ]); 49 | 50 | try { 51 | printCircuitReport(state, gateConfig); 52 | } catch (reportError) { 53 | console.warn(`Failed to generate circuit report for ${path.basename(jsonInputPath)}: ${reportError.message}`); 54 | } 55 | 56 | return vhdlPath; 57 | } 58 | 59 | async function run() { 60 | const args = process.argv.slice(2); 61 | let starterInput = 'starter.json'; 62 | let solutionInput = 'solution.json'; 63 | 64 | if (args.length === 2) { 65 | [starterInput, solutionInput] = args; 66 | } else if (args.length !== 0) { 67 | console.error('Usage: node scripts/export-task-state.js '); 68 | process.exitCode = 1; 69 | return; 70 | } 71 | 72 | try { 73 | const gateConfig = await loadGateConfig(); 74 | 75 | const starterPath = resolvePath(starterInput); 76 | const solutionPath = resolvePath(solutionInput); 77 | 78 | const starterVhdlPath = await exportSnapshot(starterPath, gateConfig); 79 | console.log(`Starter export complete: ${path.relative(ROOT_DIR, starterVhdlPath)}`); 80 | 81 | const solutionVhdlPath = await exportSnapshot(solutionPath, gateConfig); 82 | console.log(`Solution export complete: ${path.relative(ROOT_DIR, solutionVhdlPath)}`); 83 | } catch (error) { 84 | console.error('Failed to export task states:', error); 85 | process.exitCode = 1; 86 | } 87 | } 88 | 89 | run(); 90 | -------------------------------------------------------------------------------- /client/js/sim/geometry.js: -------------------------------------------------------------------------------- 1 | import { 2 | ROTATION_STEP, 3 | DEFAULT_GATE_DIMENSIONS, 4 | GRID_SIZE, 5 | BASE_ICON_SIZE, 6 | CUSTOM_GATE_PORT_SPACING, 7 | HALF_WORKSPACE 8 | } from './constants.js'; 9 | 10 | export const normalizeRotation = (value = 0) => { 11 | const numeric = Number(value); 12 | if (!Number.isFinite(numeric)) { 13 | return 0; 14 | } 15 | const steps = Math.round(numeric / ROTATION_STEP); 16 | const normalized = steps * ROTATION_STEP; 17 | return ((normalized % 360) + 360) % 360; 18 | }; 19 | 20 | export const getGateRotation = (gate) => normalizeRotation(gate?.rotation || 0); 21 | 22 | export const normalizeDimensions = (size = DEFAULT_GATE_DIMENSIONS) => { 23 | const width = Number(size?.width); 24 | const height = Number(size?.height); 25 | return { 26 | width: Number.isFinite(width) && width > 0 ? width : DEFAULT_GATE_DIMENSIONS.width, 27 | height: Number.isFinite(height) && height > 0 ? height : DEFAULT_GATE_DIMENSIONS.height 28 | }; 29 | }; 30 | 31 | export const rotatePoint = (point, rotation = 0, size = DEFAULT_GATE_DIMENSIONS) => { 32 | const { width, height } = normalizeDimensions(size); 33 | const fallback = point || { x: width / 2, y: height / 2 }; 34 | const baseX = Number(fallback?.x); 35 | const baseY = Number(fallback?.y); 36 | const halfWidth = width / 2; 37 | const halfHeight = height / 2; 38 | const x = Number.isFinite(baseX) ? baseX : halfWidth; 39 | const y = Number.isFinite(baseY) ? baseY : halfHeight; 40 | const offsetX = x - halfWidth; 41 | const offsetY = y - halfHeight; 42 | const quarterTurns = Math.round(normalizeRotation(rotation) / ROTATION_STEP) % 4; 43 | let rotatedX = offsetX; 44 | let rotatedY = offsetY; 45 | switch ((quarterTurns + 4) % 4) { 46 | case 1: 47 | rotatedX = -offsetY; 48 | rotatedY = offsetX; 49 | break; 50 | case 2: 51 | rotatedX = -offsetX; 52 | rotatedY = -offsetY; 53 | break; 54 | case 3: 55 | rotatedX = offsetY; 56 | rotatedY = -offsetX; 57 | break; 58 | default: 59 | break; 60 | } 61 | return { 62 | x: rotatedX + halfWidth, 63 | y: rotatedY + halfHeight 64 | }; 65 | }; 66 | 67 | export const alignSizeToGrid = (value) => { 68 | const numeric = Number(value); 69 | if (!Number.isFinite(numeric) || numeric <= 0) { 70 | return GRID_SIZE; 71 | } 72 | return Math.ceil(numeric / GRID_SIZE) * GRID_SIZE; 73 | }; 74 | 75 | export const computeCustomGateDimensions = (inputCount = 0, outputCount = 0) => { 76 | const maxPorts = Math.max(1, inputCount, outputCount); 77 | const rawHeight = (maxPorts + 1) * CUSTOM_GATE_PORT_SPACING; 78 | const height = Math.max(BASE_ICON_SIZE, alignSizeToGrid(rawHeight)); 79 | return { 80 | width: BASE_ICON_SIZE, 81 | height 82 | }; 83 | }; 84 | 85 | export const distributePorts = (count, side, dimensions = DEFAULT_GATE_DIMENSIONS) => { 86 | if (!count) { 87 | return []; 88 | } 89 | const { width, height } = normalizeDimensions(dimensions); 90 | const step = height / (count + 1); 91 | return Array.from({ length: count }, (_, index) => ({ 92 | x: side === 'input' ? 0 : width, 93 | y: Math.round(step * (index + 1)) 94 | })); 95 | }; 96 | 97 | export const buildAutoPortLayout = (inputs, outputs, dimensions = DEFAULT_GATE_DIMENSIONS) => ({ 98 | inputs: distributePorts(inputs, 'input', dimensions), 99 | outputs: distributePorts(outputs, 'output', dimensions) 100 | }); 101 | 102 | export const worldToCanvas = (value) => value + HALF_WORKSPACE; 103 | export const canvasToWorld = (value) => value - HALF_WORKSPACE; 104 | export const worldPointToCanvas = (point) => ({ 105 | x: worldToCanvas(point.x), 106 | y: worldToCanvas(point.y) 107 | }); 108 | -------------------------------------------------------------------------------- /client/js/sim/custom-gate-utils.js: -------------------------------------------------------------------------------- 1 | export const slugifyGateName = (value = '', fallback = 'custom-gate') => { 2 | const cleaned = value 3 | .toString() 4 | .trim() 5 | .toLowerCase() 6 | .replace(/[^a-z0-9]+/g, '-') 7 | .replace(/^-+|-+$/g, ''); 8 | return cleaned || fallback; 9 | }; 10 | 11 | export const deriveAbbreviation = (label = '') => { 12 | const cleaned = (label || '').trim(); 13 | if (!cleaned) { 14 | return 'CG'; 15 | } 16 | const letters = cleaned 17 | .split(/\s+/) 18 | .map((word) => word[0]) 19 | .join('') 20 | .slice(0, 3) 21 | .toUpperCase(); 22 | return letters || cleaned.slice(0, 3).toUpperCase(); 23 | }; 24 | 25 | export const cloneGateTemplate = (template) => ({ 26 | id: template.id, 27 | type: template.type, 28 | label: template.label, 29 | state: template.state, 30 | inputs: template.inputs, 31 | outputs: template.outputs, 32 | outputValues: new Array(template.outputs).fill(0), 33 | inputCache: new Array(template.inputs).fill(0) 34 | }); 35 | 36 | export const buildConnectionLookupMap = (connections = []) => { 37 | const lookup = new Map(); 38 | connections.forEach((connection) => { 39 | if (!connection?.to?.gateId) { 40 | return; 41 | } 42 | const key = `${connection.to.gateId}:${Number(connection.to.portIndex) || 0}`; 43 | lookup.set(key, { 44 | gateId: connection.from?.gateId, 45 | portIndex: Number(connection.from?.portIndex) || 0 46 | }); 47 | }); 48 | return lookup; 49 | }; 50 | 51 | export const prepareCustomGateInterface = (snapshot = {}) => { 52 | const gates = Array.isArray(snapshot.gates) ? snapshot.gates : []; 53 | const inputs = gates.filter((gate) => gate.type === 'input'); 54 | const outputs = gates.filter((gate) => gate.type === 'output'); 55 | if (!inputs.length || !outputs.length) { 56 | throw new Error('Custom gate snapshots must include at least one input and one output gate.'); 57 | } 58 | const normalizeName = (label, index, fallbackPrefix) => (label && label.trim()) 59 | ? label.trim() 60 | : `${fallbackPrefix} ${index + 1}`; 61 | return { 62 | inputGateIds: inputs.map((gate) => gate.id), 63 | outputGateIds: outputs.map((gate) => gate.id), 64 | inputNames: inputs.map((gate, index) => normalizeName(gate.label || '', index, 'In')), 65 | outputNames: outputs.map((gate, index) => normalizeName(gate.label || '', index, 'Out')) 66 | }; 67 | }; 68 | 69 | export const compileCustomGateSnapshot = (snapshot = {}, gateDefinitions = {}) => { 70 | const interfaceInfo = prepareCustomGateInterface(snapshot); 71 | const gates = new Map(); 72 | (snapshot.gates || []).forEach((gate) => { 73 | const definition = gateDefinitions[gate.type]; 74 | if (!definition) { 75 | throw new Error(`Gate type "${gate.type}" is not registered.`); 76 | } 77 | gates.set(gate.id, { 78 | id: gate.id, 79 | type: gate.type, 80 | label: typeof gate.label === 'string' ? gate.label : '', 81 | state: Number(gate.state) === 1 ? 1 : 0, 82 | inputs: definition.inputs, 83 | outputs: definition.outputs 84 | }); 85 | }); 86 | const connections = (snapshot.connections || []) 87 | .map((connection) => ({ 88 | id: connection.id, 89 | from: { 90 | gateId: connection.from?.gateId, 91 | portIndex: Number(connection.from?.portIndex) || 0 92 | }, 93 | to: { 94 | gateId: connection.to?.gateId, 95 | portIndex: Number(connection.to?.portIndex) || 0 96 | } 97 | })) 98 | .filter((connection) => 99 | gates.has(connection.from.gateId) && 100 | gates.has(connection.to.gateId) 101 | ); 102 | return { 103 | templateGates: gates, 104 | connections, 105 | interface: interfaceInfo, 106 | connectionLookup: buildConnectionLookupMap(connections) 107 | }; 108 | }; 109 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Elastic License 2.0 2 | 3 | URL: https://www.elastic.co/licensing/elastic-license 4 | 5 | ## Acceptance 6 | 7 | By using the software, you agree to all of the terms and conditions below. 8 | 9 | ## Copyright License 10 | 11 | The licensor grants you a non-exclusive, royalty-free, worldwide, 12 | non-sublicensable, non-transferable license to use, copy, distribute, make 13 | available, and prepare derivative works of the software, in each case subject to 14 | the limitations and conditions below. 15 | 16 | ## Limitations 17 | 18 | You may not provide the software to third parties as a hosted or managed 19 | service, where the service provides users with access to any substantial set of 20 | the features or functionality of the software. 21 | 22 | You may not move, change, disable, or circumvent the license key functionality 23 | in the software, and you may not remove or obscure any functionality in the 24 | software that is protected by the license key. 25 | 26 | You may not alter, remove, or obscure any licensing, copyright, or other notices 27 | of the licensor in the software. Any use of the licensor’s trademarks is subject 28 | to applicable law. 29 | 30 | ## Patents 31 | 32 | The licensor grants you a license, under any patent claims the licensor can 33 | license, or becomes able to license, to make, have made, use, sell, offer for 34 | sale, import and have imported the software, in each case subject to the 35 | limitations and conditions in this license. This license does not cover any 36 | patent claims that you cause to be infringed by modifications or additions to 37 | the software. If you or your company make any written claim that the software 38 | infringes or contributes to infringement of any patent, your patent license for 39 | the software granted under these terms ends immediately. If your company makes 40 | such a claim, your patent license ends immediately for work on behalf of your 41 | company. 42 | 43 | ## Notices 44 | 45 | You must ensure that anyone who gets a copy of any part of the software from you 46 | also gets a copy of these terms. 47 | 48 | If you modify the software, you must include in any modified copies of the 49 | software prominent notices stating that you have modified the software. 50 | 51 | ## No Other Rights 52 | 53 | These terms do not imply any licenses other than those expressly granted in 54 | these terms. 55 | 56 | ## Termination 57 | 58 | If you use the software in violation of these terms, such use is not licensed, 59 | and your licenses will automatically terminate. If the licensor provides you 60 | with a notice of your violation, and you cease all violation of this license no 61 | later than 30 days after you receive that notice, your licenses will be 62 | reinstated retroactively. However, if you violate these terms after such 63 | reinstatement, any additional violation of these terms will cause your licenses 64 | to terminate automatically and permanently. 65 | 66 | ## No Liability 67 | 68 | *As far as the law allows, the software comes as is, without any warranty or 69 | condition, and the licensor will not be liable to you for any damages arising 70 | out of these terms or the use or nature of the software, under any kind of 71 | legal claim.* 72 | 73 | ## Definitions 74 | 75 | The **licensor** is the entity offering these terms, and the **software** is the 76 | software the licensor makes available under these terms, including any portion 77 | of it. 78 | 79 | **you** refers to the individual or entity agreeing to these terms. 80 | 81 | **your company** is any legal entity, sole proprietorship, or other kind of 82 | organization that you work for, plus all organizations that have control over, 83 | are under the control of, or are under common control with that 84 | organization. **control** means ownership of substantially all the assets of an 85 | entity, or the power to direct its management and policies by vote, contract, or 86 | otherwise. Control can be direct or indirect. 87 | 88 | **your licenses** are all the licenses granted to you for the software under 89 | these terms. 90 | 91 | **use** means anything you do with the software requiring one of your licenses. 92 | 93 | **trademark** means trademarks, service marks, and similar rights. 94 | -------------------------------------------------------------------------------- /client/help-modal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * HelpModal - A reusable, dependency-free help modal system for Bespoke applications 3 | * 4 | * This modal system is designed to work with the bespoke.css framework and provides 5 | * a consistent help experience across all embedded applications. 6 | * 7 | * Usage: 8 | * HelpModal.init({ 9 | * triggerSelector: '#btn-help', 10 | * content: helpContent, 11 | * theme: 'auto' 12 | * }); 13 | */ 14 | 15 | export default class HelpModal { 16 | constructor(options = {}) { 17 | this.options = { 18 | triggerSelector: '#btn-help', 19 | content: '', 20 | theme: 'auto', // 'light', 'dark', or 'auto' 21 | customStyles: {}, 22 | ...options 23 | }; 24 | 25 | this.isOpen = false; 26 | this.modal = null; 27 | this.trigger = null; 28 | 29 | this.init(); 30 | } 31 | 32 | init() { 33 | this.createModal(); 34 | this.bindEvents(); 35 | } 36 | 37 | createModal() { 38 | // Create modal container using bespoke CSS classes 39 | this.modal = document.createElement('div'); 40 | this.modal.className = 'modal'; 41 | this.modal.innerHTML = ` 42 | 43 | 52 | `; 53 | 54 | // Initially hidden 55 | this.modal.style.display = 'none'; 56 | document.body.appendChild(this.modal); 57 | } 58 | 59 | bindEvents() { 60 | // Find trigger element 61 | this.trigger = document.querySelector(this.options.triggerSelector); 62 | if (!this.trigger) { 63 | console.warn(`HelpModal: Trigger element '${this.options.triggerSelector}' not found`); 64 | return; 65 | } 66 | 67 | // Convert link to button if needed 68 | if (this.trigger.tagName === 'A') { 69 | this.trigger.addEventListener('click', (e) => { 70 | e.preventDefault(); 71 | this.open(); 72 | }); 73 | } else { 74 | this.trigger.addEventListener('click', () => this.open()); 75 | } 76 | 77 | // Close button 78 | const closeBtn = this.modal.querySelector('.modal-close'); 79 | closeBtn.addEventListener('click', () => this.close()); 80 | 81 | // Backdrop click 82 | const backdrop = this.modal.querySelector('.modal-backdrop'); 83 | backdrop.addEventListener('click', () => this.close()); 84 | 85 | // ESC key 86 | document.addEventListener('keydown', (e) => { 87 | if (e.key === 'Escape' && this.isOpen) { 88 | this.close(); 89 | } 90 | }); 91 | 92 | // Handle internal navigation links 93 | this.modal.addEventListener('click', (e) => { 94 | if (e.target.matches('a[href^="#"]')) { 95 | e.preventDefault(); 96 | const targetId = e.target.getAttribute('href').substring(1); 97 | const targetElement = this.modal.querySelector(`#${targetId}`); 98 | if (targetElement) { 99 | targetElement.scrollIntoView({ behavior: 'smooth' }); 100 | } 101 | } 102 | }); 103 | } 104 | 105 | open() { 106 | if (this.isOpen) return; 107 | 108 | this.isOpen = true; 109 | this.modal.style.display = 'flex'; // Use flex to center the modal 110 | document.body.style.overflow = 'hidden'; // Prevent background scrolling 111 | 112 | // Focus management 113 | const closeBtn = this.modal.querySelector('.modal-close'); 114 | closeBtn.focus(); 115 | 116 | // Trigger custom event 117 | this.trigger.dispatchEvent(new CustomEvent('helpModal:open', { detail: this })); 118 | } 119 | 120 | close() { 121 | if (!this.isOpen) return; 122 | 123 | this.isOpen = false; 124 | this.modal.style.display = 'none'; 125 | document.body.style.overflow = ''; // Restore scrolling 126 | 127 | // Return focus to trigger 128 | this.trigger.focus(); 129 | 130 | // Trigger custom event 131 | this.trigger.dispatchEvent(new CustomEvent('helpModal:close', { detail: this })); 132 | } 133 | 134 | // Public API methods 135 | static init(options) { 136 | return new HelpModal(options); 137 | } 138 | 139 | destroy() { 140 | if (this.modal && this.modal.parentNode) { 141 | this.modal.parentNode.removeChild(this.modal); 142 | } 143 | document.body.style.overflow = ''; 144 | } 145 | 146 | // Method to update content dynamically 147 | updateContent(newContent) { 148 | const modalBody = this.modal.querySelector('.modal-body'); 149 | if (modalBody) { 150 | modalBody.innerHTML = newContent; 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /client/help-content-template.html: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 |

Overview

15 |

Logic Circuit Lab is a drag-and-drop playground for experimenting with digital logic right inside your browser. Build combinational circuits, see how signals move through each gate, and share the results with classmates or teammates.

16 |
17 | 18 |
19 |

Getting Started

20 |
    21 |
  1. Open the Circuit Elements palette and drag any gate onto the grid, or click a tile to drop it into the center of the canvas.
  2. 22 |
  3. Add Input and Output blocks so your circuit has places to send and receive signals.
  4. 23 |
  5. Connect components by clicking a gate’s output port and then the destination input port. Wires route themselves between the two points.
  6. 24 |
  7. Pan the workspace by dragging on empty grid space; use the mouse wheel or trackpad to zoom. The current zoom level appears in the toolbar.
  8. 25 |
  9. Click an Input block (or press Toggle value in the contextual editor panel) to switch between logic 0 and 1 and watch the circuit update instantly.
  10. 26 |
27 |
28 | 29 |
30 |

Key Features

31 | 32 |

Gate Palette

33 |

Frequently used gates (AND, OR, XOR, NOT, buffer, etc.) live in the left sidebar. Hover to read a short description or use the search field to jump directly to what you need.

34 | 35 |

Interactive Canvas

36 |

Place, move, and rotate gates freely across the grid. Wires glow with the accent color whenever a signal is high so you can trace activity at a glance.

37 | 38 |

Automatic Persistence

39 |

The canvas saves itself after each change. Keep an eye on the status pill in the header for messages such as “Saving…” and “Changes saved”.

40 | 41 |

Exports & Sharing

42 |

Use the Export VHDL action to create a VHDL snapshot of your current circuit. When the export finishes you’ll see a confirmation toast along with any warnings from the report.

43 |
44 | 45 |
46 |

Workflow

47 |
    48 |
  1. Plan: Sketch the circuit on paper or in the palette preview and note how many inputs and outputs you need.
  2. 49 |
  3. Place: Drag the required gates to the canvas and arrange them so related blocks sit near each other.
  4. 50 |
  5. Wire: Click an output hub and then the destination input. Click an occupied input hub to remove or reroute its connection.
  6. 51 |
  7. Verify: Step through test cases by toggling input blocks.
  8. 52 |
53 | 54 |

Tips & Best Practices

55 |
    56 |
  • Use Reset whenever you want to return to the starter layout loaded on first launch.
  • 57 |
  • Press Delete with a gate selected to remove it without leaving the keyboard.
  • 58 |
  • Name inputs and outputs (right-click to open the contextual editor panel) so exported circuits are easier to read.
  • 59 |
  • Group related gates close together to keep the auto-routed wires short and legible.
  • 60 |
61 |
62 | 63 |
64 |

Shortcuts

65 |
    66 |
  • ESC – Cancel a pending wire connection.
  • 67 |
  • Delete – Remove the currently selected gate.
  • 68 |
  • Click + Drag – Move a gate by grabbing the gate symbol.
  • 69 |
  • Drag empty grid – Pan the workspace.
  • 70 |
  • Scroll / Pinch – Zoom in or out around the cursor.
  • 71 |
  • Click output, click input – Create a wire between two components.
  • 72 |
  • Click input hub – Remove the attached wire.
  • 73 |
  • Right-click gate – Open the contextual editor for naming and quick actions.
  • 74 |
75 |
76 | 77 |
78 |

Troubleshooting / FAQ

79 | 80 |
81 | Why do I see “Save failed (will retry)”? 82 |

The browser occasionally blocks local saves (usually because storage is full or disabled). The simulator retries in the background; clearing space or re-enabling storage fixes the warning.

83 |
84 | 85 |
86 | How do I delete a wire? 87 |

Click the wire’s destination input hub. Each input accepts only one connection, so removing it frees the port immediately.

88 |
89 | 90 |
91 | The wires look misaligned after resizing the window. 92 |

The layout engine recalculates after every resize. If anything still looks off, nudge a gate slightly or press Reset to trigger a clean redraw.

93 |
94 | 95 |
96 | Is my work automatically saved? 97 |

Yes. After every change the header cycles through “Saving…”, “Changes saved”, and finally “Ready”. You can keep building without pressing a save button.

98 |
99 |
100 | -------------------------------------------------------------------------------- /test-integration.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Bespoke CSS Component Test 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

Component Test Suite

18 |
Testing Bespoke CSS Components
19 | 20 |
21 | 22 |
23 | 24 | 150 | 151 | 152 |
153 |
154 |
155 |

Main Content Area

156 |

This area would contain your main application content

157 |
158 |
159 |
160 |
161 |
162 | 163 | 164 | -------------------------------------------------------------------------------- /client/gate-registry.js: -------------------------------------------------------------------------------- 1 | (function (root, factory) { 2 | const registry = factory(); 3 | if (typeof module === 'object' && module.exports) { 4 | module.exports = registry; 5 | } else { 6 | root.gateRegistry = registry; 7 | } 8 | })(typeof globalThis !== 'undefined' ? globalThis : this, () => { 9 | const definitions = Object.create(null); 10 | const paletteOrder = []; 11 | 12 | const defaultPortLayout = () => ({ 13 | inputs: [], 14 | outputs: [] 15 | }); 16 | 17 | const assignDefaults = (definition = {}) => { 18 | const normalized = { ...definition }; 19 | normalized.inputs = Number.isInteger(definition.inputs) ? definition.inputs : 0; 20 | normalized.outputs = Number.isInteger(definition.outputs) ? definition.outputs : 0; 21 | normalized.portLayout = definition.portLayout ? definition.portLayout : defaultPortLayout(); 22 | normalized.logic = typeof definition.logic === 'function' ? definition.logic : () => []; 23 | return normalized; 24 | }; 25 | 26 | const insertIntoPalette = (type, position) => { 27 | const existingIndex = paletteOrder.indexOf(type); 28 | if (existingIndex >= 0) { 29 | paletteOrder.splice(existingIndex, 1); 30 | } 31 | if (typeof position === 'number' && position >= 0 && position <= paletteOrder.length) { 32 | paletteOrder.splice(position, 0, type); 33 | } else if (!paletteOrder.includes(type)) { 34 | paletteOrder.push(type); 35 | } 36 | }; 37 | 38 | const registerGate = (type, definition, options = {}) => { 39 | if (!type || typeof type !== 'string') { 40 | throw new Error('Gate type must be a non-empty string'); 41 | } 42 | const normalized = assignDefaults(definition); 43 | definitions[type] = normalized; 44 | const { addToPalette = true, paletteIndex } = options; 45 | if (addToPalette) { 46 | insertIntoPalette(type, paletteIndex); 47 | } 48 | return normalized; 49 | }; 50 | 51 | registerGate( 52 | 'input', 53 | { 54 | label: 'Input', 55 | description: 'Manual toggle that emits a high (1) or low (0) signal.', 56 | icon: './resources/gates/input.svg', 57 | inputs: 0, 58 | outputs: 1, 59 | allowToggle: true, 60 | supportsLabel: true, 61 | primaryAction: 'toggle-state', 62 | actions: [ 63 | { 64 | id: 'toggle-state', 65 | label: 'Toggle value', 66 | perform: ({ gate, evaluateCircuit, scheduleRender, markDirty, refreshSelection }) => { 67 | gate.state = gate.state ? 0 : 1; 68 | evaluateCircuit(true); 69 | scheduleRender(); 70 | markDirty(); 71 | refreshSelection(); 72 | } 73 | } 74 | ], 75 | portLayout: { 76 | inputs: [], 77 | outputs: [{ x: 64, y: 32 }] 78 | }, 79 | logic: (_, gate) => [gate.state ? 1 : 0] 80 | }, 81 | { paletteIndex: 0 } 82 | ); 83 | 84 | registerGate( 85 | 'output', 86 | { 87 | label: 'Output', 88 | description: 'Shows the value from a single input line.', 89 | icon: './resources/gates/output.svg', 90 | inputs: 1, 91 | outputs: 0, 92 | supportsLabel: true, 93 | portLayout: { 94 | inputs: [{ x: 0, y: 32 }], 95 | outputs: [] 96 | }, 97 | logic: (inputs, gate) => { 98 | gate.state = inputs[0] ?? 0; 99 | return []; 100 | } 101 | }, 102 | { paletteIndex: 1 } 103 | ); 104 | 105 | registerGate( 106 | 'buffer', 107 | { 108 | label: 'Buffer', 109 | description: 'Passes the input signal through unchanged.', 110 | icon: './resources/gates/buffer.svg', 111 | inputs: 1, 112 | outputs: 1, 113 | portLayout: { 114 | inputs: [{ x: 0, y: 32 }], 115 | outputs: [{ x: 64, y: 32 }] 116 | }, 117 | logic: (inputs) => [inputs[0] ?? 0] 118 | }, 119 | { paletteIndex: 2 } 120 | ); 121 | 122 | registerGate( 123 | 'not', 124 | { 125 | label: 'NOT', 126 | description: 'Inverts the incoming signal.', 127 | icon: './resources/gates/not.svg', 128 | inputs: 1, 129 | outputs: 1, 130 | portLayout: { 131 | inputs: [{ x: 0, y: 32 }], 132 | outputs: [{ x: 64, y: 32 }] 133 | }, 134 | logic: (inputs) => [inputs[0] ? 0 : 1] 135 | }, 136 | { paletteIndex: 3 } 137 | ); 138 | 139 | registerGate( 140 | 'and', 141 | { 142 | label: 'AND', 143 | description: 'Outputs 1 when all inputs are high.', 144 | icon: './resources/gates/and.svg', 145 | inputs: 2, 146 | outputs: 1, 147 | portLayout: { 148 | inputs: [ 149 | { x: 0, y: 24 }, 150 | { x: 0, y: 40 } 151 | ], 152 | outputs: [{ x: 64, y: 32 }] 153 | }, 154 | logic: (inputs) => [inputs.every(Boolean) ? 1 : 0] 155 | }, 156 | { paletteIndex: 4 } 157 | ); 158 | 159 | registerGate( 160 | 'nand', 161 | { 162 | label: 'NAND', 163 | description: 'Outputs 0 only when all inputs are high.', 164 | icon: './resources/gates/nand.svg', 165 | inputs: 2, 166 | outputs: 1, 167 | portLayout: { 168 | inputs: [ 169 | { x: 0, y: 24 }, 170 | { x: 0, y: 40 } 171 | ], 172 | outputs: [{ x: 64, y: 32 }] 173 | }, 174 | logic: (inputs) => [inputs.every(Boolean) ? 0 : 1] 175 | }, 176 | { paletteIndex: 5 } 177 | ); 178 | 179 | registerGate( 180 | 'or', 181 | { 182 | label: 'OR', 183 | description: 'Outputs 1 when any input is high.', 184 | icon: './resources/gates/or.svg', 185 | inputs: 2, 186 | outputs: 1, 187 | portLayout: { 188 | inputs: [ 189 | { x: 0, y: 24 }, 190 | { x: 0, y: 40 } 191 | ], 192 | outputs: [{ x: 64, y: 32 }] 193 | }, 194 | logic: (inputs) => [inputs.some(Boolean) ? 1 : 0] 195 | }, 196 | { paletteIndex: 6 } 197 | ); 198 | 199 | registerGate( 200 | 'nor', 201 | { 202 | label: 'NOR', 203 | description: 'Outputs 1 only when all inputs are low.', 204 | icon: './resources/gates/nor.svg', 205 | inputs: 2, 206 | outputs: 1, 207 | portLayout: { 208 | inputs: [ 209 | { x: 0, y: 24 }, 210 | { x: 0, y: 40 } 211 | ], 212 | outputs: [{ x: 64, y: 32 }] 213 | }, 214 | logic: (inputs) => [inputs.some(Boolean) ? 0 : 1] 215 | }, 216 | { paletteIndex: 7 } 217 | ); 218 | 219 | registerGate( 220 | 'xor', 221 | { 222 | label: 'XOR', 223 | description: 'Outputs 1 when an odd number of inputs are high.', 224 | icon: './resources/gates/xor.svg', 225 | inputs: 2, 226 | outputs: 1, 227 | portLayout: { 228 | inputs: [ 229 | { x: 0, y: 24 }, 230 | { x: 0, y: 40 } 231 | ], 232 | outputs: [{ x: 64, y: 32 }] 233 | }, 234 | logic: (inputs) => [inputs.filter(Boolean).length % 2 ? 1 : 0] 235 | }, 236 | { paletteIndex: 8 } 237 | ); 238 | 239 | return { 240 | definitions, 241 | paletteOrder, 242 | registerGate 243 | }; 244 | }); 245 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Logic Circuit Lab — AGENTS Playbook 2 | 3 | This brief explains how to extend the Logic Circuit Lab application that ships with the Bespoke generalized components. Use it as the single source of truth for project scope, file structure, and code style. 4 | 5 | --- 6 | 7 | ## 1. Project Overview 8 | - **Goal**: Deliver an embeddable circuit playground that feels native inside any host site by relying on the `.bespoke` design system for UI consistency. 9 | - **Client** (`client/`): Contains the HTML shell, Bespoke CSS, gate assets, logic simulator, help content, and integration tests (`test-integration.html`). 10 | - **Server** (`server.js`): Node.js (CommonJS) server that serves static assets, handles VHDL exports, and relays `/message` broadcasts via WebSockets (`ws` dependency). 11 | - **Utilities**: `circuit-report.js` summarizes each VHDL export; data files such as `initial_state.json` and `gate-config.json` seed the canvas and palette. 12 | 13 | --- 14 | 15 | ## 2. Repository Layout & Required Artifacts 16 | 17 | | Path | Purpose | 18 | | --- | --- | 19 | | `client/index.html` | Bespoke application shell (header, status, canvas, sidebar, help trigger). | 20 | | `client/bespoke.css` | Core Bespoke framework — never edit variables here, only override. | 21 | | `client/logic-sim.js` | Canvas rendering, drag/drop, wiring, simulation, persistence, exports. | 22 | | `client/logic-sim.css` | Circuit-specific styling layered on top of Bespoke tokens. | 23 | | `client/help-modal.js` | Help modal utility (import from all apps). | 24 | | `client/help-content-template.html` | Source for modal copy; fetched at runtime. | 25 | | `client/gate-registry.js`, `client/gates/*.svg` | Gate definitions, icons, logic functions. | 26 | | `client/gate-config.json` | Palette order, export report toggles, default zoom, etc. | 27 | | `client/initial_state.json` | Starter board loaded on first run or after reset. | 28 | | `server.js` | Static server plus `/vhdl/export` and `/message` endpoints. | 29 | 30 | ### Mandatory File Order 31 | Every embedded application must expose the following files (and keep them in this load order): 32 | 1. `bespoke.css` 33 | 2. `help-modal.js` 34 | 3. `app.js` (application logic) 35 | 4. `server.js` 36 | 37 | --- 38 | 39 | ## 3. Styling with Bespoke CSS 40 | 41 | 1. Always apply the `.bespoke` scope (usually on the `` or outermost `
`). 42 | 2. Use only the provided CSS custom properties for colors, spacing, typography, borders, and shadows: 43 | - Colors: `--bespoke-bg`, `--bespoke-fg`, `--bespoke-accent`, `--bespoke-muted`, `--bespoke-box`, `--bespoke-danger`, etc. 44 | - Spacing: `--bespoke-space-xs` … `--bespoke-space-2xl` 45 | - Typography: `--bespoke-font-size-*`, `--bespoke-font-weight-*` 46 | - Borders & radius: `--bespoke-stroke`, `--bespoke-radius-sm|md|lg|xl` 47 | - Shadows: `--bespoke-shadow-sm|md|lg|xl` 48 | 3. Put overrides in app-specific files (e.g., `logic-sim.css`), never inside `bespoke.css`. 49 | 4. Name CSS files in kebab-case (`logic-sim.css`, `gate-palette.css`). 50 | 5. Theme-specific tweaks should be implemented by overriding tokens on `.bespoke`. 51 | 52 | **CSS example** 53 | ```css 54 | .bespoke { 55 | --bespoke-bg: #101217; 56 | --bespoke-accent: #56ccf2; 57 | } 58 | 59 | .canvas-grid { 60 | background-image: linear-gradient(var(--grid-color) 1px, transparent 1px); 61 | box-shadow: var(--bespoke-shadow-lg); 62 | border-radius: var(--bespoke-radius-lg); 63 | } 64 | ``` 65 | 66 | --- 67 | 68 | ## 4. Client Logic & Status Flow 69 | 70 | ### Help Modal 71 | ```js 72 | import HelpModal from './help-modal.js'; 73 | 74 | const helpCopy = await fetch('./help-content-template.html').then(r => r.text()); 75 | new HelpModal({ 76 | triggerSelector: '#btn-help', 77 | content: helpCopy, 78 | theme: 'auto', 79 | }); 80 | ``` 81 | 82 | ### Status Messaging 83 | Use `setStatus()` and only these strings: 84 | - `Ready` 85 | - `Loading...` 86 | - `Saving...` 87 | - `Changes saved` 88 | - `Save failed (will retry)` 89 | - `Failed to load data` 90 | - `Auto-save initialized` 91 | 92 | ### Error Handling & Persistence 93 | 1. Wrap every async workflow in `try/catch`; log errors via `console.error`. 94 | 2. Provide useful UI feedback inside `catch` blocks. 95 | 3. Implement retry logic for network calls (e.g., exponential/backoff for `/vhdl/export`). 96 | 4. Trap and handle `localStorage` quota errors; keep the in-memory canvas running if persistence fails. 97 | 5. Validate data before writes (e.g., snapshot schema, gate payloads). 98 | 99 | ### Auto-Save Expectations 100 | - Debounce saves, call `setStatus('Saving...')`, and show `Changes saved` on success. 101 | - If a save fails, surface `Save failed (will retry)` and schedule the retry. 102 | 103 | --- 104 | 105 | ## 5. Server & Export Workflow 106 | - `GET /` serves everything under `client/`. 107 | - `POST /vhdl/export` expects `{ vhdl: string, state: object }`, writes `user.vhdl` + `state.json`, and triggers `circuit-report.js`. 108 | - `POST /message` broadcasts `{"message":"..."}` to all WebSocket clients (requires `ws`). 109 | - WebSockets become available automatically when `ws` is installed; clients alert incoming messages. 110 | - Client exports are initiated via `window.logicSim.exportToVhdl()`, a `logic-sim:export-vhdl` event, or a parent `postMessage`. 111 | 112 | --- 113 | 114 | ## 6. Code Style Guidelines & Examples 115 | 116 | ### JavaScript 117 | - Prefer ES modules in `client/` (use `import`/`export`), CommonJS in `server.js`. 118 | - Use `const`/`let` (no `var`). Default to `const`. 119 | - Keep functions pure when possible; isolate DOM mutations. 120 | - Always guard async calls with `try/catch` and propagate meaningful errors. 121 | - Document non-trivial flows with short comments (avoid restating obvious code). 122 | - Keep filenames in kebab-case (`logic-sim.js`, `help-modal.js`). 123 | 124 | ```js 125 | export async function loadInitialState() { 126 | setStatus('Loading...'); 127 | try { 128 | const res = await fetch('./initial_state.json'); 129 | if (!res.ok) throw new Error('Starter circuit unavailable'); 130 | const state = await res.json(); 131 | setStatus('Ready'); 132 | return state; 133 | } catch (error) { 134 | console.error('[loadInitialState]', error); 135 | setStatus('Failed to load data'); 136 | throw error; 137 | } 138 | } 139 | ``` 140 | 141 | ### CSS 142 | - Scope selectors under `.bespoke` whenever the rule affects the shared UI. 143 | - Use spacing helpers instead of hard-coded pixels (`padding: var(--bespoke-space-lg)`). 144 | - Favor utility classes from Bespoke before adding new ones; if you add custom classes, prefix them with the component name (`.canvas-toolbar`, `.palette-card`). 145 | 146 | ```css 147 | .bespoke .palette-card { 148 | display: flex; 149 | gap: var(--bespoke-space-sm); 150 | border: 1px solid var(--bespoke-stroke); 151 | } 152 | ``` 153 | 154 | ### HTML 155 | - Keep semantics intact: use `
`, `
`, `
165 | ``` 166 | 167 | --- 168 | 169 | ## 8. Reference Assets 170 | - `client/help-content-template.html` — duplicate sections to document new tools or grading rubrics. 171 | - `circuit-report.js` — adjust report toggles via `client/gate-config.json`. 172 | 173 | Follow this playbook whenever you add new features, help content, or integrations so the Bespoke components remain consistent across every embedded deployment of Logic Circuit Lab. 174 | -------------------------------------------------------------------------------- /client/js/sim/vhdl.js: -------------------------------------------------------------------------------- 1 | export const serializeSnapshotToVhdl = (snapshot = { gates: [], connections: [] }, gateDefinitions = {}) => { 2 | const gateMap = new Map((snapshot.gates || []).map((gate) => [gate.id, gate])); 3 | const connectionLookup = new Map(); 4 | (snapshot.connections || []).forEach((connection) => { 5 | if (!connection?.to?.gateId) { 6 | return; 7 | } 8 | const key = `${connection.to.gateId}:${Number(connection.to?.portIndex) || 0}`; 9 | connectionLookup.set(key, { 10 | gateId: connection.from?.gateId, 11 | portIndex: Number(connection.from?.portIndex) || 0 12 | }); 13 | }); 14 | 15 | const usedNames = new Set(); 16 | const signalNameMap = new Map(); 17 | const portNameMap = new Map(); 18 | 19 | const sanitizeIdentifier = (value, fallback) => { 20 | const cleaned = (value || '') 21 | .toString() 22 | .trim() 23 | .toLowerCase() 24 | .replace(/[^a-z0-9_]+/g, '_'); 25 | let candidate = cleaned.replace(/^[^a-z_]+/, ''); 26 | if (!candidate) { 27 | candidate = fallback; 28 | } 29 | return candidate || fallback; 30 | }; 31 | 32 | const ensureUnique = (base) => { 33 | let candidate = base; 34 | let counter = 1; 35 | while (usedNames.has(candidate.toLowerCase())) { 36 | candidate = `${base}_${counter}`; 37 | counter += 1; 38 | } 39 | usedNames.add(candidate.toLowerCase()); 40 | return candidate; 41 | }; 42 | 43 | const getSignalName = (gate, index) => { 44 | const key = `${gate.id}:${index}`; 45 | if (signalNameMap.has(key)) { 46 | return signalNameMap.get(key); 47 | } 48 | const base = sanitizeIdentifier(`${gate.type}_${gate.id}_${index}`, `sig_${signalNameMap.size}`); 49 | const unique = ensureUnique(base); 50 | signalNameMap.set(key, unique); 51 | return unique; 52 | }; 53 | 54 | const getPortName = (gate) => { 55 | const base = sanitizeIdentifier(gate.label || gate.type || 'output', `out_${portNameMap.size}`); 56 | const unique = ensureUnique(base); 57 | portNameMap.set(gate.id, unique); 58 | return unique; 59 | }; 60 | 61 | const resolveInputSignal = (gateId, portIndex) => { 62 | const key = `${gateId}:${portIndex}`; 63 | const from = connectionLookup.get(key); 64 | if (!from) { 65 | return null; 66 | } 67 | const gate = gateMap.get(from.gateId); 68 | if (!gate) { 69 | return null; 70 | } 71 | const definition = gateDefinitions[gate.type]; 72 | if (!definition) { 73 | return null; 74 | } 75 | if (definition.outputs === 0 && gate.type !== 'input') { 76 | return resolveInputSignal(gate.id, 0); 77 | } 78 | return getSignalName(gate, from.portIndex); 79 | }; 80 | 81 | const applyCustomVhdlTemplate = (template, context) => { 82 | if (!template || !template.trim()) { 83 | return ''; 84 | } 85 | const safeInputs = Array.isArray(context.inputs) && context.inputs.length 86 | ? context.inputs 87 | : [`'0'`]; 88 | const safeOutputs = Array.isArray(context.outputs) ? context.outputs : []; 89 | const resolveIndexed = (collection, index, fallback = `'0'`) => { 90 | const numeric = Number(index); 91 | if (!Number.isFinite(numeric) || numeric < 0) { 92 | return fallback; 93 | } 94 | return collection[numeric] ?? (collection.length ? collection[0] : fallback); 95 | }; 96 | return template 97 | .replace(/\{\{\s*input:(\d+)\s*\}\}/gi, (_, index) => resolveIndexed(safeInputs, index, `'0'`)) 98 | .replace(/\{\{\s*output:(\d+)\s*\}\}/gi, (_, index) => resolveIndexed(safeOutputs, index, safeOutputs[0] || `'0'`)) 99 | .replace(/\{\{\s*gateId\s*\}\}/gi, context.gate?.id || '') 100 | .replace(/\{\{\s*label\s*\}\}/gi, (context.gate?.label || '').trim()) 101 | .replace(/\{\{\s*type\s*\}\}/gi, (context.definition?.label || '').trim()); 102 | }; 103 | 104 | const signalDeclarations = new Set(); 105 | const assignmentLines = []; 106 | const outputAssignments = []; 107 | const outputPorts = []; 108 | 109 | (snapshot.gates || []).forEach((gate) => { 110 | const definition = gateDefinitions[gate.type]; 111 | if (!definition) { 112 | return; 113 | } 114 | 115 | if (definition.outputs > 0 && gate.type !== 'output') { 116 | for (let i = 0; i < definition.outputs; i += 1) { 117 | const sig = getSignalName(gate, i); 118 | signalDeclarations.add(`signal ${sig} : STD_LOGIC;`); 119 | } 120 | } 121 | 122 | if (gate.type === 'input') { 123 | const sig = getSignalName(gate, 0); 124 | const comment = gate.label ? ` -- ${definition.label} ${gate.label}` : ` -- ${definition.label}`; 125 | assignmentLines.push(`${sig} <= '${gate.state ? '1' : '0'}';${comment}`); 126 | return; 127 | } 128 | 129 | if (gate.type === 'output') { 130 | const inputSignal = resolveInputSignal(gate.id, 0); 131 | const portName = getPortName(gate); 132 | outputPorts.push(`${portName} : out STD_LOGIC`); 133 | const comment = gate.label ? ` -- ${definition.label} ${gate.label}` : ` -- ${definition.label}`; 134 | outputAssignments.push(`${portName} <= ${inputSignal || "'0'"};${comment}`); 135 | return; 136 | } 137 | 138 | const definitionInputs = definition.inputs || 0; 139 | const inputSignals = []; 140 | for (let i = 0; i < definitionInputs; i += 1) { 141 | inputSignals.push(resolveInputSignal(gate.id, i)); 142 | } 143 | const normalizedInputs = inputSignals.length ? inputSignals : [`'0'`]; 144 | const totalOutputs = definition.outputs || 0; 145 | const targetSignals = []; 146 | for (let i = 0; i < totalOutputs; i += 1) { 147 | targetSignals.push(getSignalName(gate, i)); 148 | } 149 | 150 | if (definition.renderMode === 'custom-square') { 151 | if (definition.customVhdl) { 152 | const snippet = applyCustomVhdlTemplate(definition.customVhdl, { 153 | inputs: normalizedInputs, 154 | outputs: targetSignals, 155 | gate, 156 | definition 157 | }); 158 | snippet 159 | .split('\n') 160 | .map((line) => line.trimEnd()) 161 | .filter((line) => line.trim().length) 162 | .forEach((line) => assignmentLines.push(line)); 163 | return; 164 | } 165 | throw new Error(`Custom gate "${definition.label}" cannot be exported to VHDL. Please expand the circuit before exporting.`); 166 | } 167 | 168 | const targetSignal = targetSignals[0] || getSignalName(gate, 0); 169 | 170 | const binaryExpression = (operator) => normalizedInputs.reduce((acc, curr) => ( 171 | acc ? `${acc} ${operator} ${curr}` : curr 172 | ), ''); 173 | 174 | let expression; 175 | switch (gate.type) { 176 | case 'buffer': 177 | expression = normalizedInputs[0] || `'0'`; 178 | break; 179 | case 'not': 180 | expression = `not ${normalizedInputs[0] || "'0'"}`; 181 | break; 182 | case 'and': 183 | expression = binaryExpression('and') || `'0'`; 184 | break; 185 | case 'nand': 186 | expression = `not (${binaryExpression('and') || "'0'"})`; 187 | break; 188 | case 'or': 189 | expression = binaryExpression('or') || `'0'`; 190 | break; 191 | case 'nor': 192 | expression = `not (${binaryExpression('or') || "'0'"})`; 193 | break; 194 | case 'xor': 195 | expression = binaryExpression('xor') || `'0'`; 196 | break; 197 | default: 198 | expression = normalizedInputs[0] || `'0'`; 199 | } 200 | 201 | const comment = gate.label ? ` -- ${definition.label} ${gate.label}` : ` -- ${definition.label}`; 202 | assignmentLines.push(`${targetSignal} <= ${expression};${comment}`); 203 | }); 204 | 205 | const commentLines = [ 206 | '-- Logic Circuit Lab export', 207 | '' 208 | ]; 209 | 210 | const headerLines = [ 211 | 'library IEEE;', 212 | 'use IEEE.STD_LOGIC_1164.ALL;', 213 | '' 214 | ]; 215 | 216 | const entityLines = outputPorts.length 217 | ? [ 218 | 'entity logic_circuit_lab is', 219 | ' port (', 220 | ` ${outputPorts.join(',\n ')}`, 221 | ' );', 222 | 'end entity logic_circuit_lab;', 223 | '' 224 | ] 225 | : [ 226 | 'entity logic_circuit_lab is', 227 | 'end entity logic_circuit_lab;', 228 | '' 229 | ]; 230 | 231 | const architectureLines = [ 232 | 'architecture behavioral of logic_circuit_lab is', 233 | ...Array.from(signalDeclarations).map((line) => ` ${line}`), 234 | 'begin', 235 | ...assignmentLines.map((line) => ` ${line}`), 236 | ...outputAssignments.map((line) => ` ${line}`), 237 | 'end architecture behavioral;', 238 | '' 239 | ]; 240 | 241 | return [ 242 | ...commentLines, 243 | ...headerLines, 244 | ...entityLines, 245 | ...architectureLines 246 | ].join('\n'); 247 | }; 248 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logic Circuit Lab 2 | 3 | Logic Circuit Lab is a browser-based playground for sketching, simulating, and exporting small digital circuits. The UI is built on the Bespoke generalized components so it can be embedded anywhere, while a lightweight Node.js server serves the app, handles VHDL exports, and exposes a simple messaging API. 4 | 5 | --- 6 | 7 | ## What’s Inside 8 | - `client/index.html` – the full Bespoke application (header, palette, canvas, inspector, help trigger). 9 | - `client/logic-sim.js` – canvas rendering, drag/drop, wiring, evaluation, auto-save, export helpers. 10 | - `client/logic-sim.css` – circuit-specific styling layered on top of `client/bespoke.css`. 11 | - `client/gate-registry.js` & `client/gates/*.svg` – built-in gate definitions, icons, and logic. 12 | - `client/help-modal.js` & `client/help-content-template.html` – Help modal framework and copy. 13 | - `client/initial_state.json` – starter circuit loaded on first launch or after reset. 14 | - `client/gate-config.json` – palette ordering, default zoom, and export-report options. 15 | - `server.js` – static file server, `/vhdl/export` handler, `/message` relay, optional WebSocket hub. 16 | - `circuit-report.js` – pretty printer that summarizes each export to the console. 17 | 18 | --- 19 | 20 | ## Features at a Glance 21 | - Drag gates from the palette or click to drop them at the center of the viewport. 22 | - Wire outputs to inputs with a click/tap workflow; wires snap to ports and redraw as you move gates. 23 | - Real-time simulation with deterministic propagation and cached inputs to avoid oscillations. 24 | - Selection panel with gate metadata, label editing, and context actions (toggle inputs, delete, etc.). 25 | - Infinite workspace with smooth pan (drag on empty canvas) and wheel-based zoom (Ctrl+wheel for precision). 26 | - Auto-save to `localStorage` with standardized Bespoke status messaging (`Ready`, `Saving...`, etc.). 27 | - Reset button reloads `initial_state.json` and re-primes auto-save. 28 | - Help modal content streamed from `help-content-template.html` and triggered by the header Help button. 29 | - One-click (or scripted) VHDL export that posts the circuit snapshot to the Node server. 30 | 31 | --- 32 | 33 | ## Getting Started 34 | 1. **Install prerequisites** – Node.js 18+ and npm. 35 | 2. **Install dependencies** – Run `npm install` if you need to add/refresh modules. 36 | 3. **Start the dev server** 37 | ```bash 38 | npm start 39 | ``` 40 | The server listens on `http://localhost:3000` and serves the contents of `client/`. 41 | 4. **Open the app** – visit `http://localhost:3000` to launch Logic Circuit Lab. 42 | 5. **Optional: broadcast a message** 43 | ```bash 44 | curl -X POST http://localhost:3000/message \ 45 | -H "Content-Type: application/json" \ 46 | -d '{"message":"Hello lab!"}' 47 | ``` 48 | Requires the `ws` dependency to be installed; all connected clients will display the alert. 49 | 50 | --- 51 | 52 | ## Using the Interface 53 | - **Palette (left sidebar)** – click a gate to drop it at the center, or drag it directly into the canvas. Available gates include Input, Output, Buffer, NOT, AND, NAND, OR, NOR, and XOR; extend the list via `gate-registry.js`. 54 | - **Canvas** – drag on empty space to pan, use the mouse wheel (or pinch gesture) to zoom, and click a gate to select it. Press Delete/Backspace to remove the active gate. Selection outlines show connection points; ports highlight when valid wiring targets are available. 55 | - **Wiring workflow** – click an output port, then click a compatible input port. A ghost wire follows your cursor until you pick a destination. Clicking an occupied input rewires it to the new source. 56 | - **Inputs and outputs** – input gates act as toggles; click them (or use the inspector action) to switch between 0/1. Output gates display the live signal coming into their single input. 57 | - **Inspector (Selection card)** – shows the currently selected gate’s properties, allows renaming labels, exposes gate-specific actions, and lists every connected port. 58 | - **Reset** – the “Reset” button above the canvas replaces the board with `initial_state.json` and reinitializes auto-save. 59 | - **Help** – the `Help` button in the header opens the modal populated from `help-content-template.html`. Update that file to document your specific lab instructions, keyboard shortcuts, or grading rubric. 60 | 61 | --- 62 | 63 | ## Custom Gates 64 | - Custom gates are described through `.json` snapshots that match the `state.json` schema (the object returned by `window.logicSim.snapshot()`). 65 | - Build a circuit with the standard palette, export it via `window.logicSim.snapshot()` (or copy `state.json`), and drop that JSON file into `client/custom-gates/` to turn it into a reusable gate without duplicating its contents. 66 | - Input and output pins for the custom gate are inferred from the `input` and `output` gates inside the JSON snapshot (their labels become the exposed port names). Simulation treats the custom gate as a black box by running the nested snapshot with the same propagation engine. 67 | - Snapshots embedded in saved circuits (or discovered under `client/custom-gates/`) automatically appear in the main palette alongside the built-in gates. 68 | - VHDL exports automatically flatten any custom gates, so you can keep them in your design and still obtain a complete netlist (the written `state.json` mirrors that flattened export). 69 | 70 | --- 71 | 72 | ## Saving, Loading, and Status Messages 73 | - On first load, `logic-sim.js` fetches `initial_state.json`, applies it to the canvas, and caches it locally. 74 | - Once the app confirms that `localStorage` works, every mutation is debounced (500 ms) and saved under the key `logic-circuit-lab-state-v1`. 75 | - Status changes are limited to the mandated Bespoke messages; `setStatus()` is exposed globally for future integrations. 76 | - If persistence fails (quota exceeded, private mode, etc.), the app logs the error, shows `Failed to load data`, and keeps the in-memory circuit running. 77 | - Resetting the canvas writes the starter snapshot back to storage, ensuring the pool stays in sync across reloads. 78 | 79 | --- 80 | 81 | ## Exporting to VHDL 82 | 1. Trigger an export by running one of the following in DevTools or from a parent frame: 83 | ```javascript 84 | window.logicSim.exportToVhdl(); 85 | // or 86 | window.dispatchEvent(new Event('logic-sim:export-vhdl')); 87 | // or 88 | window.postMessage({ type: 'logic-sim:export-vhdl' }, '*'); 89 | ``` 90 | 2. The client serializes the current snapshot to VHDL, then POSTs: 91 | ```json 92 | { 93 | "vhdl": "", 94 | "state": { ...current circuit snapshot... } 95 | } 96 | ``` 97 | to `POST /vhdl/export`. 98 | 3. The server writes `user.vhdl` and `state.json` at the repo root, then runs `circuit-report.printCircuitReport()` to log counts, positions, connection issues, and (optionally) a truth table. Report sections are toggled via `gate-config.json > exportReport`. 99 | 4. If the request fails, the client shows `Save failed (will retry)` and automatically retries after 3 s. 100 | 101 | --- 102 | 103 | ## Configuration & Data Files 104 | - **`client/gate-config.json`** 105 | - `defaultZoom` – initial canvas scale (clamped between 0.5 and 2.75). 106 | - `paletteOrder` – ordered list of gate type keys from `gate-registry.js`. 107 | - `exportReport` – toggles for each console report section plus truth-table limits. 108 | - **`client/initial_state.json`** 109 | - Defines the starter circuit (`gates`, `connections`, `nextId`). Edit coordinates, labels, or states to showcase a different demo when the app loads. 110 | - **`client/gate-registry.js`** 111 | - Extend `registerGate()` calls to add new gate types. Provide an SVG icon, `inputs`, `outputs`, port layout, and a pure logic function that returns output bits. 112 | - **`client/logic-sim.css`** 113 | - Customize colors, gate chrome, canvas grid, inspector layout, etc. Keep theme tokens (`--bespoke-*`) to remain compatible with host pages. 114 | - **`client/help-content-template.html`** 115 | - Replace placeholders with real instructions. The file is fetched as text, so inline images should use relative paths inside `client/`. 116 | 117 | --- 118 | 119 | ## Server API & Messaging 120 | - `GET /` (and static assets) – serves files from `client/`. 121 | - `POST /vhdl/export` – accepts `{ vhdl: string, state: object }`, writes artifacts, prints reports, responds with `{ success: true }` on success. 122 | - `POST /message` – accepts `{ message: string }`, broadcasts it to every connected WebSocket client (`ws` package required). Clients show an alert with the message body. 123 | - WebSockets – automatically enabled when the `ws` dependency is installed. Connections are logged, and messages flow only from `/message` to the clients; there is no inbound command channel yet. 124 | 125 | --- 126 | 127 | ## Embedding & Automation Hooks 128 | - The canvas exposes `window.logicSim` with: 129 | - `exportToVhdl()` – triggers the export flow described above. 130 | - `snapshot()` – returns the current normalized circuit state. 131 | - `resetToStarter()` – programmatically mirrors the Reset button. 132 | - Custom exporters can listen for `message` events (`event.data.type === 'logic-sim:export-vhdl'`) or dispatch the `logic-sim:export-vhdl` DOM event. 133 | - The app uses standard Bespoke status messaging and `.bespoke` scoping, so you can embed `client/index.html` inside `test-integration.html` or another host without style collisions. -------------------------------------------------------------------------------- /client/js/sim/snapshot.js: -------------------------------------------------------------------------------- 1 | import { COORDINATE_VERSION, HALF_WORKSPACE } from './constants.js'; 2 | import { normalizeRotation } from './geometry.js'; 3 | import { prepareCustomGateInterface } from './custom-gate-utils.js'; 4 | 5 | export const normalizeSnapshot = (input = {}) => { 6 | const payload = input && typeof input === 'object' 7 | ? (typeof input.snapshot === 'object' ? input.snapshot : input) 8 | : {}; 9 | 10 | const declaredOrigin = typeof payload.origin === 'string' ? payload.origin.toLowerCase() : null; 11 | const version = Number(payload.version) || 1; 12 | const originMode = declaredOrigin === 'center' 13 | ? 'center' 14 | : (declaredOrigin === 'top-left' ? 'top-left' : (version >= COORDINATE_VERSION ? 'center' : 'top-left')); 15 | 16 | const convertCoordinate = (value) => { 17 | const numeric = Number(value); 18 | if (!Number.isFinite(numeric)) { 19 | return 0; 20 | } 21 | return originMode === 'center' ? numeric : numeric - HALF_WORKSPACE; 22 | }; 23 | 24 | const gatesSource = Array.isArray(payload.gates) 25 | ? payload.gates 26 | : (Array.isArray(payload.positions) ? payload.positions : []); 27 | 28 | const gates = gatesSource.map((entry) => ({ 29 | id: entry.id, 30 | type: entry.type, 31 | x: convertCoordinate(entry.x), 32 | y: convertCoordinate(entry.y), 33 | label: typeof entry.label === 'string' ? entry.label : '', 34 | state: Number(entry.state) === 1 ? 1 : 0, 35 | rotation: normalizeRotation(entry.rotation) 36 | })); 37 | 38 | const normalizePortIndex = (value) => { 39 | const numeric = Number(value); 40 | return Number.isFinite(numeric) ? numeric : 0; 41 | }; 42 | 43 | const connections = Array.isArray(payload.connections) 44 | ? payload.connections.map((connection) => ({ 45 | id: connection.id ?? Math.random().toString(36).slice(2, 15), 46 | from: { 47 | gateId: connection.from?.gateId, 48 | portIndex: normalizePortIndex(connection.from?.portIndex) 49 | }, 50 | to: { 51 | gateId: connection.to?.gateId, 52 | portIndex: normalizePortIndex(connection.to?.portIndex) 53 | } 54 | })) 55 | : []; 56 | 57 | const customGates = Array.isArray(payload.customGates) 58 | ? payload.customGates 59 | .map((entry) => { 60 | if (!entry || typeof entry.type !== 'string') { 61 | return null; 62 | } 63 | return { 64 | type: entry.type, 65 | label: typeof entry.label === 'string' ? entry.label : entry.type, 66 | fileName: typeof entry.fileName === 'string' ? entry.fileName : '', 67 | inputNames: Array.isArray(entry.inputNames) 68 | ? entry.inputNames.filter((name) => typeof name === 'string') 69 | : [], 70 | outputNames: Array.isArray(entry.outputNames) 71 | ? entry.outputNames.filter((name) => typeof name === 'string') 72 | : [], 73 | abbreviation: typeof entry.abbreviation === 'string' ? entry.abbreviation : undefined, 74 | customVhdl: typeof entry.customVhdl === 'string' && entry.customVhdl.trim() 75 | ? entry.customVhdl.trim() 76 | : '', 77 | source: entry.source === 'embedded' ? 'embedded' : (entry.source === 'filesystem' ? 'filesystem' : 'library'), 78 | snapshot: typeof entry.snapshot === 'object' ? entry.snapshot : null 79 | }; 80 | }) 81 | .filter(Boolean) 82 | : []; 83 | 84 | const nextId = Number(payload.nextId); 85 | 86 | return { 87 | version, 88 | origin: 'center', 89 | nextId: Number.isFinite(nextId) && nextId > 0 ? nextId : undefined, 90 | gates, 91 | connections, 92 | customGates 93 | }; 94 | }; 95 | 96 | export const flattenSnapshotForExport = (snapshot = {}, gateDefinitions = {}) => { 97 | const originalGates = Array.isArray(snapshot.gates) ? snapshot.gates : []; 98 | const originalConnections = Array.isArray(snapshot.connections) ? snapshot.connections : []; 99 | const usedIds = new Set(originalGates.map((gate) => String(gate.id))); 100 | const mapOriginalId = new Map(); 101 | const flattenedGates = []; 102 | const flattenedConnections = []; 103 | const expansions = new Map(); 104 | let connectionCounter = 0; 105 | 106 | const generateGateId = () => { 107 | let candidate; 108 | do { 109 | candidate = `cxg${connectionCounter++}`; 110 | } while (usedIds.has(candidate)); 111 | usedIds.add(candidate); 112 | return candidate; 113 | }; 114 | 115 | const generateConnectionId = (preferred) => { 116 | if (preferred && !usedIds.has(preferred)) { 117 | usedIds.add(preferred); 118 | return preferred; 119 | } 120 | let candidate; 121 | do { 122 | candidate = `cxc${connectionCounter++}`; 123 | } while (usedIds.has(candidate)); 124 | usedIds.add(candidate); 125 | return candidate; 126 | }; 127 | 128 | const addConnection = (from, to, originalId) => { 129 | if (!from?.gateId || !to?.gateId) { 130 | return; 131 | } 132 | flattenedConnections.push({ 133 | id: generateConnectionId(originalId), 134 | from: { gateId: from.gateId, portIndex: Number(from.portIndex) || 0 }, 135 | to: { gateId: to.gateId, portIndex: Number(to.portIndex) || 0 } 136 | }); 137 | }; 138 | 139 | originalGates.forEach((gate) => { 140 | const definition = gateDefinitions[gate.type]; 141 | if (!definition) { 142 | return; 143 | } 144 | if (definition.renderMode !== 'custom-square' || !definition.customSnapshot) { 145 | mapOriginalId.set(gate.id, gate.id); 146 | flattenedGates.push({ ...gate }); 147 | return; 148 | } 149 | 150 | const snapshotClone = normalizeSnapshot(definition.customSnapshot); 151 | const interfaceInfo = prepareCustomGateInterface(snapshotClone); 152 | const placeholderInputIndex = new Map(); 153 | interfaceInfo.inputGateIds.forEach((id, index) => placeholderInputIndex.set(id, index)); 154 | const placeholderOutputIndex = new Map(); 155 | interfaceInfo.outputGateIds.forEach((id, index) => placeholderOutputIndex.set(id, index)); 156 | 157 | const internalGateIdMap = new Map(); 158 | 159 | snapshotClone.gates.forEach((child) => { 160 | if (placeholderInputIndex.has(child.id) || placeholderOutputIndex.has(child.id)) { 161 | return; 162 | } 163 | const childDefinition = gateDefinitions[child.type]; 164 | if (!childDefinition) { 165 | throw new Error(`Custom gate snapshot for "${definition.label}" references unknown gate "${child.type}".`); 166 | } 167 | if (childDefinition.renderMode === 'custom-square') { 168 | throw new Error(`Nested custom gates are not supported inside "${definition.label}".`); 169 | } 170 | const newId = generateGateId(); 171 | internalGateIdMap.set(child.id, newId); 172 | flattenedGates.push({ ...child, id: newId }); 173 | }); 174 | 175 | const inputTargets = new Map(); 176 | const outputSources = new Map(); 177 | 178 | snapshotClone.connections.forEach((connection) => { 179 | const fromIndex = placeholderInputIndex.get(connection.from?.gateId); 180 | const toIndex = placeholderOutputIndex.get(connection.to?.gateId); 181 | if (fromIndex !== undefined) { 182 | const targetGateId = internalGateIdMap.get(connection.to?.gateId); 183 | if (targetGateId) { 184 | const current = inputTargets.get(fromIndex) || []; 185 | current.push({ 186 | gateId: targetGateId, 187 | portIndex: Number(connection.to?.portIndex) || 0 188 | }); 189 | inputTargets.set(fromIndex, current); 190 | } 191 | return; 192 | } 193 | if (toIndex !== undefined) { 194 | const sourceGateId = internalGateIdMap.get(connection.from?.gateId); 195 | if (sourceGateId) { 196 | const current = outputSources.get(toIndex) || []; 197 | current.push({ 198 | gateId: sourceGateId, 199 | portIndex: Number(connection.from?.portIndex) || 0 200 | }); 201 | outputSources.set(toIndex, current); 202 | } 203 | return; 204 | } 205 | 206 | const fromGateId = internalGateIdMap.get(connection.from?.gateId); 207 | const toGateId = internalGateIdMap.get(connection.to?.gateId); 208 | if (fromGateId && toGateId) { 209 | addConnection( 210 | { gateId: fromGateId, portIndex: Number(connection.from?.portIndex) || 0 }, 211 | { gateId: toGateId, portIndex: Number(connection.to?.portIndex) || 0 }, 212 | connection.id 213 | ); 214 | } 215 | }); 216 | 217 | mapOriginalId.set(gate.id, gate.id); 218 | expansions.set(gate.id, { 219 | interface: interfaceInfo, 220 | inputs: inputTargets, 221 | outputs: outputSources 222 | }); 223 | }); 224 | 225 | originalConnections.forEach((connection) => { 226 | const sourceExpansion = expansions.get(connection.from?.gateId); 227 | const targetExpansion = expansions.get(connection.to?.gateId); 228 | const sourceList = sourceExpansion 229 | ? sourceExpansion.outputs.get(Number(connection.from?.portIndex) || 0) 230 | : [{ gateId: mapOriginalId.get(connection.from?.gateId), portIndex: Number(connection.from?.portIndex) || 0 }]; 231 | const targetList = targetExpansion 232 | ? targetExpansion.inputs.get(Number(connection.to?.portIndex) || 0) 233 | : [{ gateId: mapOriginalId.get(connection.to?.gateId), portIndex: Number(connection.to?.portIndex) || 0 }]; 234 | 235 | sourceList.forEach((source) => { 236 | if (!source?.gateId) { 237 | return; 238 | } 239 | targetList.forEach((target) => { 240 | if (!target?.gateId) { 241 | return; 242 | } 243 | addConnection(source, target, connection.id); 244 | }); 245 | }); 246 | }); 247 | 248 | const nextIdCandidate = flattenedGates.reduce((max, gate) => { 249 | const numeric = Number(String(gate.id).replace(/\D+/g, '')); 250 | if (Number.isFinite(numeric)) { 251 | return Math.max(max, numeric + 1); 252 | } 253 | return max; 254 | }, 1); 255 | 256 | return { 257 | version: snapshot.version || COORDINATE_VERSION, 258 | origin: snapshot.origin || 'center', 259 | nextId: Math.max(Number(snapshot.nextId) || 1, nextIdCandidate), 260 | gates: flattenedGates, 261 | connections: flattenedConnections 262 | }; 263 | }; 264 | -------------------------------------------------------------------------------- /vhdl-serializer.js: -------------------------------------------------------------------------------- 1 | const gateRegistry = require('./client/gate-registry'); 2 | 3 | const gateDefinitions = gateRegistry?.definitions || {}; 4 | 5 | function serializeSnapshotToVhdl(snapshot = { gates: [], connections: [] }) { 6 | const gateMap = new Map((snapshot.gates || []).map((gate) => [gate.id, gate])); 7 | const connectionLookup = new Map(); 8 | const customGateLookup = new Map(); 9 | (snapshot.customGates || []).forEach((entry) => { 10 | if (entry?.type) { 11 | customGateLookup.set(entry.type, entry); 12 | } 13 | }); 14 | (snapshot.connections || []).forEach((connection) => { 15 | if (!connection?.to?.gateId) { 16 | return; 17 | } 18 | const key = `${connection.to.gateId}:${Number(connection.to.portIndex) || 0}`; 19 | connectionLookup.set(key, { 20 | gateId: connection.from?.gateId, 21 | portIndex: Number(connection.from?.portIndex) || 0 22 | }); 23 | }); 24 | 25 | const usedNames = new Set(); 26 | const signalNameMap = new Map(); 27 | const portNameMap = new Map(); 28 | 29 | const sanitizeIdentifier = (value, fallback) => { 30 | const cleaned = (value || '') 31 | .toString() 32 | .trim() 33 | .toLowerCase() 34 | .replace(/[^a-z0-9_]+/g, '_'); 35 | let candidate = cleaned.replace(/^[^a-z_]+/, ''); 36 | if (!candidate) { 37 | candidate = fallback; 38 | } 39 | return candidate || fallback; 40 | }; 41 | 42 | const ensureUnique = (base) => { 43 | let candidate = base; 44 | let counter = 1; 45 | while (usedNames.has(candidate.toLowerCase())) { 46 | candidate = `${base}_${counter}`; 47 | counter += 1; 48 | } 49 | usedNames.add(candidate.toLowerCase()); 50 | return candidate; 51 | }; 52 | 53 | const getSignalName = (gate, index = 0) => { 54 | const key = `${gate.id}:${index}`; 55 | if (signalNameMap.has(key)) { 56 | return signalNameMap.get(key); 57 | } 58 | const definition = gateDefinitions[gate.type] || customGateLookup.get(gate.type); 59 | const typeSlug = sanitizeIdentifier((definition?.label || gate.type), `node_${gate.id}`); 60 | const labelSlug = (gate.label || '').trim() ? sanitizeIdentifier(gate.label, `${typeSlug}_${gate.id}`) : ''; 61 | const base = labelSlug || `${typeSlug}_${gate.id}_${index}`; 62 | const unique = ensureUnique(base || `node_${gate.id}_${index}`); 63 | signalNameMap.set(key, unique); 64 | return unique; 65 | }; 66 | 67 | const getPortName = (gate) => { 68 | if (portNameMap.has(gate.id)) { 69 | return portNameMap.get(gate.id); 70 | } 71 | const definition = gateDefinitions[gate.type]; 72 | const typeSlug = sanitizeIdentifier((definition?.label || gate.type), `out_${gate.id}`); 73 | const baseCandidate = (gate.label || '').trim() ? sanitizeIdentifier(gate.label, `${typeSlug}_${gate.id}`) : `${typeSlug}_${gate.id}`; 74 | const unique = ensureUnique(baseCandidate || `out_${gate.id}`); 75 | portNameMap.set(gate.id, unique); 76 | return unique; 77 | }; 78 | 79 | const resolveInputSignal = (gateId, portIndex) => { 80 | const key = `${gateId}:${portIndex}`; 81 | const from = connectionLookup.get(key); 82 | if (!from) { 83 | return `'0'`; 84 | } 85 | const sourceGate = gateMap.get(from.gateId); 86 | if (!sourceGate) { 87 | return `'0'`; 88 | } 89 | return getSignalName(sourceGate, from.portIndex || 0); 90 | }; 91 | 92 | const applyCustomVhdlTemplate = (template, context) => { 93 | if (!template || !template.trim()) { 94 | return ''; 95 | } 96 | const safeInputs = Array.isArray(context.inputs) && context.inputs.length 97 | ? context.inputs 98 | : [`'0'`]; 99 | const safeOutputs = Array.isArray(context.outputs) ? context.outputs : []; 100 | const resolveIndexed = (collection, index, fallback = `'0'`) => { 101 | const numeric = Number(index); 102 | if (!Number.isFinite(numeric) || numeric < 0) { 103 | return fallback; 104 | } 105 | return collection[numeric] ?? (collection.length ? collection[0] : fallback); 106 | }; 107 | return template 108 | .replace(/\{\{\s*input:(\d+)\s*\}\}/gi, (_, index) => resolveIndexed(safeInputs, index, `'0'`)) 109 | .replace(/\{\{\s*output:(\d+)\s*\}\}/gi, (_, index) => resolveIndexed(safeOutputs, index, safeOutputs[0] || `'0'`)) 110 | .replace(/\{\{\s*gateId\s*\}\}/gi, context.gate?.id || '') 111 | .replace(/\{\{\s*label\s*\}\}/gi, (context.gate?.label || '').trim()) 112 | .replace(/\{\{\s*type\s*\}\}/gi, (context.definition?.label || '').trim()); 113 | }; 114 | 115 | const signalDeclarations = new Set(); 116 | const assignmentLines = []; 117 | const outputAssignments = []; 118 | const outputPorts = []; 119 | 120 | (snapshot.gates || []).forEach((gate) => { 121 | const registryDefinition = gateDefinitions[gate.type]; 122 | const customEntry = registryDefinition ? null : customGateLookup.get(gate.type); 123 | if (!registryDefinition && !customEntry) { 124 | return; 125 | } 126 | const definition = registryDefinition || { 127 | label: customEntry.label || gate.type, 128 | inputs: Array.isArray(customEntry.inputNames) ? customEntry.inputNames.length : 0, 129 | outputs: Array.isArray(customEntry.outputNames) ? customEntry.outputNames.length : 0, 130 | customVhdl: typeof customEntry.customVhdl === 'string' ? customEntry.customVhdl : '' 131 | }; 132 | const customVhdlTemplate = typeof definition.customVhdl === 'string' && definition.customVhdl.trim() 133 | ? definition.customVhdl 134 | : (typeof customEntry?.customVhdl === 'string' ? customEntry.customVhdl : ''); 135 | 136 | if (definition.outputs > 0 && gate.type !== 'output') { 137 | for (let i = 0; i < definition.outputs; i += 1) { 138 | const sig = getSignalName(gate, i); 139 | signalDeclarations.add(`signal ${sig} : STD_LOGIC;`); 140 | } 141 | } 142 | 143 | if (gate.type === 'input') { 144 | const sig = getSignalName(gate, 0); 145 | const comment = gate.label ? ` -- ${definition.label} ${gate.label}` : ` -- ${definition.label}`; 146 | assignmentLines.push(`${sig} <= '${gate.state ? '1' : '0'}';${comment}`); 147 | return; 148 | } 149 | 150 | if (gate.type === 'output') { 151 | const inputSignal = resolveInputSignal(gate.id, 0); 152 | const portName = getPortName(gate); 153 | outputPorts.push(`${portName} : out STD_LOGIC`); 154 | const comment = gate.label ? ` -- ${definition.label} ${gate.label}` : ` -- ${definition.label}`; 155 | outputAssignments.push(`${portName} <= ${inputSignal || "'0'"};${comment}`); 156 | return; 157 | } 158 | 159 | const normalizedInputs = []; 160 | for (let i = 0; i < definition.inputs; i += 1) { 161 | normalizedInputs.push(resolveInputSignal(gate.id, i)); 162 | } 163 | const resolvedInputs = normalizedInputs.length ? normalizedInputs : [`'0'`]; 164 | const targetSignals = []; 165 | for (let i = 0; i < (definition.outputs || 0); i += 1) { 166 | targetSignals.push(getSignalName(gate, i)); 167 | } 168 | 169 | if (customEntry && !customVhdlTemplate) { 170 | throw new Error(`Custom gate "${definition.label}" cannot be exported to VHDL. Please expand the circuit before exporting.`); 171 | } 172 | 173 | if (customVhdlTemplate) { 174 | const snippet = applyCustomVhdlTemplate(customVhdlTemplate, { 175 | inputs: resolvedInputs, 176 | outputs: targetSignals, 177 | gate, 178 | definition 179 | }); 180 | snippet 181 | .split('\n') 182 | .map((line) => line.trimEnd()) 183 | .filter((line) => line.trim().length) 184 | .forEach((line) => assignmentLines.push(line)); 185 | return; 186 | } 187 | 188 | const targetSignal = targetSignals[0] || getSignalName(gate, 0); 189 | 190 | const binaryExpression = (operator) => { 191 | if (!resolvedInputs.length) { 192 | return null; 193 | } 194 | if (resolvedInputs.length === 1) { 195 | return resolvedInputs[0]; 196 | } 197 | return resolvedInputs.map((input) => `(${input})`).join(` ${operator} `); 198 | }; 199 | 200 | let expression = resolvedInputs[0] || `'0'`; 201 | switch (gate.type) { 202 | case 'buffer': 203 | expression = resolvedInputs[0] || `'0'`; 204 | break; 205 | case 'not': 206 | expression = resolvedInputs[0] ? `not (${resolvedInputs[0]})` : `'1'`; 207 | break; 208 | case 'and': 209 | expression = binaryExpression('and') || `'0'`; 210 | break; 211 | case 'nand': 212 | expression = `not (${binaryExpression('and') || "'0'"})`; 213 | break; 214 | case 'or': 215 | expression = binaryExpression('or') || `'0'`; 216 | break; 217 | case 'nor': 218 | expression = `not (${binaryExpression('or') || "'0'"})`; 219 | break; 220 | case 'xor': 221 | expression = binaryExpression('xor') || `'0'`; 222 | break; 223 | default: 224 | expression = resolvedInputs[0] || `'0'`; 225 | } 226 | 227 | const comment = gate.label ? ` -- ${definition.label} ${gate.label}` : ` -- ${definition.label}`; 228 | assignmentLines.push(`${targetSignal} <= ${expression};${comment}`); 229 | }); 230 | 231 | const commentLines = [ 232 | '-- Logic Circuit Lab export', 233 | '' 234 | ]; 235 | 236 | const headerLines = [ 237 | 'library IEEE;', 238 | 'use IEEE.STD_LOGIC_1164.ALL;', 239 | '' 240 | ]; 241 | 242 | const entityLines = outputPorts.length 243 | ? [ 244 | 'entity logic_circuit_lab is', 245 | ' port (', 246 | ` ${outputPorts.join(',\n ')}`, 247 | ' );', 248 | 'end entity logic_circuit_lab;', 249 | '' 250 | ] 251 | : [ 252 | 'entity logic_circuit_lab is', 253 | 'end entity logic_circuit_lab;', 254 | '' 255 | ]; 256 | 257 | const architectureLines = [ 258 | 'architecture behavioral of logic_circuit_lab is', 259 | ...Array.from(signalDeclarations).map((line) => ` ${line}`), 260 | 'begin', 261 | ...assignmentLines.map((line) => ` ${line}`), 262 | ...outputAssignments.map((line) => ` ${line}`), 263 | 'end architecture behavioral;', 264 | '' 265 | ]; 266 | 267 | return [ 268 | ...commentLines, 269 | ...headerLines, 270 | ...entityLines, 271 | ...architectureLines 272 | ].join('\n'); 273 | } 274 | 275 | module.exports = { 276 | serializeSnapshotToVhdl 277 | }; 278 | -------------------------------------------------------------------------------- /client/logic-sim.css: -------------------------------------------------------------------------------- 1 | .bespoke .content-area { 2 | padding: var(--bespoke-space-sm); 3 | } 4 | 5 | .logic-sim { 6 | --logic-grid-line: color-mix(in srgb, var(--bespoke-stroke) 45%, transparent); 7 | --logic-highlight: var(--bespoke-accent); 8 | --gate-size: 64px; 9 | --gate-width: var(--gate-size); 10 | --gate-height: var(--gate-size); 11 | --port-size: 8px; 12 | --logic-gate-base: #000000; 13 | --logic-gate-active: var(--logic-highlight); 14 | } 15 | 16 | @media (prefers-color-scheme: dark) { 17 | .logic-sim { 18 | --logic-gate-base: #ffffff; 19 | } 20 | } 21 | 22 | .logic-sim .header-actions { 23 | display: flex; 24 | align-items: center; 25 | gap: var(--bespoke-space-sm); 26 | } 27 | 28 | .logic-sim .palette-card, 29 | .logic-sim .canvas-card { 30 | display: flex; 31 | flex-direction: column; 32 | gap: var(--bespoke-space-md); 33 | } 34 | 35 | .logic-sim .palette-intro { 36 | margin: 0; 37 | font-size: var(--bespoke-font-size-sm); 38 | color: var(--bespoke-muted); 39 | } 40 | 41 | .logic-sim .palette-list { 42 | display: grid; 43 | grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); 44 | gap: var(--bespoke-space-sm); 45 | } 46 | 47 | .logic-sim .palette-item { 48 | display: flex; 49 | align-items: center; 50 | gap: var(--bespoke-space-sm); 51 | padding: var(--bespoke-space-sm); 52 | border: 1px solid var(--bespoke-stroke); 53 | border-radius: var(--bespoke-radius-md); 54 | background: var(--bespoke-box); 55 | box-shadow: var(--bespoke-shadow-sm); 56 | cursor: grab; 57 | transition: transform 0.12s ease, box-shadow 0.12s ease; 58 | text-align: left; 59 | } 60 | 61 | .logic-sim .palette-item:focus-visible { 62 | outline: 2px solid var(--bespoke-control-focus); 63 | outline-offset: 2px; 64 | } 65 | 66 | .logic-sim .palette-item:hover { 67 | box-shadow: var(--bespoke-shadow-md); 68 | transform: translateY(-2px); 69 | } 70 | 71 | .logic-sim .palette-item:active { 72 | cursor: grabbing; 73 | } 74 | 75 | .logic-sim .palette-label { 76 | font-weight: var(--bespoke-font-weight-medium); 77 | } 78 | 79 | .logic-sim .palette-icon { 80 | width: 48px; 81 | height: 48px; 82 | background-color: var(--logic-gate-base); 83 | mask-image: var(--gate-icon); 84 | mask-repeat: no-repeat; 85 | mask-position: center; 86 | mask-size: contain; 87 | -webkit-mask-image: var(--gate-icon); 88 | -webkit-mask-repeat: no-repeat; 89 | -webkit-mask-position: center; 90 | -webkit-mask-size: contain; 91 | flex-shrink: 0; 92 | } 93 | 94 | .logic-sim .palette-icon.is-custom { 95 | mask: none; 96 | -webkit-mask-image: none; 97 | background: color-mix(in srgb, var(--bespoke-box) 40%, transparent); 98 | border: 1px solid var(--bespoke-stroke); 99 | border-radius: var(--bespoke-radius-md); 100 | display: flex; 101 | align-items: center; 102 | justify-content: center; 103 | font-size: var(--bespoke-font-size-xs); 104 | font-weight: var(--bespoke-font-weight-semibold); 105 | color: var(--bespoke-muted); 106 | } 107 | 108 | .logic-sim .selection-details { 109 | display: flex; 110 | flex-direction: column; 111 | gap: var(--bespoke-space-sm); 112 | font-size: var(--bespoke-font-size-sm); 113 | } 114 | 115 | .logic-sim .selection-details h3 { 116 | margin: 0; 117 | font-size: var(--bespoke-font-size-base); 118 | font-weight: var(--bespoke-font-weight-semibold); 119 | } 120 | 121 | .logic-sim .selection-details dl { 122 | display: grid; 123 | grid-template-columns: max-content 1fr; 124 | gap: var(--bespoke-space-xs) var(--bespoke-space-sm); 125 | margin: 0; 126 | } 127 | 128 | .logic-sim .selection-details dt { 129 | color: var(--bespoke-muted); 130 | } 131 | 132 | .logic-sim .selection-details button { 133 | align-self: flex-start; 134 | } 135 | 136 | .logic-sim .selection-actions { 137 | display: flex; 138 | flex-wrap: wrap; 139 | gap: var(--bespoke-space-sm); 140 | } 141 | 142 | .logic-sim .canvas-card { 143 | padding: var(--bespoke-card-padding-lg); 144 | } 145 | 146 | .logic-sim .canvas-toolbar { 147 | display: flex; 148 | align-items: center; 149 | gap: var(--bespoke-space-sm); 150 | font-size: var(--bespoke-font-size-sm); 151 | color: var(--bespoke-muted); 152 | } 153 | 154 | .logic-sim .canvas-wrapper { 155 | position: relative; 156 | min-height: calc(var(--bespoke-space-2xl) * 20); 157 | background: var(--bespoke-canvas); 158 | border: 1px solid var(--bespoke-stroke); 159 | border-radius: var(--bespoke-radius-xl); 160 | box-shadow: var(--bespoke-shadow-md); 161 | overflow: hidden; 162 | } 163 | 164 | .logic-sim .board { 165 | position: absolute; 166 | top: 0; 167 | left: 0; 168 | transform-origin: 0 0; 169 | background-image: linear-gradient( 170 | to right, 171 | var(--logic-grid-line) 1px, 172 | transparent 1px 173 | ), 174 | linear-gradient( 175 | to bottom, 176 | var(--logic-grid-line) 1px, 177 | transparent 1px 178 | ); 179 | background-size: calc(var(--bespoke-space-2xl) * 0.5) calc(var(--bespoke-space-2xl) * 0.5); 180 | outline: none; 181 | } 182 | 183 | .logic-sim .board:focus-visible { 184 | outline: 2px solid var(--bespoke-control-focus); 185 | outline-offset: 2px; 186 | } 187 | 188 | .logic-sim .board.is-connecting { 189 | cursor: crosshair; 190 | } 191 | 192 | .logic-sim .wire-layer { 193 | position: absolute; 194 | top: 0; 195 | left: 0; 196 | width: 100%; 197 | height: 100%; 198 | transform-origin: 0 0; 199 | pointer-events: none; 200 | } 201 | 202 | .logic-sim .wire { 203 | fill: none; 204 | stroke: color-mix(in srgb, var(--logic-gate-base) 40%, transparent); 205 | stroke-width: 3; 206 | stroke-linecap: round; 207 | transition: stroke 0.12s ease, stroke-width 0.12s ease; 208 | } 209 | 210 | .logic-sim .wire.is-active { 211 | stroke: var(--logic-highlight); 212 | stroke-width: 4; 213 | filter: drop-shadow(0 0 6px color-mix(in srgb, var(--logic-highlight) 60%, transparent)); 214 | } 215 | 216 | .logic-sim .wire.wire-ghost { 217 | stroke: var(--logic-highlight); 218 | stroke-width: 3; 219 | stroke-dasharray: 6 6; 220 | filter: none; 221 | } 222 | 223 | .logic-sim .gate { 224 | position: absolute; 225 | width: var(--gate-width); 226 | height: var(--gate-height); 227 | padding: 0; 228 | border: none; 229 | border-radius: var(--bespoke-radius-md); 230 | background: transparent; 231 | cursor: grab; 232 | user-select: none; 233 | transition: box-shadow 0.12s ease, transform 0.12s ease; 234 | --gate-color: var(--logic-gate-base); 235 | } 236 | 237 | .logic-sim .gate-surface { 238 | position: absolute; 239 | inset: 0; 240 | width: 100%; 241 | height: 100%; 242 | transform-origin: 50% 50%; 243 | transition: transform 0.12s ease; 244 | } 245 | 246 | .logic-sim .gate-icon { 247 | width: 100%; 248 | height: 100%; 249 | background-color: var(--gate-color); 250 | mask-image: var(--gate-icon); 251 | mask-repeat: no-repeat; 252 | mask-position: center; 253 | mask-size: contain; 254 | -webkit-mask-image: var(--gate-icon); 255 | -webkit-mask-repeat: no-repeat; 256 | -webkit-mask-position: center; 257 | -webkit-mask-size: contain; 258 | pointer-events: none; 259 | } 260 | 261 | .logic-sim .gate.is-selected { 262 | box-shadow: 0 0 0 2px var(--logic-highlight); 263 | } 264 | 265 | .logic-sim .gate.is-dragging { 266 | cursor: grabbing; 267 | box-shadow: 0 10px 20px -10px rgba(15, 23, 42, 0.6); 268 | } 269 | 270 | .logic-sim .gate-label { 271 | position: absolute; 272 | left: 50%; 273 | top: calc(60% + var(--bespoke-space-xs)); 274 | transform: translateX(-50%); 275 | padding: 0 var(--bespoke-space-sm); 276 | border-radius: var(--bespoke-radius-md); 277 | background: color-mix(in srgb, var(--bespoke-box) 80%, transparent); 278 | color: var(--bespoke-muted); 279 | font-size: var(--bespoke-font-size-xs); 280 | font-weight: var(--bespoke-font-weight-medium); 281 | opacity: 0; 282 | transition: opacity 0.12s ease; 283 | pointer-events: none; 284 | } 285 | 286 | .logic-sim .gate-label.is-visible { 287 | opacity: 1; 288 | } 289 | 290 | .logic-sim .gate-custom { 291 | width: 100%; 292 | height: 100%; 293 | border-radius: inherit; 294 | border: 1px solid var(--bespoke-stroke); 295 | background: color-mix(in srgb, var(--bespoke-box) 60%, transparent); 296 | display: flex; 297 | flex-direction: column; 298 | align-items: center; 299 | justify-content: center; 300 | padding: var(--bespoke-space-xs); 301 | text-align: center; 302 | } 303 | 304 | .logic-sim .gate-custom-name { 305 | font-size: var(--bespoke-font-size-xs); 306 | font-weight: var(--bespoke-font-weight-semibold); 307 | color: var(--bespoke-fg); 308 | text-transform: uppercase; 309 | } 310 | 311 | .logic-sim .port { 312 | position: absolute; 313 | width: var(--port-size); 314 | height: var(--port-size); 315 | margin: 0; 316 | padding: 0; 317 | border-radius: 0px; 318 | border: 2px solid var(--bespoke-control-border); 319 | background: var(--bespoke-control-bg); 320 | cursor: crosshair; 321 | display: flex; 322 | align-items: center; 323 | justify-content: center; 324 | transition: border-color 0.12s ease, background 0.12s ease, box-shadow 0.12s ease; 325 | } 326 | 327 | .logic-sim .port:hover { 328 | border-color: var(--logic-highlight); 329 | } 330 | 331 | .logic-sim .port.is-active { 332 | background: var(--logic-highlight); 333 | border-color: var(--logic-highlight); 334 | box-shadow: 0 0 0 2px color-mix(in srgb, var(--logic-highlight) 30%, transparent); 335 | } 336 | 337 | .logic-sim .port-label { 338 | position: absolute; 339 | font-size: var(--bespoke-font-size-xs); 340 | color: var(--bespoke-muted); 341 | pointer-events: none; 342 | transform: translateY(-50%); 343 | white-space: nowrap; 344 | } 345 | 346 | .logic-sim .port-label.input { 347 | text-align: right; 348 | transform: translate(calc(-100% - var(--bespoke-space-xs)), -50%); 349 | } 350 | 351 | .logic-sim .port-label.output { 352 | text-align: left; 353 | transform: translate(var(--bespoke-space-xs), -50%); 354 | } 355 | 356 | .gate-context-menu { 357 | position: fixed; 358 | display: flex; 359 | flex-direction: column; 360 | padding: var(--bespoke-space-sm); 361 | border-radius: var(--bespoke-radius-md); 362 | background: var(--bespoke-box); 363 | border: 1px solid var(--bespoke-stroke); 364 | box-shadow: var(--bespoke-shadow-md); 365 | z-index: var(--bespoke-z-popover); 366 | min-width: 220px; 367 | max-width: 320px; 368 | } 369 | 370 | .gate-context-menu h4 { 371 | margin: 0; 372 | text-align: center; 373 | font-size: var(--bespoke-font-size-sm); 374 | font-weight: var(--bespoke-font-weight-semibold); 375 | } 376 | 377 | .gate-context-menu .menu-description { 378 | margin: var(--bespoke-space-xs) 0 var(--bespoke-space-sm); 379 | font-size: var(--bespoke-font-size-sm); 380 | color: var(--bespoke-muted); 381 | text-align: center; 382 | } 383 | 384 | .gate-context-menu .menu-field { 385 | display: flex; 386 | flex-direction: column; 387 | gap: var(--bespoke-space-xs); 388 | font-size: var(--bespoke-font-size-sm); 389 | } 390 | 391 | .gate-context-menu .menu-field span { 392 | color: var(--bespoke-muted); 393 | } 394 | 395 | .gate-context-menu .menu-field input { 396 | padding: var(--bespoke-space-xs) var(--bespoke-space-sm); 397 | border: 1px solid var(--bespoke-control-border); 398 | border-radius: var(--bespoke-radius-md); 399 | background: var(--bespoke-control-bg); 400 | color: var(--bespoke-fg); 401 | } 402 | 403 | .gate-context-menu .menu-actions { 404 | display: flex; 405 | flex-wrap: wrap; 406 | gap: var(--bespoke-space-sm); 407 | align-self: center; 408 | } 409 | 410 | .gate-context-menu .menu-actions .icon-button { 411 | width: 40px; 412 | height: 40px; 413 | padding: var(--bespoke-space-xs); 414 | display: inline-flex; 415 | align-items: center; 416 | justify-content: center; 417 | } 418 | 419 | .gate-context-menu .menu-actions .icon-button img { 420 | width: 1.25rem; 421 | height: 1.25rem; 422 | pointer-events: none; 423 | } 424 | 425 | 426 | @media (max-width: 960px) { 427 | .main-layout.logic-sim { 428 | flex-direction: column; 429 | } 430 | 431 | .logic-sim .palette-list { 432 | grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); 433 | } 434 | 435 | } 436 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const url = require('url'); 5 | const fsp = fs.promises; 6 | const { printCircuitReport } = require('./circuit-report'); 7 | 8 | // Try to load WebSocket module, fallback if not available 9 | let WebSocket = null; 10 | let isWebSocketAvailable = false; 11 | let wss = null; 12 | try { 13 | WebSocket = require('ws'); 14 | isWebSocketAvailable = true; 15 | console.log('WebSocket support enabled'); 16 | } catch (error) { 17 | console.log('WebSocket support disabled (ws package not installed)'); 18 | console.log('Install with: npm install ws'); 19 | } 20 | 21 | const PORT = 3000; 22 | const USER_VHDL_PATH = path.join(__dirname, 'user.vhdl'); 23 | const STATE_JSON_PATH = path.join(__dirname, 'state.json'); 24 | const GATE_CONFIG_PATH = path.join(__dirname, 'client', 'gate-config.json'); 25 | const INITIAL_STATE_PATH = path.join(__dirname, 'client', 'initial_state.json'); 26 | const CUSTOM_GATES_DIR = path.join(__dirname, 'client', 'custom-gates'); 27 | const CUSTOM_GATE_EXTENSIONS = new Set(['.json']); 28 | 29 | // Track connected WebSocket clients 30 | const wsClients = new Set(); 31 | 32 | // MIME types for different file extensions 33 | const mimeTypes = { 34 | '.html': 'text/html', 35 | '.js': 'text/javascript', 36 | '.css': 'text/css', 37 | '.json': 'application/json', 38 | '.vhdl': 'text/plain', 39 | '.png': 'image/png', 40 | '.jpg': 'image/jpeg', 41 | '.gif': 'image/gif', 42 | '.svg': 'image/svg+xml', 43 | '.ico': 'image/x-icon' 44 | }; 45 | 46 | // Get MIME type based on file extension 47 | function getMimeType(filePath) { 48 | const ext = path.extname(filePath).toLowerCase(); 49 | return mimeTypes[ext] || 'text/plain'; 50 | } 51 | 52 | const slugify = (value = '', fallback = 'custom-gate') => { 53 | const cleaned = value 54 | .toString() 55 | .trim() 56 | .toLowerCase() 57 | .replace(/[^a-z0-9]+/g, '-') 58 | .replace(/^-+|-+$/g, ''); 59 | return cleaned || fallback; 60 | }; 61 | 62 | const deriveAbbreviation = (label = '') => { 63 | const cleaned = (label || '').trim(); 64 | if (!cleaned) { 65 | return 'CG'; 66 | } 67 | const letters = cleaned 68 | .split(/\s+/) 69 | .map((word) => word[0]) 70 | .join('') 71 | .slice(0, 3) 72 | .toUpperCase(); 73 | return letters || cleaned.slice(0, 3).toUpperCase(); 74 | }; 75 | 76 | async function readFilesystemCustomGates() { 77 | await fsp.mkdir(CUSTOM_GATES_DIR, { recursive: true }); 78 | const entries = await fsp.readdir(CUSTOM_GATES_DIR, { withFileTypes: true }); 79 | const gates = []; 80 | const usedTypes = new Set(); 81 | for (const entry of entries) { 82 | if (!entry.isFile()) { 83 | continue; 84 | } 85 | const ext = path.extname(entry.name).toLowerCase(); 86 | if (!CUSTOM_GATE_EXTENSIONS.has(ext)) { 87 | continue; 88 | } 89 | const absolutePath = path.join(CUSTOM_GATES_DIR, entry.name); 90 | try { 91 | const contents = await fsp.readFile(absolutePath, 'utf8'); 92 | const snapshot = JSON.parse(contents); 93 | const description = typeof snapshot.description === 'string' && snapshot.description.trim() 94 | ? snapshot.description.trim() 95 | : ''; 96 | const snapshotLabel = typeof snapshot.label === 'string' 97 | ? snapshot.label 98 | : (typeof snapshot.name === 'string' ? snapshot.name : null); 99 | const baseName = snapshotLabel || path.basename(entry.name, ext); 100 | const baseSlug = slugify(baseName, 'custom-gate'); 101 | let type = baseSlug; 102 | let suffix = 2; 103 | while (usedTypes.has(type)) { 104 | type = `${baseSlug}-${suffix}`; 105 | suffix += 1; 106 | } 107 | usedTypes.add(type); 108 | gates.push({ 109 | type: `custom-${type}`, 110 | label: baseName, 111 | fileName: entry.name, 112 | description, 113 | abbreviation: deriveAbbreviation(baseName), 114 | snapshot 115 | }); 116 | } catch (error) { 117 | console.warn(`Failed to parse custom gate "${entry.name}":`, error.message); 118 | } 119 | } 120 | return gates; 121 | } 122 | 123 | async function loadGateConfig() { 124 | try { 125 | const raw = await fsp.readFile(GATE_CONFIG_PATH, 'utf8'); 126 | return JSON.parse(raw); 127 | } catch (error) { 128 | if (error.code !== 'ENOENT') { 129 | console.warn('Failed to load gate-config.json:', error.message); 130 | } 131 | return {}; 132 | } 133 | } 134 | 135 | // Serve static files 136 | function serveFile(filePath, res) { 137 | fs.readFile(filePath, (err, data) => { 138 | if (err) { 139 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 140 | res.end('File not found'); 141 | return; 142 | } 143 | 144 | const mimeType = getMimeType(filePath); 145 | res.writeHead(200, { 'Content-Type': mimeType }); 146 | res.end(data); 147 | }); 148 | } 149 | 150 | // Handle POST requests 151 | function handlePostRequest(req, res) { 152 | const parsedUrl = url.parse(req.url, true); 153 | 154 | if (parsedUrl.pathname === '/vhdl/export') { 155 | let body = ''; 156 | 157 | req.on('data', chunk => { 158 | body += chunk.toString(); 159 | }); 160 | 161 | req.on('end', async () => { 162 | let data; 163 | try { 164 | data = JSON.parse(body || '{}'); 165 | } catch (error) { 166 | res.writeHead(400, { 'Content-Type': 'application/json' }); 167 | res.end(JSON.stringify({ error: 'Invalid JSON' })); 168 | return; 169 | } 170 | 171 | const { vhdl, state } = data || {}; 172 | if (typeof vhdl !== 'string') { 173 | res.writeHead(400, { 'Content-Type': 'application/json' }); 174 | res.end(JSON.stringify({ error: 'Field "vhdl" is required' })); 175 | return; 176 | } 177 | if (!state || typeof state !== 'object') { 178 | res.writeHead(400, { 'Content-Type': 'application/json' }); 179 | res.end(JSON.stringify({ error: 'Field "state" must be an object' })); 180 | return; 181 | } 182 | 183 | try { 184 | await Promise.all([ 185 | fsp.writeFile(USER_VHDL_PATH, vhdl, 'utf8'), 186 | fsp.writeFile(STATE_JSON_PATH, `${JSON.stringify(state, null, 2)}\n`, 'utf8') 187 | ]); 188 | try { 189 | const gateConfig = await loadGateConfig(); 190 | printCircuitReport(state, gateConfig); 191 | } catch (reportError) { 192 | console.warn('Failed to generate export report:', reportError); 193 | } 194 | res.writeHead(200, { 'Content-Type': 'application/json' }); 195 | res.end(JSON.stringify({ success: true })); 196 | } catch (error) { 197 | console.error('Failed to export circuit data:', error); 198 | res.writeHead(500, { 'Content-Type': 'application/json' }); 199 | res.end(JSON.stringify({ error: 'Failed to export circuit data' })); 200 | } 201 | }); 202 | } else if (parsedUrl.pathname === '/message') { 203 | let body = ''; 204 | 205 | req.on('data', chunk => { 206 | body += chunk.toString(); 207 | }); 208 | 209 | req.on('end', () => { 210 | try { 211 | const data = JSON.parse(body); 212 | const message = data.message; 213 | 214 | if (!message) { 215 | res.writeHead(400, { 'Content-Type': 'application/json' }); 216 | res.end(JSON.stringify({ error: 'Message is required' })); 217 | return; 218 | } 219 | 220 | // Check if WebSocket is available 221 | if (!isWebSocketAvailable) { 222 | res.writeHead(503, { 'Content-Type': 'application/json' }); 223 | res.end(JSON.stringify({ 224 | error: 'WebSocket functionality not available', 225 | details: 'Install the ws package with: npm install ws' 226 | })); 227 | return; 228 | } 229 | 230 | // Broadcast message to all connected WebSocket clients 231 | wsClients.forEach(client => { 232 | if (client.readyState === WebSocket.OPEN) { 233 | client.send(JSON.stringify({ type: 'message', message: message })); 234 | } 235 | }); 236 | 237 | res.writeHead(200, { 'Content-Type': 'application/json' }); 238 | res.end(JSON.stringify({ success: true, clientCount: wsClients.size })); 239 | 240 | } catch (error) { 241 | res.writeHead(400, { 'Content-Type': 'application/json' }); 242 | res.end(JSON.stringify({ error: 'Invalid JSON' })); 243 | } 244 | }); 245 | } else { 246 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 247 | res.end('Not found'); 248 | } 249 | } 250 | 251 | async function handleCustomGateRegistryRequest(res) { 252 | try { 253 | const gates = await readFilesystemCustomGates(); 254 | res.writeHead(200, { 'Content-Type': 'application/json' }); 255 | res.end(JSON.stringify({ gates })); 256 | } catch (error) { 257 | console.error('Failed to enumerate custom gates:', error); 258 | res.writeHead(500, { 'Content-Type': 'application/json' }); 259 | res.end(JSON.stringify({ error: 'Failed to enumerate custom gates' })); 260 | } 261 | } 262 | 263 | async function refreshInitialStateFromExport() { 264 | try { 265 | const stateContents = await fsp.readFile(STATE_JSON_PATH, 'utf8'); 266 | await fsp.writeFile(INITIAL_STATE_PATH, stateContents, 'utf8'); 267 | console.log('client/initial_state.json refreshed from state.json'); 268 | } catch (error) { 269 | if (error.code === 'ENOENT') { 270 | console.warn('state.json not found; skipping initial_state.json refresh'); 271 | } else { 272 | console.error('Failed to copy state.json to client/initial_state.json:', error); 273 | } 274 | } 275 | } 276 | 277 | // Track open HTTP sockets so we can destroy them during shutdown 278 | const activeHttpSockets = new Set(); 279 | 280 | // Create HTTP server 281 | const server = http.createServer((req, res) => { 282 | const parsedUrl = url.parse(req.url, true); 283 | let pathname = parsedUrl.pathname; 284 | 285 | // Handle POST requests 286 | if (req.method === 'POST') { 287 | handlePostRequest(req, res); 288 | return; 289 | } 290 | 291 | // Handle GET trigger for export 292 | if (pathname === '/vhdl/trigger-export') { 293 | if (isWebSocketAvailable && wsClients.size > 0) { 294 | wsClients.forEach(client => { 295 | if (client.readyState === WebSocket.OPEN) { 296 | client.send(JSON.stringify({ type: 'logic-sim:export-vhdl' })); 297 | } 298 | }); 299 | res.writeHead(200, { 'Content-Type': 'application/json' }); 300 | res.end(JSON.stringify({ 301 | success: true, 302 | message: 'Export triggered via WebSocket', 303 | clientCount: wsClients.size 304 | })); 305 | } 306 | return; 307 | } 308 | 309 | if (pathname === '/custom-gates/registry.json') { 310 | handleCustomGateRegistryRequest(res); 311 | return; 312 | } 313 | 314 | // Default to index.html for root path 315 | if (pathname === '/') { 316 | pathname = '/index.html'; 317 | } 318 | 319 | // Remove leading slash and construct file path 320 | const filePath = path.join(__dirname, 'client', pathname.substring(1)); 321 | 322 | // Security check - prevent directory traversal 323 | const clientDir = path.join(__dirname, 'client'); 324 | if (!filePath.startsWith(clientDir)) { 325 | res.writeHead(403, { 'Content-Type': 'text/plain' }); 326 | res.end('Forbidden'); 327 | return; 328 | } 329 | 330 | // Check if file exists 331 | fs.access(filePath, fs.constants.F_OK, (err) => { 332 | if (err) { 333 | res.writeHead(404, { 'Content-Type': 'text/plain' }); 334 | res.end('File not found'); 335 | return; 336 | } 337 | 338 | // Serve the file 339 | serveFile(filePath, res); 340 | }); 341 | }); 342 | 343 | server.on('connection', (socket) => { 344 | activeHttpSockets.add(socket); 345 | socket.on('close', () => activeHttpSockets.delete(socket)); 346 | }); 347 | 348 | // Create WebSocket server only if WebSocket is available 349 | if (isWebSocketAvailable) { 350 | wss = new WebSocket.Server({ server }); 351 | 352 | wss.on('connection', (ws) => { 353 | console.log('New WebSocket client connected'); 354 | wsClients.add(ws); 355 | 356 | ws.on('close', () => { 357 | console.log('WebSocket client disconnected'); 358 | wsClients.delete(ws); 359 | }); 360 | 361 | ws.on('error', (error) => { 362 | console.error('WebSocket error:', error); 363 | wsClients.delete(ws); 364 | }); 365 | }); 366 | } 367 | 368 | async function startServer() { 369 | await refreshInitialStateFromExport(); 370 | server.listen(PORT, () => { 371 | console.log(`Server running at http://localhost:${PORT}`); 372 | if (isWebSocketAvailable) { 373 | console.log(`WebSocket server running on the same port`); 374 | } else { 375 | console.log(`WebSocket functionality disabled - install 'ws' package to enable`); 376 | } 377 | console.log(`Serving files from: ${__dirname}`); 378 | console.log('Press Ctrl+C to stop the server'); 379 | }); 380 | } 381 | 382 | startServer().catch((error) => { 383 | console.error('Failed to start server:', error); 384 | process.exit(1); 385 | }); 386 | 387 | // Handle server errors 388 | server.on('error', (err) => { 389 | if (err.code === 'EADDRINUSE') { 390 | console.error(`Port ${PORT} is already in use. Please try a different port.`); 391 | } else { 392 | console.error('Server error:', err); 393 | } 394 | process.exit(1); 395 | }); 396 | 397 | let isShuttingDown = false; 398 | 399 | function closeHttpServer() { 400 | return new Promise((resolve) => { 401 | if (!server.listening) { 402 | resolve(); 403 | return; 404 | } 405 | server.close((error) => { 406 | if (error) { 407 | console.error('Error closing HTTP server:', error); 408 | } else { 409 | console.log('HTTP server closed'); 410 | } 411 | resolve(); 412 | }); 413 | // Force close any idle sockets so close can resolve promptly 414 | activeHttpSockets.forEach((socket) => { 415 | socket.destroy(); 416 | }); 417 | }); 418 | } 419 | 420 | function closeWebSocketServer() { 421 | return new Promise((resolve) => { 422 | if (!wss) { 423 | resolve(); 424 | return; 425 | } 426 | try { 427 | wss.clients.forEach((client) => { 428 | try { 429 | client.terminate(); 430 | } catch (error) { 431 | console.error('Failed terminating WebSocket client:', error); 432 | } 433 | }); 434 | wss.close(() => { 435 | console.log('WebSocket server closed'); 436 | resolve(); 437 | }); 438 | } catch (error) { 439 | console.error('Error closing WebSocket server:', error); 440 | resolve(); 441 | } 442 | }); 443 | } 444 | 445 | async function gracefulShutdown(signal = 'SIGINT') { 446 | if (isShuttingDown) { 447 | return; 448 | } 449 | isShuttingDown = true; 450 | console.log(`\nShutting down server (${signal})...`); 451 | try { 452 | await Promise.all([closeHttpServer(), closeWebSocketServer()]); 453 | process.exit(0); 454 | } catch (error) { 455 | console.error('Error during shutdown:', error); 456 | process.exit(1); 457 | } 458 | } 459 | 460 | ['SIGINT', 'SIGTERM'].forEach((signal) => { 461 | process.on(signal, () => gracefulShutdown(signal)); 462 | }); 463 | -------------------------------------------------------------------------------- /circuit-report.js: -------------------------------------------------------------------------------- 1 | const { definitions: gateDefinitions } = require('./client/gate-registry'); 2 | 3 | const DEFAULT_REPORT_OPTIONS = { 4 | enabled: true, 5 | sections: { 6 | summary: true, 7 | gateCounts: true, 8 | gatePositions: true, 9 | spatialMetrics: true, 10 | connectionSummary: true, 11 | floatingPins: true, 12 | truthTable: true 13 | }, 14 | truthTable: { 15 | maxInputs: 6, 16 | maxRows: 64 17 | } 18 | }; 19 | 20 | const POSITION_LOG_LIMIT = 40; 21 | const FLOATING_PIN_LOG_LIMIT = 20; 22 | const FAN_LIST_LIMIT = 5; 23 | 24 | function mergeBoolean(value, fallback = true) { 25 | return typeof value === 'boolean' ? value : fallback; 26 | } 27 | 28 | function toPositiveInteger(value, fallback) { 29 | const numeric = Number(value); 30 | if (Number.isFinite(numeric) && numeric > 0) { 31 | return Math.floor(numeric); 32 | } 33 | return fallback; 34 | } 35 | 36 | function normalizeBit(value) { 37 | if (value === 1 || value === '1') { 38 | return 1; 39 | } 40 | if (value === 0 || value === '0') { 41 | return 0; 42 | } 43 | if (typeof value === 'boolean') { 44 | return value ? 1 : 0; 45 | } 46 | const numeric = Number(value); 47 | if (Number.isFinite(numeric)) { 48 | return numeric > 0 ? 1 : 0; 49 | } 50 | return value ? 1 : 0; 51 | } 52 | 53 | function arraysEqual(a = [], b = []) { 54 | if (a.length !== b.length) { 55 | return false; 56 | } 57 | for (let i = 0; i < a.length; i += 1) { 58 | if (a[i] !== b[i]) { 59 | return false; 60 | } 61 | } 62 | return true; 63 | } 64 | 65 | function sortGates(a, b) { 66 | const labelA = (a.label || '').toLowerCase(); 67 | const labelB = (b.label || '').toLowerCase(); 68 | if (labelA === labelB) { 69 | return a.id.localeCompare(b.id, undefined, { sensitivity: 'base' }); 70 | } 71 | return labelA.localeCompare(labelB); 72 | } 73 | 74 | function formatGateName(gate) { 75 | if (!gate) { 76 | return '[unknown]'; 77 | } 78 | const parts = [`[${gate.type || 'unknown'}]`]; 79 | if (gate.label && gate.label.trim()) { 80 | parts.push(`"${gate.label.trim()}"`); 81 | } 82 | parts.push(`(${gate.id})`); 83 | return parts.join(' '); 84 | } 85 | 86 | function buildCircuitModel(snapshot = {}) { 87 | const gates = Array.isArray(snapshot.gates) ? snapshot.gates : []; 88 | const sanitizedGates = gates 89 | .filter((gate) => gate && gate.id) 90 | .map((gate) => ({ 91 | id: String(gate.id), 92 | type: typeof gate.type === 'string' ? gate.type : 'unknown', 93 | x: Number.isFinite(Number(gate.x)) ? Number(gate.x) : 0, 94 | y: Number.isFinite(Number(gate.y)) ? Number(gate.y) : 0, 95 | state: normalizeBit(gate.state), 96 | label: typeof gate.label === 'string' ? gate.label : '' 97 | })); 98 | 99 | const gateMap = new Map(sanitizedGates.map((gate) => [gate.id, gate])); 100 | 101 | const connections = Array.isArray(snapshot.connections) ? snapshot.connections : []; 102 | const sanitizedConnections = connections 103 | .map((connection) => ({ 104 | id: connection?.id ? String(connection.id) : undefined, 105 | from: { 106 | gateId: connection?.from?.gateId ? String(connection.from.gateId) : undefined, 107 | portIndex: Number.isFinite(Number(connection?.from?.portIndex)) ? Number(connection.from.portIndex) : 0 108 | }, 109 | to: { 110 | gateId: connection?.to?.gateId ? String(connection.to.gateId) : undefined, 111 | portIndex: Number.isFinite(Number(connection?.to?.portIndex)) ? Number(connection.to.portIndex) : 0 112 | } 113 | })) 114 | .filter((connection) => Boolean(connection.from.gateId) && Boolean(connection.to.gateId)); 115 | 116 | const inputLookup = new Map(); 117 | sanitizedConnections.forEach((connection) => { 118 | const key = `${connection.to.gateId}:${connection.to.portIndex}`; 119 | inputLookup.set(key, { 120 | gateId: connection.from.gateId, 121 | portIndex: connection.from.portIndex 122 | }); 123 | }); 124 | 125 | const outputLookup = new Map(); 126 | sanitizedConnections.forEach((connection) => { 127 | const { gateId } = connection.from; 128 | if (!gateId) { 129 | return; 130 | } 131 | if (!outputLookup.has(gateId)) { 132 | outputLookup.set(gateId, []); 133 | } 134 | outputLookup.get(gateId).push({ 135 | gateId: connection.to.gateId, 136 | portIndex: connection.to.portIndex 137 | }); 138 | }); 139 | 140 | return { 141 | gates: sanitizedGates, 142 | connections: sanitizedConnections, 143 | gateMap, 144 | inputLookup, 145 | outputLookup, 146 | inputs: sanitizedGates.filter((gate) => gate.type === 'input'), 147 | outputs: sanitizedGates.filter((gate) => gate.type === 'output') 148 | }; 149 | } 150 | 151 | function evaluateGateOutputs(runtimeGate, inputs) { 152 | const { definition } = runtimeGate; 153 | if (!definition) { 154 | return []; 155 | } 156 | if (definition.logic) { 157 | return definition.logic(inputs, runtimeGate) || []; 158 | } 159 | return []; 160 | } 161 | 162 | function normalizeOutputArray(values, expectedLength) { 163 | const normalized = []; 164 | for (let i = 0; i < expectedLength; i += 1) { 165 | normalized.push(normalizeBit(values?.[i] ?? 0)); 166 | } 167 | return normalized; 168 | } 169 | 170 | function evaluateModel(model, overrides = {}) { 171 | const runtimeGates = new Map(); 172 | model.gates.forEach((gate) => { 173 | const definition = gateDefinitions[gate.type]; 174 | const runtimeGate = { 175 | id: gate.id, 176 | type: gate.type, 177 | label: gate.label, 178 | state: gate.type === 'input' 179 | ? normalizeBit(Object.prototype.hasOwnProperty.call(overrides, gate.id) ? overrides[gate.id] : gate.state) 180 | : normalizeBit(gate.state), 181 | definition, 182 | outputs: new Array(definition?.outputs || 0).fill(0), 183 | inputCache: new Array(definition?.inputs || 0).fill(0) 184 | }; 185 | runtimeGates.set(gate.id, runtimeGate); 186 | }); 187 | 188 | const inputValueCache = new Map(); 189 | 190 | const getInputValue = (gateId, portIndex) => { 191 | const key = `${gateId}:${portIndex}`; 192 | if (inputValueCache.has(key)) { 193 | return inputValueCache.get(key); 194 | } 195 | const source = model.inputLookup.get(key); 196 | let value = 0; 197 | if (source) { 198 | const runtimeSource = runtimeGates.get(source.gateId); 199 | if (runtimeSource && runtimeSource.outputs.length > source.portIndex) { 200 | value = runtimeSource.outputs[source.portIndex] ?? 0; 201 | } 202 | } 203 | const normalized = normalizeBit(value); 204 | inputValueCache.set(key, normalized); 205 | return normalized; 206 | }; 207 | 208 | const iterationLimit = 32; 209 | for (let iteration = 0; iteration < iterationLimit; iteration += 1) { 210 | let changed = false; 211 | inputValueCache.clear(); 212 | for (const runtimeGate of runtimeGates.values()) { 213 | const definition = runtimeGate.definition; 214 | if (!definition) { 215 | continue; 216 | } 217 | const inputs = definition.inputs 218 | ? Array.from({ length: definition.inputs }, (_, index) => getInputValue(runtimeGate.id, index)) 219 | : []; 220 | runtimeGate.inputCache = inputs; 221 | const produced = evaluateGateOutputs(runtimeGate, inputs); 222 | if (definition.outputs > 0) { 223 | const normalizedOutputs = normalizeOutputArray(produced, definition.outputs); 224 | if (!arraysEqual(runtimeGate.outputs, normalizedOutputs)) { 225 | runtimeGate.outputs = normalizedOutputs; 226 | changed = true; 227 | } 228 | } 229 | } 230 | if (!changed) { 231 | break; 232 | } 233 | } 234 | 235 | const outputValues = new Map(); 236 | model.outputs.forEach((gate) => { 237 | const runtimeGate = runtimeGates.get(gate.id); 238 | if (!runtimeGate) { 239 | outputValues.set(gate.id, 0); 240 | return; 241 | } 242 | if (runtimeGate.definition && runtimeGate.definition.inputs > 0) { 243 | outputValues.set(gate.id, normalizeBit(runtimeGate.inputCache?.[0] ?? 0)); 244 | } else if (runtimeGate.outputs.length > 0) { 245 | outputValues.set(gate.id, normalizeBit(runtimeGate.outputs[0])); 246 | } else { 247 | outputValues.set(gate.id, normalizeBit(runtimeGate.state)); 248 | } 249 | }); 250 | 251 | return { runtimeGates, outputValues }; 252 | } 253 | 254 | function computeGateCounts(model) { 255 | const counts = {}; 256 | model.gates.forEach((gate) => { 257 | const key = gate.type || 'unknown'; 258 | counts[key] = (counts[key] || 0) + 1; 259 | }); 260 | return Object.entries(counts).sort((a, b) => { 261 | if (b[1] === a[1]) { 262 | return a[0].localeCompare(b[0]); 263 | } 264 | return b[1] - a[1]; 265 | }); 266 | } 267 | 268 | function computeSpatialMetrics(model) { 269 | if (!model.gates.length) { 270 | return null; 271 | } 272 | const xs = model.gates.map((gate) => gate.x); 273 | const ys = model.gates.map((gate) => gate.y); 274 | const minX = Math.min(...xs); 275 | const maxX = Math.max(...xs); 276 | const minY = Math.min(...ys); 277 | const maxY = Math.max(...ys); 278 | return { 279 | minX, 280 | maxX, 281 | minY, 282 | maxY, 283 | width: maxX - minX, 284 | height: maxY - minY 285 | }; 286 | } 287 | 288 | function computeConnectionSummary(model) { 289 | const fanIn = new Map(); 290 | const fanOut = new Map(); 291 | model.connections.forEach((connection) => { 292 | if (connection.from.gateId) { 293 | fanOut.set(connection.from.gateId, (fanOut.get(connection.from.gateId) || 0) + 1); 294 | } 295 | if (connection.to.gateId) { 296 | fanIn.set(connection.to.gateId, (fanIn.get(connection.to.gateId) || 0) + 1); 297 | } 298 | }); 299 | 300 | const gatesWithInputs = model.gates.filter((gate) => (gateDefinitions[gate.type]?.inputs || 0) > 0); 301 | const gatesWithOutputs = model.gates.filter((gate) => (gateDefinitions[gate.type]?.outputs || 0) > 0); 302 | 303 | const averageFanIn = gatesWithInputs.length 304 | ? model.connections.length / gatesWithInputs.length 305 | : 0; 306 | const averageFanOut = gatesWithOutputs.length 307 | ? model.connections.length / gatesWithOutputs.length 308 | : 0; 309 | 310 | const topFanIn = Array.from(fanIn.entries()) 311 | .map(([gateId, total]) => ({ gate: model.gateMap.get(gateId), total })) 312 | .sort((a, b) => b.total - a.total) 313 | .slice(0, FAN_LIST_LIMIT); 314 | 315 | const topFanOut = Array.from(fanOut.entries()) 316 | .map(([gateId, total]) => ({ gate: model.gateMap.get(gateId), total })) 317 | .sort((a, b) => b.total - a.total) 318 | .slice(0, FAN_LIST_LIMIT); 319 | 320 | return { 321 | totalConnections: model.connections.length, 322 | averageFanIn, 323 | averageFanOut, 324 | topFanIn, 325 | topFanOut 326 | }; 327 | } 328 | 329 | function detectFloatingPins(model) { 330 | const openInputs = []; 331 | const floatingOutputs = []; 332 | 333 | model.gates.forEach((gate) => { 334 | const definition = gateDefinitions[gate.type]; 335 | if (!definition) { 336 | return; 337 | } 338 | if (definition.inputs > 0 && gate.type !== 'input') { 339 | for (let i = 0; i < definition.inputs; i += 1) { 340 | const key = `${gate.id}:${i}`; 341 | if (!model.inputLookup.has(key)) { 342 | openInputs.push({ gate, portIndex: i }); 343 | } 344 | } 345 | } 346 | if (definition.outputs > 0 && gate.type !== 'output') { 347 | const fanout = model.outputLookup.get(gate.id)?.length || 0; 348 | if (fanout === 0) { 349 | floatingOutputs.push(gate); 350 | } 351 | } 352 | }); 353 | 354 | return { openInputs, floatingOutputs }; 355 | } 356 | 357 | function generateTruthTable(model, options) { 358 | const orderedInputs = [...model.inputs].sort(sortGates); 359 | const orderedOutputs = [...model.outputs].sort(sortGates); 360 | 361 | if (!orderedOutputs.length) { 362 | return { skipped: true, reason: 'No output gates defined' }; 363 | } 364 | 365 | const maxInputs = toPositiveInteger(options?.maxInputs, DEFAULT_REPORT_OPTIONS.truthTable.maxInputs); 366 | const inputCount = orderedInputs.length; 367 | if (inputCount > maxInputs) { 368 | return { 369 | skipped: true, 370 | reason: `Input count (${inputCount}) exceeds configured limit (${maxInputs})` 371 | }; 372 | } 373 | 374 | const maxRows = toPositiveInteger(options?.maxRows, DEFAULT_REPORT_OPTIONS.truthTable.maxRows); 375 | const totalRows = Math.max(1, 2 ** inputCount); 376 | const rowsToRender = Math.min(totalRows, maxRows); 377 | const rows = []; 378 | 379 | for (let rowIndex = 0; rowIndex < rowsToRender; rowIndex += 1) { 380 | const assignment = {}; 381 | const inputBits = []; 382 | for (let bitIndex = 0; bitIndex < inputCount; bitIndex += 1) { 383 | const gate = orderedInputs[bitIndex]; 384 | const shift = inputCount - bitIndex - 1; 385 | const bit = ((rowIndex >> shift) & 1) || 0; 386 | assignment[gate.id] = bit; 387 | inputBits.push(bit); 388 | } 389 | const evaluation = evaluateModel(model, assignment); 390 | const outputBits = orderedOutputs.map((gate) => evaluation.outputValues.get(gate.id) || 0); 391 | rows.push({ index: rowIndex, inputs: inputBits, outputs: outputBits }); 392 | } 393 | 394 | return { 395 | skipped: false, 396 | header: { 397 | inputs: orderedInputs, 398 | outputs: orderedOutputs 399 | }, 400 | rows, 401 | totalRows, 402 | truncated: rowsToRender < totalRows 403 | }; 404 | } 405 | 406 | function buildReportOptions(gateConfig = {}) { 407 | const reportConfig = gateConfig?.exportReport || {}; 408 | const sectionOverrides = reportConfig.sections || {}; 409 | const truthTableOverrides = reportConfig.truthTable || {}; 410 | 411 | const sections = { 412 | summary: mergeBoolean(sectionOverrides.summary, DEFAULT_REPORT_OPTIONS.sections.summary), 413 | gateCounts: mergeBoolean(sectionOverrides.gateCounts, DEFAULT_REPORT_OPTIONS.sections.gateCounts), 414 | gatePositions: mergeBoolean(sectionOverrides.gatePositions, DEFAULT_REPORT_OPTIONS.sections.gatePositions), 415 | spatialMetrics: mergeBoolean(sectionOverrides.spatialMetrics, DEFAULT_REPORT_OPTIONS.sections.spatialMetrics), 416 | connectionSummary: mergeBoolean(sectionOverrides.connectionSummary, DEFAULT_REPORT_OPTIONS.sections.connectionSummary), 417 | floatingPins: mergeBoolean(sectionOverrides.floatingPins, DEFAULT_REPORT_OPTIONS.sections.floatingPins), 418 | truthTable: mergeBoolean(sectionOverrides.truthTable, DEFAULT_REPORT_OPTIONS.sections.truthTable) 419 | }; 420 | 421 | const truthTable = { 422 | enabled: sections.truthTable && mergeBoolean(truthTableOverrides.enabled, true), 423 | maxInputs: toPositiveInteger(truthTableOverrides.maxInputs, DEFAULT_REPORT_OPTIONS.truthTable.maxInputs), 424 | maxRows: toPositiveInteger(truthTableOverrides.maxRows, DEFAULT_REPORT_OPTIONS.truthTable.maxRows) 425 | }; 426 | 427 | return { 428 | enabled: mergeBoolean(reportConfig.enabled, DEFAULT_REPORT_OPTIONS.enabled), 429 | sections: { 430 | ...sections, 431 | truthTable: truthTable.enabled 432 | }, 433 | truthTable 434 | }; 435 | } 436 | 437 | function printCircuitReport(snapshot, gateConfig = {}) { 438 | const options = buildReportOptions(gateConfig); 439 | if (!options.enabled) { 440 | return; 441 | } 442 | 443 | const model = buildCircuitModel(snapshot || {}); 444 | console.log('\n=== Circuit Export Report ==='); 445 | 446 | if (options.sections.summary) { 447 | console.log(`Total gates: ${model.gates.length}`); 448 | console.log(`Total connections: ${model.connections.length}`); 449 | console.log(`Inputs: ${model.inputs.length} | Outputs: ${model.outputs.length}`); 450 | } 451 | 452 | if (options.sections.gateCounts) { 453 | const counts = computeGateCounts(model); 454 | if (!counts.length) { 455 | console.log('Gate counts: n/a (no gates present)'); 456 | } else { 457 | console.log('Gate counts:'); 458 | counts.forEach(([type, count]) => { 459 | console.log(` - ${type}: ${count}`); 460 | }); 461 | } 462 | } 463 | 464 | if (options.sections.gatePositions) { 465 | if (!model.gates.length) { 466 | console.log('Gate positions: n/a (no gates present)'); 467 | } else { 468 | console.log('Gate positions:'); 469 | model.gates.slice(0, POSITION_LOG_LIMIT).forEach((gate) => { 470 | console.log(` - ${formatGateName(gate)} @ (${gate.x}, ${gate.y})`); 471 | }); 472 | if (model.gates.length > POSITION_LOG_LIMIT) { 473 | console.log(` ... ${model.gates.length - POSITION_LOG_LIMIT} additional gates not shown`); 474 | } 475 | } 476 | } 477 | 478 | if (options.sections.spatialMetrics) { 479 | const metrics = computeSpatialMetrics(model); 480 | if (!metrics) { 481 | console.log('Spatial metrics: n/a (no gates present)'); 482 | } else { 483 | console.log('Spatial metrics:'); 484 | console.log(` - Bounds X: ${metrics.minX} → ${metrics.maxX} (width ${metrics.width})`); 485 | console.log(` - Bounds Y: ${metrics.minY} → ${metrics.maxY} (height ${metrics.height})`); 486 | } 487 | } 488 | 489 | if (options.sections.connectionSummary) { 490 | const summary = computeConnectionSummary(model); 491 | console.log('Connection summary:'); 492 | console.log(` - Total: ${summary.totalConnections}`); 493 | console.log(` - Avg fan-in: ${summary.averageFanIn.toFixed(2)}`); 494 | console.log(` - Avg fan-out: ${summary.averageFanOut.toFixed(2)}`); 495 | if (summary.topFanIn.length) { 496 | console.log(' - Highest fan-in:'); 497 | summary.topFanIn.forEach((entry) => { 498 | console.log(` • ${formatGateName(entry.gate)} → ${entry.total} inputs`); 499 | }); 500 | } 501 | if (summary.topFanOut.length) { 502 | console.log(' - Highest fan-out:'); 503 | summary.topFanOut.forEach((entry) => { 504 | console.log(` • ${formatGateName(entry.gate)} → ${entry.total} outputs`); 505 | }); 506 | } 507 | } 508 | 509 | if (options.sections.floatingPins) { 510 | const floating = detectFloatingPins(model); 511 | if (!floating.openInputs.length && !floating.floatingOutputs.length) { 512 | console.log('Connectivity check: all gate inputs and outputs are connected.'); 513 | } else { 514 | console.log('Connectivity diagnostics:'); 515 | if (floating.openInputs.length) { 516 | console.log(' Unconnected gate inputs:'); 517 | floating.openInputs.slice(0, FLOATING_PIN_LOG_LIMIT).forEach((entry) => { 518 | console.log(` • ${formatGateName(entry.gate)} input ${entry.portIndex}`); 519 | }); 520 | if (floating.openInputs.length > FLOATING_PIN_LOG_LIMIT) { 521 | console.log(` ... ${floating.openInputs.length - FLOATING_PIN_LOG_LIMIT} additional open inputs`); 522 | } 523 | } else { 524 | console.log(' Unconnected gate inputs: none'); 525 | } 526 | if (floating.floatingOutputs.length) { 527 | console.log(' Gate outputs with no destinations:'); 528 | floating.floatingOutputs.slice(0, FLOATING_PIN_LOG_LIMIT).forEach((gate) => { 529 | console.log(` • ${formatGateName(gate)}`); 530 | }); 531 | if (floating.floatingOutputs.length > FLOATING_PIN_LOG_LIMIT) { 532 | console.log(` ... ${floating.floatingOutputs.length - FLOATING_PIN_LOG_LIMIT} additional floating outputs`); 533 | } 534 | } else { 535 | console.log(' Gate outputs with no destinations: none'); 536 | } 537 | } 538 | } 539 | 540 | if (options.truthTable.enabled) { 541 | const table = generateTruthTable(model, options.truthTable); 542 | if (table.skipped) { 543 | console.log(`Truth table: skipped (${table.reason})`); 544 | } else if (!table.rows.length) { 545 | console.log('Truth table: no rows to display'); 546 | } else { 547 | const inputLabels = table.header.inputs.map((gate) => gate.label || gate.id); 548 | const outputLabels = table.header.outputs.map((gate) => gate.label || gate.id); 549 | console.log(`Truth table (${table.rows.length}/${table.totalRows} rows${table.truncated ? ', truncated' : ''}):`); 550 | const leftHeader = inputLabels.length ? inputLabels.join(' ') : '(no inputs)'; 551 | const rightHeader = outputLabels.length ? outputLabels.join(' ') : '(no outputs)'; 552 | console.log(` ${leftHeader} || ${rightHeader}`); 553 | table.rows.forEach((row) => { 554 | const left = row.inputs.length ? row.inputs.map((value) => (value ? 1 : 0)).join(' ') : '-'; 555 | const right = row.outputs.length ? row.outputs.map((value) => (value ? 1 : 0)).join(' ') : '-'; 556 | console.log(` ${left} || ${right}`); 557 | }); 558 | if (table.truncated) { 559 | console.log(` ... ${table.totalRows - table.rows.length} additional rows not shown`); 560 | } 561 | } 562 | } 563 | 564 | console.log('=== End of Circuit Report ===\n'); 565 | } 566 | 567 | module.exports = { 568 | buildReportOptions, 569 | printCircuitReport 570 | }; 571 | -------------------------------------------------------------------------------- /client/bespoke.css: -------------------------------------------------------------------------------- 1 | /* ===== BESPOKE GENERALIZED CSS ===== */ 2 | /* Reusable CSS components for embedded applications */ 3 | /* Version: 1.0.0 */ 4 | 5 | /* Import Work Sans font */ 6 | @import url('https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;500;600;700&display=swap'); 7 | 8 | /* ===== CORE FOUNDATION ===== */ 9 | .bespoke { 10 | /* CSS Custom Properties - Light Theme */ 11 | --bespoke-font-family: "Work Sans", ui-sans-serif, system-ui, sans-serif; 12 | --bespoke-bg: #ffffff; /* base background */ 13 | --bespoke-secondary-bg: #f6f6f6; /* secondary background */ 14 | --bespoke-fg: rgb(24, 33, 57); /* text-primary */ 15 | --bespoke-muted: rgb(73, 85, 115); /* text-secondary */ 16 | --bespoke-box: #ffffff; /* surface/container background */ 17 | --bespoke-stroke: #cbd5e1; /* border color */ 18 | --bespoke-danger: #dc2626; /* error/danger color */ 19 | --bespoke-accent: #1062fb; /* accent/primary color */ 20 | --bespoke-control-bg: #ffffff; /* input/button background */ 21 | --bespoke-control-border: #e2e8f0; /* input/button border */ 22 | --bespoke-control-focus: #377dff; /* focus ring color */ 23 | --bespoke-canvas: #f8fafc; /* canvas/diagram background */ 24 | --bespoke-toggle-bg: #e2e8f0; /* toggle background */ 25 | 26 | /* Light mode button colors */ 27 | --bespoke-button-bg: var(--bespoke-control-bg); 28 | --bespoke-button-border: var(--bespoke-control-border); 29 | --bespoke-button-text: var(--bespoke-fg); 30 | --bespoke-button-hover-bg: var(--bespoke-secondary-bg); 31 | --bespoke-button-danger-bg: #ffe1e1; 32 | --bespoke-button-danger-border: #fecaca; 33 | --bespoke-button-save-bg: rgba(148, 163, 184, 0.1); 34 | 35 | /* Light mode input colors */ 36 | --bespoke-input-bg: var(--bespoke-control-bg); 37 | --bespoke-input-text: var(--bespoke-fg); 38 | --bespoke-input-border: var(--bespoke-control-border); 39 | --bespoke-input-hover-border: var(--bespoke-muted); 40 | 41 | /* Spacing scale */ 42 | --bespoke-space-xs: 0.25rem; 43 | --bespoke-space-sm: 0.5rem; 44 | --bespoke-space-md: 0.75rem; 45 | --bespoke-space-lg: 1rem; 46 | --bespoke-space-xl: 1.5rem; 47 | --bespoke-space-2xl: 2rem; 48 | 49 | /* Border radius scale */ 50 | --bespoke-radius-sm: 4px; 51 | --bespoke-radius-md: 6px; 52 | --bespoke-radius-lg: 8px; 53 | --bespoke-radius-xl: 12px; 54 | 55 | /* Shadow scale */ 56 | --bespoke-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); 57 | --bespoke-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 58 | --bespoke-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 59 | --bespoke-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); 60 | 61 | /* Typography scale */ 62 | --bespoke-font-size-xs: 0.75rem; 63 | --bespoke-font-size-sm: 0.875rem; 64 | --bespoke-font-size-base: 1rem; 65 | --bespoke-font-size-lg: 1.125rem; 66 | --bespoke-font-size-xl: 1.25rem; 67 | --bespoke-font-size-2xl: 1.5rem; 68 | --bespoke-font-size-3xl: 1.875rem; 69 | 70 | /* Font weights */ 71 | --bespoke-font-weight-normal: 400; 72 | --bespoke-font-weight-medium: 500; 73 | --bespoke-font-weight-semibold: 600; 74 | --bespoke-font-weight-bold: 700; 75 | 76 | /* Line heights */ 77 | --bespoke-line-height-tight: 1.25; 78 | --bespoke-line-height-normal: 1.5; 79 | --bespoke-line-height-relaxed: 1.6; 80 | 81 | /* Z-index scale */ 82 | --bespoke-z-dropdown: 100; 83 | --bespoke-z-sticky: 200; 84 | --bespoke-z-fixed: 300; 85 | --bespoke-z-modal-backdrop: 400; 86 | --bespoke-z-modal: 500; 87 | --bespoke-z-popover: 600; 88 | --bespoke-z-tooltip: 700; 89 | --bespoke-z-toast: 800; 90 | 91 | /* Button padding scale */ 92 | --bespoke-button-padding-xs: var(--bespoke-space-xs) var(--bespoke-space-sm); 93 | --bespoke-button-padding-sm: var(--bespoke-space-sm) var(--bespoke-space-md); 94 | --bespoke-button-padding-md: var(--bespoke-space-sm) var(--bespoke-space-lg); 95 | --bespoke-button-padding-lg: var(--bespoke-space-md) var(--bespoke-space-xl); 96 | --bespoke-button-padding-xl: var(--bespoke-space-lg) var(--bespoke-space-2xl); 97 | 98 | /* Input padding scale */ 99 | --bespoke-input-padding-sm: var(--bespoke-space-sm); 100 | --bespoke-input-padding-md: var(--bespoke-space-md); 101 | --bespoke-input-padding-lg: var(--bespoke-space-lg); 102 | 103 | /* Modal padding scale */ 104 | --bespoke-modal-padding-sm: var(--bespoke-space-md); 105 | --bespoke-modal-padding-md: var(--bespoke-space-lg); 106 | --bespoke-modal-padding-lg: var(--bespoke-space-xl); 107 | 108 | /* Card padding scale */ 109 | --bespoke-card-padding-sm: var(--bespoke-space-md); 110 | --bespoke-card-padding-md: var(--bespoke-space-lg); 111 | --bespoke-card-padding-lg: var(--bespoke-space-xl); 112 | 113 | /* Header padding scale */ 114 | --bespoke-header-padding-sm: var(--bespoke-space-sm) var(--bespoke-space-md); 115 | --bespoke-header-padding-md: var(--bespoke-space-sm) var(--bespoke-space-lg); 116 | --bespoke-header-padding-lg: var(--bespoke-space-md) var(--bespoke-space-xl); 117 | } 118 | 119 | /* Dark Theme Override */ 120 | @media (prefers-color-scheme: dark) { 121 | .bespoke { 122 | --bespoke-bg: #26314c; /* dark background - rgb(48, 61, 86) */ 123 | --bespoke-secondary-bg: #303d56; /* dark secondary background */ 124 | --bespoke-fg: #ffffff; /* light text */ 125 | --bespoke-muted: rgb(193, 199, 215); /* muted text */ 126 | --bespoke-box: #26314c; /* dark surface */ 127 | --bespoke-stroke: rgba(193, 199, 215, 0.35); 128 | --bespoke-danger: #ef4444; /* dark mode danger */ 129 | --bespoke-accent: #1062fb; /* dark mode accent */ 130 | --bespoke-control-bg: #1f2937; /* dark input background */ 131 | --bespoke-control-border: #94a3b8; /* dark input border */ 132 | --bespoke-control-focus: #377dff; /* same focus color */ 133 | --bespoke-canvas: #303d56; /* dark canvas background - matches bg */ 134 | --bespoke-toggle-bg: rgb(57 69 99); /* dark toggle background */ 135 | 136 | /* Dark mode button colors - updated to match new background */ 137 | --bespoke-button-bg: #303d56; /* matches new dark background */ 138 | --bespoke-button-border: rgb(57 69 99); 139 | --bespoke-button-text: rgb(187 193 209); 140 | --bespoke-button-hover-bg: var(--bespoke-secondary-bg); 141 | --bespoke-button-hover-text: rgb(215 218 230); 142 | --bespoke-button-danger-bg: rgba(239, 68, 68, 0.05); 143 | --bespoke-button-danger-border: rgba(239, 68, 68, 0.6); 144 | --bespoke-button-save-bg: rgba(250, 90, 90, 0.7); 145 | 146 | /* Dark mode input colors */ 147 | --bespoke-input-bg: rgb(23 32 55); 148 | --bespoke-input-text: #c1c7d7; 149 | --bespoke-input-border: transparent; 150 | --bespoke-input-hover-border: #26314c; 151 | 152 | /* Note: Typography, spacing, z-index, and padding scales remain the same in dark mode */ 153 | } 154 | } 155 | 156 | /* ===== BASE STYLES ===== */ 157 | html, body { 158 | margin: 0; 159 | padding: 0; 160 | } 161 | 162 | .bespoke * { 163 | box-sizing: border-box; 164 | } 165 | 166 | .bespoke { 167 | font-family: var(--bespoke-font-family); 168 | color: var(--bespoke-fg); 169 | background: var(--bespoke-bg); 170 | line-height: 1.6; 171 | margin: 0; 172 | padding: 0; 173 | } 174 | 175 | /* ===== LAYOUT COMPONENTS ===== */ 176 | 177 | /* Header */ 178 | .bespoke .header { 179 | display: flex; 180 | align-items: center; 181 | gap: var(--bespoke-space-lg); 182 | padding: var(--bespoke-header-padding-md); 183 | border-bottom: 1px solid var(--bespoke-stroke); 184 | background: var(--bespoke-box); 185 | width: 100%; 186 | box-sizing: border-box; 187 | } 188 | 189 | .bespoke .header h1 { 190 | font-size: var(--bespoke-font-size-lg); 191 | margin: 0; 192 | font-weight: var(--bespoke-font-weight-semibold); 193 | } 194 | 195 | .bespoke .header .status { 196 | font-size: var(--bespoke-font-size-sm); 197 | color: var(--bespoke-muted); 198 | } 199 | 200 | /* Main Layout */ 201 | .bespoke .main-layout { 202 | display: grid; 203 | grid-template-columns: 300px 1fr; 204 | height: calc(100% - 42px); 205 | } 206 | 207 | .bespoke .sidebar { 208 | padding: var(--bespoke-space-sm); 209 | overflow: auto; 210 | border-right: 1px solid var(--bespoke-stroke); 211 | background: var(--bespoke-bg); 212 | } 213 | 214 | .bespoke .content-area { 215 | width: 100%; 216 | height: 100%; 217 | } 218 | 219 | /* ===== CARD COMPONENT ===== */ 220 | .bespoke .card { 221 | background: var(--bespoke-box); 222 | border: 1px solid var(--bespoke-stroke); 223 | border-radius: var(--bespoke-radius-lg); 224 | padding: var(--bespoke-card-padding-md); 225 | margin-bottom: var(--bespoke-space-lg); 226 | box-shadow: var(--bespoke-shadow-sm); 227 | } 228 | 229 | .bespoke .card h2 { 230 | font-size: var(--bespoke-font-size-xl); 231 | font-weight: var(--bespoke-font-weight-medium); 232 | margin: 0.8rem 0 0.5rem 0; 233 | color: var(--bespoke-fg); 234 | } 235 | 236 | .bespoke .card h3 { 237 | font-size: var(--bespoke-font-size-lg); 238 | font-weight: var(--bespoke-font-weight-medium); 239 | margin: 0.6rem 0 0.4rem 0; 240 | color: var(--bespoke-fg); 241 | } 242 | 243 | /* ===== FORM COMPONENTS ===== */ 244 | 245 | /* Labels */ 246 | .bespoke label { 247 | display: flex; 248 | flex-direction: column; 249 | gap: var(--bespoke-space-xs); 250 | margin: var(--bespoke-space-md) 0 var(--bespoke-space-sm) 0; 251 | } 252 | 253 | .bespoke label.row { 254 | flex-direction: row; 255 | align-items: center; 256 | gap: var(--bespoke-space-sm); 257 | margin: var(--bespoke-space-sm) 0; 258 | } 259 | 260 | /* Input Fields */ 261 | .bespoke input:not([type="checkbox"]), 262 | .bespoke select { 263 | padding: var(--bespoke-input-padding-md); 264 | border: 1px solid var(--bespoke-input-border); 265 | border-radius: var(--bespoke-radius-md); 266 | background: var(--bespoke-input-bg); 267 | color: var(--bespoke-input-text); 268 | font: inherit; 269 | min-height: 3rem; 270 | max-height: 8.737499999999999rem; 271 | padding-left: var(--bespoke-space-lg); 272 | padding-right: 2.75rem; 273 | padding-top: var(--bespoke-input-padding-md); 274 | padding-bottom: var(--bespoke-input-padding-md); 275 | transition: border-color 0.2s ease; 276 | } 277 | 278 | .bespoke input:not([type="checkbox"]):hover, 279 | .bespoke select:hover { 280 | border-color: var(--bespoke-input-hover-border); 281 | } 282 | 283 | .bespoke input:not([type="checkbox"]):focus-visible, 284 | .bespoke select:focus-visible { 285 | outline: none; 286 | border-color: var(--bespoke-control-focus); 287 | box-shadow: 0 0 0 3px rgba(55, 125, 255, 0.1); 288 | } 289 | 290 | .bespoke input[type="checkbox"] { 291 | padding: 0; 292 | margin: 0; 293 | } 294 | 295 | /* Select Styling */ 296 | .bespoke select { 297 | -webkit-appearance: none; 298 | -moz-appearance: none; 299 | appearance: none; 300 | background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e"); 301 | background-repeat: no-repeat; 302 | background-position: right 0.75rem center; 303 | background-size: 1rem; 304 | padding-right: 3rem; 305 | } 306 | 307 | .bespoke select::-ms-expand { 308 | display: none; 309 | } 310 | 311 | /* Placeholder styling */ 312 | .bespoke input::placeholder, 313 | .bespoke textarea::placeholder { 314 | color: var(--bespoke-muted); 315 | opacity: 1; 316 | } 317 | 318 | /* ===== BUTTON COMPONENTS ===== */ 319 | .bespoke button { 320 | cursor: pointer; 321 | border-radius: var(--bespoke-radius-md); 322 | margin-top: 0; 323 | padding: var(--bespoke-button-padding-md); 324 | border: 1px solid var(--bespoke-button-border); 325 | background: var(--bespoke-button-bg); 326 | color: var(--bespoke-button-text); 327 | font: inherit; 328 | transition: all 0.2s ease; 329 | } 330 | 331 | /* Generic button hover - only applies to buttons without specific variants */ 332 | .bespoke button:hover:not(.danger):not(.primary):not(.ghost):not(.save) { 333 | background: var(--bespoke-button-hover-bg); 334 | color: var(--bespoke-button-hover-text); 335 | } 336 | 337 | .bespoke button:focus-visible { 338 | outline: none; 339 | border-color: var(--bespoke-control-focus); 340 | box-shadow: 0 0 0 3px rgba(55, 125, 255, 0.1); 341 | } 342 | 343 | /* Button Variants */ 344 | .bespoke button.danger { 345 | background: var(--bespoke-button-danger-bg); 346 | border-color: var(--bespoke-button-danger-border); 347 | color: var(--bespoke-danger); 348 | } 349 | 350 | .bespoke button.danger:hover { 351 | background: rgba(239, 68, 68, 0.15); 352 | border-color: var(--bespoke-button-danger-border); 353 | color: var(--bespoke-danger); 354 | } 355 | 356 | .bespoke button.primary { 357 | background: var(--bespoke-accent) !important; 358 | border-color: var(--bespoke-accent) !important; 359 | color: white !important; 360 | } 361 | 362 | .bespoke button.primary:hover { 363 | background: #0d4ed8 !important; 364 | border-color: #0d4ed8 !important; 365 | } 366 | 367 | .bespoke button.ghost { 368 | background: transparent; 369 | border-color: var(--bespoke-stroke); 370 | color: var(--bespoke-fg); 371 | } 372 | 373 | .bespoke button.ghost:hover { 374 | background: var(--bespoke-button-hover-bg); 375 | color: var(--bespoke-button-hover-text); 376 | } 377 | 378 | /* Save buttons - only apply to buttons with .save class */ 379 | .bespoke button.save { 380 | background: var(--bespoke-button-save-bg); 381 | border-color: var(--bespoke-button-border); 382 | color: var(--bespoke-button-text); 383 | } 384 | 385 | .bespoke button.save:hover { 386 | background: var(--bespoke-button-hover-bg); 387 | color: var(--bespoke-button-hover-text); 388 | } 389 | 390 | /* Button as Link */ 391 | .bespoke .as-button { 392 | display: inline-flex; 393 | align-items: center; 394 | padding: var(--bespoke-button-padding-sm); 395 | border: 1px solid var(--bespoke-stroke); 396 | border-radius: var(--bespoke-radius-md); 397 | font: inherit; 398 | text-decoration: none; 399 | color: var(--bespoke-fg); 400 | background: var(--bespoke-box); 401 | transition: filter 0.2s ease; 402 | } 403 | 404 | .bespoke .as-button:hover { 405 | filter: brightness(0.98); 406 | } 407 | 408 | /* ===== UTILITY COMPONENTS ===== */ 409 | 410 | /* Flexbox Utilities */ 411 | .bespoke .row { 412 | display: flex; 413 | align-items: center; 414 | gap: var(--bespoke-space-sm); 415 | } 416 | 417 | .bespoke .row-between { 418 | display: flex; 419 | align-items: center; 420 | justify-content: space-between; 421 | gap: var(--bespoke-space-lg); 422 | } 423 | 424 | .bespoke .spacer { 425 | flex: 1; 426 | } 427 | 428 | /* Dividers */ 429 | .bespoke hr { 430 | border: none; 431 | border-top: 0.5px solid var(--bespoke-stroke); 432 | margin: var(--bespoke-space-lg) 0; 433 | } 434 | 435 | /* ===== MODAL COMPONENTS ===== */ 436 | .bespoke .modal { 437 | position: fixed; 438 | top: 0; 439 | left: 0; 440 | width: 100%; 441 | height: 100%; 442 | z-index: var(--bespoke-z-modal); 443 | display: flex; 444 | align-items: center; 445 | justify-content: center; 446 | padding: var(--bespoke-space-xl); 447 | box-sizing: border-box; 448 | margin: 0; 449 | } 450 | 451 | .bespoke .modal-backdrop { 452 | position: absolute; 453 | top: 0; 454 | left: 0; 455 | width: 100%; 456 | height: 100%; 457 | background: rgba(0, 0, 0, 0.5); 458 | backdrop-filter: blur(2px); 459 | } 460 | 461 | .bespoke .modal-content { 462 | position: relative; 463 | background: var(--bespoke-box); 464 | border: 1px solid var(--bespoke-stroke); 465 | border-radius: var(--bespoke-radius-xl); 466 | max-width: 800px; 467 | width: calc(100% - 40px); 468 | max-height: 90vh; 469 | display: flex; 470 | flex-direction: column; 471 | box-shadow: var(--bespoke-shadow-xl); 472 | margin: 0; 473 | } 474 | 475 | .bespoke .modal-header { 476 | display: flex; 477 | align-items: center; 478 | justify-content: space-between; 479 | padding: var(--bespoke-modal-padding-lg); 480 | border-bottom: 1px solid var(--bespoke-stroke); 481 | background: var(--bespoke-box); 482 | border-radius: var(--bespoke-radius-xl) var(--bespoke-radius-xl) 0 0; 483 | } 484 | 485 | .bespoke .modal-header h2 { 486 | margin: 0; 487 | font-size: var(--bespoke-font-size-xl); 488 | color: var(--bespoke-fg); 489 | } 490 | 491 | .bespoke .modal-close { 492 | background: none; 493 | border: none; 494 | font-size: var(--bespoke-font-size-2xl); 495 | color: var(--bespoke-muted); 496 | cursor: pointer; 497 | padding: var(--bespoke-space-xs) var(--bespoke-space-sm); 498 | border-radius: var(--bespoke-radius-sm); 499 | line-height: 1; 500 | transition: all 0.2s ease; 501 | } 502 | 503 | .bespoke .modal-close:hover { 504 | background: var(--bespoke-control-bg); 505 | color: var(--bespoke-fg); 506 | } 507 | 508 | .bespoke .modal-body { 509 | padding: var(--bespoke-modal-padding-lg); 510 | overflow-y: auto; 511 | flex: 1; 512 | line-height: var(--bespoke-line-height-relaxed); 513 | } 514 | 515 | .bespoke .modal-body h2 { 516 | margin-top: var(--bespoke-space-2xl); 517 | margin-bottom: var(--bespoke-space-lg); 518 | font-size: var(--bespoke-font-size-xl); 519 | color: var(--bespoke-fg); 520 | } 521 | 522 | .bespoke .modal-body h2:first-child { 523 | margin-top: 0; 524 | } 525 | 526 | .bespoke .modal-body h3 { 527 | margin-top: var(--bespoke-space-xl); 528 | margin-bottom: var(--bespoke-space-sm); 529 | font-size: var(--bespoke-font-size-lg); 530 | color: var(--bespoke-fg); 531 | } 532 | 533 | .bespoke .modal-body p, 534 | .bespoke .modal-body li { 535 | color: var(--bespoke-fg); 536 | margin-bottom: var(--bespoke-space-sm); 537 | } 538 | 539 | .bespoke .modal-body ul, 540 | .bespoke .modal-body ol { 541 | margin: var(--bespoke-space-sm) 0 var(--bespoke-space-lg) 0; 542 | padding-left: var(--bespoke-space-xl); 543 | } 544 | 545 | .bespoke .modal-body code { 546 | font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; 547 | background: rgba(148, 163, 184, 0.12); 548 | border-radius: var(--bespoke-radius-sm); 549 | padding: 0.15em 0.35em; 550 | font-size: var(--bespoke-font-size-sm); 551 | } 552 | 553 | .bespoke .modal-body pre { 554 | font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; 555 | background: rgba(148, 163, 184, 0.12); 556 | border-radius: var(--bespoke-radius-md); 557 | padding: var(--bespoke-input-padding-md); 558 | overflow: auto; 559 | margin: var(--bespoke-space-lg) 0; 560 | } 561 | 562 | .bespoke .modal-body img, 563 | .bespoke .modal-body video { 564 | max-width: 100%; 565 | height: auto; 566 | border-radius: var(--bespoke-radius-md); 567 | border: 1px solid var(--bespoke-stroke); 568 | margin: var(--bespoke-space-lg) 0; 569 | } 570 | 571 | /* ===== ADDITIONAL FORM COMPONENTS (NON TESTED) ===== */ 572 | 573 | /* Radio Buttons */ 574 | .bespoke input[type="radio"] { 575 | appearance: none; 576 | width: 1rem; 577 | height: 1rem; 578 | min-height: unset !important; 579 | max-height: unset !important; 580 | border: 2px solid var(--bespoke-control-border); 581 | border-radius: 50%; 582 | background: var(--bespoke-control-bg); 583 | cursor: pointer; 584 | position: relative; 585 | transition: all 0.2s ease; 586 | flex-shrink: 0; 587 | padding: 0; 588 | } 589 | 590 | .bespoke input[type="radio"]:checked { 591 | border-color: var(--bespoke-accent); 592 | background: var(--bespoke-accent); 593 | } 594 | 595 | .bespoke input[type="radio"]:checked::after { 596 | content: ''; 597 | position: absolute; 598 | top: 50%; 599 | left: 50%; 600 | transform: translate(-50%, -50%); 601 | width: 0.375rem; 602 | height: 0.375rem; 603 | border-radius: 50%; 604 | background: white; 605 | } 606 | 607 | .bespoke input[type="radio"]:hover { 608 | border-color: var(--bespoke-muted); 609 | } 610 | 611 | .bespoke input[type="radio"]:focus-visible { 612 | outline: none; 613 | border-color: var(--bespoke-control-focus); 614 | box-shadow: 0 0 0 3px rgba(55, 125, 255, 0.1); 615 | } 616 | 617 | .bespoke .radio-group { 618 | display: flex; 619 | flex-direction: column; 620 | gap: var(--bespoke-space-sm); 621 | } 622 | 623 | .bespoke .radio-group.horizontal { 624 | flex-direction: row; 625 | align-items: center; 626 | gap: var(--bespoke-space-lg); 627 | } 628 | 629 | /* Textarea */ 630 | .bespoke textarea { 631 | padding: var(--bespoke-input-padding-md); 632 | border: 1px solid var(--bespoke-input-border); 633 | border-radius: var(--bespoke-radius-md); 634 | background: var(--bespoke-input-bg); 635 | color: var(--bespoke-input-text); 636 | font: inherit; 637 | min-height: 6rem; 638 | resize: vertical; 639 | transition: border-color 0.2s ease; 640 | } 641 | 642 | .bespoke textarea:hover { 643 | border-color: var(--bespoke-input-hover-border); 644 | } 645 | 646 | .bespoke textarea:focus-visible { 647 | outline: none; 648 | border-color: var(--bespoke-control-focus); 649 | box-shadow: 0 0 0 3px rgba(55, 125, 255, 0.1); 650 | } 651 | 652 | .bespoke textarea::placeholder { 653 | color: var(--bespoke-muted); 654 | opacity: 1; 655 | } 656 | 657 | /* Toggle Switch */ 658 | .bespoke .toggle { 659 | position: relative; 660 | display: inline-block; 661 | width: 3rem; 662 | height: 1.5rem; 663 | } 664 | 665 | .bespoke .toggle-input { 666 | opacity: 0; 667 | width: 0; 668 | height: 0; 669 | } 670 | 671 | .bespoke .toggle-slider { 672 | position: absolute; 673 | cursor: pointer; 674 | top: 0; 675 | left: 0; 676 | right: 0; 677 | bottom: 0; 678 | background-color: var(--bespoke-control-border); 679 | transition: 0.3s; 680 | border-radius: 1.5rem; 681 | } 682 | 683 | /* Toggle slider background override for better visibility */ 684 | .bespoke .toggle-slider { 685 | background-color: var(--bespoke-toggle-bg); 686 | } 687 | 688 | .bespoke .toggle-slider:before { 689 | position: absolute; 690 | content: ""; 691 | height: 1.125rem; 692 | width: 1.125rem; 693 | left: 0.1875rem; 694 | bottom: 0.1875rem; 695 | background-color: white; 696 | transition: 0.3s; 697 | border-radius: 50%; 698 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 699 | } 700 | 701 | .bespoke .toggle-input:checked + .toggle-slider { 702 | background-color: var(--bespoke-accent); 703 | } 704 | 705 | .bespoke .toggle-input:checked + .toggle-slider:before { 706 | transform: translateX(1.5rem); 707 | } 708 | 709 | .bespoke .toggle-input:focus + .toggle-slider { 710 | box-shadow: 0 0 0 3px rgba(55, 125, 255, 0.1); 711 | } 712 | 713 | .bespoke .toggle-input:disabled + .toggle-slider { 714 | opacity: 0.5; 715 | cursor: not-allowed; 716 | } 717 | 718 | .bespoke .toggle-label { 719 | margin-left: var(--bespoke-space-sm); 720 | font-size: var(--bespoke-font-size-sm); 721 | color: var(--bespoke-fg); 722 | cursor: pointer; 723 | } 724 | 725 | /* ===== DARK MODE ADJUSTMENTS ===== */ 726 | @media (prefers-color-scheme: dark) { 727 | .bespoke .modal-backdrop { 728 | background: rgba(0, 0, 0, 0.7); 729 | } 730 | 731 | .bespoke .modal-body code, 732 | .bespoke .modal-body pre { 733 | background: rgba(148, 163, 184, 0.2); 734 | } 735 | 736 | .bespoke select { 737 | background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23c1c7d7' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e"); 738 | } 739 | 740 | 741 | /* Dark mode adjustments for new components (NON TESTED) */ 742 | .bespoke input[type="radio"] { 743 | background: var(--bespoke-control-bg); 744 | border-color: var(--bespoke-control-border); 745 | } 746 | 747 | 748 | 749 | 750 | } 751 | 752 | /* ===== RESPONSIVE DESIGN ===== */ 753 | @media (max-width: 768px) { 754 | .bespoke .main-layout { 755 | grid-template-columns: 1fr; 756 | grid-template-rows: auto 1fr; 757 | } 758 | 759 | .bespoke .sidebar { 760 | border-right: none; 761 | border-bottom: 1px solid var(--bespoke-stroke); 762 | } 763 | 764 | .bespoke .modal { 765 | padding: var(--bespoke-space-sm); 766 | } 767 | 768 | .bespoke .modal-content { 769 | max-height: 95vh; 770 | } 771 | 772 | .bespoke .modal-header { 773 | padding: var(--bespoke-modal-padding-md); 774 | } 775 | 776 | .bespoke .modal-body { 777 | padding: var(--bespoke-modal-padding-md); 778 | } 779 | 780 | .bespoke .modal-header h2 { 781 | font-size: var(--bespoke-font-size-lg); 782 | } 783 | } 784 | --------------------------------------------------------------------------------