/** * FacePicker — gallery UI for selecting the active face/avatar. * * Reads face definitions from /src/face/manifest.json, renders a card grid, * and delegates activation to faceManager (ES module) or FaceRenderer (legacy). * * Usage (standalone): * const picker = new FacePicker(); * await picker.mount(document.getElementById('face-picker-root')); * * Usage (embedded in SettingsPanel): * const html = await FacePicker.renderHTML(); * container.innerHTML = html; * FacePicker.attachListeners(container); */ const MANIFEST_URL = '/src/face/manifest.json'; const STORAGE_KEY = 'ai-face-active'; // ── helpers ──────────────────────────────────────────────────────────────── function getActiveFaceId() { // ES module faceManager takes priority try { if (window._faceManager?.activeFaceId) return window._faceManager.activeFaceId; } catch (_) {} // Server profile is source of truth try { if (window._serverProfile?.ui?.face_mode) return window._serverProfile.ui.face_mode; } catch (_) {} return 'eyes'; } function persistFaceId(id) { // Save to server profile so all devices see the same face // Use _activeProfileData.id as canonical source — _activeProfileId can go stale on profile switch const profileId = window._activeProfileData?.id || window.providerManager?._activeProfileId || 'default'; fetch('/api/profiles/' + profileId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ui: { face_mode: id } }) }).catch(e => console.warn('Failed to save face to profile:', e)); // Update cached profile if (window._serverProfile) { if (!window._serverProfile.ui) window._serverProfile.ui = {}; window._serverProfile.ui.face_mode = id; } } async function loadManifest() { try { // Load built-in manifest and custom faces manifest in parallel const [builtInRes, customRes] = await Promise.all([ fetch(MANIFEST_URL).catch(() => null), fetch('/api/custom-faces').catch(() => null), ]); const builtIn = builtInRes?.ok ? await builtInRes.json() : { default: 'eyes', faces: [ { id: 'eyes', name: 'AI Eyes', description: 'Classic animated eyes', preview: null, moods: [], features: [] } ] }; const custom = customRes?.ok ? await customRes.json() : { faces: {} }; // Append custom faces to the faces array const customFaces = Object.entries(custom.faces || {}).map(([id, meta]) => ({ id: `custom:${id}`, name: meta.name || id, description: meta.description || '', preview: meta.preview || null, moods: meta.moods || [], features: meta.features || ['custom'], isCustom: true, })); return { ...builtIn, faces: [...(builtIn.faces || []), ...customFaces], }; } catch (err) { console.warn('[FacePicker] Could not load manifests, using defaults:', err.message); return { default: 'eyes', faces: [ { id: 'eyes', name: 'AI Eyes', description: 'Classic animated eyes', preview: null, moods: [], features: [] } ] }; } } async function activateFace(id) { persistFaceId(id); // Try ES module faceManager first if (window._faceManager && typeof window._faceManager.load === 'function') { await window._faceManager.load(id); return; } // Fall back to legacy FaceRenderer if (window.FaceRenderer && typeof window.FaceRenderer.setMode === 'function') { window.FaceRenderer.setMode(id); return; } console.warn('[FacePicker] No face system available to activate face:', id); } // ── rendering ────────────────────────────────────────────────────────────── function renderFeatureTags(features) { if (!features || features.length === 0) return ''; return `