Skip to content

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://, or localhost while 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:// (an https:// page cannot open ws:// 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-origin list; start it with --allow-origin https://your.site to 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:

  1. files the user loads (an ELF or a .logdata file, from the Device view);
  2. the browser's cache of logdata it has used before;
  3. the logdata service, when logdata-domain is set: https://fw.unitcircle.ca/logdata/<domain>/<hash> (another server with logdata-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());
    });
  }
}