motionloom 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,44 +2,376 @@ const params = new URLSearchParams(location.search);
2
2
  const scene = params.get("scene");
3
3
  const taskId = params.get("task_id") || "unbound";
4
4
  const candidateId = params.get("candidate_id");
5
- const base = `/scenes/${encodeURIComponent(scene || "")}`;
6
- const frames = [0, 50, 100];
7
5
  const allowedStatuses = new Set(["prepared", "opened"]);
8
6
  const $ = (id) => document.getElementById(id);
7
+
9
8
  let manifest;
10
9
  let spec;
11
10
  let candidate;
11
+ let runtimeDescriptor = null;
12
12
  let review;
13
+ let driver;
14
+ let currentAnimationId = null;
15
+ let zoom = 1;
16
+ let pollTimer = null;
17
+ const inspectedAnimations = new Set();
18
+ const runtimeUi = { playing: false, loop: false, speed: 1, progress: 0, state: {} };
19
+
20
+ function safeSceneBase() {
21
+ if (!scene || !/^[A-Za-z0-9._-]+$/.test(scene)) throw new Error("Scene name is missing or unsafe");
22
+ const expectedPath = `/scenes/${encodeURIComponent(scene)}`;
23
+ const raw = params.get("artifact_base");
24
+ if (!raw) return expectedPath;
25
+ const url = new URL(raw, location.href);
26
+ if (url.origin !== location.origin) throw new Error("Cross-origin artifact_base is not allowed");
27
+ if (url.search || url.hash) throw new Error("artifact_base must not include query or fragment");
28
+ const decoded = decodeURIComponent(url.pathname).replace(/\/+$/, "");
29
+ if (decoded !== `/scenes/${scene}`) throw new Error("artifact_base does not match the selected scene");
30
+ return url.pathname.replace(/\/+$/, "");
31
+ }
32
+
33
+ const base = safeSceneBase();
34
+
35
+ function safeRelativePath(value, label = "path") {
36
+ if (typeof value !== "string" || !value || value.startsWith("/") || value.includes("\\")) {
37
+ throw new Error(`${label} must be a scene-relative path`);
38
+ }
39
+ const parts = value.split("/");
40
+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
41
+ throw new Error(`${label} contains unsafe path segments`);
42
+ }
43
+ return value;
44
+ }
13
45
 
14
- function nearest(progress) {
15
- return frames.reduce(
16
- (current, frame) => Math.abs(frame - progress) < Math.abs(current - progress) ? frame : current,
17
- frames[0],
18
- );
46
+ function sceneUrl(relative) {
47
+ return `${base}/${safeRelativePath(relative)}`;
19
48
  }
20
49
 
21
- function showProgress(progress) {
22
- const bounded = Math.max(0, Math.min(1, progress));
23
- const percent = bounded * 100;
24
- const frame = nearest(percent);
25
- $("scrubber").value = percent;
26
- $("timecode").textContent = `00:00:${(Number(spec?.duration_s || 0) * bounded).toFixed(3).padStart(6, "0")}`;
27
- $("frame").src = `${base}/snapshot/frame-${String(frame).padStart(2, "0")}.png`;
28
- $("frame").hidden = false;
29
- $("empty").hidden = true;
30
- window.__lab.lastProgress = bounded;
50
+ function clamp(value, min = 0, max = 1) {
51
+ return Math.max(min, Math.min(max, Number(value) || 0));
52
+ }
53
+
54
+ function formatTime(seconds) {
55
+ const value = Math.max(0, Number(seconds) || 0);
56
+ const minutes = Math.floor(value / 60);
57
+ const secs = Math.floor(value % 60);
58
+ const millis = Math.floor((value - Math.floor(value)) * 1000);
59
+ return `${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}.${String(millis).padStart(3, "0")}`;
60
+ }
61
+
62
+ async function fetchJson(url, { optional = false } = {}) {
63
+ const response = await fetch(url, { cache: "no-store" });
64
+ if (optional && response.status === 404) return null;
65
+ if (!response.ok) throw new Error(`Could not load ${url} (${response.status})`);
66
+ return response.json();
67
+ }
68
+
69
+ function validateCandidate() {
70
+ if (candidate.candidate_id !== candidateId) throw new Error("Candidate identity mismatch; refusing to review a different candidate");
71
+ if (candidate.task_id !== taskId) throw new Error("Task identity mismatch; refusing unbound review");
72
+ if (candidate.scene !== scene) throw new Error("Scene identity mismatch; refusing mixed evidence");
73
+ if (!allowedStatuses.has(candidate.status)) throw new Error(`Candidate status is not reviewable: ${candidate.status || "missing"}`);
74
+ const expiresAt = Date.parse(candidate.expires_at);
75
+ if (!Number.isFinite(expiresAt)) throw new Error("Candidate expiry is missing or invalid");
76
+ if (Date.now() > expiresAt) throw new Error("Candidate has expired; prepare a new browser-review URL");
77
+ }
78
+
79
+ function validateRuntimeDescriptor(value) {
80
+ if (!value) return null;
81
+ if (candidate.runtime_review?.live !== true) throw new Error("Live runtime descriptor is not bound to this browser-review candidate");
82
+ if (value.schema_version !== "1.0") throw new Error("Unsupported devlab-runtime schema version");
83
+ if (!new Set(["sprite-sequence", "iframe"]).has(value.mode)) throw new Error("Unsupported Dev Lab runtime mode");
84
+ if (!Array.isArray(value.files) || value.files.length === 0) throw new Error("Live runtime descriptor requires declared files");
85
+ const files = new Set(value.files.map((item) => safeRelativePath(item, "runtime file")));
86
+ if (files.size !== value.files.length) throw new Error("Live runtime descriptor contains duplicate files");
87
+ if (!Array.isArray(value.animations) || value.animations.length === 0) throw new Error("Live runtime descriptor requires at least one animation");
88
+ const animationIds = new Set();
89
+ for (const animation of value.animations) {
90
+ const id = animation?.id;
91
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id || "")) throw new Error("Live runtime animation id is invalid");
92
+ if (animationIds.has(id)) throw new Error(`Duplicate live runtime animation id: ${id}`);
93
+ animationIds.add(id);
94
+ if (value.mode === "sprite-sequence") {
95
+ if (!Array.isArray(animation.frames) || animation.frames.length === 0) throw new Error(`Sprite animation ${id} has no frames`);
96
+ if (!(Number(animation.fps) > 0)) throw new Error(`Sprite animation ${id} requires a positive fps`);
97
+ for (const frame of animation.frames) {
98
+ const path = safeRelativePath(frame, `frame for ${id}`);
99
+ if (!files.has(path)) throw new Error(`Frame ${path} is not declared in runtime files`);
100
+ }
101
+ }
102
+ }
103
+ if (!animationIds.has(value.default_animation)) throw new Error("default_animation does not exist in live runtime animations");
104
+ if (value.mode === "iframe") {
105
+ const entrypoint = safeRelativePath(value.entrypoint, "runtime entrypoint");
106
+ if (!files.has(entrypoint)) throw new Error("Iframe entrypoint is not declared in runtime files");
107
+ }
108
+ const candidateRuntime = candidate.runtime_review;
109
+ if (candidateRuntime?.live === true) {
110
+ if (candidateRuntime.descriptor !== "devlab-runtime.json") throw new Error("Candidate runtime descriptor binding is unexpected");
111
+ if (candidateRuntime.mode && candidateRuntime.mode !== value.mode) throw new Error("Candidate runtime mode does not match descriptor");
112
+ const boundIds = Array.isArray(candidateRuntime.animations) ? candidateRuntime.animations : [];
113
+ if (boundIds.length && boundIds.join("\0") !== value.animations.map((item) => item.id).join("\0")) {
114
+ throw new Error("Candidate runtime animation set does not match descriptor");
115
+ }
116
+ }
117
+ return value;
118
+ }
119
+
120
+ function controlEnabled(name, capability = true) {
121
+ if (!runtimeDescriptor) return false;
122
+ return runtimeDescriptor.controls?.[name] !== false && capability !== false;
123
+ }
124
+
125
+ class SnapshotDriver {
126
+ constructor() {
127
+ this.mode = "captured-evidence";
128
+ this.frames = Array.isArray(candidate.checkpoints) && candidate.checkpoints.length ? candidate.checkpoints : [0, 50, 100];
129
+ this.currentProgress = 0;
130
+ this.animation = { id: "captured-evidence", label: "Captured evidence", fps: Number(spec.fps || 0), loop: false, review_required: true };
131
+ }
132
+ listAnimations() { return [this.animation]; }
133
+ async selectAnimation() { currentAnimationId = this.animation.id; return this.getState(); }
134
+ nearest(percent) { return this.frames.reduce((best, frame) => Math.abs(frame - percent) < Math.abs(best - percent) ? frame : best, this.frames[0]); }
135
+ async seek(progress) {
136
+ this.currentProgress = clamp(progress);
137
+ const checkpoint = this.nearest(this.currentProgress * 100);
138
+ $("frame").src = `${base}/snapshot/frame-${String(checkpoint).padStart(2, "0")}.png`;
139
+ $("frame").hidden = false;
140
+ $("runtime-frame").hidden = true;
141
+ $("empty").hidden = true;
142
+ return this.getState();
143
+ }
144
+ async pause() { return this.getState(); }
145
+ getState() {
146
+ const duration = Number(spec.duration_s || 0);
147
+ const totalFrames = Number(spec.total_frames || Math.round(duration * Number(spec.fps || 0))) || 0;
148
+ return {
149
+ mode: this.mode,
150
+ playing: false,
151
+ progress: this.currentProgress,
152
+ currentTime: duration * this.currentProgress,
153
+ duration,
154
+ frame: totalFrames ? Math.round((totalFrames - 1) * this.currentProgress) : null,
155
+ totalFrames: totalFrames || null,
156
+ animation: this.animation.id,
157
+ evidenceOnly: true
158
+ };
159
+ }
160
+ }
161
+
162
+ class SpriteSequenceDriver {
163
+ constructor(descriptor, onUpdate) {
164
+ this.mode = "live-runtime";
165
+ this.descriptor = descriptor;
166
+ this.animations = descriptor.animations;
167
+ this.onUpdate = onUpdate;
168
+ this.animation = this.animations.find((item) => item.id === descriptor.default_animation) || this.animations[0];
169
+ this.frameIndex = 0;
170
+ this.playing = false;
171
+ this.speed = 1;
172
+ this.loop = Boolean(this.animation.loop);
173
+ this.raf = null;
174
+ this.lastTimestamp = null;
175
+ $("frame").classList.toggle("pixelated", descriptor.viewport?.pixel_art === true);
176
+ }
177
+ listAnimations() { return this.animations; }
178
+ async selectAnimation(id) {
179
+ const next = this.animations.find((item) => item.id === id);
180
+ if (!next) throw new Error(`Unknown animation: ${id}`);
181
+ this.pause();
182
+ this.animation = next;
183
+ this.frameIndex = 0;
184
+ this.loop = Boolean(next.loop);
185
+ this.render();
186
+ return this.getState();
187
+ }
188
+ duration() { return Number(this.animation.duration_s) || this.animation.frames.length / Number(this.animation.fps); }
189
+ render() {
190
+ const frames = this.animation.frames;
191
+ this.frameIndex = Math.max(0, Math.min(frames.length - 1, this.frameIndex));
192
+ const displayIndex = Math.max(0, Math.min(frames.length - 1, Math.floor(this.frameIndex)));
193
+ $("frame").src = sceneUrl(frames[displayIndex]);
194
+ $("frame").hidden = false;
195
+ $("runtime-frame").hidden = true;
196
+ $("empty").hidden = true;
197
+ this.onUpdate?.(this.getState());
198
+ }
199
+ async seek(progress) {
200
+ const p = clamp(progress);
201
+ this.frameIndex = Math.round(p * Math.max(0, this.animation.frames.length - 1));
202
+ this.render();
203
+ return this.getState();
204
+ }
205
+ async play() {
206
+ if (this.playing) return this.getState();
207
+ this.playing = true;
208
+ this.lastTimestamp = null;
209
+ const tick = (timestamp) => {
210
+ if (!this.playing) return;
211
+ if (this.lastTimestamp == null) this.lastTimestamp = timestamp;
212
+ const elapsed = Math.max(0, timestamp - this.lastTimestamp) / 1000;
213
+ this.lastTimestamp = timestamp;
214
+ const frameAdvance = elapsed * Number(this.animation.fps) * this.speed;
215
+ this.frameIndex += frameAdvance;
216
+ const count = this.animation.frames.length;
217
+ if (this.frameIndex >= count) {
218
+ if (this.loop) this.frameIndex %= count;
219
+ else { this.frameIndex = count - 1; this.playing = false; }
220
+ }
221
+ this.render();
222
+ if (this.playing) this.raf = requestAnimationFrame(tick);
223
+ };
224
+ this.raf = requestAnimationFrame(tick);
225
+ this.onUpdate?.(this.getState());
226
+ return this.getState();
227
+ }
228
+ async pause() {
229
+ this.playing = false;
230
+ this.lastTimestamp = null;
231
+ if (this.raf) cancelAnimationFrame(this.raf);
232
+ this.raf = null;
233
+ this.onUpdate?.(this.getState());
234
+ return this.getState();
235
+ }
236
+ async restart() { await this.pause(); this.frameIndex = 0; this.render(); return this.getState(); }
237
+ async stepFrames(delta) { await this.pause(); this.frameIndex += Number(delta || 0); this.render(); return this.getState(); }
238
+ async setSpeed(rate) { this.speed = Math.max(0.05, Number(rate) || 1); this.onUpdate?.(this.getState()); return this.getState(); }
239
+ async setLoop(enabled) { this.loop = Boolean(enabled); this.onUpdate?.(this.getState()); return this.getState(); }
240
+ getState() {
241
+ const frames = this.animation.frames.length;
242
+ const duration = this.duration();
243
+ const frame = Math.max(0, Math.min(frames - 1, Math.floor(this.frameIndex)));
244
+ const progress = frames <= 1 ? 0 : frame / (frames - 1);
245
+ return {
246
+ mode: this.mode,
247
+ runtime: "sprite-sequence",
248
+ animation: this.animation.id,
249
+ playing: this.playing,
250
+ progress,
251
+ frame,
252
+ totalFrames: frames,
253
+ currentTime: frame / Number(this.animation.fps),
254
+ duration,
255
+ fps: Number(this.animation.fps),
256
+ speed: this.speed,
257
+ loop: this.loop
258
+ };
259
+ }
260
+ dispose() { this.pause(); }
261
+ }
262
+
263
+ class IframeDriver {
264
+ constructor(descriptor, onUpdate) {
265
+ this.mode = "live-runtime";
266
+ this.descriptor = descriptor;
267
+ this.onUpdate = onUpdate;
268
+ this.pending = new Map();
269
+ this.counter = 0;
270
+ this.capabilities = {};
271
+ this.animations = descriptor.animations;
272
+ this.animation = descriptor.default_animation;
273
+ this.state = {};
274
+ this.listener = (event) => this.onMessage(event);
275
+ window.addEventListener("message", this.listener);
276
+ }
277
+ onMessage(event) {
278
+ if (event.source !== $("runtime-frame").contentWindow) return;
279
+ const data = event.data;
280
+ if (!data || data.source !== "motionloom-runtime") return;
281
+ if (data.event === "attached") return;
282
+ const pending = this.pending.get(data.id);
283
+ if (!pending) return;
284
+ this.pending.delete(data.id);
285
+ clearTimeout(pending.timer);
286
+ if (data.ok) pending.resolve(data.result);
287
+ else pending.reject(new Error(data.error || "Runtime bridge command failed"));
288
+ }
289
+ rpc(command, payload = {}, timeout = 3500) {
290
+ const frameWindow = $("runtime-frame").contentWindow;
291
+ if (!frameWindow) return Promise.reject(new Error("Runtime iframe is not available"));
292
+ const id = `ml-${Date.now()}-${++this.counter}`;
293
+ return new Promise((resolve, reject) => {
294
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Runtime command timed out: ${command}`)); }, timeout);
295
+ this.pending.set(id, { resolve, reject, timer });
296
+ frameWindow.postMessage({ source: "motionloom-devlab", id, command, payload }, "*");
297
+ });
298
+ }
299
+ async mount() {
300
+ const frame = $("runtime-frame");
301
+ frame.hidden = false;
302
+ $("frame").hidden = true;
303
+ $("empty").hidden = true;
304
+ await new Promise((resolve, reject) => {
305
+ const timer = setTimeout(() => reject(new Error("Live runtime entrypoint did not load")), 6000);
306
+ frame.addEventListener("load", () => { clearTimeout(timer); resolve(); }, { once: true });
307
+ frame.src = sceneUrl(this.descriptor.entrypoint);
308
+ });
309
+ let handshake;
310
+ for (let attempt = 0; attempt < 20; attempt += 1) {
311
+ try { handshake = await this.rpc("handshake", {}, 800); break; }
312
+ catch { await new Promise((resolve) => setTimeout(resolve, 100)); }
313
+ }
314
+ if (!handshake?.ready) throw new Error("Live runtime bridge did not become ready");
315
+ this.capabilities = handshake.capabilities || {};
316
+ if (Array.isArray(handshake.animations) && handshake.animations.length) {
317
+ const allowed = new Set(this.descriptor.animations.map((item) => item.id));
318
+ const runtimeIds = handshake.animations.map((item) => item.id).filter((id) => allowed.has(id));
319
+ if (runtimeIds.length) this.animations = this.descriptor.animations.filter((item) => runtimeIds.includes(item.id));
320
+ }
321
+ this.state = handshake.state || {};
322
+ await this.selectAnimation(this.descriptor.default_animation);
323
+ this.onUpdate?.(this.getState());
324
+ return this.getState();
325
+ }
326
+ listAnimations() { return this.animations; }
327
+ async selectAnimation(id) {
328
+ if (!this.animations.find((item) => item.id === id)) throw new Error(`Unknown animation: ${id}`);
329
+ if (this.capabilities.selectAnimation) await this.rpc("selectAnimation", { id });
330
+ this.animation = id;
331
+ await this.refresh();
332
+ return this.getState();
333
+ }
334
+ async play() { if (!this.capabilities.play) throw new Error("Runtime does not support play"); await this.rpc("play"); return this.refresh(); }
335
+ async pause() { if (!this.capabilities.pause) throw new Error("Runtime does not support pause"); await this.rpc("pause"); return this.refresh(); }
336
+ async restart() { if (!this.capabilities.restart) throw new Error("Runtime does not support restart"); await this.rpc("restart"); return this.refresh(); }
337
+ async seek(progress) { if (!this.capabilities.seek) throw new Error("Runtime does not support seek"); await this.rpc("seek", { progress: clamp(progress) }); return this.refresh(); }
338
+ async stepFrames(delta) { if (!this.capabilities.step) throw new Error("Runtime does not support frame stepping"); await this.rpc("stepFrames", { delta }); return this.refresh(); }
339
+ async setSpeed(rate) { if (!this.capabilities.speed) throw new Error("Runtime does not support speed control"); await this.rpc("setSpeed", { rate }); return this.refresh(); }
340
+ async setLoop(enabled) { if (!this.capabilities.loop) throw new Error("Runtime does not support loop control"); await this.rpc("setLoop", { enabled }); return this.refresh(); }
341
+ async refresh() {
342
+ if (this.capabilities.state) {
343
+ try { this.state = await this.rpc("getState", {}, 1200) || {}; } catch { /* keep last known state */ }
344
+ }
345
+ this.onUpdate?.(this.getState());
346
+ return this.getState();
347
+ }
348
+ getState() { return { mode: this.mode, runtime: "iframe", animation: this.animation, ...this.state }; }
349
+ dispose() {
350
+ window.removeEventListener("message", this.listener);
351
+ for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("Runtime driver disposed")); }
352
+ this.pending.clear();
353
+ }
31
354
  }
32
355
 
33
356
  function currentChecks() {
34
- return [...document.querySelectorAll("[data-check]")].map((element) => ({
35
- id: element.dataset.check,
36
- pass: element.checked,
37
- }));
357
+ return [...document.querySelectorAll("[data-check]")].map((element) => ({ id: element.dataset.check, pass: element.checked }));
358
+ }
359
+
360
+ function requiredAnimationIds() {
361
+ if (!runtimeDescriptor) return [];
362
+ return runtimeDescriptor.animations.filter((item) => item.review_required !== false).map((item) => item.id);
363
+ }
364
+
365
+ function reviewCoverageComplete() {
366
+ const required = requiredAnimationIds();
367
+ if (!runtimeDescriptor?.review_policy?.require_all_animations) return true;
368
+ return required.every((id) => inspectedAnimations.has(id));
38
369
  }
39
370
 
40
371
  function currentReview(decision) {
372
+ const state = driver?.getState?.() || runtimeUi.state || {};
41
373
  return {
42
- review_version: "1.0",
374
+ review_version: "1.1",
43
375
  task_id: taskId,
44
376
  candidate_id: candidate.candidate_id,
45
377
  scene,
@@ -48,16 +380,16 @@ function currentReview(decision) {
48
380
  reviewed_at: new Date().toISOString(),
49
381
  checks: currentChecks(),
50
382
  notes: $("notes").value,
51
- frames_inspected: frames,
52
- spec: {
53
- framework: spec.framework,
54
- category: spec.category,
55
- context_binding: spec.context_binding,
56
- },
57
- candidate: {
58
- source_sha256: candidate.source_sha256,
59
- context_sha256: candidate.context_sha256,
383
+ frames_inspected: Array.isArray(candidate.checkpoints) ? candidate.checkpoints : [0, 50, 100],
384
+ animations_inspected: [...inspectedAnimations],
385
+ runtime_review: {
386
+ mode: runtimeDescriptor ? "live-runtime" : "captured-evidence",
387
+ descriptor_sha256: candidate.runtime_review?.bundle_sha256 || null,
388
+ selected_animation: currentAnimationId,
389
+ state
60
390
  },
391
+ spec: { framework: spec.framework, category: spec.category, context_binding: spec.context_binding },
392
+ candidate: { source_sha256: candidate.source_sha256, context_sha256: candidate.context_sha256 }
61
393
  };
62
394
  }
63
395
 
@@ -71,123 +403,320 @@ function expose(payload) {
71
403
  window.__lab.exportReview = () => JSON.stringify(window.__lab.lastReview, null, 2);
72
404
  }
73
405
 
74
- async function fetchJson(url) {
75
- const response = await fetch(url);
76
- if (!response.ok) throw new Error(`Could not load ${url} (${response.status})`);
77
- return response.json();
78
- }
79
-
80
- function validateCandidate() {
81
- if (candidate.candidate_id !== candidateId) {
82
- throw new Error("Candidate identity mismatch; refusing to review a different scene");
83
- }
84
- if (candidate.task_id !== taskId) {
85
- throw new Error("Task identity mismatch; refusing unbound review");
86
- }
87
- if (!allowedStatuses.has(candidate.status)) {
88
- throw new Error(`Candidate status is not reviewable: ${candidate.status || "missing"}`);
89
- }
90
- const expiresAt = Date.parse(candidate.expires_at);
91
- if (!Number.isFinite(expiresAt)) {
92
- throw new Error("Candidate expiry is missing or invalid");
93
- }
94
- if (Date.now() > expiresAt) {
95
- throw new Error("Candidate has expired; prepare a new browser-review URL");
96
- }
406
+ function setStatus(kind, text) {
407
+ $("status").className = kind;
408
+ $("status").textContent = text;
97
409
  }
98
410
 
99
411
  function renderMetadata() {
100
412
  const values = [
101
- ["TASK", taskId],
102
- ["CANDIDATE", candidateId],
103
- ["FRAMEWORK", spec.framework],
104
- ["DURATION", `${spec.duration_s}s`],
105
- ["FPS", spec.fps],
106
- ["EASING", spec.easing],
107
- ["PRIMARY", spec.theme?.primary || "unbound"],
413
+ ["TASK", taskId], ["CANDIDATE", candidateId], ["FRAMEWORK", spec.framework],
414
+ ["CATEGORY", spec.category], ["DURATION", `${spec.duration_s ?? "—"}s`], ["FPS", spec.fps ?? "—"],
415
+ ["RUNTIME", runtimeDescriptor ? runtimeDescriptor.mode : "captured evidence"], ["SOURCE", String(candidate.source_sha256 || "").slice(0, 12) || "—"]
108
416
  ];
109
- const nodes = values.map(([key, value]) => {
110
- const pill = document.createElement("div");
111
- pill.className = "pill";
112
- const label = document.createElement("b");
113
- label.textContent = key;
114
- pill.append(label, document.createTextNode(` ${value ?? "—"}`));
115
- return pill;
116
- });
117
- $("meta").replaceChildren(...nodes);
417
+ $("meta").replaceChildren(...values.map(([key, value]) => {
418
+ const pill = document.createElement("div"); pill.className = "pill";
419
+ const label = document.createElement("b"); label.textContent = key;
420
+ const text = document.createElement("span"); text.textContent = value ?? "—";
421
+ pill.append(label, text); return pill;
422
+ }));
118
423
  }
119
424
 
120
425
  function renderChecks() {
121
426
  const nodes = (manifest.checks || []).map((check) => {
122
- const label = document.createElement("label");
123
- label.className = "check";
124
- const input = document.createElement("input");
125
- input.type = "checkbox";
126
- input.dataset.check = String(check.id || "");
127
- input.checked = Boolean(review.checks.find((item) => item.id === check.id)?.pass);
427
+ const label = document.createElement("label"); label.className = "check";
428
+ const input = document.createElement("input"); input.type = "checkbox"; input.dataset.check = String(check.id || "");
429
+ input.checked = Boolean(review.checks?.find((item) => item.id === check.id)?.pass);
430
+ input.addEventListener("change", updateReviewGate);
128
431
  const copy = document.createElement("span");
129
- const title = document.createElement("b");
130
- const detail = document.createElement("small");
131
- title.textContent = check.label || check.id || "Unnamed check";
132
- detail.textContent = check.detail || "";
133
- copy.append(title, detail);
134
- label.append(input, copy);
135
- return label;
432
+ const title = document.createElement("b"); title.textContent = check.label || check.id || "Unnamed check";
433
+ const detail = document.createElement("small"); detail.textContent = check.detail || "";
434
+ copy.append(title, detail); label.append(input, copy); return label;
136
435
  });
137
436
  $("checks").replaceChildren(...nodes);
138
437
  }
139
438
 
140
- function confirmReview() {
439
+ function renderAnimations() {
440
+ const animations = driver.listAnimations();
441
+ if (!animations.length) { $("animations").textContent = "No runtime actions declared."; return; }
442
+ const nodes = animations.map((animation) => {
443
+ const button = document.createElement("button"); button.type = "button"; button.className = "animation-btn";
444
+ button.dataset.animation = animation.id;
445
+ const dot = document.createElement("span"); dot.className = "animation-dot";
446
+ const name = document.createElement("span"); name.className = "animation-name"; name.textContent = animation.label || animation.id;
447
+ const meta = document.createElement("span"); meta.className = "animation-meta";
448
+ if (animation.fps && animation.frames) meta.textContent = `${animation.frames.length}f · ${animation.fps}fps`;
449
+ else meta.textContent = animation.loop ? "loop" : "clip";
450
+ button.append(dot, name, meta);
451
+ button.addEventListener("click", () => selectAnimation(animation.id));
452
+ return button;
453
+ });
454
+ $("animations").replaceChildren(...nodes);
455
+ updateAnimationSelection();
456
+ }
457
+
458
+ function updateAnimationSelection() {
459
+ for (const button of document.querySelectorAll(".animation-btn")) {
460
+ button.classList.toggle("selected", button.dataset.animation === currentAnimationId);
461
+ button.classList.toggle("visited", inspectedAnimations.has(button.dataset.animation));
462
+ }
463
+ const required = requiredAnimationIds();
464
+ if (runtimeDescriptor && required.length) {
465
+ const inspected = required.filter((id) => inspectedAnimations.has(id)).length;
466
+ $("review-progress").textContent = `Review coverage ${inspected}/${required.length}`;
467
+ } else {
468
+ $("review-progress").textContent = runtimeDescriptor ? "No mandatory action coverage." : "Legacy snapshot compatibility mode.";
469
+ }
470
+ updateReviewGate();
471
+ }
472
+
473
+ function updateReviewGate() {
474
+ const checks = currentChecks();
475
+ const checksPass = checks.length > 0 && checks.every((item) => item.pass);
476
+ const coverage = reviewCoverageComplete();
477
+ const liveRuntimeBlocked = candidate?.runtime_review?.live === true && driver instanceof SnapshotDriver;
478
+ $("confirm").disabled = !(checksPass && coverage && !liveRuntimeBlocked);
479
+ if (liveRuntimeBlocked) {
480
+ setStatus("warn", "LIVE RUNTIME UNAVAILABLE · approval is blocked; captured evidence remains inspectable and changes may still be requested.");
481
+ return;
482
+ }
483
+ const missing = requiredAnimationIds().filter((id) => !inspectedAnimations.has(id));
484
+ if (!coverage && missing.length) setStatus("info", `Inspect required animations before approval: ${missing.join(", ")}`);
485
+ }
486
+
487
+ function applyViewportOverlays() {
488
+ const viewport = runtimeDescriptor?.viewport || {};
489
+ if (viewport.background && ["checker", "dark", "light", "transparent", "project"].includes(viewport.background)) {
490
+ $("background").value = viewport.background;
491
+ setBackground(viewport.background);
492
+ }
493
+ const canvasHeight = Number(viewport.canvas_height);
494
+ const baselineY = Number(viewport.baseline_y);
495
+ if (canvasHeight > 0 && Number.isFinite(baselineY)) $("overlay-baseline").style.top = `${clamp(baselineY / canvasHeight) * 100}%`;
496
+ const pivot = viewport.pivot;
497
+ const canvasWidth = Number(viewport.canvas_width);
498
+ if (pivot && canvasWidth > 0 && canvasHeight > 0) {
499
+ $("overlay-pivot").style.left = `${clamp(Number(pivot.x) / canvasWidth) * 100}%`;
500
+ $("overlay-pivot").style.top = `${clamp(Number(pivot.y) / canvasHeight) * 100}%`;
501
+ }
502
+ }
503
+
504
+ function setBackground(value) {
505
+ for (const kind of ["checker", "dark", "light", "transparent", "project"]) $("stage").classList.toggle(`bg-${kind}`, value === kind);
506
+ }
507
+
508
+ function toggleTool(id, overlayId) {
509
+ const on = !$(overlayId).classList.contains("on");
510
+ $(overlayId).classList.toggle("on", on); $(id).classList.toggle("active", on);
511
+ }
512
+
513
+ function setZoom(value) {
514
+ zoom = Math.max(0.25, Math.min(4, Number(value) || 1));
515
+ $("stage-content").style.transform = `scale(${zoom})`;
516
+ $("zoom-label").textContent = `${Math.round(zoom * 100)}%`;
517
+ }
518
+
519
+ function updateTransportState(state = {}) {
520
+ runtimeUi.state = state;
521
+ const progress = clamp(state.progress ?? runtimeUi.progress ?? 0);
522
+ runtimeUi.progress = progress;
523
+ runtimeUi.playing = Boolean(state.playing);
524
+ runtimeUi.loop = state.loop ?? runtimeUi.loop;
525
+ runtimeUi.speed = Number(state.speed ?? runtimeUi.speed ?? 1);
526
+ $("scrubber").value = Math.round(progress * 1000);
527
+ $("progress-label").textContent = `${(progress * 100).toFixed(1)}%`;
528
+ const currentTime = Number(state.currentTime ?? (Number(state.duration || spec.duration_s || 0) * progress));
529
+ const duration = Number(state.duration ?? spec.duration_s ?? 0);
530
+ $("timecode").textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`;
531
+ const frame = state.frame;
532
+ const totalFrames = state.totalFrames;
533
+ $("framecode").textContent = Number.isFinite(frame) && Number.isFinite(totalFrames) ? `Frame ${frame + 1} / ${totalFrames}` : "Frame —";
534
+ $("play").classList.toggle("active", Boolean(state.playing));
535
+ $("loop").classList.toggle("active", Boolean(runtimeUi.loop));
536
+ if ([0.25, 0.5, 1, 2].includes(runtimeUi.speed)) $("speed").value = String(runtimeUi.speed);
537
+ $("runtime-state").textContent = JSON.stringify(state, null, 2);
538
+ window.__lab.lastProgress = progress;
539
+ window.__lab.runtimeState = state;
540
+ }
541
+
542
+ function configureControls() {
543
+ const caps = driver instanceof IframeDriver ? driver.capabilities : {};
544
+ const snapshot = driver instanceof SnapshotDriver;
545
+ $("play").disabled = snapshot || !controlEnabled("play", caps.play);
546
+ $("pause").disabled = snapshot || !controlEnabled("pause", caps.pause);
547
+ $("restart").disabled = snapshot || !controlEnabled("restart", caps.restart);
548
+ $("step-back").disabled = snapshot || !controlEnabled("step", caps.step);
549
+ $("step-forward").disabled = snapshot || !controlEnabled("step", caps.step);
550
+ $("speed").disabled = snapshot || !controlEnabled("speed", caps.speed);
551
+ $("loop").disabled = snapshot || !controlEnabled("loop", caps.loop);
552
+ $("scrubber").disabled = runtimeDescriptor ? !controlEnabled("seek", caps.seek) : false;
553
+ }
554
+
555
+ async function selectAnimation(id) {
556
+ try {
557
+ await driver.selectAnimation(id);
558
+ currentAnimationId = id;
559
+ inspectedAnimations.add(id);
560
+ if (review?.animations_inspected) for (const item of review.animations_inspected) inspectedAnimations.add(item);
561
+ updateAnimationSelection();
562
+ updateTransportState(driver.getState());
563
+ } catch (error) { showRuntimeError(error); }
564
+ }
565
+
566
+ function showRuntimeError(error) {
567
+ const message = error instanceof Error ? error.message : String(error);
568
+ $("runtime-error").textContent = `LIVE RUNTIME ERROR · ${message}`;
569
+ $("runtime-error").classList.add("on");
570
+ setStatus("warn", message);
571
+ }
572
+
573
+ async function fallbackToSnapshots(error) {
574
+ driver?.dispose?.();
575
+ runtimeDescriptor = null;
576
+ driver = new SnapshotDriver();
577
+ currentAnimationId = "captured-evidence";
578
+ inspectedAnimations.clear();
579
+ $("mode-badge").textContent = "CAPTURED EVIDENCE";
580
+ $("mode-badge").className = "mode-badge fallback";
581
+ $("stage-mode").textContent = "LIVE RUNTIME UNAVAILABLE · SNAPSHOT FALLBACK";
582
+ $("runtime-error").textContent = `LIVE RUNTIME UNAVAILABLE · ${error instanceof Error ? error.message : String(error)} · showing captured evidence only`;
583
+ $("runtime-error").classList.add("on");
584
+ renderAnimations(); configureControls();
585
+ await driver.seek(0); updateTransportState(driver.getState());
586
+ }
587
+
588
+ async function callDriver(method, ...args) {
589
+ try {
590
+ const result = await driver?.[method]?.(...args);
591
+ updateTransportState(result || driver?.getState?.() || {});
592
+ return result;
593
+ } catch (error) { showRuntimeError(error); throw error; }
594
+ }
595
+
596
+ function wireControls() {
597
+ $("play").addEventListener("click", () => callDriver("play").catch(() => {}));
598
+ $("pause").addEventListener("click", () => callDriver("pause").catch(() => {}));
599
+ $("restart").addEventListener("click", () => callDriver("restart").catch(() => {}));
600
+ $("step-back").addEventListener("click", () => callDriver("stepFrames", -1).catch(() => {}));
601
+ $("step-forward").addEventListener("click", () => callDriver("stepFrames", 1).catch(() => {}));
602
+ $("loop").addEventListener("click", () => callDriver("setLoop", !runtimeUi.loop).catch(() => {}));
603
+ $("speed").addEventListener("change", (event) => callDriver("setSpeed", Number(event.target.value)).catch(() => {}));
604
+ $("scrubber").addEventListener("input", (event) => callDriver("seek", Number(event.target.value) / 1000).catch(() => {}));
605
+ $("background").addEventListener("change", (event) => setBackground(event.target.value));
606
+ $("grid").addEventListener("click", () => toggleTool("grid", "overlay-grid"));
607
+ $("bounds").addEventListener("click", () => toggleTool("bounds", "overlay-bounds"));
608
+ $("baseline").addEventListener("click", () => toggleTool("baseline", "overlay-baseline"));
609
+ $("pivot").addEventListener("click", () => toggleTool("pivot", "overlay-pivot"));
610
+ $("zoom-out").addEventListener("click", () => setZoom(zoom / 1.25));
611
+ $("zoom-in").addEventListener("click", () => setZoom(zoom * 1.25));
612
+ $("zoom-reset").addEventListener("click", () => setZoom(1));
613
+ $("fullscreen").addEventListener("click", async () => {
614
+ if (document.fullscreenElement) await document.exitFullscreen(); else await $("stage-shell").requestFullscreen();
615
+ });
616
+ $("request-changes").addEventListener("click", () => submitReview("changes_requested"));
617
+ $("confirm").addEventListener("click", () => submitReview("approved"));
618
+ $("reset").addEventListener("click", () => { localStorage.removeItem(`devlab:${taskId}:${candidateId}`); location.reload(); });
619
+ window.addEventListener("keydown", (event) => {
620
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) return;
621
+ if (event.code === "Space") { event.preventDefault(); callDriver(runtimeUi.playing ? "pause" : "play").catch(() => {}); }
622
+ if (event.key === "ArrowLeft") callDriver("stepFrames", -1).catch(() => {});
623
+ if (event.key === "ArrowRight") callDriver("stepFrames", 1).catch(() => {});
624
+ });
625
+ }
626
+
627
+ function submitReview(decision) {
141
628
  const checks = currentChecks();
142
- const approved = checks.length > 0 && checks.every((check) => check.pass);
143
- const payload = currentReview(approved ? "approved" : "changes_requested");
144
- saveLocal(payload);
145
- expose(payload);
146
- $("status").className = approved ? "ok" : "warn";
147
- $("status").textContent = approved
629
+ if (decision === "approved") {
630
+ if (!checks.length || !checks.every((item) => item.pass)) { setStatus("warn", "Approval requires every quality checklist item to be checked."); return; }
631
+ if (!reviewCoverageComplete()) { updateReviewGate(); return; }
632
+ }
633
+ const payload = currentReview(decision);
634
+ saveLocal(payload); expose(payload);
635
+ setStatus(decision === "approved" ? "ok" : "warn", decision === "approved"
148
636
  ? "BROWSER REVIEW APPROVED · browser Agent must persist review.json before PR"
149
- : "CHANGES REQUESTED · browser Agent must persist notes and return to generation";
637
+ : "CHANGES REQUESTED · browser Agent must persist notes and return to generation");
638
+ }
639
+
640
+ function startRuntimePolling() {
641
+ if (!(driver instanceof IframeDriver)) return;
642
+ clearInterval(pollTimer);
643
+ pollTimer = setInterval(() => driver.refresh().catch(() => {}), 250);
150
644
  }
151
645
 
152
- window.__lab = { seek: showProgress, ready: false, taskId, candidateId, reviewRequired: true };
646
+ window.__lab = {
647
+ ready: false,
648
+ taskId,
649
+ candidateId,
650
+ reviewRequired: true,
651
+ mode: "loading",
652
+ seek: async (progress) => callDriver("seek", progress),
653
+ selectAnimation: async (id) => selectAnimation(id),
654
+ play: async () => callDriver("play"),
655
+ pause: async () => callDriver("pause"),
656
+ restart: async () => callDriver("restart"),
657
+ stepFrames: async (delta) => callDriver("stepFrames", delta),
658
+ setSpeed: async (rate) => callDriver("setSpeed", rate),
659
+ setLoop: async (enabled) => callDriver("setLoop", enabled),
660
+ getRuntimeState: () => driver?.getState?.() || runtimeUi.state,
661
+ getReview: () => window.__lab.lastReview,
662
+ exportReview: () => JSON.stringify(window.__lab.lastReview || {}, null, 2)
663
+ };
153
664
 
154
665
  async function load() {
155
- if (!scene || !candidateId) {
156
- throw new Error("Missing scene or candidate_id; open the emitted browser-review URL");
157
- }
158
- [manifest, spec, candidate] = await Promise.all([
159
- fetchJson(`${base}/manifest.json`),
160
- fetchJson(`${base}/motion-spec.json`),
161
- fetchJson(`${base}/browser-review.json`),
666
+ if (!scene || !candidateId) throw new Error("Missing scene or candidate_id; open the emitted browser-review URL");
667
+ [manifest, spec, candidate, runtimeDescriptor] = await Promise.all([
668
+ fetchJson(`${base}/manifest.json`), fetchJson(`${base}/motion-spec.json`), fetchJson(`${base}/browser-review.json`), fetchJson(`${base}/devlab-runtime.json`, { optional: true })
162
669
  ]);
163
670
  validateCandidate();
671
+ runtimeDescriptor = validateRuntimeDescriptor(runtimeDescriptor);
672
+ const expectedLiveRuntimeMissing = candidate.runtime_review?.live === true && !runtimeDescriptor;
164
673
 
165
- $("title").textContent = `${manifest.name || scene} · candidate ${candidateId}`;
166
- $("description").textContent = `${manifest.description || "Evidence review for this animation scene."} Review candidate ${candidateId} is bound to task ${taskId}.`;
167
- $("stage-label").textContent = `${scene.toUpperCase()} · ${spec.category || "animation"} · CANDIDATE`;
168
- $("stage-mode").textContent = `${spec.framework || "unknown"} · ${candidate.status} · runtime evidence`;
674
+ $("title").textContent = `${manifest.name || scene}`;
675
+ $("description").textContent = `${manifest.description || "Evidence review for this animation scene."} Candidate ${candidateId} is bound to task ${taskId}.`;
676
+ $("stage-label").textContent = `${scene.toUpperCase()} · ${spec.category || "animation"}`;
677
+ $("stage-mode").textContent = `${spec.framework || "unknown"} · ${candidate.status}`;
169
678
  renderMetadata();
170
679
 
171
680
  const saved = JSON.parse(localStorage.getItem(`devlab:${taskId}:${candidateId}`) || "null");
172
- review = saved || {
173
- checks: (manifest.checks || []).map((check) => ({ id: check.id, pass: false })),
174
- notes: "",
175
- decision: "pending",
176
- };
177
- renderChecks();
178
- $("notes").value = review.notes || "";
179
- $("scrubber").addEventListener("input", (event) => showProgress(Number(event.target.value) / 100));
180
- $("confirm").addEventListener("click", confirmReview);
181
- $("reset").addEventListener("click", () => {
182
- localStorage.removeItem(`devlab:${taskId}:${candidateId}`);
183
- location.reload();
184
- });
681
+ review = saved || { checks: (manifest.checks || []).map((check) => ({ id: check.id, pass: false })), notes: "", decision: "pending", animations_inspected: [] };
682
+ for (const id of review.animations_inspected || []) inspectedAnimations.add(id);
683
+ renderChecks(); $("notes").value = review.notes || ""; expose(review);
684
+ wireControls(); applyViewportOverlays(); setZoom(1);
685
+
686
+ if (expectedLiveRuntimeMissing) {
687
+ await fallbackToSnapshots(new Error("Hash-bound devlab-runtime.json is missing"));
688
+ } else if (runtimeDescriptor?.mode === "sprite-sequence") {
689
+ driver = new SpriteSequenceDriver(runtimeDescriptor, updateTransportState);
690
+ currentAnimationId = runtimeDescriptor.default_animation;
691
+ $("mode-badge").textContent = "LIVE RUNTIME"; $("mode-badge").className = "mode-badge live";
692
+ $("stage-mode").textContent = `${spec.framework || "sprite"} · LIVE SPRITE RUNTIME`;
693
+ await driver.selectAnimation(currentAnimationId);
694
+ } else if (runtimeDescriptor?.mode === "iframe") {
695
+ driver = new IframeDriver(runtimeDescriptor, updateTransportState);
696
+ currentAnimationId = runtimeDescriptor.default_animation;
697
+ $("mode-badge").textContent = "LIVE RUNTIME"; $("mode-badge").className = "mode-badge live";
698
+ $("stage-mode").textContent = `${spec.framework || "runtime"} · LIVE IFRAME RUNTIME`;
699
+ try { await driver.mount(); }
700
+ catch (error) { await fallbackToSnapshots(error); }
701
+ } else {
702
+ driver = new SnapshotDriver();
703
+ currentAnimationId = "captured-evidence";
704
+ $("mode-badge").textContent = "CAPTURED EVIDENCE"; $("mode-badge").className = "mode-badge fallback";
705
+ $("stage-mode").textContent = `${spec.framework || "unknown"} · SNAPSHOT COMPATIBILITY`;
706
+ await driver.seek(0);
707
+ }
708
+
709
+ renderAnimations(); configureControls(); applyViewportOverlays();
710
+ await selectAnimation(currentAnimationId);
711
+ updateTransportState(driver.getState());
712
+ startRuntimePolling();
713
+ window.__lab.mode = driver.mode;
185
714
  window.__lab.ready = true;
186
- showProgress(0);
187
715
  }
188
716
 
189
717
  load().catch((error) => {
190
- $("status").className = "warn";
191
- $("status").textContent = `DEV LAB ERROR: ${error.message}`;
718
+ $("mode-badge").textContent = "BLOCKED"; $("mode-badge").className = "mode-badge fallback";
719
+ setStatus("warn", `DEV LAB ERROR: ${error.message}`);
192
720
  $("empty").textContent = "Unable to load the exact candidate evidence.";
721
+ $("runtime-state").textContent = error.stack || error.message;
193
722
  });