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