🐐
@@ -1 +1,3 @@
|
|||||||
.vscode/
|
.vscode/
|
||||||
|
fonts/Kingdom_v1.4.ttf
|
||||||
|
Pepsi-originals/
|
||||||
|
|||||||
@@ -3,20 +3,30 @@
|
|||||||
<head><title>404 Not Found</title></head>
|
<head><title>404 Not Found</title></head>
|
||||||
<body>
|
<body>
|
||||||
<center><h1>404 Not Found</h1></center>
|
<center><h1>404 Not Found</h1></center>
|
||||||
<hr><center>The page has been deleted</center>
|
<hr><center id="msg">The page has been deleted.</center>
|
||||||
<center id="undo" style="display:none"><br><a href="/">Restore website</a></center>
|
<center id="undo" style="display:none"><br><a href="/">Restore website</a></center>
|
||||||
|
<center id="count" style="display:none"><br><font size="7">10</font></center>
|
||||||
|
<center id="quest" style="display:none"><br><button>Embark on a quest</button></center>
|
||||||
<script>
|
<script>
|
||||||
/* Restore hatch: clicking the heading undeletes the website */
|
/* Restore hatch: clicking the heading undeletes the website. Undeleting
|
||||||
|
also revokes the quest (no quest for the deleted website if the website
|
||||||
|
isn't deleted) — but the save file survives for the next deletion */
|
||||||
function restore() {
|
function restore() {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem('siteDeleted');
|
localStorage.removeItem('siteDeleted');
|
||||||
localStorage.removeItem('siteDeletedViews');
|
localStorage.removeItem('siteDeletedViews');
|
||||||
|
localStorage.removeItem('questStarted');
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
document.querySelector('h1').addEventListener('click', function () {
|
document.querySelector('h1').addEventListener('click', function () {
|
||||||
restore();
|
restore();
|
||||||
location.replace('/');
|
location.replace('/');
|
||||||
});
|
});
|
||||||
|
/* Embarking unlocks quest.html; the page bounces visitors who never did */
|
||||||
|
document.querySelector('#quest button').addEventListener('click', function () {
|
||||||
|
try { localStorage.setItem('questStarted', '1'); } catch (e) {}
|
||||||
|
location.href = '/quest.html';
|
||||||
|
});
|
||||||
/* Restoring is symmetric with deleting: it needs a confirmation too */
|
/* Restoring is symmetric with deleting: it needs a confirmation too */
|
||||||
document.querySelector('#undo a').addEventListener('click', function (e) {
|
document.querySelector('#undo a').addEventListener('click', function (e) {
|
||||||
if (!confirm('Are you sure? This will restore the entire website.')) {
|
if (!confirm('Are you sure? This will restore the entire website.')) {
|
||||||
@@ -26,12 +36,31 @@ document.querySelector('#undo a').addEventListener('click', function (e) {
|
|||||||
restore();
|
restore();
|
||||||
});
|
});
|
||||||
/* View 1 is the deletion, view 2 is the disbelieving reload (nothing
|
/* View 1 is the deletion, view 2 is the disbelieving reload (nothing
|
||||||
changes; that's the point), view 3 gets the way out */
|
changes; that's the point), view 3 gets the way out. Views 4-9 escalate
|
||||||
|
for anyone who keeps reloading past the exit; view 10 drops the act and
|
||||||
|
starts a countdown from 10 (one step per reload); at zero (view 20) the
|
||||||
|
countdown gives way to the quest */
|
||||||
|
var MSGS = {
|
||||||
|
4: 'The page has still been deleted.',
|
||||||
|
5: 'The page has been deleted for all eternity.',
|
||||||
|
6: 'The page has been deleted. If only there was a way to restore it.',
|
||||||
|
7: "The page has been deleted. Click the 'Restore website' button to restore it.",
|
||||||
|
8: "The page has been deleted. Click the 'Restore website' button to restore it.",
|
||||||
|
9: "The page has been deleted. Click the 'Restore website' button to restore it."
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
if (localStorage.getItem('siteDeleted')) {
|
if (localStorage.getItem('siteDeleted')) {
|
||||||
var views = 1 + (parseInt(localStorage.getItem('siteDeletedViews'), 10) || 0);
|
var views = 1 + (parseInt(localStorage.getItem('siteDeletedViews'), 10) || 0);
|
||||||
localStorage.setItem('siteDeletedViews', views);
|
localStorage.setItem('siteDeletedViews', views);
|
||||||
if (views > 2) document.getElementById('undo').style.display = '';
|
if (views > 2) document.getElementById('undo').style.display = '';
|
||||||
|
if (MSGS[views]) document.getElementById('msg').textContent = MSGS[views];
|
||||||
|
if (views >= 20) {
|
||||||
|
document.getElementById('quest').style.display = '';
|
||||||
|
} else if (views >= 10) {
|
||||||
|
var count = document.getElementById('count');
|
||||||
|
count.style.display = '';
|
||||||
|
count.querySelector('font').textContent = 20 - views;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 469 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 458 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 581 KiB After Width: | Height: | Size: 374 KiB |
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 356 KiB After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 724 KiB After Width: | Height: | Size: 451 KiB |
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 174 KiB |
@@ -9,3 +9,11 @@
|
|||||||
font-family: 'DM Mono'; font-style: normal; font-weight: 500; font-display: swap;
|
font-family: 'DM Mono'; font-style: normal; font-weight: 500; font-display: swap;
|
||||||
src: url('/fonts/dm-mono-500.woff2') format('woff2');
|
src: url('/fonts/dm-mono-500.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Kingdom'; font-style: normal; font-weight: 400; font-display: swap;
|
||||||
|
src: url('/fonts/kingdom.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Noto Emoji'; font-style: normal; font-weight: 400; font-display: block;
|
||||||
|
src: url('/fonts/noto-emoji.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<li><a href="#education" class="nav-link">Education</a></li>
|
<li><a href="#education" class="nav-link">Education</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- Scroll hint: shown by script.js after idling at the top -->
|
<!-- Scroll hint: shown by js/scroll-hint.js after idling at the top -->
|
||||||
<a href="#about" class="scroll-hint" id="scrollHint" aria-label="Scroll to the About section">
|
<a href="#about" class="scroll-hint" id="scrollHint" aria-label="Scroll to the About section">
|
||||||
<i class="fas fa-chevron-down" aria-hidden="true"></i>
|
<i class="fas fa-chevron-down" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-body">
|
<div class="project-body">
|
||||||
<p>An esoteric programming language designed to be usable by an ancient Roman. The letters W, J, and U are banned (non-classical Latin), all numbers must be written as Roman numerals, and every keyword is in Latin: <code>DEFINI</code> for function definitions, <code>INVOCA</code> to call them, <code>DICE</code> for output, <code>AVDI</code> for input, <code>SI</code>/<code>TVNC</code> for conditionals, and so on.</p>
|
<p>An esoteric programming language designed to be usable by an ancient Roman. The letters W, J, and U are banned (non-classical Latin), all numbers must be written as Roman numerals, and every keyword is in Latin: <code>DEFINI</code> for function definitions, <code>INVOCA</code> to call them, <code>DIC</code> for output, <code>AVDI</code> for input, <code>SI</code>/<code>TVNC</code> for conditionals, and so on.</p>
|
||||||
<p>The language includes a tree-walking interpreter and a compiler that targets C. Optional modules extend the standard library with <code>FORS</code> (random numbers), <code>FRACTIO</code> (base-12 fractions for floats), <code>MAGNVM</code> (higher numbers than standard roman numerals), and <code>SVBNVLLA</code> (negative numbers).</p>
|
<p>The language includes a tree-walking interpreter and a compiler that targets C. Optional modules extend the standard library with <code>FORS</code> (random numbers), <code>FRACTIO</code> (base-12 fractions for floats), <code>MAGNVM</code> (higher numbers than standard roman numerals), and <code>SVBNVLLA</code> (negative numbers).</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-tags">
|
<div class="project-tags">
|
||||||
@@ -392,6 +392,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<script src="script.js" defer></script>
|
<!-- achievements.js first: the other files call its window.achv API -->
|
||||||
|
<script src="js/achievements.js" defer></script>
|
||||||
|
<script src="js/sprinkles.js" defer></script>
|
||||||
|
<script src="js/nav.js" defer></script>
|
||||||
|
<script src="js/burger.js" defer></script>
|
||||||
|
<script src="js/avatar-spin.js" defer></script>
|
||||||
|
<script src="js/tooltips.js" defer></script>
|
||||||
|
<script src="js/pepsi.js" defer></script>
|
||||||
|
<script src="js/scroll-hint.js" defer></script>
|
||||||
|
<script src="js/slot-machine.js" defer></script>
|
||||||
|
<script src="js/pronouns.js" defer></script>
|
||||||
|
<script src="js/delete-website.js" defer></script>
|
||||||
|
<script src="js/icon-spin.js" defer></script>
|
||||||
|
<script src="js/d20.js" defer></script>
|
||||||
|
<script src="js/anagram.js" defer></script>
|
||||||
|
<script src="js/goat.js" defer></script>
|
||||||
|
<script src="js/terminal.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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 });
|
||||||
|
})();
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<script>
|
||||||
|
/* No quest without embarking (URL-guessers go home), and no quest for the
|
||||||
|
deleted website while the website isn't deleted */
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('questStarted') !== '1' ||
|
||||||
|
!localStorage.getItem('siteDeleted')) location.replace('/');
|
||||||
|
} catch (e) { location.replace('/'); }
|
||||||
|
</script>
|
||||||
|
<title>The Quest for the Deleted Website</title>
|
||||||
|
<link rel="icon" href="goat.png">
|
||||||
|
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||||
|
<link rel="stylesheet" href="/fa/css/all.min.css">
|
||||||
|
<script defer src="/js/quest.js"></script>
|
||||||
|
<style>
|
||||||
|
h1 { font-family: 'Kingdom', serif; font-size: 4.2em; font-weight: normal; }
|
||||||
|
/* Breathing room after the last paragraph. Padding rather than margin on
|
||||||
|
the last element: it only becomes visible once content overflows the
|
||||||
|
viewport, so short early-quest pages don't scroll */
|
||||||
|
body { padding-bottom: 4em; }
|
||||||
|
p { max-width: 46em; margin-left: auto; margin-right: auto; }
|
||||||
|
/* Centered as a block, but lines keep their columns: ascii art must not
|
||||||
|
have each line centered individually */
|
||||||
|
pre { display: inline-block; text-align: left; }
|
||||||
|
/* The figure is composed of bottom-aligned columns (hero, companion) so
|
||||||
|
art blocks of different heights stand on the same ground */
|
||||||
|
#figure .fig { display: inline-block; vertical-align: bottom; text-align: left; }
|
||||||
|
#figure .fig + .fig { margin-left: 2ch; }
|
||||||
|
/* Weapon icons live inside the ascii art: the .wpn cell keeps the pre's
|
||||||
|
monospace font, so 2ch is exactly two art columns; the icon inside
|
||||||
|
carries its own font. On the viewer-left side the cell is mirrored so
|
||||||
|
the icon faces away from the body */
|
||||||
|
.wpn { display: inline-block; width: 2ch; text-align: center; }
|
||||||
|
.wpn-flip { transform: scaleX(-1); }
|
||||||
|
.wpn .fa-solid, .wpn .wpn-em { font-size: .8em; }
|
||||||
|
.wpn-em { font-family: 'Noto Emoji'; line-height: 1; }
|
||||||
|
/* Glyphs drawn tip-down get stood on their hilt instead (inner element,
|
||||||
|
so it composes with the cell's mirror flip) */
|
||||||
|
.wpn-rot { display: inline-block; transform: rotate(180deg); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<center>
|
||||||
|
<h1>The Quest for the Deleted Website</h1>
|
||||||
|
<form id="naming">
|
||||||
|
<label for="heroName">Name your hero</label>
|
||||||
|
<input id="heroName" maxlength="40" autocomplete="off">
|
||||||
|
<input type="submit" value="Begin quest">
|
||||||
|
</form>
|
||||||
|
<div id="story" style="display:none">
|
||||||
|
<pre id="figure">
|
||||||
|
o
|
||||||
|
/|\
|
||||||
|
/ \
|
||||||
|
</pre>
|
||||||
|
<p id="intro"></p>
|
||||||
|
<p id="setout" style="display:none"></p>
|
||||||
|
<p id="traverse" style="display:none"></p>
|
||||||
|
<div id="grpTroll">
|
||||||
|
<p id="enc1" style="display:none"></p>
|
||||||
|
<p id="enc1riddle" style="display:none"></p>
|
||||||
|
<p id="enc1attack" style="display:none"></p>
|
||||||
|
<p id="enc1wrong" style="display:none"></p>
|
||||||
|
<p id="enc1correct" style="display:none"></p>
|
||||||
|
</div>
|
||||||
|
<div id="grpKid">
|
||||||
|
<p id="enc2" style="display:none"></p>
|
||||||
|
<p id="enc2yes" style="display:none"></p>
|
||||||
|
<p id="enc2no" style="display:none"></p>
|
||||||
|
<p id="kidrename" style="display:none"></p>
|
||||||
|
</div>
|
||||||
|
<div id="grpMimic">
|
||||||
|
<p id="enc3" style="display:none"></p>
|
||||||
|
<p id="enc3pry" style="display:none"></p>
|
||||||
|
<p id="enc3leave" style="display:none"></p>
|
||||||
|
</div>
|
||||||
|
<div id="grpPotion">
|
||||||
|
<p id="enc4" style="display:none"></p>
|
||||||
|
<p id="enc4steal" style="display:none"></p>
|
||||||
|
<p id="enc4explain" style="display:none"></p>
|
||||||
|
</div>
|
||||||
|
<div id="grpPrincess">
|
||||||
|
<p id="enc5" style="display:none"></p>
|
||||||
|
<p id="enc5decline" style="display:none"></p>
|
||||||
|
<p id="enc5agree" style="display:none"></p>
|
||||||
|
<p id="enc5vines" style="display:none"></p>
|
||||||
|
<p id="enc5door" style="display:none"></p>
|
||||||
|
<p id="princessrename" style="display:none"></p>
|
||||||
|
</div>
|
||||||
|
<p id="arrival" style="display:none"></p>
|
||||||
|
<p id="confront" style="display:none"></p>
|
||||||
|
<p id="ending" style="display:none"></p>
|
||||||
|
</div>
|
||||||
|
<div id="showdownAttack" style="display:none">
|
||||||
|
<p><button type="button">Attack</button></p>
|
||||||
|
</div>
|
||||||
|
<div id="endWin" style="display:none">
|
||||||
|
<p><button type="button">Return to gade.gg</button></p>
|
||||||
|
</div>
|
||||||
|
<div id="endRestart" style="display:none">
|
||||||
|
<p><button type="button">Restart quest</button></p>
|
||||||
|
</div>
|
||||||
|
<form id="vnaming" style="display:none">
|
||||||
|
<label for="villainName">Name the villain</label>
|
||||||
|
<input id="villainName" maxlength="40" autocomplete="off">
|
||||||
|
<input type="submit" value="Confirm">
|
||||||
|
</form>
|
||||||
|
<form id="vpronouns" style="display:none">
|
||||||
|
<p>Choose the villain's pronouns</p>
|
||||||
|
<p>
|
||||||
|
<button type="button" data-preset="she">She/her</button>
|
||||||
|
<button type="button" data-preset="he">He/him</button>
|
||||||
|
<button type="button" data-preset="they">They/them</button>
|
||||||
|
<button type="button" id="vcustomBtn">Custom</button>
|
||||||
|
</p>
|
||||||
|
<table id="vcustom" style="display:none">
|
||||||
|
<tr><td align="right">Subjective</td>
|
||||||
|
<td><input id="vpnSubj" placeholder="she / he / they" autocomplete="off"></td></tr>
|
||||||
|
<tr><td align="right">Objective</td>
|
||||||
|
<td><input id="vpnObj" placeholder="her / him / them" autocomplete="off"></td></tr>
|
||||||
|
<tr><td align="right">Possessive</td>
|
||||||
|
<td><input id="vpnPosd" placeholder="her / his / their" autocomplete="off"></td></tr>
|
||||||
|
<tr><td align="right">Reflexive</td>
|
||||||
|
<td><input id="vpnRefl" placeholder="herself / himself / themself" autocomplete="off"></td></tr>
|
||||||
|
<tr><td></td><td><input type="submit" value="Confirm"></td></tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
<div id="enc3choice" style="display:none">
|
||||||
|
<p>
|
||||||
|
<button type="button" data-act="pry" id="pryBtn"></button>
|
||||||
|
<button type="button" data-act="leave">Leave it</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="enc4choice" style="display:none">
|
||||||
|
<p>
|
||||||
|
<button type="button" data-act="steal">Steal a potion</button>
|
||||||
|
<button type="button" data-act="explain">Explain your situation</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="enc5choice" style="display:none">
|
||||||
|
<p>
|
||||||
|
<button type="button" data-act="agree">Agree</button>
|
||||||
|
<button type="button" data-act="decline">Decline</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="enc5method" style="display:none">
|
||||||
|
<p>
|
||||||
|
<button type="button" data-act="vines">Climb up the vines on the side of the tower</button>
|
||||||
|
<button type="button" data-act="door">Walk in through the front door</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="prename" style="display:none">
|
||||||
|
<p>Would you like to rename her?</p>
|
||||||
|
<p>
|
||||||
|
<button type="button" data-rename="yes">Yes, please</button>
|
||||||
|
<button type="button" data-rename="no">What? No! Why would I do that?</button>
|
||||||
|
</p>
|
||||||
|
<form id="prenameForm" style="display:none">
|
||||||
|
<input id="princessName" maxlength="40" autocomplete="off">
|
||||||
|
<input type="submit" value="Confirm">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="enc2choice" style="display:none">
|
||||||
|
<p>
|
||||||
|
<button type="button" data-ans="yes">Yes, yes, yes</button>
|
||||||
|
<button type="button" data-ans="no">Yes, yes, no</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="rename" style="display:none">
|
||||||
|
<p>Would you like to rename him?</p>
|
||||||
|
<p>
|
||||||
|
<button type="button" data-rename="yes">Yes, please</button>
|
||||||
|
<button type="button" data-rename="no">What? No! Why would I do that?</button>
|
||||||
|
</p>
|
||||||
|
<form id="renameForm" style="display:none">
|
||||||
|
<input id="kidName" maxlength="40" autocomplete="off">
|
||||||
|
<input type="submit" value="Confirm">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="enc1choice" style="display:none">
|
||||||
|
<p>
|
||||||
|
<button type="button" data-act="attack">Attack</button>
|
||||||
|
<button type="button" data-act="riddle">Agree to the terms</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form id="riddle" style="display:none">
|
||||||
|
<input id="riddleAnswer" maxlength="40" autocomplete="off">
|
||||||
|
<input type="submit" value="Answer">
|
||||||
|
<button type="button" id="riddleAttack">Attack</button>
|
||||||
|
</form>
|
||||||
|
<form id="pronouns" style="display:none">
|
||||||
|
<p>Choose your hero's pronouns</p>
|
||||||
|
<p>
|
||||||
|
<button type="button" data-preset="she">She/her</button>
|
||||||
|
<button type="button" data-preset="he">He/him</button>
|
||||||
|
<button type="button" data-preset="they">They/them</button>
|
||||||
|
<button type="button" id="customBtn">Custom</button>
|
||||||
|
</p>
|
||||||
|
<table id="custom" style="display:none">
|
||||||
|
<tr><td align="right">Subjective</td>
|
||||||
|
<td><input id="pnSubj" placeholder="she / he / they" autocomplete="off"></td></tr>
|
||||||
|
<tr><td align="right">Objective</td>
|
||||||
|
<td><input id="pnObj" placeholder="her / him / them" autocomplete="off"></td></tr>
|
||||||
|
<tr><td align="right">Possessive</td>
|
||||||
|
<td><input id="pnPosd" placeholder="her / his / their" autocomplete="off"></td></tr>
|
||||||
|
<tr><td align="right">Reflexive</td>
|
||||||
|
<td><input id="pnRefl" placeholder="herself / himself / themself" autocomplete="off"></td></tr>
|
||||||
|
<tr><td></td><td><input type="submit" value="Confirm"></td></tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
<div id="weapon" style="display:none">
|
||||||
|
<p>Choose your hero's preferred weapon</p>
|
||||||
|
<p>
|
||||||
|
<button type="button" data-weapon="sword">Sword</button>
|
||||||
|
<button type="button" data-weapon="axe">Axe</button>
|
||||||
|
<button type="button" data-weapon="wand">Wand</button>
|
||||||
|
<button type="button" data-weapon="gun">Gun</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div id="hand" style="display:none">
|
||||||
|
<p>Is your hero left-handed or right-handed?</p>
|
||||||
|
<p>
|
||||||
|
<button type="button" data-hand="left">Left-handed</button>
|
||||||
|
<button type="button" data-hand="right">Right-handed</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,776 +0,0 @@
|
|||||||
// ── Sprinkle background ────────────────────────────
|
|
||||||
(function () {
|
|
||||||
const canvas = document.getElementById('bgCanvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
const COLORS = ['#ffd84d', '#8cbcff', '#ff9fca', '#a9e76c', '#b99cff', '#ff9d57'];
|
|
||||||
const INK = '#171717';
|
|
||||||
const COUNT = 1000; // number of sprinkles
|
|
||||||
const MIN_LEN = 12; // px
|
|
||||||
const MAX_LEN = 18; // px
|
|
||||||
const MIN_W = 4;
|
|
||||||
const MAX_W = 6;
|
|
||||||
|
|
||||||
let W, H, sprinkles;
|
|
||||||
|
|
||||||
function rand(a, b) { return a + Math.random() * (b - a); }
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
W = canvas.width = window.innerWidth;
|
|
||||||
H = canvas.height = window.innerHeight;
|
|
||||||
if (!sprinkles) init();
|
|
||||||
// Redistribute any sprinkles that landed outside new bounds
|
|
||||||
sprinkles.forEach(s => {
|
|
||||||
if (s.x > W) s.x = rand(0, W);
|
|
||||||
if (s.y > H) s.y = rand(0, H);
|
|
||||||
});
|
|
||||||
// Resizing clears the canvas; with no animation loop running, repaint it
|
|
||||||
if (reduceMotion.matches) {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
rafId = requestAnimationFrame(draw);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
sprinkles = [];
|
|
||||||
for (let i = 0; i < COUNT; i++) {
|
|
||||||
sprinkles.push(makeSprinkle());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeSprinkle() {
|
|
||||||
return {
|
|
||||||
x: rand(0, W),
|
|
||||||
y: rand(0, H),
|
|
||||||
len: rand(MIN_LEN, MAX_LEN),
|
|
||||||
width: rand(MIN_W, MAX_W),
|
|
||||||
color: COLORS[Math.floor(Math.random() * COLORS.length)],
|
|
||||||
angle: rand(0, Math.PI * 2),
|
|
||||||
// very slow drift
|
|
||||||
vx: rand(-0.1, 0.1),
|
|
||||||
vy: rand(-0.1, 0.1),
|
|
||||||
va: rand(-0.005, 0.005), // angular velocity
|
|
||||||
alpha: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw() {
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
|
||||||
|
|
||||||
sprinkles.forEach(s => {
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(s.x, s.y);
|
|
||||||
ctx.rotate(s.angle);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, -s.len / 2);
|
|
||||||
ctx.lineTo(0, s.len / 2);
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.globalAlpha = s.alpha;
|
|
||||||
// Ink outline under the colored fill (sticker look): same path, two strokes
|
|
||||||
ctx.strokeStyle = INK;
|
|
||||||
ctx.lineWidth = s.width + 3;
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.strokeStyle = s.color;
|
|
||||||
ctx.lineWidth = s.width;
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
// Reduced motion: sprinkles stay where they are
|
|
||||||
if (reduceMotion.matches) return;
|
|
||||||
|
|
||||||
// Drift
|
|
||||||
s.x += s.vx;
|
|
||||||
s.y += s.vy;
|
|
||||||
if (s.vx > 0.1 || s.vx < -0.1) {
|
|
||||||
s.vx *= 0.98;
|
|
||||||
}
|
|
||||||
if (s.vy > 0.1 || s.vy < -0.1) {
|
|
||||||
s.vy *= 0.98;
|
|
||||||
}
|
|
||||||
s.angle += s.va;
|
|
||||||
|
|
||||||
// Repel from cursor
|
|
||||||
const dx = s.x - mouseX;
|
|
||||||
const dy = s.y - mouseY;
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
const repelRadius = 80;
|
|
||||||
if (dist < repelRadius && dist > 0) {
|
|
||||||
const force = (repelRadius - dist) / repelRadius;
|
|
||||||
s.vx += (dx / dist) * force * 1.0;
|
|
||||||
s.vy += (dy / dist) * force * 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap: when a sprinkle drifts off the screen, reappear on the other side
|
|
||||||
if (s.y < -MAX_LEN) s.y = H + MAX_LEN;
|
|
||||||
if (s.y > H + MAX_LEN) s.y = -MAX_LEN
|
|
||||||
if (s.x < -MAX_LEN) s.x = W + MAX_LEN;
|
|
||||||
if (s.x > W + MAX_LEN) s.x = -MAX_LEN;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!reduceMotion.matches) rafId = requestAnimationFrame(draw);
|
|
||||||
}
|
|
||||||
|
|
||||||
// With reduced motion the sprinkles render once as a static backdrop
|
|
||||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
let rafId;
|
|
||||||
reduceMotion.addEventListener('change', () => {
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
rafId = requestAnimationFrame(draw);
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('resize', resize, { passive: true });
|
|
||||||
resize();
|
|
||||||
let mouseX = -9999, mouseY = -9999;
|
|
||||||
window.addEventListener('mousemove', (e) => {
|
|
||||||
mouseX = e.clientX;
|
|
||||||
mouseY = e.clientY;
|
|
||||||
}, { passive: true });
|
|
||||||
rafId = requestAnimationFrame(draw);
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── Nav: scroll state & active link ───────────────────────────
|
|
||||||
(function () {
|
|
||||||
const header = document.getElementById('header');
|
|
||||||
const links = document.querySelectorAll('.nav-link');
|
|
||||||
const sections = document.querySelectorAll('section[id]');
|
|
||||||
const burger = document.getElementById('burger');
|
|
||||||
|
|
||||||
window.addEventListener('scroll', () => {
|
|
||||||
header.classList.toggle('scrolled', window.scrollY > 24 || burger.classList.contains('open'));
|
|
||||||
|
|
||||||
let current = '';
|
|
||||||
sections.forEach(s => {
|
|
||||||
if (window.scrollY >= s.offsetTop - 100) current = s.id;
|
|
||||||
});
|
|
||||||
links.forEach(l => {
|
|
||||||
l.classList.toggle('active', l.getAttribute('href') === '#' + current);
|
|
||||||
});
|
|
||||||
}, { passive: true });
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── Mobile burger ──────────────────────────────────────────────
|
|
||||||
(function () {
|
|
||||||
const burger = document.getElementById('burger');
|
|
||||||
const navLinks = document.getElementById('navLinks');
|
|
||||||
const header = document.getElementById('header');
|
|
||||||
burger.addEventListener('click', () => {
|
|
||||||
burger.classList.toggle('open');
|
|
||||||
navLinks.classList.toggle('open');
|
|
||||||
header.classList.toggle('scrolled', burger.classList.contains('open') || window.scrollY > 24);
|
|
||||||
});
|
|
||||||
navLinks.querySelectorAll('a').forEach(a => {
|
|
||||||
a.addEventListener('click', () => {
|
|
||||||
burger.classList.remove('open');
|
|
||||||
navLinks.classList.remove('open');
|
|
||||||
header.classList.toggle('scrolled', window.scrollY > 24);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── Avatar spin on click ──────────────────────────────────────
|
|
||||||
(function () {
|
|
||||||
const avatar = document.querySelector('.avatar-ring');
|
|
||||||
|
|
||||||
avatar.addEventListener('click', () => {
|
|
||||||
if (avatar.classList.contains('spinning')) return;
|
|
||||||
avatar.classList.add('spinning');
|
|
||||||
});
|
|
||||||
avatar.addEventListener('animationend', () => {
|
|
||||||
avatar.classList.remove('spinning');
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── Tag pill tooltips: tap toggle (touch devices only) ────────
|
|
||||||
(function () {
|
|
||||||
// Hover-capable devices already get the tooltips via :hover
|
|
||||||
if (window.matchMedia('(hover: hover)').matches) return;
|
|
||||||
|
|
||||||
const pills = [...document.querySelectorAll('[data-tip]')];
|
|
||||||
|
|
||||||
function closeAll() {
|
|
||||||
pills.forEach(p => {
|
|
||||||
p.classList.remove('tip-open');
|
|
||||||
p.setAttribute('aria-expanded', 'false');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pills.forEach(pill => {
|
|
||||||
pill.setAttribute('tabindex', '0');
|
|
||||||
pill.setAttribute('role', 'button');
|
|
||||||
pill.setAttribute('aria-expanded', 'false');
|
|
||||||
// Pseudo-element tooltips are invisible to screen readers
|
|
||||||
pill.setAttribute('aria-description', pill.dataset.tip);
|
|
||||||
|
|
||||||
pill.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const wasOpen = pill.classList.contains('tip-open');
|
|
||||||
closeAll();
|
|
||||||
if (!wasOpen) {
|
|
||||||
pill.classList.add('tip-open');
|
|
||||||
pill.setAttribute('aria-expanded', 'true');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
pill.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
pill.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', closeAll);
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') closeAll();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── Pepsi gallery: shuffled on load, swipe cycles through on click ─
|
|
||||||
(function () {
|
|
||||||
const COUNT = 15;
|
|
||||||
const img = document.querySelector('.pepsi-placeholder img');
|
|
||||||
if (!img) return;
|
|
||||||
const box = img.parentElement;
|
|
||||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
const src = (n) => `Pepsi/Pepsi-${n}.jpg`;
|
|
||||||
|
|
||||||
// Fisher–Yates shuffle of [1..COUNT]; clicks cycle through this order
|
|
||||||
const order = Array.from({ length: COUNT }, (_, i) => i + 1);
|
|
||||||
for (let i = order.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[order[i], order[j]] = [order[j], order[i]];
|
|
||||||
}
|
|
||||||
let pos = 0;
|
|
||||||
img.src = src(order[pos]);
|
|
||||||
|
|
||||||
let busy = false;
|
|
||||||
box.addEventListener('click', () => {
|
|
||||||
if (busy) return;
|
|
||||||
pos = (pos + 1) % order.length;
|
|
||||||
const next = order[pos];
|
|
||||||
|
|
||||||
if (reduceMotion.matches) {
|
|
||||||
img.src = src(next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
busy = true;
|
|
||||||
const incoming = new Image();
|
|
||||||
incoming.src = src(next);
|
|
||||||
incoming.alt = img.alt;
|
|
||||||
incoming.draggable = false;
|
|
||||||
incoming.className = 'pepsi-incoming';
|
|
||||||
// Decode before animating so the swipe never reveals a half-loaded frame
|
|
||||||
incoming.decode().then(() => {
|
|
||||||
box.appendChild(incoming);
|
|
||||||
const opts = { duration: 300, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' };
|
|
||||||
img.animate(
|
|
||||||
[{ transform: 'translateX(0)' }, { transform: 'translateX(-100%)' }],
|
|
||||||
opts
|
|
||||||
);
|
|
||||||
incoming.animate(
|
|
||||||
[{ transform: 'translateX(100%)' }, { transform: 'translateX(0)' }],
|
|
||||||
opts
|
|
||||||
).finished.then(() => {
|
|
||||||
img.src = incoming.src; // already decoded, swaps instantly
|
|
||||||
incoming.remove();
|
|
||||||
busy = false;
|
|
||||||
});
|
|
||||||
}).catch(() => { busy = false; });
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── Scroll hint: appears after idling at the top of the page ──
|
|
||||||
(function () {
|
|
||||||
const hint = document.getElementById('scrollHint');
|
|
||||||
if (!hint) return;
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (window.scrollY <= 24) hint.classList.add('visible');
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
// Any scroll dismisses it for the rest of the visit
|
|
||||||
window.addEventListener('scroll', () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
hint.classList.remove('visible');
|
|
||||||
}, { passive: true, once: true });
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── Job title slot machine: click the role pill ───────────────
|
|
||||||
(function () {
|
|
||||||
const pill = document.getElementById('heroRole');
|
|
||||||
if (!pill) return;
|
|
||||||
|
|
||||||
const WORDS = [
|
|
||||||
['Software', 'Backend', 'Data', 'Computer', 'Frontend', 'Full-Stack', 'Tech'],
|
|
||||||
['Developer', 'Scientist', 'Engineer', 'Enthusiast', 'Wizard'],
|
|
||||||
];
|
|
||||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
const slots = [...pill.querySelectorAll('.slot')];
|
|
||||||
|
|
||||||
// Width of a word in this mono font: 1ch + letter-spacing per character.
|
|
||||||
// Slots hug their current word at rest and expand to their widest word
|
|
||||||
// while spinning (the CSS width transition animates both moves).
|
|
||||||
const widthFor = (word) => `calc(${word.length}ch + ${word.length * 0.08}em)`;
|
|
||||||
slots.forEach((slot) => {
|
|
||||||
slot.style.width = widthFor(slot.textContent.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
let busy = false;
|
|
||||||
pill.addEventListener('click', () => {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
|
|
||||||
const spins = slots.map((slot, i) => {
|
|
||||||
const list = WORDS[i];
|
|
||||||
const reel = slot.querySelector('.reel');
|
|
||||||
const from = Math.max(list.indexOf(reel.textContent.trim()), 0);
|
|
||||||
// Random pick that's never the word already showing
|
|
||||||
let target = Math.floor(Math.random() * (list.length - 1));
|
|
||||||
if (target >= from) target++;
|
|
||||||
|
|
||||||
if (reduceMotion.matches) {
|
|
||||||
reel.innerHTML = `<span>${list[target]}</span>`;
|
|
||||||
slot.style.width = widthFor(list[target]);
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand to fit the reel's widest word for the duration of the spin
|
|
||||||
const longest = list.reduce((a, b) => (b.length > a.length ? b : a));
|
|
||||||
slot.style.width = widthFor(longest);
|
|
||||||
|
|
||||||
// Walk the reel in list order: a couple of full revolutions, then
|
|
||||||
// land on the target (the second reel spins one revolution longer)
|
|
||||||
const steps = ((target - from + list.length) % list.length) + list.length * (2 + i);
|
|
||||||
|
|
||||||
// Build the strip top-down ending at the current word, so the column
|
|
||||||
// slides downward and words enter from above. One extra word sits
|
|
||||||
// above the target to back the overshoot.
|
|
||||||
reel.innerHTML = '';
|
|
||||||
for (let k = 0; k <= steps + 1; k++) {
|
|
||||||
const w = document.createElement('span');
|
|
||||||
w.textContent = list[(from + steps + 1 - k) % list.length];
|
|
||||||
reel.appendChild(w);
|
|
||||||
}
|
|
||||||
|
|
||||||
const h = reel.firstChild.getBoundingClientRect().height;
|
|
||||||
const land = -h; // row where the target word sits
|
|
||||||
const over = land + h * 0.35; // spin runs slightly past the stop
|
|
||||||
const anim = reel.animate(
|
|
||||||
[
|
|
||||||
{ transform: `translateY(${-(steps + 1) * h}px)` },
|
|
||||||
{ transform: `translateY(${over}px)` },
|
|
||||||
],
|
|
||||||
{ duration: 900 + i * 500, easing: 'cubic-bezier(0.15, 0.85, 0.25, 1)', fill: 'forwards' }
|
|
||||||
);
|
|
||||||
return anim.finished.then(() => {
|
|
||||||
const settle = reel.animate(
|
|
||||||
[{ transform: `translateY(${over}px)` }, { transform: `translateY(${land}px)` }],
|
|
||||||
{ duration: 200, easing: 'ease', fill: 'forwards' }
|
|
||||||
);
|
|
||||||
return settle.finished.then(() => {
|
|
||||||
reel.innerHTML = `<span>${list[target]}</span>`;
|
|
||||||
settle.cancel();
|
|
||||||
anim.cancel(); // drop the forward-filled transforms after the swap
|
|
||||||
slot.style.width = widthFor(list[target]); // shrink back to fit
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Promise.all(spins).finally(() => { busy = false; });
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── Pronoun pill: non-binary flag sweep on click ──────────────
|
|
||||||
(function () {
|
|
||||||
const pill = document.querySelector('.hero-pronouns');
|
|
||||||
if (!pill) return;
|
|
||||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
|
|
||||||
pill.addEventListener('click', () => {
|
|
||||||
if (reduceMotion.matches) return;
|
|
||||||
if (pill.classList.contains('waving')) return;
|
|
||||||
pill.classList.add('waving');
|
|
||||||
});
|
|
||||||
pill.addEventListener('animationend', () => {
|
|
||||||
pill.classList.remove('waving');
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── Delete website (footer): flag in localStorage, serve the 404 ─
|
|
||||||
(function () {
|
|
||||||
const button = document.getElementById('deleteSite');
|
|
||||||
const dialog = document.getElementById('deleteDialog');
|
|
||||||
const confirm = document.getElementById('deleteConfirm');
|
|
||||||
const cancel = document.getElementById('deleteCancel');
|
|
||||||
if (!button || !dialog) return;
|
|
||||||
|
|
||||||
button.addEventListener('click', () => dialog.showModal());
|
|
||||||
cancel.addEventListener('click', () => dialog.close());
|
|
||||||
confirm.addEventListener('click', () => {
|
|
||||||
try { localStorage.setItem('siteDeleted', '1'); } catch (e) {}
|
|
||||||
location.replace('404.html');
|
|
||||||
});
|
|
||||||
// Click on the backdrop closes too
|
|
||||||
dialog.addEventListener('click', (e) => {
|
|
||||||
if (e.target === dialog) dialog.close();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── Icon circles spin on click, same move as the avatar ───────
|
|
||||||
(function () {
|
|
||||||
const icons = document.querySelectorAll('.skill-card-icon, .project-icon, .edu-icon, .nav-logo');
|
|
||||||
icons.forEach((icon) => {
|
|
||||||
icon.addEventListener('click', () => {
|
|
||||||
if (icon.classList.contains('spinning')) return;
|
|
||||||
icon.classList.add('spinning');
|
|
||||||
});
|
|
||||||
icon.addEventListener('animationend', () => {
|
|
||||||
icon.classList.remove('spinning');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── D20 on "Dungeons & Dragons": a 3D die built from matrix3d ─
|
|
||||||
(function () {
|
|
||||||
const anchor = document.querySelector('.dnd-roll');
|
|
||||||
if (!anchor) return;
|
|
||||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
|
|
||||||
// Icosahedron in CSS coordinates (x right, y down, z toward viewer)
|
|
||||||
const R = 44; // circumradius, px
|
|
||||||
const PHI = (1 + Math.sqrt(5)) / 2;
|
|
||||||
const RAW = [
|
|
||||||
[-1, PHI, 0], [1, PHI, 0], [-1, -PHI, 0], [1, -PHI, 0],
|
|
||||||
[0, -1, PHI], [0, 1, PHI], [0, -1, -PHI], [0, 1, -PHI],
|
|
||||||
[PHI, 0, -1], [PHI, 0, 1], [-PHI, 0, -1], [-PHI, 0, 1],
|
|
||||||
];
|
|
||||||
const FACES = [
|
|
||||||
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
|
|
||||||
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
|
|
||||||
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
|
|
||||||
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
|
|
||||||
];
|
|
||||||
// The standard d20 layout (per Alea Kybos' dice configuration catalog),
|
|
||||||
// embedded onto the face list above: 20 bordered by 2/8/14, opposite
|
|
||||||
// faces sum to 21, evens on 20's hemisphere and odds on 1's
|
|
||||||
const NUMBERS = [20, 2, 12, 10, 8, 18, 14, 16, 15, 17, 11, 9, 19, 1, 13, 6, 4, 3, 7, 5];
|
|
||||||
// Theme pastels the die can be cast from, one picked per roll
|
|
||||||
const PASTELS = [
|
|
||||||
[255, 216, 77], [140, 188, 255], [255, 159, 202],
|
|
||||||
[169, 231, 108], [185, 156, 255], [255, 157, 87],
|
|
||||||
];
|
|
||||||
const scale = R / Math.hypot(1, PHI);
|
|
||||||
const V = RAW.map(([x, y, z]) => [x * scale, -y * scale, z * scale]);
|
|
||||||
|
|
||||||
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
||||||
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
||||||
const cross = (a, b) => [
|
|
||||||
a[1] * b[2] - a[2] * b[1],
|
|
||||||
a[2] * b[0] - a[0] * b[2],
|
|
||||||
a[0] * b[1] - a[1] * b[0],
|
|
||||||
];
|
|
||||||
const norm = (a) => {
|
|
||||||
const l = Math.hypot(a[0], a[1], a[2]);
|
|
||||||
return [a[0] / l, a[1] / l, a[2] / l];
|
|
||||||
};
|
|
||||||
const mat3d = ([X, Y, Z], t) =>
|
|
||||||
`matrix3d(${X[0]},${X[1]},${X[2]},0,${Y[0]},${Y[1]},${Y[2]},0,` +
|
|
||||||
`${Z[0]},${Z[1]},${Z[2]},0,${t[0]},${t[1]},${t[2]},1)`;
|
|
||||||
|
|
||||||
let scene = null;
|
|
||||||
let die = null;
|
|
||||||
const bases = [];
|
|
||||||
const faceEls = [];
|
|
||||||
|
|
||||||
function build() {
|
|
||||||
scene = document.createElement('span');
|
|
||||||
scene.className = 'die-scene';
|
|
||||||
die = document.createElement('span');
|
|
||||||
die.className = 'die';
|
|
||||||
scene.appendChild(die);
|
|
||||||
|
|
||||||
const light = norm([-0.35, -0.5, 0.78]);
|
|
||||||
FACES.forEach((f, idx) => {
|
|
||||||
let [A, B, C] = f.map((i) => V[i]);
|
|
||||||
const G = [
|
|
||||||
(A[0] + B[0] + C[0]) / 3,
|
|
||||||
(A[1] + B[1] + C[1]) / 3,
|
|
||||||
(A[2] + B[2] + C[2]) / 3,
|
|
||||||
];
|
|
||||||
let n = norm(cross(sub(B, A), sub(C, A)));
|
|
||||||
if (dot(n, G) < 0) { // ensure the normal points outward
|
|
||||||
[B, C] = [C, B];
|
|
||||||
n = [-n[0], -n[1], -n[2]];
|
|
||||||
}
|
|
||||||
const X = norm(sub(B, A)); // face-local right
|
|
||||||
const Y = norm(cross(n, X)); // face-local down (X × Y = n, no mirroring)
|
|
||||||
bases.push([X, Y, n]);
|
|
||||||
|
|
||||||
const D = Math.hypot(...sub(B, A)); // edge length
|
|
||||||
const face = document.createElement('span');
|
|
||||||
face.className = 'die-face';
|
|
||||||
face.textContent = NUMBERS[idx];
|
|
||||||
// Real dice underline the ambiguous digits
|
|
||||||
if (NUMBERS[idx] === 6 || NUMBERS[idx] === 9) face.classList.add('die-face--mark');
|
|
||||||
const pts = [A, B, C].map((P) => {
|
|
||||||
const p = sub(P, G);
|
|
||||||
return `${dot(p, X) + D / 2}px ${dot(p, Y) + D / 2}px`;
|
|
||||||
});
|
|
||||||
face.style.width = face.style.height = D + 'px';
|
|
||||||
face.style.margin = -D / 2 + 'px';
|
|
||||||
face.style.fontSize = D * 0.3 + 'px';
|
|
||||||
face.style.clipPath = `polygon(${pts.join(', ')})`;
|
|
||||||
// Static lighting factor, applied to the roll's color when it's cast
|
|
||||||
faceEls.push({ el: face, shade: 0.6 + 0.4 * Math.max(0, dot(n, light)) });
|
|
||||||
face.style.transform = mat3d([X, Y, n], G);
|
|
||||||
die.appendChild(face);
|
|
||||||
});
|
|
||||||
anchor.appendChild(scene);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotation that lands face i flat toward the viewer, number upright
|
|
||||||
function landing(i) {
|
|
||||||
const [X, Y, Z] = bases[i];
|
|
||||||
return `matrix3d(${X[0]},${Y[0]},${Z[0]},0,${X[1]},${Y[1]},${Z[1]},0,` +
|
|
||||||
`${X[2]},${Y[2]},${Z[2]},0,0,0,0,1)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let busy = false;
|
|
||||||
anchor.addEventListener('click', () => {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
if (!scene) build();
|
|
||||||
scene.style.display = '';
|
|
||||||
scene.style.opacity = '1';
|
|
||||||
|
|
||||||
const result = Math.floor(Math.random() * 20) + 1;
|
|
||||||
const final = landing(NUMBERS.indexOf(result));
|
|
||||||
|
|
||||||
// Cast the die in one of the theme pastels for this roll
|
|
||||||
const [pr, pg, pb] = PASTELS[Math.floor(Math.random() * PASTELS.length)];
|
|
||||||
faceEls.forEach(({ el, shade }) => {
|
|
||||||
el.style.background =
|
|
||||||
`rgb(${Math.round(pr * shade)}, ${Math.round(pg * shade)}, ${Math.round(pb * shade)})`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Nat 20: DOM sprinkles burst from the die — unlike the canvas ones,
|
|
||||||
// these render above the cards, so the effect survives any backdrop
|
|
||||||
const burst = () => {
|
|
||||||
const r = scene.getBoundingClientRect();
|
|
||||||
const cx = r.left + r.width / 2;
|
|
||||||
const cy = r.top + r.height / 2;
|
|
||||||
for (let i = 0; i < 24; i++) {
|
|
||||||
const bit = document.createElement('span');
|
|
||||||
bit.className = 'burst-bit';
|
|
||||||
const [br, bg, bb] = PASTELS[Math.floor(Math.random() * PASTELS.length)];
|
|
||||||
bit.style.background = `rgb(${br}, ${bg}, ${bb})`;
|
|
||||||
bit.style.left = cx + 'px';
|
|
||||||
bit.style.top = cy + 'px';
|
|
||||||
document.body.appendChild(bit);
|
|
||||||
const a = Math.random() * Math.PI * 2;
|
|
||||||
const d = 60 + Math.random() * 90;
|
|
||||||
bit.animate(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
transform: `translate(-50%, -50%) rotate(${Math.random() * 360}deg)`,
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
transform:
|
|
||||||
`translate(calc(-50% + ${(Math.cos(a) * d).toFixed(1)}px), ` +
|
|
||||||
`calc(-50% + ${(Math.sin(a) * d).toFixed(1)}px)) ` +
|
|
||||||
`rotate(${((Math.random() - 0.5) * 720).toFixed(0)}deg)`,
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{ duration: 700 + Math.random() * 300, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)', fill: 'forwards' }
|
|
||||||
).finished.then(() => bit.remove());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const finish = () => {
|
|
||||||
if (result === 20 && !reduceMotion.matches) burst();
|
|
||||||
setTimeout(() => {
|
|
||||||
scene.style.opacity = '0';
|
|
||||||
setTimeout(() => {
|
|
||||||
scene.style.display = 'none';
|
|
||||||
busy = false;
|
|
||||||
}, 320);
|
|
||||||
}, 1100);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (reduceMotion.matches) {
|
|
||||||
die.style.transform = final;
|
|
||||||
finish();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// One continuous decelerating roll from a fully random direction: the
|
|
||||||
// die tumbles end-over-end along its travel (axis perpendicular to the
|
|
||||||
// motion, in-plane) with a slower barrel roll around the travel axis.
|
|
||||||
// Both extra rotations unwind to exactly zero on top of the landing
|
|
||||||
// matrix, so it settles on the result with no correcting jerk.
|
|
||||||
const ang = Math.random() * Math.PI * 2;
|
|
||||||
const dx = Math.cos(ang);
|
|
||||||
const dy = Math.sin(ang);
|
|
||||||
const roll = 810 + Math.floor(Math.random() * 3) * 72;
|
|
||||||
const wobble = 240 + Math.random() * 240;
|
|
||||||
const tumbleAxis = `${dy.toFixed(4)},${(-dx).toFixed(4)},0`;
|
|
||||||
const travelAxis = `${dx.toFixed(4)},${dy.toFixed(4)},0`;
|
|
||||||
const anim = die.animate(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
transform:
|
|
||||||
`translate(${(-dx * 150).toFixed(1)}px, ${(-dy * 150).toFixed(1)}px) ` +
|
|
||||||
`rotate3d(${tumbleAxis},${roll}deg) rotate3d(${travelAxis},${-wobble}deg) ${final}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
transform:
|
|
||||||
`translate(0px, 0px) rotate3d(${tumbleAxis},0deg) rotate3d(${travelAxis},0deg) ${final}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{ duration: 1400, easing: 'cubic-bezier(0.16, 0.6, 0.28, 1)', fill: 'forwards' }
|
|
||||||
);
|
|
||||||
anim.finished.then(() => {
|
|
||||||
die.style.transform = final;
|
|
||||||
anim.cancel();
|
|
||||||
finish();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── Name anagram: clicks cycle through rearrangements ─────────
|
|
||||||
(function () {
|
|
||||||
const h1 = document.querySelector('.hero-name');
|
|
||||||
if (!h1) return;
|
|
||||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
|
|
||||||
const NAME = 'Nikolaj Gade';
|
|
||||||
const CYCLE = [NAME, 'a joking deal', NAME, 'naked oil jag'];
|
|
||||||
|
|
||||||
// The visual letters rearrange, but assistive tech always gets the name
|
|
||||||
h1.setAttribute('aria-label', NAME);
|
|
||||||
|
|
||||||
// Split into persistent per-letter spans; the exact glyphs get reused,
|
|
||||||
// so the capitals travel (see "a jokiNG deal")
|
|
||||||
const letters = [];
|
|
||||||
h1.textContent = '';
|
|
||||||
for (const ch of NAME) {
|
|
||||||
if (ch === ' ') {
|
|
||||||
h1.appendChild(document.createTextNode(' '));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const s = document.createElement('span');
|
|
||||||
s.className = 'ltr';
|
|
||||||
s.textContent = ch;
|
|
||||||
h1.appendChild(s);
|
|
||||||
letters.push({ el: s, ch: ch.toLowerCase() });
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrange(phrase) {
|
|
||||||
// Pick a distinct source glyph for every letter of the target
|
|
||||||
const pool = [...letters];
|
|
||||||
const seq = [];
|
|
||||||
for (const ch of phrase) {
|
|
||||||
if (ch === ' ') {
|
|
||||||
seq.push(null);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const i = pool.findIndex((l) => l.ch === ch.toLowerCase());
|
|
||||||
seq.push(pool.splice(i, 1)[0].el);
|
|
||||||
}
|
|
||||||
|
|
||||||
// FLIP: measure, reorder the DOM, measure again, then animate each
|
|
||||||
// glyph from its old position into its new one
|
|
||||||
const first = new Map(letters.map((l) => [l.el, l.el.getBoundingClientRect()]));
|
|
||||||
h1.textContent = '';
|
|
||||||
seq.forEach((el) => h1.appendChild(el || document.createTextNode(' ')));
|
|
||||||
if (reduceMotion.matches) return Promise.resolve();
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
letters.map((l, k) => {
|
|
||||||
const a = first.get(l.el);
|
|
||||||
const b = l.el.getBoundingClientRect();
|
|
||||||
return l.el.animate(
|
|
||||||
[
|
|
||||||
{ transform: `translate(${(a.left - b.left).toFixed(1)}px, ${(a.top - b.top).toFixed(1)}px)` },
|
|
||||||
{ transform: 'translate(0, 0)' },
|
|
||||||
],
|
|
||||||
{
|
|
||||||
duration: 650,
|
|
||||||
delay: k * 18,
|
|
||||||
fill: 'backwards',
|
|
||||||
easing: 'cubic-bezier(0.3, 0.9, 0.3, 1)',
|
|
||||||
}
|
|
||||||
).finished;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let pos = 0;
|
|
||||||
let busy = false;
|
|
||||||
h1.addEventListener('click', () => {
|
|
||||||
if (busy) return;
|
|
||||||
busy = true;
|
|
||||||
pos = (pos + 1) % CYCLE.length;
|
|
||||||
arrange(CYCLE[pos]).finally(() => { busy = false; });
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
// ── Goat ──────────────────────────────────────────────────────
|
|
||||||
(function () {
|
|
||||||
console.log(`
|
|
||||||
%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%% %%%%%%%%%%%
|
|
||||||
%%% %%%%%%%%%%%%%%%
|
|
||||||
%% %%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%
|
|
||||||
%%%%%%%%%%%%%%%`);
|
|
||||||
|
|
||||||
const FACTS = [
|
|
||||||
`The word admiral comes from the Arabic "amir al bahr", which translates to "commander of the sea". Somewhere along the way into European languages, the bahr fell off, so the word we kept literally means "commander of the".`,
|
|
||||||
`The word stationery comes from (perhaps obviously) the word stationary. It's because book shops were some of the first shops to be in permanent buildings, since books are too heavy to carry around in a cart.`,
|
|
||||||
`The Wikipedia page for economist Guy Standing was the site of one of the biggest edit wars in Wikipedia history, started when an editor uploaded a picture with the description "Guy Standing sitting".`,
|
|
||||||
`The introduction of shipping containers, which hide their contents, dropped the number of alcohol bottles marked as "broken" by almost 100%.`,
|
|
||||||
`The words "chess" and "check" both come from the ancient Persian word "shah", meaning "king". "Check mate" comes from "shah maat", meaning "the king is dead".`,
|
|
||||||
`No one knows the origin of the character of Igor in the Frankenstein story. He's not in the original book, and his first known appearance is from the movie adaptation of a play. So it's not known if he originally appeared in the play or the movie.`,
|
|
||||||
`To avoid a binary overflow error, Swiss trains are not allowed to have exactly 256 axles.`,
|
|
||||||
];
|
|
||||||
console.log('Fun fact: ' + FACTS[Math.floor(Math.random() * FACTS.length)]);
|
|
||||||
})();
|
|
||||||
@@ -242,6 +242,7 @@ main { position: relative; z-index: 1; }
|
|||||||
padding: 0.35rem 0.9rem;
|
padding: 0.35rem 0.9rem;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
/* Slot-machine reels: each slot is a one-line window onto a word column */
|
/* Slot-machine reels: each slot is a one-line window onto a word column */
|
||||||
.hero-role .slot { display: block; height: 1.6em; overflow: hidden; transition: width 0.2s ease; }
|
.hero-role .slot { display: block; height: 1.6em; overflow: hidden; transition: width 0.2s ease; }
|
||||||
@@ -412,7 +413,9 @@ main { position: relative; z-index: 1; }
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.pepsi-placeholder {
|
.pepsi-placeholder {
|
||||||
height: 400px;
|
/* ~7:6, tuned so the card exactly levels with .about-text at full
|
||||||
|
container width (480px photo box -> 461px card) */
|
||||||
|
aspect-ratio: 41 / 35;
|
||||||
border-bottom: var(--border);
|
border-bottom: var(--border);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -701,6 +704,119 @@ footer {
|
|||||||
.dialog-button:hover { transform: translate(-2px, -2px); box-shadow: 5px 5px 0 var(--ink); }
|
.dialog-button:hover { transform: translate(-2px, -2px); box-shadow: 5px 5px 0 var(--ink); }
|
||||||
.dialog-button:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); }
|
.dialog-button:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); }
|
||||||
|
|
||||||
|
/* ── Terminal (js/terminal.js, lazy-built dialog) ───────────── */
|
||||||
|
.term-dialog {
|
||||||
|
margin: auto;
|
||||||
|
width: min(720px, 92vw);
|
||||||
|
padding: 0;
|
||||||
|
/* clips the dark screen to the sticker's rounded corners */
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--ink);
|
||||||
|
border: var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.term-dialog::backdrop { background: rgba(23, 23, 23, 0.55); }
|
||||||
|
.term-dialog[open] { animation: termPop 0.22s var(--ease); }
|
||||||
|
@keyframes termPop {
|
||||||
|
from { transform: scale(0.92) translateY(10px); opacity: 0; }
|
||||||
|
to { transform: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
.term-titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-bottom: var(--border);
|
||||||
|
}
|
||||||
|
.term-title {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.term-screen {
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--paper);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
height: min(480px, 70vh);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
/* min-height keeps blank scrollback lines one line tall */
|
||||||
|
.term-line {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
min-height: 1.5em;
|
||||||
|
}
|
||||||
|
.term-ps1, .term-prompt { color: var(--green); }
|
||||||
|
.term-dir { color: var(--blue); }
|
||||||
|
.term-inputrow { display: flex; gap: 1ch; align-items: baseline; }
|
||||||
|
.term-prompt { white-space: nowrap; }
|
||||||
|
.term-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
caret-color: var(--paper);
|
||||||
|
}
|
||||||
|
/* nano takes over the whole screen; shell scrollback hides underneath */
|
||||||
|
.term-screen.nano-open {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.term-screen.nano-open .term-out,
|
||||||
|
.term-screen.nano-open .term-inputrow { display: none; }
|
||||||
|
.nano {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
/* reverse video, like the real thing */
|
||||||
|
.nano-bar {
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0 0.5ch;
|
||||||
|
}
|
||||||
|
.nano-title { display: flex; justify-content: space-between; }
|
||||||
|
.nano-edit {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: auto;
|
||||||
|
caret-color: var(--paper);
|
||||||
|
}
|
||||||
|
.nano-status { text-align: center; min-height: 1.5em; }
|
||||||
|
.nano-status span {
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0 1ch;
|
||||||
|
}
|
||||||
|
.nano-keys { white-space: pre-wrap; }
|
||||||
|
.nano-key {
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0 0.5ch;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ─────────────────────────────────────────────── */
|
/* ── Responsive ─────────────────────────────────────────────── */
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.about-grid { grid-template-columns: 1fr; gap: 2rem; }
|
.about-grid { grid-template-columns: 1fr; gap: 2rem; }
|
||||||
@@ -741,6 +857,8 @@ footer {
|
|||||||
.burger { display: flex; }
|
.burger { display: flex; }
|
||||||
.education-grid { grid-template-columns: 1fr; }
|
.education-grid { grid-template-columns: 1fr; }
|
||||||
.project-title-row { flex-direction: column; align-items: flex-start; }
|
.project-title-row { flex-direction: column; align-items: flex-start; }
|
||||||
|
.term-dialog { width: calc(100vw - 1.5rem); }
|
||||||
|
.term-screen { height: min(480px, 65vh); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
@@ -794,7 +912,7 @@ footer {
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tooltip: shows on hover, keyboard focus, or tap (script.js toggles .tip-open) */
|
/* Tooltip: shows on hover, keyboard focus, or tap (js/tooltips.js toggles .tip-open) */
|
||||||
[data-tip]:not([data-tip=""]):hover::after,
|
[data-tip]:not([data-tip=""]):hover::after,
|
||||||
[data-tip]:not([data-tip=""]):focus-visible::after,
|
[data-tip]:not([data-tip=""]):focus-visible::after,
|
||||||
[data-tip]:not([data-tip=""]).tip-open::after {
|
[data-tip]:not([data-tip=""]).tip-open::after {
|
||||||
@@ -896,15 +1014,15 @@ footer {
|
|||||||
width: 110px;
|
width: 110px;
|
||||||
height: 110px;
|
height: 110px;
|
||||||
margin-left: -55px;
|
margin-left: -55px;
|
||||||
perspective: 420px;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
}
|
}
|
||||||
|
/* No perspective/preserve-3d: js/d20.js projects each face flat itself —
|
||||||
|
Firefox's 3D plane-splitting drops parts of faces at grazing angles */
|
||||||
.die {
|
.die {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
transform-style: preserve-3d;
|
|
||||||
}
|
}
|
||||||
.die-face {
|
.die-face {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -913,7 +1031,7 @@ footer {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
backface-visibility: hidden;
|
/* no backface-visibility: js/d20.js culls faces itself (Firefox flicker) */
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
@@ -934,6 +1052,221 @@ footer {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Achievements (js/achievements.js, absent until first unlock) ── */
|
||||||
|
.achv {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
z-index: 950;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
transition: top 0.3s var(--ease);
|
||||||
|
animation: achvPop 0.35s var(--ease);
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
/* Tucked below the nav whenever it's on screen */
|
||||||
|
#header.scrolled ~ .achv { top: calc(var(--nav-h) + 0.75rem); }
|
||||||
|
@keyframes achvPop {
|
||||||
|
from { transform: scale(0); }
|
||||||
|
}
|
||||||
|
.achv-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6ch;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0.3rem 0.85rem;
|
||||||
|
background: var(--white);
|
||||||
|
border: var(--border-thin);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: background 150ms ease;
|
||||||
|
}
|
||||||
|
.achv-badge:hover { transform: translate(-2px, -2px); box-shadow: 5px 5px 0 var(--ink); }
|
||||||
|
.achv-badge:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); }
|
||||||
|
.achv-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
.achv-panel[hidden] { display: none; }
|
||||||
|
/* Opening the panel marches the shields in from off-screen left,
|
||||||
|
staggered down the column (re-triggers on every un-hide) */
|
||||||
|
.achv-panel:not([hidden]) .achv-medal {
|
||||||
|
animation: medalSlide 0.3s var(--ease) backwards;
|
||||||
|
}
|
||||||
|
.achv-medal:nth-child(2) { animation-delay: 45ms; }
|
||||||
|
.achv-medal:nth-child(3) { animation-delay: 90ms; }
|
||||||
|
.achv-medal:nth-child(4) { animation-delay: 135ms; }
|
||||||
|
.achv-medal:nth-child(5) { animation-delay: 180ms; }
|
||||||
|
.achv-medal:nth-child(6) { animation-delay: 225ms; }
|
||||||
|
@keyframes medalSlide {
|
||||||
|
from { transform: translateX(-105px); }
|
||||||
|
}
|
||||||
|
/* …and back out on close, unwinding bottom-first. js/achievements.js
|
||||||
|
flips hidden after the last shield leaves */
|
||||||
|
.achv-panel.achv-panel--closing .achv-medal {
|
||||||
|
animation: medalSlideOut 0.3s var(--ease) forwards;
|
||||||
|
}
|
||||||
|
.achv-panel.achv-panel--closing .achv-medal:nth-child(1) { animation-delay: 225ms; }
|
||||||
|
.achv-panel.achv-panel--closing .achv-medal:nth-child(2) { animation-delay: 180ms; }
|
||||||
|
.achv-panel.achv-panel--closing .achv-medal:nth-child(3) { animation-delay: 135ms; }
|
||||||
|
.achv-panel.achv-panel--closing .achv-medal:nth-child(4) { animation-delay: 90ms; }
|
||||||
|
.achv-panel.achv-panel--closing .achv-medal:nth-child(5) { animation-delay: 45ms; }
|
||||||
|
.achv-panel.achv-panel--closing .achv-medal:nth-child(6) { animation-delay: 0ms; }
|
||||||
|
@keyframes medalSlideOut {
|
||||||
|
to { transform: translateX(-105px); }
|
||||||
|
}
|
||||||
|
.achv-sr {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px; height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Medals: heraldic shields — black until earned, blank stickers until
|
||||||
|
their art lands (see MEDALS in js/achievements.js). clip-path eats
|
||||||
|
borders, so the ink outline is a clipped ::before behind a smaller
|
||||||
|
clipped ::after face; the art is an inline SVG above both */
|
||||||
|
.achv-medal {
|
||||||
|
position: relative;
|
||||||
|
width: 80px; height: 95px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
/* Medal tooltips: the shield's own pseudo-elements are spent on the
|
||||||
|
clip-path layers, so this is a child bubble instead of [data-tip] —
|
||||||
|
keep it visually in step with the house tooltip above */
|
||||||
|
.achv-tip {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 12px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: max-content;
|
||||||
|
max-width: 220px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--white);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.achv-tip b { display: block; font-weight: 700; }
|
||||||
|
.achv-tip::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -13px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: 8px solid transparent;
|
||||||
|
border-right-color: var(--ink);
|
||||||
|
}
|
||||||
|
.achv-medal:hover .achv-tip { visibility: visible; }
|
||||||
|
.achv-medal::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--ink);
|
||||||
|
clip-path: path('M0 0 H80 V37.5 C80 70 56.25 87.5 40 95 C23.75 87.5 0 70 0 37.5 Z');
|
||||||
|
}
|
||||||
|
.achv-medal::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 3px;
|
||||||
|
background: var(--ink);
|
||||||
|
clip-path: path('M0 0 H74 V34.5 C74 65 52 81 37 89 C22 81 0 65 0 34.5 Z');
|
||||||
|
}
|
||||||
|
.achv-medal--got::after { background: var(--white); }
|
||||||
|
/* Cat lover: Pepsi (a black cat) over the Pepsi-invoking tricolor bend */
|
||||||
|
.achv-medal--cat::after {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
#e32636 0 50px,
|
||||||
|
var(--white) 50px 66px,
|
||||||
|
#0e4c96 66px 100%);
|
||||||
|
}
|
||||||
|
.m-cat, .m-inv, .m-hk, .m-nat, .m-adv, .m-comp {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
/* Natural: a golden burst on royal purple behind a white d20 */
|
||||||
|
.achv-medal--natural::after { background: #4b2e83; }
|
||||||
|
/* Adventurer: a sword palewise on deep gules */
|
||||||
|
.achv-medal--adventurer::after { background: #a01f2f; }
|
||||||
|
/* Completionist: a gold crown on the finish flag (checky of ink and
|
||||||
|
white, 9.25px squares — one conic tile is a 2×2 check) */
|
||||||
|
.achv-medal--completionist::after {
|
||||||
|
background: repeating-conic-gradient(var(--ink) 0% 25%, var(--white) 0% 50%)
|
||||||
|
top left / 18.5px 18.5px;
|
||||||
|
}
|
||||||
|
/* Hacker: the terminal quoted back — ink screen, glowing green prompt,
|
||||||
|
blinking cursor. Must re-override --got's white face */
|
||||||
|
.achv-medal--hacker::after { background: var(--ink); }
|
||||||
|
.m-hk-cursor { animation: hkBlink 1.1s infinite; }
|
||||||
|
@keyframes hkBlink {
|
||||||
|
0%, 49% { opacity: 1; }
|
||||||
|
50%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
/* Investigator: a semé of sprinkles under a white magnifying glass;
|
||||||
|
the lens is filled with the field color (same literal in the SVG)
|
||||||
|
so the ring reads clean */
|
||||||
|
.achv-medal--investigator::after { background: #177245; }
|
||||||
|
.achv-toast {
|
||||||
|
position: fixed;
|
||||||
|
/* Undo the [popover] UA centering (inset: 0 + margin: auto) */
|
||||||
|
inset: auto;
|
||||||
|
margin: 0;
|
||||||
|
top: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
/* Parked above the viewport; js/achievements.js animates it in */
|
||||||
|
transform: translate(-50%, -140%);
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
background: var(--white);
|
||||||
|
border: var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: 0.6rem 1.5rem 0.6rem 0.9rem;
|
||||||
|
}
|
||||||
|
/* The tiny medal: a real 80×95 shield scaled to 0.4, so the clip-path
|
||||||
|
px coordinates and the SVG art need no small-size variants */
|
||||||
|
.achv-toast-medal {
|
||||||
|
flex: none;
|
||||||
|
width: 32px; height: 38px;
|
||||||
|
}
|
||||||
|
.achv-toast-medal .achv-medal {
|
||||||
|
display: block;
|
||||||
|
transform: scale(0.4);
|
||||||
|
transform-origin: top left;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.achv-toast-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.achv-toast-kicker {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.achv-toast-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Reduced motion ─────────────────────────────────────────── */
|
/* ── Reduced motion ─────────────────────────────────────────── */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
html { scroll-behavior: auto; }
|
html { scroll-behavior: auto; }
|
||||||
@@ -941,4 +1274,9 @@ footer {
|
|||||||
.project-tags span, .timeline-tags span,
|
.project-tags span, .timeline-tags span,
|
||||||
.nav-link::after, #header, .hero-role .slot { transition: none; }
|
.nav-link::after, #header, .hero-role .slot { transition: none; }
|
||||||
.scroll-hint.visible i { animation: none; }
|
.scroll-hint.visible i { animation: none; }
|
||||||
|
.term-dialog[open] { animation: none; }
|
||||||
|
.achv { animation: none; transition: none; }
|
||||||
|
.achv-panel:not([hidden]) .achv-medal,
|
||||||
|
.achv-panel.achv-panel--closing .achv-medal { animation: none; }
|
||||||
|
.m-hk-cursor { animation: none; }
|
||||||
}
|
}
|
||||||
|
|||||||