// ── Achievements: invisible until the first one lands ───────── (function () { const KEY = 'achievements'; const ALL = [ { id: 'cat', name: 'Cat lover', how: 'Saw every picture of Pepsi' }, { id: 'natural', name: 'Natural', how: 'Rolled a natural 20' }, { id: 'investigator', name: 'Investigator', how: 'Clicked everything there is to click' }, { id: 'adventurer', name: 'Adventurer', how: 'Completed the Quest for the Deleted Website' }, { id: 'hacker', name: 'Hacker', how: 'Became root' }, { id: 'completionist', name: 'Completionist', how: 'Earned every other achievement' }, ]; // Investigator's checklist: six named interactions plus every round // icon — same selector as js/icon-spin.js, which reports 'icon-' const ICONS = document.querySelectorAll( '.skill-card-icon, .project-icon, .edu-icon, .nav-logo' ).length; const MARKS = ['anagram', 'slot', 'pronouns', 'avatar', 'd20', 'pepsi']; for (let i = 0; i < ICONS; i++) MARKS.push('icon-' + i); const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); // Medal art for earned achievements; ids without an entry render as // blank white shields until their design lands const MEDALS = { cat: '', investigator: '', natural: '', adventurer: '', completionist: '', hacker: '', }; const state = { unlocked: [], announced: [], pepsi: [], marks: [] }; try { const saved = JSON.parse(localStorage.getItem(KEY)); if (saved && typeof saved === 'object') { for (const k of Object.keys(state)) { if (Array.isArray(saved[k])) state[k] = saved[k]; } } } catch (e) {} const save = () => { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) {} }; // ── Badge: absent from the DOM until at least one unlock ──── let badge = null; let button = null; let panel = null; function buildBadge() { badge = document.createElement('div'); badge.className = 'achv'; button = document.createElement('button'); button.type = 'button'; button.className = 'achv-badge'; button.setAttribute('aria-expanded', 'false'); button.innerHTML = ''; panel = document.createElement('ul'); panel.className = 'achv-panel'; panel.hidden = true; // Closing plays the slide-out first; hidden flips when it's done // (0.3s slide + 225ms reverse stagger) let closing = null; const openPanel = () => { if (closing) { clearTimeout(closing); closing = null; } panel.classList.remove('achv-panel--closing'); panel.hidden = false; button.setAttribute('aria-expanded', 'true'); }; const closePanel = () => { if (panel.hidden || closing) return; button.setAttribute('aria-expanded', 'false'); if (reduceMotion.matches) { panel.hidden = true; return; } panel.classList.add('achv-panel--closing'); closing = setTimeout(() => { closing = null; panel.classList.remove('achv-panel--closing'); panel.hidden = true; }, 560); }; button.addEventListener('click', () => { if (panel.hidden || closing) openPanel(); else closePanel(); }); document.addEventListener('click', (e) => { if (badge.contains(e.target)) return; closePanel(); }); badge.appendChild(button); badge.appendChild(panel); // Body-level: keeps it a later sibling of #header, so the // `#header.scrolled ~ .achv` rule can slide it below the nav document.body.appendChild(badge); } function renderBadge() { if (!state.unlocked.length) return; if (!badge) buildBadge(); if (!badge) return; const n = state.unlocked.length; button.querySelector('.achv-count').textContent = n + '/' + ALL.length; button.setAttribute( 'aria-label', 'Achievements: ' + n + ' of ' + ALL.length + ' unlocked' ); panel.textContent = ''; ALL.forEach((a) => { const li = document.createElement('li'); const got = state.unlocked.includes(a.id); li.className = 'achv-medal' + (got ? ' achv-medal--got' : ''); if (got && MEDALS[a.id]) { li.classList.add('achv-medal--' + a.id); li.innerHTML = MEDALS[a.id]; } const tip = document.createElement('span'); tip.className = 'achv-tip'; if (got) { const title = document.createElement('b'); title.textContent = a.name; tip.appendChild(title); tip.appendChild(document.createTextNode(a.how)); } else { tip.textContent = '????'; } li.appendChild(tip); const sr = document.createElement('span'); sr.className = 'achv-sr'; sr.textContent = got ? a.name + ' — ' + a.how : '????'; li.appendChild(sr); panel.appendChild(li); }); } // ── Toasts: queued, one at a time. Shown as manual popovers so // they join the top layer and render above any open // (the terminal); without popover support they hold back until // the dialog closes instead ────────────────────────────────── const queue = []; let showing = false; function next() { if (!queue.length) { showing = false; return; } if (!HTMLElement.prototype.showPopover && document.querySelector('dialog[open]')) { setTimeout(next, 600); return; } const a = queue.shift(); const el = document.createElement('div'); el.className = 'achv-toast'; el.setAttribute('role', 'status'); el.setAttribute('popover', 'manual'); // Tiny medal: a real shield scaled down, so the art stays shared const slot = document.createElement('span'); slot.className = 'achv-toast-medal'; const shield = document.createElement('span'); shield.className = 'achv-medal achv-medal--got' + (MEDALS[a.id] ? ' achv-medal--' + a.id : ''); if (MEDALS[a.id]) shield.innerHTML = MEDALS[a.id]; slot.appendChild(shield); const text = document.createElement('span'); text.className = 'achv-toast-text'; const kicker = document.createElement('span'); kicker.className = 'achv-toast-kicker'; kicker.textContent = 'Achievement unlocked'; const label = document.createElement('span'); label.className = 'achv-toast-name'; label.textContent = a.name; text.appendChild(kicker); text.appendChild(label); el.appendChild(slot); el.appendChild(text); document.body.appendChild(el); if (el.showPopover) el.showPopover(); const remove = () => { el.remove(); next(); }; const hide = () => { if (reduceMotion.matches) { remove(); return; } el.animate( [ { transform: 'translate(-50%, 0)' }, { transform: 'translate(-50%, -140%)' }, ], { duration: 300, easing: 'cubic-bezier(0.4, 0, 0.7, 0.2)', fill: 'forwards' } ).finished.then(remove); }; if (reduceMotion.matches) { el.style.transform = 'translate(-50%, 0)'; setTimeout(hide, 3500); return; } el.animate( [ { transform: 'translate(-50%, -140%)' }, { transform: 'translate(-50%, 0)' }, ], { duration: 350, easing: 'cubic-bezier(0.2, 0.9, 0.3, 1.1)', fill: 'forwards' } ).finished.then((anim) => { el.style.transform = 'translate(-50%, 0)'; anim.cancel(); setTimeout(hide, 3500); }); } function toast(a) { queue.push(a); if (!showing) { showing = true; next(); } } function announce() { state.unlocked.forEach((id) => { if (state.announced.includes(id)) return; state.announced.push(id); const a = ALL.find((x) => x.id === id); if (a) toast(a); }); save(); renderBadge(); } // ── The API the feature files call (always guarded: // window.achv && window.achv.…) ───────────────────────────── function unlock(id) { if (state.unlocked.includes(id)) return; if (!ALL.some((a) => a.id === id)) return; state.unlocked.push(id); if ( id !== 'completionist' && ALL.every((a) => a.id === 'completionist' || state.unlocked.includes(a.id)) ) { state.unlocked.push('completionist'); } save(); announce(); } function mark(id) { if (!MARKS.includes(id) || state.marks.includes(id)) return; state.marks.push(id); save(); if (MARKS.every((m) => state.marks.includes(m))) unlock('investigator'); } function pepsi(n, total) { if (state.pepsi.includes(n)) return; state.pepsi.push(n); save(); if (state.pepsi.length >= total) unlock('cat'); } window.achv = { unlock, mark, pepsi }; // Unlocks earned off-page (the quest) or before this system existed // (a rooted terminal) surface on the next index load setTimeout(() => { try { if (localStorage.getItem('questWon') === '1') unlock('adventurer'); if (localStorage.getItem('termRoot') === '1') unlock('hacker'); } catch (e) {} announce(); }, 800); })();