Files
XZBT/XZBT.html
T

1807 lines
88 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; media-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<title>XZBT 0.1</title>
<style>:root {
color-scheme: dark;
--ink: #f3f3ef;
--muted: #9ba09b;
--surface: #151817;
--surface-2: #1d211f;
--line: #323834;
--accent: #b8ff5a;
--accent-dark: #20330d;
--danger: #ff7b72;
--warning: #ffca5c;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; color: var(--ink); background: #0b0d0c; }
button, input { font: inherit; }
button { border: 1px solid var(--line); border-radius: 0.55rem; padding: 0.66rem 0.9rem; color: var(--ink); background: var(--surface-2); cursor: pointer; }
button:hover:not(:disabled), button:focus-visible { border-color: var(--accent); outline: none; }
button:disabled { cursor: not-allowed; opacity: 0.45; }
select, input { width: 100%; accent-color: var(--accent); color: var(--ink); background: #0f1210; border: 1px solid var(--line); border-radius: 0.45rem; padding: 0.5rem; }
.primary { color: #10130e; border-color: var(--accent); background: var(--accent); font-weight: 700; }
.app-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem clamp(1rem, 3vw, 2.5rem); border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: baseline; gap: 0.8rem; }
.brand h1 { margin: 0; letter-spacing: 0.22em; font-size: 1.15rem; }
.brand span, .status-cluster { color: var(--muted); font-size: 0.8rem; }
.status-cluster { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem 1rem; }
.storage-warning { color: var(--warning); }
.storage-ready { color: var(--accent); }
main { display: grid; grid-template-columns: minmax(260px, 340px) 1fr; min-height: calc(100vh - 65px); }
.sidebar { padding: 1.25rem; border-right: 1px solid var(--line); background: #101311; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.section-heading h2 { margin: 0; font-size: 0.95rem; letter-spacing: 0.08em; text-transform: uppercase; }
.drop-zone { padding: 1rem; margin-bottom: 1rem; text-align: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 0.75rem; }
.drop-zone.is-dragging { color: var(--accent); border-color: var(--accent); background: var(--accent-dark); }
.exhibit-list { display: grid; gap: 0.75rem; }
.exhibit-card { padding: 1rem; border: 1px solid var(--line); border-radius: 0.8rem; background: var(--surface); }
.exhibit-card.is-active { border-color: var(--accent); box-shadow: inset 3px 0 var(--accent); }
.exhibit-card h3, .exhibit-card p { margin: 0 0 0.55rem; }
.exhibit-card .description { color: #c7cbc7; font-size: 0.9rem; line-height: 1.45; }
.exhibit-card .meta { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.72rem; }
.workspace { display: grid; grid-template-rows: minmax(300px, 1fr) minmax(220px, 38vh); min-width: 0; }
.stage { display: grid; place-items: center; padding: clamp(1.5rem, 5vw, 5rem); background: radial-gradient(circle at 50% 45%, #1a211c, #0b0d0c 62%); }
.stage-card { width: min(660px, 100%); padding: clamp(1.5rem, 4vw, 3rem); border: 1px solid var(--line); border-radius: 1rem; background: rgba(16, 19, 17, 0.88); }
.eyebrow { color: var(--accent); letter-spacing: 0.12em; text-transform: uppercase; font-size: 0.75rem; }
.stage-card h2 { margin: 0.35rem 0 0.7rem; font-size: clamp(1.7rem, 4vw, 3rem); }
.stage-card p { color: var(--muted); line-height: 1.55; }
.runtime-readout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; margin: 1.5rem 0; }
.runtime-readout div { padding: 0.8rem; border: 1px solid var(--line); border-radius: 0.55rem; overflow: hidden; }
.runtime-readout dt { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; }
.runtime-readout dd { margin: 0.35rem 0 0; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
.grammar-panel { margin: 1rem 0; padding-top: 0.8rem; border-top: 1px solid var(--line); }
.grammar-panel h3 { margin: 0 0 0.7rem; font-size: 0.78rem; letter-spacing: 0.08em; text-transform: uppercase; }
.parameter-control { display: grid; grid-template-columns: minmax(8rem, 1fr) minmax(8rem, 1.5fr) auto; gap: 0.65rem; align-items: center; margin: 0.55rem 0; }
.parameter-control label { color: #d9ddd9; font-size: 0.82rem; }
.compact { padding: 0.45rem 0.6rem; font-size: 0.75rem; }
.override-badge { margin-left: 0.5rem; padding: 0.12rem 0.35rem; color: var(--accent); background: var(--accent-dark); border-radius: 999px; font-size: 0.62rem; text-transform: uppercase; }
.resolved-values { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.35rem 1rem; padding: 0; list-style: none; }
.resolved-values li { display: flex; justify-content: space-between; gap: 1rem; min-width: 0; font-size: 0.76rem; }
.resolved-values code { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
.resolved-values output, #condition-result { color: var(--accent); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.diagnostics-panel { min-height: 0; padding: 1rem 1.25rem; border-top: 1px solid var(--line); background: var(--surface); overflow: auto; }
.count { display: inline-grid; place-items: center; min-width: 1.6rem; min-height: 1.6rem; border-radius: 999px; color: var(--accent); background: var(--accent-dark); }
.diagnostic-list { display: grid; gap: 0.55rem; padding: 0; margin: 0; list-style: none; }
.diagnostic { display: grid; grid-template-columns: 4.5rem minmax(9rem, auto) 1fr; gap: 0.65rem; align-items: baseline; padding: 0.7rem; border-left: 3px solid var(--line); background: #101311; font-size: 0.82rem; }
.diagnostic.error { border-color: var(--danger); }
.diagnostic.warning { border-color: var(--warning); }
.diagnostic.info { border-color: var(--accent); }
.diagnostic .severity, .diagnostic small { color: var(--muted); font-size: 0.68rem; }
.diagnostic small { grid-column: 3; }
.empty { color: var(--muted); text-align: center; }
[hidden] { display: none !important; }
@media (max-width: 760px) {
main { grid-template-columns: 1fr; }
.sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
.workspace { grid-template-rows: auto auto; }
.diagnostic { grid-template-columns: 1fr; }
.diagnostic small { grid-column: 1; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; } }</style>
</head>
<body>
<header class="app-header">
<div class="brand"><h1>XZBT</h1><span id="runtime-version"></span></div>
<div class="status-cluster" role="status" aria-live="polite">
<span id="app-status">Starting…</span><span id="storage-status">Checking storage…</span>
</div>
</header>
<main>
<aside class="sidebar" aria-labelledby="library-title">
<div class="section-heading"><h2 id="library-title">Exhibit library</h2><button id="import-button" class="primary" type="button">Import files</button></div>
<input id="import-files" type="file" accept=".xzbt,application/json" multiple hidden>
<div id="drop-zone" class="drop-zone">Drop .xzbt files here</div>
<p id="empty-state" class="empty">No exhibits loaded.</p>
<div id="library-list" class="exhibit-list"></div>
</aside>
<section class="workspace">
<section class="stage" aria-live="polite">
<div id="stage-empty" class="stage-card"><span class="eyebrow">Runtime ready</span><h2>Select an exhibit</h2><p>Import declarative XZBT 0.1 documents, then activate one from the library. Exhibits run without adding subject-specific code to this runtime.</p></div>
<div id="stage-active" class="stage-card" hidden>
<span class="eyebrow">Active exhibit</span><h2 id="active-name"></h2>
<dl class="runtime-readout"><div><dt>Exhibit ID</dt><dd id="active-id"></dd></div><div><dt>Resolved seed</dt><dd id="active-seed"></dd></div><div style="grid-column:1/-1"><dt>Deterministic visual-stream preview</dt><dd id="active-sequence"></dd></div></dl>
<p>The common grammar resolves stored parameters, mutable state, signals, bindings, actions, and temporary overrides. Audio and visual engines attach in later phases.</p>
<section class="grammar-panel" aria-labelledby="configuration-title"><h3 id="configuration-title">Parameters</h3><div id="configuration"></div></section>
<section class="grammar-panel" aria-labelledby="values-title"><h3 id="values-title">Resolved values</h3><ul id="resolved-values" class="resolved-values"></ul><p>Generated condition: <output id="condition-result">n/a</output></p></section>
<button id="deactivate-button" type="button">Deactivate</button>
</div>
</section>
<section class="diagnostics-panel" aria-labelledby="diagnostics-title">
<div class="section-heading"><h2 id="diagnostics-title">Diagnostics <span id="diagnostic-count" class="count">0</span></h2><button id="clear-diagnostics" type="button">Clear</button></div>
<p id="diagnostics-empty" class="empty">No diagnostics yet.</p><ol id="diagnostic-list" class="diagnostic-list"></ol>
</section>
</section>
</main>
<script>'use strict';
(() => {
/* src/runtime/constants.js */
const XZBT_FORMAT_VERSION = '0.1';
const XZBT_RUNTIME_VERSION = '0.1.0-phase2';
const UINT32_RANGE = 0x1_0000_0000;
const RNG_DOMAINS = Object.freeze([
'cadence',
'scenario',
'visual',
'sound',
'manual-sample'
]);
const DATABASE = Object.freeze({
name: 'xzbt-runtime-0.1',
version: 1,
exhibits: 'exhibits',
preferences: 'preferences'
});
const ID_PATTERN = /^[a-z][a-z0-9_-]*$/;
const ALLOWED_TOP_LEVEL_FIELDS = Object.freeze([
'xzbt', 'meta', 'runtime', 'parameters', 'ui', 'state', 'signals',
'components', 'visuals', 'audio', 'sounds', 'cadence', 'modulators',
'bindings', 'events', 'scenarios'
]);
const ALLOWED_META_FIELDS = Object.freeze([
'id', 'name', 'version', 'author', 'description', 'license', 'tags'
]);
/* src/runtime/diagnostics.js */
class Diagnostics {
constructor({ limit = 250, onChange = () => {} } = {}) {
this.limit = limit;
this.onChange = onChange;
this.entries = [];
this.sequence = 0;
}
add(severity, code, message, context = {}) {
const entry = Object.freeze({
sequence: ++this.sequence,
timestamp: new Date().toISOString(),
severity,
code,
exhibitId: context.exhibitId ?? null,
section: context.section ?? null,
objectId: context.objectId ?? null,
property: context.property ?? null,
message
});
this.entries.push(entry);
if (this.entries.length > this.limit) this.entries.shift();
this.onChange(this.list());
return entry;
}
info(code, message, context) { return this.add('info', code, message, context); }
warn(code, message, context) { return this.add('warning', code, message, context); }
error(code, message, context) { return this.add('error', code, message, context); }
list() { return [...this.entries]; }
clear() { this.entries.length = 0; this.onChange([]); }
}
/* src/runtime/rng.js */
function rotateLeft(value, count) {
return ((value << count) | (value >>> (32 - count))) >>> 0;
}
function multiply32(left, right) {
return Math.imul(left, right) >>> 0;
}
function fnv1a32(text) {
let hash = 0x811c9dc5;
for (const byte of new TextEncoder().encode(text)) {
hash ^= byte;
hash = multiply32(hash, 0x01000193);
}
return hash >>> 0;
}
function splitMix32(seed) {
let state = seed >>> 0;
return () => {
state = (state + 0x9e3779b9) >>> 0;
let value = state;
value = multiply32(value ^ (value >>> 16), 0x21f0aaad);
value = multiply32(value ^ (value >>> 15), 0x735a2d97);
return (value ^ (value >>> 15)) >>> 0;
};
}
function deriveStreamState(rootSeed, domain, stableInstanceKey) {
validateRootSeed(rootSeed);
if (!RNG_DOMAINS.includes(domain)) throw new RangeError(`Unknown XZBT random domain: ${domain}.`);
if (typeof stableInstanceKey !== 'string' || stableInstanceKey.length === 0) {
throw new TypeError('A non-empty stable instance key is required.');
}
const hash = fnv1a32(`xzbt-0.1\0${rootSeed}\0${domain}\0${stableInstanceKey}`);
const expand = splitMix32(hash);
const state = [expand(), expand(), expand(), expand()];
if (state.every((word) => word === 0)) state[3] = 1;
return state;
}
class RandomStream {
constructor(state) {
if (!Array.isArray(state) || state.length !== 4 || state.some((word) => !Number.isInteger(word))) {
throw new TypeError('xoshiro128** state must contain four integer words.');
}
this.state = state.map((word) => word >>> 0);
if (this.state.every((word) => word === 0)) throw new RangeError('xoshiro128** state cannot be all zero.');
}
nextUint32() {
const state = this.state;
const result = multiply32(rotateLeft(multiply32(state[1], 5), 7), 9);
const temporary = (state[1] << 9) >>> 0;
state[2] ^= state[0];
state[3] ^= state[1];
state[1] ^= state[2];
state[0] ^= state[3];
state[2] ^= temporary;
state[3] = rotateLeft(state[3], 11);
for (let index = 0; index < 4; index++) state[index] >>>= 0;
return result >>> 0;
}
nextFloat() { return this.nextUint32() / UINT32_RANGE; }
nextInteger(minimum, maximum) {
if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || maximum < minimum) {
throw new RangeError('Integer bounds must be safe integers with maximum >= minimum.');
}
const span = maximum - minimum + 1;
if (span > UINT32_RANGE) throw new RangeError('Integer range cannot exceed 2^32 values.');
const limit = UINT32_RANGE - (UINT32_RANGE % span);
let value;
do value = this.nextUint32(); while (value >= limit);
return minimum + (value % span);
}
}
class SeededRNG {
constructor(rootSeed) {
this.rootSeed = validateRootSeed(rootSeed);
}
stream(domain, stableInstanceKey) {
return new RandomStream(deriveStreamState(this.rootSeed, domain, stableInstanceKey));
}
}
function validateRootSeed(seed) {
if (!Number.isInteger(seed) || seed < 0 || seed >= UINT32_RANGE) {
throw new RangeError('Root seed must be an unsigned 32-bit integer.');
}
return seed >>> 0;
}
function resolveRootSeed(authoredSeed = 'random', cryptoProvider = globalThis.crypto) {
if (authoredSeed !== 'random') return validateRootSeed(authoredSeed);
if (!cryptoProvider?.getRandomValues) throw new Error('Cryptographic entropy is unavailable; a random exhibit seed cannot be resolved.');
return cryptoProvider.getRandomValues(new Uint32Array(1))[0] >>> 0;
}
/* src/runtime/types.js */
const PARAMETER_TYPES = Object.freeze(['number', 'integer', 'boolean', 'string', 'color', 'enum']);
const STATE_TYPES = Object.freeze(['number', 'integer', 'boolean', 'string']);
const EASINGS = Object.freeze(['linear', 'ease-in', 'ease-out', 'ease-in-out']);
const DURATION_PATTERN = /^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h)$/;
const RUNTIME_SIGNAL_TYPES = Object.freeze({
'signals.time.elapsed': 'number',
'signals.time.delta': 'number',
'signals.audio.low': 'number',
'signals.audio.mid': 'number',
'signals.audio.high': 'number',
'signals.audio.energy': 'number',
'signals.pointer.x': 'number',
'signals.pointer.y': 'number',
'signals.viewport.width': 'number',
'signals.viewport.height': 'number',
'signals.scenario.active': 'boolean'
});
class RuntimeFault extends Error {
constructor(code, message, path = '$') {
super(message);
this.name = 'RuntimeFault';
this.code = code;
this.path = path;
}
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isNumericType(type) { return type === 'number' || type === 'integer'; }
function clamp(value, minimum = -Infinity, maximum = Infinity) {
return Math.min(maximum, Math.max(minimum, value));
}
function roundHalfAwayFromZero(value) {
return Math.sign(value) * Math.floor(Math.abs(value) + 0.5);
}
function easingValue(name, progress) {
const value = clamp(progress, 0, 1);
switch (name) {
case 'linear': return value;
case 'ease-in': return value * value;
case 'ease-out': return 1 - ((1 - value) * (1 - value));
case 'ease-in-out': return value < 0.5 ? 2 * value * value : 1 - (((-2 * value + 2) ** 2) / 2);
default: throw new RuntimeFault('ERR_INVALID_TRANSITION', `Unsupported easing '${name}'.`);
}
}
function lerp(from, to, amount) { return from + ((to - from) * amount); }
function parseDuration(value, path = '$') {
if (typeof value !== 'string') throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration must be a single-unit string.', path);
const match = DURATION_PATTERN.exec(value);
if (!match) throw new RuntimeFault('ERR_INVALID_DURATION', `Invalid duration '${value}'.`, path);
const scalar = Number(match[1]);
const multipliers = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 };
const milliseconds = scalar * multipliers[match[2]];
if (!Number.isFinite(milliseconds)) throw new RuntimeFault('ERR_INVALID_DURATION', `Duration '${value}' is not finite.`, path);
return milliseconds;
}
function valueMatchesType(type, value, spec = {}) {
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
if (type === 'integer') return Number.isInteger(value);
if (type === 'boolean') return typeof value === 'boolean';
if (type === 'string') return typeof value === 'string';
if (type === 'color') return typeof value === 'string' && value.trim().length > 0;
if (type === 'enum') return typeof value === 'string' && Array.isArray(spec.values) && spec.values.includes(value);
return false;
}
function normalizeForSpec(spec, value, { clampNumeric = false } = {}) {
if (!valueMatchesType(spec.type, value, spec)) {
throw new RuntimeFault('ERR_TYPE_MISMATCH', `Value ${JSON.stringify(value)} does not match type '${spec.type}'.`);
}
if (isNumericType(spec.type)) {
let result = value;
if (spec.type === 'integer') result = roundHalfAwayFromZero(result);
const minimum = spec.min ?? -Infinity;
const maximum = spec.max ?? Infinity;
if (!clampNumeric && (result < minimum || result > maximum)) {
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Value ${result} is outside [${minimum}, ${maximum}].`);
}
result = clamp(result, minimum, maximum);
return result;
}
return value;
}
/* src/runtime/values.js */
const OPERATOR_ARITY = Object.freeze({
abs: 1, negate: 1, round: 1, floor: 1, ceil: 1,
add: 2, subtract: 2, multiply: 2, divide: 2, min: 2, max: 2,
clamp: 3, lerp: 3
});
class ValueResolver {
constructor(resolveReference) {
this.resolveReference = resolveReference;
}
evaluate(spec, stream, path = '$') {
if (typeof spec === 'number') {
if (!Number.isFinite(spec)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Numeric literals must be finite.', path);
return spec;
}
if (typeof spec === 'string' || typeof spec === 'boolean') return spec;
if (!isRecord(spec)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Invalid ValueSpec.', path);
if (Object.hasOwn(spec, 'ref')) return this.resolveReference(spec.ref);
if (Object.hasOwn(spec, 'random')) {
if (!stream) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Random ValueSpec requires an owning random stream.', path);
const { min, max, integer = false } = spec.random;
if (!Number.isFinite(min) || !Number.isFinite(max) || max < min || typeof integer !== 'boolean') {
throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Random range requires finite min <= max and an optional boolean integer flag.', `${path}.random`);
}
if (integer && Math.ceil(min) > Math.floor(max)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Integer random range contains no integers.', `${path}.random`);
return integer ? stream.nextInteger(Math.ceil(min), Math.floor(max)) : min + (stream.nextFloat() * (max - min));
}
if (Object.hasOwn(spec, 'choose')) {
if (!stream || !Array.isArray(spec.choose) || spec.choose.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Weighted choice requires a non-empty list and an owning stream.', `${path}.choose`);
let total = 0;
for (const option of spec.choose) {
if (!isRecord(option) || !Number.isFinite(option.weight) || option.weight <= 0 || !Object.hasOwn(option, 'value')) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Every choice requires a value and a finite positive weight.', `${path}.choose`);
total += option.weight;
}
const selected = stream.nextFloat() * total;
let cumulative = 0;
for (let index = 0; index < spec.choose.length; index += 1) {
cumulative += spec.choose[index].weight;
if (selected < cumulative || index === spec.choose.length - 1) return this.evaluate(spec.choose[index].value, stream, `${path}.choose[${index}].value`);
}
}
if (Object.hasOwn(spec, 'op')) {
const arity = OPERATOR_ARITY[spec.op];
if (!arity) throw new RuntimeFault('ERR_INVALID_OPERATOR', `Unsupported ValueSpec operator '${spec.op}'.`, `${path}.op`);
if (!Array.isArray(spec.args) || spec.args.length !== arity) throw new RuntimeFault('ERR_INVALID_ARITY', `Operator '${spec.op}' requires ${arity} arguments.`, `${path}.args`);
const values = spec.args.map((argument, index) => this.evaluate(argument, stream, `${path}.args[${index}]`));
if (values.some((value) => typeof value !== 'number' || !Number.isFinite(value))) throw new RuntimeFault('ERR_TYPE_MISMATCH', `Operator '${spec.op}' requires finite numbers.`, path);
let result;
switch (spec.op) {
case 'abs': result = Math.abs(values[0]); break;
case 'negate': result = -values[0]; break;
case 'round': result = Math.round(values[0]); break;
case 'floor': result = Math.floor(values[0]); break;
case 'ceil': result = Math.ceil(values[0]); break;
case 'add': result = values[0] + values[1]; break;
case 'subtract': result = values[0] - values[1]; break;
case 'multiply': result = values[0] * values[1]; break;
case 'divide': result = values[1] === 0 ? 0 : values[0] / values[1]; break;
case 'min': result = Math.min(values[0], values[1]); break;
case 'max': result = Math.max(values[0], values[1]); break;
case 'clamp':
if (values[1] > values[2]) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Clamp minimum cannot exceed maximum.', path);
result = clamp(values[0], values[1], values[2]);
break;
case 'lerp': result = values[0] + ((values[1] - values[0]) * values[2]); break;
}
if (!Number.isFinite(result)) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', `Operator '${spec.op}' produced a non-finite result.`, path);
return result;
}
throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Unrecognized ValueSpec form.', path);
}
}
class ConditionEvaluator {
constructor(valueResolver) { this.values = valueResolver; }
evaluate(condition, stream, path = '$') {
if (!isRecord(condition)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'ConditionSpec must be an object.', path);
if (Object.hasOwn(condition, 'and')) {
if (!Array.isArray(condition.and) || condition.and.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'and requires a non-empty array.', `${path}.and`);
return condition.and.every((child, index) => this.evaluate(child, stream, `${path}.and[${index}]`));
}
if (Object.hasOwn(condition, 'or')) {
if (!Array.isArray(condition.or) || condition.or.length === 0) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'or requires a non-empty array.', `${path}.or`);
return condition.or.some((child, index) => this.evaluate(child, stream, `${path}.or[${index}]`));
}
if (Object.hasOwn(condition, 'not')) return !this.evaluate(condition.not, stream, `${path}.not`);
const comparisons = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'];
if (!comparisons.includes(condition.op) || !Object.hasOwn(condition, 'left') || !Object.hasOwn(condition, 'right')) throw new RuntimeFault('ERR_INVALID_OPERATOR', `Unsupported comparison operator '${condition.op}'.`, `${path}.op`);
const left = this.values.evaluate(condition.left, stream, `${path}.left`);
const right = this.values.evaluate(condition.right, stream, `${path}.right`);
if (typeof left !== typeof right || !['number', 'boolean', 'string'].includes(typeof left)) throw new RuntimeFault('ERR_TYPE_MISMATCH', 'Condition operands must have compatible primitive types.', path);
switch (condition.op) {
case 'eq': return left === right;
case 'ne': return left !== right;
case 'gt': return left > right;
case 'gte': return left >= right;
case 'lt': return left < right;
case 'lte': return left <= right;
}
}
}
/* src/runtime/validator.js */
function issue(code, path, message, context = {}) {
return { code, path, message, ...context };
}
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseExhibit(source, filename = 'document.xzbt') {
if (typeof source !== 'string') {
return { valid: false, document: null, errors: [issue('ERR_SCHEMA_VALIDATION', '$', 'Exhibit source must be UTF-8 JSON text.')], filename };
}
const normalizedSource = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
try {
return { valid: true, document: JSON.parse(normalizedSource), errors: [], filename, source: normalizedSource };
} catch (error) {
return { valid: false, document: null, errors: [issue('ERR_SCHEMA_VALIDATION', '$', `Malformed JSON: ${error.message}`)], filename, source: normalizedSource };
}
}
function validateExhibit(document, filename = 'document.xzbt') {
const errors = [];
const add = (code, path, message, context) => errors.push(issue(code, path, message, context));
if (!isPlainObject(document)) {
add('ERR_SCHEMA_VALIDATION', '$', 'Top-level document must be a JSON object.');
return { valid: false, errors, warnings: [], filename };
}
if (document.xzbt !== XZBT_FORMAT_VERSION) {
add('ERR_UNSUPPORTED_VERSION', '$.xzbt', `Unsupported XZBT format version: '${document.xzbt}'. Expected '${XZBT_FORMAT_VERSION}'.`, { property: 'xzbt' });
}
for (const field of Object.keys(document)) {
if (!ALLOWED_TOP_LEVEL_FIELDS.includes(field)) add('ERR_UNKNOWN_FIELD', `$.${field}`, `Unrecognized top-level field: '${field}'.`, { section: field });
}
if (!isPlainObject(document.meta)) {
add('ERR_SCHEMA_VALIDATION', '$.meta', 'Missing or invalid required object: meta.', { section: 'meta' });
} else {
for (const field of Object.keys(document.meta)) {
if (!ALLOWED_META_FIELDS.includes(field)) add('ERR_UNKNOWN_FIELD', `$.meta.${field}`, `Unrecognized metadata field: '${field}'.`, { section: 'meta', property: field });
}
const { id, name, version, author, description, license, tags } = document.meta;
if (typeof id !== 'string' || !ID_PATTERN.test(id) || id.length > 64) {
add('ERR_INVALID_ID', '$.meta.id', "Exhibit id must match ^[a-z][a-z0-9_-]*$ and contain at most 64 characters.", { section: 'meta', property: 'id' });
}
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 128) {
add('ERR_SCHEMA_VALIDATION', '$.meta.name', 'Exhibit name must be a non-empty string of at most 128 characters.', { section: 'meta', property: 'name' });
}
if (version !== undefined && typeof version !== 'string') add('ERR_TYPE_MISMATCH', '$.meta.version', 'Exhibit version must be a string.', { section: 'meta', property: 'version' });
if (author !== undefined && (typeof author !== 'string' || author.length > 128)) add('ERR_TYPE_MISMATCH', '$.meta.author', 'Author must be a string of at most 128 characters.', { section: 'meta', property: 'author' });
if (description !== undefined && (typeof description !== 'string' || description.length > 1024)) add('ERR_TYPE_MISMATCH', '$.meta.description', 'Description must be a string of at most 1024 characters.', { section: 'meta', property: 'description' });
if (license !== undefined && typeof license !== 'string') add('ERR_TYPE_MISMATCH', '$.meta.license', 'License must be a string.', { section: 'meta', property: 'license' });
if (tags !== undefined && (!Array.isArray(tags) || tags.length > 16 || tags.some((tag) => typeof tag !== 'string' || tag.length > 32))) {
add('ERR_TYPE_MISMATCH', '$.meta.tags', 'Tags must contain at most 16 strings of at most 32 characters.', { section: 'meta', property: 'tags' });
}
}
if (document.runtime !== undefined) {
if (!isPlainObject(document.runtime)) {
add('ERR_SCHEMA_VALIDATION', '$.runtime', 'runtime must be an object.', { section: 'runtime' });
} else {
for (const field of Object.keys(document.runtime)) {
if (field !== 'seed') add('ERR_UNKNOWN_FIELD', `$.runtime.${field}`, `Unrecognized runtime field: '${field}'.`, { section: 'runtime', property: field });
}
const seed = document.runtime.seed;
if (seed !== undefined && seed !== 'random' && (!Number.isInteger(seed) || seed < 0 || seed >= UINT32_RANGE)) {
add('ERR_OUT_OF_BOUNDS', '$.runtime.seed', "Seed must be 'random' or an unsigned 32-bit integer.", { section: 'runtime', property: 'seed' });
}
}
}
validateDefinitions(document, errors);
validateBindings(document, errors);
return { valid: errors.length === 0, errors, warnings: [], filename };
}
const PARAMETER_FIELDS = new Set(['type', 'default', 'min', 'max', 'step', 'values', 'label', 'unit']);
const STATE_FIELDS = new Set(['type', 'initial', 'min', 'max']);
const BINDING_FIELDS = new Set(['source', 'target', 'scale', 'offset', 'clamp', 'smoothing', 'when']);
const VALUE_OPERATORS = Object.freeze({ abs: 1, negate: 1, round: 1, floor: 1, ceil: 1, add: 2, subtract: 2, multiply: 2, divide: 2, min: 2, max: 2, clamp: 3, lerp: 3 });
const COMPARISONS = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte']);
function pushError(errors, code, path, message) { errors.push(issue(code, path, message)); }
function validateDefinitions(document, errors) {
validateDefinitionMap(document.parameters, 'parameters', PARAMETER_TYPES, 'default', PARAMETER_FIELDS, errors);
validateDefinitionMap(document.state, 'state', STATE_TYPES, 'initial', STATE_FIELDS, errors);
}
function validateDefinitionMap(definitions, namespace, allowedTypes, valueField, allowedFields, errors) {
if (definitions === undefined) return;
if (!isPlainObject(definitions)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', `$.${namespace}`, `${namespace} must be an object.`);
return;
}
for (const [id, spec] of Object.entries(definitions)) {
const path = `$.${namespace}.${id}`;
if (!ID_PATTERN.test(id)) pushError(errors, 'ERR_INVALID_ID', path, `${namespace} ID '${id}' is invalid.`);
if (!isPlainObject(spec)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', path, `${namespace} definition must be an object.`);
continue;
}
for (const field of Object.keys(spec)) if (!allowedFields.has(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized ${namespace} field '${field}'.`);
if (!allowedTypes.includes(spec.type)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.type`, `Unsupported ${namespace} type '${spec.type}'.`);
if (!Object.hasOwn(spec, valueField)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.${valueField}`, `Missing required field '${valueField}'.`);
else if (!valueMatchesType(spec.type, spec[valueField], spec)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.${valueField}`, `Value does not match declared type '${spec.type}'.`);
if (spec.min !== undefined && !Number.isFinite(spec.min)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.min`, 'min must be a finite number.');
if (spec.max !== undefined && !Number.isFinite(spec.max)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.max`, 'max must be a finite number.');
if (Number.isFinite(spec.min) && Number.isFinite(spec.max) && spec.min > spec.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.min`, 'min cannot exceed max.');
if (isNumericType(spec.type) && typeof spec[valueField] === 'number') {
if (spec.min !== undefined && spec[valueField] < spec.min) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, `${valueField} is below min.`);
if (spec.max !== undefined && spec[valueField] > spec.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, `${valueField} is above max.`);
}
if (namespace === 'parameters' && spec.step !== undefined && (!Number.isFinite(spec.step) || spec.step <= 0)) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.step`, 'step must be a finite positive number.');
if (spec.type === 'enum') {
if (!Array.isArray(spec.values) || spec.values.length === 0 || spec.values.some((value) => typeof value !== 'string')) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.values`, 'Enum values must be a non-empty string array.');
else if (!spec.values.includes(spec[valueField])) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.${valueField}`, 'Enum default is not declared in values.');
}
}
}
function referenceType(document, path) {
if (typeof path !== 'string') return null;
const parts = path.split('.');
if (parts.length === 2 && parts[0] === 'parameters') return document.parameters?.[parts[1]]?.type ?? null;
if (parts.length === 2 && parts[0] === 'state') return document.state?.[parts[1]]?.type ?? null;
if (parts[0] === 'signals') return RUNTIME_SIGNAL_TYPES[path] ?? null;
if (parts.length === 2 && parts[0] === 'modulators') return document.modulators?.[parts[1]] ? 'number' : null;
if (parts.length === 4 && parts[0] === 'audio' && parts[1] === 'buses' && parts[3] === 'gain') return document.audio?.buses?.[parts[2]] ? 'number' : null;
return null;
}
function validateReference(document, path, location, errors) {
if (typeof path !== 'string' || !/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+$/.test(path) || !referenceType(document, path)) pushError(errors, 'ERR_INVALID_REFERENCE', location, `Reference '${path}' does not resolve.`);
}
function validateValueSpec(document, spec, path, errors) {
if (typeof spec === 'number') {
if (!Number.isFinite(spec)) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Number must be finite.');
return;
}
if (typeof spec === 'string' || typeof spec === 'boolean') return;
if (!isPlainObject(spec)) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Invalid ValueSpec.');
return;
}
const forms = ['ref', 'random', 'choose', 'op'].filter((field) => Object.hasOwn(spec, field));
if (forms.length !== 1) {
pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'ValueSpec must use exactly one recognized form.');
return;
}
const form = forms[0];
for (const field of Object.keys(spec)) if (field !== form && !(form === 'op' && field === 'args')) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unrecognized ValueSpec field '${field}'.`);
if (form === 'ref') return validateReference(document, spec.ref, `${path}.ref`, errors);
if (form === 'random') {
const random = spec.random;
if (!isPlainObject(random)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.random`, 'random must be an object.');
for (const field of Object.keys(random)) if (!['min', 'max', 'integer', 'distribution'].includes(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.random.${field}`, `Unknown random field '${field}'.`);
if (!Number.isFinite(random.min) || !Number.isFinite(random.max)) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.random`, 'random min and max must be finite numbers.');
else if (random.min > random.max) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.random`, 'random min cannot exceed max.');
if (random.integer !== undefined && typeof random.integer !== 'boolean') pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.random.integer`, 'integer must be boolean.');
if (random.distribution !== undefined && !['uniform', 'gaussian'].includes(random.distribution)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.random.distribution`, 'Unsupported distribution.');
return;
}
if (form === 'choose') {
if (!Array.isArray(spec.choose) || spec.choose.length === 0) return pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.choose`, 'choose must be a non-empty array.');
spec.choose.forEach((option, index) => {
if (!isPlainObject(option) || !Object.hasOwn(option, 'value') || !Number.isFinite(option.weight) || option.weight <= 0) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.choose[${index}]`, 'Choice requires value and a finite positive weight.');
else validateValueSpec(document, option.value, `${path}.choose[${index}].value`, errors);
});
return;
}
if (!Object.hasOwn(VALUE_OPERATORS, spec.op)) pushError(errors, 'ERR_INVALID_OPERATOR', `${path}.op`, `Unknown operator '${spec.op}'.`);
if (!Array.isArray(spec.args)) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.args`, 'Operator args must be an array.');
else {
if (spec.args.length !== VALUE_OPERATORS[spec.op]) pushError(errors, 'ERR_INVALID_ARITY', `${path}.args`, `Operator '${spec.op}' has invalid arity.`);
spec.args.forEach((argument, index) => validateValueSpec(document, argument, `${path}.args[${index}]`, errors));
}
}
function validateCondition(document, condition, path, errors) {
if (!isPlainObject(condition)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'ConditionSpec must be an object.');
if (Object.hasOwn(condition, 'and') || Object.hasOwn(condition, 'or')) {
const key = Object.hasOwn(condition, 'and') ? 'and' : 'or';
if (Object.keys(condition).length !== 1) pushError(errors, 'ERR_UNKNOWN_FIELD', path, 'Logical condition contains extra fields.');
if (!Array.isArray(condition[key]) || condition[key].length === 0) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.${key}`, `${key} requires a non-empty array.`);
else condition[key].forEach((child, index) => validateCondition(document, child, `${path}.${key}[${index}]`, errors));
return;
}
if (Object.hasOwn(condition, 'not')) {
if (Object.keys(condition).length !== 1) pushError(errors, 'ERR_UNKNOWN_FIELD', path, 'not condition contains extra fields.');
return validateCondition(document, condition.not, `${path}.not`, errors);
}
if (!COMPARISONS.has(condition.op)) pushError(errors, 'ERR_INVALID_OPERATOR', `${path}.op`, `Unknown comparison '${condition.op}'.`);
if (!Object.hasOwn(condition, 'left') || !Object.hasOwn(condition, 'right')) pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Comparison requires left and right.');
else {
validateValueSpec(document, condition.left, `${path}.left`, errors);
validateValueSpec(document, condition.right, `${path}.right`, errors);
}
}
function validateBindings(document, errors) {
if (document.bindings === undefined) return;
if (!Array.isArray(document.bindings)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', '$.bindings', 'bindings must be an array.');
const writers = new Map();
const dependencies = new Map();
document.bindings.forEach((binding, index) => {
const path = `$.bindings[${index}]`;
if (!isPlainObject(binding)) return pushError(errors, 'ERR_SCHEMA_VALIDATION', path, 'Binding must be an object.');
for (const field of Object.keys(binding)) if (!BINDING_FIELDS.has(field)) pushError(errors, 'ERR_UNKNOWN_FIELD', `${path}.${field}`, `Unknown binding field '${field}'.`);
validateReference(document, binding.source, `${path}.source`, errors);
validateReference(document, binding.target, `${path}.target`, errors);
if (!(typeof binding.target === 'string' && (/^(parameters|state)\.[a-z][a-z0-9_-]*$/.test(binding.target) || /^audio\.buses\.[a-z][a-z0-9_-]*\.gain$/.test(binding.target)))) pushError(errors, 'ERR_UNSUPPORTED_TARGET', `${path}.target`, `Binding target '${binding.target}' is not exposed.`);
for (const field of ['scale', 'offset']) if (binding[field] !== undefined && !Number.isFinite(binding[field])) pushError(errors, 'ERR_TYPE_MISMATCH', `${path}.${field}`, `${field} must be finite.`);
if (binding.clamp !== undefined && (!Array.isArray(binding.clamp) || binding.clamp.length !== 2 || binding.clamp.some((value) => !Number.isFinite(value)))) pushError(errors, 'ERR_SCHEMA_VALIDATION', `${path}.clamp`, 'clamp must contain two finite numbers.');
else if (binding.clamp?.[0] > binding.clamp?.[1]) pushError(errors, 'ERR_OUT_OF_BOUNDS', `${path}.clamp`, 'clamp minimum cannot exceed maximum.');
if (binding.smoothing !== undefined && (typeof binding.smoothing !== 'string' || !DURATION_PATTERN.test(binding.smoothing))) pushError(errors, 'ERR_INVALID_DURATION', `${path}.smoothing`, 'Invalid smoothing duration.');
if (binding.when !== undefined) validateCondition(document, binding.when, `${path}.when`, errors);
const sourceType = referenceType(document, binding.source);
const targetType = referenceType(document, binding.target);
if (sourceType && targetType) {
const transformed = binding.scale !== undefined || binding.offset !== undefined || binding.clamp !== undefined || (binding.smoothing !== undefined && binding.smoothing !== '0ms');
if (transformed && (!isNumericType(sourceType) || !isNumericType(targetType))) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Binding transforms require numeric endpoints.');
else if (!transformed && sourceType !== targetType && !(isNumericType(sourceType) && isNumericType(targetType))) pushError(errors, 'ERR_TYPE_MISMATCH', path, 'Binding endpoint types do not match.');
}
if (typeof binding.target === 'string') {
if (writers.has(binding.target)) pushError(errors, 'ERR_CONFLICTING_BINDING', `${path}.target`, `Target already written by binding ${writers.get(binding.target)}.`);
else writers.set(binding.target, index);
if (!dependencies.has(binding.target)) dependencies.set(binding.target, []);
if (typeof binding.source === 'string') dependencies.get(binding.target).push(binding.source);
}
});
const visiting = new Set();
const visited = new Set();
const visit = (node, trail) => {
if (visiting.has(node)) return pushError(errors, 'ERR_CYCLIC_DEPENDENCY', '$.bindings', `Binding cycle: ${[...trail, node].join(' -> ')}.`);
if (visited.has(node)) return;
visiting.add(node);
for (const source of dependencies.get(node) ?? []) visit(source, [...trail, node]);
visiting.delete(node);
visited.add(node);
};
for (const target of dependencies.keys()) visit(target, []);
}
function parseAndValidateExhibit(source, filename) {
const parsed = parseExhibit(source, filename);
if (!parsed.valid) return parsed;
return { ...validateExhibit(parsed.document, filename), document: parsed.document, source: parsed.source };
}
/* src/runtime/persistence.js */
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed.'));
});
}
function transactionDone(transaction) {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted.'));
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed.'));
});
}
class PersistenceManager {
constructor({ indexedDBProvider = globalThis.indexedDB, diagnostics } = {}) {
this.indexedDB = indexedDBProvider;
this.diagnostics = diagnostics;
this.database = null;
this.available = false;
}
async open() {
if (!this.indexedDB) return this.failOpen('IndexedDB is unavailable in this browser context.');
try {
const request = this.indexedDB.open(DATABASE.name, DATABASE.version);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(DATABASE.exhibits)) database.createObjectStore(DATABASE.exhibits, { keyPath: 'id' });
if (!database.objectStoreNames.contains(DATABASE.preferences)) database.createObjectStore(DATABASE.preferences, { keyPath: 'key' });
};
this.database = await requestResult(request);
this.database.onversionchange = () => {
this.database.close();
this.database = null;
this.available = false;
};
this.available = true;
this.diagnostics?.info('INFO_STORAGE_READY', 'Local exhibit storage is ready.', { section: 'persistence' });
return true;
} catch (error) {
return this.failOpen(error.message);
}
}
failOpen(reason) {
this.available = false;
this.diagnostics?.warn('WARN_STORAGE_UNAVAILABLE', `Session-only mode: ${reason} Imported exhibits must be imported again on a later launch.`, { section: 'persistence' });
return false;
}
async loadSnapshot() {
if (!this.available) return { exhibits: [], preferences: {} };
const transaction = this.database.transaction([DATABASE.exhibits, DATABASE.preferences], 'readonly');
const exhibitsRequest = transaction.objectStore(DATABASE.exhibits).getAll();
const preferencesRequest = transaction.objectStore(DATABASE.preferences).getAll();
const [exhibits, rows] = await Promise.all([requestResult(exhibitsRequest), requestResult(preferencesRequest), transactionDone(transaction)]);
return { exhibits, preferences: Object.fromEntries(rows.map(({ key, value }) => [key, value])) };
}
async saveExhibit(record) {
if (!this.available) return false;
const transaction = this.database.transaction(DATABASE.exhibits, 'readwrite');
transaction.objectStore(DATABASE.exhibits).put(record);
await transactionDone(transaction);
return true;
}
async savePreference(key, value) {
if (!this.available) return false;
const transaction = this.database.transaction(DATABASE.preferences, 'readwrite');
transaction.objectStore(DATABASE.preferences).put({ key, value });
await transactionDone(transaction);
return true;
}
}
/* src/runtime/library.js */
function bytesToHex(bytes) {
return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function sha256(source, cryptoProvider = globalThis.crypto) {
if (!cryptoProvider?.subtle) throw new Error('Web Crypto SHA-256 is unavailable.');
const digest = await cryptoProvider.subtle.digest('SHA-256', new TextEncoder().encode(source));
return bytesToHex(new Uint8Array(digest));
}
class LibraryManager {
constructor({ persistence, diagnostics, cryptoProvider = globalThis.crypto } = {}) {
this.persistence = persistence;
this.diagnostics = diagnostics;
this.crypto = cryptoProvider;
this.records = new Map();
}
list() {
return [...this.records.values()].sort((left, right) => left.document.meta.name.localeCompare(right.document.meta.name));
}
get(id) { return this.records.get(id) ?? null; }
restore(records) {
for (const record of records) {
const checked = validateStoredRecord(record);
if (!checked.valid) {
this.diagnostics?.error('ERR_CACHE_INVALID', `Cached exhibit was ignored: ${checked.message}`, { exhibitId: record?.id ?? null, section: 'persistence' });
continue;
}
this.records.set(record.id, record);
}
if (this.records.size > 0) this.diagnostics?.info('INFO_LIBRARY_RESTORED', `Restored ${this.records.size} cached exhibit${this.records.size === 1 ? '' : 's'}.`, { section: 'library' });
}
async importSource(source, filename, { confirmReplacement = async () => false } = {}) {
const result = parseAndValidateExhibit(source, filename);
if (!result.valid) {
for (const error of result.errors) this.diagnostics?.error(error.code, error.message, { exhibitId: result.document?.meta?.id ?? null, section: error.section ?? 'loader', objectId: error.objectId, property: error.property });
return { status: 'invalid', ...result };
}
let digest;
try {
digest = await sha256(result.source, this.crypto);
} catch (error) {
this.diagnostics?.error('ERR_IMPORT_DIGEST', error.message, { exhibitId: result.document.meta.id, section: 'loader' });
return { status: 'invalid', valid: false, errors: [{ code: 'ERR_IMPORT_DIGEST', path: '$', message: error.message }] };
}
const id = result.document.meta.id;
const existing = this.records.get(id);
if (existing?.digest === digest) {
this.diagnostics?.info('INFO_IMPORT_IDENTICAL', `${result.document.meta.name} is already in the library; no changes were made.`, { exhibitId: id, section: 'library' });
return { status: 'identical', record: existing };
}
if (existing && !(await confirmReplacement(existing, result.document))) {
this.diagnostics?.info('INFO_IMPORT_REPLACEMENT_CANCELLED', `Replacement of ${existing.document.meta.name} was cancelled.`, { exhibitId: id, section: 'library' });
return { status: 'cancelled', record: existing };
}
const record = Object.freeze({
id,
digest,
source: result.source,
document: result.document,
sourceInfo: { kind: 'file', name: filename },
importedAt: new Date().toISOString()
});
this.records.set(id, record);
try {
await this.persistence?.saveExhibit(record);
} catch (error) {
this.diagnostics?.warn('WARN_STORAGE_WRITE_FAILED', `The exhibit is available for this session but could not be cached: ${error.message}`, { exhibitId: id, section: 'persistence' });
}
this.diagnostics?.info(existing ? 'INFO_EXHIBIT_REPLACED' : 'INFO_EXHIBIT_IMPORTED', `${result.document.meta.name} ${existing ? 'replaced' : 'imported'}.`, { exhibitId: id, section: 'library' });
return { status: existing ? 'replaced' : 'imported', record };
}
}
function validateStoredRecord(record) {
if (!record || typeof record.source !== 'string' || typeof record.digest !== 'string' || record.id !== record.document?.meta?.id) {
return { valid: false, message: 'record shape is invalid.' };
}
const result = parseAndValidateExhibit(record.source, record.sourceInfo?.name ?? 'cached.xzbt');
if (!result.valid || result.document.meta.id !== record.id) return { valid: false, message: 'definition no longer validates.' };
return { valid: true };
}
/* src/runtime/resolution.js */
const STEP_SECONDS = 1 / 60;
class SignalProvider {
constructor() {
this.values = new Map([
['signals.time.elapsed', 0], ['signals.time.delta', 0],
['signals.audio.low', 0], ['signals.audio.mid', 0], ['signals.audio.high', 0], ['signals.audio.energy', 0],
['signals.pointer.x', 0], ['signals.pointer.y', 0],
['signals.viewport.width', globalThis.innerWidth ?? 0], ['signals.viewport.height', globalThis.innerHeight ?? 0],
['signals.scenario.active', false]
]);
}
get(path) {
if (!this.values.has(path)) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown runtime signal '${path}'.`, path);
return this.values.get(path);
}
set(path, value) {
const type = RUNTIME_SIGNAL_TYPES[path];
if (!type) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown runtime signal '${path}'.`, path);
if ((type === 'number' && (!Number.isFinite(value))) || (type === 'boolean' && typeof value !== 'boolean')) throw new RuntimeFault('ERR_TYPE_MISMATCH', `Signal '${path}' requires ${type}.`, path);
this.values.set(path, value);
}
updateTime(elapsedSeconds, deltaSeconds) {
this.values.set('signals.time.elapsed', elapsedSeconds);
this.values.set('signals.time.delta', deltaSeconds);
}
}
class OverrideStack {
constructor(engine) {
this.engine = engine;
this.instances = new Map();
this.activationSequence = 0;
}
add(action, sampledValue, { owner = 'performance', inheritedPriority = 0 } = {}) {
const target = this.engine.target(action.target);
if (!target) throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Override target '${action.target}' is not exposed.`, action.target);
const normalized = normalizeForSpec(target.spec, sampledValue);
if (action.scope !== 'scenario' && action.scope !== 'duration') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', "Override scope must be 'scenario' or 'duration'.");
const durationMs = action.scope === 'duration' ? parseDuration(action.duration, '$.duration') : null;
if (action.scope === 'duration' && durationMs <= 0) throw new RuntimeFault('ERR_INVALID_DURATION', 'Duration overrides require duration greater than 0ms.');
if (action.scope === 'scenario' && action.duration !== undefined) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Scenario-scoped overrides cannot declare duration.');
const priority = action.priority ?? inheritedPriority;
if (!Number.isInteger(priority) || priority < -1000 || priority > 1000) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Override priority must be an integer from -1000 through 1000.');
const transition = action.transition ?? {};
const attackMs = parseDuration(transition.in ?? '0ms', '$.transition.in');
const releaseMs = parseDuration(transition.out ?? '0ms', '$.transition.out');
const easing = transition.easing ?? 'linear';
easingValue(easing, 0);
if (!isNumericType(target.spec.type) && (attackMs > 0 || releaseMs > 0)) throw new RuntimeFault('ERR_INVALID_TRANSITION', 'Non-numeric overrides require zero-duration transitions.');
const sequence = ++this.activationSequence;
const instance = {
id: `instances.override-${sequence}`,
authorId: action.id ?? null,
target: action.target,
owner,
scope: action.scope,
priority,
activationSequence: sequence,
sampledValue: normalized,
attackOrigin: this.engine.get(action.target),
attackMs,
releaseMs,
easing,
activatedAt: this.engine.logicalMilliseconds,
expiresAt: durationMs === null ? null : this.engine.logicalMilliseconds + durationMs,
phase: 'active',
releaseStartedAt: null,
releaseStartValue: null,
lastValue: null
};
this.instances.set(instance.id, instance);
this.engine.invalidate();
return instance.id;
}
beginRelease(id) {
const instance = this.instances.get(id);
if (!instance || instance.phase === 'releasing') return false;
if (instance.releaseMs === 0) {
this.instances.delete(id);
} else {
instance.phase = 'releasing';
instance.releaseStartedAt = this.engine.logicalMilliseconds;
instance.releaseStartValue = instance.lastValue ?? instance.sampledValue;
}
this.engine.invalidate();
return true;
}
releaseOwner(owner) {
for (const instance of [...this.instances.values()]) if (instance.owner === owner && instance.scope === 'scenario') this.beginRelease(instance.id);
}
advance() {
const now = this.engine.logicalMilliseconds;
for (const instance of [...this.instances.values()]) {
if (instance.phase === 'active' && instance.expiresAt !== null && now >= instance.expiresAt) this.beginRelease(instance.id);
if (instance.phase === 'releasing' && now - instance.releaseStartedAt >= instance.releaseMs) this.instances.delete(instance.id);
}
}
forTarget(path) { return [...this.instances.values()].filter((instance) => instance.target === path); }
has(path) { return this.forTarget(path).length > 0; }
select(path) {
return this.forTarget(path).reduce((winner, candidate) => {
if (!winner || candidate.priority > winner.priority || (candidate.priority === winner.priority && candidate.activationSequence > winner.activationSequence)) return candidate;
return winner;
}, null);
}
value(instance, lowerValue) {
const now = this.engine.logicalMilliseconds;
let value = instance.sampledValue;
if (instance.phase === 'releasing') {
const progress = (now - instance.releaseStartedAt) / instance.releaseMs;
value = lerp(lowerValue, instance.releaseStartValue, 1 - easingValue(instance.easing, progress));
} else if (instance.attackMs > 0) {
const progress = (now - instance.activatedAt) / instance.attackMs;
value = lerp(instance.attackOrigin, instance.sampledValue, easingValue(instance.easing, progress));
}
instance.lastValue = value;
return value;
}
clear() { this.instances.clear(); this.engine.invalidate(); }
}
class ResolutionEngine {
constructor(document, rootRng, { diagnostics, initialParameters = {}, onParameterChange = () => {} } = {}) {
this.document = document;
this.rng = rootRng;
this.diagnostics = diagnostics;
this.onParameterChange = onParameterChange;
this.parameters = new Map();
this.state = new Map();
this.signals = new SignalProvider();
this.bindings = (document.bindings ?? []).map((binding, index) => ({ definition: binding, index, smoother: undefined, enabled: false, lastTick: -1 }));
this.transitions = new Map();
this.resolved = new Map();
this.resolving = new Set();
this.tickIndex = 0;
this.logicalMilliseconds = 0;
this.valueResolver = new ValueResolver((path) => this.get(path));
this.conditions = new ConditionEvaluator(this.valueResolver);
this.overrides = new OverrideStack(this);
for (const [id, spec] of Object.entries(document.parameters ?? {})) {
const candidate = Object.hasOwn(initialParameters, id) ? initialParameters[id] : spec.default;
this.parameters.set(id, valueMatchesType(spec.type, candidate, spec) ? normalizeForSpec(spec, candidate, { clampNumeric: true }) : spec.default);
}
for (const [id, spec] of Object.entries(document.state ?? {})) this.state.set(id, spec.initial);
this.resolveAll();
}
target(path) {
const [namespace, id, extra] = path.split('.');
if (extra !== undefined) return null;
if (namespace === 'parameters' && this.document.parameters?.[id]) return { namespace, id, spec: this.document.parameters[id] };
if (namespace === 'state' && this.document.state?.[id]) return { namespace, id, spec: this.document.state[id] };
return null;
}
base(path) {
const target = this.target(path);
if (!target) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown value reference '${path}'.`, path);
return target.namespace === 'parameters' ? this.parameters.get(target.id) : this.state.get(target.id);
}
get(path) {
if (path.startsWith('signals.')) return this.signals.get(path);
if (this.resolved.has(path)) return this.resolved.get(path);
return this.resolveTarget(path);
}
resolveAll() {
this.resolved.clear();
for (const id of Object.keys(this.document.parameters ?? {})) this.resolveTarget(`parameters.${id}`);
for (const id of Object.keys(this.document.state ?? {})) this.resolveTarget(`state.${id}`);
return this.snapshot();
}
resolveTarget(path) {
if (this.resolved.has(path)) return this.resolved.get(path);
const target = this.target(path);
if (!target) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown or unsupported reference '${path}'.`, path);
if (this.resolving.has(path)) throw new RuntimeFault('ERR_CYCLIC_DEPENDENCY', `Resolution cycle reached '${path}'.`, path);
this.resolving.add(path);
try {
let lower = this.base(path);
const binding = this.bindings.find((item) => item.definition.target === path);
if (binding) lower = this.bindingValue(binding, target, lower);
const winner = this.overrides.select(path);
let value = winner ? this.overrides.value(winner, lower) : lower;
if (isNumericType(target.spec.type)) {
if (target.spec.type === 'integer') value = roundHalfAwayFromZero(value);
value = clamp(value, target.spec.min ?? -Infinity, target.spec.max ?? Infinity);
}
this.resolved.set(path, value);
return value;
} finally {
this.resolving.delete(path);
}
}
bindingValue(binding, target, baseValue) {
const definition = binding.definition;
const conditionStream = this.rng.stream('scenario', `binding-${binding.index}:condition`);
const enabled = definition.when === undefined || this.conditions.evaluate(definition.when, conditionStream, `$.bindings[${binding.index}].when`);
if (!enabled) {
binding.enabled = false;
binding.smoother = undefined;
return baseValue;
}
const source = this.get(definition.source);
const numeric = typeof source === 'number' && isNumericType(target.spec.type);
let value = source;
if (numeric) {
value = (source * (definition.scale ?? 1)) + (definition.offset ?? 0);
if (definition.clamp) value = clamp(value, definition.clamp[0], definition.clamp[1]);
const tau = parseDuration(definition.smoothing ?? '0ms') / 1000;
if (!binding.enabled || binding.smoother === undefined) {
binding.smoother = value;
binding.lastTick = this.tickIndex;
} else if (binding.lastTick !== this.tickIndex) {
if (tau === 0) binding.smoother = value;
else binding.smoother += (1 - Math.exp(-STEP_SECONDS / tau)) * (value - binding.smoother);
binding.lastTick = this.tickIndex;
}
value = binding.smoother;
}
binding.enabled = true;
return normalizeForSpec(target.spec, value, { clampNumeric: true });
}
setParameter(id, value) {
const spec = this.document.parameters?.[id];
if (!spec) throw new RuntimeFault('ERR_INVALID_REFERENCE', `Unknown parameter '${id}'.`, `parameters.${id}`);
this.parameters.set(id, normalizeForSpec(spec, value));
this.invalidate();
this.onParameterChange(this.parameterSnapshot());
return this.resolveAll();
}
setState(path, value, transition = {}) {
const target = this.target(path);
if (!target || target.namespace !== 'state') throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `set may target state only; got '${path}'.`, path);
const normalized = normalizeForSpec(target.spec, value);
const durationMs = parseDuration(transition.duration ?? '0ms', '$.transition.duration');
const easing = transition.easing ?? 'linear';
easingValue(easing, 0);
if (!isNumericType(target.spec.type) && durationMs > 0) throw new RuntimeFault('ERR_INVALID_TRANSITION', 'Non-numeric state values require zero-duration transitions.');
if (durationMs === 0) {
this.state.set(target.id, normalized);
this.transitions.delete(path);
} else {
this.transitions.set(path, { path, from: this.base(path), to: normalized, start: this.logicalMilliseconds, durationMs, easing, spec: target.spec });
}
this.invalidate();
return this.resolveAll();
}
advance(milliseconds = 1000 / 60) {
if (!Number.isFinite(milliseconds) || milliseconds < 0) throw new RangeError('Advance duration must be finite and nonnegative.');
this.logicalMilliseconds += milliseconds;
this.tickIndex += 1;
this.signals.updateTime(this.logicalMilliseconds / 1000, milliseconds / 1000);
for (const [path, transition] of [...this.transitions]) {
const progress = clamp((this.logicalMilliseconds - transition.start) / transition.durationMs, 0, 1);
let value = lerp(transition.from, transition.to, easingValue(transition.easing, progress));
if (transition.spec.type === 'integer') value = roundHalfAwayFromZero(value);
value = normalizeForSpec(transition.spec, value, { clampNumeric: true });
this.state.set(path.split('.')[1], value);
if (progress >= 1) this.transitions.delete(path);
}
this.overrides.advance();
return this.resolveAll();
}
evaluateValue(spec, stream, path) { return this.valueResolver.evaluate(spec, stream, path); }
evaluateCondition(spec, stream, path) { return this.conditions.evaluate(spec, stream, path); }
invalidate() { this.resolved.clear(); }
parameterSnapshot() { return Object.fromEntries(this.parameters); }
stateSnapshot() { return Object.fromEntries(this.state); }
snapshot() { return Object.fromEntries(this.resolved); }
dispose() { this.overrides.clear(); this.transitions.clear(); this.resolved.clear(); }
}
/* src/runtime/actions.js */
class ActionExecutor {
constructor(engine, { diagnostics } = {}) {
this.engine = engine;
this.diagnostics = diagnostics;
this.invocationOrdinal = 0;
}
execute(actions, context = {}) {
if (!Array.isArray(actions)) throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Actions must be an array.');
const stream = this.engine.rng.stream('scenario', `actions:${++this.invocationOrdinal}`);
const results = [];
for (let index = 0; index < actions.length; index += 1) {
const action = actions[index];
try {
results.push(this.executeOne(action, stream, context, `$.actions[${index}]`));
} catch (error) {
const fault = error instanceof RuntimeFault ? error : new RuntimeFault('ERR_ACTION_FAILURE', error.message);
this.diagnostics?.error(fault.code, fault.message, { exhibitId: this.engine.document.meta.id, section: 'actions', objectId: action?.id ?? null, property: fault.path });
results.push({ status: 'failed', error: fault });
if (action?.critical !== false) throw fault;
}
}
return results;
}
executeOne(action, stream, context, path) {
if (!isRecord(action) || typeof action.type !== 'string') throw new RuntimeFault('ERR_SCHEMA_VALIDATION', 'Action requires a type.', path);
if (action.when !== undefined && !this.engine.evaluateCondition(action.when, stream, `${path}.when`)) return { status: 'skipped', reason: 'condition' };
if (action.chance !== undefined) {
if (!Number.isFinite(action.chance) || action.chance < 0 || action.chance > 1) throw new RuntimeFault('ERR_OUT_OF_BOUNDS', 'Action chance must be from 0 through 1.', `${path}.chance`);
if (stream.nextFloat() >= action.chance) return { status: 'skipped', reason: 'chance' };
}
if (action.type === 'set') {
const value = this.engine.evaluateValue(action.value, stream, `${path}.value`);
this.engine.setState(action.target, value, action.transition);
return { status: 'executed', type: 'set', target: action.target, value };
}
if (action.type === 'override') {
const value = this.engine.evaluateValue(action.value, stream, `${path}.value`);
const instanceId = this.engine.overrides.add(action, value, { owner: context.owner, inheritedPriority: context.priority });
this.engine.resolveAll();
return { status: 'executed', type: 'override', target: action.target, value, instanceId };
}
throw new RuntimeFault('ERR_UNSUPPORTED_TARGET', `Action '${action.type}' belongs to a later subsystem phase.`, `${path}.type`);
}
}
/* src/runtime/performance.js */
class CommonGrammarPerformance {
constructor(record, rootSeed, options = {}) {
this.record = record;
this.rootSeed = rootSeed;
this.rng = new SeededRNG(rootSeed);
this.diagnostics = options.diagnostics;
this.onUpdate = options.onUpdate ?? (() => {});
this.engine = new ResolutionEngine(record.document, this.rng, {
diagnostics: this.diagnostics,
initialParameters: options.initialParameters,
onParameterChange: options.onParameterChange
});
this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics });
this.state = 'prepared';
this.resources = new Set();
this.frameRequest = null;
this.lastFrame = undefined;
this.accumulator = 0;
this.boundFrame = (now) => this.frame(now);
this.boundPointer = (event) => {
this.engine.signals.set('signals.pointer.x', event.clientX);
this.engine.signals.set('signals.pointer.y', event.clientY);
this.engine.invalidate();
};
this.boundResize = () => {
this.engine.signals.set('signals.viewport.width', globalThis.innerWidth ?? 0);
this.engine.signals.set('signals.viewport.height', globalThis.innerHeight ?? 0);
this.engine.invalidate();
};
}
async activate() {
if (this.state !== 'prepared') throw new Error(`Cannot activate a performance in state ${this.state}.`);
this.state = 'active';
this.onUpdate(this.engine);
if (typeof globalThis.addEventListener === 'function') {
globalThis.addEventListener('pointermove', this.boundPointer, { passive: true });
globalThis.addEventListener('resize', this.boundResize, { passive: true });
}
if (typeof globalThis.requestAnimationFrame === 'function') this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
}
frame(now) {
if (this.state !== 'active') return;
if (this.lastFrame === undefined) this.lastFrame = now;
const observed = Math.max(0, now - this.lastFrame);
this.lastFrame = now;
const accepted = Math.min(observed, 250);
this.accumulator += accepted;
const step = 1000 / 60;
let ticks = 0;
while (this.accumulator + 1e-9 >= step && ticks < 8) {
this.engine.advance(step);
this.accumulator -= step;
ticks += 1;
}
if (observed > 250 || this.accumulator >= step) {
this.accumulator = Math.min(this.accumulator, step - 1e-9);
this.diagnostics?.warn('WARN_CLOCK_STALL', 'Discarded excess elapsed time to preserve the fixed-step work bound.', { exhibitId: this.record.id, section: 'scheduler' });
}
if (ticks > 0) this.onUpdate(this.engine);
this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame);
}
setParameter(id, value) {
const result = this.engine.setParameter(id, value);
this.onUpdate(this.engine);
return result;
}
execute(actionArray, context) {
const result = this.actions.execute(actionArray, context);
this.onUpdate(this.engine);
return result;
}
releaseOverride(id) {
const released = this.engine.overrides.beginRelease(id);
this.engine.resolveAll();
this.onUpdate(this.engine);
return released;
}
async deactivate() {
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
this.frameRequest = null;
this.lastFrame = undefined;
if (typeof globalThis.removeEventListener === 'function') {
globalThis.removeEventListener('pointermove', this.boundPointer);
globalThis.removeEventListener('resize', this.boundResize);
}
if (this.state === 'active') this.state = 'inactive';
this.engine.overrides.clear();
this.resources.clear();
}
async dispose() {
if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest);
this.frameRequest = null;
if (typeof globalThis.removeEventListener === 'function') {
globalThis.removeEventListener('pointermove', this.boundPointer);
globalThis.removeEventListener('resize', this.boundResize);
}
this.engine.dispose();
this.resources.clear();
this.state = 'disposed';
}
}
/* src/runtime/activation.js */
class InertPerformance {
constructor(record, rootSeed) {
this.record = record;
this.rootSeed = rootSeed;
this.rng = new SeededRNG(rootSeed);
this.state = 'prepared';
this.resources = new Set();
}
async activate() {
if (this.state !== 'prepared') throw new Error(`Cannot activate a performance in state ${this.state}.`);
this.state = 'active';
}
async deactivate() {
if (this.state === 'active') this.state = 'inactive';
this.resources.clear();
}
async dispose() {
this.resources.clear();
this.state = 'disposed';
}
}
class ActivationController {
constructor({ diagnostics, persistence, performanceFactory, cryptoProvider = globalThis.crypto } = {}) {
this.diagnostics = diagnostics;
this.persistence = persistence;
this.crypto = cryptoProvider;
this.performanceFactory = performanceFactory ?? ((record, seed) => new CommonGrammarPerformance(record, seed));
this.current = null;
}
prepare(record) {
const seed = resolveRootSeed(record.document.runtime?.seed ?? 'random', this.crypto);
const performance = this.performanceFactory(record, seed);
if (!performance || typeof performance.activate !== 'function' || typeof performance.deactivate !== 'function' || typeof performance.dispose !== 'function') {
throw new TypeError('Performance factory returned an invalid lifecycle object.');
}
return { record, seed, performance };
}
async activate(record) {
let candidate;
try {
candidate = this.prepare(record);
} catch (error) {
this.diagnostics?.error('ERR_ACTIVATION_PREPARE', `Could not prepare exhibit: ${error.message}`, { exhibitId: record.id, section: 'activation' });
return false;
}
const previous = this.current;
if (previous) {
try {
await previous.performance.deactivate('replacement');
await previous.performance.dispose();
} catch (error) {
await candidate.performance.dispose();
this.diagnostics?.error('ERR_DEACTIVATION', `Could not safely deactivate the current exhibit: ${error.message}`, { exhibitId: previous.record.id, section: 'activation' });
return false;
}
this.current = null;
}
try {
await candidate.performance.activate();
this.current = candidate;
try {
await this.persistence?.savePreference('lastExhibitId', record.id);
} catch (error) {
this.diagnostics?.warn('WARN_STORAGE_WRITE_FAILED', `The active selection could not be remembered: ${error.message}`, { exhibitId: record.id, section: 'persistence' });
}
this.diagnostics?.info('INFO_EXHIBIT_ACTIVATED', `${record.document.meta.name} activated with resolved seed ${candidate.seed}.`, { exhibitId: record.id, section: 'activation', property: 'runtime.seed' });
return true;
} catch (error) {
await candidate.performance.dispose();
this.diagnostics?.error('ERR_ACTIVATION', `Activation failed: ${error.message}`, { exhibitId: record.id, section: 'activation' });
if (previous) await this.recover(previous);
return false;
}
}
async recover(previous) {
try {
const recovery = this.prepare(previous.record);
await recovery.performance.activate();
this.current = recovery;
this.diagnostics?.warn('WARN_ACTIVATION_RECOVERED', `The previous exhibit was restarted after activation failure.`, { exhibitId: previous.record.id, section: 'activation' });
} catch (error) {
this.diagnostics?.error('ERR_ACTIVATION_RECOVERY', `The previous exhibit could not be restarted: ${error.message}`, { exhibitId: previous.record.id, section: 'activation' });
}
}
async deactivate() {
if (!this.current) return true;
const current = this.current;
this.current = null;
try {
await current.performance.deactivate('manual');
await current.performance.dispose();
this.diagnostics?.info('INFO_EXHIBIT_DEACTIVATED', `${current.record.document.meta.name} deactivated.`, { exhibitId: current.record.id, section: 'activation' });
return true;
} catch (error) {
this.diagnostics?.error('ERR_DEACTIVATION', `Exhibit teardown failed: ${error.message}`, { exhibitId: current.record.id, section: 'activation' });
return false;
}
}
}
/* src/runtime/app.js */
function element(id) { return document.getElementById(id); }
function text(tag, value, className) {
const node = document.createElement(tag);
node.textContent = value;
if (className) node.className = className;
return node;
}
class XZBTApplication {
constructor() {
this.diagnostics = new Diagnostics({ onChange: (entries) => this.renderDiagnostics(entries) });
this.persistence = new PersistenceManager({ diagnostics: this.diagnostics });
this.library = new LibraryManager({ persistence: this.persistence, diagnostics: this.diagnostics });
this.parameterValues = {};
this.activation = new ActivationController({
persistence: this.persistence,
diagnostics: this.diagnostics,
performanceFactory: (record, seed) => new CommonGrammarPerformance(record, seed, {
diagnostics: this.diagnostics,
initialParameters: this.parameterValues[record.id],
onParameterChange: (values) => this.rememberParameters(record.id, values),
onUpdate: (engine) => {
if (this.activation.current?.record.id === record.id) this.renderValues(engine);
}
})
});
this.busy = false;
}
async start() {
element('runtime-version').textContent = `Runtime ${XZBT_RUNTIME_VERSION}`;
this.bindEvents();
await this.persistence.open();
this.renderStorage();
try {
const snapshot = await this.persistence.loadSnapshot();
for (const [key, value] of Object.entries(snapshot.preferences)) {
if (key.startsWith('parameters:')) this.parameterValues[key.slice('parameters:'.length)] = value;
}
this.library.restore(snapshot.exhibits);
this.renderLibrary();
const last = this.library.get(snapshot.preferences.lastExhibitId);
if (last) await this.activate(last.id);
} catch (error) {
this.diagnostics.warn('WARN_STORAGE_READ_FAILED', `Cached exhibits could not be restored: ${error.message}`, { section: 'persistence' });
this.renderLibrary();
}
this.setStatus(this.activation.current ? 'Exhibit active' : (this.library.list().length ? 'Library ready' : 'Import exhibits to begin'));
}
bindEvents() {
element('import-files').addEventListener('change', async (event) => {
await this.importFiles([...event.target.files]);
event.target.value = '';
});
element('import-button').addEventListener('click', () => element('import-files').click());
element('deactivate-button').addEventListener('click', () => this.deactivate());
element('clear-diagnostics').addEventListener('click', () => this.diagnostics.clear());
const dropZone = element('drop-zone');
for (const type of ['dragenter', 'dragover']) dropZone.addEventListener(type, (event) => {
event.preventDefault();
dropZone.classList.add('is-dragging');
});
for (const type of ['dragleave', 'drop']) dropZone.addEventListener(type, (event) => {
event.preventDefault();
dropZone.classList.remove('is-dragging');
});
dropZone.addEventListener('drop', (event) => this.importFiles([...event.dataTransfer.files]));
window.addEventListener('unhandledrejection', (event) => {
this.diagnostics.error('ERR_RUNTIME', event.reason?.message ?? String(event.reason), { section: 'runtime' });
});
}
async importFiles(files) {
if (this.busy) return;
const exhibitFiles = files.filter((file) => file.name.toLowerCase().endsWith('.xzbt'));
if (!exhibitFiles.length) {
this.diagnostics.warn('WARN_IMPORT_EMPTY', 'Choose one or more files with the .xzbt extension.', { section: 'loader' });
return;
}
this.setBusy(true, `Importing ${exhibitFiles.length} exhibit${exhibitFiles.length === 1 ? '' : 's'}…`);
try {
for (const file of exhibitFiles) {
let source;
try {
source = await file.text();
} catch (error) {
this.diagnostics.error('ERR_IMPORT_READ', `Could not read ${file.name}: ${error.message}`, { section: 'loader' });
continue;
}
await this.library.importSource(source, file.name, {
confirmReplacement: async (existing, candidate) => window.confirm(
`${candidate.meta.name} has the same exhibit ID as ${existing.document.meta.name}, but different source bytes. Replace the cached exhibit?`
)
});
}
this.renderLibrary();
} finally {
this.setBusy(false, 'Library ready');
}
}
async activate(id) {
if (this.busy || this.activation.current?.record.id === id) return;
const record = this.library.get(id);
if (!record) return;
this.setBusy(true, `Preparing ${record.document.meta.name}…`);
try {
await this.activation.activate(record);
this.renderLibrary();
this.renderStage();
} finally {
this.setBusy(false, this.activation.current ? 'Exhibit active' : 'Library ready');
}
}
async deactivate() {
if (this.busy || !this.activation.current) return;
this.setBusy(true, 'Deactivating exhibit…');
try {
await this.activation.deactivate();
this.renderLibrary();
this.renderStage();
} finally {
this.setBusy(false, 'Library ready');
}
}
renderLibrary() {
const records = this.library.list();
const container = element('library-list');
container.replaceChildren();
element('empty-state').hidden = records.length > 0;
for (const record of records) {
const meta = record.document.meta;
const active = this.activation.current?.record.id === record.id;
const card = document.createElement('article');
card.className = `exhibit-card${active ? ' is-active' : ''}`;
card.append(text('h3', meta.name));
card.append(text('p', meta.description || 'Minimal XZBT 0.1 exhibit.', 'description'));
const details = [meta.author, meta.version && `v${meta.version}`, record.id].filter(Boolean).join(' · ');
card.append(text('p', details, 'meta'));
const button = text('button', active ? 'Active' : 'Activate');
button.type = 'button';
button.disabled = active || this.busy;
button.addEventListener('click', () => this.activate(record.id));
card.append(button);
container.append(card);
}
}
renderStage() {
const current = this.activation.current;
element('deactivate-button').disabled = !current || this.busy;
element('stage-empty').hidden = Boolean(current);
element('stage-active').hidden = !current;
if (!current) {
element('configuration').replaceChildren();
element('resolved-values').replaceChildren();
return;
}
const { meta } = current.record.document;
element('active-name').textContent = meta.name;
element('active-id').textContent = meta.id;
element('active-seed').textContent = String(current.seed);
const preview = current.performance.rng.stream('visual', 'runtime-preview:1');
element('active-sequence').textContent = [preview.nextUint32(), preview.nextUint32(), preview.nextUint32()].join(' · ');
this.renderConfiguration(current.performance);
this.renderValues(current.performance.engine);
}
renderConfiguration(performance) {
const container = element('configuration');
container.replaceChildren();
const definitions = performance.record.document.parameters ?? {};
if (Object.keys(definitions).length === 0) {
container.append(text('p', 'This exhibit declares no parameters.', 'empty'));
return;
}
for (const [id, spec] of Object.entries(definitions)) {
const path = `parameters.${id}`;
const row = document.createElement('div');
row.className = 'parameter-control';
const label = text('label', spec.label ?? id);
label.htmlFor = `parameter-${id}`;
const badge = text('span', 'overridden', 'override-badge');
badge.dataset.overrideFor = path;
badge.hidden = true;
label.append(badge);
const input = this.parameterInput(id, spec, performance.engine.parameters.get(id));
const update = () => {
try {
const value = spec.type === 'boolean' ? input.checked : (spec.type === 'number' || spec.type === 'integer' ? Number(input.value) : input.value);
performance.setParameter(id, value);
} catch (error) {
this.diagnostics.error(error.code ?? 'ERR_TYPE_MISMATCH', error.message, { exhibitId: performance.record.id, section: 'parameters', objectId: id });
}
};
input.addEventListener(spec.type === 'number' ? 'input' : 'change', update);
row.append(label, input);
const override = text('button', 'Temporary override');
override.type = 'button';
override.className = 'compact';
override.addEventListener('click', () => {
const value = this.demoOverrideValue(spec, performance.engine.parameters.get(id));
try {
performance.execute([{
type: 'override', target: path, value, scope: 'duration', duration: '3s',
transition: { in: spec.type === 'number' || spec.type === 'integer' ? '250ms' : '0ms', out: spec.type === 'number' || spec.type === 'integer' ? '750ms' : '0ms', easing: 'ease-in-out' }
}], { owner: 'phase2-ui', priority: 0 });
} catch {}
});
row.append(override);
container.append(row);
}
}
parameterInput(id, spec, value) {
let input;
if (spec.type === 'enum') {
input = document.createElement('select');
for (const optionValue of spec.values) {
const option = document.createElement('option');
option.value = optionValue;
option.textContent = optionValue;
input.append(option);
}
input.value = value;
} else {
input = document.createElement('input');
if (spec.type === 'boolean') { input.type = 'checkbox'; input.checked = value; }
else if (spec.type === 'color') { input.type = 'color'; input.value = value; }
else if (spec.type === 'number' || spec.type === 'integer') {
input.type = spec.min !== undefined && spec.max !== undefined ? 'range' : 'number';
if (spec.min !== undefined) input.min = String(spec.min);
if (spec.max !== undefined) input.max = String(spec.max);
input.step = String(spec.step ?? (spec.type === 'integer' ? 1 : 'any'));
input.value = String(value);
} else { input.type = 'text'; input.value = value; }
}
input.id = `parameter-${id}`;
input.dataset.parameter = id;
return input;
}
demoOverrideValue(spec, stored) {
if (spec.type === 'number' || spec.type === 'integer') return spec.max ?? (stored + 1);
if (spec.type === 'boolean') return !stored;
if (spec.type === 'enum') return spec.values[(spec.values.indexOf(stored) + 1) % spec.values.length];
if (spec.type === 'color') return stored.toLowerCase() === '#b8ff5a' ? '#ffca5c' : '#b8ff5a';
return `${stored}*`;
}
renderValues(engine) {
const container = element('resolved-values');
if (!container) return;
const snapshot = engine.snapshot();
container.replaceChildren();
for (const [path, value] of Object.entries(snapshot)) {
const row = document.createElement('li');
row.append(text('code', path), text('output', typeof value === 'number' ? value.toFixed(4).replace(/0+$/, '').replace(/\.$/, '') : String(value)));
container.append(row);
}
for (const badge of document.querySelectorAll('[data-override-for]')) badge.hidden = !engine.overrides.has(badge.dataset.overrideFor);
const first = Object.entries(engine.document.parameters ?? {})[0];
if (first) {
const [id, spec] = first;
const stored = engine.parameters.get(id);
const condition = (spec.type === 'number' || spec.type === 'integer')
? { op: 'gt', left: { ref: `parameters.${id}` }, right: ((spec.min ?? 0) + (spec.max ?? 1)) / 2 }
: { op: 'eq', left: { ref: `parameters.${id}` }, right: stored };
element('condition-result').textContent = String(engine.evaluateCondition(condition, engine.rng.stream('scenario', 'phase2-condition:1')));
} else element('condition-result').textContent = 'n/a';
}
rememberParameters(exhibitId, values) {
this.parameterValues[exhibitId] = values;
this.persistence.savePreference(`parameters:${exhibitId}`, values).catch((error) => {
this.diagnostics.warn('WARN_STORAGE_WRITE_FAILED', `Parameter changes remain session-only: ${error.message}`, { exhibitId, section: 'persistence' });
});
}
renderDiagnostics(entries) {
const container = element('diagnostic-list');
if (!container) return;
container.replaceChildren();
const newest = [...entries].reverse();
element('diagnostic-count').textContent = String(entries.length);
element('diagnostics-empty').hidden = newest.length > 0;
for (const entry of newest) {
const row = document.createElement('li');
row.className = `diagnostic ${entry.severity}`;
row.append(text('span', entry.severity.toUpperCase(), 'severity'));
row.append(text('code', entry.code));
row.append(text('span', entry.message, 'diagnostic-message'));
const context = [entry.exhibitId, entry.section, entry.objectId, entry.property].filter(Boolean).join(' ');
if (context) row.append(text('small', context));
container.append(row);
}
}
renderStorage() {
const node = element('storage-status');
node.textContent = this.persistence.available ? 'Local cache available' : 'Session only';
node.className = this.persistence.available ? 'storage-ready' : 'storage-warning';
}
setBusy(busy, status) {
this.busy = busy;
element('import-button').disabled = busy;
element('deactivate-button').disabled = busy || !this.activation.current;
this.setStatus(status);
this.renderLibrary();
}
setStatus(value) { element('app-status').textContent = value; }
}
async function bootstrap() {
const application = new XZBTApplication();
globalThis.XZBT = Object.freeze({ application });
await application.start();
}
void bootstrap();
})();</script>
</body>
</html>