refactor: split prompt studio views and unify UI lifecycle

This commit is contained in:
DemonGatanjieu
2026-09-15 09:32:18 +08:00
parent 6dd7f94c0e
commit de02f5dd81
31 changed files with 2482 additions and 2597 deletions
+55
View File
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import { fixture, Element, all } from './ui_fixture.mjs';
const f = fixture();
const record = { filename: 'parameters.json', kind: 'image_node_selection', name: 'Parameters', node_types: ['CLIPTextEncode'], selection: { scope: 'nodes' } };
f.fetch = async url => ({ ok: true, json: async () => String(url).includes('material_full')
? { status: 'success', data: record, node_blocks: [2, 3].map(id => ({ node_id: id, type: 'CLIPTextEncode', widgets_values: [`text ${id}`] })) }
: { status: 'success', materials: [record], page: 1, pages: 1, total: 1, tags: [] } });
const materials = await f.module('ui_materials.js');
const owner = { nbPanel: new Element('div') };
owner.nbPanel.style.display = 'flex';
f.document.body.appendChild(owner.nbPanel);
for (const key of ['showMaterials', 'refreshMaterials', 'openSavedMaterial', 'openMaterialLibrary']) owner[key] = materials[key].bind(owner);
await owner.openMaterialLibrary();
assert.equal(owner.notebookContainer, undefined);
assert.equal(owner.materialApplyMode, true);
assert.equal(new URL(f.requests.at(-1)[0], 'http://test').searchParams.get('node_type'), 'CLIPTextEncode');
assert.deepEqual(f.errors, []);
const card = owner.materialList.querySelector('article');
assert.ok(card, 'current material card renders');
await card.click();
assert.equal(f.node.widgets[0].value, 'original', 'opening details must not write to canvas');
assert.ok(owner.materialDetailView);
await card.querySelector('.anomalous-material-card-apply-btn').click();
assert.deepEqual(f.errors, []);
assert.ok(owner.materialBlockDialog);
let choices = owner.materialBlockDialog.querySelectorAll('.anomalous-material-choice-card');
await choices[1].querySelector('button').click();
assert.equal(f.node.widgets[0].value, 'text 3');
await f.button(owner.materialContext, 'Undo').click();
assert.equal(f.node.widgets[0].value, 'original');
f.runTimers();
await card.querySelector('.anomalous-material-card-apply-btn').click();
const dialog = owner.materialBlockDialog;
f.app.canvas.selected_nodes = {};
await dialog.querySelector('.anomalous-material-choice-card').querySelector('button').click();
assert.equal(f.node.widgets[0].value, 'original');
assert.ok(dialog.textContent.includes((await f.module('locales.js')).translate('materialTargetChanged')));
dialog.close();
f.app.canvas.selected_nodes = { 1: f.node };
const notes = await f.module('ui_notebooks.js');
owner.showNotebooks = notes.showNotebooks.bind(owner);
owner.refreshNotebooks = () => {};
await owner.showNotebooks();
assert.equal(owner.materialContainer.style.display, 'none');
assert.ok(owner.notebookContainer);
assert.equal(owner.notebookMaterialsTab, undefined);
await owner.showMaterials();
assert.equal(owner.notebookContainer.style.display, 'none');
assert.equal(owner.materialContainer.style.display, 'flex');
assert.equal(all(owner.materialContainer).some(el => el.className === 'anomalous-nb-section-tabs'), false);
assert.deepEqual(f.errors, []);
console.log('material workspace: current cards, explicit apply, undo, stale selection and notes isolation passed');
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+25
View File
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { loadPromptSourceCards, mergePromptSourceCards } from '../web/modules/prompt_material_source.js';
const requests = [];
globalThis.fetch = async (url, options) => {
requests.push([url, options]);
const parsed = new URL(url, 'http://test');
const page = Number(parsed.searchParams.get('page'));
const filename = parsed.searchParams.get('filename');
return { ok: true, json: async () => filename
? { status: 'success', data: { kind: 'prompt_text', note: { promptEn: filename } } }
: { status: 'success', pages: 2, materials: page === 1 ? [{ filename: 'first.json' }] : [{ filename: 'first.json' }, { filename: 'last.json' }] } };
};
const controller = new AbortController();
const cards = await loadPromptSourceCards(controller.signal);
assert.deepEqual(cards.map(card => card.content), ['first.json', 'last.json']);
assert.equal(requests.length, 4, 'two pages and two unique details');
assert.equal(requests.every(([, options]) => options.signal === controller.signal), true);
const deck = [];
assert.equal(mergePromptSourceCards(deck, cards), 2);
assert.equal(mergePromptSourceCards(deck, cards), 0);
controller.abort();
await assert.rejects(loadPromptSourceCards(controller.signal), { name: 'AbortError' });
assert.equal(requests.length, 4);
console.log('prompt material source: later pages, deduplication, counts and cancellation passed');
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import { fixture, all } from './ui_fixture.mjs';
const f = fixture();
const studio = await f.module('ui_prompt_composer.js');
const translator = await f.module('ui_prompt_translator.js');
const owner = { setTriggerVisible(value) { this.triggerVisible = value; } };
const listeners = () => f.window.listenerCount() + f.document.listenerCount();
const clean = () => {
assert.equal(listeners(), 0, 'all global listeners must be released');
assert.equal(f.document.querySelector('.anomalous-prompt-studio-overlay'), null);
assert.equal(f.document.querySelector('.anomalous-translator-overlay'), null);
assert.equal(owner.sidePromptComposerControl, null);
assert.equal(owner.triggerVisible, true);
};
for (let i = 0; i < 3; i++) {
await studio.openPromptStudio(owner);
await f.flush();
assert.equal(f.document.querySelectorAll('.anomalous-source-card-compact').length, 6);
owner.sidePromptComposerControl.addBlock({ content: 'quality', role: 'positive', category: 'base' });
assert.equal(owner.promptPlanDraft.plan.positive, 'quality');
f.window.dispatch('keydown', { key: 'Escape' });
clean();
owner.promptPlanDraft = null;
}
await studio.openPromptStudio(owner);
await studio.openPromptStudio(owner);
assert.equal(f.document.querySelectorAll('.anomalous-prompt-studio-overlay').length, 1);
const beforeDrag = listeners();
const handle = f.document.querySelector('.anomalous-studio-resize-handle');
f.document.body.style.cursor = 'crosshair';
handle.onmousedown({ button: 0, clientX: 100, preventDefault() {} });
assert.equal(listeners(), beforeDrag + 2);
f.window.dispatch('mousemove', { clientX: 160 });
assert.equal(f.document.querySelector('.anomalous-prompt-studio-drawer').style.width, '640px');
studio.closePromptStudio(owner);
assert.equal(f.document.body.style.cursor, 'crosshair');
clean();
await studio.openPromptStudio(owner);
owner.sidePromptComposerControl.addBlock({ content: 'quality', role: 'positive' });
await f.button(f.document.body, '⛶').click();
assert.ok(f.document.querySelector('.anomalous-prompt-inspector-overlay'));
f.window.dispatch('keydown', { key: 'Escape' });
assert.equal(f.document.querySelector('.anomalous-prompt-inspector-overlay'), null);
assert.ok(f.document.querySelector('.anomalous-prompt-studio-overlay'), 'first Escape closes only inspector');
f.window.dispatch('keydown', { key: 'Escape' });
clean();
// Full-text edits remain authoritative after recomposition, save and reopening.
await studio.openPromptStudio(owner);
owner.sidePromptComposerControl.addBlock({ content: 'scene', role: 'positive' });
owner.sidePromptComposerControl.addBlock({ content: 'bad anatomy', role: 'negative' });
await f.button(f.document.body, 'Positive (').click();
await f.button(f.document.body, '⛶').click();
const inspectorText = f.document.querySelector('.anomalous-inspector-textarea');
inspectorText.value = 'edited whole prompt';
inspectorText.oninput();
f.window.dispatch('keydown', { key: 'Escape' });
await f.button(f.document.body, '📋').click();
assert.equal(f.clipboard.at(-1), 'edited whole prompt');
assert.equal(owner.promptPlanDraft.plan.negative, 'bad anatomy');
const name = f.document.querySelector('.anomalous-prompt-name-input');
name.value = 'round trip'; name.oninput();
f.fetch = async url => ({ ok: true, json: async () => String(url).includes('/materials?')
? { status: 'success', materials: [], pages: 1 }
: { status: 'success', material: { filename: 'saved.json' } } });
await f.button(f.document.body, '💾').click();
const savedRequest = f.requests.findLast(([url]) => url.includes('save_prompt_plan'));
const saved = JSON.parse(savedRequest[1].body);
assert.equal(saved.name, 'round trip');
assert.equal(saved.plan.positive, 'edited whole prompt');
assert.equal(saved.plan.negative, 'bad anatomy');
await f.button(f.document.body, 'Sync Library').click();
assert.ok(f.requests.at(-1)[0].includes('category=prompts'));
studio.closePromptStudio(owner);
await studio.openPromptStudio(owner);
assert.equal(owner.promptPlanDraft.plan.positive, 'edited whole prompt');
await f.button(f.document.body, '✨').click();
assert.equal(owner.promptPlanDraft.plan.positive, '');
assert.equal(owner.promptPlanDraft.plan.negative, '');
studio.closePromptStudio(owner);
clean();
// Closing while a saved plan is loading must not replace the draft or reopen.
const keptDraft = owner.promptPlanDraft;
let releasePlan;
f.fetch = async () => new Promise(resolve => { releasePlan = () => resolve({ ok: true, json: async () => ({ status: 'success', data: { kind: 'prompt_plan', name: 'late', plan: { positive: 'late text' } } }) }); });
const loadingPlan = studio.showPromptComposer(owner, { filename: 'late.json' });
await f.flush();
studio.closePromptStudio(owner);
releasePlan(); await loadingPlan;
assert.equal(owner.promptPlanDraft, keptDraft);
clean();
for (const path of ['button', 'escape', 'backdrop']) {
translator.openPromptTranslator(owner);
translator.openPromptTranslator(owner);
assert.equal(f.document.querySelectorAll('.anomalous-translator-overlay').length, 1);
const overlay = f.document.querySelector('.anomalous-translator-overlay');
if (path === 'button') f.document.querySelector('.anomalous-translator-close').click();
if (path === 'escape') f.document.dispatch('keydown', { key: 'Escape' });
if (path === 'backdrop') { overlay.classList.remove('is-sidebar'); overlay.onclick({ target: overlay }); }
clean();
}
// A delayed translation may not write into a new selection, overwrite an edited
// widget, or write after the translator closes. Kana must use the EN service too.
for (const change of ['selection', 'widget', 'close', 'none']) {
translator.openPromptTranslator(owner);
const target = f.document.querySelector('.is-target');
target.value = 'かわいい';
let release;
f.fetch = async (url, options) => {
assert.equal(JSON.parse(options.body).target_lang, 'en');
return new Promise(resolve => { release = () => resolve({ ok: true, json: async () => ({ translated: 'cute' }) }); });
};
const pending = f.button(f.document.body, 'Translate to EN & Write').click();
await f.flush();
if (change === 'selection') f.app.canvas.selected_nodes = {};
if (change === 'widget') f.node.widgets[0].value = 'edited';
if (change === 'close') f.document.querySelector('.anomalous-translator-close').click();
release?.(); await pending;
assert.equal(f.node.widgets[0].value, change === 'widget' ? 'edited' : change === 'none' ? 'cute' : 'original');
f.document.querySelector('.anomalous-translator-close')?.click();
f.app.canvas.selected_nodes = { 1: f.node };
f.node.widgets[0].value = 'original';
(await f.module('translation_service.js')).clearTranslationCache();
clean();
}
assert.deepEqual(f.errors, []);
console.log('prompt UI: reopen/close, Escape nesting, resize cleanup, real card insertion and delayed node writes passed');
+72
View File
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import { materialPromptText, loadMaterialPrompts } from '../web/modules/material_prompt_data.js';
import { composePromptPlan, joinPromptText, categorizePromptSnippet, smartSortPromptBlocks, assemblePromptBlocks, planToWorkbenchDraft, workbenchDraftToSavedPlan } from '../web/modules/prompt_composition.js';
import { translate } from '../web/modules/locales.js';
assert.deepEqual(materialPromptText({ data: { kind: 'prompt_text', note: { promptEn: ' exact ', promptZh: 'translation' } } }), { positive: ' exact ', negative: '' });
assert.deepEqual(materialPromptText({ data: { kind: 'prompt_plan', plan: { parts: [{ positive: 'base', negative: 'bad' }, { positive: 'skip', enabled: false }], positive: 'scene' } } }), { positive: 'base\nscene', negative: 'bad' });
assert.deepEqual(materialPromptText({ data: { kind: 'image_node_selection' }, prompt_groups: { positive: ['yes'], negative: ['no'] }, node_blocks: [{ type: 'CheckpointLoaderSimple', widgets_values: ['not-prompt'] }] }), { positive: 'yes', negative: 'no' });
assert.deepEqual(materialPromptText({ data: {}, node_blocks: [{ widgets_values: ['unknown role'] }] }), { positive: '', negative: '' });
let lastUrl;
globalThis.fetch = async url => { lastUrl = url; return { ok: true, json: async () => ({ status: 'success', data: { kind: 'prompt_note_bundle', note: { promptEn: 'real detail' } } }) }; };
assert.equal((await loadMaterialPrompts('note.json')).positive, 'real detail'); assert.ok(lastUrl.includes('include_workflow=0'));
class Element {
constructor(tag) { this.tagName = tag; this.children = []; this.style = {}; this.value = ''; this.attrs = {}; this.className = ''; this.classList = { add() {}, remove() {}, toggle() {} }; this.dataset = {}; }
appendChild(child) { child.parent = this; this.children.push(child); return child; }
append(...children) { children.forEach(c => this.appendChild(c)); }
replaceChildren() { this.children.forEach(c => c.parent = null); this.children = []; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(c => c !== this); this.parent = null; }
setAttribute(k, v) { this.attrs[k] = v; }
addEventListener(key, fn) { (this.listeners ||= {})[key] = fn; }
focus() {}
get isConnected() { return this.tagName === 'body' || !!this.parent?.isConnected; }
}
const document = { body: new Element('body'), createElement: tag => new Element(tag), addEventListener: (key, fn) => events[key] = fn };
const text = (parent, tag, value, cls = '') => { const el = document.createElement(tag); el.textContent = value; el.className = cls; parent.appendChild(el); return el; };
const all = parent => [parent, ...parent.children.flatMap(all)];
const events = {};
let sent;
const context = vm.createContext({ console, document, app: {}, translate, t: translate, text, composePromptPlan, joinPromptText,
categorizePromptSnippet, smartSortPromptBlocks, assemblePromptBlocks, planToWorkbenchDraft, workbenchDraftToSavedPlan,
window: { addEventListener: (key, fn) => events[key] = fn, removeEventListener: (key, fn) => { if (events[key] === fn) delete events[key]; } },
selectedMaterialNode: () => null, promptWidgetTargets: () => [], bindMaterialDrag() {},
anomalousConfirm: async () => true, anomalousAlert: async msg => { throw new Error(msg); }, showMaterialSaved() {},
jsonResponse: response => response.json(), loadMaterialPrompts: async () => ({ positive: 'imported', negative: '' }),
URL, URLSearchParams, AbortController, setTimeout, clearTimeout,
fetch: async (url, options) => {
sent = [url, options];
return { ok: true, json: async () => url.includes('save_prompt_plan') ? { status: 'success' } : { status: 'success', materials: [{ filename: 'p.json', name: 'Real prompt' }], total: 49, page: 1, pages: 2 } };
},
});
async function moduleFor(file, extra) {
const source = fs.readFileSync(`web/modules/${file}`, 'utf8').replace(/^import[\s\S]*?;\s*/gm, '');
const mod = new vm.SourceTextModule(source + `\nexport { ${extra} };`, {
context,
identifier: `file:///${process.cwd().replace(/\\/g, '/')}/web/modules/${file}`,
initializeImportMeta(meta, module) { meta.url = module.identifier; }
}); await mod.link(() => { throw new Error('unexpected import'); }); await mod.evaluate(); return mod.namespace;
}
const recipes = await moduleFor('ui_recipes.js', 'recipeMatchesFilter, updateRecipeFilterControls, getRecipeReadiness');
assert.equal(recipes.recipeMatchesFilter({ name: 'cat', workflow_scope: 'partial', tags: ['A'] }, 'cat', new Set(['a']), 'complete'), false);
assert.equal(recipes.recipeMatchesFilter({ name: 'cat', workflow_scope: 'partial', tags: ['A'] }, 'cat', new Set(['a']), 'partial'), true);
assert.equal(recipes.recipeMatchesFilter({ name: 'old' }, '', new Set(), 'complete'), true);
const recipeOwner = { recipeTagSelect: new Element('select'), recipeSelectedTags: new Set(['B']) };
recipes.updateRecipeFilterControls(recipeOwner, [{ data: { tags: ['A', 'B'] } }]);
assert.equal(recipeOwner.recipeTagSelect.children.length, 3); assert.equal(recipeOwner.recipeTagSelect.value, 'B');
assert.equal(recipes.getRecipeReadiness({ params: { model_references: [{ identity: { status: 'verified' }, currentAvailability: 'missing' }] } }).status, 'missing');
assert.equal(recipes.getRecipeReadiness({ params: { model_references: [{ identity: { status: 'verified' } }] } }).status, 'warning');
const drags = await moduleFor('material_drag.js', '');
const graph = { getNodeOnPos: () => null };
const surface = new Element('canvas'); surface.getBoundingClientRect = () => ({ left: 0, top: 0, right: 100, bottom: 100 });
context.app.graph = graph; context.app.canvas = { graph, canvas: surface, convertEventToCanvasOffset: () => [50, 50] };
const source = new Element('article'); let drops = 0;
drags.bindMaterialDrag(source, {}, { payload: () => ({}), dropOnCanvas: () => { drops++; } });
const event = target => ({ target, clientX: 50, clientY: 50, dataTransfer: { setData() {} }, preventDefault() {}, stopPropagation() {}, stopImmediatePropagation() {} });
source.listeners.dragstart(event(source)); await events.drop(event(new Element('button'))); assert.equal(drops, 0);
source.listeners.dragstart(event(source)); context.app.graph = {}; await events.drop(event(surface)); assert.equal(drops, 0); context.app.graph = graph;
source.listeners.dragstart(event(source)); await events.drop(event(surface)); assert.equal(drops, 1); assert.equal(events.drop, undefined);
console.log('studio contracts: recipe scope/tags/readiness, prompt detail shapes, active material drag cleanup passed');
+115
View File
@@ -0,0 +1,115 @@
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
export const all = node => [node, ...node.children.flatMap(all)];
export class Events {
listeners = new Map();
addEventListener(type, fn) { if (!this.listeners.has(type)) this.listeners.set(type, new Set()); this.listeners.get(type).add(fn); }
removeEventListener(type, fn) { this.listeners.get(type)?.delete(fn); }
dispatch(type, event = {}) { for (const fn of [...(this.listeners.get(type) || [])]) fn(event); }
listenerCount() { return [...this.listeners.values()].reduce((sum, set) => sum + set.size, 0); }
}
export class Element extends Events {
constructor(tag) {
super();
this.tagName = tag; this.children = []; this.dataset = {}; this.attrs = {};
this.className = ''; this.value = ''; this._text = ''; this.disabled = false;
this.style = { setProperty(key, value) { this[key] = value; }, removeProperty(key) { delete this[key]; } };
this.classList = {
contains: name => this.className.split(/\s+/).includes(name),
add: (...names) => { this.className = [...new Set([...this.className.split(/\s+/).filter(Boolean), ...names])].join(' '); },
remove: (...names) => { this.className = this.className.split(/\s+/).filter(name => !names.includes(name)).join(' '); },
toggle: (name, force) => { const enabled = force ?? !this.classList.contains(name); this.classList[enabled ? 'add' : 'remove'](name); return enabled; },
};
}
set textContent(value) { this.replaceChildren(); this._text = String(value); }
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set innerHTML(value) { this.textContent = value; }
get innerHTML() { return this.textContent; }
appendChild(child) { child.remove(); child.parentNode = this; this.children.push(child); return child; }
append(...children) { children.forEach(child => this.appendChild(child)); }
insertBefore(child, before) { if (!before) return this.appendChild(child); child.remove(); child.parentNode = this; this.children.splice(this.children.indexOf(before), 0, child); return child; }
prepend(child) { child.remove(); child.parentNode = this; this.children.unshift(child); }
replaceChildren(...children) { for (const child of this.children) child.parentNode = null; this.children = []; this._text = ''; this.append(...children); }
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
get parentElement() { return this.parentNode; }
get isConnected() { return this.tagName === 'body' || !!this.parentNode?.isConnected; }
get firstChild() { return this.children[0] || null; }
get options() { return this.children; }
cloneNode(deep = false) {
const copy = new Element(this.tagName);
copy.className = this.className; copy.value = this.value; copy._text = this._text;
copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset };
if (deep) copy.append(...this.children.map(child => child.cloneNode(true)));
return copy;
}
setAttribute(name, value) { this.attrs[name] = value; }
removeAttribute(name) { delete this.attrs[name]; }
getAttribute(name) { return this.attrs[name] ?? null; }
matches(selector) { return selector.startsWith('.') ? this.classList.contains(selector.slice(1)) : this.tagName === selector; }
querySelectorAll(selector) { return all(this).slice(1).filter(child => selector.split(',').some(part => child.matches(part.trim()))); }
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
closest(selector) { return this.matches(selector) ? this : this.parentNode?.closest(selector) || null; }
contains(child) { return all(this).includes(child); }
hasChildNodes() { return !!this.children.length; }
getBoundingClientRect() { return { width: parseFloat(this.style.width || this.style['--amb-translator-width']) || 580, height: 600, top: 0, left: 0, right: 580, bottom: 600 }; }
click() { if (!this.disabled) return this.onclick?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
showModal() { this.open = true; }
close() { this.open = false; this.onclose?.(); }
focus() {}
scrollIntoView() {}
}
export function fixture() {
const document = new Events();
document.body = new Element('body');
document.createElement = tag => new Element(tag);
document.createDocumentFragment = () => new Element('fragment');
document.querySelector = selector => document.body.querySelector(selector);
document.querySelectorAll = selector => document.body.querySelectorAll(selector);
const window = new Events();
Object.assign(window, { innerWidth: 1440, innerHeight: 900, anomalous_browser_lang: 'en' });
const storage = new Map();
const timers = new Map();
let timerId = 0;
const errors = [], requests = [], clipboard = [];
const node = { id: 1, type: 'CLIPTextEncode', widgets: [{ name: 'text', type: 'customtext', value: 'original' }], widgets_values: ['original'] };
const app = { graph: { getNodeById: id => id === node.id ? node : null }, canvas: { selected_nodes: { 1: node } } };
const state = {
app, node, window, document, errors, requests, clipboard,
async fetch(url) { return { ok: true, json: async () => String(url).includes('/materials?') ? { status: 'success', materials: [], pages: 1 } : { status: 'success', translated: 'translated' } }; },
};
const context = vm.createContext({ console, URL, URLSearchParams, Blob, AbortController, DOMException, structuredClone,
document, window, app, errors, anomalous_browser_lang: 'en',
setTimeout: callback => { timers.set(++timerId, callback); return timerId; }, clearTimeout: id => timers.delete(id),
requestAnimationFrame: callback => callback(), queueMicrotask,
localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, String(value)), removeItem: key => storage.delete(key) },
navigator: { clipboard: { async writeText(text) { clipboard.push(text); } } }, confirm: () => true,
fetch: async (url, options = {}) => { requests.push([url, options]); return state.fetch(url, options); },
});
const modules = new Map();
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../web/modules');
const load = filename => {
if (modules.has(filename)) return modules.get(filename);
let source;
if (filename.endsWith('/scripts/app.js')) source = 'export const app = globalThis.app;';
else if (filename.endsWith('/ui_dialog.js')) source = 'export async function anomalousAlert(message) { errors.push(message); } export async function anomalousConfirm() { return true; }';
else if (filename.endsWith('/material_feedback.js')) source = 'export function showMaterialSaved() {}';
else if (filename.endsWith('/ui_gallery_detail.js')) source = 'export function showImageWorkbench() {}';
else source = fs.readFileSync(filename, 'utf8');
const mod = new vm.SourceTextModule(source, { context, identifier: filename });
modules.set(filename, mod); return mod;
};
state.module = async name => {
const mod = load(path.resolve(root, name).replaceAll('\\', '/'));
if (mod.status === 'unlinked') await mod.link((specifier, parent) => load(path.resolve(path.dirname(parent.identifier), specifier).replaceAll('\\', '/')));
if (mod.status === 'linked') await mod.evaluate();
return mod.namespace;
};
state.button = (rootNode, label) => all(rootNode).find(el => el.tagName === 'button' && el.textContent.includes(label));
state.flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
state.runTimers = () => { const pending = [...timers.values()]; timers.clear(); pending.forEach(callback => callback()); };
return state;
}