🐐
This commit is contained in:
@@ -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);
|
||||
})();
|
||||
@@ -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; });
|
||||
});
|
||||
})();
|
||||
@@ -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');
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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
@@ -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)]);
|
||||
})();
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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
@@ -0,0 +1,59 @@
|
||||
// ── Pepsi gallery: shuffled on load, swipe cycles through on click ─
|
||||
(function () {
|
||||
const COUNT = 20;
|
||||
const img = document.querySelector('.pepsi-placeholder img');
|
||||
if (!img) return;
|
||||
const box = img.parentElement;
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const src = (n) => `Pepsi/Pepsi-${n}.jpg`;
|
||||
|
||||
// Fisher–Yates shuffle of [1..COUNT]; clicks cycle through this order
|
||||
const order = Array.from({ length: COUNT }, (_, i) => i + 1);
|
||||
for (let i = order.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[order[i], order[j]] = [order[j], order[i]];
|
||||
}
|
||||
let pos = 0;
|
||||
img.src = src(order[pos]);
|
||||
const seen = (n) => { if (window.achv) window.achv.pepsi(n, COUNT); };
|
||||
seen(order[pos]);
|
||||
|
||||
let busy = false;
|
||||
box.addEventListener('click', () => {
|
||||
if (busy) return;
|
||||
if (window.achv) window.achv.mark('pepsi');
|
||||
pos = (pos + 1) % order.length;
|
||||
const next = order[pos];
|
||||
|
||||
if (reduceMotion.matches) {
|
||||
img.src = src(next);
|
||||
seen(next);
|
||||
return;
|
||||
}
|
||||
|
||||
busy = true;
|
||||
const incoming = new Image();
|
||||
incoming.src = src(next);
|
||||
incoming.alt = img.alt;
|
||||
incoming.draggable = false;
|
||||
incoming.className = 'pepsi-incoming';
|
||||
// Decode before animating so the swipe never reveals a half-loaded frame
|
||||
incoming.decode().then(() => {
|
||||
box.appendChild(incoming);
|
||||
seen(next);
|
||||
const opts = { duration: 300, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' };
|
||||
img.animate(
|
||||
[{ transform: 'translateX(0)' }, { transform: 'translateX(-100%)' }],
|
||||
opts
|
||||
);
|
||||
incoming.animate(
|
||||
[{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }],
|
||||
opts
|
||||
).finished.then(() => {
|
||||
img.src = incoming.src; // already decoded, swaps instantly
|
||||
incoming.remove();
|
||||
busy = false;
|
||||
});
|
||||
}).catch(() => { busy = false; });
|
||||
});
|
||||
})();
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 });
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
})();
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user