Current section
Files
Jump to
Current section
Files
priv/static/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>A2UI</title>
<style>
:root {
--bg: #1a1a2e;
--surface: #16213e;
--card: #0f3460;
--text: #e6e6e6;
--muted: #8888aa;
--accent: #e94560;
--success: #4ecca3;
--mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
padding: 2rem;
min-height: 100vh;
}
header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
header h1 { font-size: 1.4rem; }
.status {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
}
.status.connected { background: var(--success); color: #000; }
.status.disconnected { background: var(--accent); color: #fff; }
.layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 800px) {
.layout { grid-template-columns: 1fr; }
}
.panel h2 {
font-size: 0.9rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
/* Surface rendering */
.a2ui-surface {
background: var(--card);
border-radius: 0.5rem;
padding: 1.5rem;
min-height: 4rem;
}
.a2ui-surface-empty {
color: var(--muted);
font-style: italic;
text-align: center;
padding: 2rem;
}
.a2ui-row { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
.a2ui-column { display: flex; flex-direction: column; gap: 0.5rem; }
.a2ui-card {
background: var(--surface);
border-radius: 0.4rem;
padding: 1rem;
}
.a2ui-card-title {
font-size: 0.8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.a2ui-text { font-size: 1rem; line-height: 1.5; }
.a2ui-button {
background: var(--accent);
color: #fff;
border: none;
padding: 0.45rem 1rem;
border-radius: 0.3rem;
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: opacity 0.15s;
}
.a2ui-button:hover { opacity: 0.85; }
.a2ui-button:active { opacity: 0.7; }
.a2ui-checkbox { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
.a2ui-checkbox input { cursor: pointer; }
.a2ui-divider {
border: none;
border-top: 1px solid rgba(255,255,255,0.1);
margin: 0.5rem 0;
}
.a2ui-text-field {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
color: var(--text);
padding: 0.45rem 0.6rem;
border-radius: 0.3rem;
font-size: 0.9rem;
font-family: inherit;
outline: none;
width: 100%;
max-width: 300px;
}
.a2ui-text-field:focus { border-color: var(--accent); }
.a2ui-text-field::placeholder { color: var(--muted); }
.a2ui-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
max-width: 200px;
height: 4px;
background: rgba(255,255,255,0.15);
border-radius: 2px;
outline: none;
}
.a2ui-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
background: var(--accent);
border-radius: 50%;
cursor: pointer;
}
.a2ui-select {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
color: var(--text);
padding: 0.45rem 0.6rem;
border-radius: 0.3rem;
font-size: 0.9rem;
font-family: inherit;
}
.a2ui-image { max-width: 100%; border-radius: 0.3rem; }
.a2ui-icon { font-size: 0.85rem; color: var(--muted); font-family: var(--mono); }
.a2ui-video, .a2ui-audio { max-width: 100%; border-radius: 0.3rem; }
.a2ui-list { display: flex; flex-direction: column; gap: 0.25rem; }
.a2ui-tabs-header {
display: flex; gap: 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
margin-bottom: 0.75rem;
}
.a2ui-tab-btn {
background: none; border: none;
color: var(--muted);
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
cursor: pointer;
border-bottom: 2px solid transparent;
font-family: inherit;
}
.a2ui-tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
.a2ui-modal-overlay {
background: var(--surface);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 0.5rem;
padding: 1rem;
}
.a2ui-modal-title {
font-size: 0.9rem; font-weight: 600;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.a2ui-unknown {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--muted);
background: rgba(255,255,255,0.03);
padding: 0.5rem;
border-radius: 0.25rem;
}
/* Message log */
#messages {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 70vh;
overflow-y: auto;
}
.message {
background: var(--surface);
border-radius: 0.4rem;
padding: 0.6rem 0.8rem;
border-left: 3px solid var(--accent);
font-size: 0.8rem;
}
.message.outgoing { border-left-color: var(--success); }
.message-type {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
margin-bottom: 0.3rem;
}
.message.outgoing .message-type { color: var(--success); }
.message pre {
font-family: var(--mono);
font-size: 0.7rem;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.4;
color: var(--muted);
}
</style>
</head>
<body>
<header>
<h1 id="header-title">A2UI</h1>
<div id="status" class="status disconnected">Disconnected</div>
</header>
<div class="layout">
<div class="panel">
<h2>Surface</h2>
<div id="surface-container" class="a2ui-surface">
<div class="a2ui-surface-empty">Connecting…</div>
</div>
</div>
<div class="panel">
<h2>Messages</h2>
<div id="messages"></div>
</div>
</div>
<script>
(() => {
const statusEl = document.getElementById('status');
const messagesEl = document.getElementById('messages');
const surfaceEl = document.getElementById('surface-container');
let components = {};
let dataModel = {};
let rootComponentId = null;
let surfaceId = null;
let ws;
function connect() {
const params = location.search || '';
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws' + params;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
statusEl.textContent = 'Connected';
statusEl.className = 'status connected';
};
ws.onclose = (e) => {
statusEl.textContent = 'Disconnected';
statusEl.className = 'status disconnected';
setTimeout(connect, 2000);
};
ws.onerror = () => {};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// v0.9: messages arrive as JSON arrays
const messages = Array.isArray(data) ? data : [data];
messages.forEach(msg => {
const type = Object.keys(msg).find(k => k !== 'version');
if (type) {
logMessage(type, msg[type]);
handleMessage(type, msg[type]);
}
});
} catch (e) {
logMessage('error', { raw: event.data, error: e.message });
}
};
}
function handleMessage(type, payload) {
switch (type) {
case 'updateComponents':
surfaceId = payload.surfaceId;
(payload.components || []).forEach(c => { components[c.id] = c; });
renderSurface();
break;
case 'updateDataModel':
if (payload.path) {
// Path-level operation: upsert or delete
if ('value' in payload) {
dataModel[payload.path] = payload.value;
} else {
delete dataModel[payload.path];
}
} else {
// Bulk update
Object.assign(dataModel, payload.data || {});
}
renderSurface();
break;
case 'createSurface':
rootComponentId = payload.rootComponentId;
if (payload.theme) {
applyTheme(payload.theme);
}
renderSurface();
break;
case 'deleteSurface':
components = {};
dataModel = {};
rootComponentId = null;
surfaceEl.innerHTML = '<div class="a2ui-surface-empty">Surface deleted</div>';
break;
}
}
function applyTheme(theme) {
if (theme.agentDisplayName) {
document.getElementById('header-title').textContent = theme.agentDisplayName;
}
if (theme.primaryColor) {
document.documentElement.style.setProperty('--accent', theme.primaryColor);
}
}
function renderSurface() {
if (!rootComponentId || !components[rootComponentId]) {
surfaceEl.innerHTML = '<div class="a2ui-surface-empty">Waiting for surface…</div>';
return;
}
surfaceEl.innerHTML = '';
renderComponent(surfaceEl, rootComponentId);
}
function renderComponent(parent, id) {
const comp = components[id];
if (!comp) return;
// v0.9: component type is a string discriminator, props at top level
const typeName = comp.component;
switch (typeName) {
case 'Text': {
const el = document.createElement('div');
el.className = 'a2ui-text';
el.textContent = resolveValue(comp.text);
parent.appendChild(el);
break;
}
case 'Button': {
const btn = document.createElement('button');
btn.className = 'a2ui-button';
btn.textContent = resolveValue(comp.label);
if (comp.action && comp.action.event) {
const event = comp.action.event;
btn.onclick = () => sendAction(event.name, event.context);
}
parent.appendChild(btn);
break;
}
case 'CheckBox': {
const label = document.createElement('label');
label.className = 'a2ui-checkbox';
const input = document.createElement('input');
input.type = 'checkbox';
if (comp.checked) input.checked = resolveValue(comp.checked) === 'true';
if (comp.action && comp.action.event) {
const event = comp.action.event;
input.onchange = () => sendAction(event.name, event.context);
}
label.appendChild(input);
if (comp.label) {
const span = document.createElement('span');
span.textContent = resolveValue(comp.label);
label.appendChild(span);
}
parent.appendChild(label);
break;
}
case 'Card': {
const card = document.createElement('div');
card.className = 'a2ui-card';
if (comp.title) {
const title = document.createElement('div');
title.className = 'a2ui-card-title';
title.textContent = resolveValue(comp.title);
card.appendChild(title);
}
const body = document.createElement('div');
body.className = 'a2ui-column';
renderChildren(body, comp.children);
card.appendChild(body);
parent.appendChild(card);
break;
}
case 'Row': {
const row = document.createElement('div');
row.className = 'a2ui-row';
renderChildren(row, comp.children);
parent.appendChild(row);
break;
}
case 'Column': {
const col = document.createElement('div');
col.className = 'a2ui-column';
renderChildren(col, comp.children);
parent.appendChild(col);
break;
}
case 'TextField': {
const input = document.createElement('input');
input.type = 'text';
input.className = 'a2ui-text-field';
if (comp.placeholder) input.placeholder = resolveValue(comp.placeholder);
if (comp.value) input.value = resolveValue(comp.value);
if (comp.action && comp.action.event) {
const event = comp.action.event;
input.onchange = () => sendAction(event.name, event.context);
}
parent.appendChild(input);
break;
}
case 'Slider': {
const input = document.createElement('input');
input.type = 'range';
input.className = 'a2ui-slider';
if (comp.min !== undefined) input.min = resolveValue(comp.min);
if (comp.max !== undefined) input.max = resolveValue(comp.max);
if (comp.value) input.value = resolveValue(comp.value);
if (comp.action && comp.action.event) {
const event = comp.action.event;
input.oninput = () => sendAction(event.name, event.context);
}
parent.appendChild(input);
break;
}
case 'DateTimeInput': {
const input = document.createElement('input');
input.type = 'datetime-local';
input.className = 'a2ui-text-field';
if (comp.value) input.value = resolveValue(comp.value);
if (comp.action && comp.action.event) {
const event = comp.action.event;
input.onchange = () => sendAction(event.name, event.context);
}
parent.appendChild(input);
break;
}
case 'ChoicePicker': {
const select = document.createElement('select');
select.className = 'a2ui-select';
if (Array.isArray(comp.options)) {
comp.options.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value || opt.label || '';
option.textContent = opt.label || opt.value || '';
select.appendChild(option);
});
}
if (comp.value) select.value = resolveValue(comp.value);
if (comp.action && comp.action.event) {
const event = comp.action.event;
select.onchange = () => sendAction(event.name, event.context);
}
parent.appendChild(select);
break;
}
case 'Image': {
const img = document.createElement('img');
img.className = 'a2ui-image';
img.src = resolveValue(comp.src);
if (comp.alt) img.alt = resolveValue(comp.alt);
parent.appendChild(img);
break;
}
case 'Icon': {
const span = document.createElement('span');
span.className = 'a2ui-icon';
span.textContent = resolveValue(comp.icon);
parent.appendChild(span);
break;
}
case 'Video': {
const video = document.createElement('video');
video.className = 'a2ui-video';
video.controls = true;
video.src = resolveValue(comp.src);
parent.appendChild(video);
break;
}
case 'AudioPlayer': {
const audio = document.createElement('audio');
audio.className = 'a2ui-audio';
audio.controls = true;
audio.src = resolveValue(comp.src);
parent.appendChild(audio);
break;
}
case 'List': {
const list = document.createElement('div');
list.className = 'a2ui-list';
renderChildren(list, comp.children);
parent.appendChild(list);
break;
}
case 'Tabs': {
const tabs = document.createElement('div');
const header = document.createElement('div');
header.className = 'a2ui-tabs-header';
const body = document.createElement('div');
if (comp.title) {
const title = document.createElement('div');
title.className = 'a2ui-card-title';
title.textContent = resolveValue(comp.title);
tabs.appendChild(title);
}
const childIds = Array.isArray(comp.children) ? comp.children : [];
childIds.forEach((cid, i) => {
const btn = document.createElement('button');
btn.className = 'a2ui-tab-btn' + (i === 0 ? ' active' : '');
btn.textContent = cid;
btn.onclick = () => {
header.querySelectorAll('.a2ui-tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
body.innerHTML = '';
renderComponent(body, cid);
};
header.appendChild(btn);
});
tabs.appendChild(header);
if (childIds.length > 0) renderComponent(body, childIds[0]);
tabs.appendChild(body);
parent.appendChild(tabs);
break;
}
case 'Modal': {
const modal = document.createElement('div');
modal.className = 'a2ui-modal-overlay';
if (comp.title) {
const title = document.createElement('div');
title.className = 'a2ui-modal-title';
title.textContent = resolveValue(comp.title);
modal.appendChild(title);
}
const modalBody = document.createElement('div');
modalBody.className = 'a2ui-column';
renderChildren(modalBody, comp.children);
modal.appendChild(modalBody);
parent.appendChild(modal);
break;
}
case 'Divider': {
const hr = document.createElement('hr');
hr.className = 'a2ui-divider';
parent.appendChild(hr);
break;
}
default: {
const el = document.createElement('div');
el.className = 'a2ui-unknown';
el.textContent = '[' + typeName + '] ' + JSON.stringify(comp, null, 2);
parent.appendChild(el);
}
}
}
function renderChildren(parent, children) {
if (!children) return;
if (Array.isArray(children)) {
children.forEach(childId => renderComponent(parent, childId));
} else if (children.path && children.componentId) {
// TemplateChildList: data-driven children
const el = document.createElement('div');
el.className = 'a2ui-unknown';
el.textContent = '[template: ' + children.componentId + ' × ' + children.path + ']';
parent.appendChild(el);
}
}
function resolveValue(value) {
if (value === undefined || value === null) return '';
// v0.9: plain string/number/boolean → literal value
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
// v0.9: {"call": "..."} → FunctionCall (debug display)
if (value.call) {
const args = value.args ? Object.values(value.args).map(a => resolveValue(a)).join(', ') : '';
return '[fn:' + value.call + '(' + args + ')]';
}
// v0.9: {"path": "/..."} → data model lookup
if (value.path && dataModel[value.path] !== undefined) {
return String(dataModel[value.path]);
}
if (value.path) return '{' + value.path + '}';
return '';
}
function sendAction(name, context) {
// v0.9: action with event envelope, wrapped in array
const action = { event: { name } };
if (context) action.event.context = context;
if (surfaceId) action.surfaceId = surfaceId;
const msg = JSON.stringify([{ action }]);
ws.send(msg);
logMessage('action', { name, context }, true);
}
function logMessage(type, data, outgoing) {
const div = document.createElement('div');
div.className = 'message' + (outgoing ? ' outgoing' : '');
const typeEl = document.createElement('div');
typeEl.className = 'message-type';
typeEl.textContent = type;
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(data, null, 2);
div.appendChild(typeEl);
div.appendChild(pre);
messagesEl.prepend(div);
while (messagesEl.children.length > 50) messagesEl.removeChild(messagesEl.lastChild);
}
connect();
})();
</script>
</body>
</html>