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.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +32 -1
- package/README.md +18 -7
- package/SECURITY.md +6 -7
- package/SKILL.md +5 -3
- package/agent-card.json +4 -4
- package/agent-surfaces.json +1 -1
- package/dev-lab/public/action-library.js +899 -0
- package/dev-lab/public/devlab.js +648 -119
- package/dev-lab/public/index.html +151 -18
- package/dev-lab/public/runtime-bridge.js +109 -0
- package/docs/CHECKLIST.md +15 -2
- package/docs/DEV-LAB-RUNTIME.md +160 -0
- package/docs/DEV-LAB-STATE-TRANSITIONS.md +121 -0
- package/docs/STATUS.md +3 -3
- package/docs/releases/2.6.0.md +38 -0
- package/package.json +3 -1
- package/schemas/browser-review-candidate.schema.json +37 -0
- package/schemas/devlab-runtime.schema.json +140 -0
- package/schemas/devlab-state-machine.schema.json +103 -0
- package/scripts/review-hook.py +255 -19
package/dev-lab/public/devlab.js
CHANGED
|
@@ -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
|
|
15
|
-
return
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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.
|
|
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:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
["
|
|
103
|
-
["
|
|
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
|
-
|
|
110
|
-
const pill = document.createElement("div");
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
label
|
|
114
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
input.
|
|
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
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 = {
|
|
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
|
-
|
|
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}
|
|
166
|
-
$("description").textContent = `${manifest.description || "Evidence review for this animation scene."}
|
|
167
|
-
$("stage-label").textContent = `${scene.toUpperCase()} · ${spec.category || "animation"}
|
|
168
|
-
$("stage-mode").textContent = `${spec.framework || "unknown"} · ${candidate.status}
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
$("
|
|
191
|
-
|
|
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
|
});
|