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 = `
`;
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());
}