Embedding in a web page¶
The web app is one element, <uc-log>, and any page can use it: a product's support page, a lab
dashboard, an internal tool. It brings the same views (logs, terminal, commands, plots, device
information, firmware update) and connects the same way: Web Serial, WebHID, Web Bluetooth, or a
hub.
Add it to a page¶
<script src="https://uclog.unitcircle.ca/element/latest/uclog-element.iife.js"></script>
<uc-log logdata-domain="acme.example" style="height: 600px"></uc-log>
That is all: the script defines <uc-log> (and the global UcLog), and the element shows a
Connect… button. It fills its container: give it a height (it is at least 360 px tall).
Which script:
| URL | |
|---|---|
/element/N/uclog-element.iife.js |
release N; it never changes. Use this on a page you publish |
/element/latest/uclog-element.iife.js |
the newest release |
/element/dev/uclog-element.iife.js |
built from main; for trying changes |
The script is about 160 KB compressed, with everything it needs inside, the two workers included.
You can also copy it to your own site: it is attached to each
release on GitHub as uclog-element-N.iife.js.
What the page needs¶
- A secure context:
https://, orlocalhostwhile you develop. Web Serial, WebHID and Web Bluetooth are only in Chrome and Edge. - In an iframe, allow the device APIs:
<iframe src="…" allow="serial; hid; bluetooth">. -
A Content-Security-Policy, if the page has one, must allow the script and the element's workers, which start from
blob:URLs:script-src 'self' https://uclog.unitcircle.ca; worker-src blob: -
Hubs: a hub on another machine needs
wss://(anhttps://page cannot openws://to another host). A hub on the user's machine asks its operator before it lets in a page that is not on its--allow-originlist; start it with--allow-origin https://your.siteto skip the question. Chrome also asks the user before a public site may reach this machine or the local network (Local network access).
Logdata¶
The element decodes logs with the device's logdata, found by the image hash the device reports:
- files the user loads (an ELF or a
.logdatafile, from the Device view); - the browser's cache of logdata it has used before;
- the logdata service, when
logdata-domainis set:https://fw.unitcircle.ca/logdata/<domain>/<hash>(another server withlogdata-url).
The service only answers pages on the origins your account allows. If your account needs a token to read logdata, the page sets it as a property (it is never an attribute, so it does not appear in the HTML):
document.querySelector('uc-log').logdataToken = tokenFromYourBackend;
A page can also hand over logdata itself, for example the build that matches the firmware it ships:
const r = await fetch('/firmware/app.logdata');
await el.loadLogdata({ name: 'app.logdata', data: new Uint8Array(await r.arrayBuffer()) });
Scripting it¶
const el = document.querySelector('uc-log');
// Connect: a hub, the simulated device, or a browser chooser (from a click).
await el.connect('ws://localhost:9000');
await el.connect('loopback:rate=20');
button.onclick = () => el.connect('serial'); // also 'hid' and 'ble'
el.send(61, 'help\r'); // a line to the terminal port
await el.disconnect();
el.addEventListener('uc-connect', (e) => console.log('connected to', e.detail.uri));
el.addEventListener('uc-log', (e) => { // with emit-logs
for (const r of e.detail.records) if (r.level === 'ERROR') report(r.file, r.line, r.text);
});
Every attribute, property, method, event, part and colour is in the
<uc-log> reference.
Styling¶
Colours and shapes are CSS custom properties on the element (a value set by the page applies in
both light and dark), and its parts can be styled with ::part():
uc-log {
--uc-accent: #0b6e4f;
--uc-radius: 4px;
--uc-font: system-ui, sans-serif;
}
uc-log::part(header) { background: #f4f1ea; }
Examples¶
Both are in the repository and tested on every change, so what you see here runs.
A device portal¶
A stand-in for a product's support page (web/examples/embed):
its own buttons drive the element, its events are listed beside it, and a checkbox brands it. Its
server sends a strict Content-Security-Policy. To run it:
cd web
npx -y pnpm@12.10.1 install
npx -y pnpm@12.10.1 --filter @uclog/element build
node examples/embed/serve.mjs # http://localhost:8080/
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Embedding example</title>
<link rel="icon" href="data:,">
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; display: grid; grid-template-rows: auto 1fr; height: 100vh; }
header { padding: 10px 16px; border-bottom: 1px solid #8884; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
header h1 { font-size: 16px; margin: 0 12px 0 0; }
main { display: grid; grid-template-columns: 1fr 320px; min-height: 0; }
uc-log { margin: 12px; min-height: 0; }
/* Branding through custom properties (see web/README.md, "Styling"). */
uc-log.branded { --uc-accent: #0f766e; --uc-radius: 12px; }
aside { border-left: 1px solid #8884; padding: 8px 12px; overflow: auto; font: 12px ui-monospace, monospace; }
aside div { padding: 2px 0; border-bottom: 1px solid #8882; white-space: pre-wrap; word-break: break-all; }
input { width: 14em; }
</style>
</head>
<body>
<header>
<h1>Acme device portal</h1>
<button id="sim">Simulated device</button>
<button id="serial">Serial…</button>
<input id="hub" value="ws://localhost:9000" aria-label="Hub URL"><button id="hubgo">Hub</button>
<button id="disc">Disconnect</button>
<button id="hello">send "echo hello"</button>
<button id="logdata">Load sample.logdata</button>
<label><input type="checkbox" id="brand" style="width:auto"> branding</label>
</header>
<main>
<!-- The element. Every attribute is optional. -->
<uc-log
panels="log,terminal,commands,plots,device,fw-update"
layout="tabs"
theme="auto"
storage-key="acme-portal"
emit-logs></uc-log>
<aside id="events"><b>Events</b></aside>
</main>
<script type="module" src="page.js"></script>
</body>
</html>
// The host page's own code: load the bundle, drive the element, listen to its events.
// ?bundle=iife loads the single-file classic script instead of the ES module. ?worker-base=1
// uses the worker files (no blob: workers, for sites whose CSP forbids them).
const q = new URLSearchParams(location.search);
const el = document.querySelector('uc-log');
if (q.has('worker-base')) el.setAttribute('worker-base', '/uclog/workers');
const script = document.createElement('script');
if (q.get('bundle') === 'iife') script.src = '/uclog/uclog-element.iife.js';
else {
script.type = 'module';
script.src = '/uclog/uclog-element.js';
}
document.head.append(script);
const events = document.getElementById('events');
function log(type, detail) {
const d = document.createElement('div');
d.textContent = `${new Date().toLocaleTimeString()} ${type} ${detail === undefined ? '' : JSON.stringify(detail)}`;
events.insertBefore(d, events.children[1] ?? null);
while (events.children.length > 200) events.lastChild.remove();
}
el.addEventListener('uc-ready', () => log('uc-ready'));
el.addEventListener('uc-connect', (e) => log('uc-connect', e.detail));
el.addEventListener('uc-disconnect', (e) => log('uc-disconnect', e.detail));
el.addEventListener('uc-error', (e) => log('uc-error', e.detail));
el.addEventListener('uc-device-info', (e) => log('uc-device-info', e.detail.fields));
// With emit-logs: decoded records, in batches. Here, warnings and worse.
const LOUD = new Set(['WARN', 'ERROR', 'FATAL', 'PANIC']);
el.addEventListener('uc-log', (e) => {
for (const r of e.detail.records) if (LOUD.has(r.level)) log('uc-log', `${r.level} ${r.file}:${r.line} ${r.text}`);
});
const on = (id, f) => document.getElementById(id).addEventListener('click', () => f().catch?.((e) => log('error', e.message)));
on('sim', () => el.connect('loopback:rate=5'));
on('serial', () => el.connect('serial')); // opens the browser's chooser (needs a click)
on('hubgo', () => el.connect(document.getElementById('hub').value));
on('disc', () => el.disconnect());
on('hello', async () => el.send(61, 'echo hello\r'));
on('logdata', async () => {
// The simulated device's logdata. For a real device, load its .logdata or ELF the same way, or
// set logdata-domain to look it up by app_hash.
const data = new Uint8Array(await (await fetch('/sample.logdata')).arrayBuffer());
const r = await el.loadLogdata({ name: 'sample.logdata', data });
log('loadLogdata', r);
});
document.getElementById('brand').addEventListener('change', (e) => el.classList.toggle('branded', e.target.checked));
The web app¶
The web app itself is <uc-log> and a page script
(web/apps/demo), built with Vite from the package
(import '@uclog/element'). The page script adds what a standalone app needs: query parameters,
the sample logdata for the simulated device, and working offline.
// © 2026 Unit Circle Inc.
// SPDX-License-Identifier: Apache-2.0
// The standalone page: one full-size <uc-log>. Query parameters set its attributes
// (?hub-url=ws://host:9000&theme=dark&layout=grid&logdata-domain=…), and ?connect=… connects.
// Shared by the hosted app (main.ts) and the one-file app (single.ts).
import type { UcLog } from '@uclog/element';
export function startPage(storageKey: string, sampleLogdata: string): UcLog {
const el = document.createElement('uc-log') as UcLog;
el.setAttribute('storage-key', storageKey);
const q = new URLSearchParams(location.search);
for (const a of ['hub-url', 'theme', 'layout', 'panels', 'logdata-domain', 'logdata-url']) {
const v = q.get(a);
if (v !== null) el.setAttribute(a, v);
}
// The simulated device logs with spec/vectors/sample.logdata. Load it under the simulated
// device's app_hash (5a × 64) so it is only used for that device.
void fetch(sampleLogdata)
.then((r) => r.arrayBuffer())
.then((b) => el.loadLogdata({ name: `${'5a'.repeat(64)}.logdata`, data: new Uint8Array(b) }))
.catch(() => {});
const auto = q.get('connect');
if (auto) void el.connect(auto);
document.body.append(el);
Object.assign(window, { ucLog: el });
return el;
}
// © 2026 Unit Circle Inc.
// SPDX-License-Identifier: Apache-2.0
// The web app (uclog.unitcircle.ca/app/, and `vite` for development), and the reference for
// embedding: it uses only the published package, `@uclog/element`, as any site would. Built for
// the site, it also works offline (sw.js, written by vite.config.ts) and offers the one-file app
// (uclog-app.html, `vite build --mode single`).
import '@uclog/element'; // defines <uc-log>
import sampleUrl from '../../../../spec/vectors/sample.logdata?url';
import { startPage } from './page.ts';
const el = startPage('uclog-demo', sampleUrl);
// The built app on http(s): the service worker (precached app, updates on the next load) and a
// link to the one-file app. Not in development (no sw.js) or from a file.
if (import.meta.env.PROD && location.protocol !== 'file:') {
const bar = document.getElementById('app-bar');
if (bar) bar.hidden = false;
if ('serviceWorker' in navigator) {
void navigator.serviceWorker.register('sw.js').then((reg) => {
// A newer version installed while this page runs: offer to switch to it now.
const offer = (w: ServiceWorker | null) => {
if (!w || !navigator.serviceWorker.controller) return; // the first install: nothing to say
const update = document.getElementById('app-update');
if (!update) return;
update.hidden = false;
update.querySelector('button')!.onclick = () => w.postMessage('skip-waiting');
el.dispatchEvent(new CustomEvent('uc-note', { detail: 'A new version of the app is ready: reload to use it' }));
};
offer(reg.waiting);
reg.addEventListener('updatefound', () => {
const w = reg.installing;
w?.addEventListener('statechange', () => w.state === 'installed' && offer(w));
});
navigator.serviceWorker.addEventListener('controllerchange', () => location.reload());
});
}
}