The UI is a body fragment mounted in a sandboxed iframe. It receives one typed snapshot at a time and returns a small, fixed set of actions. It cannot read raw buffers, open a socket, write storage, or change the detector. Its job is to turn host truth into a decision the person in front of the instrument can make.
The host builds the document itself and injects the fragment into a <iframe sandbox="allow-scripts">. The iframe has no same-origin access, no top navigation, no forms, and no popups. The document's policy is:
default-src 'none';
img-src data:;
style-src 'unsafe-inline';
font-src data:;
script-src 'unsafe-inline';
connect-src 'none';
media-src 'none';
frame-src 'none';
worker-src 'none';
form-action 'none';
base-uri 'none';| Allowed | Blocked |
|---|---|
| Inline HTML, inline CSS, inline scripts, data-URI images and fonts, canvas, SVG. | fetch, XMLHttpRequest, WebSocket, external scripts/styles/images, localStorage, IndexedDB, cookies, forms, frames, workers, Tauri. |
The host bridge: osmograph.onData, action, setTrajectory, plus ready, and toast. The legacy setDetection / setTransforms methods are v1-only and are refused for a v2 profile. | Any other global. A reference to an unknown osmograph method is a critical gate failure. |
The fragment is a body fragment, not a document. Do not include <html>, <head>, or <body>; the host owns the document, the base styles, and the bridge. A Vial UI larger than 512 KB is refused.
Call osmograph.onData(callback) once at startup. The host immediately replays the latest payload, then sends a new one on the Vial's sampling cadence. The callback may run before the baseline is ready; treat every field as possibly null or unknown.
| Field | Type | Meaning |
|---|---|---|
schema | "osmograph.vial-runtime/1" | Payload contract. |
snapshot | VialHostSnapshot | The typed host state below. |
series | { source, label, channels, time, timeUnit, values, fingerprint, evolution, response } | null | A downsampled window. `response` is the profile-transformed series and is null until transforms are in force. |
sources | { id, label, kind, detail? }[] | The sources this Vial may select with `set-source`. |
selectedSource | string | The source currently feeding `series`. |
events | { start, end, alert, peak, channels, ongoing }[] | The host change log for this session. Useful for context, not a second detector. |
domain | { fields, checks, logs } | The persisted local Vial state: saved fields, checklist checks, and log entries. |
view | workspace | home | Which surface mounted this document. |
host | { name, version, runtime } | The host identity. |
payload.snapshot is the only live state a Vial may depend on. Channel ids are ch0…chN; a dead channel is a refusal, not a zero.
| Field | Type | Meaning |
|---|---|---|
schema | "osmograph.vial-host/1" | Contract version. |
takenAt | number | Unix milliseconds when the snapshot was built. |
source | live | replay | dataset | none | Where the reading came from. Home is never a live source. |
samplingMode | timeseries | trial | unknown | The array is an ordered time series in this host. |
channels | { id, label, kind, unit?, health? }[] | Channel ids are ch0…chN. Health is ok | stuck | saturated | noisy | unknown. |
environment | { temperature, humidity } | null | Only present when the host measured it. |
baseline | { state, progress, ageSec } | state is none | warming | ready; progress is 0–1; ageSec is null when the host did not observe the baseline start. |
anomaly | { state, score, confidence, alertLevel, triggeredChannels, consecutive } | state is unknown | normal | warning | critical. This is the per-Vial engine state before the profile's band and admissibility gate; the decision the UI presents is detection. triggeredChannels is a count, not an array. consecutive is the number of consecutive anomalous samples. |
classifier | { state, name, label, confidence } | A loaded classifier, if the user trained one. Never invent a label. |
channelLimits | Record<channelId, { dead, cv, min, max, r0 }> | Host-computed single-unit limits. A dead channel must be shown as dead, not zero. |
detectionConfig | { enabled, alertThreshold, perChannelLimits } | null | The profile in force. For v2 this comes from the manifest; the UI cannot change it. |
detection | { active, triggeredChannels: string[], admissible?, reason? } | null | The Vial's verdict. `admissible: false` means the profile refused to arm (baseline not ready, coverage below the minimum, or a recording/no rig); show `reason`. Null means no verdict yet, not normal. A v2 profile detector runs on the live array only. |
transformsApplied | string[] | Transform ids the host actually applied, for display and debugging. |
trajectory | { at, values }[] | Recent points for the fields the UI asked the host to track. At most 600 points. |
A v2 Vial does not configure the detector from the UI. The profile in vial.json is compiled by the host when the Vial opens, and a v2 UI that calls setDetection or setTransforms fails the gate. The UI reads the verdict and writes context; the manifest is the program.
osmograph.action(action) posts one of the following. The host validates the shape, the nonce, the pane, and the current Vial. Invalid actions are dropped with a toast; they never throw into the page.
| Action | Payload | Effect |
|---|---|---|
ready | — | Tells the host the bridge is live; the host sends a fresh snapshot. |
toast | { message } | A host toast. Use it for confirmations, not alarms. |
resize | { height } | Request a pane height. Clamped to 420–2000 px (320–620 px on Home). |
save-field | { key, value } | Persist one text field in the local Vial state. |
add-log | { values } | Append a timestamped record to the local Vial log. |
remove-log | { id } | Remove one log record. |
export-log | — | Export the local log through the host. |
open-workspace | — | Return to this Vial's workspace. |
open-dashboard | — | Open the host dashboard. |
open-datasets | — | Open the host datasets view. |
open-check | — | Open the host check view. |
open-vials | — | Close the workspace and return to the Vials list. |
set-source | { source } | Switch to a source the host offered for this Vial. |
set-trajectory | { fields, intervalMs } | Ask the host to track fields such as ch0, anomaly.score, baseline.progress, env.temperature. Clamped to 1–600 s. |
set-detection | { enabled, alertThreshold, perChannelLimits } | Legacy only. A v2 Vial is refused with a message: the profile is compiled from the manifest. |
set-transforms | { baseline, conditioning, response } | Legacy only. A v2 Vial is refused: transforms are declared in the manifest. |
A Vial can tighten its own presentation and its own trajectory request. It cannot loosen the host's `channelLimits`, raise the base alert level, hide a dead channel, or overwrite another Vial's state.
This fragment shows the contract without inventing a measurement. It reports baseline progress, the engine state, the profile verdict, the number of triggered channels, and a dead-channel refusal. It persists one field and one log entry.
<style>
:root { color: #1a1a17; background: #fbf8f2; font: 13px/1.5 ui-monospace, monospace; }
body { margin: 0; padding: 16px; }
.row { display: flex; justify-content: space-between; gap: 16px; border-bottom: 1px solid #d8d4ca; padding: 8px 0; }
.label { color: #6b665c; }
.live { color: #0e7490; }
.warn { color: #a16207; }
.alarm { color: #e11d48; }
.dead { color: #6b665c; text-decoration: line-through; }
input, button { font: inherit; border: 1px solid #1a1a17; border-radius: 0; background: transparent; padding: 4px 8px; }
button { cursor: pointer; }
</style>
<div class="row"><span class="label">Baseline</span><span id="baseline">waiting</span></div>
<div class="row"><span class="label">Engine</span><span id="engine">unknown</span></div>
<div class="row"><span class="label">Profile</span><span id="state">unknown</span></div>
<div class="row"><span class="label">Triggered channels</span><span id="triggered">—</span></div>
<div class="row"><span class="label">Channels</span><span id="channels">—</span></div>
<p><input id="note" placeholder="Batch note" /></p>
<p><button id="save">Save note</button> <button id="log">Log reading</button></p>
<script>
const baseline = document.getElementById("baseline");
const engine = document.getElementById("engine");
const state = document.getElementById("state");
const triggered = document.getElementById("triggered");
const channels = document.getElementById("channels");
const note = document.getElementById("note");
function paint(payload) {
const snapshot = payload.snapshot;
const b = snapshot.baseline;
baseline.textContent = b.state === "ready"
? "ready"
: b.state === "warming"
? "warming " + Math.round(b.progress * 100) + "%"
: "none";
baseline.className = b.state === "ready" ? "live" : "warn";
engine.textContent = snapshot.anomaly.state
+ (snapshot.anomaly.score === null ? "" : " · " + snapshot.anomaly.score.toFixed(2));
const det = snapshot.detection;
if (!det) {
state.textContent = "no verdict yet";
state.className = "";
} else if (!det.admissible) {
state.textContent = "not armed";
state.className = "warn";
state.title = det.reason || "the profile refused to arm";
} else {
state.textContent = det.active ? "change active" : "below profile";
state.className = det.active ? "alarm" : "live";
state.title = "";
}
triggered.textContent = det ? String(det.triggeredChannels.length) : "—";
channels.innerHTML = "";
for (const channel of snapshot.channels) {
const limit = snapshot.channelLimits[channel.id];
const row = document.createElement("span");
row.textContent = channel.label;
row.className = limit && limit.dead ? "dead" : "";
row.title = limit
? "cv " + limit.cv.toFixed(4) + " · min " + limit.min + " · max " + limit.max
: "no limit yet";
channels.appendChild(row);
channels.appendChild(document.createTextNode(" "));
}
}
osmograph.onData(paint);
document.getElementById("save").addEventListener("click", () => {
osmograph.action({ type: "save-field", key: "note", value: note.value });
osmograph.toast("Note saved locally.");
});
document.getElementById("log").addEventListener("click", () => {
osmograph.action({ type: "add-log", values: { note: note.value } });
});
osmograph.ready();
</script>The example uses the host palette deliberately: cyan only for live, healthy data; yellow for a warning; red only for an alarm; ink for primary text; 0 px radius; 1 px hairlines. A Vial UI that paints a warning red, or uses cyan for a static label, is lying about the state.
#0e7490 is live data only. Not a heading, not a button, not a decorative accent.#e11d48 is alarm only. A critical state, a stop rule, a refusal that requires action.#a16207 is warning. A degraded baseline, a stuck channel, a change that has not yet crossed the escalation timer.#1a1a17 is primary. Body text, borders, and the one action that matters.A Vial may also contribute a declarative Home card through the manifest's home block. Home cards are composed by the host; they never run arbitrary code. A card declares:
| Field | Meaning |
|---|---|
headline | One line of state, written in the Vial's voice. |
detail | One sentence of context or the next action. |
metrics | { label, value, hint? }[] — values are strings, so omit a number rather than invent one. |
actions | { id, label, kind, href? }[]; kind is open-workspace, open-check, open-datasets, open-gallery, or external. |
limits | The conditions where the card must not be read as a live claim. |
sources | Which host sources the card can reflect. |
The host adds the runtime state (idle | warming | live | attention | ready) and a generated timestamp. Home is a summary, not a second detector.