@orbias/elements
v0.3.0
Published
Orbias Elements — embeddable Surface B custom elements (ADR-0008).
Readme
@orbias/elements
Embeddable web components that render live Orbias device state — machine status, consumable levels, uptime — on any web page, with data-freshness honesty built in. Framework-agnostic (built on Lit): drop a custom element into plain HTML, React, Vue, or Svelte — no rendering or polling logic to re-implement.
Status:
0.x— pre-stable. The component API and the--oe-*theming token contract are still forming and may change on a minor bump. Pin with^0.3and expect0.xminors to break. The real-time stream transport is beta.Migrating from
0.2.x.0.3.0fixes a--oe-*self-reference cycle that silently prevented published defaults from ever applying. If you relied on the previously-broken (unstyled/un-inherited) rendering, set the--oe-*tokens you need explicitly; otherwise you'll now see the documented defaults — including a newprefers-color-scheme: darkstep — for the first time.
Install
npm install @orbias/elements @orbias/client@orbias/client is a peer dependency: the components render off a client
instance you inject, so a page with several elements opens one authenticated
connection rather than one per component.
Quick start
Register the elements once, create a client, and share one live stream across every element on the page:
import { createOrbiasClient } from "@orbias/client";
import { defineOrbiasElements, ClientDeviceStream } from "@orbias/elements";
// 1. Register the <orbias-*> custom element tags (idempotent — call once).
defineOrbiasElements();
// 2. One client + one stream, shared by every element.
const client = createOrbiasClient({ baseUrl: "https://api.orbias.com/v1" });
const stream = new ClientDeviceStream(client, {
// The token must carry the `stream:subscribe` scope.
headers: { authorization: `Bearer ${token}` },
});
// 3. Point each element at a device and hand it the shared stream.
const el = document.querySelector("orbias-machine-status");
el.stream = stream;<orbias-machine-status device-id="dev_a1b2c3"></orbias-machine-status>An element subscribes when it connects, re-subscribes if you change device-id
or stream, and tears the subscription down when it's removed from the DOM.
Components
Device tiles — one device stream each, freshness-honest:
| Tag | Renders |
| --------------------------- | ----------------------------------------- |
| <orbias-machine-status> | A machine's running / idle / fault status |
| <orbias-consumable-level> | A consumable's remaining level |
| <orbias-uptime-tile> | A derived uptime figure |
Composition components — the "thread family" (Surface B/C), for building a portal on your own tokens. They take record-shaped data via properties and invent nothing: no data renders a stated empty state, never a placeholder.
| Tag | Renders |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| <orbias-timeline> | A timeline of events — refuses any entry without a person and a time |
| <orbias-thread> | A thread (order / visit / alert) with its messages, under the same gate |
| <orbias-waiting-on> | What is waiting on whom, grouped by who has to act, counted |
| <orbias-site-card> | A site card built only from record fields (no free-text) |
| <orbias-room-card> | A room's assets and latest cited reading, freshness-honest |
| <orbias-compliance-pack> | Compliance packs — a verified mark set by the record, never the portal |
| <orbias-approval> | A one-tap approve / decline; emits orbias-approval, the portal writes |
| <orbias-ask-for-help> | The embedded "Need help?" card — three verbs, carries the site card, emits orbias-ask-for-help, the host opens the thread |
| <orbias-room-free> | Room occupancy (free / in-use) from entry/exit counts, per-room honesty, counts never identities |
The composition components carry the same honesty gates in code: a timeline or
thread drops (and visibly counts) any entry missing a person or a time; a
compliance pack shows "Verified" only when the record carries a signer, a time
and a measurement source — there is no portal-facing way to set the mark; an
approval never manufactures a decision stamp, and the Approve / Decline buttons
dispatch an orbias-approval event for the host to act on; an ask-for-help
card only renders its confirmation stamp once a receipt names who raised the
request and when. Every one renders on
tokens v5 and on a tenant's tokens with no code change (the --oe-* contract
below) and uses tabular numerals throughout; the two that take a live reading —
<orbias-site-card> and <orbias-room-card> — also carry the stale de-ink
signature when their freshness goes stale.
Spatial components (FOR-883, v0) — a background image + declarative pins, binding to a device id or an estate-node id (FOR-811: operator/site/space/ asset), never a tag:
| Tag | Renders |
| ------------------------ | -------------------------------------------------------- |
| <orbias-image-overlay> | An SVG/PNG background with pinned tiles positioned by id |
<orbias-image-overlay> positions — it does not re-implement — a value tile:
pass any existing device tile or composition component as a light-DOM child
with slot="pin" and a data-pin-id matching a key in the element's pins
map ({ [id]: { x, y } }, percentage offsets over the background image). The
slotted tile keeps its own honesty machine, freshness, and attribution
untouched; a pin id with no matching map entry stays visible and is counted in
a refused note, never silently dropped. The element exposes ::part(root | canvas | image) for host chrome. See demo/image-overlay.html.
<oe-floorplan>/<oe-map> (true 2D/3D floorplan and map rendering) are
deferred behind an external-provider seam rather than an in-house spatial
editor (Akenza's precedent: smplrspace.com).
Not built in this pass: the provider exists and ships a JS SDK (verified
via its GitHub org), but its concrete integration contract — auth model, the
actual data-layer API, and whether the specific device-binding pattern is real
— is UNVERIFIED (its docs site was unreachable from this session's network).
See the comment at the bottom of src/spatial/image-overlay.ts.
Data-freshness honesty
Every component surfaces one of four freshness states — fresh · delayed ·
stale · offline — derived from server-authoritative timestamps, never a local
clock. The indicator carries colour and glyph and text (plus a
screen-reader description), so it survives an arbitrary theme: a host can
recolour it via the state tokens (--oe-ok / --oe-warn / --oe-bad) but
cannot hide it or drop the label. If a device goes quiet or the stream drops, the
component visibly ages to stale / offline on its own — and the stale/offline
states de-ink the value and dash the card border, so a reading is never
dressed as current.
Theming
Style everything through --oe-* CSS custom properties — no shadow-DOM piercing
and no subclassing. Map your design tokens onto the surface in one stylesheet:
orbias-machine-status {
--oe-surface: var(--card-bg);
--oe-surface-2: var(--card-well);
--oe-ink: var(--card-fg);
--oe-muted: var(--card-muted);
--oe-dim: var(--card-dim);
--oe-ok: var(--good);
--oe-warn: var(--warn);
--oe-bad: var(--danger);
--oe-accent: var(--brand);
--oe-radius: 12px;
--oe-font: var(--font-sans);
--oe-mono: var(--font-mono);
}Tokens may be set on the element itself (as above) or on any ancestor — custom
properties inherit across the open shadow boundary. Internally the stylesheets
read a private --_oe-* mirror of each token; that name is an implementation
detail, never set it.
The full token contract — --oe-surface, --oe-surface-2, --oe-ink,
--oe-muted, --oe-dim, --oe-line, --oe-ok, --oe-warn, --oe-bad,
--oe-interpreted, --oe-accent, --oe-accent-fg (the label ink on the
accent), --oe-action-border (the secondary button's ≥3:1 boundary),
--oe-target-min (the touch-target floor calibration raises), --oe-radius,
--oe-font, --oe-font-size, --oe-mono, --oe-space,
--oe-motion-duration — is what a host maps once, at the embed boundary. Defaults render sensibly on a bare page and meet
4.5:1 contrast. ::part(root | body | attribution) are exposed for host chrome;
the honesty indicator deliberately is not.
Token meanings:
--oe-surface— the primary background (card / panel).--oe-surface-2— a secondary surface, used for wells, insets, and layered backgrounds.--oe-ink— primary text colour (headings, values).--oe-muted— secondary text colour (sublines, captions, helper text). Verified ≥4.5:1 contrast.--oe-dim— lowest-contrast informative colour (stale/offline state ink). Verified ≥4.5:1 contrast.--oe-line— UI borders (cards, dividers). Verified ≥3:1 non-text contrast.--oe-ok,--oe-warn,--oe-bad— semantic state colours (freshness honesty states).--oe-accent— brand / accent colour.--oe-radius— corner rounding (cards, buttons).--oe-font— primary typeface (typically a sans-serif system stack).--oe-mono— monospace typeface (for room refs, ids, technical values).--oe-space— base spacing unit (margins, padding, gaps).--oe-motion-duration— transition timing (animations, honesty state changes).
Token migration (
0.1.x→0.2.0). The theming tokens were renamed to the canonical Surface B contract. Update any host token map:| Old (
0.1.x) | New (0.2.0) | | ---------------------------- | -------------- | |--oe-color-surface|--oe-surface| |--oe-color-foreground|--oe-ink| |--oe-color-muted|--oe-muted| |--oe-color-accent|--oe-accent| |--oe-color-honesty-fresh|--oe-ok| |--oe-color-honesty-delayed|--oe-warn| |--oe-color-honesty-stale|--oe-bad| |--oe-color-honesty-offline|--oe-dim|New tokens added:
--oe-surface-2,--oe-dim,--oe-line,--oe-mono. The accent default changed from blue to a quiet teal (#0e6e63).--oe-font,--oe-radius,--oe-spaceand--oe-motion-durationare unchanged.
Dark mode (FOR-915)
Every component ships a @media (prefers-color-scheme: dark) default step for
the --oe-* colour tokens — the OS preference selects it; nothing flips it by
script. A host-set token (on the element or any ancestor, in either scheme)
always wins, because the dark step only changes what an UNSET token falls back
to. To force one value in both schemes, set it explicitly:
orbias-machine-status {
--oe-surface: #131417; /* wins over both the light and dark default */
}The dark step is the estate's existing --oe-* dark palette (the tenant
sign-in theme): surface #131417, ink #f7f8f8, muted #8a8f98, honesty
#4ecb8f / #e8a33d / #df5b5b. --oe-accent is not restepped — the estate
has one accent (#5e6ad2, FOR-1061), which clears 3:1 as a mark on the dark
surface. Every pair is measured by bun run gate:contrast.
Calibration (FOR-915)
calibration is a property (and reflected attribute) on every component —
device tile or thread-family composition — set to one of desk | hand | hub |
wall | tile, the same five viewing distances the dashboard spec below
carries:
<orbias-machine-status device-id="dev_a1b2c3" calibration="hub"></orbias-machine-status>It scales --oe-font-size and --oe-space, and — at hub/wall, the FOR-915
glove/distance floor — raises every touch target (the honesty stamp, a status
pill, an .oe-action button) to a 64px minimum via the shared
--oe-target-min token. It never changes what a component means or its
honesty state — only its targets and type scale (FOR-915 Frame 12).
Dashboards as a spec (FOR-882)
A dashboard is a declarative spec — JSON or YAML — that binds components to
estate nodes (operator → site → space → asset → device; ADR-0004), not
tags, and carries --oe-* theme tokens and an optional calibration
(desk / hand / hub / wall / tile; FOR-915). The same spec renders two ways.
import { parseDashboardSpec } from "@orbias/elements/render";
const spec = parseDashboardSpec({
version: 0,
title: "Bench",
layout: { columns: 3, gap: 16 },
tokens: { "--oe-accent": "#5e6ad2" }, // FOR-1061: the estate's one accent
components: [
{ type: "machine-status", binding: { node: "device", id: "dev_a1b2c3" } },
{
type: "consumable-level",
binding: { node: "asset", id: "ast_tank" },
measurement: { measurementType: "level", unit: "%" },
},
{ type: "uptime-tile", binding: { node: "site", id: "sit_bench" } },
],
});1. Live — the host mounts the Elements from the spec (the normal Surface B
path). mountDashboard resolves each binding to a device, injects the shared
stream, applies the tokens, and lays the tiles out on a CSS grid:
import { defineOrbiasElements, mountDashboard } from "@orbias/elements";
defineOrbiasElements();
const handle = mountDashboard(spec, {
container: document.querySelector("#dashboard"),
stream, // the one shared ClientDeviceStream
nodes, // estate nodes, so a site/asset binding resolves to its device
});
// handle.unmount() removes it (each tile tears down its subscription).2. Machine-render — a headless renderer takes the spec + a point-in-time estate snapshot and emits SVG (or PNG). It runs the reading through the same stale-honesty machine the live tiles use, so a stale tile renders stale in the image — dashed border, de-inked value, the state's glyph and label:
import { renderDashboardSvg, renderDashboardPng } from "@orbias/elements/render";
const snapshot = {
observedNow: Date.parse("2026-09-11T10:00:00Z"), // server-authoritative "now"
readings: { dev_a1b2c3: reading /* Surface A wire shape */ },
nodes,
};
const svg = renderDashboardSvg(spec, snapshot); // a standalone <svg> string
// PNG rasterises that SVG through an INJECTED rasteriser — the SDK carries no
// native image binary (inject resvg in Node, or a canvas in the browser):
import { Resvg } from "@resvg/resvg-js";
const png = await renderDashboardPng(spec, snapshot, {
rasterize: (svg) => new Resvg(Buffer.from(svg)).render().asPng(),
});@orbias/elements/render is DOM-free, so a Node service or a Worker can
import the spec + SVG/PNG path without a DOM (the live mountDashboard helper is
on the main @orbias/elements entry, since it needs the DOM).
v0 scope: the three device tiles above; JSON/YAML spec (YAML authors parse to
an object first — the SDK carries no YAML dependency); live-mount; SVG/PNG. Not
yet: PDF, a hosted "just send me a link" page, and headless rendering of the
composition components (they consume record models, not a device snapshot). The
measurement binding is validated and carried today; the renderer consumes it
(unit-aware formatting, counterType aggregation) once FOR-847's metric
dictionary lands. Headless rendering of the eight thread-family components has
a recorded v1 plan (FOR-1066 tracking comment) but is not built.
A per-COMPONENT calibration (falling back to the spec's) is threaded through
both render paths: live, mountDashboard sets it as the mounted element's
calibration attribute; headless, renderDashboardSvg scales that tile's own
type/target sizing by it — the GRID geometry (cell size) still comes from the
dashboard-level calibration.
See demo/thread-family.html for the eight thread-family components composed
on the synthetic fixtures, with dark and tenant-accent toggles.
Attribution
Components render a "Powered by Orbias" line by default, via the attribution
property (and a same-named slot for custom markup).
Requirements
- A modern browser with Custom Elements and ES modules (this is an ESM-only package).
- An
@orbias/clientinstance and a Surface A token carrying thestream:subscribescope for live data.
