The web components
@seyd/web is three custom elements over @seyd/core: <seyd-video> connects to a robot and paints its video, <seyd-hud> shows the numbers, and <seyd-connect-error> says what to fix when no direct path connects. They are Shadow DOM elements with no framework dependency, so they work in plain HTML and inside React, Vue, Svelte or anything else that can put a tag in a document. There is no React package; the elements are the integration.
Browser support
Section titled “Browser support”The pilot runs where WebTransport and WebCodecs run: Chrome and Edge, on desktop and on Android. Safari has no WebTransport, so an iPhone or iPad cannot run the web pilot today.
<seyd-video>
Section titled “<seyd-video>”Set robot-id and signal-url and the element creates a SeydSession, races the robot’s candidates and renders the video channel into its own canvas. Over the picture it draws a status line (bottom left), a red border while the picture is degraded by loss, and an amber RELAY badge (top right) for as long as the session is carried by the cloud relay. The badge cannot be switched off, because a relayed session must never pass for a direct one (ADR 0010).
Every attribute the element observes, from sdks/js/web/src/seyd-video.ts:
| Attribute | What it does |
|---|---|
robot-id |
The robot to connect to. Required; nothing starts until both this and signal-url are set. |
signal-url |
The signal server’s WebSocket, for example wss://signal.seyd.io/ws. Required. |
qos |
The QoS profile to ask for: latency, balanced or quality (reference). Changing it on a live session calls setQos(); it is the only attribute that applies without a restart. |
token |
A session token minted by POST /api/v1/session-tokens (see session tokens). Leave it out only for a robot with a public grant. |
loss |
Inject chunk loss at the pilot, as a fraction such as 0.05. For exercising the recovery path; values above 1 are clamped to 1. |
burst |
Length of each injected loss burst, in chunks. Default 1; only read together with loss. |
host |
Where the media engine runs: auto (a Web Worker with an OffscreenCanvas where the browser supports it, the default), worker or inline. |
trace |
Present or absent. When present, every stats snapshot carries a per-frame pipeline trace in its trace field; tools/latency-ab.py reads it. |
paths |
Comma-separated candidate labels to race, such as srflx or host,portmap; every other candidate is left out. A label that matches nothing (none) makes the race fail at once, which is how the relay is exercised on demand. |
presentation-delay |
Milliseconds to hold each decoded frame behind the capture timeline (ADR 0005). Overrides the profile’s value; 0 restores decode on arrival. |
relay |
0, false or off refuses the cloud relay, so a failed race ends in a diagnosis instead. Any other value, or no attribute, allows it. |
Changing qos applies live. Changing any other attribute closes the session and starts a new one, and changes that arrive in the same task are coalesced into one start, so a framework that sets robot-id, signal-url and token one after the other starts exactly one session. The element also waits while Chrome is prerendering the page, so a prediction from the address bar cannot take a driver slot nobody asked for.
Properties: .session is the live SeydSession, or null; .canvas is the canvas the video is drawn into, whose width and height follow the decoded picture. Anything you slot inside the element is positioned absolutely over the picture, which is where your own controls go.
The seyd-session event
Section titled “The seyd-session event”The element creates the session and hands it out once, as a bubbling CustomEvent named seyd-session whose detail is the SeydSession. Listen for it to wire the HUD and the guidance box, subscribe to sensors and send commands. A restart (any attribute other than qos changed) dispatches it again with the new session, so wire things up inside the handler rather than once at load.
<seyd-hud>
Section titled “<seyd-hud>”The stats overlay. Set .session and it subscribes to the session’s stats events. It starts hidden; the S key toggles it (without Ctrl or Cmd held), and so does .toggle(), for a button on a touch screen. .visible reports the state, which the element keeps in localStorage under seyd.hud so it survives a reload. Every line is explained in Latency and the HUD.
<seyd-connect-error>
Section titled “<seyd-connect-error>”The guidance box. Set .session and it listens for p2p-failed and relay; or set .failure with a P2pFailure you already hold. It sorts the failure into one of nine classes and shows a title, one paragraph of what to change, a “How to fix this” link to that class’s page under Networking, and the raw diagnosis (reason, candidates, NAT report) behind a Diagnostics disclosure.
It has two forms:
- Red, after
p2p-failed: there is no session. The full guidance is shown. - Amber, after
relay: the session runs through the cloud relay. The same guidance collapses to one line, “Relayed via Seyd cloud”, with a “why / how to fix” button that expands it, so the box never sits in the way of driving. The relay is the symptom of a network problem, not its cure, so the guidance stays until a session connects directly.
Both forms can be dismissed with the × or Escape; the next failure or relay event brings the box back. A pointer event that starts on the box never reaches the video surface, so a click on the link cannot become a camera command.
Theming
Section titled “Theming”The overlays read the Seyd design tokens as CSS custom properties from the host page, each with a fallback equal to the dark theme’s value. A page that imports @seyd/theme gets the look of the demo and the console; a page that has never heard of it still looks right. Define any of these on :root, or on the element, to restyle them:
| Property | Used for |
|---|---|
--seyd-scrim, --seyd-on-scrim, --seyd-on-scrim-2 |
The translucent panel over the picture and its text. The scrim tokens do not flip between light and dark mode, because they always sit on video. |
--seyd-accent |
Connected directly, and links. |
--seyd-amber |
Relayed: the status line, the RELAY badge, the relayed guidance box. |
--seyd-danger |
The degraded-picture border, error status, the failure box border. |
--seyd-font-body, --seyd-font-mono, --seyd-font-display |
The status line and guidance body; the HUD and the status text; the guidance heading. |
--seyd-radius, --seyd-radius-sm |
Corner radii of the HUD and the guidance box; of the status line. |
The colour meanings are the same on every Seyd surface: green is direct, amber is relayed, red is stop. Keep them if you retheme, so an operator who has seen the demo reads your page the same way. The whole system is docs/design.md in the repository.
The smallest page
Section titled “The smallest page”One element, one robot, wired to the HUD and the guidance box. This is sdks/js/web/examples/minimal.html in the repository, rendered as is:
<!-- Copyright 2026 Anton Gravestam SPDX-License-Identifier: Apache-2.0--><!doctype html><!-- minimal.html — the smallest pilot page: one element, one robot. Serve it from a bundler that resolves @seyd/web (Vite, esbuild, webpack) or replace the import with the path to your built bundle. --><html lang="en"><head> <meta charset="utf-8" /> <title>My robot</title> <style> body { margin: 0; background: #000; } seyd-video { display: block; width: 100vw; height: 100vh; } seyd-hud { position: fixed; top: 10px; left: 10px; } seyd-connect-error { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); } </style></head><body> <seyd-video id="video" robot-id="my-robot" signal-url="wss://signal.seyd.io/ws"></seyd-video> <seyd-hud id="hud"></seyd-hud> <seyd-connect-error id="err"></seyd-connect-error> <script type="module"> import '@seyd/web'; const video = document.getElementById('video'); // The element creates a SeydSession and hands it out once; wire the HUD // and the guidance box to it, and send commands through it. video.addEventListener('seyd-session', (e) => { const session = e.detail; document.getElementById('hud').session = session; document.getElementById('err').session = session; session.on('sensor', ({ channel, data }) => console.log(channel.name, data)); window.addEventListener('keydown', (k) => { if (k.key === 'ArrowUp') session.send('drive', { throttle: 1 }); }); }); </script></body></html>The demo pilot page
Section titled “The demo pilot page”The public demo at /pilot/ is the same three elements plus a header, a footer and PTZ controls, so it is the second real example. The page, web/demo/pilot/index.html, holds layout only: a header with the robot id, the role tag, a QoS select and a stats button; <seyd-video>, <seyd-hud> and <seyd-connect-error> inside main; a footer with the key hints and the last sensor value. The script does the rest:
// Copyright 2026 Anton Gravestam// SPDX-License-Identifier: Apache-2.0import '@seyd/web';import { mountThemeSwitch } from '@seyd/theme';import type { SeydSession } from '@seyd/core';import type { SeydConnectErrorElement, SeydHudElement, SeydVideoElement } from '@seyd/web';import { PtzController } from './ptz.js';import { FlightController, formatTelemetry } from './flight.js';import { GamepadReader, describe } from './gamepad.js';
const params = new URLSearchParams(location.search);const ROBOT_ID = params.get('robot') || 'seyd-demo';// A short-lived ES256 session token, minted by the console's "open pilot" or// by a customer's own backend. Absent on the public demo, where `seyd-demo`// carries a public grant instead.const TOKEN = params.get('token');const SIGNAL_URL = params.get('signal') || (location.hostname === 'localhost' || location.hostname === '127.0.0.1' ? 'ws://localhost:8080/ws' : `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`);const QOS_PROFILES = ['latency', 'balanced', 'quality'];let qos = params.get('qos') || localStorage.getItem('seyd.qos') || 'balanced';if (!QOS_PROFILES.includes(qos)) qos = 'balanced';
const video = document.getElementById('video') as SeydVideoElement;const hud = document.getElementById('hud') as SeydHudElement;const err = document.getElementById('err') as SeydConnectErrorElement;const qosSelect = document.getElementById('qos') as HTMLSelectElement;const hudBtn = document.getElementById('hud-btn') as HTMLButtonElement;const hintEl = document.getElementById('hint')!;const sensorEl = document.getElementById('sensor')!;const roleEl = document.getElementById('role')!;const lanHintEl = document.getElementById('lan-hint')!;const noticeEl = document.getElementById('notice')!;const takeoffBtn = document.getElementById('takeoff-btn') as HTMLButtonElement;const landBtn = document.getElementById('land-btn') as HTMLButtonElement;const padEl = document.getElementById('pad')!;document.getElementById('robot')!.textContent = ROBOT_ID;qosSelect.value = qos;mountThemeSwitch(document.getElementById('theme-switch')!);
let ptz: PtzController | null = null;let flight: FlightController | null = null;
video.addEventListener('seyd-session', (e) => { const session = (e as CustomEvent<SeydSession>).detail; hud.session = session; err.session = session; ptz?.dispose(); ptz = new PtzController(session, video.canvas, video); flight?.dispose(); flight = new FlightController(session, video.canvas, video); session.on('welcome', ({ role, pathLabel }) => { const hasPtz = session.hasCommandChannel('ptz'); // A robot declares what it can be told: a `ptz` channel gets the camera // controls, a `flight` channel the drone's. Both are velocity schemes with // a hold on the robot, so an abandoned gesture stops on its own. const hasFlight = session.hasCommandChannel('flight'); ptz?.setEnabled(hasPtz && role === 'driver'); flight?.setEnabled(hasFlight && role === 'driver'); takeoffBtn.hidden = landBtn.hidden = !(hasFlight && role === 'driver'); noticeEl.textContent = hasFlight ? 'You are flying a physical drone. L or the land button lands it; it lands itself when you leave.' : 'You are controlling a physical camera. Response latency reflects your connection.'; roleEl.textContent = role === 'driver' ? 'driver' : 'observer — someone else is driving; controls disabled'; // Driver in the accent (you hold the direct path); observer in amber (someone else does). roleEl.className = role === 'driver' ? 'tag on' : 'tag warn'; roleEl.hidden = false; // Chrome refuses a public-origin page a direct connection to a private IP // (Local Network Access) unless the user allows it; the race then falls // through to the hairpin. Only meaningful when a LAN candidate was offered. const hadHost = session.candidates.some((c) => c.label === 'host'); const viaHairpin = pathLabel === 'srflx' || pathLabel === 'portmap'; lanHintEl.textContent = 'Chrome blocked the direct LAN connection (local network access); if you are on the robot\'s network, allow it in the site permissions for lowest latency.'; lanHintEl.hidden = !(hadHost && viaHairpin && location.protocol === 'https:'); hintEl.textContent = (hasPtz ? 'DRAG or ARROWS — look · SHIFT — fast · WHEEL or +/− — zoom · H — home · ' : '') + (hasFlight ? 'T — take off · L — land · ARROWS or DRAG — move · R/F — up/down · Q/E — turn · SHIFT — fast · ' : '') + 'SPACE — snapshot · S — stats · GAMEPAD — press any button to enable'; }); session.on('stats', (s) => { const w = window as unknown as { __seydTrace?: string[] }; if (w.__seydTrace && s.trace) w.__seydTrace.push(...s.trace); }); session.on('sensor', ({ channel, data }) => { const flightLine = formatTelemetry(data); sensorEl.textContent = flightLine ? `${channel.name}: ${flightLine}` : `${channel.name}: ${typeof data === 'string' ? data : JSON.stringify(data)}`; }); session.on('state', ({ state }) => { if (state !== 'connected') { ptz?.setEnabled(false); flight?.setEnabled(false); roleEl.hidden = true; lanHintEl.hidden = true; takeoffBtn.hidden = landBtn.hidden = true; } });});
// The HUD toggles with `S`, which a phone or a touch panel does not have, so// the same toggle is a button. The pressed state mirrors the HUD's own// visibility, which it persists in localStorage across reloads.const syncHudBtn = () => hudBtn.setAttribute('aria-pressed', String(hud.visible));hudBtn.addEventListener('click', () => { hud.toggle(); syncHudBtn(); });document.addEventListener('keydown', (e) => { if (e.code === 'KeyS') queueMicrotask(syncHudBtn); });syncHudBtn();takeoffBtn.addEventListener('click', () => flight?.takeoff());landBtn.addEventListener('click', () => flight?.land());
// A USB gamepad drives whichever scheme is live. Camera: d-pad or left stick// looks, shoulders or right stick zoom, Start goes home. Drone: d-pad or// left stick is pitch/roll, shoulders turn, X/Y or right stick climb, B or// the trigger descends, A is fast, Start takes off, Select lands. The pad// speaks velocities like the keyboard, so the robot-side holds apply as is.const PAD_SPEED = 35, PAD_FAST = 70, PTZ_SPEED = 55, PTZ_FAST = 100;const padDebug = params.get('pad') === 'debug';new GamepadReader((p, gp) => { if (padDebug) padEl.textContent = `🎮 ${describe(gp)}`; const s = p.fast ? PAD_FAST : PAD_SPEED; const yaw = p.look.x * s || (p.turnRight ? s : 0) - (p.turnLeft ? s : 0); const throttle = p.look.y * s || (p.up ? s : 0) - (p.down ? s : 0); flight?.setPad({ roll: p.move.x * s, pitch: p.move.y * s, throttle, yaw }); if (p.start) flight?.takeoff(); if (p.select) flight?.land(); const z = p.fast ? PTZ_FAST : PTZ_SPEED; ptz?.setPad({ pan: p.move.x * z, tilt: p.move.y * z, zoom: p.look.y * z || (p.turnRight || p.up ? z : 0) - (p.turnLeft || p.down ? z : 0) }); if (p.start) ptz?.home();}, (id) => { padEl.textContent = id ? `🎮 ${id.replace(/\s*\(.*$/, '')}` : ''; });
qosSelect.addEventListener('change', () => { qos = qosSelect.value; localStorage.setItem('seyd.qos', qos); video.session?.setQos(qos);});
document.addEventListener('keydown', (e) => { if (e.code === 'Space' && !e.repeat) { e.preventDefault(); snapshot(); }});
function snapshot(): void { const c = video.canvas; if (!c.width || video.session?.state !== 'connected') return; // The canvas is rendered by the worker (OffscreenCanvas) — toBlob still works on the placeholder element. c.toBlob((blob) => { if (!blob) return; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `seyd-snapshot-${Date.now()}.png`; a.click(); URL.revokeObjectURL(url); }, 'image/png');}
const loss = parseFloat(params.get('loss') ?? '0') || 0;if (params.get('paths')) video.setAttribute('paths', params.get('paths')!);// ?relay=0 refuses the cloud relay (ADR 0010), for diagnosing the direct path.if (params.get('relay') !== null) video.setAttribute('relay', params.get('relay')!);if (params.get('trace')) { video.setAttribute('trace', '1'); (window as unknown as { __seydTrace: string[] }).__seydTrace = []; }// ?pd=0 restores decode-on-arrival, for A/B against the paced default.if (params.get('pd') !== null) video.setAttribute('presentation-delay', params.get('pd')!);if (loss > 0) { video.setAttribute('loss', String(loss)); video.setAttribute('burst', params.get('burst') ?? '1'); }video.setAttribute('qos', qos);
/** * Every pilot needs a session token; the signal server accepts none without * one. A link from the console carries `?token=`, but a visitor arriving at * the public demo has nothing, so the page asks for one. The server grants it * only if the robot carries a public grant — which is what replaced the old * `SEYD_DEV_ALLOW_ANONYMOUS`, and is why this request is unauthenticated. * * Tries `drive` first and falls back to `observe`, so a robot published for * viewing only still works instead of failing shut. */async function obtainToken(): Promise<string | null> { if (TOKEN) return TOKEN; const api = SIGNAL_URL.replace(/^ws/, 'http').replace(/\/ws$/, ''); for (const scope of ['drive', 'observe']) { try { const res = await fetch(`${api}/api/v1/session-tokens`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ robot_id: ROBOT_ID, scope }), }); if (res.ok) return (await res.json()).token as string; if (res.status !== 401 && res.status !== 403) break; // not a permissions problem } catch { break; // offline or blocked; fall through to connecting without one } } return null;}
void obtainToken().then((token) => { if (token) video.setAttribute('token', token); video.setAttribute('signal-url', SIGNAL_URL); video.setAttribute('robot-id', ROBOT_ID); // last: this attribute set is what starts the session});Things worth copying from it:
- Attributes are set last, with
robot-idat the end, because that is the attribute whose arrival starts the session. The token is fetched first: the page asksPOST /api/v1/session-tokensfordrive, then forobserve, and connects without one only if both are refused. welcometells you the role. A driver gets the PTZ controls; an observer sees “someone else is driving” and the controls stay off.hasCommandChannel('ptz')decides whether there is anything to drive at all.- The state handler disables the controls on anything but
connected, so a dropped session cannot leave a command timer running. - The stats button mirrors
hud.visible, because a phone has noSkey. - The query parameters
?loss=,?paths=,?relay=,?pd=and?trace=map straight onto the attributes above; see Latency and the HUD.
The PTZ controls are web/demo/src/ptz.ts: a drag on the picture is a virtual joystick with a 12 % dead zone, the arrow keys pan and tilt, Shift is fast, the wheel and +/- zoom, H goes home. It sends velocity, not position, on the ptz command channel, repeating every 200 ms while held so a lost stop costs the robot one expiry window, and it stops on window blur and on tab hide.
The page enables one control scheme per command channel the robot declares. A robot with a flight channel (the Tello drone demo) gets web/demo/src/flight.ts instead: the arrows are pitch and roll, R/F climb and descend, Q/E turn, T takes off and L lands, with take-off and land buttons in the header for touch. Same shape as PTZ — four velocities in −100..100 repeated every 100 ms while held, one explicit zero on release — against a robot-side hold that centres the sticks when the repeats stop. A telemetry sensor message in the bridge’s shape is formatted on the footer instead of dumped as JSON. A USB gamepad (web/demo/src/gamepad.ts, the browser’s Gamepad API) drives whichever scheme is live with the same velocities the keyboard sends: d-pad or left stick to look or move, shoulders to zoom or turn, Start for home or take-off, Select to land. The pad is read by the page, not the SDK; ?pad=debug shows its raw axes and buttons on the footer.
Reference
Section titled “Reference”The generated API for both packages is the JavaScript reference: SeydVideoElement, SeydHudElement, SeydConnectErrorElement and classify().