svrnty-hermes-webui-plugin/static/bte.js
Svrnty b75fbf48ae
Some checks failed
plugin-tests / test (push) Failing after 6s
upstream-drift / drift (push) Failing after 6s
feat(bte panel): real SKU dropdown + contextual CMO chat handoff
Tranche C — BTE basics.

1. SKU dropdown now sources from Adwright's TopRecipes (top-selling
   offerings) via /api/adwright/last-panel-update?tool=adwright_list_recipes
   instead of bundled PLACEHOLDER_SKUS. Falls back to placeholders on
   any failure. state.skus drives both the toolbar select build and the
   name lookups in _openCmoChat / requestPhotoshoot. _refreshSkuOptions
   rewrites the <option> set in place so the dropdown updates without
   rebuilding the toolbar.

2. _openCmoChat now pre-fills the chat textarea with the operator's
   current BTE setup (brand, mode, media, recipe family, SKU, variants,
   selected asset) so they don't retype context every iteration.
   Switch + setTimeout(200) lets the chat panel mount before set value
   + dispatch input event. Doesn't auto-send — operator reviews then
   hits send themselves.

C3 (seed assets) closed without code: BTE backend at localhost:6001
already serves 10 real assets (1 evaluated with brandFit 4.2 ★3.8,
2 approved with hasImageData=true, 5 requested). Original "placeholder"
complaint was about the SKU dropdown, not the asset grid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:02:45 -04:00

607 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// svrnty-bte: BTE Command Center panel — brand asset gen + rate + iterate.
// IIFE, idempotent, namespace window.SvrntyBTE. Class prefix .svrnty-bte-*.
// Per COMMAND-CENTER-PRD §3 + PLANB-RECIPE-TAXONOMY.md §5.
(function () {
"use strict";
if (window.__svrntyBteLoaded) return;
window.__svrntyBteLoaded = true;
// ── Taxonomy (mirror of sot/07-BRAND/PLANB-RECIPE-TAXONOMY.md) ─────────────
const MODES = [
{ slug: "polished", label: "Polished" },
{ slug: "ugc", label: "UGC" },
{ slug: "photoreal", label: "Photorealistic" },
{ slug: "artistic", label: "Artistic" },
];
const MEDIA = [
{ slug: "image", label: "Image", enabled: true },
{ slug: "video", label: "Video (soon)", enabled: false },
];
const FAMILIES = [
{ slug: "hero-shot", label: "Hero Shot" },
{ slug: "lifestyle-shot", label: "Lifestyle Shot" },
{ slug: "photoshoot", label: "Photoshoot" },
{ slug: "recipe-sheet", label: "Recipe Sheet" },
{ slug: "montage-catalog", label: "Montage Catalog" },
];
// Placeholder SKUs — to be replaced with Woo catalog when piped in.
const PLACEHOLDER_SKUS = [
{ id: "42", name: "Poulet tao (placeholder)" },
{ id: "43", name: "Boeuf bourguignon (placeholder)" },
{ id: "44", name: "Saumon teriyaki (placeholder)" },
{ id: "45", name: "Tofu sauté (placeholder)" },
];
const PROXY_BASE = "/api/bte/proxy?path=";
const POLL_INTERVAL_MS = 2000;
// ── State ────────────────────────────────────────────────────────────────
const state = {
brand: "planb",
mode: "polished",
media: "image",
family: null,
skuId: null,
variants: 4,
batch: false,
runId: null,
assets: [], // [{id, thumbUrl, lifecycle, ratingCount, meanScore, recipeSlug, ...}]
selected: null, // asset id
pollTimer: null,
log: [],
// SKU options surfaced in the toolbar dropdown. Defaults to the bundled
// placeholders until _fetchSkuOptions replaces them with real top-selling
// offerings from Adwright's TopRecipes RPC.
skus: PLACEHOLDER_SKUS.slice(),
};
// ── Public namespace ─────────────────────────────────────────────────────
const SvrntyBTE = {
open: () => _openOverlay(),
close: () => _closeOverlay(),
state,
};
window.SvrntyBTE = SvrntyBTE;
// ── Init: listen for sidebar nav events from svrnty_nav.js ───────────────
// The floating launcher is gone; sidebar button (injected by svrnty_nav.js)
// calls switchPanel('bte') which sets main.svrnty-showing-bte. We mirror
// that with overlay open/close + remove any stale launcher from prior load.
function _init() {
// Remove any prior floating launcher (older plugin versions injected one).
document.querySelectorAll(".svrnty-bte-launcher").forEach((el) => el.remove());
window.addEventListener("svrnty:panel-switch", (ev) => {
const name = ev && ev.detail && ev.detail.name;
if (name === "bte") _openOverlay();
else _closeOverlay();
});
// If page boots already on bte (deep link via switchPanel), show now.
const main = document.querySelector("main.main");
if (main && main.classList.contains("svrnty-showing-bte")) _openOverlay();
}
// ── Overlay lifecycle ────────────────────────────────────────────────────
// Mount inside <main> so the left sidebar stays visible. CSS
// (.svrnty-bte-overlay rules in bte.css) handles show/hide via
// main.svrnty-showing-bte class set by svrnty_nav.js wrapper.
function _openOverlay() {
let overlay = document.getElementById("svrntyBteOverlay");
const mountTarget = document.querySelector("main.main") || document.body;
if (!overlay) {
overlay = _buildOverlay();
mountTarget.appendChild(overlay);
} else if (overlay.parentElement !== mountTarget && mountTarget.tagName === "MAIN") {
// Migrate from body to main (handles older sessions that mounted on body).
mountTarget.appendChild(overlay);
}
overlay.hidden = false;
_refreshGrid();
_fetchSkuOptions();
}
// Pull top-selling offerings from Adwright (TopRecipes) so the SKU
// dropdown reflects what's actually shipping, not the bundled PLACEHOLDER
// list. Best-effort — falls through to placeholders on any failure.
function _fetchSkuOptions() {
fetch("/api/adwright/last-panel-update?session_id=&since=0&tool=adwright_list_recipes")
.then((r) => r.json())
.then((r) => {
const recipes = r && r.update && r.update.payload && r.update.payload.recipes;
if (!Array.isArray(recipes) || !recipes.length) return;
state.skus = recipes.map((rec) => ({
id: String(rec.product_id || rec.id || ""),
name: rec.name || "",
})).filter((s) => s.id && s.name);
_refreshSkuOptions();
})
.catch(() => { /* fallback: keep placeholders */ });
}
function _refreshSkuOptions() {
const sel = document.getElementById("svrntyBteSku");
if (!sel) return;
const current = sel.value;
let html = '<option value="">— pick SKU —</option>';
state.skus.forEach((s) => {
html += '<option value="' + _esc(s.id) + '">' + _esc(s.name) + "</option>";
});
sel.innerHTML = html;
if (current) sel.value = current;
}
function _closeOverlay() {
const overlay = document.getElementById("svrntyBteOverlay");
if (overlay) overlay.hidden = true;
if (state.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
}
}
// ── Overlay DOM build ────────────────────────────────────────────────────
function _buildOverlay() {
const root = document.createElement("div");
root.id = "svrntyBteOverlay";
root.className = "svrnty-bte-overlay";
root.appendChild(_buildToolbar());
const body = document.createElement("div");
body.className = "svrnty-bte-body";
body.appendChild(_buildMain());
body.appendChild(_buildRail());
root.appendChild(body);
return root;
}
function _buildToolbar() {
const bar = document.createElement("div");
bar.className = "svrnty-bte-toolbar";
const title = document.createElement("span");
title.className = "svrnty-bte-toolbar-title";
title.textContent = "BTE Command Center";
bar.appendChild(title);
// Brand selector (single-option for now — Plan B)
bar.appendChild(_labeled("Brand", _select("svrntyBteBrand",
[{ value: "planb", label: "Plan B" }],
state.brand, (v) => { state.brand = v; })));
// Content mode pills
bar.appendChild(_pillGroup("Mode", "svrnty-bte-pill-mode", MODES, state.mode, (v) => {
state.mode = v; _updateToolbarPressed(); _updateGenerateEnabled();
}));
// Media toggle
bar.appendChild(_pillGroup("Media", "svrnty-bte-pill-media",
MEDIA.map((m) => ({ slug: m.slug, label: m.label, disabled: !m.enabled })),
state.media, (v) => {
state.media = v; _updateToolbarPressed(); _updateGenerateEnabled();
}));
// Recipe family pills
bar.appendChild(_pillGroup("Recipe", "svrnty-bte-pill-family", FAMILIES, state.family, (v) => {
state.family = v; _updateToolbarPressed(); _updateGenerateEnabled(); _refreshGrid();
}));
// SKU dropdown — populated from state.skus (placeholder at boot,
// replaced by real top-selling offerings via _fetchSkuOptions).
const skuOpts = [{ value: "", label: "— pick SKU —" }].concat(
state.skus.map((s) => ({ value: s.id, label: s.name })));
bar.appendChild(_labeled("SKU", _select("svrntyBteSku", skuOpts, state.skuId || "", (v) => {
state.skuId = v || null; _updateGenerateEnabled();
})));
// Variants stepper
const stepper = document.createElement("input");
stepper.type = "number";
stepper.min = "1"; stepper.max = "12"; stepper.value = String(state.variants);
stepper.className = "svrnty-bte-stepper";
stepper.addEventListener("change", () => {
const n = parseInt(stepper.value, 10);
state.variants = isNaN(n) ? 1 : Math.max(1, Math.min(12, n));
stepper.value = String(state.variants);
});
bar.appendChild(_labeled("Variants", stepper));
// Batch toggle
bar.appendChild(_pillGroup("Run", "svrnty-bte-pill-batch",
[{ slug: "single", label: "Single" }, { slug: "batch", label: "Batch" }],
state.batch ? "batch" : "single",
(v) => { state.batch = (v === "batch"); }));
// Generate button
const gen = document.createElement("button");
gen.id = "svrntyBteGenerate";
gen.className = "svrnty-bte-generate";
gen.type = "button";
gen.textContent = "Generate";
gen.disabled = true;
gen.addEventListener("click", _onGenerate);
bar.appendChild(gen);
// Close
const close = document.createElement("button");
close.className = "svrnty-bte-close";
close.type = "button";
close.textContent = "Close";
close.addEventListener("click", _closeOverlay);
bar.appendChild(close);
return bar;
}
function _buildMain() {
const main = document.createElement("div");
main.className = "svrnty-bte-main";
main.id = "svrntyBteMain";
main.innerHTML =
'<div id="svrntyBteBanner"></div>' +
'<div class="svrnty-bte-empty" id="svrntyBteEmpty">' +
'Pick a mode, recipe family, and SKU to begin. Recent renders for the selected cell will appear here.' +
'</div>' +
'<div class="svrnty-bte-grid" id="svrntyBteGrid"></div>' +
'<div id="svrntyBteDetail"></div>';
return main;
}
function _buildRail() {
const rail = document.createElement("div");
rail.className = "svrnty-bte-rail";
rail.innerHTML =
'<div class="svrnty-bte-rail-section">' +
'<h4>CMO chat</h4>' +
'<p>Tell CMO what to change about the next batch — warmer light, less white space, different framing — then re-run.</p>' +
'<button class="svrnty-bte-cmo-link" id="svrntyBteCmoOpen" type="button">Open CMO chat →</button>' +
'</div>' +
'<div class="svrnty-bte-rail-section">' +
'<h4>Run status</h4>' +
'<div class="svrnty-bte-run-log" id="svrntyBteRunLog">No active run.</div>' +
'</div>' +
'<div class="svrnty-bte-rail-section">' +
'<h4>Selected cell</h4>' +
'<p id="svrntyBteCellLabel">—</p>' +
'</div>';
// CMO chat link: simplest honest path — switch the WebUI rail to the chat
// panel so JP can talk to whichever profile is active. Real CMO-scoped
// iframe deferred (PRD §6.5 option A — Phase E).
setTimeout(() => {
const cmoBtn = document.getElementById("svrntyBteCmoOpen");
if (cmoBtn) cmoBtn.addEventListener("click", _openCmoChat);
}, 0);
return rail;
}
// ── DOM helpers ──────────────────────────────────────────────────────────
function _labeled(label, child) {
const wrap = document.createElement("div");
wrap.className = "svrnty-bte-toolbar-group";
const lab = document.createElement("span");
lab.className = "svrnty-bte-toolbar-label";
lab.textContent = label;
wrap.appendChild(lab);
wrap.appendChild(child);
return wrap;
}
function _select(id, options, value, onChange) {
const sel = document.createElement("select");
sel.id = id;
sel.className = "svrnty-bte-select";
options.forEach((o) => {
const opt = document.createElement("option");
opt.value = o.value;
opt.textContent = o.label;
if (o.value === value) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener("change", () => onChange(sel.value));
return sel;
}
function _pillGroup(label, className, items, currentValue, onPick) {
const wrap = document.createElement("div");
wrap.className = "svrnty-bte-toolbar-group";
const lab = document.createElement("span");
lab.className = "svrnty-bte-toolbar-label";
lab.textContent = label;
wrap.appendChild(lab);
items.forEach((it) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "svrnty-bte-pill " + className;
btn.dataset.value = it.slug;
btn.textContent = it.label;
btn.setAttribute("aria-pressed", it.slug === currentValue ? "true" : "false");
if (it.disabled) btn.disabled = true;
btn.addEventListener("click", () => {
if (btn.disabled) return;
onPick(it.slug);
});
wrap.appendChild(btn);
});
return wrap;
}
function _updateToolbarPressed() {
const sync = (cls, val) => {
document.querySelectorAll("." + cls).forEach((b) => {
b.setAttribute("aria-pressed", b.dataset.value === val ? "true" : "false");
});
};
sync("svrnty-bte-pill-mode", state.mode);
sync("svrnty-bte-pill-media", state.media);
sync("svrnty-bte-pill-family", state.family);
sync("svrnty-bte-pill-batch", state.batch ? "batch" : "single");
_renderCellLabel();
}
function _updateGenerateEnabled() {
const gen = document.getElementById("svrntyBteGenerate");
if (!gen) return;
gen.disabled = !(state.mode && state.media && state.family && state.skuId);
}
function _renderCellLabel() {
const el = document.getElementById("svrntyBteCellLabel");
if (!el) return;
if (!state.family) { el.textContent = "—"; return; }
el.textContent = `${state.family}__${state.mode}__${state.media}`;
}
// ── Generate flow ────────────────────────────────────────────────────────
function _onGenerate() {
if (!state.family || !state.skuId) return;
const sku = state.skus.find((s) => s.id === state.skuId);
const recipeLabel = `${state.family}__${state.mode}__${state.media}`;
const body = {
brandId: state.brand,
recipeSlug: state.family,
recipeLabel: recipeLabel,
items: [{ offeringId: parseInt(state.skuId, 10), offeringName: sku ? sku.name : state.skuId }],
variantsPerScenario: state.variants,
tags: { source: "svrnty-bte-panel" },
};
_log(`POST requestPhotoshoot (${recipeLabel}, sku=${state.skuId}, variants=${state.variants})`);
_proxyPost("/api/command/requestPhotoshoot", body)
.then((resp) => {
if (resp.status === 404 || resp.status === 501) {
_banner("BTE endpoint /api/command/requestPhotoshoot not yet implemented — see COMMAND-CENTER-PRD §5.8 (status: implementing).", "warn");
_log(`${resp.status} (endpoint not yet built)`);
return;
}
if (!resp.ok) {
_banner(`BTE returned ${resp.status}: ${resp.bodyText.slice(0, 200)}`, "warn");
_log(`${resp.status} ${resp.bodyText.slice(0, 80)}`);
return;
}
const json = _safeJson(resp.bodyText) || {};
state.runId = json.runId || json.run_id || null;
_log(`← runId=${state.runId || "(none)"}, assetIds=${(json.assetIds || []).length}`);
_banner(`Run ${state.runId || "started"} — polling grid every ${POLL_INTERVAL_MS / 1000}s.`, "info");
_startPolling();
})
.catch((e) => {
_banner(`Proxy error: ${e.message}`, "warn");
_log(`× ${e.message}`);
});
}
// ── Grid refresh + polling ───────────────────────────────────────────────
// Uses /api/query/assetDtos (live BTE endpoint) instead of /api/query/assetGrid
// (PRD §5.4 spec — not yet implemented). Returns all brand-scoped assets so
// the grid shows every render CMO has produced via bte_image_generate.
function _refreshGrid() {
// assetDtos is brand-scoped, not recipe-scoped. Always fetch.
const body = { pageSize: 48, pageNumber: 1, sortBy: "createdAt", sortDescending: true };
_proxyPost("/api/query/assetDtos", body)
.then((resp) => {
if (!resp.ok) {
_log(`assetDtos ← ${resp.status}`);
return;
}
const json = _safeJson(resp.bodyText) || { data: [] };
// Normalize AssetDto rows to the panel's `assets` shape so _renderGrid stays unchanged.
state.assets = (json.data || [])
.filter((a) => a.brandId === state.brand || !state.brand || state.brand === "planb")
.map((a) => ({
id: a.id,
thumbUrl: a.hasImageData ? ("/api/assets/" + a.id + "/image") : null,
lifecycle: a.status,
ratingCount: 0,
meanScore: a.visualPolishScore || a.brandFitScore || null,
recipeSlug: state.family || "(any)",
prompt: a.prompt || "",
width: a.width,
height: a.height,
createdAt: a.createdAt,
}));
_renderGrid();
const stillInFlight = state.assets.some((a) => a.lifecycle === "requested" || a.lifecycle === "generating" || a.lifecycle === "evaluating");
if (!stillInFlight && state.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
_log("polling stopped — no in-flight renders");
}
})
.catch((e) => _log(`assetDtos × ${e.message}`));
}
function _startPolling() {
if (state.pollTimer) clearInterval(state.pollTimer);
_refreshGrid();
state.pollTimer = setInterval(_refreshGrid, POLL_INTERVAL_MS);
}
function _renderGrid() {
const grid = document.getElementById("svrntyBteGrid");
const empty = document.getElementById("svrntyBteEmpty");
if (!grid || !empty) return;
if (!state.assets.length) {
grid.innerHTML = "";
empty.style.display = "";
return;
}
empty.style.display = "none";
grid.innerHTML = state.assets.map((a) => {
const thumb = a.thumbUrl
? `<img src="${PROXY_BASE}${encodeURIComponent(a.thumbUrl)}" alt="">`
: '<span>no thumb</span>';
const score = (a.meanScore != null) ? ` · ★${a.meanScore.toFixed(1)}` : "";
const sel = (a.id === state.selected) ? " svrnty-bte-card-selected" : "";
return (
`<div class="svrnty-bte-card${sel}" data-id="${_esc(a.id)}">` +
`<div class="svrnty-bte-card-thumb">${thumb}</div>` +
`<div class="svrnty-bte-card-meta">` +
`<span class="svrnty-bte-status svrnty-bte-status-${_esc(a.lifecycle || "generating")}">${_esc(a.lifecycle || "—")}</span>` +
`<span>${a.ratingCount || 0}r${score}</span>` +
`</div>` +
`</div>`
);
}).join("");
grid.querySelectorAll(".svrnty-bte-card").forEach((card) => {
card.addEventListener("click", () => _selectAsset(card.dataset.id));
});
}
function _selectAsset(id) {
state.selected = id;
const asset = state.assets.find((a) => a.id === id);
_renderGrid();
_renderDetail(asset);
}
function _renderDetail(asset) {
const host = document.getElementById("svrntyBteDetail");
if (!host) return;
if (!asset) { host.innerHTML = ""; return; }
const thumb = asset.thumbUrl
? `<img src="${PROXY_BASE}${encodeURIComponent(asset.thumbUrl)}" alt="">`
: "no preview";
host.innerHTML =
'<div class="svrnty-bte-detail">' +
`<div class="svrnty-bte-detail-preview">${thumb}</div>` +
'<dl class="svrnty-bte-detail-meta">' +
`<dt>asset id</dt><dd>${_esc(asset.id)}</dd>` +
`<dt>recipe</dt><dd>${_esc(asset.recipeSlug || "—")} v${asset.recipeVersion || "?"}</dd>` +
`<dt>lifecycle</dt><dd>${_esc(asset.lifecycle || "—")}</dd>` +
`<dt>ratings</dt><dd>${asset.ratingCount || 0} · mean ${asset.meanScore != null ? asset.meanScore.toFixed(2) : "—"}</dd>` +
'<dt>rate</dt><dd>' +
'<div class="svrnty-bte-rate-row">' +
`<button class="svrnty-bte-rate-btn svrnty-bte-rate-btn-up" data-v="1" data-id="${_esc(asset.id)}" type="button">+1</button>` +
`<button class="svrnty-bte-rate-btn svrnty-bte-rate-btn-down" data-v="-1" data-id="${_esc(asset.id)}" type="button">1</button>` +
'<select class="svrnty-bte-select" id="svrntyBteScore">' +
'<option value="">— score —</option>' +
'<option value="1">1</option><option value="2">2</option><option value="3">3</option><option value="4">4</option><option value="5">5</option>' +
'</select>' +
'</div>' +
`<textarea class="svrnty-bte-comment" id="svrntyBteComment" placeholder="Comment (optional) — e.g. 'too plastic', 'needs warmer light'"></textarea>` +
'</dd>' +
'</dl>' +
'</div>';
host.querySelectorAll(".svrnty-bte-rate-btn").forEach((btn) => {
btn.addEventListener("click", () => _rateAsset(btn.dataset.id, parseInt(btn.dataset.v, 10)));
});
}
function _rateAsset(id, verdict) {
const scoreSel = document.getElementById("svrntyBteScore");
const commentEl = document.getElementById("svrntyBteComment");
const body = {
assetId: id,
verdict: verdict > 0 ? "accept" : "reject",
score: scoreSel && scoreSel.value ? parseInt(scoreSel.value, 10) : null,
comment: commentEl ? (commentEl.value || "").trim() : "",
ratedBy: localStorage.getItem("svrnty-bte.rater") || "jp",
};
_log(`POST rateAsset ${id} ${body.verdict}`);
_proxyPost("/api/command/rateAsset", body)
.then((resp) => {
if (resp.status === 404 || resp.status === 501) {
_banner("BTE /api/command/rateAsset not yet implemented (PRD §7).", "warn");
return;
}
if (!resp.ok) {
_log(`rateAsset ← ${resp.status}`);
return;
}
_log(`rateAsset ← ok`);
_refreshGrid();
})
.catch((e) => _log(`rateAsset × ${e.message}`));
}
// ── CMO chat handoff ─────────────────────────────────────────────────────
function _openCmoChat() {
// Pre-fill the chat textarea with BTE state context so the operator
// doesn't have to retype "I'm in BTE, brand Plan B, mode Polished, …".
// Don't auto-send — user reviews + edits + hits send. Full CMO-scoped
// iframe is COMMAND-CENTER-PRD §6.5 Phase E (not v1).
const modeL = MODES.find((m) => m.slug === state.mode);
const familyL = FAMILIES.find((f) => f.slug === state.family);
const sku = state.skus.find((s) => s.id === state.skuId);
const lines = ["I'm working in BTE Command Center. Help me iterate."];
lines.push("- Brand: " + state.brand);
if (modeL) lines.push("- Mode: " + modeL.label);
lines.push("- Media: " + state.media);
if (familyL) lines.push("- Recipe family: " + familyL.label);
if (sku) lines.push("- SKU: " + sku.name);
lines.push("- Variants: " + state.variants);
if (state.selected) lines.push("- Selected asset: " + state.selected);
const prompt = lines.join("\n") + "\n\nWhat should I change next?";
_closeOverlay();
if (typeof window.switchPanel === "function") {
try { window.switchPanel("chat", { fromRailClick: true }); } catch (_) { /* ignore */ }
}
// Defer pre-fill so the chat panel + #msg textarea mount first.
setTimeout(() => {
const msg = document.getElementById("msg");
if (!msg) return;
msg.value = prompt;
try { msg.dispatchEvent(new Event("input", { bubbles: true })); } catch (_) {}
try { msg.focus(); } catch (_) {}
}, 200);
}
// ── Proxy helpers ────────────────────────────────────────────────────────
function _proxyPost(btePath, body) {
return fetch(PROXY_BASE + encodeURIComponent(btePath), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then(_responseEnvelope);
}
function _responseEnvelope(r) {
return r.text().then((t) => ({ ok: r.ok, status: r.status, bodyText: t }));
}
function _safeJson(s) {
try { return JSON.parse(s); } catch (_) { return null; }
}
// ── UI utils ─────────────────────────────────────────────────────────────
function _banner(text, kind) {
const host = document.getElementById("svrntyBteBanner");
if (!host) return;
host.innerHTML = `<div class="svrnty-bte-banner svrnty-bte-banner-${kind === "warn" ? "warn" : "info"}">${_esc(text)}</div>`;
}
function _log(line) {
state.log.unshift(`${new Date().toLocaleTimeString()} ${line}`);
state.log = state.log.slice(0, 50);
const el = document.getElementById("svrntyBteRunLog");
if (el) el.textContent = state.log.join("\n");
}
function _esc(s) {
return String(s == null ? "" : s).replace(/[<>&"]/g, (c) =>
({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" }[c]));
}
// ── Boot ─────────────────────────────────────────────────────────────────
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _init);
} else {
_init();
}
})();