This document describes the architecture of the Megahub web IDE. It is aimed at developers who want to understand, extend, or test the frontend. User-facing information is in README.md.
The IDE is a single-page web application built with Vite and plain ES modules — no framework, no virtual DOM. It runs entirely in the browser and communicates with the Megahub device either via the Web Bluetooth API (BLE mode) or HTTP (WiFi mode).
The UI is composed of Web Components (Custom Elements with Shadow DOM). Each component is self-contained: it owns its own HTML template and CSS stylesheet, which are imported at build time as raw strings and adopted via CSSStyleSheet.
Vite is configured with three named modes, selected at build time:
| Mode | Command | Description |
|---|---|---|
dev |
npm run dev |
Local dev server; uses localStorage for persistence, no real device needed |
bt |
npm run build:bt |
Hosted on GitHub Pages; communicates with the device over BLE |
web |
npm run build:web |
Embedded in firmware flash; communicates with the ESP32 HTTP server |
The active mode is exposed to all modules via import.meta.env.VITE_MODE. Each module that talks to the device branches on this value to call either BLE or HTTP endpoints.
frontend/
├── index.html Entry point HTML
├── src/
│ ├── index.js Application wiring layer (thin)
│ ├── bleclient.js BLE protocol client
│ ├── theme.css VS Code dark theme CSS variables
│ ├── styles.css Global layout and utility styles
│ ├── app/
│ │ ├── state.js Observable state store
│ │ ├── events.js CustomEvent name constants
│ │ ├── ble-instance.js Shared BLEClient singleton
│ │ ├── app.js Application logic (project management, code execution)
│ │ ├── connection.js BLE connection lifecycle and reconnection
│ │ └── autosave.js Periodic auto-save timer
│ └── components/
│ ├── blockly/ Blockly workspace editor + block definitions
│ ├── btdevicelist/ Bluetooth Classic device list and pairing
│ ├── files/ Project list, create/open/delete/autostart
│ ├── logger/ Real-time log output panel
│ ├── luapreview/ Generated Lua code preview panel
│ ├── portstatus/ LEGO port connection status grid
│ ├── sidebar-toggle/ Accordion sidebar controller
│ └── ui/ Button bar (Execute, Stop, Save, Autosave)
└── test/
└── setup.js Vitest global setup (state reset before each test)
All application state is held in the state.js module. No window.Application or other globals. Components and modules import only what they need.
User action
└─► Component dispatches CustomEvent (bubbles: true, composed: true)
└─► index.js hears it on document
└─► Calls App function (app.js)
└─► Talks to device (BLE or HTTP)
└─► Calls setState()
└─► Notifies subscribers
└─► Components re-render
Components never import app.js. They communicate upward only through events and receive data only through state subscriptions. This keeps components independently testable.
index.js is the only place that knows about both components and the app module. Its job is to:
- Register
document-level CustomEvent listeners and route them to App functions - Subscribe to state keys that affect non-component DOM (e.g., the header breadcrumb)
- Wire toolbar buttons (Execute, Stop, Save, Autosave) to App functions
- Set up the initial UI state on page load
A minimal pub/sub store (~80 lines, no dependencies).
| Key | Type | Description |
|---|---|---|
portStatuses |
Array |
Current LEGO port connection data from the device |
deviceList |
Array |
Bluetooth Classic device list (for pairing panel) |
isConnected |
boolean |
Whether the BLE connection is active |
activeProject |
string|null |
Name of the currently open project |
projects |
Array |
Project list shown in the files panel |
autostartProject |
string|null |
Name of the autostart-marked project |
import { getState, setState, subscribe, resetState } from './app/state.js';
// Read a value
const project = getState('activeProject');
// Write one or more values — notifies subscribers for each changed key
setState({ portStatuses: ports, isConnected: true });
// Subscribe to a single key — returns an unsubscribe function
const unsub = subscribe('projects', projects => this.render(projects));
// Call unsub() in disconnectedCallback to prevent memory leaks
// Reset everything — used in test setup
resetState();Components signal user intent by dispatching CustomEvents with bubbles: true, composed: true so they cross Shadow DOM boundaries and reach document. index.js listens at the document level.
| Constant | Event name | detail payload |
Meaning |
|---|---|---|---|
APP_EVENT_PROJECT_OPEN |
app:project:open |
{ id } |
User clicked Open |
APP_EVENT_PROJECT_CREATE |
app:project:create |
{ id } |
User clicked Create |
APP_EVENT_PROJECT_DELETE |
app:project:delete |
{ id } |
User clicked Delete |
APP_EVENT_AUTOSTART_SET |
app:autostart:set |
{ project } |
User clicked autostart star |
APP_EVENT_BT_DISCOVER |
app:bt:discover |
{} |
User clicked Scan |
APP_EVENT_BT_PAIR |
app:bt:pair |
{ mac } |
User clicked Pair |
APP_EVENT_BT_UNPAIR |
app:bt:unpair |
{ mac } |
User clicked Unpair |
Named exports, no class, no singleton pattern. Each function is independently callable and testable.
| Export | Description |
|---|---|
setMode(mode) |
Switch the UI between btconnect, management, editor views |
setInitState() |
Reset to the BLE connect screen |
jumpToFilesView() |
Load projects + autostart from device, navigate to management view |
createProject(id) |
Switch to editor for a new empty project |
openProject(id) |
Load project file and switch to editor |
deleteProject(id) |
Delete from device and refresh project list |
setAutostartProject(project) |
Persist autostart selection to device |
requestProjectFile(id, filename) |
Fetch a file from the active project |
saveProjectFile(filename, type, content) |
Upload a file to the active project |
syntaxCheck(luaCode) |
Run a Lua syntax check on the device |
executeCode(luaCode) |
Upload and execute a Lua program |
stop() |
Stop the running program |
requestPairing(mac) |
Initiate Bluetooth Classic pairing |
requestRemovePairing(mac) |
Remove a pairing |
startBluetoothDiscovery() |
Start a Bluetooth Classic device scan |
Every device-facing function contains a mode branch:
const mode = import.meta.env.VITE_MODE; // 'dev' | 'bt' | 'web'
export async function executeCode(luaCode) {
if (mode === 'dev') { return true; }
if (mode === 'bt') { /* BLE sendRequest */ }
if (mode === 'web') { /* fetch('/execute', ...) */ }
}Manages the full connect / disconnect / reconnect lifecycle.
import { initBLEConnection } from './app/connection.js';
await initBLEConnection({ logger, uiComponents, blocklyEditor });- Shows the 6-step connection progress modal
- Calls
bleClient.connect()with a step-update callback - On success: updates status bar, wires BLE event listeners, sends
READY_FOR_EVENTS, then navigates to the project list - On failure: shows the error state in the modal (auto-closes after 4 s)
- Registers a disconnect handler (only once) that triggers reconnection
Exponential backoff: 2 s → 4 s → 8 s → 16 s → 30 s (cap), up to 10 attempts. A persistent "Connection Lost" notification is shown and dismissed on success. After 10 failed attempts the UI returns to the connect screen.
| BLE event | Action |
|---|---|
APP_EVENT_TYPE_PORTSTATUS |
setState({ portStatuses: status.ports }) |
APP_EVENT_TYPE_BTCLASSICDEVICES |
setState({ deviceList: data }) |
APP_EVENT_TYPE_LOG |
Direct call: logger.addToLog(text) |
APP_EVENT_TYPE_COMMAND |
Direct call: blocklyEditor.addProfilingOverlay() or uiComponents.processUIEvent() |
Log and command events are delivered directly to components because they are append-only streams, not replaceable state.
Low-level Web Bluetooth API wrapper. Manages the GATT connection, characteristic notifications, MTU negotiation, request/response framing, and streaming file upload. ble-instance.js exports a single shared instance that app.js and connection.js both import.
Each component lives in its own directory: component.js, component.html, and style.css. The HTML template and stylesheet are inlined at build time.
connectedCallback() {
this._unsub = subscribe('projects', projects => {
if (projects) this.render(projects);
});
}
disconnectedCallback() {
this._unsub?.();
}// User interaction → CustomEvent → index.js → App function
this.dispatchEvent(new CustomEvent(APP_EVENT_PROJECT_OPEN, {
bubbles: true,
composed: true,
detail: { id: projectName }
}));| Component | State subscriptions | Events dispatched |
|---|---|---|
custom-files |
projects, autostartProject |
PROJECT_OPEN, PROJECT_CREATE, PROJECT_DELETE, AUTOSTART_SET |
custom-portstatus |
portStatuses |
— |
custom-btdevicelist |
deviceList |
BT_DISCOVER, BT_PAIR, BT_UNPAIR |
custom-logger |
— | — |
custom-blockly |
— | — |
custom-luapreview |
— | — |
custom-ui |
— | — |
custom-sidebar-toggle |
— | — |
A 10-second interval timer, toggled by the autosave toolbar button. The save function is injected by index.js at startup via setSaveFunction(fn). The timer only fires if getState('activeProject') is non-null, so it does nothing while no project is open.
The layout has three display modes, controlled by setMode():
| Mode | When | What is visible |
|---|---|---|
btconnect |
On load (BLE build) | Connect button only |
management |
After connect | Project list, logger |
editor |
Project open | Blockly editor, toolbar, sidebar panels |
Two visibility mechanisms are in use:
dynamicvisibilityclass —setMode()setsdisplay: none/blockimperatively for elements with a matchingvisible-<mode>class.body[data-mode]CSS attribute — used for elements that needdisplay: flexordisplay: gridinstead ofblock(e.g., the header back button).
Tests use Vitest with happy-dom as the DOM environment. happy-dom is used instead of jsdom because it has proper Shadow DOM support, which is required for testing Web Components.
cd frontend
npm test # single run
npm run test:watch # watch mode
npm run test:coverage # coverage report (V8 provider)Tests are co-located with source files (src/**/*.test.js) or placed in test/.
test/setup.js calls resetState() in a beforeEach hook so each test starts with a clean state and no lingering subscribers.
| Test file | Coverage |
|---|---|
src/app/state.test.js |
getState, setState, subscribe, unsubscribe, resetState, multi-subscriber, unknown key |
src/components/blockly/mh_wait.test.js |
Block definition structure, Lua code generator output |
Priority areas for additional test coverage:
filescomponent — render from state, CustomEvent dispatch on button clicks, autostart star toggle vs. backend notification splitportstatuscomponent — renders connected and disconnected port cards correctlybtdevicelistcomponent — scan, pair, unpair event dispatch; paired badge renderingapp.jspure functions —validateProjectNameequivalents, mode branching with mockedfetch/bleClientautosave.js— enable/disable, timer fires only when project is open (usevi.useFakeTimers())
- Create
src/components/<name>/component.js,component.html,style.css. - In
component.js:- Import state subscriptions you need from
../../app/state.js - Import event constants from
../../app/events.js - Subscribe in
connectedCallback, unsubscribe indisconnectedCallback - Dispatch CustomEvents for user actions — never call
app.jsdirectly
- Import state subscriptions you need from
- Register the custom element:
customElements.define('custom-<name>', YourClass) - Add the element to
index.htmlwhere needed - In
index.js, adddocument.addEventListener(YOUR_EVENT, ...)handlers if the component introduces new event types - Add the new event constants to
events.js
- Add the key and its default value to
_stateinstate.js - Add the same key to the
resetState()call instate.js - Update the JSDoc type annotation at the top of
state.js - Call
setState({ yourKey: value })wherever the value is produced (typically inconnection.jsorapp.js) - Subscribe to it in any component that needs it