diff --git a/.gitignore b/.gitignore index dbe9c82..b10a178 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -.vscode/ \ No newline at end of file +.vscode/ +fonts/Kingdom_v1.4.ttf +Pepsi-originals/ diff --git a/404.html b/404.html index ba48c53..2d4d27b 100644 --- a/404.html +++ b/404.html @@ -3,20 +3,30 @@ 404 Not Found

404 Not Found

-
The page has been deleted
+
The page has been deleted.
+ + diff --git a/Pepsi/Pepsi-1.jpg b/Pepsi/Pepsi-1.jpg index 72cbd15..1dd12a9 100644 Binary files a/Pepsi/Pepsi-1.jpg and b/Pepsi/Pepsi-1.jpg differ diff --git a/Pepsi/Pepsi-10.jpg b/Pepsi/Pepsi-10.jpg index 20ce575..36126b5 100644 Binary files a/Pepsi/Pepsi-10.jpg and b/Pepsi/Pepsi-10.jpg differ diff --git a/Pepsi/Pepsi-11.jpg b/Pepsi/Pepsi-11.jpg index 761b2c9..c0471e2 100644 Binary files a/Pepsi/Pepsi-11.jpg and b/Pepsi/Pepsi-11.jpg differ diff --git a/Pepsi/Pepsi-12.jpg b/Pepsi/Pepsi-12.jpg index 9122fbd..ea3b44c 100644 Binary files a/Pepsi/Pepsi-12.jpg and b/Pepsi/Pepsi-12.jpg differ diff --git a/Pepsi/Pepsi-13.jpg b/Pepsi/Pepsi-13.jpg index c2896b4..e1ce5de 100644 Binary files a/Pepsi/Pepsi-13.jpg and b/Pepsi/Pepsi-13.jpg differ diff --git a/Pepsi/Pepsi-14.jpg b/Pepsi/Pepsi-14.jpg index e5c6935..6ad09ef 100644 Binary files a/Pepsi/Pepsi-14.jpg and b/Pepsi/Pepsi-14.jpg differ diff --git a/Pepsi/Pepsi-15.jpg b/Pepsi/Pepsi-15.jpg index 845fb07..92c3ba5 100644 Binary files a/Pepsi/Pepsi-15.jpg and b/Pepsi/Pepsi-15.jpg differ diff --git a/Pepsi/Pepsi-16.jpg b/Pepsi/Pepsi-16.jpg new file mode 100644 index 0000000..5b90406 Binary files /dev/null and b/Pepsi/Pepsi-16.jpg differ diff --git a/Pepsi/Pepsi-17.jpg b/Pepsi/Pepsi-17.jpg new file mode 100644 index 0000000..52c4b4a Binary files /dev/null and b/Pepsi/Pepsi-17.jpg differ diff --git a/Pepsi/Pepsi-18.jpg b/Pepsi/Pepsi-18.jpg new file mode 100644 index 0000000..30b29d3 Binary files /dev/null and b/Pepsi/Pepsi-18.jpg differ diff --git a/Pepsi/Pepsi-19.jpg b/Pepsi/Pepsi-19.jpg new file mode 100644 index 0000000..06a32c8 Binary files /dev/null and b/Pepsi/Pepsi-19.jpg differ diff --git a/Pepsi/Pepsi-2.jpg b/Pepsi/Pepsi-2.jpg index 8f991e8..b06c416 100644 Binary files a/Pepsi/Pepsi-2.jpg and b/Pepsi/Pepsi-2.jpg differ diff --git a/Pepsi/Pepsi-20.jpg b/Pepsi/Pepsi-20.jpg new file mode 100644 index 0000000..0faac19 Binary files /dev/null and b/Pepsi/Pepsi-20.jpg differ diff --git a/Pepsi/Pepsi-3.jpg b/Pepsi/Pepsi-3.jpg index bcbb714..74de13e 100644 Binary files a/Pepsi/Pepsi-3.jpg and b/Pepsi/Pepsi-3.jpg differ diff --git a/Pepsi/Pepsi-4.jpg b/Pepsi/Pepsi-4.jpg index f316b5f..892a80c 100644 Binary files a/Pepsi/Pepsi-4.jpg and b/Pepsi/Pepsi-4.jpg differ diff --git a/Pepsi/Pepsi-5.jpg b/Pepsi/Pepsi-5.jpg index f3422a6..002277a 100644 Binary files a/Pepsi/Pepsi-5.jpg and b/Pepsi/Pepsi-5.jpg differ diff --git a/Pepsi/Pepsi-6.jpg b/Pepsi/Pepsi-6.jpg index a4d4497..5913e6a 100644 Binary files a/Pepsi/Pepsi-6.jpg and b/Pepsi/Pepsi-6.jpg differ diff --git a/Pepsi/Pepsi-7.jpg b/Pepsi/Pepsi-7.jpg index 976b5a5..8f6b354 100644 Binary files a/Pepsi/Pepsi-7.jpg and b/Pepsi/Pepsi-7.jpg differ diff --git a/Pepsi/Pepsi-8.jpg b/Pepsi/Pepsi-8.jpg index 71be080..2d5ed65 100644 Binary files a/Pepsi/Pepsi-8.jpg and b/Pepsi/Pepsi-8.jpg differ diff --git a/Pepsi/Pepsi-9.jpg b/Pepsi/Pepsi-9.jpg index 14600fd..ed66459 100644 Binary files a/Pepsi/Pepsi-9.jpg and b/Pepsi/Pepsi-9.jpg differ diff --git a/fonts/fonts.css b/fonts/fonts.css index 1864f70..aba73e9 100644 --- a/fonts/fonts.css +++ b/fonts/fonts.css @@ -9,3 +9,11 @@ font-family: 'DM Mono'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/dm-mono-500.woff2') format('woff2'); } +@font-face { + font-family: 'Kingdom'; font-style: normal; font-weight: 400; font-display: swap; + src: url('/fonts/kingdom.woff2') format('woff2'); +} +@font-face { + font-family: 'Noto Emoji'; font-style: normal; font-weight: 400; font-display: block; + src: url('/fonts/noto-emoji.woff2') format('woff2'); +} diff --git a/fonts/kingdom.woff2 b/fonts/kingdom.woff2 new file mode 100644 index 0000000..0f4046e Binary files /dev/null and b/fonts/kingdom.woff2 differ diff --git a/fonts/noto-emoji.woff2 b/fonts/noto-emoji.woff2 new file mode 100644 index 0000000..eb11cfe Binary files /dev/null and b/fonts/noto-emoji.woff2 differ diff --git a/index.html b/index.html index bb849b4..4efb66c 100644 --- a/index.html +++ b/index.html @@ -52,7 +52,7 @@
  • Education
  • - + @@ -214,7 +214,7 @@
    -

    An esoteric programming language designed to be usable by an ancient Roman. The letters W, J, and U are banned (non-classical Latin), all numbers must be written as Roman numerals, and every keyword is in Latin: DEFINI for function definitions, INVOCA to call them, DICE for output, AVDI for input, SI/TVNC for conditionals, and so on.

    +

    An esoteric programming language designed to be usable by an ancient Roman. The letters W, J, and U are banned (non-classical Latin), all numbers must be written as Roman numerals, and every keyword is in Latin: DEFINI for function definitions, INVOCA to call them, DIC for output, AVDI for input, SI/TVNC for conditionals, and so on.

    The language includes a tree-walking interpreter and a compiler that targets C. Optional modules extend the standard library with FORS (random numbers), FRACTIO (base-12 fractions for floats), MAGNVM (higher numbers than standard roman numerals), and SVBNVLLA (negative numbers).

    @@ -392,6 +392,22 @@
    - + + + + + + + + + + + + + + + + + diff --git a/js/achievements.js b/js/achievements.js new file mode 100644 index 0000000..27e3c64 --- /dev/null +++ b/js/achievements.js @@ -0,0 +1,341 @@ +// ── 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); +})(); diff --git a/js/anagram.js b/js/anagram.js new file mode 100644 index 0000000..9ed9559 --- /dev/null +++ b/js/anagram.js @@ -0,0 +1,78 @@ +// ── Name anagram: clicks cycle through rearrangements ───────── +(function () { + const h1 = document.querySelector('.hero-name'); + if (!h1) return; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + + const NAME = 'Nikolaj Gade'; + const CYCLE = [NAME, 'a joking deal', NAME, 'naked oil jag']; + + // The visual letters rearrange, but assistive tech always gets the name + h1.setAttribute('aria-label', NAME); + + // Split into persistent per-letter spans; the exact glyphs get reused, + // so the capitals travel (see "a jokiNG deal") + const letters = []; + h1.textContent = ''; + for (const ch of NAME) { + if (ch === ' ') { + h1.appendChild(document.createTextNode(' ')); + continue; + } + const s = document.createElement('span'); + s.className = 'ltr'; + s.textContent = ch; + h1.appendChild(s); + letters.push({ el: s, ch: ch.toLowerCase() }); + } + + function arrange(phrase) { + // Pick a distinct source glyph for every letter of the target + const pool = [...letters]; + const seq = []; + for (const ch of phrase) { + if (ch === ' ') { + seq.push(null); + continue; + } + const i = pool.findIndex((l) => l.ch === ch.toLowerCase()); + seq.push(pool.splice(i, 1)[0].el); + } + + // FLIP: measure, reorder the DOM, measure again, then animate each + // glyph from its old position into its new one + const first = new Map(letters.map((l) => [l.el, l.el.getBoundingClientRect()])); + h1.textContent = ''; + seq.forEach((el) => h1.appendChild(el || document.createTextNode(' '))); + if (reduceMotion.matches) return Promise.resolve(); + + return Promise.all( + letters.map((l, k) => { + const a = first.get(l.el); + const b = l.el.getBoundingClientRect(); + return l.el.animate( + [ + { transform: `translate(${(a.left - b.left).toFixed(1)}px, ${(a.top - b.top).toFixed(1)}px)` }, + { transform: 'translate(0, 0)' }, + ], + { + duration: 650, + delay: k * 18, + fill: 'backwards', + easing: 'cubic-bezier(0.3, 0.9, 0.3, 1)', + } + ).finished; + }) + ); + } + + let pos = 0; + let busy = false; + h1.addEventListener('click', () => { + if (window.achv) window.achv.mark('anagram'); + if (busy) return; + busy = true; + pos = (pos + 1) % CYCLE.length; + arrange(CYCLE[pos]).finally(() => { busy = false; }); + }); +})(); diff --git a/js/avatar-spin.js b/js/avatar-spin.js new file mode 100644 index 0000000..fd75f55 --- /dev/null +++ b/js/avatar-spin.js @@ -0,0 +1,14 @@ +// ── Avatar spin on click ────────────────────────────────────── +(function () { + const avatar = document.querySelector('.avatar-ring'); + if (!avatar) return; + + avatar.addEventListener('click', () => { + if (window.achv) window.achv.mark('avatar'); + if (avatar.classList.contains('spinning')) return; + avatar.classList.add('spinning'); + }); + avatar.addEventListener('animationend', () => { + avatar.classList.remove('spinning'); + }); +})(); diff --git a/js/burger.js b/js/burger.js new file mode 100644 index 0000000..21cabad --- /dev/null +++ b/js/burger.js @@ -0,0 +1,19 @@ +// ── Mobile burger ────────────────────────────────────────────── +(function () { + const burger = document.getElementById('burger'); + const navLinks = document.getElementById('navLinks'); + const header = document.getElementById('header'); + if (!burger || !navLinks || !header) return; + burger.addEventListener('click', () => { + burger.classList.toggle('open'); + navLinks.classList.toggle('open'); + header.classList.toggle('scrolled', burger.classList.contains('open') || window.scrollY > 24); + }); + navLinks.querySelectorAll('a').forEach(a => { + a.addEventListener('click', () => { + burger.classList.remove('open'); + navLinks.classList.remove('open'); + header.classList.toggle('scrolled', window.scrollY > 24); + }); + }); +})(); diff --git a/js/d20.js b/js/d20.js new file mode 100644 index 0000000..5bee4ac --- /dev/null +++ b/js/d20.js @@ -0,0 +1,252 @@ +// ── D20 on "Dungeons & Dragons": a 3D die built from matrix3d ─ +(function () { + const anchor = document.querySelector('.dnd-roll'); + if (!anchor) return; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + + // Icosahedron in CSS coordinates (x right, y down, z toward viewer) + const R = 44; // circumradius, px + const PHI = (1 + Math.sqrt(5)) / 2; + const RAW = [ + [-1, PHI, 0], [1, PHI, 0], [-1, -PHI, 0], [1, -PHI, 0], + [0, -1, PHI], [0, 1, PHI], [0, -1, -PHI], [0, 1, -PHI], + [PHI, 0, -1], [PHI, 0, 1], [-PHI, 0, -1], [-PHI, 0, 1], + ]; + const FACES = [ + [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], + [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], + [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], + [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], + ]; + // The standard d20 layout (per Alea Kybos' dice configuration catalog), + // embedded onto the face list above: 20 bordered by 2/8/14, opposite + // faces sum to 21, evens on 20's hemisphere and odds on 1's + const NUMBERS = [20, 2, 12, 10, 8, 18, 14, 16, 15, 17, 11, 9, 19, 1, 13, 6, 4, 3, 7, 5]; + // Theme pastels the die can be cast from, one picked per roll + const PASTELS = [ + [255, 216, 77], [140, 188, 255], [255, 159, 202], + [169, 231, 108], [185, 156, 255], [255, 157, 87], + ]; + const scale = R / Math.hypot(1, PHI); + const V = RAW.map(([x, y, z]) => [x * scale, -y * scale, z * scale]); + + const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + const cross = (a, b) => [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; + const norm = (a) => { + const l = Math.hypot(a[0], a[1], a[2]); + return [a[0] / l, a[1] / l, a[2] / l]; + }; + const mat3d = ([X, Y, Z], t) => + `matrix3d(${X[0]},${X[1]},${X[2]},0,${Y[0]},${Y[1]},${Y[2]},0,` + + `${Z[0]},${Z[1]},${Z[2]},0,${t[0]},${t[1]},${t[2]},1)`; + + let scene = null; + let die = null; + const bases = []; + const faceEls = []; + + function build() { + scene = document.createElement('span'); + scene.className = 'die-scene'; + die = document.createElement('span'); + die.className = 'die'; + scene.appendChild(die); + + const light = norm([-0.35, -0.5, 0.78]); + FACES.forEach((f, idx) => { + let [A, B, C] = f.map((i) => V[i]); + const G = [ + (A[0] + B[0] + C[0]) / 3, + (A[1] + B[1] + C[1]) / 3, + (A[2] + B[2] + C[2]) / 3, + ]; + let n = norm(cross(sub(B, A), sub(C, A))); + if (dot(n, G) < 0) { // ensure the normal points outward + [B, C] = [C, B]; + n = [-n[0], -n[1], -n[2]]; + } + const X = norm(sub(B, A)); // face-local right + const Y = norm(cross(n, X)); // face-local down (X × Y = n, no mirroring) + bases.push([X, Y, n]); + + const D = Math.hypot(...sub(B, A)); // edge length + const face = document.createElement('span'); + face.className = 'die-face'; + face.textContent = NUMBERS[idx]; + // Real dice underline the ambiguous digits + if (NUMBERS[idx] === 6 || NUMBERS[idx] === 9) face.classList.add('die-face--mark'); + const pts = [A, B, C].map((P) => { + const p = sub(P, G); + return `${dot(p, X) + D / 2}px ${dot(p, Y) + D / 2}px`; + }); + face.style.width = face.style.height = D + 'px'; + face.style.margin = -D / 2 + 'px'; + face.style.fontSize = D * 0.3 + 'px'; + face.style.clipPath = `polygon(${pts.join(', ')})`; + // Static lighting factor, applied to the roll's color when it's cast + faceEls.push({ + el: face, + shade: 0.6 + 0.4 * Math.max(0, dot(n, light)), + n, G, + t: mat3d([X, Y, n], G), + }); + die.appendChild(face); + }); + anchor.appendChild(scene); + } + + // No preserve-3d anywhere: Firefox's plane-splitting misrenders faces + // that meet at shared edges (halves of a face vanish at grazing + // angles), so each face is projected to its own flat 2D transform and + // back faces are culled by hand — being convex, the visible faces + // never overlap, so no browser z-sorting is needed either + const PERSP = 420; // projection depth, px + function pose(dieT) { + const m = new DOMMatrix(dieT); + faceEls.forEach(({ el, n, G, t }) => { + const nx = m.m11 * n[0] + m.m21 * n[1] + m.m31 * n[2]; + const ny = m.m12 * n[0] + m.m22 * n[1] + m.m32 * n[2]; + const nz = m.m13 * n[0] + m.m23 * n[1] + m.m33 * n[2]; + const cx = m.m11 * G[0] + m.m21 * G[1] + m.m31 * G[2] + m.m41; + const cy = m.m12 * G[0] + m.m22 * G[1] + m.m32 * G[2] + m.m42; + const cz = m.m13 * G[0] + m.m23 * G[1] + m.m33 * G[2] + m.m43; + if (nx * cx + ny * cy + nz * (cz - PERSP) < 0) { + el.style.transform = `perspective(${PERSP}px) ${dieT} ${t}`; + el.style.visibility = ''; + } else { + el.style.visibility = 'hidden'; + } + }); + } + + // cubic-bezier(0.16, 0.6, 0.28, 1) by hand, since the tumble is now a + // rAF loop instead of a WAAPI animation + function ease(x) { + if (x <= 0) return 0; + if (x >= 1) return 1; + const bez = (a, b, u) => + 3 * a * u * (1 - u) * (1 - u) + 3 * b * u * u * (1 - u) + u * u * u; + let lo = 0; + let hi = 1; + for (let i = 0; i < 24; i++) { + const mid = (lo + hi) / 2; + if (bez(0.16, 0.28, mid) < x) lo = mid; else hi = mid; + } + return bez(0.6, 1, (lo + hi) / 2); + } + + // Rotation that lands face i flat toward the viewer, number upright + function landing(i) { + const [X, Y, Z] = bases[i]; + return `matrix3d(${X[0]},${Y[0]},${Z[0]},0,${X[1]},${Y[1]},${Z[1]},0,` + + `${X[2]},${Y[2]},${Z[2]},0,0,0,0,1)`; + } + + let busy = false; + let rolls = 0; + anchor.addEventListener('click', () => { + if (busy) return; + busy = true; + if (window.achv) window.achv.mark('d20'); + if (!scene) build(); + scene.style.display = ''; + scene.style.opacity = '1'; + + // The first two rolls of a page load can never land a 20 — the + // achievement trigger stays out of reach of casual clicking + rolls++; + let result = Math.floor(Math.random() * 20) + 1; + if (rolls <= 2 && result === 20) result = Math.floor(Math.random() * 19) + 1; + const final = landing(NUMBERS.indexOf(result)); + + // Cast the die in one of the theme pastels for this roll + const [pr, pg, pb] = PASTELS[Math.floor(Math.random() * PASTELS.length)]; + faceEls.forEach(({ el, shade }) => { + el.style.background = + `rgb(${Math.round(pr * shade)}, ${Math.round(pg * shade)}, ${Math.round(pb * shade)})`; + }); + + // Nat 20: DOM sprinkles burst from the die — unlike the canvas ones, + // these render above the cards, so the effect survives any backdrop + const burst = () => { + const r = scene.getBoundingClientRect(); + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + for (let i = 0; i < 24; i++) { + const bit = document.createElement('span'); + bit.className = 'burst-bit'; + const [br, bg, bb] = PASTELS[Math.floor(Math.random() * PASTELS.length)]; + bit.style.background = `rgb(${br}, ${bg}, ${bb})`; + bit.style.left = cx + 'px'; + bit.style.top = cy + 'px'; + document.body.appendChild(bit); + const a = Math.random() * Math.PI * 2; + const d = 60 + Math.random() * 90; + bit.animate( + [ + { + transform: `translate(-50%, -50%) rotate(${Math.random() * 360}deg)`, + opacity: 1, + }, + { + transform: + `translate(calc(-50% + ${(Math.cos(a) * d).toFixed(1)}px), ` + + `calc(-50% + ${(Math.sin(a) * d).toFixed(1)}px)) ` + + `rotate(${((Math.random() - 0.5) * 720).toFixed(0)}deg)`, + opacity: 0, + }, + ], + { duration: 700 + Math.random() * 300, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)', fill: 'forwards' } + ).finished.then(() => bit.remove()); + } + }; + + const finish = () => { + if (result === 20 && !reduceMotion.matches) burst(); + if (result === 20 && window.achv) window.achv.unlock('natural'); + setTimeout(() => { + scene.style.opacity = '0'; + setTimeout(() => { + scene.style.display = 'none'; + busy = false; + }, 320); + }, 1100); + }; + + if (reduceMotion.matches) { + pose(final); + finish(); + return; + } + // One continuous decelerating roll from a fully random direction: the + // die tumbles end-over-end along its travel (axis perpendicular to the + // motion, in-plane) with a slower barrel roll around the travel axis. + // Both extra rotations unwind to exactly zero on top of the landing + // matrix, so it settles on the result with no correcting jerk. + const ang = Math.random() * Math.PI * 2; + const dx = Math.cos(ang); + const dy = Math.sin(ang); + const roll = 810 + Math.floor(Math.random() * 3) * 72; + const wobble = 240 + Math.random() * 240; + const tumbleAxis = `${dy.toFixed(4)},${(-dx).toFixed(4)},0`; + const travelAxis = `${dx.toFixed(4)},${dy.toFixed(4)},0`; + const at = (e) => + `translate(${(-dx * 150 * (1 - e)).toFixed(2)}px, ` + + `${(-dy * 150 * (1 - e)).toFixed(2)}px) ` + + `rotate3d(${tumbleAxis},${(roll * (1 - e)).toFixed(3)}deg) ` + + `rotate3d(${travelAxis},${(-wobble * (1 - e)).toFixed(3)}deg) ${final}`; + pose(at(0)); + const t0 = performance.now(); + requestAnimationFrame(function frame(now) { + const p = Math.min(1, Math.max(0, (now - t0) / 1400)); + pose(p < 1 ? at(ease(p)) : final); + if (p < 1) requestAnimationFrame(frame); + else finish(); + }); + }); +})(); diff --git a/js/delete-website.js b/js/delete-website.js new file mode 100644 index 0000000..507d6f4 --- /dev/null +++ b/js/delete-website.js @@ -0,0 +1,19 @@ +// ── Delete website (footer): flag in localStorage, serve the 404 ─ +(function () { + const button = document.getElementById('deleteSite'); + const dialog = document.getElementById('deleteDialog'); + const confirm = document.getElementById('deleteConfirm'); + const cancel = document.getElementById('deleteCancel'); + if (!button || !dialog) return; + + button.addEventListener('click', () => dialog.showModal()); + cancel.addEventListener('click', () => dialog.close()); + confirm.addEventListener('click', () => { + try { localStorage.setItem('siteDeleted', '1'); } catch (e) {} + location.replace('404.html'); + }); + // Click on the backdrop closes too + dialog.addEventListener('click', (e) => { + if (e.target === dialog) dialog.close(); + }); +})(); diff --git a/js/goat.js b/js/goat.js new file mode 100644 index 0000000..4e509ee --- /dev/null +++ b/js/goat.js @@ -0,0 +1,44 @@ +// ── Goat ────────────────────────────────────────────────────── +(function () { + console.log(` + %%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%% + %%%% %%%%%%%%%%% + %%% %%%%%%%%%%%%%%% + %% %%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%`); + + const FACTS = [ + `The word admiral comes from the Arabic "amir al bahr", which translates to "commander of the sea". Somewhere along the way into European languages, the bahr fell off, so the word we kept literally means "commander of the".`, + `The word stationery comes from (perhaps obviously) the word stationary. It's because book shops were some of the first shops to be in permanent buildings, since books are too heavy to carry around in a cart.`, + `The Wikipedia page for economist Guy Standing was the site of one of the biggest edit wars in Wikipedia history, started when an editor uploaded a picture with the description "Guy Standing sitting".`, + `The introduction of shipping containers, which hide their contents, dropped the number of alcohol bottles marked as "broken" by almost 100%.`, + `The words "chess" and "check" both come from the ancient Persian word "shah", meaning "king". "Check mate" comes from "shah maat", meaning "the king is dead".`, + `No one knows the origin of the character of Igor in the Frankenstein story. He's not in the original book, and his first known appearance is from the movie adaptation of a play. So it's not known if he originally appeared in the play or the movie.`, + `To avoid a binary overflow error, Swiss trains are not allowed to have exactly 256 axles.`, + ]; + console.log('Fun fact: ' + FACTS[Math.floor(Math.random() * FACTS.length)]); +})(); diff --git a/js/icon-spin.js b/js/icon-spin.js new file mode 100644 index 0000000..192453f --- /dev/null +++ b/js/icon-spin.js @@ -0,0 +1,14 @@ +// ── Icon circles spin on click, same move as the avatar ─────── +(function () { + const icons = document.querySelectorAll('.skill-card-icon, .project-icon, .edu-icon, .nav-logo'); + icons.forEach((icon, i) => { + icon.addEventListener('click', () => { + if (window.achv) window.achv.mark('icon-' + i); + if (icon.classList.contains('spinning')) return; + icon.classList.add('spinning'); + }); + icon.addEventListener('animationend', () => { + icon.classList.remove('spinning'); + }); + }); +})(); diff --git a/js/nav.js b/js/nav.js new file mode 100644 index 0000000..0c32749 --- /dev/null +++ b/js/nav.js @@ -0,0 +1,20 @@ +// ── Nav: scroll state & active link ─────────────────────────── +(function () { + const header = document.getElementById('header'); + const links = document.querySelectorAll('.nav-link'); + const sections = document.querySelectorAll('section[id]'); + const burger = document.getElementById('burger'); + if (!header || !burger) return; + + window.addEventListener('scroll', () => { + header.classList.toggle('scrolled', window.scrollY > 24 || burger.classList.contains('open')); + + let current = ''; + sections.forEach(s => { + if (window.scrollY >= s.offsetTop - 100) current = s.id; + }); + links.forEach(l => { + l.classList.toggle('active', l.getAttribute('href') === '#' + current); + }); + }, { passive: true }); +})(); diff --git a/js/pepsi.js b/js/pepsi.js new file mode 100644 index 0000000..c381d28 --- /dev/null +++ b/js/pepsi.js @@ -0,0 +1,59 @@ +// ── Pepsi gallery: shuffled on load, swipe cycles through on click ─ +(function () { + const COUNT = 20; + const img = document.querySelector('.pepsi-placeholder img'); + if (!img) return; + const box = img.parentElement; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + const src = (n) => `Pepsi/Pepsi-${n}.jpg`; + + // Fisher–Yates shuffle of [1..COUNT]; clicks cycle through this order + const order = Array.from({ length: COUNT }, (_, i) => i + 1); + for (let i = order.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [order[i], order[j]] = [order[j], order[i]]; + } + let pos = 0; + img.src = src(order[pos]); + const seen = (n) => { if (window.achv) window.achv.pepsi(n, COUNT); }; + seen(order[pos]); + + let busy = false; + box.addEventListener('click', () => { + if (busy) return; + if (window.achv) window.achv.mark('pepsi'); + pos = (pos + 1) % order.length; + const next = order[pos]; + + if (reduceMotion.matches) { + img.src = src(next); + seen(next); + return; + } + + busy = true; + const incoming = new Image(); + incoming.src = src(next); + incoming.alt = img.alt; + incoming.draggable = false; + incoming.className = 'pepsi-incoming'; + // Decode before animating so the swipe never reveals a half-loaded frame + incoming.decode().then(() => { + box.appendChild(incoming); + seen(next); + const opts = { duration: 300, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }; + img.animate( + [{ transform: 'translateX(0)' }, { transform: 'translateX(-100%)' }], + opts + ); + incoming.animate( + [{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }], + opts + ).finished.then(() => { + img.src = incoming.src; // already decoded, swaps instantly + incoming.remove(); + busy = false; + }); + }).catch(() => { busy = false; }); + }); +})(); diff --git a/js/pronouns.js b/js/pronouns.js new file mode 100644 index 0000000..c39526c --- /dev/null +++ b/js/pronouns.js @@ -0,0 +1,16 @@ +// ── Pronoun pill: non-binary flag sweep on click ────────────── +(function () { + const pill = document.querySelector('.hero-pronouns'); + if (!pill) return; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + + pill.addEventListener('click', () => { + if (window.achv) window.achv.mark('pronouns'); + if (reduceMotion.matches) return; + if (pill.classList.contains('waving')) return; + pill.classList.add('waving'); + }); + pill.addEventListener('animationend', () => { + pill.classList.remove('waving'); + }); +})(); diff --git a/js/quest.js b/js/quest.js new file mode 100644 index 0000000..cd2ebb8 --- /dev/null +++ b/js/quest.js @@ -0,0 +1,1024 @@ +/* The Quest for the Deleted Website (quest.html only). + + The quest survives reloads (and even restoring the website): the save + file is questHero + questPronouns (JSON) + questWeapon + questHand + + questEncounters (JSON) + questEnc1 (JSON) + questEnc2 + questKid + + questEnc3 + questEnc4 + questEnc5 + questPrincess + questWeaponLost + + questVillain + questVillainPronouns (JSON) + questAttacked in + localStorage. + + All story text lives in TEXT, written in full with {token} substitutions. + Tokens: {name} plus the pronoun forms {subj} {obj} {posd} {refl}. + Capitalize the token to capitalize the substitution: {Subj} -> "She" */ +(function () { + var naming = document.getElementById('naming'); + if (!naming) return; + + var TEXT = { + intro: '{name} was but a simple farmer living in the magical kingdom of' + + ' Grazaikya, until one day when gade.gg was deleted.', + setout: '{name} had wanted to log onto gade.gg, {posd} favorite website, but saw' + + ' that it had been deleted, presumably by an evil, heartless monster.' + + ' So {subj} picked up {posd} preferred weapon, and embarked on a quest' + + ' to find the deleted website.', + traverse: 'Clutching {posd} {weapon} in {posd} {hand} hand, {name}' + + ' traversed across the lands of Grazaikya, determined to recover the' + + ' deleted site.', + enc1: '{name} attempted to cross a bridge over a river, but was' + + ' accosted by a hideous creature. A bridge troll stopped {obj} and' + + ' said "Before you may cross the river, you must first answer my' + + ' riddle!".', + enc1riddle: '"Very well, then", said {name}, "tell me your riddle,' + + ' hideous beast". "Wow, rude" said the troll, aghast. It then stood' + + ' for a moment, waiting for an apology that never came, and then' + + ' continued: "I run, but never walk; I babble, but never talk. What' + + ' am I?".', + enc1attack: '{name} {attacked} the troll{withweapon}, but the' + + ' troll attacked back, ripping off {posd} leg. {name} {attacked} the' + + ' troll one more time, which ended its life. {name} hobbled away.', + enc1wrong: '"{answer}?" asked {name}, unsure of {refl}. "Nope", said' + + ' the troll, picking up {name} above its head. With a horrible' + + " sound, the troll ripped off {name}'s leg, and threw {obj} across" + + ' to the other side of the bridge. {name} hobbled away from the' + + ' troll, who was laughing triumphantly.', + enc1correct: '"A river!" said {name}. "Yeah, that\'s right!", answered' + + ' the troll, "you\'re, like, super smart!". "Now let me pass, hideous' + + ' beast", demanded {name}. The troll obliged, despite being pretty' + + ' hurt by the name-calling. {name} continued onwards on {posd} quest.', + enc2: 'While walking on a path through grassy fields, a young boy, no' + + ' older than 16, ran up to {name}. "Are you an adventurer? Are you' + + ' on a quest? Can I join?", the kid asked excitedly.', + enc2yes: '"Why, certainly. The more the merrier!", said {name},' + + ' cheerfully. The kid jumped up and twirled around in the air.' + + ' "You won\'t regret this!", he said, foreshadowingly, "My name\'s' + + ' Breigan!". Breigan and {name} continued on their quest.', + enc2no: '"Shouldn\'t you be with your parents, kid?", asked {name}.' + + ' The boy lowered his head and looked at the ground.' + + ' "I don\'t have parents", he said, "not since the fire". He then' + + ' slowly walked away, sulking, leaving {name} to continue {posd}' + + ' quest alone.', + enc3: 'At nightfall, {name} came across an abandoned cottage. Feeling' + + ' tired, {subj} decided to use it for shelter for the night. Inside' + + ' the cottage, {name} saw a seemingly untouched chest amongst the' + + ' rubble. {Subj} tried to open it, but it was locked.', + enc3pryBtn: 'Pry it open with your {weapon}', + enc3pry: '{name} attempted to force the chest open with {posd}' + + ' {weapon}. After a few tries, the chest broke open, revealing a' + + ' horrifying row of teeth inside. The mimic lunged forward towards' + + ' {name}, who, in {posd} panic, let go of {posd} {weapon}. {name}' + + ' left the cottage, continuing the quest without {posd} {weapon}.', + enc3leave: '"Better to leave things like that alone", {name} thought.' + + " {Subj} had a good night's rest in the cottage, and continued" + + ' onwards on {posd} quest in the morning.', + enc4: 'While travelling along a road, {name} met an old man selling' + + ' potions. "Potionseller!", {subj} said, "I am going into battle,' + + ' and I need your strongest potions". "Well", the old man answered,' + + ' "my strongest potions are quite expensive. Do you have the coin?".' + + ' {name} did, in fact, not have any money.', + enc4steal: '{name} {attacked} the potionseller{withweapon}, and took' + + ' one of his potions. Once {subj} was far enough away, {subj} drank' + + ' it. Suddenly, {subj} could feel {posd} body changing: {posd} limbs' + + ' shrinking and {posd} nose embiggening. Suddenly, {name} found' + + ' {refl} in the body of a pig. {Subj} sulked for a bit, and then' + + ' continued onwards on {posd} quest, as a pig.', + enc4explain: '"I have no money", {name} said, "but if you-", {subj}' + + ' continued, but before {subj} could finish, the potionseller spat' + + ' in {posd} face and said "piss off, poor-y!". {name} continued' + + ' onwards on {posd} quest, potionless.', + enc5: '{name} was walking along a road when {subj} passed a tower' + + ' standing by the path. A beautiful young woman stuck her head out' + + ' of the tallest window and shouted at {name}, "Oi! Are you here to' + + ' rescue me?". Flustered, {name} answered "I wasn\'t planning on' + + ' it. Are you a princess or something?". "Yeah, probably", said the' + + ' likely princess.', + enc5decline: '"Actually, I\'m quite busy", said {name}, walking away.' + + ' "Aw, come on?", the probable princess asked. "No no, I simply' + + ' must go", replied {name}, already too far away to hear whatever' + + ' the potential princess said next.', + enc5agree: '"All right, then", said {name}, "I\'m sure saving you' + + ' won\'t take too much time". {Subj} looked at {posd} options.', + enc5vines: '{name} started climbing up the side of the tower,' + + ' grabbing on to the vines. The probable princess looked puzzled:' + + ' "You know the door is unlocked, right?". {name}, already out of' + + ' breath, replied "No, I\'ve committed now!". {Subj} had climbed up' + + ' roughly 30 cm and was already straining all of {posd} muscles to' + + ' hang on to the vines. "Seriously", said the princess, worried,' + + ' "you should really just take the door". {name} lost {posd} grip' + + ' on the vines and tumbled to the ground. Embarrassed, {subj}' + + ' quickly ran away, continuing {posd} quest.', + enc5door: '{name} walked up to the front door, expecting it to be' + + ' locked. It was not. {Subj} then walked up the spiral staircase,' + + ' expecting to be jumped by a monster or something. {Subj} was not.' + + " Finally, {subj} reached the potential princess' door, which was" + + ' already open. "I\'m confused", said {name}, "What did you need' + + ' saving from?". "Boredom, mainly", replied the' + + ' now-not-quite-so-likely princess. "I\'m Anastasia, by the way",' + + ' she said, while walking down the stairs. Before {name} could' + + ' protest, Anastasia had already joined {posd} quest.', + princessrename: "{name} didn't like Anastasia's name, so {subj}" + + ' started calling her {princess} instead.', + kidrename: "{name} didn't like Breigan's name, so {subj} started" + + ' calling him {kid} instead.', + arrival: '{name} arrived at the tower of the evil wizard {subj} had' + + ' heard was behind the deletion of gade.gg, {posd} favorite website.', + confront: '{name} went inside the tower, seeing the massive dark' + + ' gothic interior. There, sitting on a throne, was {vname}.' + + ' "I suspect you have come to kill me", {vsubj} said, "for deleting' + + ' gade.gg, your favorite website."' + }; + + /* The showdown endings, one handwritten text per situation. Key: + chosen encounters' 1-based pool positions, a colon, then one S/F + letter each, all in pool order — '123:SFS' = troll+kid+mimic with + only the kid failed. Success is: troll = riddle solved, Breigan = joined, + mimic = chest left alone. The wizard is only beatable with >= 2 S. + Villain tokens: {vname} {vsubj} {vobj} {vposd} {vrefl}. + Empty entries render nothing */ + var ENDINGS = { + '123:SSS': '{name} ran towards {vname}, {attacking} {vobj} with {posd}' + + ' {weapon}. {kid} came up behind {vname}, pushing {vobj}, and' + + ' making {vobj} lose {vposd} balance. {vname} tried to cast a spell' + + ' at either of them, but it was too late. {name} and {kid} killed' + + ' {vname}, restoring the website for good.', + '123:SSF': '{name} and {kid} ran towards {vname}, ready to punch {vobj}.' + + ' With a flick of {vposd} wrist, {vname} cast a spell on {kid},' + + ' disintegrating him immediately. {name} yelled out towards him, but' + + ' there was nothing {subj} could do. Nothing, except avenge him.' + + ' {name} ran up to {vname} and delivered a powerful punch. A punch' + + ' so powerful that {vposd} head exploded. {vname} was dead, and the' + + ' website was restored for good.', + '123:SFS': '{name} ran towards {vname}. An epic duel commenced, with {vname}' + + ' throwing spells and {name} {attacking} with {posd} {weapon}.' + + ' Finally, when the dust settled, {name} stood victorious. {vname}' + + ' was dead, and the website was restored for good.', + '123:SFF': '{name} ran towards {vname}, ready to punch {vobj}. With a flick' + + ' of {vposd} wrist, {vname} cast a spell on {name}, disintegrating' + + ' {obj} immediately.', + '123:FSS': '{name} hobbled towards {vname}, {attacking} {vobj} with {posd}' + + ' {weapon}. {kid} came up behind {vname}, pushing {vobj}, and' + + ' making {vobj} lose {vposd} balance. {vname} tried to cast a spell' + + ' at either of them, but it was too late. {name} and {kid} killed' + + ' {vname}, restoring the website for good.', + '123:FSF': '{name} hobbled towards {vname}, ready to punch {vobj}. With a' + + ' flick of {vposd} wrist, {vname} cast a spell on {kid},' + + ' disintegrating him immediately. {name} yelled out towards him, but' + + ' there was nothing {subj} could do. With another flick of {vposd}' + + ' wrist, {vname} cast the same spell on {name}, disintegrating {obj}' + + ' as well.', + '123:FFS': '{name} hobbled towards {vname}, {attacking} {vobj} with {posd}' + + ' {weapon}. With a flick of {vposd} wrist, {vname} cast a spell on' + + ' {name}, disintegrating {obj} immediately.', + '123:FFF': '{name} hobbled towards {vname}, ready to punch {vobj}. With a' + + ' flick of {vposd} wrist, {vname} cast a spell on {name},' + + ' disintegrating {obj} immediately.', + /* Family 124 (troll, Breigan, potion): the one-legged pigs. The + other six situations are aliased below */ + '124:FSF': '{name} hobbled towards {vname} on {posd} three remaining' + + ' trotters, {posd} {weapon} clenched in {posd} pig mouth, with' + + ' {kid} running ahead. With a flick of {vposd} wrist, {vname} cast' + + ' a spell on {kid}, disintegrating him immediately. {name} let out' + + ' a devastated squeal. With another flick of {vposd} wrist, {vname}' + + ' cast the same spell on {name}, disintegrating {obj} as well.', + '124:FFF': '{name} hobbled towards {vname} on {posd} three remaining' + + ' trotters, {attacking} {vobj} with {posd} {weapon}. {vname}' + + ' watched the limping pig approach for a while, mildly fascinated.' + + ' Then, with a flick of {vposd} wrist, {vsubj} cast a spell on' + + ' {name}, disintegrating {obj} immediately.', + /* Family 134 (troll, mimic, potion): no Breigan, so the win + threshold lands differently — same body, different fate. The + three overlapping-outcome situations are aliased below */ + '134:SSF': '{name} charged towards {vname}, clutching {posd} {weapon}' + + ' in {posd} pig mouth. {vname} laughed at the sight, flicking' + + ' {vposd} wrist to cast a spell. But the spell missed: {vname} had' + + ' never fought a pig before, and pigs are much lower to the ground' + + ' than the heroes {vsubj} was used to. {name} {attacked} {vobj}' + + ' before {vsubj} could try again. {vname} was dead, and the website' + + ' was restored for good.', + '134:SFS': '{name} ran towards {vname}, ready to punch {vobj}.' + + ' {vname} flicked {vposd} wrist and cast a spell, but {name} dodged' + + ' it and kept running. {Subj} reached the throne and delivered a' + + ' powerful punch. A punch so powerful that {vposd} head exploded.' + + ' {vname} was dead, and the website was restored for good.', + '134:FSS': '{name} hobbled towards {vname}, {attacking} {vobj} with' + + ' {posd} {weapon}. An epic duel commenced, with {vname} throwing' + + ' spells and {name} deflecting them on one leg. Finally, when the' + + ' dust settled, {name} stood victorious. {vname} was dead, and the' + + ' website was restored for good.', + '134:FFS': '{name} hobbled towards {vname}, ready to punch {vobj}.' + + ' {vname} watched {posd} slow approach with something between pity' + + ' and boredom. With a flick of {vposd} wrist, {vname} cast a spell' + + ' on {name}, disintegrating {obj} immediately.', + '134:FFF': '{name} hobbled towards {vname} on {posd} three remaining' + + ' trotters, ready to bite {vobj}. {vname} waited politely for the' + + ' pig to arrive. Then, with a flick of {vposd} wrist, {vsubj} cast' + + ' a spell on {name}, disintegrating {obj} immediately.', + /* Family 234 (Breigan, mimic, potion): pig situations. The human + ones are aliased from family 123 below */ + '234:SSF': '{name} charged towards {vname}, clutching {posd} {weapon}' + + ' in {posd} pig mouth. {vname} hesitated. {Vsubj} had prepared for' + + ' many things, but not for a pig with {aweapon}. {kid} used the' + + ' moment to come up behind {vname}, pushing {vobj} and making {vobj}' + + ' lose {vposd} balance. {name} {attacked} {vobj} before {vsubj}' + + ' could get back up. {vname} was dead, and the website was restored' + + ' for good.', + '234:SFF': '{kid} ran towards {vname}, ready to punch {vobj}, with' + + ' {name} trotting closely behind. With a flick of {vposd} wrist,' + + ' {vname} cast a spell on {kid}, disintegrating him immediately.' + + ' {name} let out a devastated squeal. With another flick of {vposd}' + + ' wrist, {vname} cast the same spell on {name}, disintegrating {obj}' + + ' as well.', + '234:FSF': '{name} trotted towards {vname}, {attacking} {vobj} with' + + ' {posd} {weapon}. {vname} was briefly impressed by the sight of a' + + ' pig wielding {aweapon}, but not enough to be stopped. With a' + + ' flick of {vposd} wrist, {vname} cast a spell on {name},' + + ' disintegrating {obj} immediately.', + '234:FFF': '{name} trotted towards {vname}, ready to bite {vobj}.' + + ' {vname} looked at the pig for a long moment, almost feeling sorry' + + ' for it. Then, with a flick of {vposd} wrist, {vsubj} cast a spell' + + ' on {name}, disintegrating {obj} immediately.', + /* Family 345 (mimic, potion, princess): Anastasia situations. The + princess-less four are aliased below */ + '345:SSS': '{name} ran towards {vname}, {attacking} {vobj} with' + + ' {posd} {weapon}. {vname} blocked the attack with a shield spell' + + ' and began preparing a counterattack. While the wizard was busy,' + + ' {princess} calmly walked up behind {vobj} and kicked {vobj} off' + + ' the throne. {name} {attacked} {vobj} one final time before' + + ' {vsubj} could get up. {vname} was dead, and the website was' + + ' restored for good.', + '345:SFS': '{name} charged towards {vname}, clutching {posd} {weapon}' + + ' in {posd} pig mouth. "Is that a pig?", asked {vname}, bewildered.' + + ' "I\'m honestly as confused as you are", answered {princess},' + + ' already walking up behind the throne. Before {vname} could figure' + + ' out what was going on, {princess} pushed {vobj} to the ground,' + + ' and {name} {attacked} {vobj}. {vname} was dead, and the website' + + ' was restored for good.', + '345:FSS': '{name} ran towards {vname}, ready to punch {vobj}.' + + ' {vname} flicked {vposd} wrist and cast a spell at {name}, but' + + ' {princess} pulled {obj} out of the way at the last moment. Before' + + ' {vname} could try again, {name} reached the throne and delivered' + + ' a powerful punch. A punch so powerful that {vposd} head exploded.' + + ' {vname} was dead, and the website was restored for good.', + '345:FFS': '{name} trotted towards {vname}, ready to bite {vobj},' + + ' with {princess} walking behind. With a flick of {vposd} wrist,' + + ' {vname} cast a spell at {princess}, who calmly sidestepped it.' + + ' {Vsubj} then cast the same spell at {name}, who did not sidestep' + + ' it, and was disintegrated immediately. {princess} shrugged, and' + + ' showed herself out.', + /* Family 145 (troll, potion, princess): the one-legged Anastasia + situations. The other six are aliased below */ + '145:FSS': '{name} hobbled towards {vname}, {attacking} {vobj} with' + + ' {posd} {weapon}. {vname} aimed a spell at {posd} remaining leg,' + + ' but {princess} threw a rock at {vposd} head, ruining {vposd}' + + ' concentration. Before {vname} could recover, {name} reached the' + + ' throne and {attacked} {vobj} one final time. {vname} was dead,' + + ' and the website was restored for good.', + '145:FFS': '{name} hobbled towards {vname} on {posd} three remaining' + + ' trotters, {posd} {weapon} clenched in {posd} pig mouth, with' + + ' {princess} walking behind. With a flick of {vposd} wrist, {vname}' + + ' cast a spell at {princess}, who calmly sidestepped it. {Vsubj}' + + ' then cast the same spell at {name}, who, being a three-legged' + + ' pig, could not. {princess} sighed, muttered something under her' + + ' breath, and left the tower.', + /* Family 135 (troll, mimic, princess): one new situation — the rest + alias below */ + '135:FFS': '{name} hobbled towards {vname}, ready to punch {vobj},' + + ' leaning on {princess} for support. With a flick of {vposd} wrist,' + + ' {vname} cast a spell at {princess}, who calmly sidestepped it.' + + ' {Vsubj} then cast the same spell at {name}, who could not' + + ' sidestep anything, and was disintegrated immediately. {princess}' + + ' stood in silence for a moment, then left the tower without a word.', + /* Family 125 (troll, Breigan, princess): armed human throughout, so + three losses have no armed-and-able precedent to alias */ + '125:SSS': '{name}, {kid} and {princess} charged towards {vname}' + + ' together. {vname} began casting a spell, but {princess} reached' + + ' the throne first and pushed {vobj} to the floor. What followed' + + ' was less of a battle and more of a group activity: the three of' + + ' them simply kicked {vname} until {vsubj} stopped moving. {name}' + + ' had briefly considered using {posd} {weapon}, but kicking had' + + ' felt more appropriate. {vname} was dead, and the website was' + + ' restored for good.', + '125:FSS': '{name} hobbled towards {vname}, but {kid} and {princess}' + + ' had already run ahead. {princess} knocked {vname} to the ground,' + + ' and {kid} sat down on {vposd} back to keep {vobj} there. By the' + + ' time {name} finally reached the throne, {vname} was already dead.' + + ' {Subj} tried not to feel left out. The website was restored for' + + ' good.', + '125:SFF': '{name} ran towards {vname}, {attacking} {vobj} with' + + ' {posd} {weapon}. An epic duel commenced, with {vname} throwing' + + ' spells and {name} {attacking} back. Slowly, however, {name} began' + + ' to tire. With a flick of {vposd} wrist, {vname} cast a spell on' + + ' {name}, disintegrating {obj} immediately.', + '125:FSF': '{name} hobbled towards {vname}, {attacking} {vobj} with' + + ' {posd} {weapon}, with {kid} running ahead. With a flick of' + + ' {vposd} wrist, {vname} cast a spell on {kid}, disintegrating him' + + ' immediately. {name} let out a scream and {attacked} {vobj} with' + + ' everything {subj} had left. It was not enough. With another flick' + + ' of {vposd} wrist, {vname} cast the same spell on {name},' + + ' disintegrating {obj} as well.', + '125:FFS': '{name} hobbled towards {vname}, {attacking} {vobj} with' + + ' {posd} {weapon}, leaning on {princess} for support. With a flick' + + ' of {vposd} wrist, {vname} cast a spell at {princess}, who calmly' + + ' sidestepped it. {name}, who had been leaning on her, lost {posd}' + + ' balance and fell. With another flick of {vposd} wrist, {vname}' + + ' cast a spell on {name}, disintegrating {obj} immediately.' + + ' {princess} gave {vname} a long, disappointed look, and left the' + + ' tower.', + /* Family 235 (Breigan, mimic, princess): intact human throughout. + The weaponless group kicking needs its own entry (the 125 version + mentions the weapon); plus the unarmed one-companion losses */ + '235:SFS': '{name}, {kid} and {princess} charged towards {vname}' + + ' together. {vname} began casting a spell, but {princess} reached' + + ' the throne first and pushed {vobj} to the floor. What followed' + + ' was less of a battle and more of a group activity: the three of' + + ' them simply kicked {vname} until {vsubj} stopped moving. {vname}' + + ' was dead, and the website was restored for good.', + '235:SFF': '{name} and {kid} ran towards {vname}, ready to punch' + + ' {vobj}. With a flick of {vposd} wrist, {vname} cast a spell on' + + ' {kid}, disintegrating him immediately. {name} yelled out towards' + + ' him, but there was nothing {subj} could do. With another flick of' + + ' {vposd} wrist, {vname} cast the same spell on {name},' + + ' disintegrating {obj} as well.', + '235:FFS': '{name} ran towards {vname}, ready to punch {vobj}, with' + + ' {princess} running beside {obj}. With a flick of {vposd} wrist,' + + ' {vname} cast a spell at {princess}, who calmly sidestepped it.' + + ' {Vsubj} then cast the same spell at {name}, who tried to sidestep' + + ' it the same way {princess} had. {Subj} did not succeed, and was' + + ' disintegrated immediately. {princess} pinched the bridge of her' + + ' nose, turned around, and left the tower.', + /* Family 245 (Breigan, potion, princess): armed and able-bodied + throughout — the news are the pig situations */ + '245:SFS': '{name}, {kid} and {princess} charged towards {vname}' + + ' together. {vname} took a moment too long deciding whether to aim' + + ' at the boy, the princess, or the pig with {aweapon} in its mouth.' + + ' {princess} used that moment to push {vobj} off the throne, {kid}' + + ' pinned {vposd} arms, and {name} {attacked} {vobj}. {vname} was' + + ' dead, and the website was restored for good.', + '245:SFF': '{kid} ran towards {vname}, with {name} trotting behind,' + + ' {posd} {weapon} clenched in {posd} pig mouth. With a flick of' + + ' {vposd} wrist, {vname} cast a spell on {kid}, disintegrating him' + + ' immediately. {name} let out a devastated squeal and charged. With' + + ' another flick of {vposd} wrist, {vname} cast the same spell on' + + ' {name}, disintegrating {obj} as well.', + '245:FFS': '{name} trotted towards {vname}, {posd} {weapon} clenched' + + ' in {posd} pig mouth, with {princess} walking behind. With a flick' + + ' of {vposd} wrist, {vname} cast a spell at {princess}, who calmly' + + ' sidestepped it. {Vsubj} then cast the same spell at {name}. The' + + ' {weapon} clattered to the floor, its owner now a pile of dust.' + + ' {princess} picked it up, considered it for a moment, then put it' + + ' down again and left the tower.' + }; + /* Family 234 with a human hero (potion succeeded) is the same fight as + the corresponding two-legged 123 ending, reused verbatim */ + ENDINGS['234:SSS'] = ENDINGS['123:SSS']; + ENDINGS['234:SFS'] = ENDINGS['123:SSF']; + ENDINGS['234:FSS'] = ENDINGS['123:SFS']; + ENDINGS['234:FFS'] = ENDINGS['123:SFF']; + /* Family 124 reuses: humans match 123 (same legs/kid/weapon), pigs + with both legs match 234 */ + ENDINGS['124:SSS'] = ENDINGS['123:SSS']; + ENDINGS['124:SSF'] = ENDINGS['234:SSF']; + ENDINGS['124:SFS'] = ENDINGS['123:SFS']; + ENDINGS['124:SFF'] = ENDINGS['234:FSF']; + ENDINGS['124:FSS'] = ENDINGS['123:FSS']; + ENDINGS['124:FFS'] = ENDINGS['123:FFS']; + /* Family 134 reuses, matched on state AND outcome */ + ENDINGS['134:SSS'] = ENDINGS['123:SFS']; + ENDINGS['134:SFF'] = ENDINGS['234:FFF']; + ENDINGS['134:FSF'] = ENDINGS['124:FFF']; + /* Family 345 reuses: no princess collapses to lone-hero situations */ + ENDINGS['345:SSF'] = ENDINGS['123:SFS']; + ENDINGS['345:SFF'] = ENDINGS['234:FSF']; + ENDINGS['345:FSF'] = ENDINGS['123:SFF']; + ENDINGS['345:FFF'] = ENDINGS['234:FFF']; + /* Family 125 reuses (letters: troll, Breigan, princess) */ + ENDINGS['125:SSF'] = ENDINGS['123:SSS']; + ENDINGS['125:SFS'] = ENDINGS['345:SSS']; + ENDINGS['125:FFF'] = ENDINGS['123:FFS']; + /* Family 245 reuses (letters: Breigan, potion, princess) */ + ENDINGS['245:SSS'] = ENDINGS['125:SSS']; + ENDINGS['245:SSF'] = ENDINGS['123:SSS']; + ENDINGS['245:FSS'] = ENDINGS['345:SSS']; + ENDINGS['245:FSF'] = ENDINGS['125:SFF']; + ENDINGS['245:FFF'] = ENDINGS['234:FSF']; + /* Family 235 reuses (letters: Breigan, mimic, princess) */ + ENDINGS['235:SSS'] = ENDINGS['125:SSS']; + ENDINGS['235:SSF'] = ENDINGS['123:SSS']; + ENDINGS['235:FSS'] = ENDINGS['345:SSS']; + ENDINGS['235:FSF'] = ENDINGS['125:SFF']; + ENDINGS['235:FFF'] = ENDINGS['123:SFF']; + /* Family 135 reuses (letters: troll, mimic, princess) */ + ENDINGS['135:SSS'] = ENDINGS['345:SSS']; + ENDINGS['135:SSF'] = ENDINGS['123:SFS']; + ENDINGS['135:SFS'] = ENDINGS['345:FSS']; + ENDINGS['135:SFF'] = ENDINGS['123:SFF']; + ENDINGS['135:FSS'] = ENDINGS['145:FSS']; + ENDINGS['135:FSF'] = ENDINGS['123:FFS']; + ENDINGS['135:FFF'] = ENDINGS['134:FFS']; + /* Family 145 reuses (letters: troll, potion, princess) */ + ENDINGS['145:SSS'] = ENDINGS['345:SSS']; + ENDINGS['145:SSF'] = ENDINGS['123:SFS']; + ENDINGS['145:SFS'] = ENDINGS['345:SFS']; + ENDINGS['145:SFF'] = ENDINGS['234:FSF']; + ENDINGS['145:FSF'] = ENDINGS['123:FFS']; + ENDINGS['145:FFF'] = ENDINGS['124:FFF']; + + /* The encounter pool. ROLLED of these are drawn per hero when the hand + is chosen, stored in order in questEncounters; the ENDINGS set + signature uses 1-based pool positions. Each entry knows how its save + key resolves and what counts as success */ + var POOL = ['troll', 'kid', 'mimic', 'potion', 'princess']; + var ROLLED = 3; + function rollEncounters() { + var pool = POOL.slice(); + var picked = []; + while (picked.length < ROLLED && pool.length) { + picked.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0]); + } + return picked; + } + + /* The hero. The weapon is an icon glyph dropped into the {w} cell of the + art (a fixed 2ch box, so the monospace grid survives). Keys are viewer + sides, not hands: the hero faces the viewer */ + var ART = { + base: ' o\n/|\\\n/ \\', + right: ' o\n/|\\_{w}\n/ \\', + left: ' o\n{w}_/|\\\n / \\', + /* Breigan, age 16, shorter than the hero */ + kid: ' o\n/|\\', + /* Anastasia. The circumflex is a crown */ + princess: ' \u00f4\n/|\\\n/ \\', + /* The hero as a pig (potion theft). Still clutches the weapon */ + pig: { + base: '^..^\n(oo)\n /\\', + right: '^..^\n(oo)_{w}\n /\\', + left: ' ^..^\n{w}_(oo)\n /\\' + } + }; + + /* fa: Font Awesome class; em: Noto Emoji glyph (subsetted into + fonts/noto-emoji.woff2 — resubset if the roster changes); + attacked/attacking: the combat verbs */ + var WEAPONS = { + sword: { em: '\uD83D\uDDE1', rot: true, attacked: 'slashed', attacking: 'slashing' }, + axe: { em: '\uD83E\uDE93', reversed: true, attacked: 'chopped', attacking: 'chopping' }, + wand: { fa: 'fa-wand-sparkles', attacked: 'zapped', attacking: 'zapping' }, + gun: { fa: 'fa-gun', attacked: 'shot', attacking: 'shooting' } + }; + + var PRESETS = { + she: { subj: 'she', obj: 'her', posd: 'her', refl: 'herself' }, + he: { subj: 'he', obj: 'him', posd: 'his', refl: 'himself' }, + they: { subj: 'they', obj: 'them', posd: 'their', refl: 'themself' } + }; + + var story = document.getElementById('story'); + var pronouns = document.getElementById('pronouns'); + var custom = document.getElementById('custom'); + var weapon = document.getElementById('weapon'); + var hand = document.getElementById('hand'); + var vnaming = document.getElementById('vnaming'); + var vpronouns = document.getElementById('vpronouns'); + + function loadKey(key) { + try { return localStorage.getItem(key); } catch (e) { return null; } + } + function saveKey(key, value) { + try { localStorage.setItem(key, value); } catch (e) {} + } + function state() { + var pron = null, enc1 = null, vpron = null, order = null; + try { pron = JSON.parse(loadKey('questPronouns')); } catch (e) {} + try { enc1 = JSON.parse(loadKey('questEnc1')); } catch (e) {} + try { vpron = JSON.parse(loadKey('questVillainPronouns')); } catch (e) {} + try { order = JSON.parse(loadKey('questEncounters')); } catch (e) {} + var weaponKey = loadKey('questWeapon'); + return { + name: loadKey('questHero'), + pron: pron, + weapon: WEAPONS[weaponKey] ? weaponKey : null, + hand: loadKey('questHand'), + /* The rolled encounter ids, in play order */ + order: order && order.length ? order : null, + /* Set by the mimic eating the weapon: bare fists from then on, + {attacked} becomes 'punched' */ + weaponLost: loadKey('questWeaponLost') === '1', + /* { asked: bool, result: 'attack'|'solved'|'wrong'|null, + answer: string (wrong only) } */ + enc1: enc1, + /* 'yes' (Breigan joins) | 'no' | null */ + enc2: loadKey('questEnc2'), + /* The kid's name once the rename question is answered ('Breigan' + if declined); null while unanswered. He/him either way */ + kid: loadKey('questKid'), + /* 'pry' (mimic eats the weapon) | 'leave' | null */ + enc3: loadKey('questEnc3'), + /* 'steal' (hero becomes a pig) | 'explain' | null */ + enc4: loadKey('questEnc4'), + /* 'decline' | 'agree' (mid-choice) | 'vines' | 'door' | null */ + enc5: loadKey('questEnc5'), + /* The princess's name once the rename question is answered + ('Anastasia' if declined); null while unanswered */ + princess: loadKey('questPrincess'), + vname: loadKey('questVillain'), + vpron: vpron, + attacked: loadKey('questAttacked') === '1' + }; + } + + function fill(text, vars) { + return text.replace(/\{([A-Za-z]+)\}/g, function (token, key) { + var value = vars[key.toLowerCase()]; + if (value === undefined) return token; + if (key[0] !== key[0].toLowerCase()) { + value = value.charAt(0).toUpperCase() + value.slice(1); + } + return value; + }); + } + + /* Everything on the page is a function of the save file */ + function render() { + var s = state(); + var vars = { name: s.name, kid: s.kid || 'Breigan', + princess: s.princess || 'Anastasia' }; + if (s.pron) for (var k in s.pron) vars[k] = s.pron[k]; + naming.style.display = s.name ? 'none' : ''; + story.style.display = s.name ? '' : 'none'; + pronouns.style.display = s.name && !s.pron ? '' : 'none'; + weapon.style.display = s.pron && !s.weapon ? '' : 'none'; + hand.style.display = s.weapon && !s.hand ? '' : 'none'; + if (s.name) { + document.getElementById('intro').textContent = fill(TEXT.intro, vars); + } + var setout = document.getElementById('setout'); + setout.style.display = s.pron ? '' : 'none'; + if (s.pron) setout.textContent = fill(TEXT.setout, vars); + var traverse = document.getElementById('traverse'); + var armed = s.weapon && s.hand; + traverse.style.display = armed ? '' : 'none'; + if (armed) { + vars.weapon = s.weapon; + /* {aweapon}: article included — 'a sword' but 'an axe' */ + vars.aweapon = (/^[aeiou]/.test(s.weapon) ? 'an ' : 'a ') + s.weapon; + vars.hand = s.hand; + traverse.textContent = fill(TEXT.traverse, vars); + } + + /* The roll: which encounters this hero gets, in which order. Rolled + lazily so pre-roll saves pick one up on their next render */ + var order = s.order; + if (armed && !order) { + order = rollEncounters(); + saveKey('questEncounters', JSON.stringify(order)); + } + var resolvedEnc = { + troll: !!(s.enc1 && s.enc1.result), + kid: s.enc2 === 'no' || (s.enc2 === 'yes' && !!s.kid), + mimic: !!s.enc3, + potion: !!s.enc4, + princess: s.enc5 === 'decline' || s.enc5 === 'vines' || + (s.enc5 === 'door' && !!s.princess) + }; + /* Reorder the story's encounter groups to match the roll, and find + the single encounter currently awaiting the player */ + var active = null; + var arrival = document.getElementById('arrival'); + if (armed && order) { + for (var oi = 0; oi < order.length; oi++) { + var g = document.getElementById( + 'grp' + order[oi].charAt(0).toUpperCase() + order[oi].slice(1)); + if (g) story.insertBefore(g, arrival); + if (active === null && !resolvedEnc[order[oi]]) active = order[oi]; + } + } + function appeared(id) { + if (!armed || !order || order.indexOf(id) < 0) return false; + return resolvedEnc[id] || id === active; + } + /* Weapon state as of a given encounter: lost only if the mimic ate it + earlier in the order (texts must not change retroactively) */ + function armedAt(id) { + var i = order ? order.indexOf(id) : -1; + for (var j = 0; j < i; j++) { + if (order[j] === 'mimic' && s.enc3 === 'pry') return false; + } + return true; + } + function combatVars(isArmed) { + vars.attacked = isArmed ? WEAPONS[s.weapon].attacked : 'punched'; + vars.withweapon = isArmed ? ' with ' + vars.posd + ' ' + s.weapon : ''; + } + + /* The bridge troll's riddle */ + var showTroll = appeared('troll'); + if (showTroll) combatVars(armedAt('troll')); + var enc1 = document.getElementById('enc1'); + enc1.style.display = showTroll ? '' : 'none'; + if (showTroll) enc1.textContent = fill(TEXT.enc1, vars); + document.getElementById('enc1choice').style.display = + showTroll && !s.enc1 ? '' : 'none'; + var asked = showTroll && s.enc1 && s.enc1.asked; + var enc1riddle = document.getElementById('enc1riddle'); + enc1riddle.style.display = asked ? '' : 'none'; + if (asked) enc1riddle.textContent = fill(TEXT.enc1riddle, vars); + document.getElementById('riddle').style.display = + asked && !s.enc1.result ? '' : 'none'; + var attacked = showTroll && s.enc1 && s.enc1.result === 'attack'; + var enc1attack = document.getElementById('enc1attack'); + enc1attack.style.display = attacked ? '' : 'none'; + if (attacked) enc1attack.textContent = fill(TEXT.enc1attack, vars); + var wrong = showTroll && s.enc1 && s.enc1.result === 'wrong'; + var enc1wrong = document.getElementById('enc1wrong'); + enc1wrong.style.display = wrong ? '' : 'none'; + if (wrong) { + vars.answer = s.enc1.answer; + enc1wrong.textContent = fill(TEXT.enc1wrong, vars); + } + var solved = showTroll && s.enc1 && s.enc1.result === 'solved'; + var enc1correct = document.getElementById('enc1correct'); + enc1correct.style.display = solved ? '' : 'none'; + if (solved) enc1correct.textContent = fill(TEXT.enc1correct, vars); + + /* Breigan */ + var showKid = appeared('kid'); + var enc2 = document.getElementById('enc2'); + enc2.style.display = showKid ? '' : 'none'; + if (showKid) enc2.textContent = fill(TEXT.enc2, vars); + document.getElementById('enc2choice').style.display = + showKid && !s.enc2 ? '' : 'none'; + var enc2yes = document.getElementById('enc2yes'); + var joined = showKid && s.enc2 === 'yes'; + enc2yes.style.display = joined ? '' : 'none'; + if (joined) enc2yes.textContent = fill(TEXT.enc2yes, vars); + var enc2no = document.getElementById('enc2no'); + var declined = showKid && s.enc2 === 'no'; + enc2no.style.display = declined ? '' : 'none'; + if (declined) enc2no.textContent = fill(TEXT.enc2no, vars); + /* The rename question holds up the journey until answered */ + document.getElementById('rename').style.display = + joined && !s.kid ? '' : 'none'; + + /* The mimic chest */ + var showMimic = appeared('mimic'); + var enc3 = document.getElementById('enc3'); + enc3.style.display = showMimic ? '' : 'none'; + if (showMimic) enc3.textContent = fill(TEXT.enc3, vars); + var choosing3 = showMimic && !s.enc3; + document.getElementById('enc3choice').style.display = choosing3 ? '' : 'none'; + if (choosing3) { + document.getElementById('pryBtn').textContent = fill(TEXT.enc3pryBtn, vars); + } + var pried = showMimic && s.enc3 === 'pry'; + var enc3pry = document.getElementById('enc3pry'); + enc3pry.style.display = pried ? '' : 'none'; + if (pried) enc3pry.textContent = fill(TEXT.enc3pry, vars); + var left = showMimic && s.enc3 === 'leave'; + var enc3leave = document.getElementById('enc3leave'); + enc3leave.style.display = left ? '' : 'none'; + if (left) enc3leave.textContent = fill(TEXT.enc3leave, vars); + + /* The potion seller */ + var showPotion = appeared('potion'); + var enc4 = document.getElementById('enc4'); + enc4.style.display = showPotion ? '' : 'none'; + if (showPotion) enc4.textContent = fill(TEXT.enc4, vars); + document.getElementById('enc4choice').style.display = + showPotion && !s.enc4 ? '' : 'none'; + var stole = showPotion && s.enc4 === 'steal'; + var enc4steal = document.getElementById('enc4steal'); + enc4steal.style.display = stole ? '' : 'none'; + if (stole) { + combatVars(armedAt('potion')); + enc4steal.textContent = fill(TEXT.enc4steal, vars); + } + var explained = showPotion && s.enc4 === 'explain'; + var enc4explain = document.getElementById('enc4explain'); + enc4explain.style.display = explained ? '' : 'none'; + if (explained) enc4explain.textContent = fill(TEXT.enc4explain, vars); + + /* Breigan's rename line (only when he was actually renamed) */ + var kidrename = document.getElementById('kidrename'); + var kidRenamed = joined && s.kid && s.kid !== 'Breigan'; + kidrename.style.display = kidRenamed ? '' : 'none'; + if (kidRenamed) kidrename.textContent = fill(TEXT.kidrename, vars); + + /* The probable princess */ + var showPrincess = appeared('princess'); + var enc5 = document.getElementById('enc5'); + enc5.style.display = showPrincess ? '' : 'none'; + if (showPrincess) enc5.textContent = fill(TEXT.enc5, vars); + document.getElementById('enc5choice').style.display = + showPrincess && !s.enc5 ? '' : 'none'; + var declined5 = showPrincess && s.enc5 === 'decline'; + var enc5decline = document.getElementById('enc5decline'); + enc5decline.style.display = declined5 ? '' : 'none'; + if (declined5) enc5decline.textContent = fill(TEXT.enc5decline, vars); + var agreed = showPrincess && (s.enc5 === 'agree' || + s.enc5 === 'vines' || s.enc5 === 'door'); + var enc5agree = document.getElementById('enc5agree'); + enc5agree.style.display = agreed ? '' : 'none'; + if (agreed) enc5agree.textContent = fill(TEXT.enc5agree, vars); + document.getElementById('enc5method').style.display = + showPrincess && s.enc5 === 'agree' ? '' : 'none'; + var fell = showPrincess && s.enc5 === 'vines'; + var enc5vines = document.getElementById('enc5vines'); + enc5vines.style.display = fell ? '' : 'none'; + if (fell) enc5vines.textContent = fill(TEXT.enc5vines, vars); + var rescued = showPrincess && s.enc5 === 'door'; + var enc5door = document.getElementById('enc5door'); + enc5door.style.display = rescued ? '' : 'none'; + if (rescued) enc5door.textContent = fill(TEXT.enc5door, vars); + document.getElementById('prename').style.display = + rescued && !s.princess ? '' : 'none'; + var princessrename = document.getElementById('princessrename'); + var pRenamed = rescued && s.princess && s.princess !== 'Anastasia'; + princessrename.style.display = pRenamed ? '' : 'none'; + if (pRenamed) princessrename.textContent = fill(TEXT.princessrename, vars); + + /* The wizard's tower: arrival, villain creation, then the ending */ + var encountersDone = armed && order && active === null; + arrival.style.display = encountersDone ? '' : 'none'; + if (encountersDone) arrival.textContent = fill(TEXT.arrival, vars); + vnaming.style.display = encountersDone && !s.vname ? '' : 'none'; + vpronouns.style.display = + encountersDone && s.vname && !s.vpron ? '' : 'none'; + var showdown = encountersDone && s.vname && s.vpron; + var confront = document.getElementById('confront'); + confront.style.display = showdown ? '' : 'none'; + if (showdown) { + vars.vname = s.vname; + for (var vk in s.vpron) vars['v' + vk] = s.vpron[vk]; + /* Showdown-time forms: unlike the troll fight, the ending happens + after any weapon loss, so here punched/bare-handed applies */ + vars.attacked = s.weaponLost ? 'punched' : WEAPONS[s.weapon].attacked; + vars.attacking = s.weaponLost ? 'punching' : WEAPONS[s.weapon].attacking; + vars.withweapon = s.weaponLost ? '' : ' with ' + vars.posd + ' ' + s.weapon; + confront.textContent = fill(TEXT.confront, vars); + } + document.getElementById('showdownAttack').style.display = + showdown && !s.attacked ? '' : 'none'; + var ending = document.getElementById('ending'); + var endingText = ''; + var won = false; + if (showdown && s.attacked) { + /* The situation key: chosen encounters in pool order (1-based + positions), then their outcomes in the same order */ + var successOf = { + troll: s.enc1 && s.enc1.result === 'solved', + kid: s.enc2 === 'yes', + mimic: s.enc3 === 'leave', + potion: s.enc4 === 'explain', + princess: s.enc5 === 'door' + }; + var chosen = order.slice().sort(function (a, b) { + return POOL.indexOf(a) - POOL.indexOf(b); + }); + var sig = '', letters = '', successes = 0; + chosen.forEach(function (id) { + sig += POOL.indexOf(id) + 1; + letters += successOf[id] ? 'S' : 'F'; + successes += successOf[id] ? 1 : 0; + }); + won = successes >= 2; + endingText = ENDINGS[sig + ':' + letters] || ''; + } + ending.style.display = endingText ? '' : 'none'; + if (endingText) ending.textContent = fill(endingText, vars); + document.getElementById('endWin').style.display = + showdown && s.attacked && won ? '' : 'none'; + /* Restart is offered after every ending — losses need it, wins may + want another go */ + document.getElementById('endRestart').style.display = + showdown && s.attacked ? '' : 'none'; + + var figure = document.getElementById('figure'); + /* The hero faces the viewer: their left hand is on the viewer's + right, so the art side is the opposite of the handedness */ + var side = s.hand === 'left' ? 'right' : 'left'; + /* The potion theft turned the hero into a pig (weapon still in trotter) */ + var isPig = s.enc4 === 'steal'; + var artSet = isPig ? ART.pig : ART; + var hero; + if (armed && !s.weaponLost) { + var w = WEAPONS[s.weapon]; + /* Mirror so the weapon faces away from the body; 'reversed' marks + glyphs whose natural facing is opposite the FA convention */ + var mirrored = side === 'left'; + if (w.reversed) mirrored = !mirrored; + var flip = mirrored ? ' wpn-flip' : ''; + var icon = w.fa + ? '' + : '' + + w.em + ''; + icon = '' + icon + ''; + hero = artSet[side].replace('{w}', icon); + } else { + hero = artSet.base; + } + /* The troll took a leg (in combat or as a quiz penalty) */ + if (armed && (attacked || wrong)) { + hero = hero.replace(isPig ? '/\\' : '/ \\', '/'); + } + /* Companions line up on the hero's free side, away from the weapon — + and stay there even if the weapon is later lost */ + var figs = ['' + hero + '']; + function addCompanion(art) { + var span = '' + art + ''; + if (side === 'right') figs.unshift(span); + else figs.push(span); + } + if (joined) addCompanion(ART.kid); + if (rescued) addCompanion(ART.princess); + figure.innerHTML = figs.join(''); + } + + naming.addEventListener('submit', function (e) { + e.preventDefault(); + var name = document.getElementById('heroName').value.trim(); + if (!name) return; + saveKey('questHero', name); + render(); + }); + + /* Hero and villain share the pronoun-picker wiring; ids differ by prefix */ + function wirePronouns(form, customTable, customBtn, prefix, storageKey) { + form.addEventListener('click', function (e) { + var preset = e.target.getAttribute && e.target.getAttribute('data-preset'); + if (!preset) return; + saveKey(storageKey, JSON.stringify(PRESETS[preset])); + render(); + }); + customBtn.addEventListener('click', function () { + customTable.style.display = customTable.style.display ? '' : 'none'; + }); + form.addEventListener('submit', function (e) { + e.preventDefault(); + var pron = { + subj: document.getElementById(prefix + 'Subj').value.trim(), + obj: document.getElementById(prefix + 'Obj').value.trim(), + posd: document.getElementById(prefix + 'Posd').value.trim(), + refl: document.getElementById(prefix + 'Refl').value.trim() + }; + for (var k in pron) if (!pron[k]) return; + saveKey(storageKey, JSON.stringify(pron)); + render(); + }); + } + wirePronouns(pronouns, custom, + document.getElementById('customBtn'), 'pn', 'questPronouns'); + wirePronouns(vpronouns, document.getElementById('vcustom'), + document.getElementById('vcustomBtn'), 'vpn', 'questVillainPronouns'); + + vnaming.addEventListener('submit', function (e) { + e.preventDefault(); + var name = document.getElementById('villainName').value.trim(); + if (!name) return; + saveKey('questVillain', name); + render(); + }); + + document.getElementById('showdownAttack').addEventListener('click', function (e) { + if (e.target.tagName !== 'BUTTON') return; + saveKey('questAttacked', '1'); + render(); + }); + + /* A restart or a completed quest wipes the whole save file, hero + included — a fresh run starts at "Name your hero" */ + function clearRun() { + try { + ['questHero', 'questPronouns', 'questWeapon', 'questHand', 'questEncounters', + 'questEnc1', 'questEnc2', 'questKid', 'questEnc3', 'questEnc4', 'questEnc5', 'questPrincess', + 'questWeaponLost', + 'questVillain', 'questVillainPronouns', 'questAttacked' + ].forEach(function (k) { localStorage.removeItem(k); }); + } catch (err) {} + } + + /* Winning genuinely restores gade.gg: same keys the 404's restore + hatch clears, then home — where the guard no longer redirects */ + document.getElementById('endWin').addEventListener('click', function (e) { + if (e.target.tagName !== 'BUTTON') return; + /* Permanent — survives the save wipe and the restore. The + achievements system on the main page reads it, so the Adventurer + toast greets the return home */ + saveKey('questWon', '1'); + clearRun(); + try { + localStorage.removeItem('siteDeleted'); + localStorage.removeItem('siteDeletedViews'); + localStorage.removeItem('questStarted'); + } catch (err) {} + location.href = '/'; + }); + + document.getElementById('endRestart').addEventListener('click', function (e) { + if (e.target.tagName !== 'BUTTON') return; + clearRun(); + render(); + }); + + weapon.addEventListener('click', function (e) { + var chosen = e.target.getAttribute && e.target.getAttribute('data-weapon'); + if (!chosen) return; + saveKey('questWeapon', chosen); + render(); + }); + + hand.addEventListener('click', function (e) { + var chosen = e.target.getAttribute && e.target.getAttribute('data-hand'); + if (!chosen) return; + saveKey('questHand', chosen); + render(); + }); + + document.getElementById('enc1choice').addEventListener('click', function (e) { + var act = e.target.getAttribute && e.target.getAttribute('data-act'); + if (!act) return; + saveKey('questEnc1', JSON.stringify(act === 'attack' + ? { asked: false, result: 'attack' } + : { asked: true, result: null })); + render(); + }); + + document.getElementById('riddle').addEventListener('submit', function (e) { + e.preventDefault(); + var raw = document.getElementById('riddleAnswer').value.trim(); + if (!raw) return; + var answer = raw.toLowerCase(); + var enc1 = answer === 'a river' || answer === 'river' || answer === 'water' + ? { asked: true, result: 'solved' } + : { asked: true, result: 'wrong', answer: raw }; + saveKey('questEnc1', JSON.stringify(enc1)); + render(); + }); + + document.getElementById('riddleAttack').addEventListener('click', function () { + saveKey('questEnc1', JSON.stringify({ asked: true, result: 'attack' })); + render(); + }); + + document.getElementById('enc2choice').addEventListener('click', function (e) { + var ans = e.target.getAttribute && e.target.getAttribute('data-ans'); + if (!ans) return; + saveKey('questEnc2', ans); + render(); + }); + + /* Breigan and Anastasia share the rename wiring */ + function wireRename(divId, formId, inputId, storageKey, defaultName) { + var form = document.getElementById(formId); + document.getElementById(divId).addEventListener('click', function (e) { + var ans = e.target.getAttribute && e.target.getAttribute('data-rename'); + if (!ans) return; + if (ans === 'no') { + saveKey(storageKey, defaultName); + render(); + } else { + form.style.display = ''; + } + }); + form.addEventListener('submit', function (e) { + e.preventDefault(); + var name = document.getElementById(inputId).value.trim(); + if (!name) return; + saveKey(storageKey, name); + render(); + }); + } + wireRename('rename', 'renameForm', 'kidName', 'questKid', 'Breigan'); + wireRename('prename', 'prenameForm', 'princessName', + 'questPrincess', 'Anastasia'); + + document.getElementById('enc3choice').addEventListener('click', function (e) { + var act = e.target.getAttribute && e.target.getAttribute('data-act'); + if (!act) return; + saveKey('questEnc3', act); + if (act === 'pry') saveKey('questWeaponLost', '1'); + render(); + }); + + document.getElementById('enc4choice').addEventListener('click', function (e) { + var act = e.target.getAttribute && e.target.getAttribute('data-act'); + if (!act) return; + saveKey('questEnc4', act); + render(); + }); + + document.getElementById('enc5choice').addEventListener('click', function (e) { + var act = e.target.getAttribute && e.target.getAttribute('data-act'); + if (!act) return; + saveKey('questEnc5', act); + render(); + }); + + document.getElementById('enc5method').addEventListener('click', function (e) { + var act = e.target.getAttribute && e.target.getAttribute('data-act'); + if (!act) return; + saveKey('questEnc5', act); + render(); + }); + + render(); +})(); diff --git a/js/scroll-hint.js b/js/scroll-hint.js new file mode 100644 index 0000000..5bfbc78 --- /dev/null +++ b/js/scroll-hint.js @@ -0,0 +1,15 @@ +// ── Scroll hint: appears after idling at the top of the page ── +(function () { + const hint = document.getElementById('scrollHint'); + if (!hint) return; + + const timer = setTimeout(() => { + if (window.scrollY <= 24) hint.classList.add('visible'); + }, 3000); + + // Any scroll dismisses it for the rest of the visit + window.addEventListener('scroll', () => { + clearTimeout(timer); + hint.classList.remove('visible'); + }, { passive: true, once: true }); +})(); diff --git a/js/slot-machine.js b/js/slot-machine.js new file mode 100644 index 0000000..239d4a8 --- /dev/null +++ b/js/slot-machine.js @@ -0,0 +1,85 @@ +// ── Job title slot machine: click the role pill ─────────────── +(function () { + const pill = document.getElementById('heroRole'); + if (!pill) return; + + const WORDS = [ + ['Software', 'Backend', 'Data', 'Computer', 'Frontend', 'Full-Stack', 'Tech', 'Server', 'It Project', 'Cloud'], + ['Developer', 'Scientist', 'Engineer', 'Enthusiast', 'Wizard', 'Programmer', 'Manager'], + ]; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + const slots = [...pill.querySelectorAll('.slot')]; + + // Width of a word in this mono font: 1ch + letter-spacing per character. + // Slots hug their current word at rest and expand to their widest word + // while spinning (the CSS width transition animates both moves). + const widthFor = (word) => `calc(${word.length}ch + ${word.length * 0.08}em)`; + slots.forEach((slot) => { + slot.style.width = widthFor(slot.textContent.trim()); + }); + + let busy = false; + pill.addEventListener('click', () => { + if (window.achv) window.achv.mark('slot'); + if (busy) return; + busy = true; + + const spins = slots.map((slot, i) => { + const list = WORDS[i]; + const reel = slot.querySelector('.reel'); + const from = Math.max(list.indexOf(reel.textContent.trim()), 0); + // Random pick that's never the word already showing + let target = Math.floor(Math.random() * (list.length - 1)); + if (target >= from) target++; + + if (reduceMotion.matches) { + reel.innerHTML = `${list[target]}`; + slot.style.width = widthFor(list[target]); + return Promise.resolve(); + } + + // Expand to fit the reel's widest word for the duration of the spin + const longest = list.reduce((a, b) => (b.length > a.length ? b : a)); + slot.style.width = widthFor(longest); + + // Walk the reel in list order: a couple of full revolutions, then + // land on the target (the second reel spins one revolution longer) + const steps = ((target - from + list.length) % list.length) + list.length * (2 + i); + + // Build the strip top-down ending at the current word, so the column + // slides downward and words enter from above. One extra word sits + // above the target to back the overshoot. + reel.innerHTML = ''; + for (let k = 0; k <= steps + 1; k++) { + const w = document.createElement('span'); + w.textContent = list[(from + steps + 1 - k) % list.length]; + reel.appendChild(w); + } + + const h = reel.firstChild.getBoundingClientRect().height; + const land = -h; // row where the target word sits + const over = land + h * 0.35; // spin runs slightly past the stop + const anim = reel.animate( + [ + { transform: `translateY(${-(steps + 1) * h}px)` }, + { transform: `translateY(${over}px)` }, + ], + { duration: 900 + i * 500, easing: 'cubic-bezier(0.15, 0.85, 0.25, 1)', fill: 'forwards' } + ); + return anim.finished.then(() => { + const settle = reel.animate( + [{ transform: `translateY(${over}px)` }, { transform: `translateY(${land}px)` }], + { duration: 200, easing: 'ease', fill: 'forwards' } + ); + return settle.finished.then(() => { + reel.innerHTML = `${list[target]}`; + settle.cancel(); + anim.cancel(); // drop the forward-filled transforms after the swap + slot.style.width = widthFor(list[target]); // shrink back to fit + }); + }); + }); + + Promise.all(spins).finally(() => { busy = false; }); + }); +})(); diff --git a/js/sprinkles.js b/js/sprinkles.js new file mode 100644 index 0000000..509d4d4 --- /dev/null +++ b/js/sprinkles.js @@ -0,0 +1,126 @@ +// ── Sprinkle background ──────────────────────────── +(function () { + const canvas = document.getElementById('bgCanvas'); + const ctx = canvas.getContext('2d'); + + const COLORS = ['#ffd84d', '#8cbcff', '#ff9fca', '#a9e76c', '#b99cff', '#ff9d57']; + const INK = '#171717'; + const COUNT = 1000; // number of sprinkles + const MIN_LEN = 12; // px + const MAX_LEN = 18; // px + const MIN_W = 4; + const MAX_W = 6; + + let W, H, sprinkles; + + function rand(a, b) { return a + Math.random() * (b - a); } + + function resize() { + W = canvas.width = window.innerWidth; + H = canvas.height = window.innerHeight; + if (!sprinkles) init(); + // Redistribute any sprinkles that landed outside new bounds + sprinkles.forEach(s => { + if (s.x > W) s.x = rand(0, W); + if (s.y > H) s.y = rand(0, H); + }); + // Resizing clears the canvas; with no animation loop running, repaint it + if (reduceMotion.matches) { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(draw); + } + } + + function init() { + sprinkles = []; + for (let i = 0; i < COUNT; i++) { + sprinkles.push(makeSprinkle()); + } + } + + function makeSprinkle() { + return { + x: rand(0, W), + y: rand(0, H), + len: rand(MIN_LEN, MAX_LEN), + width: rand(MIN_W, MAX_W), + color: COLORS[Math.floor(Math.random() * COLORS.length)], + angle: rand(0, Math.PI * 2), + // very slow drift + vx: rand(-0.1, 0.1), + vy: rand(-0.1, 0.1), + va: rand(-0.005, 0.005), // angular velocity + }; + } + + function draw() { + ctx.clearRect(0, 0, W, H); + ctx.lineCap = 'round'; + + sprinkles.forEach(s => { + // Endpoints computed directly; no canvas transform per sprinkle + const ex = Math.sin(s.angle) * s.len / 2; + const ey = -Math.cos(s.angle) * s.len / 2; + ctx.beginPath(); + ctx.moveTo(s.x + ex, s.y + ey); + ctx.lineTo(s.x - ex, s.y - ey); + // Ink outline under the colored fill (sticker look): same path, two strokes + ctx.strokeStyle = INK; + ctx.lineWidth = s.width + 3; + ctx.stroke(); + ctx.strokeStyle = s.color; + ctx.lineWidth = s.width; + ctx.stroke(); + + // Reduced motion: sprinkles stay where they are + if (reduceMotion.matches) return; + + // Drift + s.x += s.vx; + s.y += s.vy; + if (s.vx > 0.1 || s.vx < -0.1) { + s.vx *= 0.98; + } + if (s.vy > 0.1 || s.vy < -0.1) { + s.vy *= 0.98; + } + s.angle += s.va; + + // Repel from cursor + const dx = s.x - mouseX; + const dy = s.y - mouseY; + const dist = Math.sqrt(dx * dx + dy * dy); + const repelRadius = 80; + if (dist < repelRadius && dist > 0) { + const force = (repelRadius - dist) / repelRadius; + s.vx += (dx / dist) * force * 1.0; + s.vy += (dy / dist) * force * 1.0; + } + + // Wrap: when a sprinkle drifts off the screen, reappear on the other side + if (s.y < -MAX_LEN) s.y = H + MAX_LEN; + if (s.y > H + MAX_LEN) s.y = -MAX_LEN + if (s.x < -MAX_LEN) s.x = W + MAX_LEN; + if (s.x > W + MAX_LEN) s.x = -MAX_LEN; + }); + + if (!reduceMotion.matches) rafId = requestAnimationFrame(draw); + } + + // With reduced motion the sprinkles render once as a static backdrop + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + let rafId; + reduceMotion.addEventListener('change', () => { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(draw); + }); + + window.addEventListener('resize', resize, { passive: true }); + resize(); + let mouseX = -9999, mouseY = -9999; + window.addEventListener('mousemove', (e) => { + mouseX = e.clientX; + mouseY = e.clientY; + }, { passive: true }); + rafId = requestAnimationFrame(draw); +})(); diff --git a/js/terminal.js b/js/terminal.js new file mode 100644 index 0000000..c2b9b75 --- /dev/null +++ b/js/terminal.js @@ -0,0 +1,734 @@ +// ── Fake terminal: click the Environment card's icon ────────── +(function () { + const glyph = document.querySelector('.skill-card-icon .fa-terminal'); + if (!glyph) return; + const icon = glyph.closest('.skill-card-icon'); + + const HOST = 'gade.gg'; + const KERNEL = '6.6-goat'; + const T0 = performance.now(); + + // Current user: su can change it (root's password leaked in auth.log), + // exit pops back. Root passes every permission check and may write. + const USERS = { + root: ['root'], + nikolaj: ['home', 'nikolaj'], + guest: ['home', 'guest'], + pepsi: ['home', 'pepsi'], + }; + const PASSWORDS = { root: 'Pepsi2022' }; + let user = 'guest'; + let home = USERS.guest; + const userStack = []; + let pendingSu = null; // user being su'd to while the password prompt is up + + // Filesystem: dirs are { children }, files are strings — or functions + // returning a string, for live content (.bash_history). locked dirs + // deny everyone but root and their owner. Only root may write. + const dir = (children) => ({ children }); + const locked = (owner, children) => ({ children, locked: owner }); + const DENIED = { denied: true }; // a path tried to pass through a locked dir + const isFile = (n) => typeof n === 'string' || typeof n === 'function'; + const fileBody = (n) => (typeof n === 'function' ? n() : n); + const allowed = (n) => !n.locked || user === 'root' || user === n.locked; + const GOAT = ` %%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%% + %%%% %%%%%%%%%%% + %%% %%%%%%%%%%%%%%% + %% %%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%% +%%%%%%%%%%%%%%% +`; + const BIN = '\u007fELF��������'; + const FS = dir({ + bin: dir({ cat: BIN, echo: BIN, gsh: BIN, ls: BIN, nano: BIN, rm: BIN, sh: BIN }), + etc: dir({ + hostname: 'gade.gg\n', + hosts: + '127.0.0.1 localhost\n' + + '10.0.0.1 bulbasaur\n' + + '10.0.0.6 charizard\n' + + '10.0.0.29 nidoran-f\n' + + '10.0.0.32 nidoran-m\n' + + '10.0.0.58 growlithe\n' + + '10.0.0.63 abra\n' + + '10.0.0.64 kadabra\n' + + '10.0.0.133 eevee\n' + + '10.0.0.150 mewtwo\n' + + '10.0.0.151 mew\n', + motd: "Welcome to gade.gg.\nType 'help' for commands.\n", + passwd: + 'root:x:0:0:root:/root:/bin/sh\n' + + 'nikolaj:x:1000:1000:Nikolaj Gade:/home/nikolaj:/bin/fish\n' + + 'guest:x:1001:1001:guest:/home/guest:/bin/gsh\n' + + 'pepsi:x:1002:1002:Pepsi Max Lime Gade:/home/pepsi:/usr/bin/meow\n', + }), + home: dir({ + guest: dir({ + 'readme.txt': 'Nothing here yet. Have a look around.\n', + '.bash_history': () => (history.length ? history.join('\n') + '\n' : ''), + '.bashrc': "# ~/.bashrc\nPS1='\\u@\\h:\\w\\$ '\nalias sl=ls\n", + }), + nikolaj: locked('nikolaj', { + 'array-props.pdf': '%PDF-1.7\n%����\n', + }), + pepsi: dir({ + '.plan': + 'nap.\n' + + 'nap.\n' + + 'knock something off the table.\n' + + 'nap.\n', + }), + }), + proc: dir({ + uptime: () => { + const s = (performance.now() - T0) / 1000; + return s.toFixed(2) + ' ' + (s * 4).toFixed(2) + '\n'; + }, + version: + 'Linux version ' + KERNEL + ' (rats@gade.gg) (gcc (GCC) 14.2.1) #1 SMP PREEMPT\n', + }), + root: locked('root', { + goat: GOAT, + '.bash_history': 'rm -rf /\n', + }), + usr: dir({ + bin: dir({ meow: BIN }), + share: dir({ + centvrion: dir({ 'salve.cent': '// Hello, world\nDIC("SALVE MVNDE")\n' }), + }), + }), + var: dir({ + log: dir({ + 'auth.log': + 'Aug 9 03:12:44 gade sshd[812]: Failed password for root from 10.0.0.150 port 51434 ssh2\n' + + 'Aug 9 03:12:51 gade sshd[815]: Failed password for invalid user Pepsi2022 from 10.0.0.150 port 51438 ssh2\n' + + 'Aug 9 03:13:02 gade sshd[819]: Accepted password for root from 10.0.0.150 port 51442 ssh2\n', + }), + }), + }); + + let cwd = [...USERS.guest]; + + function resolvePath(path) { + if (path === '~') path = '/' + home.join('/'); + else if (path.startsWith('~/')) path = '/' + home.join('/') + path.slice(1); + const segs = path.startsWith('/') ? [] : [...cwd]; + for (const part of path.split('/')) { + if (part === '' || part === '.') continue; + if (part === '..') segs.pop(); + else segs.push(part); + } + return segs; + } + + function nodeAt(segs) { + let node = FS; + for (const s of segs) { + if (!allowed(node)) return DENIED; + if (!node.children || !Object.prototype.hasOwnProperty.call(node.children, s)) return null; + node = node.children[s]; + } + return node; + } + + function displayPath(segs) { + const p = '/' + segs.join('/'); + const h = '/' + home.join('/'); + if (p === h) return '~'; + if (p.startsWith(h + '/')) return '~' + p.slice(h.length); + return p; + } + + const promptText = () => + user + '@' + HOST + ':' + displayPath(cwd) + (user === 'root' ? '#' : '$'); + + let dialog = null; + let screen = null; + let out = null; + let input = null; + let promptEl = null; + let termTitle = null; + + function becomeUser(u) { + user = u; + home = USERS[u]; + // The quiet flag a future achievements system reads + if (u === 'root') { + try { localStorage.setItem('termRoot', '1'); } catch (e) {} + if (window.achv) window.achv.unlock('hacker'); + } + promptEl.textContent = promptText(); + termTitle.textContent = u + '@' + HOST; + } + + // Real history: ↑ recalls it, ~/.bash_history reads it, localStorage + // keeps it. Seeded so a first visit finds a previous guest's session. + const HIST_SEED = ['ls', 'cd /', 'ls', 'cat etc/motd', 'cd']; + const HIST_MAX = 200; + let history = HIST_SEED; + try { history = JSON.parse(localStorage.getItem('termHistory')) || HIST_SEED; } catch (e) {} + if (!Array.isArray(history)) history = HIST_SEED; + history = history.slice(-HIST_MAX).map(String); + let hIdx = history.length; + let draft = ''; + function saveHistory() { + try { localStorage.setItem('termHistory', JSON.stringify(history)); } catch (e) {} + } + + // parts: [{ text, cls? }] — one scrollback line + function line(parts) { + const el = document.createElement('div'); + el.className = 'term-line'; + for (const part of parts) { + if (part.cls) { + const span = document.createElement('span'); + span.className = part.cls; + span.textContent = part.text; + el.appendChild(span); + } else { + el.appendChild(document.createTextNode(part.text)); + } + } + out.appendChild(el); + } + const print = (text) => line([{ text }]); + + // hidden commands work but stay out of help and tab completion — + // finding them is the point + const ALIASES = { sl: 'ls' }; // honoring ~/.bashrc + const COMMANDS = { + cat: { usage: 'cat ', desc: 'print a file', run(args) { + for (const t of args) { + const node = nodeAt(resolvePath(t)); + if (node === null) print('cat: ' + t + ': No such file or directory'); + else if (node === DENIED) print('cat: ' + t + ': Permission denied'); + else if (isFile(node)) { + const body = fileBody(node).replace(/\n$/, ''); + if (body) print(body); + } else print('cat: ' + t + ': Is a directory'); + } + } }, + cd: { usage: 'cd [dir]', desc: 'change directory', run(args) { + const target = args[0] || '~'; + const segs = resolvePath(target); + const node = nodeAt(segs); + if (node === null) print('cd: no such file or directory: ' + target); + else if (node === DENIED || !allowed(node)) print('cd: permission denied: ' + target); + else if (isFile(node)) print('cd: not a directory: ' + target); + else { + cwd = segs; + promptEl.textContent = promptText(); + } + } }, + clear: { usage: 'clear', desc: 'clear the screen', run() { out.textContent = ''; } }, + echo: { usage: 'echo [text]', desc: 'print text', run(args) { print(args.join(' ')); } }, + exit: { usage: 'exit', desc: 'close the terminal', run() { + if (userStack.length) becomeUser(userStack.pop()); + else dialog.close(); + } }, + help: { usage: 'help', desc: 'list commands', run() { + const names = Object.keys(COMMANDS).filter((n) => !COMMANDS[n].hidden).sort(); + const pad = Math.max(...names.map((n) => COMMANDS[n].usage.length)) + 2; + names.forEach((n) => print(' ' + COMMANDS[n].usage.padEnd(pad) + COMMANDS[n].desc)); + } }, + history: { hidden: true, usage: 'history', desc: '', run() { + history.forEach((h, i) => print(String(i + 1).padStart(5) + ' ' + h)); + } }, + ls: { usage: 'ls [-a] [dir]', desc: 'list directory contents', run(args) { + const all = args.some((a) => /^-[a-zA-Z]*a/.test(a)); + const paths = args.filter((a) => !a.startsWith('-')); + const target = paths[0] || '.'; + const node = nodeAt(resolvePath(target)); + if (node === null) { print("ls: cannot access '" + target + "': No such file or directory"); return; } + if (node === DENIED || !allowed(node)) { print("ls: cannot open directory '" + target + "': Permission denied"); return; } + if (isFile(node)) { print(target); return; } + let names = Object.keys(node.children).sort(); + names = all ? ['.', '..', ...names] : names.filter((n) => !n.startsWith('.')); + if (!names.length) return; + const parts = []; + names.forEach((n, i) => { + if (i) parts.push({ text: ' ' }); + const child = n === '.' || n === '..' ? FS : node.children[n]; + parts.push(isFile(child) ? { text: n } : { text: n, cls: 'term-dir' }); + }); + line(parts); + } }, + meow: { hidden: true, usage: 'meow', desc: '', run() { print('meow'); } }, + nano: { usage: 'nano [file]', desc: 'edit a file', run(args) { + const target = args[0]; + let name = 'New Buffer'; + let content = ''; + let segs = null; + if (target) { + segs = resolvePath(target); + const node = nodeAt(segs); + if (node === DENIED) { print('nano: ' + target + ': Permission denied'); return; } + if (node !== null && !isFile(node)) { print('nano: ' + target + ': Is a directory'); return; } + if (node !== null) content = fileBody(node); + name = segs[segs.length - 1] || target; + } + openNano(name, content, segs); + } }, + pwd: { usage: 'pwd', desc: 'print working directory', run() { print('/' + cwd.join('/')); } }, + rm: { usage: 'rm [-rf] ', desc: 'remove files', run(args) { + const recursive = args.some((a) => /^-[a-zA-Z]*r/i.test(a) || a === '--recursive'); + const targets = args.filter((a) => !a.startsWith('-')); + if (!targets.length) { print('rm: missing operand'); return; } + for (const t of targets) { + const segs = resolvePath(t); + // The one write the filesystem allows: rm -rf / is the real + // delete-website flow, confirmation dialog and all + if (recursive && segs.length === 0) { + const dlg = document.getElementById('deleteDialog'); + if (dlg) { + dlg.showModal(); + // Chromium: once a nested modal has closed, the input under it + // still gets key events but no longer inserts text — only a + // fresh input element types again + dlg.addEventListener('close', () => { + const fresh = input.cloneNode(); + input.replaceWith(fresh); + input = fresh; + wireInput(fresh); + fresh.focus(); + }, { once: true }); + return; + } + } + const node = nodeAt(segs); + if (node === null) print("rm: cannot remove '" + t + "': No such file or directory"); + else if (node === DENIED) print("rm: cannot remove '" + t + "': Permission denied"); + else if (!isFile(node) && !recursive) print("rm: cannot remove '" + t + "': Is a directory"); + else if (user !== 'root') print("rm: cannot remove '" + t + "': Permission denied"); + else { + const parent = nodeAt(segs.slice(0, -1)); + delete parent.children[segs[segs.length - 1]]; + } + } + } }, + roll: { usage: 'roll [NdS+M]', desc: 'roll dice', run(args) { + const spec = (args[0] || 'd20').toLowerCase(); + const m = /^(\d*)d(\d+)([+-]\d+)?$/.exec(spec); + if (!m) { print('roll: usage: roll [NdS+M], e.g. roll 2d6+1'); return; } + const n = Math.min(parseInt(m[1] || '1', 10) || 1, 100); + const sides = parseInt(m[2], 10); + const mod = parseInt(m[3] || '0', 10); + const rolls = []; + for (let i = 0; i < n; i++) rolls.push(1 + Math.floor(Math.random() * sides)); + const total = rolls.reduce((a, r) => a + r, 0) + mod; + if (n === 1 && !mod) { print(spec + ': ' + total); return; } + let breakdown = rolls.join(' + '); + if (mod) breakdown += mod > 0 ? ' + ' + mod : ' - ' + -mod; + print(spec + ': ' + breakdown + ' = ' + total); + } }, + su: { hidden: true, usage: 'su [user]', desc: '', run(args) { + const target = args.filter((a) => !a.startsWith('-'))[0] || 'root'; + if (!(target in USERS)) { print('su: user ' + target + ' does not exist'); return; } + if (user === 'root') { + userStack.push(user); + becomeUser(target); + return; + } + pendingSu = target; + promptEl.textContent = 'Password:'; + input.type = 'password'; + } }, + sudo: { hidden: true, usage: 'sudo', desc: '', run(args) { + if (user !== 'root') { + print(user + ' is not in the sudoers file. This incident will be reported.'); + return; + } + if (!args.length) return; + const cmd = COMMANDS[ALIASES[args[0]] || args[0]]; + if (cmd) cmd.run(args.slice(1)); + else print('sudo: ' + args[0] + ': command not found'); + } }, + uname: { hidden: true, usage: 'uname', desc: '', run(args) { + if (args.includes('-a')) print('Linux ' + HOST + ' ' + KERNEL + ' #1 SMP PREEMPT x86_64 GNU/Linux'); + else if (args.includes('-r')) print(KERNEL); + else print('Linux'); + } }, + whoami: { usage: 'whoami', desc: 'print user name', run() { print(user); } }, + }; + + function runLine(raw) { + line([{ text: promptText() + ' ', cls: 'term-ps1' }, { text: raw }]); + const trimmed = raw.trim(); + if (trimmed) { + if (trimmed !== history[history.length - 1]) { + history.push(trimmed); + if (history.length > HIST_MAX) history.shift(); + saveHistory(); + } + const tokens = trimmed.split(/\s+/); + const cmd = COMMANDS[ALIASES[tokens[0]] || tokens[0]]; + if (cmd) cmd.run(tokens.slice(1)); + else print('gsh: ' + tokens[0] + ': command not found'); + } + hIdx = history.length; + draft = ''; + screen.scrollTop = screen.scrollHeight; + } + + // Tab: complete command names (first word) or paths (arguments) to the + // longest shared prefix; a stuck tab with several matches lists them + function complete() { + const v = input.value; + const token = /(\S*)$/.exec(v)[1]; + const before = v.slice(0, v.length - token.length); + let base = ''; + let candidates; + if (before.trim() === '') { + candidates = Object.keys(COMMANDS) + .filter((c) => !COMMANDS[c].hidden && c.startsWith(token)) + .map((c) => c + ' '); + } else { + const slash = token.lastIndexOf('/'); + base = slash >= 0 ? token.slice(0, slash + 1) : ''; + const prefix = token.slice(slash + 1); + const node = nodeAt(resolvePath(base || '.')); + if (!node || node === DENIED || !allowed(node) || isFile(node)) return; + candidates = Object.keys(node.children) + .filter((n) => n.startsWith(prefix) && (prefix.startsWith('.') || !n.startsWith('.'))) + .map((n) => n + (isFile(node.children[n]) ? ' ' : '/')); + } + if (!candidates.length) return; + let lcp = candidates[0]; + for (const c of candidates) { + while (!c.startsWith(lcp)) lcp = lcp.slice(0, -1); + } + if (candidates.length === 1) { + input.value = before + base + candidates[0]; + } else if (lcp && base + lcp !== token) { + input.value = before + base + lcp; + } else { + line(candidates.map((c, i) => ({ text: (i ? ' ' : '') + c.trimEnd() }))); + screen.scrollTop = screen.scrollHeight; + } + } + + // ── nano: the one installed editor. Editing works; saving is another + // matter (the filesystem is read-only, and it says so like nano would) + let nano = null; + let nanoTitleName = null; + let nanoTitleMod = null; + let nanoEdit = null; + let nanoStatus = null; + let nanoKeys = null; + let nanoName = ''; + let nanoSegs = null; + let nanoOriginal = ''; + let nanoPrompting = false; + const NANO_EDIT_KEYS = [['^X', 'Exit'], ['^O', 'Write Out']]; + + function setNanoKeys(pairs) { + nanoKeys.textContent = ''; + pairs.forEach(([key, label]) => { + const k = document.createElement('span'); + k.className = 'nano-key'; + k.textContent = key; + nanoKeys.appendChild(k); + nanoKeys.appendChild(document.createTextNode(' ' + label + ' ')); + }); + } + + function setNanoStatus(msg) { + nanoStatus.textContent = ''; + if (msg) { + const s = document.createElement('span'); + s.textContent = msg; + nanoStatus.appendChild(s); + } + } + + function updateNanoTitle() { + nanoTitleName.textContent = nanoName; + nanoTitleMod.textContent = nanoEdit.value === nanoOriginal ? '' : 'Modified'; + } + + function buildNano() { + nano = document.createElement('div'); + nano.className = 'nano'; + const title = document.createElement('div'); + title.className = 'nano-bar nano-title'; + const version = document.createElement('span'); + version.textContent = 'GNU nano 8.1'; + nanoTitleName = document.createElement('span'); + nanoTitleMod = document.createElement('span'); + title.append(version, nanoTitleName, nanoTitleMod); + nanoEdit = document.createElement('textarea'); + nanoEdit.className = 'nano-edit'; + nanoEdit.spellcheck = false; + nanoEdit.setAttribute('aria-label', 'Editor'); + nanoStatus = document.createElement('div'); + nanoStatus.className = 'nano-status'; + nanoKeys = document.createElement('div'); + nanoKeys.className = 'nano-keys'; + nano.append(title, nanoEdit, nanoStatus, nanoKeys); + nanoEdit.addEventListener('keydown', nanoKeydown); + nanoEdit.addEventListener('input', updateNanoTitle); + } + + function openNano(name, content, segs) { + if (!nano) buildNano(); + nanoName = name; + nanoSegs = segs; + nanoOriginal = content; + nanoEdit.value = content; + nanoPrompting = false; + setNanoStatus(''); + setNanoKeys(NANO_EDIT_KEYS); + updateNanoTitle(); + screen.classList.add('nano-open'); + screen.appendChild(nano); + nanoEdit.focus(); + nanoEdit.setSelectionRange(0, 0); + } + + function closeNano() { + nanoPrompting = false; + screen.classList.remove('nano-open'); + nano.remove(); + input.focus(); + screen.scrollTop = screen.scrollHeight; + } + + function nanoAskExit() { + nanoPrompting = true; + setNanoStatus('Save modified buffer?'); + setNanoKeys([['Y', 'Yes'], ['N', 'No'], ['^C', 'Cancel']]); + } + + function nanoEndPrompt(msg) { + nanoPrompting = false; + setNanoStatus(msg); + setNanoKeys(NANO_EDIT_KEYS); + } + + // Writing works for root and no one else; sets its own status line + function nanoSave() { + if (user !== 'root') { + setNanoStatus('[ Error writing ' + nanoName + ': Permission denied ]'); + return false; + } + if (!nanoSegs || !nanoSegs.length) { + setNanoStatus('[ Cancelled ]'); + return false; + } + const parent = nodeAt(nanoSegs.slice(0, -1)); + if (!parent || parent === DENIED || isFile(parent)) { + setNanoStatus('[ Directory does not exist ]'); + return false; + } + parent.children[nanoSegs[nanoSegs.length - 1]] = nanoEdit.value; + nanoOriginal = nanoEdit.value; + updateNanoTitle(); + const n = nanoEdit.value ? nanoEdit.value.replace(/\n$/, '').split('\n').length : 0; + setNanoStatus('[ Wrote ' + n + ' line' + (n === 1 ? '' : 's') + ' ]'); + return true; + } + + function nanoKeydown(e) { + if (nanoPrompting) { + e.preventDefault(); + const k = e.key.toLowerCase(); + if (k === 'y') { + nanoPrompting = false; + setNanoKeys(NANO_EDIT_KEYS); + if (nanoSave()) closeNano(); + } else if (k === 'n') closeNano(); + else if ((k === 'c' && e.ctrlKey) || e.key === 'Escape') nanoEndPrompt('[ Cancelled ]'); + return; + } + if (e.ctrlKey && !e.altKey && !e.metaKey) { + const k = e.key.toLowerCase(); + if (k === 'x') { + e.preventDefault(); + if (nanoEdit.value !== nanoOriginal) nanoAskExit(); + else closeNano(); + } else if (k === 'o' || k === 's') { + e.preventDefault(); + nanoSave(); + } + return; // other ctrl combos (copy, paste, undo) keep their defaults + } + if (e.key === 'Escape') { + e.preventDefault(); + if (nanoEdit.value !== nanoOriginal) nanoAskExit(); + else closeNano(); + } else if (e.key === 'Tab') { + e.preventDefault(); + nanoEdit.setRangeText('\t', nanoEdit.selectionStart, nanoEdit.selectionEnd, 'end'); + updateNanoTitle(); + } + } + + function build() { + dialog = document.createElement('dialog'); + dialog.className = 'term-dialog'; + dialog.setAttribute('aria-label', 'Terminal'); + + const bar = document.createElement('div'); + bar.className = 'term-titlebar'; + termTitle = document.createElement('span'); + termTitle.className = 'term-title'; + termTitle.textContent = user + '@' + HOST; + bar.appendChild(termTitle); + + screen = document.createElement('div'); + screen.className = 'term-screen'; + out = document.createElement('div'); + out.className = 'term-out'; + out.setAttribute('role', 'log'); + const row = document.createElement('div'); + row.className = 'term-inputrow'; + promptEl = document.createElement('span'); + promptEl.className = 'term-prompt'; + promptEl.setAttribute('aria-hidden', 'true'); + promptEl.textContent = promptText(); + input = document.createElement('input'); + input.className = 'term-input'; + input.type = 'text'; + input.autocapitalize = 'off'; + input.autocomplete = 'off'; + input.spellcheck = false; + input.setAttribute('aria-label', 'Command input'); + row.append(promptEl, input); + screen.append(out, row); + dialog.append(bar, screen); + document.body.appendChild(dialog); + + const motd = nodeAt(resolvePath('/etc/motd')); + if (typeof motd === 'string') print(motd.replace(/\n$/, '')); + + wireInput(input); + // Click focuses the input, but not when selecting scrollback text + screen.addEventListener('click', () => { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed) input.focus(); + }); + // Click on the backdrop closes, like the delete dialog + dialog.addEventListener('click', (e) => { + if (e.target === dialog) dialog.close(); + }); + // While nano is up, Escape belongs to nano, not the dialog + dialog.addEventListener('cancel', (e) => { + if (screen.classList.contains('nano-open')) e.preventDefault(); + }); + } + + function wireInput(el) { + el.addEventListener('keydown', (e) => { + if (e.ctrlKey && !e.altKey && !e.metaKey) { + const k = e.key.toLowerCase(); + if (k === 'c') { + e.preventDefault(); + if (pendingSu) { + pendingSu = null; + input.type = 'text'; + line([{ text: 'Password:^C' }]); + promptEl.textContent = promptText(); + } else { + line([{ text: promptText() + ' ', cls: 'term-ps1' }, { text: input.value + '^C' }]); + } + input.value = ''; + hIdx = history.length; + draft = ''; + screen.scrollTop = screen.scrollHeight; + } else if (k === 'd') { + e.preventDefault(); + if (pendingSu) { + pendingSu = null; + input.type = 'text'; + input.value = ''; + line([{ text: 'Password:' }]); + promptEl.textContent = promptText(); + } else if (input.value === '') COMMANDS.exit.run(); + } else if (k === 'l') { + e.preventDefault(); + out.textContent = ''; + } + return; // unhandled ctrl combos (paste, …) keep their defaults + } + if (e.key === 'Enter') { + // preventDefault kills the trailing keypress: if a command opens a + // modal (rm -rf /), the keypress would land on its autofocused + // button and click it before the dialog is ever seen + e.preventDefault(); + const raw = input.value; + input.value = ''; + if (pendingSu) { + // password attempts stay out of scrollback and history + const target = pendingSu; + pendingSu = null; + input.type = 'text'; + line([{ text: 'Password:' }]); + if (raw === PASSWORDS[target]) { + userStack.push(user); + becomeUser(target); + } else { + print('su: Authentication failure'); + promptEl.textContent = promptText(); + } + screen.scrollTop = screen.scrollHeight; + return; + } + runLine(raw); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (pendingSu) return; + if (hIdx > 0) { + if (hIdx === history.length) draft = input.value; + hIdx--; + input.value = history[hIdx]; + input.setSelectionRange(input.value.length, input.value.length); + } + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (pendingSu) return; + if (hIdx < history.length) { + hIdx++; + input.value = hIdx === history.length ? draft : history[hIdx]; + input.setSelectionRange(input.value.length, input.value.length); + } + } else if (e.key === 'Tab') { + e.preventDefault(); + if (!pendingSu) complete(); + } + }); + } + + function open() { + if (!dialog) build(); // lazy: no trace in the DOM until first opened + if (dialog.open) return; + dialog.showModal(); + if (screen.classList.contains('nano-open')) nanoEdit.focus(); + else input.focus(); + screen.scrollTop = screen.scrollHeight; + } + + icon.addEventListener('click', open); +})(); diff --git a/js/tooltips.js b/js/tooltips.js new file mode 100644 index 0000000..411a16f --- /dev/null +++ b/js/tooltips.js @@ -0,0 +1,43 @@ +// ── Tag pill tooltips: tap toggle (touch devices only) ──────── +(function () { + // Hover-capable devices already get the tooltips via :hover + if (window.matchMedia('(hover: hover)').matches) return; + + const pills = [...document.querySelectorAll('[data-tip]')]; + + function closeAll() { + pills.forEach(p => { + p.classList.remove('tip-open'); + p.setAttribute('aria-expanded', 'false'); + }); + } + + pills.forEach(pill => { + pill.setAttribute('tabindex', '0'); + pill.setAttribute('role', 'button'); + pill.setAttribute('aria-expanded', 'false'); + // Pseudo-element tooltips are invisible to screen readers + pill.setAttribute('aria-description', pill.dataset.tip); + + pill.addEventListener('click', (e) => { + e.stopPropagation(); + const wasOpen = pill.classList.contains('tip-open'); + closeAll(); + if (!wasOpen) { + pill.classList.add('tip-open'); + pill.setAttribute('aria-expanded', 'true'); + } + }); + pill.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + pill.click(); + } + }); + }); + + document.addEventListener('click', closeAll); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeAll(); + }); +})(); diff --git a/quest.html b/quest.html new file mode 100644 index 0000000..ba5c7af --- /dev/null +++ b/quest.html @@ -0,0 +1,232 @@ + + + + + +The Quest for the Deleted Website + + + + + + + +
    +

    The Quest for the Deleted Website

    +
    + + + +
    + + + + + + + + + + + + + + + + + + +
    + + diff --git a/script.js b/script.js deleted file mode 100644 index 046e32c..0000000 --- a/script.js +++ /dev/null @@ -1,776 +0,0 @@ -// ── Sprinkle background ──────────────────────────── -(function () { - const canvas = document.getElementById('bgCanvas'); - const ctx = canvas.getContext('2d'); - - const COLORS = ['#ffd84d', '#8cbcff', '#ff9fca', '#a9e76c', '#b99cff', '#ff9d57']; - const INK = '#171717'; - const COUNT = 1000; // number of sprinkles - const MIN_LEN = 12; // px - const MAX_LEN = 18; // px - const MIN_W = 4; - const MAX_W = 6; - - let W, H, sprinkles; - - function rand(a, b) { return a + Math.random() * (b - a); } - - function resize() { - W = canvas.width = window.innerWidth; - H = canvas.height = window.innerHeight; - if (!sprinkles) init(); - // Redistribute any sprinkles that landed outside new bounds - sprinkles.forEach(s => { - if (s.x > W) s.x = rand(0, W); - if (s.y > H) s.y = rand(0, H); - }); - // Resizing clears the canvas; with no animation loop running, repaint it - if (reduceMotion.matches) { - cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(draw); - } - } - - function init() { - sprinkles = []; - for (let i = 0; i < COUNT; i++) { - sprinkles.push(makeSprinkle()); - } - } - - function makeSprinkle() { - return { - x: rand(0, W), - y: rand(0, H), - len: rand(MIN_LEN, MAX_LEN), - width: rand(MIN_W, MAX_W), - color: COLORS[Math.floor(Math.random() * COLORS.length)], - angle: rand(0, Math.PI * 2), - // very slow drift - vx: rand(-0.1, 0.1), - vy: rand(-0.1, 0.1), - va: rand(-0.005, 0.005), // angular velocity - alpha: 1, - }; - } - - function draw() { - ctx.clearRect(0, 0, W, H); - - sprinkles.forEach(s => { - ctx.save(); - ctx.translate(s.x, s.y); - ctx.rotate(s.angle); - ctx.beginPath(); - ctx.moveTo(0, -s.len / 2); - ctx.lineTo(0, s.len / 2); - ctx.lineCap = 'round'; - ctx.globalAlpha = s.alpha; - // Ink outline under the colored fill (sticker look): same path, two strokes - ctx.strokeStyle = INK; - ctx.lineWidth = s.width + 3; - ctx.stroke(); - ctx.strokeStyle = s.color; - ctx.lineWidth = s.width; - ctx.stroke(); - ctx.restore(); - - // Reduced motion: sprinkles stay where they are - if (reduceMotion.matches) return; - - // Drift - s.x += s.vx; - s.y += s.vy; - if (s.vx > 0.1 || s.vx < -0.1) { - s.vx *= 0.98; - } - if (s.vy > 0.1 || s.vy < -0.1) { - s.vy *= 0.98; - } - s.angle += s.va; - - // Repel from cursor - const dx = s.x - mouseX; - const dy = s.y - mouseY; - const dist = Math.sqrt(dx * dx + dy * dy); - const repelRadius = 80; - if (dist < repelRadius && dist > 0) { - const force = (repelRadius - dist) / repelRadius; - s.vx += (dx / dist) * force * 1.0; - s.vy += (dy / dist) * force * 1.0; - } - - // Wrap: when a sprinkle drifts off the screen, reappear on the other side - if (s.y < -MAX_LEN) s.y = H + MAX_LEN; - if (s.y > H + MAX_LEN) s.y = -MAX_LEN - if (s.x < -MAX_LEN) s.x = W + MAX_LEN; - if (s.x > W + MAX_LEN) s.x = -MAX_LEN; - }); - - if (!reduceMotion.matches) rafId = requestAnimationFrame(draw); - } - - // With reduced motion the sprinkles render once as a static backdrop - const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); - let rafId; - reduceMotion.addEventListener('change', () => { - cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(draw); - }); - - window.addEventListener('resize', resize, { passive: true }); - resize(); - let mouseX = -9999, mouseY = -9999; - window.addEventListener('mousemove', (e) => { - mouseX = e.clientX; - mouseY = e.clientY; - }, { passive: true }); - rafId = requestAnimationFrame(draw); -})(); - - -// ── Nav: scroll state & active link ─────────────────────────── -(function () { - const header = document.getElementById('header'); - const links = document.querySelectorAll('.nav-link'); - const sections = document.querySelectorAll('section[id]'); - const burger = document.getElementById('burger'); - - window.addEventListener('scroll', () => { - header.classList.toggle('scrolled', window.scrollY > 24 || burger.classList.contains('open')); - - let current = ''; - sections.forEach(s => { - if (window.scrollY >= s.offsetTop - 100) current = s.id; - }); - links.forEach(l => { - l.classList.toggle('active', l.getAttribute('href') === '#' + current); - }); - }, { passive: true }); -})(); - - -// ── Mobile burger ────────────────────────────────────────────── -(function () { - const burger = document.getElementById('burger'); - const navLinks = document.getElementById('navLinks'); - const header = document.getElementById('header'); - burger.addEventListener('click', () => { - burger.classList.toggle('open'); - navLinks.classList.toggle('open'); - header.classList.toggle('scrolled', burger.classList.contains('open') || window.scrollY > 24); - }); - navLinks.querySelectorAll('a').forEach(a => { - a.addEventListener('click', () => { - burger.classList.remove('open'); - navLinks.classList.remove('open'); - header.classList.toggle('scrolled', window.scrollY > 24); - }); - }); -})(); - - -// ── Avatar spin on click ────────────────────────────────────── -(function () { - const avatar = document.querySelector('.avatar-ring'); - - avatar.addEventListener('click', () => { - if (avatar.classList.contains('spinning')) return; - avatar.classList.add('spinning'); - }); - avatar.addEventListener('animationend', () => { - avatar.classList.remove('spinning'); - }); -})(); - - -// ── Tag pill tooltips: tap toggle (touch devices only) ──────── -(function () { - // Hover-capable devices already get the tooltips via :hover - if (window.matchMedia('(hover: hover)').matches) return; - - const pills = [...document.querySelectorAll('[data-tip]')]; - - function closeAll() { - pills.forEach(p => { - p.classList.remove('tip-open'); - p.setAttribute('aria-expanded', 'false'); - }); - } - - pills.forEach(pill => { - pill.setAttribute('tabindex', '0'); - pill.setAttribute('role', 'button'); - pill.setAttribute('aria-expanded', 'false'); - // Pseudo-element tooltips are invisible to screen readers - pill.setAttribute('aria-description', pill.dataset.tip); - - pill.addEventListener('click', (e) => { - e.stopPropagation(); - const wasOpen = pill.classList.contains('tip-open'); - closeAll(); - if (!wasOpen) { - pill.classList.add('tip-open'); - pill.setAttribute('aria-expanded', 'true'); - } - }); - pill.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - pill.click(); - } - }); - }); - - document.addEventListener('click', closeAll); - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') closeAll(); - }); -})(); - - - -// ── Pepsi gallery: shuffled on load, swipe cycles through on click ─ -(function () { - const COUNT = 15; - const img = document.querySelector('.pepsi-placeholder img'); - if (!img) return; - const box = img.parentElement; - const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); - const src = (n) => `Pepsi/Pepsi-${n}.jpg`; - - // Fisher–Yates shuffle of [1..COUNT]; clicks cycle through this order - const order = Array.from({ length: COUNT }, (_, i) => i + 1); - for (let i = order.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [order[i], order[j]] = [order[j], order[i]]; - } - let pos = 0; - img.src = src(order[pos]); - - let busy = false; - box.addEventListener('click', () => { - if (busy) return; - pos = (pos + 1) % order.length; - const next = order[pos]; - - if (reduceMotion.matches) { - img.src = src(next); - return; - } - - busy = true; - const incoming = new Image(); - incoming.src = src(next); - incoming.alt = img.alt; - incoming.draggable = false; - incoming.className = 'pepsi-incoming'; - // Decode before animating so the swipe never reveals a half-loaded frame - incoming.decode().then(() => { - box.appendChild(incoming); - const opts = { duration: 300, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }; - img.animate( - [{ transform: 'translateX(0)' }, { transform: 'translateX(-100%)' }], - opts - ); - incoming.animate( - [{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }], - opts - ).finished.then(() => { - img.src = incoming.src; // already decoded, swaps instantly - incoming.remove(); - busy = false; - }); - }).catch(() => { busy = false; }); - }); -})(); - - -// ── Scroll hint: appears after idling at the top of the page ── -(function () { - const hint = document.getElementById('scrollHint'); - if (!hint) return; - - const timer = setTimeout(() => { - if (window.scrollY <= 24) hint.classList.add('visible'); - }, 3000); - - // Any scroll dismisses it for the rest of the visit - window.addEventListener('scroll', () => { - clearTimeout(timer); - hint.classList.remove('visible'); - }, { passive: true, once: true }); -})(); - - -// ── Job title slot machine: click the role pill ─────────────── -(function () { - const pill = document.getElementById('heroRole'); - if (!pill) return; - - const WORDS = [ - ['Software', 'Backend', 'Data', 'Computer', 'Frontend', 'Full-Stack', 'Tech'], - ['Developer', 'Scientist', 'Engineer', 'Enthusiast', 'Wizard'], - ]; - const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); - const slots = [...pill.querySelectorAll('.slot')]; - - // Width of a word in this mono font: 1ch + letter-spacing per character. - // Slots hug their current word at rest and expand to their widest word - // while spinning (the CSS width transition animates both moves). - const widthFor = (word) => `calc(${word.length}ch + ${word.length * 0.08}em)`; - slots.forEach((slot) => { - slot.style.width = widthFor(slot.textContent.trim()); - }); - - let busy = false; - pill.addEventListener('click', () => { - if (busy) return; - busy = true; - - const spins = slots.map((slot, i) => { - const list = WORDS[i]; - const reel = slot.querySelector('.reel'); - const from = Math.max(list.indexOf(reel.textContent.trim()), 0); - // Random pick that's never the word already showing - let target = Math.floor(Math.random() * (list.length - 1)); - if (target >= from) target++; - - if (reduceMotion.matches) { - reel.innerHTML = `${list[target]}`; - slot.style.width = widthFor(list[target]); - return Promise.resolve(); - } - - // Expand to fit the reel's widest word for the duration of the spin - const longest = list.reduce((a, b) => (b.length > a.length ? b : a)); - slot.style.width = widthFor(longest); - - // Walk the reel in list order: a couple of full revolutions, then - // land on the target (the second reel spins one revolution longer) - const steps = ((target - from + list.length) % list.length) + list.length * (2 + i); - - // Build the strip top-down ending at the current word, so the column - // slides downward and words enter from above. One extra word sits - // above the target to back the overshoot. - reel.innerHTML = ''; - for (let k = 0; k <= steps + 1; k++) { - const w = document.createElement('span'); - w.textContent = list[(from + steps + 1 - k) % list.length]; - reel.appendChild(w); - } - - const h = reel.firstChild.getBoundingClientRect().height; - const land = -h; // row where the target word sits - const over = land + h * 0.35; // spin runs slightly past the stop - const anim = reel.animate( - [ - { transform: `translateY(${-(steps + 1) * h}px)` }, - { transform: `translateY(${over}px)` }, - ], - { duration: 900 + i * 500, easing: 'cubic-bezier(0.15, 0.85, 0.25, 1)', fill: 'forwards' } - ); - return anim.finished.then(() => { - const settle = reel.animate( - [{ transform: `translateY(${over}px)` }, { transform: `translateY(${land}px)` }], - { duration: 200, easing: 'ease', fill: 'forwards' } - ); - return settle.finished.then(() => { - reel.innerHTML = `${list[target]}`; - settle.cancel(); - anim.cancel(); // drop the forward-filled transforms after the swap - slot.style.width = widthFor(list[target]); // shrink back to fit - }); - }); - }); - - Promise.all(spins).finally(() => { busy = false; }); - }); -})(); - - -// ── Pronoun pill: non-binary flag sweep on click ────────────── -(function () { - const pill = document.querySelector('.hero-pronouns'); - if (!pill) return; - const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); - - pill.addEventListener('click', () => { - if (reduceMotion.matches) return; - if (pill.classList.contains('waving')) return; - pill.classList.add('waving'); - }); - pill.addEventListener('animationend', () => { - pill.classList.remove('waving'); - }); -})(); - - -// ── Delete website (footer): flag in localStorage, serve the 404 ─ -(function () { - const button = document.getElementById('deleteSite'); - const dialog = document.getElementById('deleteDialog'); - const confirm = document.getElementById('deleteConfirm'); - const cancel = document.getElementById('deleteCancel'); - if (!button || !dialog) return; - - button.addEventListener('click', () => dialog.showModal()); - cancel.addEventListener('click', () => dialog.close()); - confirm.addEventListener('click', () => { - try { localStorage.setItem('siteDeleted', '1'); } catch (e) {} - location.replace('404.html'); - }); - // Click on the backdrop closes too - dialog.addEventListener('click', (e) => { - if (e.target === dialog) dialog.close(); - }); -})(); - - -// ── Icon circles spin on click, same move as the avatar ─────── -(function () { - const icons = document.querySelectorAll('.skill-card-icon, .project-icon, .edu-icon, .nav-logo'); - icons.forEach((icon) => { - icon.addEventListener('click', () => { - if (icon.classList.contains('spinning')) return; - icon.classList.add('spinning'); - }); - icon.addEventListener('animationend', () => { - icon.classList.remove('spinning'); - }); - }); -})(); - - -// ── D20 on "Dungeons & Dragons": a 3D die built from matrix3d ─ -(function () { - const anchor = document.querySelector('.dnd-roll'); - if (!anchor) return; - const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); - - // Icosahedron in CSS coordinates (x right, y down, z toward viewer) - const R = 44; // circumradius, px - const PHI = (1 + Math.sqrt(5)) / 2; - const RAW = [ - [-1, PHI, 0], [1, PHI, 0], [-1, -PHI, 0], [1, -PHI, 0], - [0, -1, PHI], [0, 1, PHI], [0, -1, -PHI], [0, 1, -PHI], - [PHI, 0, -1], [PHI, 0, 1], [-PHI, 0, -1], [-PHI, 0, 1], - ]; - const FACES = [ - [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], - [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], - [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], - [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], - ]; - // The standard d20 layout (per Alea Kybos' dice configuration catalog), - // embedded onto the face list above: 20 bordered by 2/8/14, opposite - // faces sum to 21, evens on 20's hemisphere and odds on 1's - const NUMBERS = [20, 2, 12, 10, 8, 18, 14, 16, 15, 17, 11, 9, 19, 1, 13, 6, 4, 3, 7, 5]; - // Theme pastels the die can be cast from, one picked per roll - const PASTELS = [ - [255, 216, 77], [140, 188, 255], [255, 159, 202], - [169, 231, 108], [185, 156, 255], [255, 157, 87], - ]; - const scale = R / Math.hypot(1, PHI); - const V = RAW.map(([x, y, z]) => [x * scale, -y * scale, z * scale]); - - const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; - const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; - const cross = (a, b) => [ - a[1] * b[2] - a[2] * b[1], - a[2] * b[0] - a[0] * b[2], - a[0] * b[1] - a[1] * b[0], - ]; - const norm = (a) => { - const l = Math.hypot(a[0], a[1], a[2]); - return [a[0] / l, a[1] / l, a[2] / l]; - }; - const mat3d = ([X, Y, Z], t) => - `matrix3d(${X[0]},${X[1]},${X[2]},0,${Y[0]},${Y[1]},${Y[2]},0,` + - `${Z[0]},${Z[1]},${Z[2]},0,${t[0]},${t[1]},${t[2]},1)`; - - let scene = null; - let die = null; - const bases = []; - const faceEls = []; - - function build() { - scene = document.createElement('span'); - scene.className = 'die-scene'; - die = document.createElement('span'); - die.className = 'die'; - scene.appendChild(die); - - const light = norm([-0.35, -0.5, 0.78]); - FACES.forEach((f, idx) => { - let [A, B, C] = f.map((i) => V[i]); - const G = [ - (A[0] + B[0] + C[0]) / 3, - (A[1] + B[1] + C[1]) / 3, - (A[2] + B[2] + C[2]) / 3, - ]; - let n = norm(cross(sub(B, A), sub(C, A))); - if (dot(n, G) < 0) { // ensure the normal points outward - [B, C] = [C, B]; - n = [-n[0], -n[1], -n[2]]; - } - const X = norm(sub(B, A)); // face-local right - const Y = norm(cross(n, X)); // face-local down (X × Y = n, no mirroring) - bases.push([X, Y, n]); - - const D = Math.hypot(...sub(B, A)); // edge length - const face = document.createElement('span'); - face.className = 'die-face'; - face.textContent = NUMBERS[idx]; - // Real dice underline the ambiguous digits - if (NUMBERS[idx] === 6 || NUMBERS[idx] === 9) face.classList.add('die-face--mark'); - const pts = [A, B, C].map((P) => { - const p = sub(P, G); - return `${dot(p, X) + D / 2}px ${dot(p, Y) + D / 2}px`; - }); - face.style.width = face.style.height = D + 'px'; - face.style.margin = -D / 2 + 'px'; - face.style.fontSize = D * 0.3 + 'px'; - face.style.clipPath = `polygon(${pts.join(', ')})`; - // Static lighting factor, applied to the roll's color when it's cast - faceEls.push({ el: face, shade: 0.6 + 0.4 * Math.max(0, dot(n, light)) }); - face.style.transform = mat3d([X, Y, n], G); - die.appendChild(face); - }); - anchor.appendChild(scene); - } - - // Rotation that lands face i flat toward the viewer, number upright - function landing(i) { - const [X, Y, Z] = bases[i]; - return `matrix3d(${X[0]},${Y[0]},${Z[0]},0,${X[1]},${Y[1]},${Z[1]},0,` + - `${X[2]},${Y[2]},${Z[2]},0,0,0,0,1)`; - } - - let busy = false; - anchor.addEventListener('click', () => { - if (busy) return; - busy = true; - if (!scene) build(); - scene.style.display = ''; - scene.style.opacity = '1'; - - const result = Math.floor(Math.random() * 20) + 1; - const final = landing(NUMBERS.indexOf(result)); - - // Cast the die in one of the theme pastels for this roll - const [pr, pg, pb] = PASTELS[Math.floor(Math.random() * PASTELS.length)]; - faceEls.forEach(({ el, shade }) => { - el.style.background = - `rgb(${Math.round(pr * shade)}, ${Math.round(pg * shade)}, ${Math.round(pb * shade)})`; - }); - - // Nat 20: DOM sprinkles burst from the die — unlike the canvas ones, - // these render above the cards, so the effect survives any backdrop - const burst = () => { - const r = scene.getBoundingClientRect(); - const cx = r.left + r.width / 2; - const cy = r.top + r.height / 2; - for (let i = 0; i < 24; i++) { - const bit = document.createElement('span'); - bit.className = 'burst-bit'; - const [br, bg, bb] = PASTELS[Math.floor(Math.random() * PASTELS.length)]; - bit.style.background = `rgb(${br}, ${bg}, ${bb})`; - bit.style.left = cx + 'px'; - bit.style.top = cy + 'px'; - document.body.appendChild(bit); - const a = Math.random() * Math.PI * 2; - const d = 60 + Math.random() * 90; - bit.animate( - [ - { - transform: `translate(-50%, -50%) rotate(${Math.random() * 360}deg)`, - opacity: 1, - }, - { - transform: - `translate(calc(-50% + ${(Math.cos(a) * d).toFixed(1)}px), ` + - `calc(-50% + ${(Math.sin(a) * d).toFixed(1)}px)) ` + - `rotate(${((Math.random() - 0.5) * 720).toFixed(0)}deg)`, - opacity: 0, - }, - ], - { duration: 700 + Math.random() * 300, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)', fill: 'forwards' } - ).finished.then(() => bit.remove()); - } - }; - - const finish = () => { - if (result === 20 && !reduceMotion.matches) burst(); - setTimeout(() => { - scene.style.opacity = '0'; - setTimeout(() => { - scene.style.display = 'none'; - busy = false; - }, 320); - }, 1100); - }; - - if (reduceMotion.matches) { - die.style.transform = final; - finish(); - return; - } - // One continuous decelerating roll from a fully random direction: the - // die tumbles end-over-end along its travel (axis perpendicular to the - // motion, in-plane) with a slower barrel roll around the travel axis. - // Both extra rotations unwind to exactly zero on top of the landing - // matrix, so it settles on the result with no correcting jerk. - const ang = Math.random() * Math.PI * 2; - const dx = Math.cos(ang); - const dy = Math.sin(ang); - const roll = 810 + Math.floor(Math.random() * 3) * 72; - const wobble = 240 + Math.random() * 240; - const tumbleAxis = `${dy.toFixed(4)},${(-dx).toFixed(4)},0`; - const travelAxis = `${dx.toFixed(4)},${dy.toFixed(4)},0`; - const anim = die.animate( - [ - { - transform: - `translate(${(-dx * 150).toFixed(1)}px, ${(-dy * 150).toFixed(1)}px) ` + - `rotate3d(${tumbleAxis},${roll}deg) rotate3d(${travelAxis},${-wobble}deg) ${final}`, - }, - { - transform: - `translate(0px, 0px) rotate3d(${tumbleAxis},0deg) rotate3d(${travelAxis},0deg) ${final}`, - }, - ], - { duration: 1400, easing: 'cubic-bezier(0.16, 0.6, 0.28, 1)', fill: 'forwards' } - ); - anim.finished.then(() => { - die.style.transform = final; - anim.cancel(); - finish(); - }); - }); -})(); - - -// ── Name anagram: clicks cycle through rearrangements ───────── -(function () { - const h1 = document.querySelector('.hero-name'); - if (!h1) return; - const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); - - const NAME = 'Nikolaj Gade'; - const CYCLE = [NAME, 'a joking deal', NAME, 'naked oil jag']; - - // The visual letters rearrange, but assistive tech always gets the name - h1.setAttribute('aria-label', NAME); - - // Split into persistent per-letter spans; the exact glyphs get reused, - // so the capitals travel (see "a jokiNG deal") - const letters = []; - h1.textContent = ''; - for (const ch of NAME) { - if (ch === ' ') { - h1.appendChild(document.createTextNode(' ')); - continue; - } - const s = document.createElement('span'); - s.className = 'ltr'; - s.textContent = ch; - h1.appendChild(s); - letters.push({ el: s, ch: ch.toLowerCase() }); - } - - function arrange(phrase) { - // Pick a distinct source glyph for every letter of the target - const pool = [...letters]; - const seq = []; - for (const ch of phrase) { - if (ch === ' ') { - seq.push(null); - continue; - } - const i = pool.findIndex((l) => l.ch === ch.toLowerCase()); - seq.push(pool.splice(i, 1)[0].el); - } - - // FLIP: measure, reorder the DOM, measure again, then animate each - // glyph from its old position into its new one - const first = new Map(letters.map((l) => [l.el, l.el.getBoundingClientRect()])); - h1.textContent = ''; - seq.forEach((el) => h1.appendChild(el || document.createTextNode(' '))); - if (reduceMotion.matches) return Promise.resolve(); - - return Promise.all( - letters.map((l, k) => { - const a = first.get(l.el); - const b = l.el.getBoundingClientRect(); - return l.el.animate( - [ - { transform: `translate(${(a.left - b.left).toFixed(1)}px, ${(a.top - b.top).toFixed(1)}px)` }, - { transform: 'translate(0, 0)' }, - ], - { - duration: 650, - delay: k * 18, - fill: 'backwards', - easing: 'cubic-bezier(0.3, 0.9, 0.3, 1)', - } - ).finished; - }) - ); - } - - let pos = 0; - let busy = false; - h1.addEventListener('click', () => { - if (busy) return; - busy = true; - pos = (pos + 1) % CYCLE.length; - arrange(CYCLE[pos]).finally(() => { busy = false; }); - }); -})(); - - -// ── Goat ────────────────────────────────────────────────────── -(function () { - console.log(` - %%%%%%%%%%%%%% - %%%%%%%%%%%%%%%%%%%% - %%%% %%%%%%%%%%% - %%% %%%%%%%%%%%%%%% - %% %%%%%%%%%%%%%%%%% - %%%%%%%%%%%%%%%%%%% - %%%%%%%%%%%%%%%%%%%%%%% - %%%%%%%%%%%%%%%%%%%%%% - %%%%%%%%%%%%%%%%%%%%%%%%% - %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%% -%%%%%%%%%%%%%%%`); - - const FACTS = [ - `The word admiral comes from the Arabic "amir al bahr", which translates to "commander of the sea". Somewhere along the way into European languages, the bahr fell off, so the word we kept literally means "commander of the".`, - `The word stationery comes from (perhaps obviously) the word stationary. It's because book shops were some of the first shops to be in permanent buildings, since books are too heavy to carry around in a cart.`, - `The Wikipedia page for economist Guy Standing was the site of one of the biggest edit wars in Wikipedia history, started when an editor uploaded a picture with the description "Guy Standing sitting".`, - `The introduction of shipping containers, which hide their contents, dropped the number of alcohol bottles marked as "broken" by almost 100%.`, - `The words "chess" and "check" both come from the ancient Persian word "shah", meaning "king". "Check mate" comes from "shah maat", meaning "the king is dead".`, - `No one knows the origin of the character of Igor in the Frankenstein story. He's not in the original book, and his first known appearance is from the movie adaptation of a play. So it's not known if he originally appeared in the play or the movie.`, - `To avoid a binary overflow error, Swiss trains are not allowed to have exactly 256 axles.`, - ]; - console.log('Fun fact: ' + FACTS[Math.floor(Math.random() * FACTS.length)]); -})(); diff --git a/style.css b/style.css index a139034..4990d4d 100644 --- a/style.css +++ b/style.css @@ -242,6 +242,7 @@ main { position: relative; z-index: 1; } padding: 0.35rem 0.9rem; letter-spacing: 0.08em; text-transform: uppercase; + cursor: pointer; } /* Slot-machine reels: each slot is a one-line window onto a word column */ .hero-role .slot { display: block; height: 1.6em; overflow: hidden; transition: width 0.2s ease; } @@ -412,7 +413,9 @@ main { position: relative; z-index: 1; } text-align: center; } .pepsi-placeholder { - height: 400px; + /* ~7:6, tuned so the card exactly levels with .about-text at full + container width (480px photo box -> 461px card) */ + aspect-ratio: 41 / 35; border-bottom: var(--border); position: relative; overflow: hidden; @@ -701,6 +704,119 @@ footer { .dialog-button:hover { transform: translate(-2px, -2px); box-shadow: 5px 5px 0 var(--ink); } .dialog-button:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); } +/* ── Terminal (js/terminal.js, lazy-built dialog) ───────────── */ +.term-dialog { + margin: auto; + width: min(720px, 92vw); + padding: 0; + /* clips the dark screen to the sticker's rounded corners */ + overflow: hidden; + background: var(--white); + color: var(--ink); + border: var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); +} +.term-dialog::backdrop { background: rgba(23, 23, 23, 0.55); } +.term-dialog[open] { animation: termPop 0.22s var(--ease); } +@keyframes termPop { + from { transform: scale(0.92) translateY(10px); opacity: 0; } + to { transform: none; opacity: 1; } +} +.term-titlebar { + display: flex; + align-items: center; + justify-content: center; + padding: 0.6rem 1rem; + border-bottom: var(--border); +} +.term-title { + font-family: var(--mono); + font-weight: 500; + font-size: 0.75rem; + letter-spacing: 0.05em; +} +.term-screen { + background: var(--ink); + color: var(--paper); + font-family: var(--mono); + font-size: 0.85rem; + line-height: 1.5; + height: min(480px, 70vh); + padding: 0.9rem 1rem; + overflow-y: auto; + overscroll-behavior: contain; + cursor: text; +} +/* min-height keeps blank scrollback lines one line tall */ +.term-line { + white-space: pre-wrap; + overflow-wrap: anywhere; + min-height: 1.5em; +} +.term-ps1, .term-prompt { color: var(--green); } +.term-dir { color: var(--blue); } +.term-inputrow { display: flex; gap: 1ch; align-items: baseline; } +.term-prompt { white-space: nowrap; } +.term-input { + flex: 1; + min-width: 0; + padding: 0; + font: inherit; + color: inherit; + background: none; + border: none; + outline: none; + caret-color: var(--paper); +} +/* nano takes over the whole screen; shell scrollback hides underneath */ +.term-screen.nano-open { + overflow: hidden; + display: flex; + flex-direction: column; +} +.term-screen.nano-open .term-out, +.term-screen.nano-open .term-inputrow { display: none; } +.nano { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +/* reverse video, like the real thing */ +.nano-bar { + background: var(--paper); + color: var(--ink); + padding: 0 0.5ch; +} +.nano-title { display: flex; justify-content: space-between; } +.nano-edit { + flex: 1; + min-height: 0; + font: inherit; + color: inherit; + background: none; + border: none; + outline: none; + resize: none; + white-space: pre; + overflow: auto; + caret-color: var(--paper); +} +.nano-status { text-align: center; min-height: 1.5em; } +.nano-status span { + background: var(--paper); + color: var(--ink); + padding: 0 1ch; +} +.nano-keys { white-space: pre-wrap; } +.nano-key { + background: var(--paper); + color: var(--ink); + padding: 0 0.5ch; +} + /* ── Responsive ─────────────────────────────────────────────── */ @media (max-width: 960px) { .about-grid { grid-template-columns: 1fr; gap: 2rem; } @@ -741,6 +857,8 @@ footer { .burger { display: flex; } .education-grid { grid-template-columns: 1fr; } .project-title-row { flex-direction: column; align-items: flex-start; } + .term-dialog { width: calc(100vw - 1.5rem); } + .term-screen { height: min(480px, 65vh); } } @media (max-width: 480px) { @@ -794,7 +912,7 @@ footer { -webkit-user-select: none; } -/* Tooltip: shows on hover, keyboard focus, or tap (script.js toggles .tip-open) */ +/* Tooltip: shows on hover, keyboard focus, or tap (js/tooltips.js toggles .tip-open) */ [data-tip]:not([data-tip=""]):hover::after, [data-tip]:not([data-tip=""]):focus-visible::after, [data-tip]:not([data-tip=""]).tip-open::after { @@ -896,15 +1014,15 @@ footer { width: 110px; height: 110px; margin-left: -55px; - perspective: 420px; pointer-events: none; z-index: 10; transition: opacity 0.3s; } +/* No perspective/preserve-3d: js/d20.js projects each face flat itself — + Firefox's 3D plane-splitting drops parts of faces at grazing angles */ .die { position: absolute; inset: 0; - transform-style: preserve-3d; } .die-face { position: absolute; @@ -913,7 +1031,7 @@ footer { display: flex; align-items: center; justify-content: center; - backface-visibility: hidden; + /* no backface-visibility: js/d20.js culls faces itself (Firefox flicker) */ color: var(--ink); font-weight: 700; } @@ -934,6 +1052,221 @@ footer { pointer-events: none; } +/* ── Achievements (js/achievements.js, absent until first unlock) ── */ +.achv { + position: fixed; + top: 0.75rem; + left: 0.75rem; + z-index: 950; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + transition: top 0.3s var(--ease); + animation: achvPop 0.35s var(--ease); + transform-origin: top left; +} +/* Tucked below the nav whenever it's on screen */ +#header.scrolled ~ .achv { top: calc(var(--nav-h) + 0.75rem); } +@keyframes achvPop { + from { transform: scale(0); } +} +.achv-badge { + display: inline-flex; + align-items: center; + gap: 0.6ch; + font-family: var(--mono); + font-size: 0.85rem; + color: var(--ink); + padding: 0.3rem 0.85rem; + background: var(--white); + border: var(--border-thin); + border-radius: var(--radius-pill); + box-shadow: var(--shadow-sm); + transition: background 150ms ease; +} +.achv-badge:hover { transform: translate(-2px, -2px); box-shadow: 5px 5px 0 var(--ink); } +.achv-badge:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); } +.achv-panel { + display: flex; + flex-direction: column; + gap: 0.55rem; +} +.achv-panel[hidden] { display: none; } +/* Opening the panel marches the shields in from off-screen left, + staggered down the column (re-triggers on every un-hide) */ +.achv-panel:not([hidden]) .achv-medal { + animation: medalSlide 0.3s var(--ease) backwards; +} +.achv-medal:nth-child(2) { animation-delay: 45ms; } +.achv-medal:nth-child(3) { animation-delay: 90ms; } +.achv-medal:nth-child(4) { animation-delay: 135ms; } +.achv-medal:nth-child(5) { animation-delay: 180ms; } +.achv-medal:nth-child(6) { animation-delay: 225ms; } +@keyframes medalSlide { + from { transform: translateX(-105px); } +} +/* …and back out on close, unwinding bottom-first. js/achievements.js + flips hidden after the last shield leaves */ +.achv-panel.achv-panel--closing .achv-medal { + animation: medalSlideOut 0.3s var(--ease) forwards; +} +.achv-panel.achv-panel--closing .achv-medal:nth-child(1) { animation-delay: 225ms; } +.achv-panel.achv-panel--closing .achv-medal:nth-child(2) { animation-delay: 180ms; } +.achv-panel.achv-panel--closing .achv-medal:nth-child(3) { animation-delay: 135ms; } +.achv-panel.achv-panel--closing .achv-medal:nth-child(4) { animation-delay: 90ms; } +.achv-panel.achv-panel--closing .achv-medal:nth-child(5) { animation-delay: 45ms; } +.achv-panel.achv-panel--closing .achv-medal:nth-child(6) { animation-delay: 0ms; } +@keyframes medalSlideOut { + to { transform: translateX(-105px); } +} +.achv-sr { + position: absolute; + width: 1px; height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +/* Medals: heraldic shields — black until earned, blank stickers until + their art lands (see MEDALS in js/achievements.js). clip-path eats + borders, so the ink outline is a clipped ::before behind a smaller + clipped ::after face; the art is an inline SVG above both */ +.achv-medal { + position: relative; + width: 80px; height: 95px; + cursor: help; +} +/* Medal tooltips: the shield's own pseudo-elements are spent on the + clip-path layers, so this is a child bubble instead of [data-tip] — + keep it visually in step with the house tooltip above */ +.achv-tip { + position: absolute; + left: calc(100% + 12px); + top: 50%; + transform: translateY(-50%); + width: max-content; + max-width: 220px; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md); + background: var(--ink); + color: var(--white); + font-size: 0.75rem; + font-weight: 500; + line-height: 1.4; + z-index: 10; + pointer-events: none; + visibility: hidden; +} +.achv-tip b { display: block; font-weight: 700; } +.achv-tip::before { + content: ''; + position: absolute; + left: -13px; + top: 50%; + transform: translateY(-50%); + border: 8px solid transparent; + border-right-color: var(--ink); +} +.achv-medal:hover .achv-tip { visibility: visible; } +.achv-medal::before { + content: ''; + position: absolute; + inset: 0; + background: var(--ink); + clip-path: path('M0 0 H80 V37.5 C80 70 56.25 87.5 40 95 C23.75 87.5 0 70 0 37.5 Z'); +} +.achv-medal::after { + content: ''; + position: absolute; + inset: 3px; + background: var(--ink); + clip-path: path('M0 0 H74 V34.5 C74 65 52 81 37 89 C22 81 0 65 0 34.5 Z'); +} +.achv-medal--got::after { background: var(--white); } +/* Cat lover: Pepsi (a black cat) over the Pepsi-invoking tricolor bend */ +.achv-medal--cat::after { + background: linear-gradient(135deg, + #e32636 0 50px, + var(--white) 50px 66px, + #0e4c96 66px 100%); +} +.m-cat, .m-inv, .m-hk, .m-nat, .m-adv, .m-comp { + position: absolute; + inset: 0; + width: 100%; height: 100%; + z-index: 1; +} +/* Natural: a golden burst on royal purple behind a white d20 */ +.achv-medal--natural::after { background: #4b2e83; } +/* Adventurer: a sword palewise on deep gules */ +.achv-medal--adventurer::after { background: #a01f2f; } +/* Completionist: a gold crown on the finish flag (checky of ink and + white, 9.25px squares — one conic tile is a 2×2 check) */ +.achv-medal--completionist::after { + background: repeating-conic-gradient(var(--ink) 0% 25%, var(--white) 0% 50%) + top left / 18.5px 18.5px; +} +/* Hacker: the terminal quoted back — ink screen, glowing green prompt, + blinking cursor. Must re-override --got's white face */ +.achv-medal--hacker::after { background: var(--ink); } +.m-hk-cursor { animation: hkBlink 1.1s infinite; } +@keyframes hkBlink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} +/* Investigator: a semé of sprinkles under a white magnifying glass; + the lens is filled with the field color (same literal in the SVG) + so the ring reads clean */ +.achv-medal--investigator::after { background: #177245; } +.achv-toast { + position: fixed; + /* Undo the [popover] UA centering (inset: 0 + margin: auto) */ + inset: auto; + margin: 0; + top: 1rem; + left: 50%; + /* Parked above the viewport; js/achievements.js animates it in */ + transform: translate(-50%, -140%); + z-index: 1200; + display: flex; + align-items: center; + gap: 0.8rem; + background: var(--white); + border: var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + padding: 0.6rem 1.5rem 0.6rem 0.9rem; +} +/* The tiny medal: a real 80×95 shield scaled to 0.4, so the clip-path + px coordinates and the SVG art need no small-size variants */ +.achv-toast-medal { + flex: none; + width: 32px; height: 38px; +} +.achv-toast-medal .achv-medal { + display: block; + transform: scale(0.4); + transform-origin: top left; + cursor: default; +} +.achv-toast-text { + display: flex; + flex-direction: column; + text-align: left; +} +.achv-toast-kicker { + font-family: var(--mono); + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.achv-toast-name { + font-weight: 700; + font-size: 1.05rem; + letter-spacing: -0.02em; + line-height: 1.25; +} + /* ── Reduced motion ─────────────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } @@ -941,4 +1274,9 @@ footer { .project-tags span, .timeline-tags span, .nav-link::after, #header, .hero-role .slot { transition: none; } .scroll-hint.visible i { animation: none; } + .term-dialog[open] { animation: none; } + .achv { animation: none; transition: none; } + .achv-panel:not([hidden]) .achv-medal, + .achv-panel.achv-panel--closing .achv-medal { animation: none; } + .m-hk-cursor { animation: none; } }