// 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 ──────────────────────────────────────────────────── // Mount inside
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(); } 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(); } })();