const cfg = window.ECT_COMPANION_CONFIG || {}; if (cfg.enabled) { const base = String(cfg.basePath || '').replace(/\/$/, ''); const url = (path) => path.startsWith('http') ? path : `${base}${path.startsWith('/') ? path : `/${path}`}`; const localKey = `ect.companion.${document.body?.dataset?.siteVariant || document.body?.dataset?.playLayout || 'site'}`; const notificationKey = 'ect.companion.last-notification'; const loadLocal = () => { try { return JSON.parse(localStorage.getItem(localKey) || '{}'); } catch { return {}; } }; const saveLocal = (value) => { try { localStorage.setItem(localKey, JSON.stringify(value)); } catch {} }; const escapeHtml = (value) => String(value).replace(/[&<>'"]/g, (char) => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char])); let local = loadLocal(); let catalog = null; let pet = null; let stateName = 'idle'; let frame = 0; let timer = 0; let idleTimer = 0; let delightTimer = 0; let userIdleTimer = 0; let userIdle = false; let messageTimer = 0; let homeAnimation = 0; let activity = []; let drag = null; let suppressClick = false; let paletteCommands = []; let activeCommand = -1; let paletteOpener = null; const normalizePreferences = (value = {}) => { const rawVisible = value.visible; const visible = rawVisible === undefined ? true : ![false, 0, '0', 'false', 'off', 'no'].includes(typeof rawVisible === 'string' ? rawVisible.toLowerCase() : rawVisible); const animationFrequency = String(value.animationFrequency ?? value.animation_frequency ?? 'normal'); return { activePet:String(value.activePet ?? value.active_pet ?? 'ember').toLowerCase(), visible, scalePercent:Math.max(60, Math.min(150, Number(value.scalePercent ?? value.scale_percent ?? 100) || 100)), animationFrequency:['reduced','normal','frequent'].includes(animationFrequency) ? animationFrequency : 'normal', }; }; let preferences = normalizePreferences({...normalizePreferences(cfg.preferences || {}), ...(cfg.authenticated ? {} : (local.preferences || {}))}); const playMode = cfg.mode === 'play'; const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches || preferences.animationFrequency === 'reduced'; const mobile = () => matchMedia('(max-width: 600px)').matches; const activeEvents = new Set(['busy', 'loading', 'thinking']); const selectionEvents = new Set(['click', 'wave']); const userIdleDelay = Math.max(50, Number(cfg.userIdleDelay) || 120000); let activeStateName = ''; const root = document.createElement('aside'); root.className = `ect-companion${playMode ? ' is-play-mode' : ''}`; root.setAttribute('aria-label', 'Companion'); root.innerHTML = ` `; document.body.append(root); const restoreButton = root.querySelector('.ect-companion-restore'); const sprite = root.querySelector('.ect-companion-sprite'); const menuButton = root.querySelector('.ect-companion-menu-toggle'); const menu = root.querySelector('.ect-companion-menu'); const notification = root.querySelector('.ect-companion-notification'); const message = root.querySelector('.ect-companion-message'); const collapsedNotification = root.querySelector('.ect-companion-notification-collapsed'); const scaleInput = root.querySelector('[data-companion-scale]'); const frequencySelect = root.querySelector('[data-companion-frequency]'); const petSelect = root.querySelector('[data-companion-pet]'); const petSelectRow = root.querySelector('[data-companion-pet-row]'); const palette = document.createElement('dialog'); palette.className = 'ect-command-palette'; palette.setAttribute('aria-labelledby', 'ect-command-title'); palette.innerHTML = `

Command palette

Esc
`; document.body.append(palette); const commands = [ {group:'Navigate', label:'Go home', syntax:'/go home', run:()=>location.assign(url('/'))}, {group:'Navigate', label:'Go to Play', syntax:'/go play', run:()=>location.assign(url('/play/'))}, {group:'Navigate', label:'Go to decks', syntax:'/go decks', run:()=>location.assign(url('/decks'))}, {group:'Navigate', label:'Go to cards', syntax:'/go cards', run:()=>location.assign(url('/build'))}, {group:'Deck tools', label:'Validate a deck', syntax:'/deck validate', run:()=>location.assign(url('/my-decks'))}, {group:'Deck tools', label:'Import a deck', syntax:'/deck import', run:()=>location.assign(url('/my-decks'))}, {group:'Account', label:'Open settings', syntax:'/go settings', run:()=>location.assign(url('/account'))}, {group:'Support', label:'Support ECT', syntax:'/support', run:()=>location.assign(url('/support/donating'))}, {group:'Support', label:'Guardian Hall', syntax:'/support guardians', run:()=>location.assign(url('/support/guardians'))}, {group:'Support', label:'Milestones', syntax:'/support milestones', run:()=>location.assign(url('/support/milestones'))}, {group:'Support', label:'Donating', syntax:'/support donating', run:()=>location.assign(url('/support/donating'))}, {group:'Support', label:'Watch an Ad', syntax:'/support ad', run:()=>location.assign(url('/support/watch-ad'))}, ]; function setActiveCommand(index) { const buttons = [...palette.querySelectorAll('[data-command-index]')]; activeCommand = buttons.length ? Math.max(0, Math.min(index, buttons.length - 1)) : -1; buttons.forEach((button, buttonIndex) => { const active = buttonIndex === activeCommand; button.classList.toggle('is-active', active); button.setAttribute('aria-selected', active ? 'true' : 'false'); }); if (activeCommand >= 0) buttons[activeCommand].scrollIntoView({block:'nearest'}); } function renderCommands(query = '') { const q = query.trim().toLowerCase(); const list = palette.querySelector('.ect-command-list'); paletteCommands = commands.filter((command) => !q || command.label.toLowerCase().includes(q) || command.syntax.includes(q)); if (q.startsWith('/search ') && q.length > 8) { const cardQuery = query.trim().slice(8); paletteCommands = [{group:'Card search', label:`Search cards for “${cardQuery}”`, syntax:'/search', run:()=>location.assign(url(`/build?q=${encodeURIComponent(cardQuery)}`))}]; } list.innerHTML = ''; let group = ''; paletteCommands.forEach((command, index) => { if (command.group !== group) { group = command.group; const heading = document.createElement('h3'); heading.textContent = group; list.append(heading); } const button = document.createElement('button'); button.type = 'button'; button.dataset.commandIndex = String(index); button.setAttribute('role', 'option'); button.innerHTML = `${escapeHtml(command.label)}${escapeHtml(command.syntax)}`; button.addEventListener('mouseenter', () => setActiveCommand(index)); button.addEventListener('click', () => { palette.close(); command.run(); }); list.append(button); }); if (!paletteCommands.length) list.innerHTML = '
No matching commandTry a destination, deck action, or Support page.
'; setActiveCommand(paletteCommands.length ? 0 : -1); } function openPalette() { closeMenu(); paletteOpener = document.activeElement; const input = palette.querySelector('input'); input.value = ''; renderCommands(''); if (!palette.open) palette.showModal(); setTimeout(() => input.focus(), 0); } function petName() { return String(pet?.displayName || 'Companion'); } function safeDestination(value) { if (!value) return ''; try { const destination = new URL(String(value), location.href); return destination.origin === location.origin ? destination.href : ''; } catch { return ''; } } function availablePets(data) { if (cfg.entitlements?.allPets === true) return (data?.pets || []).filter((item) => item?.id); const cosmetics = new Set((cfg.entitlements?.cosmetics || []).map(String)); return (data?.pets || []).filter((item) => !item.unlockKey || cosmetics.has(String(item.unlockKey))); } function configurePetOptions(pets) { petSelect.replaceChildren(...pets.map((item) => { const option = document.createElement('option'); option.value = String(item.id); option.textContent = String(item.displayName || item.id); return option; })); petSelect.value = preferences.activePet; petSelectRow.hidden = pets.length < 2; } function configurePetPresentation() { const name = petName(); root.setAttribute('aria-label', `${name} companion`); root.querySelectorAll('[data-companion-name]').forEach((node) => { node.textContent = name; }); root.querySelectorAll('[data-companion-notification-name]').forEach((node) => { node.textContent = name; }); restoreButton.setAttribute('aria-label', `Show ${name} companion`); sprite.setAttribute('aria-label', `${name} companion. Click for a reaction or double click for commands.`); menuButton.setAttribute('aria-label', menu.hidden ? `Open ${name} menu` : `Close ${name} menu`); collapsedNotification.setAttribute('aria-label', `Show collapsed ${name} notification`); } function openMenu() { collapseMessage(); menu.hidden = false; menuButton.setAttribute('aria-expanded', 'true'); menuButton.setAttribute('aria-label', `Close ${petName()} menu`); positionMenu(); } function closeMenu() { menu.hidden = true; menuButton.setAttribute('aria-expanded', 'false'); menuButton.setAttribute('aria-label', `Open ${petName()} menu`); } function toggleMenu() { if (menu.hidden) openMenu(); else closeMenu(); } function collapseMessage() { clearTimeout(messageTimer); if (notification.hidden || !message.textContent) return; notification.hidden = true; collapsedNotification.hidden = false; } function expandMessage() { if (!message.textContent) return; collapsedNotification.hidden = true; notification.hidden = false; positionNotification(); clearTimeout(messageTimer); messageTimer = setTimeout(collapseMessage, 4200); } function showMessage(text, destination = '') { if (!text) return; message.textContent = String(text).slice(0, 240); const href = safeDestination(destination); if (href) message.href = href; else message.removeAttribute('href'); message.classList.toggle('is-actionable', Boolean(href)); expandMessage(); } function state(name, messageText = '', destination = '') { if (!pet) return; clearTimeout(idleTimer); const eventName = String(name); if (reduced()) { activeStateName = ''; stateName = 'idle'; frame = 0; restart(); showMessage(messageText, destination); return; } stateName = pet.eventMap?.[eventName] || (pet.states?.[eventName] ? eventName : 'idle'); if (activeEvents.has(eventName)) activeStateName = stateName; else if (!selectionEvents.has(eventName)) activeStateName = ''; frame = 0; restart(); showMessage(messageText, destination); if (stateName !== 'idle' && !activeEvents.has(eventName)) { idleTimer = setTimeout(() => { stateName = activeStateName || 'idle'; frame = 0; restart(); }, Math.max(1600, (pet.states[stateName]?.frames || 1) * (pet.states[stateName]?.duration || 160) * 2)); } } function renderedScale() { const modeScale = playMode ? (mobile() ? .62 : .78) : (mobile() ? .78 : 1); return (preferences.scalePercent / 100) * modeScale; } function facingTowardCenter(visibleState) { if (visibleState === 'running-left') return 'left'; if (visibleState === 'running-right') return 'right'; const bounds = viewportBounds(); const anchor = root.getBoundingClientRect(); return anchor.left + anchor.width / 2 > bounds.left + bounds.width / 2 ? 'left' : 'right'; } function draw() { if (!pet) return; const visibleState = reduced() ? 'idle' : stateName; const visibleFrame = reduced() ? 0 : frame; const definition = pet.states[visibleState] || pet.states.idle; const frameWidth = pet.layout.frameWidth / 2; const frameHeight = pet.layout.frameHeight / 2; const scale = renderedScale(); const facing = facingTowardCenter(visibleState); sprite.style.backgroundImage = `url("${url(pet.spritesheet)}")`; sprite.style.backgroundSize = `${pet.layout.columns * frameWidth}px ${pet.layout.rows * frameHeight}px`; sprite.style.backgroundPosition = `-${visibleFrame * frameWidth}px -${definition.row * frameHeight}px`; sprite.style.transformOrigin = 'bottom center'; sprite.style.transform = `scale(${scale}) scaleX(${facing === 'left' ? -1 : 1})`; root.style.width = preferences.visible ? `${Math.max(74, 104 * scale)}px` : '52px'; root.style.height = preferences.visible ? `${Math.max(80, 112 * scale)}px` : '44px'; root.classList.toggle('is-hidden', !preferences.visible); document.body.classList.toggle('companion-visible', preferences.visible); root.dataset.state = visibleState === 'idle' && userIdle ? 'sleeping' : visibleState; root.dataset.facing = facing; } function scheduleDelight() { clearTimeout(delightTimer); if (!pet || reduced() || userIdle || !preferences.visible || document.hidden || stateName !== 'idle') return; const [minimum, maximum] = preferences.animationFrequency === 'frequent' ? [25000, 55000] : [60000, 150000]; delightTimer = setTimeout(() => { if (userIdle || !preferences.visible || document.hidden || stateName !== 'idle') { scheduleDelight(); return; } const choices = ['waving', 'jumping', 'look-a'].filter((name) => pet.states?.[name]); const delight = choices[Math.floor(Math.random() * choices.length)]; if (!delight) return; stateName = delight; frame = 0; restart(); const definition = pet.states[delight]; idleTimer = setTimeout(() => { if (delight === 'look-a' && pet.states?.['look-b']) { stateName = 'look-b'; frame = 0; restart(); const followUp = pet.states['look-b']; idleTimer = setTimeout(() => { stateName = 'idle'; frame = 0; restart(); }, followUp.frames * followUp.duration); return; } stateName = 'idle'; frame = 0; restart(); }, definition.frames * definition.duration); }, minimum + Math.random() * (maximum - minimum)); } function restart() { clearInterval(timer); clearTimeout(delightTimer); draw(); if (reduced() || document.hidden) return; const definition = pet?.states[stateName] || pet?.states?.idle; if (!definition) return; if (stateName === 'idle') { frame = 0; draw(); if (userIdle) { timer = setInterval(() => { frame = (frame + 1) % definition.frames; draw(); }, definition.duration * 2.5); return; } scheduleDelight(); return; } timer = setInterval(() => { frame = (frame + 1) % definition.frames; draw(); }, definition.duration * (preferences.animationFrequency === 'frequent' ? .8 : 1)); } function scheduleUserIdle() { clearTimeout(userIdleTimer); userIdleTimer = setTimeout(() => { userIdle = true; if (stateName === 'idle') { frame = 0; restart(); } }, userIdleDelay); } function markUserActive() { const wasIdle = userIdle; userIdle = false; scheduleUserIdle(); if (wasIdle && stateName === 'idle') { frame = 0; restart(); } } async function persist() { if (cfg.authenticated) delete local.preferences; else local.preferences = preferences; saveLocal(local); if (!cfg.authenticated) return; try { await fetch(url('/api/v1/me/companion/preferences'), {method:'POST', credentials:'same-origin', headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-Token':cfg.csrf || ''}, body:JSON.stringify(preferences)}); } catch {} } function clampPoint(x, y) { const rect = root.getBoundingClientRect(); const padding = 8; return { x: Math.max(padding, Math.min(innerWidth - rect.width - padding, Number(x) || padding)), y: Math.max(padding, Math.min(innerHeight - rect.height - padding, Number(y) || padding)), }; } function viewportBounds() { const viewport = window.visualViewport; const left = viewport?.offsetLeft || 0; const top = viewport?.offsetTop || 0; const width = viewport?.width || innerWidth; const height = viewport?.height || innerHeight; return {left, top, right:left + width, bottom:top + height, width, height}; } function positionNotification() { if (notification.hidden) return; const bounds = viewportBounds(); const padding = 8; notification.style.position = 'fixed'; notification.style.right = 'auto'; notification.style.bottom = 'auto'; const anchor = root.getBoundingClientRect(); const rect = notification.getBoundingClientRect(); const preferredLeft = anchor.left >= rect.width + padding ? anchor.left - rect.width + 8 : anchor.right - 8; const preferredTop = anchor.top >= rect.height * .5 ? anchor.top - rect.height + 28 : anchor.bottom - 28; const maxLeft = Math.max(bounds.left + padding, bounds.right - rect.width - padding); const maxTop = Math.max(bounds.top + padding, bounds.bottom - rect.height - padding); notification.style.left = `${Math.max(bounds.left + padding, Math.min(maxLeft, preferredLeft))}px`; notification.style.top = `${Math.max(bounds.top + padding, Math.min(maxTop, preferredTop))}px`; } function positionMenu() { if (menu.hidden) return; const bounds = viewportBounds(); const padding = 8; const availableWidth = Math.max(0, bounds.width - padding * 2); const availableHeight = Math.max(0, bounds.height - padding * 2); menu.style.position = 'fixed'; menu.style.right = 'auto'; menu.style.bottom = 'auto'; menu.style.maxWidth = `${availableWidth}px`; menu.style.maxHeight = `${availableHeight}px`; if (mobile()) menu.style.width = `${availableWidth}px`; else menu.style.removeProperty('width'); const anchor = root.getBoundingClientRect(); const rect = menu.getBoundingClientRect(); const preferredLeft = mobile() ? bounds.left + padding : (anchor.left >= rect.width + padding ? anchor.left - rect.width + 10 : anchor.right - 10); const preferredTop = mobile() ? bounds.bottom - rect.height - padding : anchor.bottom - rect.height + 2; const maxLeft = Math.max(bounds.left + padding, bounds.right - rect.width - padding); const maxTop = Math.max(bounds.top + padding, bounds.bottom - rect.height - padding); menu.style.left = `${Math.max(bounds.left + padding, Math.min(maxLeft, preferredLeft))}px`; menu.style.top = `${Math.max(bounds.top + padding, Math.min(maxTop, preferredTop))}px`; } function applyPosition() { if (!local.position) return; const point = clampPoint(local.position.x, local.position.y); root.style.left = `${point.x}px`; root.style.top = `${point.y}px`; root.style.right = 'auto'; root.style.bottom = 'auto'; local.position = {x:Math.round(point.x), y:Math.round(point.y)}; saveLocal(local); } function cancelReturnHome() { if (homeAnimation) cancelAnimationFrame(homeAnimation); homeAnimation = 0; } function finishReturnHome() { cancelReturnHome(); delete local.position; saveLocal(local); root.style.removeProperty('left'); root.style.removeProperty('top'); root.style.removeProperty('right'); root.style.removeProperty('bottom'); draw(); state('idle'); } function returnHome() { closeMenu(); const start = root.getBoundingClientRect(); root.style.removeProperty('left'); root.style.removeProperty('top'); root.style.removeProperty('right'); root.style.removeProperty('bottom'); draw(); const target = root.getBoundingClientRect(); root.style.left = `${start.left}px`; root.style.top = `${start.top}px`; root.style.right = 'auto'; root.style.bottom = 'auto'; if (reduced() || Math.hypot(target.left - start.left, target.top - start.top) < 2) { finishReturnHome(); return; } cancelReturnHome(); stateName = target.left < start.left ? 'running-left' : 'running-right'; frame = 0; restart(); const started = performance.now(); const duration = Math.min(720, Math.max(320, Math.hypot(target.left - start.left, target.top - start.top) * .65)); const move = (now) => { const progress = Math.min(1, (now - started) / duration); const eased = 1 - Math.pow(1 - progress, 3); root.style.left = `${start.left + (target.left - start.left) * eased}px`; root.style.top = `${start.top + (target.top - start.top) * eased}px`; if (progress < 1) homeAnimation = requestAnimationFrame(move); else finishReturnHome(); }; homeAnimation = requestAnimationFrame(move); } async function loadActivity() { const box = root.querySelector('[data-companion-activity]'); box.hidden = false; box.innerHTML = 'Loading…'; if (cfg.authenticated) { try { const response = await fetch(url('/api/v1/me/companion'), {credentials:'same-origin', headers:{Accept:'application/json'}}); const payload = await response.json(); activity = payload?.data?.activity || activity; if (payload?.data?.preferences) preferences = normalizePreferences(payload.data.preferences); } catch {} } box.innerHTML = activity.length ? activity.slice(0, 6).map((item) => { const destination = safeDestination(item.targetUrl); return `
${escapeHtml(item.subject || 'Activity')}${item.body ? `
${escapeHtml(item.body)}
` : ''}${destination ? `Open` : ''}
`; }).join('') : 'No recent activity.'; scaleInput.value = String(preferences.scalePercent); frequencySelect.value = preferences.animationFrequency; draw(); positionMenu(); } sprite.addEventListener('click', () => { if (suppressClick) { suppressClick = false; return; } state('click'); }); sprite.addEventListener('dblclick', (event) => { event.preventDefault(); openPalette(); }); sprite.addEventListener('contextmenu', (event) => { event.preventDefault(); toggleMenu(); }); menuButton.addEventListener('click', toggleMenu); root.querySelector('.ect-companion-notification-collapse').addEventListener('click', collapseMessage); collapsedNotification.addEventListener('click', expandMessage); restoreButton.addEventListener('click', () => { preferences.visible = true; restart(); persist(); sprite.focus(); }); root.querySelector('[data-companion-close]').addEventListener('click', () => { closeMenu(); menuButton.focus(); }); scaleInput.value = String(preferences.scalePercent); frequencySelect.value = preferences.animationFrequency; scaleInput.addEventListener('input', (event) => { preferences.scalePercent = Number(event.target.value); draw(); applyPosition(); positionMenu(); }); scaleInput.addEventListener('change', persist); frequencySelect.addEventListener('change', (event) => { preferences.animationFrequency = event.target.value; restart(); persist(); }); petSelect.addEventListener('change', () => { const next = availablePets(catalog).find((item) => String(item.id) === petSelect.value); if (!next) return; preferences.activePet = String(next.id); pet = next; stateName = 'idle'; frame = 0; configurePetPresentation(); restart(); persist(); }); root.querySelectorAll('[data-companion-action]').forEach((button) => button.addEventListener('click', () => { const action = button.dataset.companionAction; if (action === 'activity') loadActivity(); if (action === 'commands') openPalette(); if (action === 'home') returnHome(); if (action === 'hide') { preferences.visible = false; closeMenu(); activeStateName = ''; stateName = 'idle'; frame = 0; restart(); persist(); } })); sprite.addEventListener('pointerdown', (event) => { if (event.button !== 0) return; cancelReturnHome(); const rect = root.getBoundingClientRect(); drag = {id:event.pointerId, startX:event.clientX, startY:event.clientY, left:rect.left, top:rect.top, moved:false}; sprite.setPointerCapture?.(event.pointerId); }); sprite.addEventListener('pointermove', (event) => { if (!drag || drag.id !== event.pointerId) return; const dx = event.clientX - drag.startX; const dy = event.clientY - drag.startY; if (Math.hypot(dx, dy) < 5 && !drag.moved) return; drag.moved = true; suppressClick = true; root.classList.add('is-dragging'); const point = clampPoint(drag.left + dx, drag.top + dy); root.style.left = `${point.x}px`; root.style.top = `${point.y}px`; root.style.right = 'auto'; root.style.bottom = 'auto'; positionMenu(); positionNotification(); stateName = dx < 0 ? 'running-left' : 'running-right'; draw(); }); const endDrag = (event) => { if (!drag || drag.id !== event.pointerId) return; if (drag.moved) { const rect = root.getBoundingClientRect(); local.position = {x:Math.round(rect.left), y:Math.round(rect.top)}; saveLocal(local); state('idle'); } root.classList.remove('is-dragging'); drag = null; }; sprite.addEventListener('pointerup', endDrag); sprite.addEventListener('pointercancel', endDrag); const paletteInput = palette.querySelector('input'); paletteInput.addEventListener('input', (event) => renderCommands(event.target.value)); paletteInput.addEventListener('keydown', (event) => { if (event.key === 'ArrowDown') { event.preventDefault(); setActiveCommand(activeCommand + 1); } if (event.key === 'ArrowUp') { event.preventDefault(); setActiveCommand(activeCommand - 1); } if (event.key === 'Enter' && activeCommand >= 0) { event.preventDefault(); palette.close(); paletteCommands[activeCommand]?.run(); } }); palette.addEventListener('close', () => paletteOpener?.focus?.()); palette.addEventListener('click', (event) => { if (event.target === palette) palette.close(); }); document.addEventListener('keydown', (event) => { const editable = event.target instanceof HTMLElement && (event.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName)); if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); openPalette(); } else if (event.key === '/' && !editable && !palette.open) { event.preventDefault(); openPalette(); } else if (event.key === 'Escape' && !menu.hidden) { closeMenu(); menuButton.focus(); } }); document.addEventListener('pointerdown', markUserActive, {capture:true, passive:true}); document.addEventListener('keydown', markUserActive, {capture:true}); addEventListener('scroll', markUserActive, {passive:true}); addEventListener('resize', () => { draw(); applyPosition(); positionMenu(); positionNotification(); }); matchMedia('(max-width: 600px)').addEventListener?.('change', positionMenu); document.addEventListener('visibilitychange', () => { if (document.hidden) restart(); else markUserActive(); }); if ('ResizeObserver' in window) new ResizeObserver(() => positionMenu()).observe(document.documentElement); window.visualViewport?.addEventListener('resize', positionMenu); window.visualViewport?.addEventListener('scroll', positionMenu); window.visualViewport?.addEventListener('resize', positionNotification); window.visualViewport?.addEventListener('scroll', positionNotification); matchMedia('(prefers-reduced-motion: reduce)').addEventListener?.('change', restart); window.addEventListener('ect:companion', (event) => state(String(event.detail?.type || 'notification'), String(event.detail?.message || ''), String(event.detail?.destination || ''))); window.ECTCompanion = { notify:(type, message='', destination='') => window.dispatchEvent(new CustomEvent('ect:companion', {detail:{type, message, destination}})), show:() => { preferences.visible = true; restart(); persist(); }, hide:() => { preferences.visible = false; closeMenu(); activeStateName = ''; stateName = 'idle'; frame = 0; restart(); persist(); }, commands:openPalette, }; fetch(url(cfg.catalog || '/assets/companions/catalog.json'), {credentials:'same-origin', cache:'no-store'}) .then((response) => response.json()) .then((data) => { catalog = data; const pets = availablePets(data); if (!pets.length) throw new Error('No companion is available.'); pet = pets.find((item) => item.id === preferences.activePet) || pets.find((item) => item.id === data.defaultPet) || pets[0]; preferences.activePet = String(pet.id); configurePetOptions(pets); configurePetPresentation(); draw(); applyPosition(); restart(); scheduleUserIdle(); if (cfg.authenticated) loadActivity().then(() => { let lastNotification = ''; try { lastNotification = localStorage.getItem(notificationKey) || ''; } catch {} const unread = activity.find((item) => !item.readAt && String(item.id || '') !== lastNotification); if (unread) { try { localStorage.setItem(notificationKey, String(unread.id || '')); } catch {} state('notification', unread.subject || 'New activity', unread.targetUrl || ''); } }); }) .catch(() => root.remove()); }