This commit is contained in:
2026-08-09 23:42:23 +02:00
parent 21da2edd79
commit bd2ccdf73e
46 changed files with 3540 additions and 788 deletions
+2
View File
@@ -1 +1,3 @@
.vscode/
fonts/Kingdom_v1.4.ttf
Pepsi-originals/
+32 -3
View File
@@ -3,20 +3,30 @@
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>The page has been deleted</center>
<hr><center id="msg">The page has been deleted.</center>
<center id="undo" style="display:none"><br><a href="/">Restore website</a></center>
<center id="count" style="display:none"><br><font size="7">10</font></center>
<center id="quest" style="display:none"><br><button>Embark on a quest</button></center>
<script>
/* Restore hatch: clicking the heading undeletes the website */
/* Restore hatch: clicking the heading undeletes the website. Undeleting
also revokes the quest (no quest for the deleted website if the website
isn't deleted) — but the save file survives for the next deletion */
function restore() {
try {
localStorage.removeItem('siteDeleted');
localStorage.removeItem('siteDeletedViews');
localStorage.removeItem('questStarted');
} catch (e) {}
}
document.querySelector('h1').addEventListener('click', function () {
restore();
location.replace('/');
});
/* Embarking unlocks quest.html; the page bounces visitors who never did */
document.querySelector('#quest button').addEventListener('click', function () {
try { localStorage.setItem('questStarted', '1'); } catch (e) {}
location.href = '/quest.html';
});
/* Restoring is symmetric with deleting: it needs a confirmation too */
document.querySelector('#undo a').addEventListener('click', function (e) {
if (!confirm('Are you sure? This will restore the entire website.')) {
@@ -26,12 +36,31 @@ document.querySelector('#undo a').addEventListener('click', function (e) {
restore();
});
/* View 1 is the deletion, view 2 is the disbelieving reload (nothing
changes; that's the point), view 3 gets the way out */
changes; that's the point), view 3 gets the way out. Views 4-9 escalate
for anyone who keeps reloading past the exit; view 10 drops the act and
starts a countdown from 10 (one step per reload); at zero (view 20) the
countdown gives way to the quest */
var MSGS = {
4: 'The page has still been deleted.',
5: 'The page has been deleted for all eternity.',
6: 'The page has been deleted. If only there was a way to restore it.',
7: "The page has been deleted. Click the 'Restore website' button to restore it.",
8: "The page has been deleted. Click the 'Restore website' button to restore it.",
9: "The page has been deleted. Click the 'Restore website' button to restore it."
};
try {
if (localStorage.getItem('siteDeleted')) {
var views = 1 + (parseInt(localStorage.getItem('siteDeletedViews'), 10) || 0);
localStorage.setItem('siteDeletedViews', views);
if (views > 2) document.getElementById('undo').style.display = '';
if (MSGS[views]) document.getElementById('msg').textContent = MSGS[views];
if (views >= 20) {
document.getElementById('quest').style.display = '';
} else if (views >= 10) {
var count = document.getElementById('count');
count.style.display = '';
count.querySelector('font').textContent = 20 - views;
}
}
} catch (e) {}
</script>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 535 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 581 KiB

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 226 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 KiB

After

Width:  |  Height:  |  Size: 206 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 724 KiB

After

Width:  |  Height:  |  Size: 451 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 185 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 281 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 201 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 174 KiB

+8
View File
@@ -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');
}
Binary file not shown.
Binary file not shown.
+19 -3
View File
@@ -52,7 +52,7 @@
<li><a href="#education" class="nav-link">Education</a></li>
</ul>
<!-- Scroll hint: shown by script.js after idling at the top -->
<!-- Scroll hint: shown by js/scroll-hint.js after idling at the top -->
<a href="#about" class="scroll-hint" id="scrollHint" aria-label="Scroll to the About section">
<i class="fas fa-chevron-down" aria-hidden="true"></i>
</a>
@@ -214,7 +214,7 @@
</div>
</div>
<div class="project-body">
<p>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: <code>DEFINI</code> for function definitions, <code>INVOCA</code> to call them, <code>DICE</code> for output, <code>AVDI</code> for input, <code>SI</code>/<code>TVNC</code> for conditionals, and so on.</p>
<p>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: <code>DEFINI</code> for function definitions, <code>INVOCA</code> to call them, <code>DIC</code> for output, <code>AVDI</code> for input, <code>SI</code>/<code>TVNC</code> for conditionals, and so on.</p>
<p>The language includes a tree-walking interpreter and a compiler that targets C. Optional modules extend the standard library with <code>FORS</code> (random numbers), <code>FRACTIO</code> (base-12 fractions for floats), <code>MAGNVM</code> (higher numbers than standard roman numerals), and <code>SVBNVLLA</code> (negative numbers).</p>
</div>
<div class="project-tags">
@@ -392,6 +392,22 @@
</div>
</dialog>
<script src="script.js" defer></script>
<!-- achievements.js first: the other files call its window.achv API -->
<script src="js/achievements.js" defer></script>
<script src="js/sprinkles.js" defer></script>
<script src="js/nav.js" defer></script>
<script src="js/burger.js" defer></script>
<script src="js/avatar-spin.js" defer></script>
<script src="js/tooltips.js" defer></script>
<script src="js/pepsi.js" defer></script>
<script src="js/scroll-hint.js" defer></script>
<script src="js/slot-machine.js" defer></script>
<script src="js/pronouns.js" defer></script>
<script src="js/delete-website.js" defer></script>
<script src="js/icon-spin.js" defer></script>
<script src="js/d20.js" defer></script>
<script src="js/anagram.js" defer></script>
<script src="js/goat.js" defer></script>
<script src="js/terminal.js" defer></script>
</body>
</html>
+341
View File
@@ -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-<index>'
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:
'<svg class="m-cat" viewBox="0 0 80 95" aria-hidden="true">' +
'<path d="M40,28 C38,27 35,26 33,26 L14,10 L17,37 C13,42 12,50 14,56 ' +
'C18,67 28,73 40,73 C52,73 62,67 66,56 C68,50 67,42 63,37 L66,10 ' +
'L47,26 C45,26 42,27 40,28 Z" fill="var(--ink)"/>' +
'<ellipse cx="29" cy="48" rx="5" ry="6" fill="var(--yellow)"/>' +
'<ellipse cx="51" cy="48" rx="5" ry="6" fill="var(--yellow)"/>' +
'<ellipse cx="29" cy="48" rx="1.8" ry="5" fill="var(--ink)"/>' +
'<ellipse cx="51" cy="48" rx="1.8" ry="5" fill="var(--ink)"/>' +
'<path d="M36,58 h8 l-4,5 z" fill="var(--pink)" stroke="var(--pink)" ' +
'stroke-width="2" stroke-linejoin="round"/>' +
'<g fill="none" stroke="var(--white)" stroke-width="2" stroke-linecap="round">' +
'<path d="M31,57 Q20,55 12,53"/><path d="M31,60 Q20,60 11,60"/>' +
'<path d="M31,63 Q20,65 12,67"/><path d="M49,57 Q60,55 68,53"/>' +
'<path d="M49,60 Q60,60 69,60"/><path d="M49,63 Q60,65 68,67"/>' +
'</g></svg>',
investigator:
'<svg class="m-inv" viewBox="0 0 80 95" aria-hidden="true">' +
'<defs><pattern id="invSeme" width="16" height="28" ' +
'patternUnits="userSpaceOnUse" fill="var(--yellow)">' +
'<rect x="-1.5" y="-4.5" width="3" height="9" rx="1.5" transform="translate(8 7) rotate(45)"/>' +
'<rect x="-1.5" y="-4.5" width="3" height="9" rx="1.5" transform="translate(0 21) rotate(45)"/>' +
'<rect x="-1.5" y="-4.5" width="3" height="9" rx="1.5" transform="translate(16 21) rotate(45)"/>' +
'</pattern></defs>' +
'<path d="M3 3 H77 V37.5 C77 68 55 84 40 92 C25 84 3 68 3 37.5 Z" fill="url(#invSeme)"/>' +
'<circle cx="32" cy="38" r="15" fill="#177245" stroke="var(--white)" stroke-width="5"/>' +
'<line x1="43" y1="49" x2="56" y2="64" stroke="var(--white)" stroke-width="7" stroke-linecap="round"/>' +
'</svg>',
natural:
'<svg class="m-nat" viewBox="0 0 80 95" aria-hidden="true">' +
'<defs><clipPath id="natFace">' +
'<path d="M3 3 H77 V37.5 C77 68 55 84 40 92 C25 84 3 68 3 37.5 Z"/>' +
'</clipPath></defs>' +
'<g clip-path="url(#natFace)" fill="var(--yellow)">' +
'<path d="M40 42 L26.5 -26.7 L53.5 -26.7 Z"/>' +
'<path d="M40 42 L100.7 7 L114.2 30.4 Z"/>' +
'<path d="M40 42 L114.2 53.6 L100.7 77 Z"/>' +
'<path d="M40 42 L53.5 110.7 L26.5 110.7 Z"/>' +
'<path d="M40 42 L-20.7 77 L-34.2 53.6 Z"/>' +
'<path d="M40 42 L-34.2 30.4 L-20.7 7 Z"/>' +
'</g>' +
'<g stroke="var(--ink)" stroke-width="2" stroke-linejoin="round">' +
'<polygon points="40,18 60.8,30 60.8,54 40,66 19.2,54 19.2,30" fill="var(--white)"/>' +
'<g fill="none">' +
'<polygon points="40,28 52.12,49 27.88,49"/>' +
'<line x1="40" y1="28" x2="40" y2="18"/>' +
'<line x1="27.88" y1="49" x2="19.2" y2="54"/>' +
'<line x1="52.12" y1="49" x2="60.8" y2="54"/>' +
'<line x1="40" y1="28" x2="60.8" y2="30"/>' +
'<line x1="40" y1="28" x2="19.2" y2="30"/>' +
'<line x1="27.88" y1="49" x2="19.2" y2="30"/>' +
'<line x1="27.88" y1="49" x2="40" y2="66"/>' +
'<line x1="52.12" y1="49" x2="40" y2="66"/>' +
'<line x1="52.12" y1="49" x2="60.8" y2="30"/>' +
'</g></g>' +
'<text x="40" y="46" text-anchor="middle" ' +
'font-family="\'Space Grotesk\', sans-serif" font-weight="700" ' +
'font-size="9.5" fill="var(--ink)">20</text>' +
'</svg>',
adventurer:
'<svg class="m-adv" viewBox="0 0 80 95" aria-hidden="true">' +
'<path d="M40,9 L44.5,21 L43.5,58 L36.5,58 L35.5,21 Z" fill="var(--white)"/>' +
'<rect x="24" y="58" width="32" height="6" rx="2" fill="var(--yellow)"/>' +
'<rect x="36.5" y="64" width="7" height="14" fill="var(--ink)"/>' +
'<circle cx="40" cy="82" r="5" fill="var(--yellow)"/>' +
'</svg>',
completionist:
'<svg class="m-comp" viewBox="0 0 80 95" aria-hidden="true">' +
'<g stroke="var(--ink)" stroke-width="2" stroke-linejoin="round" fill="var(--yellow)">' +
'<path d="M19.2,54 L21.2,21 L30.9,44 L40,13 L49.1,44 L58.8,21 L60.8,54 Z"/>' +
'<rect x="17.9" y="54" width="44.2" height="10.4" rx="2.6"/>' +
'</g></svg>',
hacker:
'<svg class="m-hk" viewBox="0 0 80 95" aria-hidden="true">' +
'<defs><filter id="hkGlow" x="-60%" y="-60%" width="220%" height="220%">' +
'<feGaussianBlur stdDeviation="2.4" result="b"/>' +
'<feMerge><feMergeNode in="b"/><feMergeNode in="b"/>' +
'<feMergeNode in="SourceGraphic"/></feMerge>' +
'</filter></defs>' +
'<g filter="url(#hkGlow)">' +
'<path d="M25,31 L40,42 L25,53" fill="none" stroke="var(--green)" ' +
'stroke-width="5.5" stroke-linecap="square"/>' +
'<rect class="m-hk-cursor" x="45" y="50" width="13" height="5.5" fill="var(--green)"/>' +
'</g></svg>',
};
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 =
'<i class="fas fa-trophy" aria-hidden="true"></i><span class="achv-count"></span>';
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 <dialog>
// (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);
})();
+78
View File
@@ -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; });
});
})();
+14
View File
@@ -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');
});
})();
+19
View File
@@ -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);
});
});
})();
+252
View File
@@ -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();
});
});
})();
+19
View File
@@ -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();
});
})();
+44
View File
@@ -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)]);
})();
+14
View File
@@ -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');
});
});
})();
+20
View File
@@ -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 });
})();
+59
View File
@@ -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`;
// FisherYates 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; });
});
})();
+16
View File
@@ -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');
});
})();
+1024
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -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 });
})();
+85
View File
@@ -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 = `<span>${list[target]}</span>`;
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 = `<span>${list[target]}</span>`;
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; });
});
})();
+126
View File
@@ -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);
})();
+734
View File
@@ -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 <file>', 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] <file>', 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);
})();
+43
View File
@@ -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();
});
})();
+232
View File
@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script>
/* No quest without embarking (URL-guessers go home), and no quest for the
deleted website while the website isn't deleted */
try {
if (localStorage.getItem('questStarted') !== '1' ||
!localStorage.getItem('siteDeleted')) location.replace('/');
} catch (e) { location.replace('/'); }
</script>
<title>The Quest for the Deleted Website</title>
<link rel="icon" href="goat.png">
<link rel="stylesheet" href="/fonts/fonts.css">
<link rel="stylesheet" href="/fa/css/all.min.css">
<script defer src="/js/quest.js"></script>
<style>
h1 { font-family: 'Kingdom', serif; font-size: 4.2em; font-weight: normal; }
/* Breathing room after the last paragraph. Padding rather than margin on
the last element: it only becomes visible once content overflows the
viewport, so short early-quest pages don't scroll */
body { padding-bottom: 4em; }
p { max-width: 46em; margin-left: auto; margin-right: auto; }
/* Centered as a block, but lines keep their columns: ascii art must not
have each line centered individually */
pre { display: inline-block; text-align: left; }
/* The figure is composed of bottom-aligned columns (hero, companion) so
art blocks of different heights stand on the same ground */
#figure .fig { display: inline-block; vertical-align: bottom; text-align: left; }
#figure .fig + .fig { margin-left: 2ch; }
/* Weapon icons live inside the ascii art: the .wpn cell keeps the pre's
monospace font, so 2ch is exactly two art columns; the icon inside
carries its own font. On the viewer-left side the cell is mirrored so
the icon faces away from the body */
.wpn { display: inline-block; width: 2ch; text-align: center; }
.wpn-flip { transform: scaleX(-1); }
.wpn .fa-solid, .wpn .wpn-em { font-size: .8em; }
.wpn-em { font-family: 'Noto Emoji'; line-height: 1; }
/* Glyphs drawn tip-down get stood on their hilt instead (inner element,
so it composes with the cell's mirror flip) */
.wpn-rot { display: inline-block; transform: rotate(180deg); }
</style>
</head>
<body>
<center>
<h1>The Quest for the Deleted Website</h1>
<form id="naming">
<label for="heroName">Name your hero</label>
<input id="heroName" maxlength="40" autocomplete="off">
<input type="submit" value="Begin quest">
</form>
<div id="story" style="display:none">
<pre id="figure">
o
/|\
/ \
</pre>
<p id="intro"></p>
<p id="setout" style="display:none"></p>
<p id="traverse" style="display:none"></p>
<div id="grpTroll">
<p id="enc1" style="display:none"></p>
<p id="enc1riddle" style="display:none"></p>
<p id="enc1attack" style="display:none"></p>
<p id="enc1wrong" style="display:none"></p>
<p id="enc1correct" style="display:none"></p>
</div>
<div id="grpKid">
<p id="enc2" style="display:none"></p>
<p id="enc2yes" style="display:none"></p>
<p id="enc2no" style="display:none"></p>
<p id="kidrename" style="display:none"></p>
</div>
<div id="grpMimic">
<p id="enc3" style="display:none"></p>
<p id="enc3pry" style="display:none"></p>
<p id="enc3leave" style="display:none"></p>
</div>
<div id="grpPotion">
<p id="enc4" style="display:none"></p>
<p id="enc4steal" style="display:none"></p>
<p id="enc4explain" style="display:none"></p>
</div>
<div id="grpPrincess">
<p id="enc5" style="display:none"></p>
<p id="enc5decline" style="display:none"></p>
<p id="enc5agree" style="display:none"></p>
<p id="enc5vines" style="display:none"></p>
<p id="enc5door" style="display:none"></p>
<p id="princessrename" style="display:none"></p>
</div>
<p id="arrival" style="display:none"></p>
<p id="confront" style="display:none"></p>
<p id="ending" style="display:none"></p>
</div>
<div id="showdownAttack" style="display:none">
<p><button type="button">Attack</button></p>
</div>
<div id="endWin" style="display:none">
<p><button type="button">Return to gade.gg</button></p>
</div>
<div id="endRestart" style="display:none">
<p><button type="button">Restart quest</button></p>
</div>
<form id="vnaming" style="display:none">
<label for="villainName">Name the villain</label>
<input id="villainName" maxlength="40" autocomplete="off">
<input type="submit" value="Confirm">
</form>
<form id="vpronouns" style="display:none">
<p>Choose the villain's pronouns</p>
<p>
<button type="button" data-preset="she">She/her</button>
<button type="button" data-preset="he">He/him</button>
<button type="button" data-preset="they">They/them</button>
<button type="button" id="vcustomBtn">Custom</button>
</p>
<table id="vcustom" style="display:none">
<tr><td align="right">Subjective</td>
<td><input id="vpnSubj" placeholder="she / he / they" autocomplete="off"></td></tr>
<tr><td align="right">Objective</td>
<td><input id="vpnObj" placeholder="her / him / them" autocomplete="off"></td></tr>
<tr><td align="right">Possessive</td>
<td><input id="vpnPosd" placeholder="her / his / their" autocomplete="off"></td></tr>
<tr><td align="right">Reflexive</td>
<td><input id="vpnRefl" placeholder="herself / himself / themself" autocomplete="off"></td></tr>
<tr><td></td><td><input type="submit" value="Confirm"></td></tr>
</table>
</form>
<div id="enc3choice" style="display:none">
<p>
<button type="button" data-act="pry" id="pryBtn"></button>
<button type="button" data-act="leave">Leave it</button>
</p>
</div>
<div id="enc4choice" style="display:none">
<p>
<button type="button" data-act="steal">Steal a potion</button>
<button type="button" data-act="explain">Explain your situation</button>
</p>
</div>
<div id="enc5choice" style="display:none">
<p>
<button type="button" data-act="agree">Agree</button>
<button type="button" data-act="decline">Decline</button>
</p>
</div>
<div id="enc5method" style="display:none">
<p>
<button type="button" data-act="vines">Climb up the vines on the side of the tower</button>
<button type="button" data-act="door">Walk in through the front door</button>
</p>
</div>
<div id="prename" style="display:none">
<p>Would you like to rename her?</p>
<p>
<button type="button" data-rename="yes">Yes, please</button>
<button type="button" data-rename="no">What? No! Why would I do that?</button>
</p>
<form id="prenameForm" style="display:none">
<input id="princessName" maxlength="40" autocomplete="off">
<input type="submit" value="Confirm">
</form>
</div>
<div id="enc2choice" style="display:none">
<p>
<button type="button" data-ans="yes">Yes, yes, yes</button>
<button type="button" data-ans="no">Yes, yes, no</button>
</p>
</div>
<div id="rename" style="display:none">
<p>Would you like to rename him?</p>
<p>
<button type="button" data-rename="yes">Yes, please</button>
<button type="button" data-rename="no">What? No! Why would I do that?</button>
</p>
<form id="renameForm" style="display:none">
<input id="kidName" maxlength="40" autocomplete="off">
<input type="submit" value="Confirm">
</form>
</div>
<div id="enc1choice" style="display:none">
<p>
<button type="button" data-act="attack">Attack</button>
<button type="button" data-act="riddle">Agree to the terms</button>
</p>
</div>
<form id="riddle" style="display:none">
<input id="riddleAnswer" maxlength="40" autocomplete="off">
<input type="submit" value="Answer">
<button type="button" id="riddleAttack">Attack</button>
</form>
<form id="pronouns" style="display:none">
<p>Choose your hero's pronouns</p>
<p>
<button type="button" data-preset="she">She/her</button>
<button type="button" data-preset="he">He/him</button>
<button type="button" data-preset="they">They/them</button>
<button type="button" id="customBtn">Custom</button>
</p>
<table id="custom" style="display:none">
<tr><td align="right">Subjective</td>
<td><input id="pnSubj" placeholder="she / he / they" autocomplete="off"></td></tr>
<tr><td align="right">Objective</td>
<td><input id="pnObj" placeholder="her / him / them" autocomplete="off"></td></tr>
<tr><td align="right">Possessive</td>
<td><input id="pnPosd" placeholder="her / his / their" autocomplete="off"></td></tr>
<tr><td align="right">Reflexive</td>
<td><input id="pnRefl" placeholder="herself / himself / themself" autocomplete="off"></td></tr>
<tr><td></td><td><input type="submit" value="Confirm"></td></tr>
</table>
</form>
<div id="weapon" style="display:none">
<p>Choose your hero's preferred weapon</p>
<p>
<button type="button" data-weapon="sword">Sword</button>
<button type="button" data-weapon="axe">Axe</button>
<button type="button" data-weapon="wand">Wand</button>
<button type="button" data-weapon="gun">Gun</button>
</p>
</div>
<div id="hand" style="display:none">
<p>Is your hero left-handed or right-handed?</p>
<p>
<button type="button" data-hand="left">Left-handed</button>
<button type="button" data-hand="right">Right-handed</button>
</p>
</div>
</center>
</body>
</html>
-776
View File
@@ -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`;
// FisherYates 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 = `<span>${list[target]}</span>`;
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 = `<span>${list[target]}</span>`;
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)]);
})();
+343 -5
View File
@@ -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; }
}