import { ExhibitHost } from './host.js'; import { argumentSchema } from './validation.js'; import { postMessageTransport } from './transport/post-message.js'; import { ExhibitConnection } from './connection.js'; import { LocalSurfaces } from './local-surfaces.js'; const host = new ExhibitHost(); const byId = id => document.getElementById(id); const json = value => JSON.stringify(value, null, 2); const rows = new Map(); let renderedCatalog = null; let renderedSurfaces = null; const surfaceRows = new Map(); const connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render, createFrame() { const frame = document.createElement('iframe'); frame.title = 'Connected exhibit'; // Append only after src is assigned to avoid negotiating with the blank document. queueMicrotask(() => { if (connection.frame === frame) byId('frame-container').append(frame); }); return frame; } }); const localSurfaces = new LocalSurfaces({ host, changed: render, createFrame(entry) { const frame = document.createElement('iframe'); frame.title = entry.descriptor.label; // Like the control frame, append only after assigning src. queueMicrotask(() => { if (entry.frame === frame) surfaceRows.get(entry.descriptor.id)?.pane.append(frame); }); return frame; } }); new MutationObserver(() => localSurfaces.sweep()).observe(byId('surfaces'), { childList: true, subtree: true }); window.addEventListener('pagehide', () => connection.disconnect()); function element(tag, text, parent) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; parent?.append(node); return node; } function editor(spec, label, parent) { const wrapper = element('label', label, parent); const options = spec.enum; const input = element(options ? 'select' : 'input', undefined, wrapper); input.setAttribute('aria-label', label); if (options) { for (const [index, value] of options.entries()) { const option = element('option', spec.optionLabels?.[index] ?? String(value), input); option.value = String(index); } } else { input.type = spec.type === 'boolean' ? 'checkbox' : ['number', 'integer'].includes(spec.type) ? 'number' : 'text'; for (const key of ['min', 'max', 'step', 'minLength', 'maxLength']) if (spec[key] !== undefined) input[key] = spec[key]; if (input.type === 'number' && spec.step === undefined) input.step = spec.type === 'integer' ? '1' : 'any'; } return { input, read() { return options ? options[Number(input.value)] : spec.type === 'boolean' ? input.checked : ['number', 'integer'].includes(spec.type) ? (input.value === '' ? NaN : Number(input.value)) : input.value; }, write(value) { if (options) input.value = String(options.findIndex(v => Object.is(v, value))); else if (spec.type === 'boolean') input.checked = value === true; else input.value = value ?? ''; } }; } function buildCatalog() { rows.clear(); byId('catalog').replaceChildren(); if (!host.catalog.length) element('p', 'Connect an exhibit to discover its controls.', byId('catalog')); const groups = new Map(); for (const target of host.catalog) { const category = target.category || 'Other'; if (!groups.has(category)) { const group = element('div', undefined, byId('catalog')); group.className = 'target-group'; element('h3', category, group); groups.set(category, group); } const section = element('article', undefined, groups.get(category)); section.className = 'target'; element('h3', target.label ?? target.id, section); element('code', target.id, section); element('p', `${target.kind} · ${target.readable ? 'readable' : 'not readable'} · ${target.writable ? 'writable' : 'not writable'}`, section); const current = element('output', '', section); const detail = element('details', undefined, section); element('summary', 'Target descriptor', detail); element('pre', json(target), detail); const notice = element('p', '', section); notice.className = 'notice'; const form = element('form', undefined, section); form.className = 'controls'; form.noValidate = true; let valueEditor, schemaError = '', argumentEditors = []; if (target.kind === 'impulse') { try { for (const arg of argumentSchema(target)) { const group = element('div', undefined, form); let include = null; if (!arg.required) { const l = element('label', `Include ${arg.label ?? arg.name}`, group); include = element('input', undefined, l); include.type = 'checkbox'; } const field = editor(arg, arg.label ?? arg.name, group); if (arg.description) element('p', arg.description, group); argumentEditors.push({ arg, include, field }); } } catch (error) { schemaError = error.message; } } else if (target.writable) { if (target.kind === 'selection') valueEditor = editor({ enum: target.options.map(o => o.value), optionLabels: target.options.map(o => o.label) }, 'New value', form); else if (target.kind === 'range' || ['boolean', 'number', 'integer', 'string'].includes(target.valueType)) { valueEditor = editor({ ...target, type: target.kind === 'range' ? 'number' : target.valueType }, 'New value', form); } else schemaError = 'Unsupported state value type.'; } let submit = null; let row; if (target.kind === 'impulse' || target.writable) { submit = element('button', target.kind === 'impulse' ? 'Invoke' : 'Set', form); form.addEventListener('submit', async event => { event.preventDefault(); if (row.busy) return; row.busy = true; submit.disabled = true; try { if (target.kind === 'impulse') { const args = Object.create(null); for (const { arg, include, field } of argumentEditors) if (!include || include.checked) args[arg.name] = field.read(); await host.invoke(target.id, args); } else await host.set(target.id, valueEditor.read()); } catch { /* Host reports every control failure in the visible console. */ } row.busy = false; render(); }); } row = { target, current, notice, submit, schemaError, valueEditor, initialized: false, busy: false }; rows.set(target.id, row); } } function buildSurfaces() { // Preserve live panes across registry refreshes; replacing their ancestors // would unload the documents even if their descriptor URLs did not change. const container = byId('surfaces'); for (const [id, row] of surfaceRows) if (!localSurfaces.entries.has(id)) { row.card.remove(); surfaceRows.delete(id); } container.querySelectorAll(':scope > p').forEach(node => node.remove()); byId('surface-count').textContent = host.surfaces.length ? `(${host.surfaces.length})` : ''; if (!host.surfaces.length) { element('p', 'No presentation surfaces advertised.', container); return; } for (const surface of host.surfaces) { let row = surfaceRows.get(surface.id); if (!row) { const card = element('article', undefined, container); card.className = 'surface'; const metadata = element('div', undefined, card); const status = element('p', '', card); status.setAttribute('role', 'status'); const controls = element('div', undefined, card); controls.className = 'controls'; const buttons = {}; for (const [label, method] of [['Open', 'open'], ['Reload', 'reload'], ['Close', 'close']]) { const button = element('button', label, controls); buttons[method] = button; button.addEventListener('click', () => localSurfaces[method](surface.id)); } const pane = element('div', undefined, card); row = { card, metadata, status, buttons, pane }; surfaceRows.set(surface.id, row); } row.metadata.replaceChildren(); element('h3', surface.label, row.metadata); element('code', surface.id, row.metadata); const badges = element('div', undefined, row.metadata); const b = element('span', surface.primary ? 'Primary' : 'Non-primary', badges); b.className = 'surface-badge'; if (surface.role) { const b = element('span', `Role: ${surface.role}`, badges); b.className = 'surface-badge'; } if (surface.category) { const b = element('span', `Category: ${surface.category}`, badges); b.className = 'surface-badge'; } const meta = []; if (surface.description) meta.push(surface.description); if (surface.url) meta.push(`URL: ${surface.url}`); if (surface.aspectRatio) meta.push(`Aspect ratio: ${surface.aspectRatio}`); if (surface.requires?.length) meta.push(`Requires: ${surface.requires.join(', ')}`); if (meta.length) { const p = element('p', meta.join(' · '), row.metadata); p.className = 'surface-meta'; } } } function render() { byId('status').textContent = connection.loading ? 'Loading exhibit…' : `${host.status} · ${host.sync}`; byId('status').dataset.state = host.status; byId('identity').textContent = host.exhibit ? `${host.exhibit.product} · ${host.exhibit.version ?? ''} · Contract ${host.contract.major}.${host.contract.minor}` : 'No exhibit connected. Enter a served path to begin.'; byId('loaded-url').textContent = connection.url ? `Exhibit location: ${connection.url}` : ''; byId('metadata').textContent = json({ sessionId: host.sessionId, contract: host.contract, exhibit: host.exhibit, registryRevision: host.registryRevision, stateRevision: host.stateRevision, sequence: host.sequence }); byId('capabilities').replaceChildren(); if (!host.capabilities.length) element('p', host.sessionId ? 'No capabilities declared.' : 'Capabilities appear after discovery.', byId('capabilities')); for (const capability of host.capabilities) element('li', `${capability.id}: ${capability.state}`, byId('capabilities')); byId('state').textContent = json(Object.fromEntries(host.values)); byId('events').textContent = json(host.eventLog); byId('errors').textContent = json(host.logs); const latest = host.logs.findLast(entry => entry.code !== 'SESSION'); byId('latest-error').textContent = latest ? `Latest diagnostic (${latest.time}): ${latest.code}: ${latest.message}` : ''; byId('latest-error').hidden = !latest; byId('target-count').textContent = `(${host.catalog.length})`; byId('refresh').disabled = host.status !== 'connected'; byId('reconnect').disabled = !connection.url || connection.loading || host.status === 'negotiating'; byId('disconnect').disabled = !connection.frame; if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); } if (host.surfaces !== renderedSurfaces) { renderedSurfaces = host.surfaces; buildSurfaces(); } for (const [id, row] of surfaceRows) { const entry = localSurfaces.entries.get(id); const state = entry?.state ?? 'unavailable'; const requirements = (Array.isArray(entry?.descriptor.requires) ? entry.descriptor.requires : []) .filter(id => host.capabilities.find(c => c.id === id)?.state !== 'ready'); row.status.textContent = state === 'control' ? 'Open in the authoritative control frame. Use Connection controls to reload or disconnect.' : `${state}${entry?.error ? ': ' + entry.error : ''}${requirements.length ? ' · Unavailable: ' + requirements.join(', ') : ''}`; row.buttons.open.disabled = !entry || ['control', 'open', 'loading'].includes(state) || requirements.length > 0; row.buttons.reload.disabled = !entry || ['control', 'closed'].includes(state); row.buttons.close.disabled = !entry || ['control', 'closed'].includes(state); } for (const row of rows.values()) { row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.'; if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) { row.valueEditor.write(host.values.get(row.target.id)); row.initialized = true; } const unavailable = host.unavailable(row.target); row.notice.textContent = row.schemaError || (unavailable.length ? `Unavailable: ${unavailable.join(', ')}` : ''); if (row.submit) row.submit.disabled = row.busy || host.status !== 'connected' || !!row.schemaError || unavailable.length > 0; } } byId('connection').addEventListener('submit', event => { event.preventDefault(); try { connection.load(byId('exhibit-url').value); } catch (error) { host.report(error); } }); byId('reconnect').addEventListener('click', () => connection.reconnect()); byId('disconnect').addEventListener('click', () => connection.disconnect()); byId('refresh').addEventListener('click', () => host.refresh().catch(error => host.report(error))); byId('clear-errors').addEventListener('click', () => { host.logs = []; render(); }); host.subscribe(render); render();