scripts/lab-ui.jsjavascript
/**
* @module lab-ui
* @description
* Presentation controller for the redesigned lab. It does NOT do detection,
* matching, or saving — the engine (main.js, engine*.js, auto-find-loop.js,
* ghostyles-manager.js, plugins3d-loader.js) still owns all of that. This
* module only supplies the new information architecture on top of the engine's
* existing DOM ids and event bus:
*
* View bar -> toggles which overlay canvas is visible
* Readout <- engine `matchStateChanged` events
* Action rail -> proxies the two pinned Ghostyles to the engine's buttons
* Dock -> routes to Screens (reusing #settingsDrawer / #historyDrawer)
* Plugin bar -> swaps in for the Dock when a command Ghostyle is active
* Badges <- faces via #dbCountBadge (engine), uploads via recordings
*
* Everything that reaches into the engine is guarded and marked `INTEGRATION:`
* where the assumption must be confirmed against live engine behaviour. This
* file has NOT been run against a camera + models; treat it as a first pass.
*/
import { state } from './state.js';
// Drives which landmark visualisation bbox-overlay paints onto #bboxOverlay.
// INTEGRATION: export name confirmed in bbox-overlay.js (setOverlayMode).
import { setOverlayMode } from './bbox-overlay.js';
import { applyI18n, t } from './i18n.js';
import { initUploadConsentFlow } from './upload-consent.js';
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const on = (el, ev, fn) => el && el.addEventListener(ev, fn);
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ---- icons for the pinned rail slots (heuristic by ghostyle id) ---------- */
const ICONS = {
brush: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 14.5 4 20l4 .5.5-4z"/><path d="M15 6l3 3"/><path d="M8.5 15.5 18 6a2.1 2.1 0 0 0-3-3L5.5 12.5"/></svg>',
stripes: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 4v16M10 4v16M15 4v16M20 4v16"/></svg>',
ghost: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2a8 8 0 0 0-8 8v10.2c0 .9 1 1.4 1.7.8l1.6-1.2 1.7 1.3c.4.3.9.3 1.2 0l1.8-1.3 1.8 1.3c.3.3.8.3 1.2 0l1.7-1.3 1.6 1.2c.7.6 1.7.1 1.7-.8V10a8 8 0 0 0-8-8z"/><circle cx="9.2" cy="10.5" r="1.4" fill="#1a130b"/><circle cx="14.8" cy="10.5" r="1.4" fill="#1a130b"/></svg>'
};
const iconFor = id => /brush/i.test(id) ? ICONS.brush : /strip/i.test(id) ? ICONS.stripes : ICONS.ghost;
/* Ghostyle ids that carry their own bottom-bar controls (brush strokes, stripe
params, …). INTEGRATION: extend this list / detect it from the plugin
manifest as command Ghostyles are added. */
const COMMAND_GHOSTYLES = ['brush', 'stripes', 'uv-stripes'];
const els = {
viewer: $('#viewer'),
readout: $('#readout'), num: $('#gm-num'), thr: $('#gm-thr'), stateTxt: $('#gm-state'), spark: $('#gm-spark'),
dock: $('#gm-dock'), pluginbar: $('#gm-pluginbar'), pbTitle: $('#gm-pluginbar-title'), pbClose: $('#gm-pluginbar-close'),
ghostyles: $('#ghostylesContainer'),
facesBadge: $('#dbCountBadge'), uploadBadge: $('#gm-upload-badge'), uploadNav: $('#gm-nav-upload'), uploadCount: $('#gm-upload-count'),
toast: $('#gm-toast'), recdot: $('#gm-recdot')
};
const bus = state && state.gstmxxEvents; // the engine event target
const THR_DEFAULT = 0.58;
const ui = { pins: [], recordings: 0, THR: THR_DEFAULT, history: [] };
/* ---- toast --------------------------------------------------------------- */
let toastT;
function toast(msg) {
if (!els.toast) return;
els.toast.textContent = msg; els.toast.classList.add('show');
clearTimeout(toastT); toastT = setTimeout(() => els.toast.classList.remove('show'), 1600);
}
/* ---- View bar ------------------------------------------------------------ */
// Camera = clean (bbox canvas hidden); 2D points / 3D mesh = bbox-overlay draws
// the corresponding visualisation onto #bboxOverlay. The effect canvases
// (#overlay, #mesh3dOverlay) are never hidden.
const VIEW_TO_MODE = { off: 'bbox', '2d': '2d', '3d': 'mesh' };
function applyView(v) {
if (els.viewer) els.viewer.dataset.view = v;
const bbox = document.getElementById('bboxOverlay');
if (bbox) bbox.classList.toggle('gm-canvas-hidden', v === 'off');
try { setOverlayMode(VIEW_TO_MODE[v] || 'bbox'); } catch (_) { /* engine not ready */ }
}
$$('.seg').forEach(seg => on(seg, 'click', () => {
$$('.seg').forEach(s => s.setAttribute('aria-selected', 'false'));
seg.setAttribute('aria-selected', 'true');
applyView(seg.dataset.view);
}));
/* ---- Screens (dock routing) --------------------------------------------- */
const SCREENS = { faces: '#historyDrawer', settings: '#settingsDrawer', upload: '#gm-screen-upload', gallery: '#gm-screen-gallery', info: '#gm-screen-info' };
function hideAllScreens() { Object.values(SCREENS).forEach(sel => { const el = $(sel); if (el) el.classList.add('hidden'); }); }
function openScreen(key) {
const el = $(SCREENS[key]); if (!el) return;
const wasOpen = !el.classList.contains('hidden');
hideAllScreens();
$$('.navbtn').forEach(b => b.setAttribute('aria-current', 'false'));
if (!wasOpen) {
el.classList.remove('hidden');
const btn = $(`.navbtn[data-screen="${key}"]`); if (btn) btn.setAttribute('aria-current', 'true');
// The engine renders saved faces only on `dbChanged` / its own drawer-open;
// our IA opens the drawer directly, so nudge a re-render. Safe: bbox-overlay
// only resets its view when count === 0.
if (key === 'faces' && bus && state.db) {
try { bus.dispatchEvent(new CustomEvent('dbChanged', { detail: { count: (state.db.faces || []).length } })); } catch (_) {}
}
}
}
$$('.navbtn[data-screen]').forEach(b => on(b, 'click', () => {
if (b.getAttribute('aria-disabled') === 'true') { toast(t('record_clip_first_toast')); return; }
openScreen(b.dataset.screen);
}));
// screen close buttons (also bound by the engine; harmless to double-bind)
on($('#closeSettingsBtn'), 'click', () => { hideAllScreens(); $$('.navbtn').forEach(b => b.setAttribute('aria-current', 'false')); });
on($('#closeHistoryBtn'), 'click', () => { hideAllScreens(); $$('.navbtn').forEach(b => b.setAttribute('aria-current', 'false')); });
$$('[data-close-screen]').forEach(b => on(b, 'click', () => { hideAllScreens(); $$('.navbtn').forEach(n => n.setAttribute('aria-current', 'false')); }));
hideAllScreens();
/* ---- Fullscreen / threshold display (Settings) --------------------------- */
on($('#gm-fs'), 'click', () => {
const t = document.documentElement;
if (!document.fullscreenElement) {
(t.requestFullscreen ? t.requestFullscreen() : Promise.reject())
.then(() => { $('#gm-fs').textContent = t('exit_fullscreen_button'); })
.catch(() => toast(t('fullscreen_not_available_toast')));
} else { document.exitFullscreen(); $('#gm-fs').textContent = t('enter_fullscreen_button'); }
});
on($('#gm-thr-input'), 'input', e => {
const v = parseFloat(e.target.value);
if (!Number.isFinite(v)) return;
ui.THR = v;
if (els.thr) els.thr.textContent = '/ ' + v.toFixed(2);
// INTEGRATION: this only changes the readout's reference line. To change the
// engine's real matching threshold, call a setter if the engine exposes one:
if (window.gstmxx && typeof window.gstmxx.setMatchThreshold === 'function') {
try { window.gstmxx.setMatchThreshold(v); } catch (_) {}
}
});
/* ---- Record -> upload gating -------------------------------------------- */
on($('#recordBtn'), 'click', () => {
if (els.recdot) { els.recdot.classList.add('on'); setTimeout(() => els.recdot.classList.remove('on'), reduce ? 200 : 1600); }
});
if (bus) {
bus.addEventListener('uploadQueueChanged', (event) => {
ui.recordings = Number(event.detail && event.detail.count) || 0;
refreshUploadGate();
});
}
function refreshUploadGate() {
if (els.uploadBadge) { els.uploadBadge.hidden = ui.recordings === 0; els.uploadBadge.textContent = ui.recordings; }
if (els.uploadNav) els.uploadNav.setAttribute('aria-disabled', ui.recordings === 0 ? 'true' : 'false');
if (els.uploadCount) els.uploadCount.textContent = ui.recordings ? t('clips_ready_to_send', { count: ui.recordings }) : t('no_clips_recorded');
}
initUploadConsentFlow();
/* ---- Pinned Ghostyles on the rail --------------------------------------- */
async function resolvePins() {
// Prefer ghostyles.json order (so the JSON maintainer controls the defaults).
try {
const res = await fetch('ghostyles.json', { cache: 'no-store' });
if (res.ok) {
const list = await res.json();
const ids = (Array.isArray(list) ? list : list.ghostyles || [])
.map(g => (typeof g === 'string' ? g : g.id)).filter(Boolean)
.filter(id => id !== '00-template');
if (ids.length) return ids.slice(0, 2);
}
} catch (_) { /* fall through */ }
// Fallback: first two ghostyle buttons the engine built.
return $$('.preview-btn[data-effect]', els.ghostyles).slice(0, 2).map(b => b.dataset.effect);
}
function ghostyleName(id) {
const btn = $(`.preview-btn[data-effect="${id}"]`);
const t = btn && btn.querySelector('.preview-btn__title');
return (t && t.textContent.trim()) || (btn && btn.textContent.trim()) || id;
}
function shortCap(name) { return (name || '').split(/\s+/).pop().slice(0, 8) || '—'; }
function updatePinButtonStates() {
$$('.ghostyle-row').forEach(row => {
const id = row.dataset.effect;
const pin1Btn = row.querySelector('.pin-btn--1');
const pin2Btn = row.querySelector('.pin-btn--2');
if (pin1Btn) {
const isPinned1 = ui.pins[0] === id;
pin1Btn.classList.toggle('active', isPinned1);
pin1Btn.setAttribute('aria-pressed', isPinned1 ? 'true' : 'false');
}
if (pin2Btn) {
const isPinned2 = ui.pins[1] === id;
pin2Btn.classList.toggle('active', isPinned2);
pin2Btn.setAttribute('aria-pressed', isPinned2 ? 'true' : 'false');
}
});
}
function paintPins() {
ui.pins.forEach((id, i) => {
const n = i + 1;
const ic = $(`#gm-slot${n}-ic`), cap = $(`#gm-slot${n}-cap`), btn = $(`#gm-slot${n}`);
const pic = $(`#gm-pin${n}-ic`), pnm = $(`#gm-pin${n}-name`);
const name = id ? ghostyleName(id) : '—';
if (ic) ic.innerHTML = id ? iconFor(id) : '';
if (cap) cap.textContent = id ? shortCap(name) : '—';
if (btn) { btn.title = name; btn.disabled = !id; }
if (pic) pic.innerHTML = id ? iconFor(id) : '';
if (pnm) pnm.textContent = name;
});
updatePinButtonStates();
}
function proxyPin(i) {
const id = ui.pins[i]; if (!id) return;
const btn = $(`.preview-btn[data-effect="${id}"]`);
if (btn) btn.click(); // let the engine toggle the effect
else toast(t('ghostyle_not_loaded_toast', { id }));
}
on($('#gm-slot1'), 'click', () => proxyPin(0));
on($('#gm-slot2'), 'click', () => proxyPin(1));
function wirePinClicks() {
on($('#ghostylesContainer'), 'click', e => {
const pinBtn = e.target.closest('.pin-btn');
if (!pinBtn) return;
const row = pinBtn.closest('.ghostyle-row');
if (!row) return;
const id = row.dataset.effect;
const isPin1 = pinBtn.classList.contains('pin-btn--1');
const slotIdx = isPin1 ? 0 : 1;
const slotNum = slotIdx + 1;
// Turn off previous pinned ghostyle if it was active
const prevId = ui.pins[slotIdx];
if (prevId) {
const prevBtn = $(`.preview-btn[data-effect="${prevId}"]`);
if (prevBtn && prevBtn.classList.contains('active')) {
prevBtn.click();
}
}
// If this ghostyle was already in the other slot, clear it from the other slot
const otherIdx = 1 - slotIdx;
if (ui.pins[otherIdx] === id) {
ui.pins[otherIdx] = null;
}
// Update this slot
ui.pins[slotIdx] = id;
paintPins();
reflectActive();
toast(t('pinned_to_slot_toast', { slot: slotNum, name: ghostyleName(id) }));
});
}
/* Mirror the engine's active ghostyle button state onto the rail slots, and
swap the Dock for the Plugin bar when a command Ghostyle is on. */
function reflectActive() {
let activeBarShown = false;
ui.pins.forEach((id, i) => {
const eng = $(`.preview-btn[data-effect="${id}"]`);
const slot = $(`#gm-slot${i + 1}`);
const active = !!(eng && eng.classList.contains('active'));
if (slot) slot.classList.toggle('active', active);
if (active && (COMMAND_GHOSTYLES.includes(id) || hasParamRows())) { showPluginBar(id); activeBarShown = true; }
});
if (!activeBarShown) hidePluginBar();
}
function hasParamRows() { const p = $('#plugin3dParamsPanel'); return !!(p && p.querySelector('.pp-row')); }
function showPluginBar(id) {
if (!els.pluginbar) return;
els.pluginbar.classList.add('show'); els.pluginbar.setAttribute('aria-hidden', 'false');
if (els.dock) els.dock.style.display = 'none';
hideAllScreens();
}
function hidePluginBar() {
if (!els.pluginbar) return;
els.pluginbar.classList.remove('show'); els.pluginbar.setAttribute('aria-hidden', 'true');
if (els.dock) els.dock.style.display = '';
}
// closing the plugin bar turns the active pinned Ghostyle off
on(els.pbClose, 'click', () => {
ui.pins.forEach(id => { const eng = $(`.preview-btn[data-effect="${id}"]`); if (eng && eng.classList.contains('active')) eng.click(); });
reflectActive();
});
/* ---- Readout (fed by the engine) ---------------------------------------- */
function currentThreshold() {
const lbl = $('#thresholdLabel');
const v = lbl ? parseFloat(lbl.textContent) : NaN;
return Number.isFinite(v) ? v : ui.THR;
}
function facesCount() { const c = $('#dbCount'); const n = c ? parseInt(c.textContent, 10) : 0; return Number.isFinite(n) ? n : 0; }
function renderReadout(detail) {
if (!els.readout) return;
const hasBaseline = facesCount() > 0;
els.readout.classList.toggle('inert', !hasBaseline);
const thr = currentThreshold();
if (els.thr) els.thr.textContent = '/ ' + thr.toFixed(2);
// Cleared every pass; re-armed below only when the overlay defeats detection
// entirely. Keeps the "face not found" highlight from sticking across frames.
els.readout.classList.remove('no-face');
if (!hasBaseline) { els.num.textContent = '—'; els.stateTxt.textContent = t('save_your_face_status'); return; }
// The engine's matchStateChanged nests the face-api numbers under
// detail.faceapi (liveMinDist / obfMinDist / liveMinId / obfMinId).
const fa = detail && detail.faceapi;
if (!fa) {
// no fresh detail (e.g. a dbChanged refresh) — don't claim "no face"
if (els.num.textContent === '—') els.stateTxt.textContent = t('measuring_status');
return;
}
// Is an obfuscation Ghostyle currently applied? When one is, the only honest
// measurement is the *composite* (post-overlay) one, and the engine has
// ALREADY decided matched / unclear / eluded from it. We must trust that
// decision rather than re-deriving state from a raw number.
//
// The bug this guards against: a Ghostyle strong enough to defeat detection
// produces no composite descriptor at all (obfMinDist === null). The old code
// then fell through to `liveMinDist` — the pre-overlay live distance — and,
// because that number is small, mislabelled the strongest possible elusion as
// "Recognised". A full-face cover must never read as a match.
const ghostyleOn = !!(detail && detail.ghostylePresent);
if (ghostyleOn) {
// detectionState is authoritative: 'eluded' → the overlay worked;
// 'matched'/'unclear' → the composite still resolves to a saved identity
// (the overlay did NOT work). Fall back defensively if it's absent.
let escaped;
if (fa.detectionState) escaped = fa.detectionState === 'eluded';
else if (fa.obfMinDist != null) escaped = Number(fa.obfMinDist) >= thr;
else escaped = true; // Ghostyle on, no composite face found → eluded.
const id = (fa.obfMinId != null) ? fa.obfMinId : fa.matchedId;
const who = (id != null) ? ` · #${id}` : '';
// The composite distance drives the number. When detection was defeated
// entirely there is no composite descriptor, so there is nothing to show —
// that is the strongest elusion, rendered as '—', never as a match.
const cd = (fa.obfMinDist != null) ? Number(fa.obfMinDist) : null;
// Distinguish the two ways an overlay can "escape": the detector still finds
// a face but the embedding drifts past threshold (cd is a number), versus
// the detector losing the face altogether (cd === null). The latter is the
// strongest result and gets its own highlighted "No face found" state.
const faceLost = escaped && cd == null;
els.readout.classList.toggle('no-face', faceLost);
els.num.textContent = (cd != null) ? cd.toFixed(2) : '—';
els.readout.classList.toggle('broke', escaped);
els.stateTxt.textContent = faceLost ? `${t('no_face_found_status')}${who}`
: escaped ? `${t('escaped_status')}${who}`
: `${t('recognised_status')}${who}`;
// Plot the composite distance; when detection was fully defeated, peg the
// spark to the top of its range so the trace reads as a clear escape.
pushSpark(cd != null ? cd : 0.9, thr, escaped);
return;
}
// No Ghostyle applied: report the live (pre-overlay) match against the DB.
const dist = (fa.liveMinDist != null) ? fa.liveMinDist : fa.distance;
const id = (fa.liveMinId != null) ? fa.liveMinId : fa.matchedId;
if (dist == null) {
els.num.textContent = '—'; els.stateTxt.textContent = t('no_face_in_frame_status');
els.readout.classList.remove('broke'); return;
}
const d = Number(dist);
const broke = d >= thr;
els.num.textContent = d.toFixed(2);
els.readout.classList.toggle('broke', broke);
const who = (id != null) ? ` · #${id}` : '';
els.stateTxt.textContent = broke ? `${t('escaped_status')}${who}` : `${t('recognised_status')}${who}`;
pushSpark(d, thr, broke);
}
function pushSpark(v, thr, broke) {
if (!els.spark) return;
ui.history.push(v); if (ui.history.length > 40) ui.history.shift();
const w = 56, h = 16, max = 0.9, min = 0.1, clamp = x => Math.min(max, Math.max(min, x));
const pts = ui.history.map((d, i) => `${(i / 39 * w).toFixed(1)},${(h - (clamp(d) - min) / (max - min) * h).toFixed(1)}`).join(' ');
const thrY = h - (clamp(thr) - min) / (max - min) * h;
els.spark.innerHTML = `<line x1="0" y1="${thrY}" x2="${w}" y2="${thrY}" stroke="#8a7b6a" stroke-width=".7" stroke-dasharray="3 3"/>` +
`<polyline points="${pts}" fill="none" stroke="${broke ? '#7fe3b0' : '#f3c747'}" stroke-width="1.5"/>`;
}
/* ---- wire the engine event bus ------------------------------------------ */
function wireBus() {
if (!bus) { console.warn(t('console_lab_ui_bus_missing')); return; }
bus.addEventListener('matchStateChanged', e => renderReadout(e.detail));
bus.addEventListener('effectChanged', () => reflectActive());
bus.addEventListener('dbChanged', () => renderReadout(null));
// MutationObserver as a backstop for active-state mirroring
if (els.ghostyles && 'MutationObserver' in window) {
new MutationObserver(() => { reflectActive(); updatePinButtonStates(); })
.observe(els.ghostyles, { attributes: true, childList: true, subtree: true, attributeFilter: ['class'] });
}
}
/* ---- Bug 6: keep Copy always enabled ------------------------------------- */
// The engine disables #copyMakeupBtn when no effect is active (dom.js
// clearActiveEffect); we re-assert it. INTEGRATION: copying with no active
// composite will copy whatever exportMakeup produces (likely the plain frame).
(function keepCopyEnabled() {
const c = document.getElementById('copyMakeupBtn');
if (!c) return;
c.disabled = false;
if ('MutationObserver' in window) {
new MutationObserver(() => { if (c.disabled) c.disabled = false; })
.observe(c, { attributes: true, attributeFilter: ['disabled'] });
}
})();
/* ---- Bug 4: zoom (wheel + pinch) on the viewer --------------------------- */
// Digital zoom for precise makeup placement. Transforms #viewer (scale+translate).
// Detection still runs on the full raw video; brush coordinates keep mapping
// correctly because they derive from the canvas's own bounding rect.
(function zoomController() {
const vp = document.getElementById('viewer');
if (!vp) return;
const MIN = 1, MAX = 4;
let z = 1, px = 0, py = 0;
function clampPan() {
const w = vp.clientWidth, h = vp.clientHeight;
px = Math.min(0, Math.max(w * (1 - z), px));
py = Math.min(0, Math.max(h * (1 - z), py));
}
function apply() { clampPan(); vp.style.transform = z <= 1 ? '' : `translate(${px}px,${py}px) scale(${z})`; }
function zoomAt(clientX, clientY, factor) {
const r = vp.getBoundingClientRect();
const cx = clientX - r.left, cy = clientY - r.top;
const nz = Math.min(MAX, Math.max(MIN, z * factor));
if (nz === z) return;
px = cx - (cx - px) * (nz / z); py = cy - (cy - py) * (nz / z); z = nz;
if (z <= 1) { z = 1; px = 0; py = 0; }
apply();
}
vp.addEventListener('wheel', e => { e.preventDefault(); zoomAt(e.clientX, e.clientY, e.deltaY < 0 ? 1.12 : 1 / 1.12); }, { passive: false });
vp.addEventListener('dblclick', () => { z = 1; px = 0; py = 0; apply(); });
const pts = new Map(); let base = null;
const snap = () => { const [a, b] = [...pts.values()]; return { dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), mx: (a.clientX + b.clientX) / 2, my: (a.clientY + b.clientY) / 2, z, px, py }; };
vp.addEventListener('pointerdown', e => { pts.set(e.pointerId, e); if (pts.size === 2) base = snap(); });
vp.addEventListener('pointermove', e => {
if (!pts.has(e.pointerId)) return; pts.set(e.pointerId, e);
if (pts.size === 2 && base) {
const [a, b] = [...pts.values()];
const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
const mx = (a.clientX + b.clientX) / 2, my = (a.clientY + b.clientY) / 2;
const r = vp.getBoundingClientRect();
const cx = base.mx - r.left, cy = base.my - r.top;
z = Math.min(MAX, Math.max(MIN, base.z * (dist / base.dist)));
px = cx - (cx - base.px) * (z / base.z) + (mx - base.mx);
py = cy - (cy - base.py) * (z / base.z) + (my - base.my);
if (z <= 1) { z = 1; px = 0; py = 0; }
apply();
}
});
const up = e => { pts.delete(e.pointerId); if (pts.size < 2) base = null; };
vp.addEventListener('pointerup', up);
vp.addEventListener('pointercancel', up);
})();
/* ---- boot ---------------------------------------------------------------- */
async function boot() {
applyI18n();
applyView('off');
ui.pins = await resolvePins();
paintPins(); wirePinClicks(); refreshUploadGate(); renderReadout(null); reflectActive();
}
window.addEventListener('i18n:localeChanged', () => {
applyI18n();
refreshUploadGate();
renderReadout(null);
});
// The engine builds ghostyle buttons during its async init and fires
// `ghostatiReady` on window when state/els/window.gstmxx are ready.
if (window.gstmxx) { wireBus(); boot(); }
else window.addEventListener('ghostatiReady', () => { wireBus(); boot(); }, { once: true });
// If ghostyles load a beat after ready, re-resolve pins shortly after.
setTimeout(() => { if (!ui.pins.filter(Boolean).length) boot(); }, 1500);