diff --git a/frontend/src/data/examples-picow-wifi.ts b/frontend/src/data/examples-picow-wifi.ts
index 31d69a7d..1ddf0352 100644
--- a/frontend/src/data/examples-picow-wifi.ts
+++ b/frontend/src/data/examples-picow-wifi.ts
@@ -73,13 +73,21 @@ async def wifi_connect():
led_on = False
def page():
- # The board's onboard LED isn't drawn on the canvas, so show the state
- # here; the buttons reload the page so you can see it flip.
- state = "ON" if led_on else "OFF"
- return ("
Pico W LED: %s
"
- " "
- ""
- "") % state
+ # The board's onboard LED isn't drawn on the canvas, so show a big
+ # state indicator here; the buttons reload so you can see it flip.
+ color = "#22c55e" if led_on else "#666"
+ label = "LED ON" if led_on else "LED OFF"
+ return (""
+ ""
+ ""
+ "
"
- " "
- ""
- "") % state
+ on = (relay_state == 0)
+ color = "#22c55e" if on else "#666"
+ label = "RELAY ON" if on else "RELAY OFF"
+ return (""
+ ""
+ "
Pico W Relay (GP2)
"
+ "
%s
"
+ ""
+ ""
+ "") % (color, label)
# Wrap each client in try/except: a browser that drops the connection
# mid-response would otherwise raise ECONNRESET and kill the server loop.
diff --git a/frontend/src/lib/openDeviceGateway.ts b/frontend/src/lib/openDeviceGateway.ts
index 8c31c831..ad0d9754 100644
--- a/frontend/src/lib/openDeviceGateway.ts
+++ b/frontend/src/lib/openDeviceGateway.ts
@@ -1,83 +1,105 @@
/**
* openDeviceGateway
*
- * Opens an emulated board's IoT-gateway page inside an in-app iframe panel
- * (same tab) instead of a new browser tab.
+ * Opens an emulated board's IoT-gateway page in a small, FLOATING, draggable
+ * panel docked to the top-right — NOT a modal overlay and NOT a new tab.
*
- * Why: the Raspberry Pi Pico W emulation runs in THIS browser tab, driven by
- * requestAnimationFrame. Opening the gateway in a new tab backgrounds the
- * emulation tab, the browser pauses its rAF, the simulated chip freezes, and
- * the gateway can no longer reach the server running on it (the request times
- * out / returns 502). Rendering the served page in an iframe keeps the
- * emulation tab in the foreground, so the chip keeps running and answers.
+ * Why not a new tab: the Raspberry Pi Pico W emulation runs in THIS browser
+ * tab, driven by requestAnimationFrame. A new tab backgrounds the emulation
+ * tab, the browser pauses its rAF, the simulated chip freezes, and the gateway
+ * can no longer reach the server on it (request times out / 502). An in-tab
+ * iframe keeps the emulation in the foreground so the chip keeps answering.
*
- * (The ESP32 doesn't need this — its server runs in QEMU on the backend, which
- * is unaffected by browser tab visibility.)
+ * Why not a modal: the panel must NOT block the canvas or the editor — the
+ * user needs to watch the board react (LED/relay) and keep pressing buttons,
+ * wiring components, editing code while the device page is open. So: no
+ * backdrop, draggable by its title bar, resizable, parked out of the way.
*
- * Plain DOM, no React state, so it can be invoked from anywhere (serial-monitor
- * link, canvas WiFi badge) without threading props.
+ * (The ESP32 doesn't need any of this — its server runs in QEMU on the
+ * backend, immune to tab visibility — so it still opens in a new tab.)
*/
-const OVERLAY_ID = 'velxio-device-gateway-overlay';
+const PANEL_ID = 'velxio-device-gateway-panel';
export function openDeviceGateway(url: string): void {
if (typeof document === 'undefined') return;
- document.getElementById(OVERLAY_ID)?.remove();
-
- const overlay = document.createElement('div');
- overlay.id = OVERLAY_ID;
- overlay.style.cssText =
- 'position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,0.55);' +
- 'display:flex;align-items:center;justify-content:center;';
+ document.getElementById(PANEL_ID)?.remove();
const panel = document.createElement('div');
+ panel.id = PANEL_ID;
panel.style.cssText =
- 'background:#1e1e1e;border:1px solid #3a3a3a;border-radius:10px;' +
- 'width:min(440px,94vw);height:min(640px,90vh);display:flex;' +
- 'flex-direction:column;overflow:hidden;box-shadow:0 12px 48px rgba(0,0,0,0.5);';
+ 'position:fixed;top:64px;right:20px;z-index:100000;' +
+ 'width:340px;height:500px;display:flex;flex-direction:column;' +
+ 'background:#1e1e1e;border:1px solid #444;border-radius:10px;overflow:hidden;' +
+ 'box-shadow:0 12px 48px rgba(0,0,0,0.55);resize:both;min-width:240px;min-height:220px;';
const bar = document.createElement('div');
bar.style.cssText =
- 'display:flex;align-items:center;justify-content:space-between;gap:12px;' +
- 'padding:8px 12px;background:#252526;color:#ddd;' +
- 'font:13px -apple-system,BlinkMacSystemFont,sans-serif;border-bottom:1px solid #3a3a3a;';
+ 'flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;gap:10px;' +
+ 'padding:7px 10px;background:#2d2d2e;color:#ddd;cursor:move;user-select:none;' +
+ 'font:12px -apple-system,BlinkMacSystemFont,sans-serif;border-bottom:1px solid #444;';
const title = document.createElement('span');
- title.textContent = 'Device web page (IoT Gateway)';
- title.style.cssText = 'font-weight:600;';
+ title.textContent = 'Device web page';
+ title.style.cssText = 'font-weight:600;white-space:nowrap;';
const right = document.createElement('div');
- right.style.cssText = 'display:flex;align-items:center;gap:14px;';
+ right.style.cssText = 'display:flex;align-items:center;gap:12px;';
const reload = document.createElement('button');
reload.type = 'button';
reload.textContent = 'Reload';
- reload.style.cssText = 'background:none;border:none;color:#4fc3f7;cursor:pointer;font-size:13px;padding:0;';
+ reload.style.cssText = 'background:none;border:none;color:#4fc3f7;cursor:pointer;font-size:12px;padding:0;';
const openTab = document.createElement('a');
openTab.textContent = 'Open in tab';
openTab.href = url;
openTab.target = '_blank';
openTab.rel = 'noreferrer';
- openTab.style.cssText = 'color:#4fc3f7;text-decoration:none;font-size:13px;';
+ openTab.style.cssText = 'color:#4fc3f7;text-decoration:none;font-size:12px;';
const close = document.createElement('button');
close.type = 'button';
close.textContent = 'Close';
- close.style.cssText = 'background:#3a3a3a;border:none;color:#fff;cursor:pointer;font-size:13px;border-radius:5px;padding:4px 10px;';
+ close.style.cssText = 'background:#444;border:none;color:#fff;cursor:pointer;font-size:12px;border-radius:4px;padding:3px 9px;';
const iframe = document.createElement('iframe');
iframe.src = url;
- iframe.style.cssText = 'flex:1;border:none;width:100%;background:#fff;';
+ iframe.style.cssText = 'flex:1 1 auto;border:none;width:100%;background:#fff;';
reload.onclick = () => { iframe.src = url; };
- const dismiss = () => overlay.remove();
- close.onclick = dismiss;
- overlay.onclick = (e) => { if (e.target === overlay) dismiss(); };
+ close.onclick = () => panel.remove();
+
+ // Drag by the title bar. Switch from right-anchored to left/top on grab so
+ // the panel follows the cursor cleanly.
+ bar.addEventListener('mousedown', (e) => {
+ if (e.target !== bar && e.target !== title) return; // not on a button
+ e.preventDefault();
+ const rect = panel.getBoundingClientRect();
+ const dx = e.clientX - rect.left;
+ const dy = e.clientY - rect.top;
+ panel.style.right = 'auto';
+ panel.style.left = rect.left + 'px';
+ panel.style.top = rect.top + 'px';
+ // While dragging, don't let the iframe swallow mouse events.
+ iframe.style.pointerEvents = 'none';
+ const onMove = (m: MouseEvent) => {
+ const maxX = window.innerWidth - 60;
+ const maxY = window.innerHeight - 30;
+ panel.style.left = Math.max(0, Math.min(maxX, m.clientX - dx)) + 'px';
+ panel.style.top = Math.max(0, Math.min(maxY, m.clientY - dy)) + 'px';
+ };
+ const onUp = () => {
+ iframe.style.pointerEvents = '';
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ };
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ });
right.append(reload, openTab, close);
bar.append(title, right);
panel.append(bar, iframe);
- overlay.append(panel);
- document.body.append(overlay);
+ document.body.append(panel);
}