motionloom 2.5.0 → 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 +52 -1
- package/README.md +20 -8
- package/SECURITY.md +6 -7
- package/SKILL.md +5 -3
- package/agent-card.json +4 -4
- package/agent-surfaces.json +1 -1
- package/bin/motionloom.mjs +5 -4
- package/capability-registry.json +26 -26
- package/dev-lab/public/action-library.js +899 -0
- package/dev-lab/public/devlab.js +722 -0
- package/dev-lab/public/index.html +163 -0
- 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.5.1.md +21 -0
- package/docs/releases/2.6.0.md +38 -0
- package/docs/releases/npm-publish-from-workstation.md +6 -6
- package/package.json +12 -4
- package/requirements.txt +1 -0
- package/rig-adapter-registry.json +3 -3
- 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/capture-runtime-telemetry.py +16 -8
- package/scripts/devlab.py +3 -10
- package/scripts/docs-audit.py +13 -1
- package/scripts/package-consumer-smoke.mjs +76 -0
- package/scripts/review-hook.py +255 -19
- package/scripts/runtime-adapters.mjs +28 -3
- package/scripts/skill-doctor.py +27 -1
- package/tests/runtime-harness/index.html +24 -0
- package/tests/runtime-harness/main.jsx +111 -0
- package/tests/scripts/run_tests.py +41 -0
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const params = new URLSearchParams(location.search);
|
|
3
|
+
const scene = params.get("scene");
|
|
4
|
+
if (!scene || !/^[A-Za-z0-9._-]+$/.test(scene)) return;
|
|
5
|
+
|
|
6
|
+
const safeBase = (() => {
|
|
7
|
+
const expected = `/scenes/${encodeURIComponent(scene)}`;
|
|
8
|
+
const raw = params.get("artifact_base");
|
|
9
|
+
if (!raw) return expected;
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(raw, location.href);
|
|
12
|
+
if (url.origin !== location.origin || url.search || url.hash) return expected;
|
|
13
|
+
const decoded = decodeURIComponent(url.pathname).replace(/\/+$/, "");
|
|
14
|
+
return decoded === `/scenes/${scene}` ? url.pathname.replace(/\/+$/, "") : expected;
|
|
15
|
+
} catch {
|
|
16
|
+
return expected;
|
|
17
|
+
}
|
|
18
|
+
})();
|
|
19
|
+
|
|
20
|
+
const state = {
|
|
21
|
+
query: "",
|
|
22
|
+
filter: "all",
|
|
23
|
+
group: "all",
|
|
24
|
+
collapsed: new Set(),
|
|
25
|
+
};
|
|
26
|
+
const metaById = new Map();
|
|
27
|
+
const groupMeta = new Map();
|
|
28
|
+
let descriptor = null;
|
|
29
|
+
let observer = null;
|
|
30
|
+
let refreshTimer = null;
|
|
31
|
+
let pollTimer = null;
|
|
32
|
+
let rebuilding = false;
|
|
33
|
+
|
|
34
|
+
function installStyles() {
|
|
35
|
+
if (document.getElementById("action-library-style")) return;
|
|
36
|
+
const style = document.createElement("style");
|
|
37
|
+
style.id = "action-library-style";
|
|
38
|
+
style.textContent = `
|
|
39
|
+
.action-library-toolbar{display:grid;gap:8px;margin:0 0 10px}
|
|
40
|
+
.action-search{width:100%;min-width:0;border:1px solid var(--line2);border-radius:7px;background:#0f1117;color:var(--text);padding:8px 9px;outline:none}
|
|
41
|
+
.action-search:focus{border-color:var(--amber);box-shadow:0 0 0 1px var(--amber)}
|
|
42
|
+
.action-filter{width:100%;border:1px solid var(--line2);border-radius:7px;background:#242936;color:var(--text);padding:7px 8px}
|
|
43
|
+
.action-group-chips{display:flex;gap:5px;overflow:auto;padding:1px 0 3px;scrollbar-width:thin}
|
|
44
|
+
.action-group-chip{flex:0 0 auto;border-color:transparent;background:#202430;color:var(--muted);padding:5px 7px;font:10px ui-monospace,monospace}
|
|
45
|
+
.action-group-chip.active{color:var(--text);border-color:#6b5726;background:#2b2519}
|
|
46
|
+
.action-library-summary{color:var(--muted);font:10px/1.35 ui-monospace,monospace}
|
|
47
|
+
.action-group{display:grid;gap:4px}
|
|
48
|
+
.action-group[hidden]{display:none}
|
|
49
|
+
.action-group-header{width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;border-color:transparent;background:transparent;color:var(--muted);padding:7px 6px 4px;text-align:left;font:700 10px ui-monospace,monospace;letter-spacing:.07em;text-transform:uppercase}
|
|
50
|
+
.action-group-header:hover:not(:disabled){background:#1c202a;border-color:transparent}
|
|
51
|
+
.action-group-header .chevron{display:inline-block;width:12px;color:#697284}
|
|
52
|
+
.action-group-header .group-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
53
|
+
.action-group-header .group-count{font-weight:500;letter-spacing:0;color:#697284}
|
|
54
|
+
.action-group-actions{display:grid;gap:4px}
|
|
55
|
+
.action-group.collapsed .action-group-actions{display:none}
|
|
56
|
+
.animation-btn[hidden]{display:none}
|
|
57
|
+
@media(max-width:820px){
|
|
58
|
+
nav[aria-label="Animation actions"]{overflow:visible}
|
|
59
|
+
.action-library-toolbar{grid-template-columns:minmax(170px,1fr) minmax(120px,.45fr);align-items:start}
|
|
60
|
+
.action-group-chips,.action-library-summary{grid-column:1/-1}
|
|
61
|
+
#animations{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));overflow:visible}
|
|
62
|
+
.action-group{min-width:0}
|
|
63
|
+
.action-group-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}
|
|
64
|
+
.animation-btn{width:100%;min-width:0}
|
|
65
|
+
}
|
|
66
|
+
@media(max-width:520px){.action-library-toolbar{grid-template-columns:1fr}.action-group-chips,.action-library-summary{grid-column:auto}}
|
|
67
|
+
`;
|
|
68
|
+
document.head.append(style);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function fetchDescriptor() {
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(`${safeBase}/devlab-runtime.json`, { cache: "no-store" });
|
|
74
|
+
if (!response.ok) return null;
|
|
75
|
+
return await response.json();
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeGroupId(value) {
|
|
82
|
+
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(value) ? value : "ungrouped";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hydrateMetadata(value) {
|
|
86
|
+
descriptor = value;
|
|
87
|
+
metaById.clear();
|
|
88
|
+
groupMeta.clear();
|
|
89
|
+
const declaredGroups = Array.isArray(value?.groups) ? value.groups : [];
|
|
90
|
+
declaredGroups.forEach((group, index) => {
|
|
91
|
+
if (!group || typeof group.id !== "string") return;
|
|
92
|
+
const id = normalizeGroupId(group.id);
|
|
93
|
+
groupMeta.set(id, {
|
|
94
|
+
id,
|
|
95
|
+
label: typeof group.label === "string" && group.label ? group.label : id,
|
|
96
|
+
order: Number.isFinite(Number(group.order)) ? Number(group.order) : index,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
for (const animation of Array.isArray(value?.animations) ? value.animations : []) {
|
|
100
|
+
if (!animation || typeof animation.id !== "string") continue;
|
|
101
|
+
const group = normalizeGroupId(animation.group);
|
|
102
|
+
metaById.set(animation.id, { ...animation, group });
|
|
103
|
+
if (!groupMeta.has(group)) {
|
|
104
|
+
groupMeta.set(group, {
|
|
105
|
+
id: group,
|
|
106
|
+
label: group === "ungrouped" ? "Other" : group.replace(/[._-]+/g, " "),
|
|
107
|
+
order: 1000 + groupMeta.size,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!groupMeta.has("ungrouped")) groupMeta.set("ungrouped", { id: "ungrouped", label: "Other", order: 9999 });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sortedGroups(buttons) {
|
|
115
|
+
const present = new Set(buttons.map((button) => button.dataset.actionGroup || "ungrouped"));
|
|
116
|
+
return [...groupMeta.values()]
|
|
117
|
+
.filter((group) => present.has(group.id))
|
|
118
|
+
.sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildToolbar(nav) {
|
|
122
|
+
if (document.getElementById("action-library-toolbar")) return;
|
|
123
|
+
const toolbar = document.createElement("div");
|
|
124
|
+
toolbar.id = "action-library-toolbar";
|
|
125
|
+
toolbar.className = "action-library-toolbar";
|
|
126
|
+
|
|
127
|
+
const search = document.createElement("input");
|
|
128
|
+
search.id = "animation-search";
|
|
129
|
+
search.className = "action-search";
|
|
130
|
+
search.type = "search";
|
|
131
|
+
search.autocomplete = "off";
|
|
132
|
+
search.spellcheck = false;
|
|
133
|
+
search.placeholder = "Search actions…";
|
|
134
|
+
search.setAttribute("aria-label", "Search animation actions");
|
|
135
|
+
search.addEventListener("input", () => { state.query = search.value.trim().toLowerCase(); applyFilters(); });
|
|
136
|
+
|
|
137
|
+
const filter = document.createElement("select");
|
|
138
|
+
filter.id = "animation-filter";
|
|
139
|
+
filter.className = "action-filter";
|
|
140
|
+
filter.setAttribute("aria-label", "Filter animation actions");
|
|
141
|
+
for (const [value, label] of [["all", "All actions"], ["required", "Required review"], ["unreviewed", "Unreviewed"], ["looping", "Looping"], ["one-shot", "One-shot"]]) {
|
|
142
|
+
const option = document.createElement("option"); option.value = value; option.textContent = label; filter.append(option);
|
|
143
|
+
}
|
|
144
|
+
filter.addEventListener("change", () => { state.filter = filter.value; applyFilters(); });
|
|
145
|
+
|
|
146
|
+
const chips = document.createElement("div");
|
|
147
|
+
chips.id = "animation-groups";
|
|
148
|
+
chips.className = "action-group-chips";
|
|
149
|
+
chips.setAttribute("aria-label", "Animation groups");
|
|
150
|
+
|
|
151
|
+
const summary = document.createElement("div");
|
|
152
|
+
summary.id = "animation-library-summary";
|
|
153
|
+
summary.className = "action-library-summary";
|
|
154
|
+
|
|
155
|
+
toolbar.append(search, filter, chips, summary);
|
|
156
|
+
nav.insertBefore(toolbar, document.getElementById("animations"));
|
|
157
|
+
|
|
158
|
+
window.addEventListener("keydown", (event) => {
|
|
159
|
+
const target = event.target;
|
|
160
|
+
const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement;
|
|
161
|
+
if (event.key === "/" && !typing) { event.preventDefault(); search.focus(); search.select(); }
|
|
162
|
+
if (event.key === "Escape" && document.activeElement === search && search.value) {
|
|
163
|
+
event.preventDefault(); search.value = ""; state.query = ""; applyFilters();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderGroupChips(buttons) {
|
|
169
|
+
const chips = document.getElementById("animation-groups");
|
|
170
|
+
if (!chips) return;
|
|
171
|
+
const groups = sortedGroups(buttons);
|
|
172
|
+
const entries = [{ id: "all", label: "All", count: buttons.length }, ...groups.map((group) => ({
|
|
173
|
+
id: group.id,
|
|
174
|
+
label: group.label,
|
|
175
|
+
count: buttons.filter((button) => button.dataset.actionGroup === group.id).length,
|
|
176
|
+
}))];
|
|
177
|
+
chips.replaceChildren(...entries.map((entry) => {
|
|
178
|
+
const button = document.createElement("button");
|
|
179
|
+
button.type = "button";
|
|
180
|
+
button.className = `action-group-chip${state.group === entry.id ? " active" : ""}`;
|
|
181
|
+
button.dataset.group = entry.id;
|
|
182
|
+
button.textContent = `${entry.label} ${entry.count}`;
|
|
183
|
+
button.addEventListener("click", () => {
|
|
184
|
+
state.group = entry.id;
|
|
185
|
+
renderGroupChips([...document.querySelectorAll(".animation-btn")]);
|
|
186
|
+
applyFilters();
|
|
187
|
+
});
|
|
188
|
+
return button;
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function persistCollapsed() {
|
|
193
|
+
try { sessionStorage.setItem("motionloom:action-groups:collapsed", JSON.stringify([...state.collapsed])); } catch { /* optional */ }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function restoreCollapsed() {
|
|
197
|
+
try {
|
|
198
|
+
const value = JSON.parse(sessionStorage.getItem("motionloom:action-groups:collapsed") || "[]");
|
|
199
|
+
if (Array.isArray(value)) state.collapsed = new Set(value.filter((item) => typeof item === "string"));
|
|
200
|
+
} catch { /* optional */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function rebuildGroups() {
|
|
204
|
+
const container = document.getElementById("animations");
|
|
205
|
+
if (!container || rebuilding) return;
|
|
206
|
+
const buttons = [...container.querySelectorAll(".animation-btn")];
|
|
207
|
+
if (!buttons.length) return;
|
|
208
|
+
rebuilding = true;
|
|
209
|
+
observer?.disconnect();
|
|
210
|
+
|
|
211
|
+
for (const button of buttons) {
|
|
212
|
+
const id = button.dataset.animation || "";
|
|
213
|
+
const meta = metaById.get(id) || {};
|
|
214
|
+
const group = normalizeGroupId(meta.group);
|
|
215
|
+
button.dataset.actionGroup = group;
|
|
216
|
+
const label = button.querySelector(".animation-name")?.textContent || id;
|
|
217
|
+
const tags = Array.isArray(meta.tags) ? meta.tags : [];
|
|
218
|
+
const events = Array.isArray(meta.events) ? meta.events : [];
|
|
219
|
+
const groupLabel = groupMeta.get(group)?.label || group;
|
|
220
|
+
button.dataset.actionKeywords = [id, label, group, groupLabel, ...tags, ...events].join(" ").toLowerCase();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const fragment = document.createDocumentFragment();
|
|
224
|
+
const groups = sortedGroups(buttons);
|
|
225
|
+
for (const group of groups) {
|
|
226
|
+
const groupButtons = buttons.filter((button) => button.dataset.actionGroup === group.id);
|
|
227
|
+
if (!groupButtons.length) continue;
|
|
228
|
+
const section = document.createElement("section");
|
|
229
|
+
section.className = "action-group";
|
|
230
|
+
section.dataset.group = group.id;
|
|
231
|
+
|
|
232
|
+
const header = document.createElement("button");
|
|
233
|
+
header.type = "button";
|
|
234
|
+
header.className = "action-group-header";
|
|
235
|
+
header.setAttribute("aria-expanded", state.collapsed.has(group.id) ? "false" : "true");
|
|
236
|
+
const chevron = document.createElement("span"); chevron.className = "chevron"; chevron.textContent = state.collapsed.has(group.id) ? "▸" : "▾";
|
|
237
|
+
const title = document.createElement("span"); title.className = "group-title"; title.textContent = group.label;
|
|
238
|
+
const count = document.createElement("span"); count.className = "group-count"; count.textContent = String(groupButtons.length);
|
|
239
|
+
header.append(chevron, title, count);
|
|
240
|
+
header.addEventListener("click", () => {
|
|
241
|
+
if (state.collapsed.has(group.id)) state.collapsed.delete(group.id); else state.collapsed.add(group.id);
|
|
242
|
+
persistCollapsed();
|
|
243
|
+
applyFilters();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const actions = document.createElement("div"); actions.className = "action-group-actions"; actions.append(...groupButtons);
|
|
247
|
+
section.append(header, actions); fragment.append(section);
|
|
248
|
+
}
|
|
249
|
+
container.replaceChildren(fragment);
|
|
250
|
+
renderGroupChips(buttons);
|
|
251
|
+
rebuilding = false;
|
|
252
|
+
observer?.observe(container, { childList: true, subtree: true });
|
|
253
|
+
applyFilters();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function filterMatches(button) {
|
|
257
|
+
const id = button.dataset.animation || "";
|
|
258
|
+
const meta = metaById.get(id) || {};
|
|
259
|
+
const selected = button.classList.contains("selected");
|
|
260
|
+
const visited = button.classList.contains("visited");
|
|
261
|
+
if (state.query && !(button.dataset.actionKeywords || id).includes(state.query)) return false;
|
|
262
|
+
if (state.group !== "all" && button.dataset.actionGroup !== state.group) return false;
|
|
263
|
+
if (state.filter === "required" && meta.review_required === false) return false;
|
|
264
|
+
if (state.filter === "unreviewed" && visited && !selected) return false;
|
|
265
|
+
if (state.filter === "looping" && meta.loop !== true) return false;
|
|
266
|
+
if (state.filter === "one-shot" && meta.loop !== false) return false;
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function applyFilters() {
|
|
271
|
+
const buttons = [...document.querySelectorAll("#animations .animation-btn")];
|
|
272
|
+
if (!buttons.length) return;
|
|
273
|
+
let visible = 0;
|
|
274
|
+
let visited = 0;
|
|
275
|
+
let required = 0;
|
|
276
|
+
for (const button of buttons) {
|
|
277
|
+
const id = button.dataset.animation || "";
|
|
278
|
+
const meta = metaById.get(id) || {};
|
|
279
|
+
if (button.classList.contains("visited")) visited += 1;
|
|
280
|
+
if (meta.review_required !== false) required += 1;
|
|
281
|
+
const show = filterMatches(button);
|
|
282
|
+
button.hidden = !show;
|
|
283
|
+
if (show) visible += 1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const section of document.querySelectorAll("#animations .action-group")) {
|
|
287
|
+
const groupId = section.dataset.group;
|
|
288
|
+
const shown = [...section.querySelectorAll(".animation-btn")].filter((button) => !button.hidden);
|
|
289
|
+
const total = section.querySelectorAll(".animation-btn").length;
|
|
290
|
+
section.hidden = shown.length === 0;
|
|
291
|
+
const autoExpand = Boolean(state.query) || state.group !== "all";
|
|
292
|
+
const collapsed = !autoExpand && state.collapsed.has(groupId);
|
|
293
|
+
section.classList.toggle("collapsed", collapsed);
|
|
294
|
+
const header = section.querySelector(".action-group-header");
|
|
295
|
+
const chevron = section.querySelector(".chevron");
|
|
296
|
+
const count = section.querySelector(".group-count");
|
|
297
|
+
if (header) header.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
|
298
|
+
if (chevron) chevron.textContent = collapsed ? "▸" : "▾";
|
|
299
|
+
if (count) count.textContent = shown.length === total ? String(total) : `${shown.length}/${total}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const summary = document.getElementById("animation-library-summary");
|
|
303
|
+
if (summary) summary.textContent = `${visible}/${buttons.length} visible · ${visited}/${buttons.length} inspected · ${required} review-required`;
|
|
304
|
+
for (const chip of document.querySelectorAll(".action-group-chip")) chip.classList.toggle("active", chip.dataset.group === state.group);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function scheduleRebuild() {
|
|
308
|
+
clearTimeout(refreshTimer);
|
|
309
|
+
refreshTimer = setTimeout(rebuildGroups, 20);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function waitForLab() {
|
|
313
|
+
const deadline = Date.now() + 10000;
|
|
314
|
+
while (Date.now() < deadline) {
|
|
315
|
+
if (window.__lab?.ready === true && document.querySelector("#animations .animation-btn")) return true;
|
|
316
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function init() {
|
|
322
|
+
if (!(await waitForLab())) return;
|
|
323
|
+
installStyles();
|
|
324
|
+
restoreCollapsed();
|
|
325
|
+
hydrateMetadata(await fetchDescriptor());
|
|
326
|
+
const nav = document.querySelector('nav[aria-label="Animation actions"]');
|
|
327
|
+
const container = document.getElementById("animations");
|
|
328
|
+
if (!nav || !container) return;
|
|
329
|
+
buildToolbar(nav);
|
|
330
|
+
observer = new MutationObserver(() => { if (!rebuilding) scheduleRebuild(); });
|
|
331
|
+
observer.observe(container, { childList: true, subtree: true });
|
|
332
|
+
rebuildGroups();
|
|
333
|
+
pollTimer = setInterval(applyFilters, 250);
|
|
334
|
+
|
|
335
|
+
window.__lab.actionLibrary = {
|
|
336
|
+
ready: true,
|
|
337
|
+
getState: () => ({ query: state.query, filter: state.filter, group: state.group, visible: [...document.querySelectorAll("#animations .animation-btn")].filter((button) => !button.hidden).map((button) => button.dataset.animation) }),
|
|
338
|
+
setSearch: (query = "") => {
|
|
339
|
+
state.query = String(query).trim().toLowerCase();
|
|
340
|
+
const input = document.getElementById("animation-search"); if (input) input.value = query;
|
|
341
|
+
applyFilters();
|
|
342
|
+
},
|
|
343
|
+
setFilter: (filter = "all") => {
|
|
344
|
+
const allowed = new Set(["all", "required", "unreviewed", "looping", "one-shot"]);
|
|
345
|
+
state.filter = allowed.has(filter) ? filter : "all";
|
|
346
|
+
const select = document.getElementById("animation-filter"); if (select) select.value = state.filter;
|
|
347
|
+
applyFilters();
|
|
348
|
+
},
|
|
349
|
+
setGroup: (group = "all") => {
|
|
350
|
+
state.group = group === "all" || groupMeta.has(group) ? group : "all";
|
|
351
|
+
renderGroupChips([...document.querySelectorAll(".animation-btn")]);
|
|
352
|
+
applyFilters();
|
|
353
|
+
},
|
|
354
|
+
listGroups: () => sortedGroups([...document.querySelectorAll(".animation-btn")]).map((group) => ({ ...group })),
|
|
355
|
+
dispose: () => { observer?.disconnect(); clearInterval(pollTimer); },
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
init();
|
|
360
|
+
})();
|
|
361
|
+
|
|
362
|
+
(() => {
|
|
363
|
+
const params = new URLSearchParams(location.search);
|
|
364
|
+
const scene = params.get("scene");
|
|
365
|
+
if (!scene || !/^[A-Za-z0-9._-]+$/.test(scene)) return;
|
|
366
|
+
|
|
367
|
+
const safeBase = (() => {
|
|
368
|
+
const expected = `/scenes/${encodeURIComponent(scene)}`;
|
|
369
|
+
const raw = params.get("artifact_base");
|
|
370
|
+
if (!raw) return expected;
|
|
371
|
+
try {
|
|
372
|
+
const url = new URL(raw, location.href);
|
|
373
|
+
if (url.origin !== location.origin || url.search || url.hash) return expected;
|
|
374
|
+
const decoded = decodeURIComponent(url.pathname).replace(/\/+$/, "");
|
|
375
|
+
return decoded === `/scenes/${scene}` ? url.pathname.replace(/\/+$/, "") : expected;
|
|
376
|
+
} catch {
|
|
377
|
+
return expected;
|
|
378
|
+
}
|
|
379
|
+
})();
|
|
380
|
+
|
|
381
|
+
const MACHINE_FILE = "devlab-state-machine.json";
|
|
382
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
383
|
+
const state = {
|
|
384
|
+
descriptor: null,
|
|
385
|
+
machine: null,
|
|
386
|
+
states: new Map(),
|
|
387
|
+
transitions: new Map(),
|
|
388
|
+
sequences: new Map(),
|
|
389
|
+
currentState: null,
|
|
390
|
+
inspectedTransitions: new Set(),
|
|
391
|
+
inspectedSequences: new Set(),
|
|
392
|
+
history: [],
|
|
393
|
+
runtimeCapabilities: {},
|
|
394
|
+
runningSequence: null,
|
|
395
|
+
blocked: null,
|
|
396
|
+
pollTimer: null,
|
|
397
|
+
rpcCounter: 0,
|
|
398
|
+
pending: new Map(),
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
function installStyles() {
|
|
402
|
+
if (document.getElementById("state-transition-style")) return;
|
|
403
|
+
const style = document.createElement("style");
|
|
404
|
+
style.id = "state-transition-style";
|
|
405
|
+
style.textContent = `
|
|
406
|
+
.state-machine-panel{margin-top:16px;padding-top:14px;border-top:1px solid var(--line);display:grid;gap:8px}
|
|
407
|
+
.state-machine-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
|
|
408
|
+
.state-machine-current{display:flex;align-items:center;gap:7px;min-width:0;padding:7px 8px;border:1px solid var(--line);border-radius:7px;background:#10131a}
|
|
409
|
+
.state-machine-dot{width:8px;height:8px;border-radius:50%;background:var(--good);box-shadow:0 0 0 3px #52d49b18}
|
|
410
|
+
.state-machine-current strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
411
|
+
.state-machine-current small{margin-left:auto;color:var(--muted);font:10px ui-monospace,monospace}
|
|
412
|
+
.state-machine-actions{display:grid;gap:5px}
|
|
413
|
+
.transition-btn{width:100%;display:grid;grid-template-columns:8px minmax(0,1fr) auto;align-items:center;gap:7px;text-align:left;padding:7px 8px;background:#1d222d}
|
|
414
|
+
.transition-btn .transition-dot{width:6px;height:6px;border-radius:50%;background:#596173}
|
|
415
|
+
.transition-btn.inspected .transition-dot{background:var(--good)}
|
|
416
|
+
.transition-btn .transition-copy{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
417
|
+
.transition-btn .transition-mode{color:var(--muted);font:9px ui-monospace,monospace}
|
|
418
|
+
.transition-empty,.state-machine-coverage,.state-machine-error{color:var(--muted);font:10px/1.4 ui-monospace,monospace}
|
|
419
|
+
.state-machine-error{color:var(--bad)}
|
|
420
|
+
.sequence-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:5px}
|
|
421
|
+
.sequence-row select{min-width:0;color:var(--text);background:#242936;border:1px solid var(--line2);border-radius:7px;padding:6px 7px}
|
|
422
|
+
.sequence-row button{padding:6px 8px;font-size:11px}
|
|
423
|
+
.transition-history{max-height:110px;overflow:auto;border:1px solid var(--line);border-radius:6px;background:#0f1117;padding:6px;display:grid;gap:4px}
|
|
424
|
+
.transition-history-item{display:grid;grid-template-columns:auto 1fr;gap:6px;color:var(--muted);font:9px/1.35 ui-monospace,monospace}
|
|
425
|
+
.transition-history-item b{color:var(--text);font-weight:600}
|
|
426
|
+
#confirm.state-machine-gated{box-shadow:0 0 0 1px #6f363b inset;filter:saturate(.7)}
|
|
427
|
+
@media(max-width:820px){.state-machine-panel{margin-top:12px}.state-machine-actions{grid-template-columns:repeat(auto-fit,minmax(170px,1fr))}.transition-history{max-height:90px}}
|
|
428
|
+
`;
|
|
429
|
+
document.head.append(style);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function fetchJson(url, optional = false) {
|
|
433
|
+
const response = await fetch(url, { cache: "no-store" });
|
|
434
|
+
if (optional && response.status === 404) return null;
|
|
435
|
+
if (!response.ok) throw new Error(`Could not load ${url} (${response.status})`);
|
|
436
|
+
return response.json();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function waitForLab() {
|
|
440
|
+
const deadline = Date.now() + 10000;
|
|
441
|
+
while (Date.now() < deadline) {
|
|
442
|
+
if (window.__lab?.ready === true) return true;
|
|
443
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
444
|
+
}
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function validateMachine(raw) {
|
|
449
|
+
if (!raw || raw.schema_version !== "1.0") throw new Error("Unsupported or missing state-machine schema version");
|
|
450
|
+
if (!Array.isArray(raw.states) || raw.states.length < 1 || raw.states.length > 256) throw new Error("State machine requires 1..256 states");
|
|
451
|
+
if (!Array.isArray(raw.transitions) || raw.transitions.length < 1 || raw.transitions.length > 512) throw new Error("State machine requires 1..512 transitions");
|
|
452
|
+
const animationIds = new Set((state.descriptor?.animations || []).map((item) => item.id));
|
|
453
|
+
const states = new Map();
|
|
454
|
+
for (const item of raw.states) {
|
|
455
|
+
if (!ID.test(item?.id || "")) throw new Error("State id is invalid");
|
|
456
|
+
if (states.has(item.id)) throw new Error(`Duplicate state id: ${item.id}`);
|
|
457
|
+
if (!ID.test(item?.animation || "") || !animationIds.has(item.animation)) throw new Error(`State ${item.id} references an unknown animation`);
|
|
458
|
+
states.set(item.id, { id: item.id, label: item.label || item.id, animation: item.animation, description: item.description || "" });
|
|
459
|
+
}
|
|
460
|
+
if (!states.has(raw.initial_state)) throw new Error("State-machine initial_state does not exist");
|
|
461
|
+
|
|
462
|
+
const transitions = new Map();
|
|
463
|
+
for (const item of raw.transitions) {
|
|
464
|
+
if (!ID.test(item?.id || "")) throw new Error("Transition id is invalid");
|
|
465
|
+
if (transitions.has(item.id)) throw new Error(`Duplicate transition id: ${item.id}`);
|
|
466
|
+
if (item.from !== "*" && !states.has(item.from)) throw new Error(`Transition ${item.id} has an unknown source state`);
|
|
467
|
+
if (!states.has(item.to)) throw new Error(`Transition ${item.id} has an unknown target state`);
|
|
468
|
+
if (!new Set(["select-animation", "runtime-trigger"]).has(item.mode)) throw new Error(`Transition ${item.id} has an unsupported mode`);
|
|
469
|
+
const waitMs = item.wait_ms == null ? 0 : Number(item.wait_ms);
|
|
470
|
+
if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 10000) throw new Error(`Transition ${item.id} wait_ms is out of range`);
|
|
471
|
+
transitions.set(item.id, {
|
|
472
|
+
id: item.id,
|
|
473
|
+
label: item.label || item.trigger || item.id,
|
|
474
|
+
from: item.from,
|
|
475
|
+
to: item.to,
|
|
476
|
+
trigger: item.trigger || item.id,
|
|
477
|
+
mode: item.mode,
|
|
478
|
+
auto_play: item.auto_play !== false,
|
|
479
|
+
review_required: item.review_required !== false,
|
|
480
|
+
wait_ms: waitMs,
|
|
481
|
+
payload: item.payload && typeof item.payload === "object" ? item.payload : {},
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const sequences = new Map();
|
|
486
|
+
for (const item of Array.isArray(raw.sequences) ? raw.sequences : []) {
|
|
487
|
+
if (!ID.test(item?.id || "")) throw new Error("Sequence id is invalid");
|
|
488
|
+
if (sequences.has(item.id)) throw new Error(`Duplicate sequence id: ${item.id}`);
|
|
489
|
+
if (!Array.isArray(item.steps) || item.steps.length < 1 || item.steps.length > 64) throw new Error(`Sequence ${item.id} requires 1..64 steps`);
|
|
490
|
+
const steps = item.steps.map((step) => {
|
|
491
|
+
if (!transitions.has(step?.transition)) throw new Error(`Sequence ${item.id} references unknown transition ${step?.transition || ""}`);
|
|
492
|
+
const waitMs = step.wait_ms == null ? transitions.get(step.transition).wait_ms : Number(step.wait_ms);
|
|
493
|
+
if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 10000) throw new Error(`Sequence ${item.id} wait_ms is out of range`);
|
|
494
|
+
return { transition: step.transition, wait_ms: waitMs };
|
|
495
|
+
});
|
|
496
|
+
sequences.set(item.id, { id: item.id, label: item.label || item.id, description: item.description || "", review_required: item.review_required === true, steps });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
state.states = states;
|
|
500
|
+
state.transitions = transitions;
|
|
501
|
+
state.sequences = sequences;
|
|
502
|
+
return {
|
|
503
|
+
...raw,
|
|
504
|
+
review_policy: {
|
|
505
|
+
require_all_transitions: raw.review_policy?.require_all_transitions === true,
|
|
506
|
+
require_all_sequences: raw.review_policy?.require_all_sequences === true,
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function stateFromRuntime(runtime = window.__lab?.getRuntimeState?.() || {}) {
|
|
512
|
+
const explicit = runtime.state || runtime.currentState || runtime.current_state;
|
|
513
|
+
if (typeof explicit === "string" && state.states.has(explicit)) return explicit;
|
|
514
|
+
const animation = runtime.animation;
|
|
515
|
+
if (typeof animation === "string") {
|
|
516
|
+
const match = [...state.states.values()].find((item) => item.animation === animation);
|
|
517
|
+
if (match) return match.id;
|
|
518
|
+
}
|
|
519
|
+
return state.currentState && state.states.has(state.currentState) ? state.currentState : state.machine?.initial_state || null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function requiredTransitions() {
|
|
523
|
+
return [...state.transitions.values()].filter((item) => item.review_required).map((item) => item.id);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function requiredSequences() {
|
|
527
|
+
return [...state.sequences.values()].filter((item) => item.review_required).map((item) => item.id);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function coverage() {
|
|
531
|
+
const requiredT = requiredTransitions();
|
|
532
|
+
const requiredS = requiredSequences();
|
|
533
|
+
const transitionsComplete = !state.machine?.review_policy?.require_all_transitions || requiredT.every((id) => state.inspectedTransitions.has(id));
|
|
534
|
+
const sequencesComplete = !state.machine?.review_policy?.require_all_sequences || requiredS.every((id) => state.inspectedSequences.has(id));
|
|
535
|
+
return {
|
|
536
|
+
transitions: { inspected: requiredT.filter((id) => state.inspectedTransitions.has(id)).length, required: requiredT.length, complete: transitionsComplete },
|
|
537
|
+
sequences: { inspected: requiredS.filter((id) => state.inspectedSequences.has(id)).length, required: requiredS.length, complete: sequencesComplete },
|
|
538
|
+
complete: !state.blocked && transitionsComplete && sequencesComplete,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function localKey() {
|
|
543
|
+
return `devlab:${window.__lab.taskId}:${window.__lab.candidateId}`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function augmentReview(input = {}) {
|
|
547
|
+
const runtime = window.__lab?.getRuntimeState?.() || {};
|
|
548
|
+
const current = stateFromRuntime(runtime);
|
|
549
|
+
const payload = {
|
|
550
|
+
...input,
|
|
551
|
+
review_version: "1.2",
|
|
552
|
+
transitions_inspected: [...state.inspectedTransitions],
|
|
553
|
+
sequences_inspected: [...state.inspectedSequences],
|
|
554
|
+
state_machine_review: {
|
|
555
|
+
contract: MACHINE_FILE,
|
|
556
|
+
current_state: current,
|
|
557
|
+
coverage: coverage(),
|
|
558
|
+
runtime_transition_capable: Boolean(state.runtimeCapabilities.transition),
|
|
559
|
+
history: state.history.slice(-50),
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
if (payload.runtime_review && typeof payload.runtime_review === "object") {
|
|
563
|
+
payload.runtime_review = { ...payload.runtime_review, observed_state: current };
|
|
564
|
+
}
|
|
565
|
+
return payload;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function persistEvidence() {
|
|
569
|
+
let base = window.__lab?.lastReview || {};
|
|
570
|
+
try {
|
|
571
|
+
const stored = JSON.parse(localStorage.getItem(localKey()) || "null");
|
|
572
|
+
if (stored && typeof stored === "object") base = stored;
|
|
573
|
+
} catch { /* optional local evidence */ }
|
|
574
|
+
const payload = augmentReview(base);
|
|
575
|
+
window.__lab.lastReview = payload;
|
|
576
|
+
window.__lab.getReview = () => window.__lab.lastReview;
|
|
577
|
+
window.__lab.exportReview = () => JSON.stringify(window.__lab.lastReview || {}, null, 2);
|
|
578
|
+
try { localStorage.setItem(localKey(), JSON.stringify(payload)); } catch { /* optional local evidence */ }
|
|
579
|
+
return payload;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function restoreEvidence() {
|
|
583
|
+
let value = window.__lab?.lastReview || {};
|
|
584
|
+
try { value = JSON.parse(localStorage.getItem(localKey()) || "null") || value; } catch { /* optional */ }
|
|
585
|
+
for (const id of Array.isArray(value?.transitions_inspected) ? value.transitions_inspected : []) if (state.transitions.has(id)) state.inspectedTransitions.add(id);
|
|
586
|
+
for (const id of Array.isArray(value?.sequences_inspected) ? value.sequences_inspected : []) if (state.sequences.has(id)) state.inspectedSequences.add(id);
|
|
587
|
+
const history = value?.state_machine_review?.history;
|
|
588
|
+
if (Array.isArray(history)) state.history = history.slice(-50).filter((item) => item && typeof item === "object");
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function setMessage(kind, text) {
|
|
592
|
+
const node = document.getElementById("status");
|
|
593
|
+
if (!node) return;
|
|
594
|
+
node.className = kind;
|
|
595
|
+
node.textContent = text;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function rpc(command, payload = {}, timeout = 1800) {
|
|
599
|
+
const frame = document.getElementById("runtime-frame");
|
|
600
|
+
const target = frame?.contentWindow;
|
|
601
|
+
if (!target) return Promise.reject(new Error("Live runtime iframe is unavailable"));
|
|
602
|
+
const id = `ml-state-${Date.now()}-${++state.rpcCounter}`;
|
|
603
|
+
return new Promise((resolve, reject) => {
|
|
604
|
+
const timer = setTimeout(() => { state.pending.delete(id); reject(new Error(`Runtime command timed out: ${command}`)); }, timeout);
|
|
605
|
+
state.pending.set(id, { resolve, reject, timer });
|
|
606
|
+
target.postMessage({ source: "motionloom-devlab", id, command, payload }, "*");
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function onMessage(event) {
|
|
611
|
+
const frame = document.getElementById("runtime-frame");
|
|
612
|
+
if (!frame || event.source !== frame.contentWindow) return;
|
|
613
|
+
const data = event.data;
|
|
614
|
+
if (!data || data.source !== "motionloom-runtime" || !data.id) return;
|
|
615
|
+
const pending = state.pending.get(data.id);
|
|
616
|
+
if (!pending) return;
|
|
617
|
+
state.pending.delete(data.id);
|
|
618
|
+
clearTimeout(pending.timer);
|
|
619
|
+
if (data.ok) pending.resolve(data.result);
|
|
620
|
+
else pending.reject(new Error(data.error || "Runtime transition command failed"));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function transitionSupport(item) {
|
|
624
|
+
if (item.mode === "select-animation") return { ok: true, reason: "clip switch" };
|
|
625
|
+
if (state.descriptor?.mode !== "iframe") return { ok: false, reason: "requires iframe runtime" };
|
|
626
|
+
if (!state.runtimeCapabilities.transition) return { ok: false, reason: "runtime adapter has no triggerTransition" };
|
|
627
|
+
if (!state.runtimeCapabilities.state) return { ok: false, reason: "runtime state is not observable" };
|
|
628
|
+
return { ok: true, reason: "runtime trigger" };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function observeRuntimeTarget(targetState, timeout = 1200) {
|
|
632
|
+
const deadline = Date.now() + timeout;
|
|
633
|
+
while (Date.now() < deadline) {
|
|
634
|
+
let runtime = null;
|
|
635
|
+
try { runtime = await rpc("getState", {}, 700); } catch { /* retry until deadline */ }
|
|
636
|
+
const observed = stateFromRuntime(runtime || {});
|
|
637
|
+
if (observed === targetState) return { observed, runtime };
|
|
638
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
639
|
+
}
|
|
640
|
+
throw new Error(`Runtime did not expose target state ${targetState}`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function executeTransition(id, options = {}) {
|
|
644
|
+
const item = state.transitions.get(id);
|
|
645
|
+
if (!item) throw new Error(`Unknown transition: ${id}`);
|
|
646
|
+
const current = stateFromRuntime();
|
|
647
|
+
state.currentState = current;
|
|
648
|
+
if (item.from !== "*" && current !== item.from) throw new Error(`${item.label} requires state ${item.from}; current state is ${current || "unknown"}`);
|
|
649
|
+
const support = transitionSupport(item);
|
|
650
|
+
if (!support.ok) throw new Error(`${item.label} is unavailable: ${support.reason}`);
|
|
651
|
+
|
|
652
|
+
const target = state.states.get(item.to);
|
|
653
|
+
if (item.mode === "runtime-trigger") {
|
|
654
|
+
await rpc("triggerTransition", { id: item.id, from: item.from, to: item.to, trigger: item.trigger, payload: item.payload });
|
|
655
|
+
await observeRuntimeTarget(item.to);
|
|
656
|
+
} else {
|
|
657
|
+
await window.__lab.selectAnimation(target.animation);
|
|
658
|
+
if (item.auto_play) await window.__lab.play();
|
|
659
|
+
const observed = stateFromRuntime();
|
|
660
|
+
if (observed !== item.to) throw new Error(`Target state mismatch after ${item.label}: expected ${item.to}, observed ${observed || "unknown"}`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
state.currentState = item.to;
|
|
664
|
+
state.inspectedTransitions.add(item.id);
|
|
665
|
+
state.history.push({ at: new Date().toISOString(), transition: item.id, from: current, to: item.to, mode: item.mode, sequence: options.sequence || null });
|
|
666
|
+
if (state.history.length > 50) state.history = state.history.slice(-50);
|
|
667
|
+
persistEvidence();
|
|
668
|
+
render();
|
|
669
|
+
window.dispatchEvent(new CustomEvent("motionloom:transition", { detail: { id: item.id, from: current, to: item.to, mode: item.mode } }));
|
|
670
|
+
return getState();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function resetState() {
|
|
674
|
+
if (!state.machine) return;
|
|
675
|
+
stopSequence();
|
|
676
|
+
const initial = state.states.get(state.machine.initial_state);
|
|
677
|
+
if (!initial) return;
|
|
678
|
+
await window.__lab.selectAnimation(initial.animation);
|
|
679
|
+
state.currentState = initial.id;
|
|
680
|
+
render();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function stopSequence() {
|
|
684
|
+
if (state.runningSequence) state.runningSequence.cancelled = true;
|
|
685
|
+
state.runningSequence = null;
|
|
686
|
+
render();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function runSequence(id) {
|
|
690
|
+
const sequence = state.sequences.get(id);
|
|
691
|
+
if (!sequence) throw new Error(`Unknown state sequence: ${id}`);
|
|
692
|
+
if (state.runningSequence) throw new Error("Another state sequence is already running");
|
|
693
|
+
const token = { id, cancelled: false };
|
|
694
|
+
state.runningSequence = token;
|
|
695
|
+
render();
|
|
696
|
+
try {
|
|
697
|
+
for (const step of sequence.steps) {
|
|
698
|
+
if (token.cancelled) throw new Error("State sequence stopped by reviewer");
|
|
699
|
+
await executeTransition(step.transition, { sequence: id });
|
|
700
|
+
if (step.wait_ms > 0) await new Promise((resolve) => setTimeout(resolve, step.wait_ms));
|
|
701
|
+
}
|
|
702
|
+
if (!token.cancelled) {
|
|
703
|
+
state.inspectedSequences.add(id);
|
|
704
|
+
persistEvidence();
|
|
705
|
+
window.dispatchEvent(new CustomEvent("motionloom:sequence", { detail: { id, completed: true } }));
|
|
706
|
+
}
|
|
707
|
+
} finally {
|
|
708
|
+
if (state.runningSequence === token) state.runningSequence = null;
|
|
709
|
+
render();
|
|
710
|
+
}
|
|
711
|
+
return getState();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function getState() {
|
|
715
|
+
state.currentState = stateFromRuntime();
|
|
716
|
+
return {
|
|
717
|
+
ready: Boolean(state.machine) && !state.blocked,
|
|
718
|
+
blocked: state.blocked,
|
|
719
|
+
currentState: state.currentState,
|
|
720
|
+
initialState: state.machine?.initial_state || null,
|
|
721
|
+
transitionsInspected: [...state.inspectedTransitions],
|
|
722
|
+
sequencesInspected: [...state.inspectedSequences],
|
|
723
|
+
runningSequence: state.runningSequence?.id || null,
|
|
724
|
+
coverage: coverage(),
|
|
725
|
+
history: state.history.slice(),
|
|
726
|
+
runtimeCapabilities: { ...state.runtimeCapabilities },
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function buildPanel() {
|
|
731
|
+
if (document.getElementById("state-machine-panel")) return;
|
|
732
|
+
const nav = document.querySelector('nav[aria-label="Animation actions"]');
|
|
733
|
+
if (!nav) return;
|
|
734
|
+
const panel = document.createElement("section");
|
|
735
|
+
panel.id = "state-machine-panel";
|
|
736
|
+
panel.className = "state-machine-panel";
|
|
737
|
+
panel.innerHTML = `
|
|
738
|
+
<div class="state-machine-head"><div class="eyebrow" style="margin:0">State machine</div><button id="state-reset" type="button">Reset</button></div>
|
|
739
|
+
<div class="state-machine-current"><span class="state-machine-dot"></span><strong id="state-current">—</strong><small id="state-current-animation"></small></div>
|
|
740
|
+
<div id="state-transition-actions" class="state-machine-actions"></div>
|
|
741
|
+
<div id="state-machine-coverage" class="state-machine-coverage"></div>
|
|
742
|
+
<div id="state-sequences" class="sequence-row" hidden><select id="state-sequence-select" aria-label="State sequence"></select><button id="state-sequence-run" type="button">Run</button><button id="state-sequence-stop" type="button" disabled>Stop</button></div>
|
|
743
|
+
<div id="state-machine-error" class="state-machine-error"></div>
|
|
744
|
+
<div id="state-history" class="transition-history"></div>
|
|
745
|
+
`;
|
|
746
|
+
nav.append(panel);
|
|
747
|
+
document.getElementById("state-reset")?.addEventListener("click", () => resetState().catch((error) => { setMessage("warn", error.message); }));
|
|
748
|
+
document.getElementById("state-sequence-run")?.addEventListener("click", () => {
|
|
749
|
+
const id = document.getElementById("state-sequence-select")?.value;
|
|
750
|
+
if (id) runSequence(id).catch((error) => setMessage("warn", error.message));
|
|
751
|
+
});
|
|
752
|
+
document.getElementById("state-sequence-stop")?.addEventListener("click", stopSequence);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function render() {
|
|
756
|
+
if (!state.machine) return;
|
|
757
|
+
state.currentState = stateFromRuntime();
|
|
758
|
+
const current = state.states.get(state.currentState);
|
|
759
|
+
const currentNode = document.getElementById("state-current");
|
|
760
|
+
const animationNode = document.getElementById("state-current-animation");
|
|
761
|
+
if (currentNode) currentNode.textContent = current?.label || state.currentState || "Unknown";
|
|
762
|
+
if (animationNode) animationNode.textContent = current?.animation || "—";
|
|
763
|
+
|
|
764
|
+
const outgoing = [...state.transitions.values()].filter((item) => item.from === "*" || item.from === state.currentState);
|
|
765
|
+
const actions = document.getElementById("state-transition-actions");
|
|
766
|
+
if (actions) {
|
|
767
|
+
if (!outgoing.length) {
|
|
768
|
+
const empty = document.createElement("div"); empty.className = "transition-empty"; empty.textContent = "No declared transitions from this state."; actions.replaceChildren(empty);
|
|
769
|
+
} else {
|
|
770
|
+
actions.replaceChildren(...outgoing.map((item) => {
|
|
771
|
+
const support = transitionSupport(item);
|
|
772
|
+
const button = document.createElement("button");
|
|
773
|
+
button.type = "button";
|
|
774
|
+
button.className = `transition-btn${state.inspectedTransitions.has(item.id) ? " inspected" : ""}`;
|
|
775
|
+
button.dataset.transition = item.id;
|
|
776
|
+
button.disabled = !support.ok || Boolean(state.runningSequence);
|
|
777
|
+
button.title = support.ok ? `${item.from} → ${item.to} · ${item.trigger}` : support.reason;
|
|
778
|
+
const dot = document.createElement("span"); dot.className = "transition-dot";
|
|
779
|
+
const copy = document.createElement("span"); copy.className = "transition-copy"; copy.textContent = `${item.label} → ${state.states.get(item.to)?.label || item.to}`;
|
|
780
|
+
const mode = document.createElement("span"); mode.className = "transition-mode"; mode.textContent = item.mode === "runtime-trigger" ? "trigger" : "clip";
|
|
781
|
+
button.append(dot, copy, mode);
|
|
782
|
+
button.addEventListener("click", () => executeTransition(item.id).catch((error) => setMessage("warn", error.message)));
|
|
783
|
+
return button;
|
|
784
|
+
}));
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const c = coverage();
|
|
789
|
+
const coverageNode = document.getElementById("state-machine-coverage");
|
|
790
|
+
if (coverageNode) coverageNode.textContent = `Transition review ${c.transitions.inspected}/${c.transitions.required}${c.sequences.required ? ` · Sequences ${c.sequences.inspected}/${c.sequences.required}` : ""}`;
|
|
791
|
+
|
|
792
|
+
const sequenceRow = document.getElementById("state-sequences");
|
|
793
|
+
const sequenceSelect = document.getElementById("state-sequence-select");
|
|
794
|
+
if (sequenceRow && sequenceSelect) {
|
|
795
|
+
sequenceRow.hidden = state.sequences.size === 0;
|
|
796
|
+
const selected = sequenceSelect.value;
|
|
797
|
+
sequenceSelect.replaceChildren(...[...state.sequences.values()].map((sequence) => {
|
|
798
|
+
const option = document.createElement("option"); option.value = sequence.id; option.textContent = `${state.inspectedSequences.has(sequence.id) ? "✓ " : ""}${sequence.label}`; return option;
|
|
799
|
+
}));
|
|
800
|
+
if (state.sequences.has(selected)) sequenceSelect.value = selected;
|
|
801
|
+
}
|
|
802
|
+
const run = document.getElementById("state-sequence-run");
|
|
803
|
+
const stop = document.getElementById("state-sequence-stop");
|
|
804
|
+
if (run) run.disabled = Boolean(state.runningSequence) || state.sequences.size === 0;
|
|
805
|
+
if (stop) stop.disabled = !state.runningSequence;
|
|
806
|
+
|
|
807
|
+
const history = document.getElementById("state-history");
|
|
808
|
+
if (history) {
|
|
809
|
+
const items = state.history.slice(-6).reverse();
|
|
810
|
+
if (!items.length) {
|
|
811
|
+
const empty = document.createElement("div"); empty.className = "transition-empty"; empty.textContent = "Transition history will appear here."; history.replaceChildren(empty);
|
|
812
|
+
} else {
|
|
813
|
+
history.replaceChildren(...items.map((item) => {
|
|
814
|
+
const row = document.createElement("div"); row.className = "transition-history-item";
|
|
815
|
+
const time = document.createElement("span"); time.textContent = new Date(item.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
816
|
+
const copy = document.createElement("span"); const name = state.transitions.get(item.transition)?.label || item.transition; copy.innerHTML = `<b></b> ${item.from || "?"} → ${item.to}`; copy.querySelector("b").textContent = name;
|
|
817
|
+
row.append(time, copy); return row;
|
|
818
|
+
}));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const error = document.getElementById("state-machine-error");
|
|
823
|
+
if (error) error.textContent = state.blocked ? `STATE MACHINE BLOCKED · ${state.blocked}` : "";
|
|
824
|
+
const confirm = document.getElementById("confirm");
|
|
825
|
+
if (confirm) {
|
|
826
|
+
confirm.classList.toggle("state-machine-gated", !c.complete);
|
|
827
|
+
confirm.title = !c.complete ? "State/transition review requirements are incomplete" : "";
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function guardApproval(event) {
|
|
832
|
+
const c = coverage();
|
|
833
|
+
if (state.blocked || !c.complete) {
|
|
834
|
+
event.preventDefault();
|
|
835
|
+
event.stopImmediatePropagation();
|
|
836
|
+
const missingTransitions = requiredTransitions().filter((id) => !state.inspectedTransitions.has(id));
|
|
837
|
+
const missingSequences = requiredSequences().filter((id) => !state.inspectedSequences.has(id));
|
|
838
|
+
const details = [missingTransitions.length ? `transitions: ${missingTransitions.join(", ")}` : "", missingSequences.length ? `sequences: ${missingSequences.join(", ")}` : ""].filter(Boolean).join(" · ");
|
|
839
|
+
setMessage("warn", state.blocked ? `State-machine review is blocked: ${state.blocked}` : `State-machine review incomplete${details ? ` · ${details}` : ""}`);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
setTimeout(persistEvidence, 0);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function init() {
|
|
846
|
+
if (!(await waitForLab())) return;
|
|
847
|
+
let descriptor;
|
|
848
|
+
try { descriptor = await fetchJson(`${safeBase}/devlab-runtime.json`, true); } catch { return; }
|
|
849
|
+
if (!descriptor || !Array.isArray(descriptor.files) || !descriptor.files.includes(MACHINE_FILE)) return;
|
|
850
|
+
state.descriptor = descriptor;
|
|
851
|
+
installStyles();
|
|
852
|
+
buildPanel();
|
|
853
|
+
window.addEventListener("message", onMessage);
|
|
854
|
+
|
|
855
|
+
try {
|
|
856
|
+
const raw = await fetchJson(`${safeBase}/${MACHINE_FILE}`);
|
|
857
|
+
state.machine = validateMachine(raw);
|
|
858
|
+
if (descriptor.mode === "iframe") {
|
|
859
|
+
const handshake = await rpc("handshake", {}, 1500);
|
|
860
|
+
state.runtimeCapabilities = handshake?.capabilities || {};
|
|
861
|
+
}
|
|
862
|
+
state.currentState = stateFromRuntime();
|
|
863
|
+
restoreEvidence();
|
|
864
|
+
persistEvidence();
|
|
865
|
+
} catch (error) {
|
|
866
|
+
state.blocked = error instanceof Error ? error.message : String(error);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const confirm = document.getElementById("confirm");
|
|
870
|
+
const requestChanges = document.getElementById("request-changes");
|
|
871
|
+
confirm?.addEventListener("click", guardApproval, true);
|
|
872
|
+
requestChanges?.addEventListener("click", () => setTimeout(persistEvidence, 0), true);
|
|
873
|
+
state.pollTimer = setInterval(render, 250);
|
|
874
|
+
render();
|
|
875
|
+
|
|
876
|
+
window.__lab.stateMachine = {
|
|
877
|
+
ready: !state.blocked,
|
|
878
|
+
error: state.blocked,
|
|
879
|
+
listStates: () => [...state.states.values()].map((item) => ({ ...item })),
|
|
880
|
+
listTransitions: () => [...state.transitions.values()].map((item) => ({ ...item })),
|
|
881
|
+
listSequences: () => [...state.sequences.values()].map((item) => ({ ...item, steps: item.steps.map((step) => ({ ...step })) })),
|
|
882
|
+
getState,
|
|
883
|
+
getCoverage: coverage,
|
|
884
|
+
triggerTransition: executeTransition,
|
|
885
|
+
runSequence,
|
|
886
|
+
stopSequence,
|
|
887
|
+
resetState,
|
|
888
|
+
dispose: () => {
|
|
889
|
+
clearInterval(state.pollTimer);
|
|
890
|
+
stopSequence();
|
|
891
|
+
window.removeEventListener("message", onMessage);
|
|
892
|
+
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("State transition tester disposed")); }
|
|
893
|
+
state.pending.clear();
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
init();
|
|
899
|
+
})();
|