<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>__WGUI_TITLE__</title>
<link rel="icon" type="image/png" href="/favicon.png">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1e1e1e;
--bg-dark: #0f0f0f;
--surface: #2a2a2a;
--surface-hover: #333333;
--surface-elevated: #3a3a3a;
--border: #404040;
--border-light: #505050;
--text: #e0e0e0;
--text-dim: #888888;
--text-muted: #666666;
--coral: #e06c5c;
--coral-glow: rgba(224, 108, 92, 0.3);
--teal: #00bfa5;
--teal-glow: rgba(0, 191, 165, 0.3);
--blue: #2196f3;
--blue-glow: rgba(33, 150, 243, 0.3);
--green: #4caf50;
--green-glow: rgba(76, 175, 80, 0.3);
--purple: #9c27b0;
--purple-glow: rgba(156, 39, 176, 0.3);
--orange: #ff9800;
--orange-glow: rgba(255, 152, 0, 0.3);
--yellow: #ffeb3b;
--yellow-glow: rgba(255, 235, 59, 0.3);
--red: #f44336;
--red-glow: rgba(244, 67, 54, 0.3);
--accent: var(--coral);
--accent-glow: var(--coral-glow);
--radius: 8px;
--radius-lg: 12px;
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
--mono: 'Cascadia Code', 'Fira Code', 'Consolas', 'Courier New', monospace;
}
body {
font-family: var(--font);
font-size: 13px;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
#app {
column-count: 2;
column-gap: 12px;
padding: 12px;
max-width: 1800px;
margin: 0 auto;
}
@media (min-width: 1200px) { #app { column-count: 3; } }
@media (max-width: 700px) { #app { column-count: 1; } }
#app > .card {
break-inside: avoid;
margin-bottom: 12px;
}
#status-bar {
position: fixed;
top: 0; left: 0; right: 0;
height: 3px;
z-index: 100;
transition: background 0.3s;
}
#status-bar.connected { background: var(--green); }
#status-bar.disconnected { background: var(--red); }
#status-bar.connecting { background: var(--yellow); }
.card {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.card.coral { border-color: var(--coral); --accent: var(--coral); --accent-glow: var(--coral-glow); }
.card.teal { border-color: var(--teal); --accent: var(--teal); --accent-glow: var(--teal-glow); }
.card.blue { border-color: var(--blue); --accent: var(--blue); --accent-glow: var(--blue-glow); }
.card.green { border-color: var(--green); --accent: var(--green); --accent-glow: var(--green-glow); }
.card.purple { border-color: var(--purple); --accent: var(--purple); --accent-glow: var(--purple-glow); }
.card.orange { border-color: var(--orange); --accent: var(--orange); --accent-glow: var(--orange-glow); }
.card.yellow { border-color: var(--yellow); --accent: var(--yellow); --accent-glow: var(--yellow-glow); }
.card.red { border-color: var(--red); --accent: var(--red); --accent-glow: var(--red-glow); }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
}
.card-header:hover {
background: rgba(255,255,255,0.04);
}
.card-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--accent);
}
.card-toggle {
font-size: 10px;
color: var(--text-dim);
transition: transform 0.2s;
}
.card.collapsed .card-toggle {
transform: rotate(-90deg);
}
.card.collapsed .card-body {
display: none;
}
.card-body {
display: flex;
flex-direction: column;
padding: 8px 0;
}
.stat-card {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.stat-card.coral { border-color: var(--coral); }
.stat-card.teal { border-color: var(--teal); }
.stat-card.blue { border-color: var(--blue); }
.stat-card.green { border-color: var(--green); }
.stat-card.purple { border-color: var(--purple); }
.stat-card.orange { border-color: var(--orange); }
.stat-card.yellow { border-color: var(--yellow); }
.stat-card.red { border-color: var(--red); }
.stat-value {
font-family: var(--mono);
font-size: 28px;
font-weight: 600;
color: var(--text);
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-label {
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-subvalue {
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-container {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.progress-container.coral { border-color: var(--coral); --accent: var(--coral); }
.progress-container.teal { border-color: var(--teal); --accent: var(--teal); }
.progress-container.blue { border-color: var(--blue); --accent: var(--blue); }
.progress-container.green { border-color: var(--green); --accent: var(--green); }
.progress-container.purple { border-color: var(--purple); --accent: var(--purple); }
.progress-container.orange { border-color: var(--orange); --accent: var(--orange); }
.progress-container.yellow { border-color: var(--yellow); --accent: var(--yellow); }
.progress-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.progress-title {
font-size: 14px;
font-weight: 500;
color: var(--text);
}
.progress-percent {
font-family: var(--mono);
font-size: 24px;
font-weight: 600;
color: var(--accent);
}
.progress-percent sup {
font-size: 14px;
font-weight: 400;
margin-left: 2px;
}
.progress-track {
height: 8px;
background: var(--bg);
border-radius: 4px;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 4px;
transition: width 0.3s ease;
box-shadow: 0 0 10px var(--accent-glow);
}
.progress-subtitle {
font-size: 11px;
color: var(--text-dim);
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-dim);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
box-shadow: 0 0 6px currentColor;
transition: background 0.2s;
}
.status-dot.green { background: var(--green); color: var(--green-glow); }
.status-dot.red { background: var(--red); color: var(--red-glow); }
.status-dot.yellow { background: var(--yellow); color: var(--yellow-glow); }
.status-dot.blue { background: var(--blue); color: var(--blue-glow); }
.status-dot.coral { background: var(--coral); color: var(--coral-glow); }
.status-dot.teal { background: var(--teal); color: var(--teal-glow); }
.mini-chart {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
padding: 12px;
height: 120px;
display: flex;
flex-direction: column;
}
.mini-chart.coral { border-color: var(--coral); --accent: var(--coral); }
.mini-chart.teal { border-color: var(--teal); --accent: var(--teal); }
.mini-chart.blue { border-color: var(--blue); --accent: var(--blue); }
.mini-chart.green { border-color: var(--green); --accent: var(--green); }
.mini-chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
gap: 4px;
overflow: hidden;
}
.mini-chart-title {
font-size: 12px;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.mini-chart-value {
font-family: var(--mono);
font-size: 14px;
color: var(--accent);
white-space: nowrap;
flex-shrink: 0;
}
.mini-chart-canvas {
flex: 1;
width: 100%;
min-height: 0;
}
.widget-row {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 12px;
min-height: 40px;
transition: background 0.1s;
}
.widget-row:hover {
background: rgba(255,255,255,0.02);
}
.widget-label {
flex: 0 1 100px;
font-size: 12px;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 60px;
}
.widget-control {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.slider-container {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
}
input[type="range"] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: var(--bg);
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--surface);
box-shadow: 0 0 8px var(--accent-glow);
transition: background 0.15s, transform 0.15s;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: var(--accent);
transform: scale(1.1);
}
.slider-value {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
min-width: 60px;
text-align: right;
background: var(--bg);
padding: 4px 8px;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.checkbox-wrapper {
display: flex;
align-items: center;
cursor: pointer;
gap: 8px;
}
.checkbox-box {
width: 18px;
height: 18px;
border: 2px solid var(--border);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
background: transparent;
}
.checkbox-box.checked {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
.checkbox-box::after {
content: '';
display: block;
width: 5px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
opacity: 0;
transition: opacity 0.15s;
}
.checkbox-box.checked::after {
opacity: 1;
}
.checkbox-label {
font-size: 12px;
color: var(--text);
}
.color-picker-wrapper {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.color-picker-container {
position: relative;
display: flex;
align-items: center;
gap: 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px 8px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.color-picker-container:hover {
border-color: var(--border-light);
}
.color-picker-container:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
}
input[type="color"] {
-webkit-appearance: none;
appearance: none;
width: 32px;
height: 24px;
border: 2px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
background: transparent;
padding: 0;
overflow: hidden;
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 3px;
}
.color-hex {
font-family: var(--mono);
font-size: 12px;
color: var(--text);
min-width: 70px;
}
.plot-container {
background: var(--surface);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
height: 280px;
}
.plot-container.coral { border-color: var(--coral); }
.plot-container.teal { border-color: var(--teal); }
.plot-container.blue { border-color: var(--blue); }
.plot-container.green { border-color: var(--green); }
.plot-container.purple { border-color: var(--purple); }
.plot-container.orange { border-color: var(--orange); }
.plot-container.yellow { border-color: var(--yellow); }
.plot-container.red { border-color: var(--red); }
.plot-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.plot-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.plot-legend {
display: flex;
gap: 16px;
}
.plot-legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim);
}
.plot-legend-color {
width: 12px;
height: 3px;
border-radius: 2px;
}
.plot-canvas {
flex: 1;
width: 100%;
min-height: 0;
}
.plot-axis-labels {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--text-muted);
}
.grid .stat-card {
padding: 12px;
min-width: 0;
}
.grid .stat-value {
font-size: 22px;
}
.grid .stat-label {
font-size: 10px;
}
.grid {
display: grid;
gap: 12px;
box-sizing: border-box;
}
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
.grid-4 { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
@media (max-width: 600px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
}
.card-body > .grid {
margin: 8px 16px;
}
.card-body > .grid:first-child {
margin-top: 0;
}
.grid > .widget-row {
padding: 0;
display: block;
}
.text-input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 12px;
padding: 6px 10px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.text-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
}
select.dropdown {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 12px;
padding: 6px 10px;
outline: none;
cursor: pointer;
transition: border-color 0.15s;
}
select.dropdown:focus {
border-color: var(--accent);
}
select.dropdown option {
background: var(--surface);
color: var(--text);
}
.wgui-button {
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius);
padding: 8px 18px;
font-family: var(--font);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
box-shadow: 0 2px 8px var(--accent-glow);
}
.wgui-button:hover {
filter: brightness(1.1);
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--accent-glow);
}
.wgui-button:active {
transform: scale(0.97);
}
.label-text {
font-size: 12px;
color: var(--text);
}
.separator {
height: 1px;
background: var(--border);
margin: 6px 16px;
}
.section-header {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
padding: 8px 16px 4px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-dim);
font-size: 14px;
gap: 8px;
column-span: all;
}
.empty-state .logo {
font-size: 24px;
font-weight: 700;
color: var(--accent);
letter-spacing: 2px;
margin-bottom: 8px;
}
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
</style>
</head>
<body>
<div id="status-bar" class="connecting"></div>
<div id="app">
<div class="empty-state">
<div class="logo">wgui</div>
<div>Connecting...</div>
</div>
</div>
<script>
(function() {
'use strict';
let elements = [];
let ws = null;
let reconnectDelay = 500;
const recentlyInteracted = new Map(); let chartInstances = new Map();
const wsPort = parseInt(location.port) + 1;
const wsUrl = `ws://${location.hostname}:${wsPort}`;
function connect() {
setStatus('connecting');
ws = new WebSocket(wsUrl);
ws.onopen = () => {
setStatus('connected');
reconnectDelay = 500;
};
ws.onclose = () => {
setStatus('disconnected');
ws = null;
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 1.5, 5000);
};
ws.onerror = () => {};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
handleMessage(msg);
};
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
function setStatus(s) {
const bar = document.getElementById('status-bar');
bar.className = s;
}
function handleMessage(msg) {
switch (msg.type) {
case 'snapshot':
elements = msg.elements;
render();
break;
case 'add':
elements = elements.filter(el => el.id !== msg.element.id);
elements.push(msg.element);
render();
break;
case 'update': {
let found = false;
for (let el of elements) {
if (el.id === msg.id) {
el.value = msg.value;
if (msg.label) el.label = msg.label;
if (msg.meta) el.meta = msg.meta;
found = true;
updateElementDOM(el);
break;
}
}
if (!found) console.warn('wgui: update for unknown id', msg.id);
break;
}
case 'remove':
elements = elements.filter(el => el.id !== msg.id);
render();
break;
}
}
function render() {
const app = document.getElementById('app');
if (elements.length === 0) {
app.innerHTML = `
<div class="empty-state">
<div class="logo">wgui</div>
<div>No elements declared</div>
</div>`;
return;
}
const gridChildIds = new Set();
for (const el of elements) {
if (el.kind.type === 'Grid' && el.value.data && el.value.data.children) {
for (const childId of el.value.data.children) {
gridChildIds.add(childId);
}
}
}
const windows = new Map();
for (const el of elements) {
if (gridChildIds.has(el.id)) continue;
const win = el.window || 'Default';
if (!windows.has(win)) windows.set(win, []);
windows.get(win).push(el);
}
let html = '';
for (const [name, elems] of windows) {
const firstAccent = elems[0]?.meta?.accent || 'coral';
html += `<div class="card ${firstAccent}" data-window="${esc(name)}">`;
html += `<div class="card-header" onclick="wgui.toggleCard(this)">`;
html += `<span class="card-title">${esc(name)}</span>`;
html += `<span class="card-toggle">▼</span>`;
html += `</div>`;
html += `<div class="card-body">`;
for (const el of elems) {
html += renderElement(el);
}
html += `</div></div>`;
}
app.innerHTML = html;
for (const el of elements) {
if (el.kind.type === 'MiniChart') {
initMiniChart(el);
} else if (el.kind.type === 'Plot') {
initPlot(el);
}
}
}
function renderElement(el) {
const r = renderers[el.kind.type];
if (r) return r(el);
return `<div class="widget-row"><span class="widget-label">${esc(el.label)}</span><span class="widget-control">Unknown: ${esc(el.kind.type)}</span></div>`;
}
function updateElementDOM(el) {
const row = document.querySelector(`[data-id="${CSS.escape(el.id)}"]`);
if (!row) { console.warn('wgui: no DOM for', el.id, '→ full render'); render(); return; }
switch (el.kind.type) {
case 'Slider': {
const input = row.querySelector('input[type="range"]');
const display = row.querySelector('.slider-value');
const val = el.value.type === 'Float' ? el.value.data : el.value.data;
const recent = recentlyInteracted.get(el.id);
const isActive = document.activeElement === input || (recent && Date.now() - recent < 500);
if (input && !isActive) input.value = val;
if (display && !isActive) display.textContent = formatNum(val, el.meta.step);
break;
}
case 'Checkbox': {
const box = row.querySelector('.checkbox-box');
if (box) {
if (el.value.data) box.classList.add('checked');
else box.classList.remove('checked');
}
break;
}
case 'ColorPicker3': {
const input = row.querySelector('input[type="color"]');
const hexLabel = row.querySelector('.color-hex');
const hex = rgbToHex(el.value.data);
if (input && document.activeElement !== input) input.value = hex;
if (hexLabel) hexLabel.textContent = hex;
break;
}
case 'ColorPicker4': {
const input = row.querySelector('input[type="color"]');
const hexLabel = row.querySelector('.color-hex');
const hex = rgbToHex(el.value.data);
const alpha = el.value.data[3] ?? 1.0;
if (input && document.activeElement !== input) input.value = hex;
if (hexLabel) hexLabel.textContent = hex;
const alphaSlider = row.querySelector('input[type="range"]');
if (alphaSlider && document.activeElement !== alphaSlider) alphaSlider.value = alpha;
const alphaText = row.querySelector('.alpha-text');
if (alphaText) alphaText.textContent = `α${alpha.toFixed(2)}`;
break;
}
case 'TextInput': {
const input = row.querySelector('.text-input');
if (input && document.activeElement !== input) input.value = el.value.data;
break;
}
case 'Dropdown': {
const select = row.querySelector('select');
if (select && document.activeElement !== select) select.selectedIndex = el.value.data.selected;
break;
}
case 'ProgressBar': {
const fill = row.querySelector('.progress-fill');
const percent = row.querySelector('.progress-percent');
const subtitle = row.querySelector('.progress-subtitle');
const val = el.value.data;
const pct = Math.round(val * 100);
if (fill) fill.style.width = pct + '%';
if (percent) percent.innerHTML = pct + '<sup>%</sup>';
if (subtitle && el.meta.subtitle) subtitle.textContent = el.meta.subtitle;
break;
}
case 'Stat': {
const valueEl = row.querySelector('.stat-value');
const subEl = row.querySelector('.stat-subvalue');
if (valueEl) valueEl.textContent = el.value.data.value;
if (subEl && el.value.data.subvalue) subEl.textContent = el.value.data.subvalue;
break;
}
case 'Status': {
const data = el.value.data;
const color = data.active ? (data.activeColor || data.active_color || 'green') : (data.inactiveColor || data.inactive_color || 'red');
const text = data.active ? (data.activeText || data.active_text || 'Active') : (data.inactiveText || data.inactive_text || 'Inactive');
const dot = row.querySelector('.status-dot');
const span = row.querySelector('.status-indicator span');
if (dot) { dot.className = 'status-dot ' + color; }
if (span) span.textContent = text;
break;
}
case 'MiniChart': {
updateMiniChart(el);
break;
}
case 'Plot': {
updatePlot(el);
break;
}
case 'Grid': {
const children = el.value.data.children || [];
for (const childId of children) {
const child = elements.find(e => e.id === childId);
if (child) updateElementDOM(child);
}
break;
}
case 'Label': {
const labelEl = row.querySelector('.label-text');
if (labelEl) labelEl.textContent = el.value.data;
const nameEl = row.querySelector('.widget-label');
if (nameEl) nameEl.textContent = el.label;
break;
}
case 'Section': {
row.textContent = el.label;
break;
}
case 'Button': {
const btn = row.querySelector('.wgui-button');
if (btn) btn.textContent = el.label;
break;
}
case 'Separator':
break;
default:
render();
}
}
const renderers = {
Slider: (el) => {
const val = el.value.data;
const min = el.meta.min ?? 0;
const max = el.meta.max ?? 1;
const step = el.meta.step ?? 0.01;
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control slider-container">
<input type="range" min="${min}" max="${max}" step="${step}" value="${val}"
oninput="wgui.onSlider('${escJs(el.id)}', this.value, this)">
<span class="slider-value">${formatNum(val, step)}</span>
</div>
</div>`;
},
Checkbox: (el) => {
const checked = el.value.data ? 'checked' : '';
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control">
<div class="checkbox-wrapper" onclick="wgui.onCheckbox('${escJs(el.id)}', this)">
<div class="checkbox-box ${checked}"></div>
<span class="checkbox-label">${el.value.data ? 'On' : 'Off'}</span>
</div>
</div>
</div>`;
},
ColorPicker3: (el) => {
const hex = rgbToHex(el.value.data);
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control color-picker-wrapper">
<div class="color-picker-container">
<input type="color" value="${hex}"
oninput="wgui.onColor3('${escJs(el.id)}', this.value)">
<span class="color-hex">${hex}</span>
</div>
</div>
</div>`;
},
ColorPicker4: (el) => {
const hex = rgbToHex(el.value.data);
const alpha = el.value.data[3] ?? 1.0;
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control color-picker-wrapper">
<div class="color-picker-container">
<input type="color" value="${hex}"
oninput="wgui.onColor4('${escJs(el.id)}', this.value, '${escJs(el.id)}')">
<span class="color-hex">${hex}</span>
</div>
<input type="range" min="0" max="1" step="0.01" value="${alpha}" style="max-width:80px"
oninput="wgui.onColor4Alpha('${escJs(el.id)}', this.value)">
<span class="alpha-text" style="font-family:var(--mono);font-size:11px;color:var(--text-dim);min-width:40px;">α${alpha.toFixed(2)}</span>
</div>
</div>`;
},
TextInput: (el) => {
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control">
<input type="text" class="text-input" value="${esc(el.value.data)}"
oninput="wgui.onText('${escJs(el.id)}', this.value)">
</div>
</div>`;
},
Dropdown: (el) => {
const opts = el.value.data.options.map((o, i) =>
`<option value="${i}" ${i === el.value.data.selected ? 'selected' : ''}>${esc(o)}</option>`
).join('');
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control">
<select class="dropdown" onchange="wgui.onDropdown('${escJs(el.id)}', this.value, this)">
${opts}
</select>
</div>
</div>`;
},
Button: (el) => {
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label"></span>
<div class="widget-control">
<button class="wgui-button" onclick="wgui.onButton('${escJs(el.id)}')">${esc(el.label)}</button>
</div>
</div>`;
},
Label: (el) => {
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control">
<span class="label-text">${esc(el.value.data)}</span>
</div>
</div>`;
},
Separator: (_el) => {
return `<div class="separator"></div>`;
},
Section: (el) => {
return `<div class="section-header" data-id="${esc(el.id)}">${esc(el.label)}</div>`;
},
ProgressBar: (el) => {
const val = el.value.data;
const pct = Math.round(val * 100);
const accent = el.meta.accent || 'coral';
const subtitle = el.meta.subtitle || '';
return `<div class="widget-row" data-id="${esc(el.id)}">
<div class="progress-container ${accent}" style="flex: 1;">
<div class="progress-header">
<span class="progress-title">${esc(el.label)}</span>
<span class="progress-percent">${pct}<sup>%</sup></span>
</div>
<div class="progress-track">
<div class="progress-fill" style="width: ${pct}%"></div>
</div>
${subtitle ? `<div class="progress-subtitle">${esc(subtitle)}</div>` : ''}
</div>
</div>`;
},
Stat: (el) => {
const accent = el.meta.accent || 'coral';
const data = el.value.data;
return `<div class="widget-row" data-id="${esc(el.id)}">
<div class="stat-card ${accent}" style="flex: 1;">
<div class="stat-label">${esc(el.label)}</div>
<div class="stat-value">${esc(data.value)}</div>
${data.subvalue ? `<div class="stat-subvalue">${esc(data.subvalue)}</div>` : ''}
</div>
</div>`;
},
Status: (el) => {
const data = el.value.data;
const color = data.active ? (data.activeColor || 'green') : (data.inactiveColor || 'red');
const text = data.active ? (data.activeText || 'Active') : (data.inactiveText || 'Inactive');
return `<div class="widget-row" data-id="${esc(el.id)}">
<span class="widget-label">${esc(el.label)}</span>
<div class="widget-control">
<div class="status-indicator">
<div class="status-dot ${color}"></div>
<span>${esc(text)}</span>
</div>
</div>
</div>`;
},
MiniChart: (el) => {
const accent = el.meta.accent || 'coral';
const data = el.value.data;
const currentValue = Array.isArray(data.values) && data.values.length > 0
? data.values[data.values.length - 1]
: (data.current || 0);
const unit = data.unit || '';
return `<div class="widget-row" data-id="${esc(el.id)}">
<div class="mini-chart ${accent}" style="flex: 1;">
<div class="mini-chart-header">
<span class="mini-chart-title">${esc(el.label)}</span>
<span class="mini-chart-value">${currentValue}${esc(unit)}</span>
</div>
<canvas class="mini-chart-canvas" id="chart-${esc(el.id)}"></canvas>
</div>
</div>`;
},
Grid: (el) => {
const data = el.value.data || {};
const cols = data.cols || 2;
const children = data.children || [];
let html = `<div class="grid grid-${cols}">`;
for (const childId of children) {
const child = elements.find(e => e.id === childId);
if (child) {
html += renderElement(child);
}
}
html += '</div>';
return html;
},
Plot: (el) => {
const data = el.value.data || {};
const series = data.series || [];
const xLabel = data.x_label || '';
const yLabel = data.y_label || '';
const accent = series.length > 0 ? series[0].color : 'coral';
let legendHtml = '';
if (series.length > 1) {
legendHtml = '<div class="plot-legend">' +
series.map(s => `<div class="plot-legend-item"><div class="plot-legend-color" style="background:var(--${s.color})"></div>${esc(s.name)}</div>`).join('') +
'</div>';
}
return `<div class="widget-row" data-id="${esc(el.id)}">
<div class="plot-container ${accent}" style="flex: 1;">
<div class="plot-header">
<span class="plot-title">${esc(el.label)}</span>
${legendHtml}
</div>
<canvas class="plot-canvas" id="plot-${esc(el.id)}"></canvas>
<div class="plot-axis-labels">
<span>${esc(xLabel)}</span>
<span>${esc(yLabel)}</span>
</div>
</div>
</div>`;
}
};
function initMiniChart(el) {
const canvas = document.getElementById(`chart-${el.id}`);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
chartInstances.set(el.id, { canvas, ctx, el });
drawMiniChart(el, canvas, ctx);
}
function updateMiniChart(el) {
const instance = chartInstances.get(el.id);
if (instance) {
drawMiniChart(el, instance.canvas, instance.ctx);
}
const row = document.querySelector(`[data-id="${CSS.escape(el.id)}"]`);
if (row) {
const valSpan = row.querySelector('.mini-chart-value');
if (valSpan) {
const data = el.value.data;
const values = data.values || [];
const current = values.length > 0 ? values[values.length - 1] : (data.current || 0);
const unit = data.unit || '';
valSpan.textContent = `${current}${unit}`;
}
}
}
function drawMiniChart(el, canvas, ctx) {
const data = el.value.data;
const values = data.values || [];
if (values.length < 2) return;
const width = canvas.width / window.devicePixelRatio;
const height = canvas.height / window.devicePixelRatio;
const accent = getComputedStyle(canvas.closest('.mini-chart')).getPropertyValue('--accent').trim() || '#e06c5c';
ctx.clearRect(0, 0, width, height);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const padding = 4;
ctx.strokeStyle = 'rgba(128, 128, 128, 0.1)';
ctx.lineWidth = 1;
for (let i = 0; i <= 3; i++) {
const y = padding + (height - 2 * padding) * (i / 3);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, accent + '40');
gradient.addColorStop(1, accent + '00');
ctx.beginPath();
ctx.moveTo(0, height - padding - ((values[0] - min) / range) * (height - 2 * padding));
for (let i = 1; i < values.length; i++) {
const x = (i / (values.length - 1)) * width;
const y = height - padding - ((values[i] - min) / range) * (height - 2 * padding);
ctx.lineTo(x, y);
}
ctx.lineTo(width, height);
ctx.lineTo(0, height);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
ctx.beginPath();
ctx.strokeStyle = accent;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
for (let i = 0; i < values.length; i++) {
const x = (i / (values.length - 1)) * width;
const y = height - padding - ((values[i] - min) / range) * (height - 2 * padding);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
window.wgui = {
toggleCard(headerEl) {
headerEl.parentElement.classList.toggle('collapsed');
},
onSlider(id, rawVal, inputEl) {
const el = elements.find(e => e.id === id);
if (!el) return;
recentlyInteracted.set(id, Date.now());
const isInt = el.value.type === 'Int';
const val = isInt ? parseInt(rawVal) : parseFloat(rawVal);
el.value.data = val;
const display = inputEl.parentElement.querySelector('.slider-value');
if (display) display.textContent = formatNum(val, el.meta.step);
send({ type: 'set', id, value: isInt ? { type: 'Int', data: val } : { type: 'Float', data: val } });
},
onCheckbox(id, wrapperEl) {
const el = elements.find(e => e.id === id);
if (!el) return;
el.value.data = !el.value.data;
const box = wrapperEl.querySelector('.checkbox-box');
box.classList.toggle('checked');
const label = wrapperEl.querySelector('.checkbox-label');
if (label) label.textContent = el.value.data ? 'On' : 'Off';
send({ type: 'set', id, value: { type: 'Bool', data: el.value.data } });
},
onColor3(id, hex) {
const rgb = hexToRgb(hex);
const el = elements.find(e => e.id === id);
if (el) el.value.data = rgb;
const row = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
if (row) row.querySelector('.color-hex').textContent = hex;
send({ type: 'set', id, value: { type: 'Color3', data: rgb } });
},
onColor4(id, hex) {
const rgb = hexToRgb(hex);
const el = elements.find(e => e.id === id);
if (!el) return;
const a = el.value.data[3] ?? 1.0;
el.value.data = [...rgb, a];
const row = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
if (row) row.querySelector('.color-hex').textContent = hex;
send({ type: 'set', id, value: { type: 'Color4', data: el.value.data } });
},
onColor4Alpha(id, alphaStr) {
const el = elements.find(e => e.id === id);
if (!el) return;
el.value.data[3] = parseFloat(alphaStr);
const row = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
if (row) {
const alphaText = row.querySelector('.alpha-text');
if (alphaText) alphaText.textContent = `α${el.value.data[3].toFixed(2)}`;
}
send({ type: 'set', id, value: { type: 'Color4', data: el.value.data } });
},
onText(id, val) {
const el = elements.find(e => e.id === id);
if (el) el.value.data = val;
send({ type: 'set', id, value: { type: 'String', data: val } });
},
onDropdown(id, selectedStr, selectEl) {
const selected = parseInt(selectedStr);
const el = elements.find(e => e.id === id);
if (!el) return;
el.value.data.selected = selected;
send({ type: 'set', id, value: { type: 'Enum', data: { selected, options: el.value.data.options } } });
},
onButton(id) {
send({ type: 'set', id, value: { type: 'Button', data: true } });
}
};
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function escJs(s) {
return String(s).replace(/\\/g,'\\\\').replace(/'/g,"\\'");
}
function formatNum(v, step) {
if (step && step >= 1) return String(Math.round(v));
return Number(v).toFixed(3);
}
function rgbToHex(arr) {
const r = Math.round((arr[0] ?? 0) * 255);
const g = Math.round((arr[1] ?? 0) * 255);
const b = Math.round((arr[2] ?? 0) * 255);
return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
}
function hexToRgb(hex) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
return [r, g, b];
}
function initPlot(el) {
const canvas = document.getElementById(`plot-${el.id}`);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * window.devicePixelRatio;
canvas.height = rect.height * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
chartInstances.set(el.id, { canvas, ctx, el, type: 'plot' });
drawPlot(el, canvas, ctx);
}
function updatePlot(el) {
const instance = chartInstances.get(el.id);
if (instance && instance.type === 'plot') {
drawPlot(el, instance.canvas, instance.ctx);
}
}
function drawPlot(el, canvas, ctx) {
const data = el.value.data || {};
const series = data.series || [];
if (series.length === 0) return;
let allValues = [];
for (const s of series) {
if (s.values && s.values.length > 0) {
allValues = allValues.concat(s.values);
}
}
if (allValues.length < 2) return;
const width = canvas.width / window.devicePixelRatio;
const height = canvas.height / window.devicePixelRatio;
const min = Math.min(...allValues);
const max = Math.max(...allValues);
const range = max - min || 1;
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(128, 128, 128, 0.15)';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = padding.top + (plotHeight) * (i / 4);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
for (const s of series) {
const values = s.values || [];
if (values.length < 2) continue;
const color = getComputedStyle(canvas.closest('.plot-container')).getPropertyValue(`--${s.color}`).trim() || '#e06c5c';
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
for (let i = 0; i < values.length; i++) {
const x = padding.left + (i / (values.length - 1)) * plotWidth;
const y = padding.top + plotHeight - ((values[i] - min) / range) * plotHeight;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
if (series.indexOf(s) === 0) {
const gradient = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom);
gradient.addColorStop(0, color + '30');
gradient.addColorStop(1, color + '05');
ctx.beginPath();
ctx.moveTo(padding.left, padding.top + plotHeight);
for (let i = 0; i < values.length; i++) {
const x = padding.left + (i / (values.length - 1)) * plotWidth;
const y = padding.top + plotHeight - ((values[i] - min) / range) * plotHeight;
ctx.lineTo(x, y);
}
ctx.lineTo(width - padding.right, padding.top + plotHeight);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
}
ctx.fillStyle = color;
for (let i = 0; i < values.length; i += Math.ceil(values.length / 20)) {
const x = padding.left + (i / (values.length - 1)) * plotWidth;
const y = padding.top + plotHeight - ((values[i] - min) / range) * plotHeight;
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
}
}
}
window.addEventListener('resize', () => {
for (const el of elements) {
if (el.kind.type === 'MiniChart') {
initMiniChart(el);
} else if (el.kind.type === 'Plot') {
initPlot(el);
}
}
});
connect();
})();
</script>
</body>
</html>