// 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: [],
};
// ── 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 ────────────────────────────────────────────────────
function _openOverlay() {
let overlay = document.getElementById("svrntyBteOverlay");
if (!overlay) {
overlay = _buildOverlay();
document.body.appendChild(overlay);
}
overlay.hidden = false;
_refreshGrid();
}
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
const skuOpts = [{ value: "", label: "— pick SKU —" }].concat(
PLACEHOLDER_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 =
'
' +
'' +
'Pick a mode, recipe family, and SKU to begin. Recent renders for the selected cell will appear here.' +
'
' +
'' +
'';
return main;
}
function _buildRail() {
const rail = document.createElement("div");
rail.className = "svrnty-bte-rail";
rail.innerHTML =
'' +
'
CMO chat
' +
'
Tell CMO what to change about the next batch — warmer light, less white space, different framing — then re-run.
' +
'
' +
'
' +
'' +
'
Run status
' +
'
No active run.
' +
'
' +
'' +
'
Selected cell
' +
'
—
' +
'
';
// 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 = PLACEHOLDER_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 ───────────────────────────────────────────────
function _refreshGrid() {
if (!state.family) return;
const filters = {
brandId: state.brand,
recipeSlug: state.family,
lifecycle: ["approved", "generating", "evaluating"],
};
if (state.runId) filters.runId = state.runId;
const body = {
filters: filters,
page: 1,
pageSize: 24,
sort: "-created_at",
};
_proxyPost("/api/query/assetGrid", body)
.then((resp) => {
if (resp.status === 404 || resp.status === 501) {
state.assets = [];
_renderGrid();
_banner("BTE endpoint /api/query/assetGrid not yet implemented (PRD §5.4 status: implementing). Grid will populate once endpoint ships.", "warn");
return;
}
if (!resp.ok) {
_log(`assetGrid ← ${resp.status}`);
return;
}
const json = _safeJson(resp.bodyText) || { items: [] };
state.assets = json.items || [];
_renderGrid();
// Stop polling when no rendering asset remains.
const stillInFlight = state.assets.some((a) => a.lifecycle === "generating" || a.lifecycle === "evaluating");
if (!stillInFlight && state.pollTimer) {
clearInterval(state.pollTimer);
state.pollTimer = null;
_log("polling stopped — all renders complete");
}
})
.catch((e) => _log(`assetGrid × ${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
? `
`
: 'no thumb';
const score = (a.meanScore != null) ? ` · ★${a.meanScore.toFixed(1)}` : "";
const sel = (a.id === state.selected) ? " svrnty-bte-card-selected" : "";
return (
`` +
`
${thumb}
` +
`
` +
`${_esc(a.lifecycle || "—")}` +
`${a.ratingCount || 0}r${score}` +
`
` +
`
`
);
}).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
? `
`
: "no preview";
host.innerHTML =
'' +
`
${thumb}
` +
'
' +
`- asset id
- ${_esc(asset.id)}
` +
`- recipe
- ${_esc(asset.recipeSlug || "—")} v${asset.recipeVersion || "?"}
` +
`- lifecycle
- ${_esc(asset.lifecycle || "—")}
` +
`- ratings
- ${asset.ratingCount || 0} · mean ${asset.meanScore != null ? asset.meanScore.toFixed(2) : "—"}
` +
'- rate
- ' +
'
' +
`` +
`` +
'' +
'
' +
`` +
' ' +
'
' +
'
';
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() {
// Honest minimum: surface the WebUI chat panel (whichever profile is
// active). Full CMO-scoped iframe / component reuse is COMMAND-CENTER-PRD
// §6.5 Phase E — not v1 of this panel.
_closeOverlay();
if (typeof window.switchPanel === "function") {
try { window.switchPanel("chat", { fromRailClick: true }); } catch (_) { /* ignore */ }
}
}
// ── 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 = `${_esc(text)}
`;
}
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) =>
({ "<": "<", ">": ">", "&": "&", '"': """ }[c]));
}
// ── Boot ─────────────────────────────────────────────────────────────────
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _init);
} else {
_init();
}
})();