🐐
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>404 Not Found</title></head>
|
||||||
|
<body>
|
||||||
|
<center><h1>404 Not Found</h1></center>
|
||||||
|
<hr><center>The page has been deleted</center>
|
||||||
|
<script>
|
||||||
|
/* Restore hatch: clicking the heading undeletes the website */
|
||||||
|
document.querySelector('h1').addEventListener('click', function () {
|
||||||
|
try { localStorage.removeItem('siteDeleted'); } catch (e) {}
|
||||||
|
location.replace('/');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1
-2
@@ -5,8 +5,7 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'DM Mono'; font-style: normal; font-weight: 400; font-display: swap;
|
font-family: 'DM Mono'; font-style: normal; font-weight: 400; font-display: swap;
|
||||||
src: url('/fonts/dm-mono-400.woff2') format('woff2');
|
src: url('/fonts/dm-mono-400.woff2') format('woff2');
|
||||||
}
|
}@font-face {
|
||||||
@font-face {
|
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-5
@@ -2,6 +2,10 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<script>
|
||||||
|
/* The website stays deleted for visitors who deleted it (see footer) */
|
||||||
|
try { if (localStorage.getItem('siteDeleted')) location.replace('404.html'); } catch (e) {}
|
||||||
|
</script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Nikolaj Gade 💻 Software Developer</title>
|
<title>Nikolaj Gade 💻 Software Developer</title>
|
||||||
|
|
||||||
@@ -33,7 +37,7 @@
|
|||||||
<header id="header">
|
<header id="header">
|
||||||
<nav>
|
<nav>
|
||||||
<a href="#home" class="nav-logo" aria-label="Nikolaj Gade — home">
|
<a href="#home" class="nav-logo" aria-label="Nikolaj Gade — home">
|
||||||
<span class="bracket"><</span>NG<span class="bracket">/></span>
|
NG
|
||||||
</a>
|
</a>
|
||||||
<button class="burger" id="burger" aria-label="Menu">
|
<button class="burger" id="burger" aria-label="Menu">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
@@ -48,14 +52,24 @@
|
|||||||
<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 -->
|
||||||
|
<a href="#about" class="scroll-hint" id="scrollHint" aria-label="Scroll to the About section">
|
||||||
|
<i class="fas fa-chevron-down" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
<!-- HERO -->
|
<!-- HERO -->
|
||||||
<section id="home" class="hero">
|
<section id="home" class="hero">
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<h1 class="hero-name">Nikolaj <span class="hero-name-accent">Gade</span></h1>
|
<h1 class="hero-name">Nikolaj Gade</h1>
|
||||||
<p class="hero-role">Software Developer</p>
|
<div class="hero-chips">
|
||||||
<p class="hero-pronouns">they/them · he/him</p>
|
<p class="hero-role" id="heroRole">
|
||||||
|
<span class="slot"><span class="reel"><span>Software</span></span></span>
|
||||||
|
<span class="slot"><span class="reel"><span>Developer</span></span></span>
|
||||||
|
</p>
|
||||||
|
<p class="hero-pronouns">they/them · he/him</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="hero-links">
|
<div class="hero-links">
|
||||||
<a href="https://github.com/NikolajDanger" class="hero-link" target="_blank" rel="noopener">
|
<a href="https://github.com/NikolajDanger" class="hero-link" target="_blank" rel="noopener">
|
||||||
@@ -100,7 +114,7 @@
|
|||||||
<div class="about-text">
|
<div class="about-text">
|
||||||
<p>Hi 👋 I'm Nikolaj, a software developer based in <strong>Nørrebro, Copenhagen</strong>. I started programming around 2007 as a kid tinkering in <strong>Game Maker</strong>, and I haven't stopped since.</p>
|
<p>Hi 👋 I'm Nikolaj, a software developer based in <strong>Nørrebro, Copenhagen</strong>. I started programming around 2007 as a kid tinkering in <strong>Game Maker</strong>, and I haven't stopped since.</p>
|
||||||
<p>My main language is <strong>Python</strong>, but I'm a chameleon by nature: I love picking up new tools and languages, and there's nothing quite like the feeling of new concepts snapping into place. I do my best work when I can sink my teeth into something and not let go until it's done.</p>
|
<p>My main language is <strong>Python</strong>, but I'm a chameleon by nature: I love picking up new tools and languages, and there's nothing quite like the feeling of new concepts snapping into place. I do my best work when I can sink my teeth into something and not let go until it's done.</p>
|
||||||
<p>When I'm not at the keyboard, you might find me reading a book or running a <strong>Dungeons & Dragons</strong> campaign as a Dungeon Master. I live in an apartment in Nørrebro with my cat, <strong>Pepsi</strong> (pictured here).</p>
|
<p>When I'm not at the keyboard, you might find me reading a book or running a <strong class="dnd-roll">Dungeons & Dragons</strong> campaign as a Dungeon Master. I live in an apartment in Nørrebro with my cat, <strong>Pepsi</strong> (pictured here).</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-right">
|
<div class="about-right">
|
||||||
<div class="pepsi-card">
|
<div class="pepsi-card">
|
||||||
@@ -361,10 +375,23 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="delete-site-wrap">
|
||||||
|
<button id="deleteSite" class="delete-site">Delete website</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>This website was built by a horde of angry rats masquerading as a software developer</p>
|
<p>This website was built by a horde of angry rats masquerading as a software developer</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<dialog id="deleteDialog" class="delete-dialog">
|
||||||
|
<p class="delete-dialog-title">Are you sure?</p>
|
||||||
|
<p>This is a permanent deletion of this entire website.</p>
|
||||||
|
<div class="delete-actions">
|
||||||
|
<button id="deleteCancel" class="dialog-button" autofocus>Cancel</button>
|
||||||
|
<button id="deleteConfirm" class="dialog-button dialog-button--danger">Yes, I'm sure</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<script src="script.js" defer></script>
|
<script src="script.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -284,3 +284,447 @@
|
|||||||
}).catch(() => { 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; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|||||||
@@ -58,16 +58,19 @@ a:focus-visible, button:focus-visible, [data-tip]:focus-visible {
|
|||||||
|
|
||||||
/* ── Nav ────────────────────────────────────────────────────── */
|
/* ── Nav ────────────────────────────────────────────────────── */
|
||||||
#header {
|
#header {
|
||||||
background: transparent;
|
background: var(--paper);
|
||||||
border-bottom: 3px solid transparent;
|
border-bottom: var(--border);
|
||||||
position: fixed; top: 0; left: 0; right: 0;
|
position: fixed; top: 0; left: 0; right: 0;
|
||||||
height: var(--nav-h);
|
height: var(--nav-h);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
transition: background 0.3s, border-color 0.3s;
|
/* Hidden until scrolled; visibility keeps hidden links unfocusable */
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s, visibility 0.3s;
|
||||||
}
|
}
|
||||||
#header.scrolled {
|
#header.scrolled {
|
||||||
background: var(--paper);
|
opacity: 1;
|
||||||
border-bottom-color: var(--ink);
|
visibility: visible;
|
||||||
}
|
}
|
||||||
nav {
|
nav {
|
||||||
max-width: var(--max-w);
|
max-width: var(--max-w);
|
||||||
@@ -77,13 +80,27 @@ nav {
|
|||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
}
|
}
|
||||||
.nav-logo {
|
.nav-logo {
|
||||||
font-size: 1.2rem;
|
width: 40px; height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--yellow);
|
||||||
|
border: var(--border);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 0.95rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.nav-logo .bracket { color: var(--accent); }
|
.nav-links {
|
||||||
|
display: flex; gap: 0.1rem;
|
||||||
.nav-links { display: flex; gap: 0.1rem; }
|
/* Sibling of #header, so it hides/shows on its own */
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.3s, visibility 0.3s;
|
||||||
|
}
|
||||||
|
#header.scrolled + .nav-links {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 769px) {
|
@media (min-width: 769px) {
|
||||||
.nav-links {
|
.nav-links {
|
||||||
@@ -123,6 +140,33 @@ nav {
|
|||||||
.burger.open span:nth-child(2) { opacity: 0; }
|
.burger.open span:nth-child(2) { opacity: 0; }
|
||||||
.burger.open span:nth-child(3) { transform: rotate(-45deg) translate(5.5px, -5.5px); }
|
.burger.open span:nth-child(3) { transform: rotate(-45deg) translate(5.5px, -5.5px); }
|
||||||
|
|
||||||
|
/* ── Scroll hint ────────────────────────────────────────────── */
|
||||||
|
.scroll-hint {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--white);
|
||||||
|
border: var(--border);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 900;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.6s, visibility 0.6s, background 150ms ease;
|
||||||
|
}
|
||||||
|
.scroll-hint.visible { opacity: 1; visibility: visible; }
|
||||||
|
.scroll-hint:hover { background: var(--yellow); }
|
||||||
|
.scroll-hint.visible i { animation: hintBob 1.6s ease-in-out infinite; }
|
||||||
|
@keyframes hintBob {
|
||||||
|
0%, 100% { transform: translateY(-2px); }
|
||||||
|
50% { transform: translateY(3px); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Layout ─────────────────────────────────────────────────── */
|
/* ── Layout ─────────────────────────────────────────────────── */
|
||||||
main { position: relative; z-index: 1; }
|
main { position: relative; z-index: 1; }
|
||||||
|
|
||||||
@@ -144,11 +188,13 @@ main { position: relative; z-index: 1; }
|
|||||||
|
|
||||||
/* ── Hero ───────────────────────────────────────────────────── */
|
/* ── Hero ───────────────────────────────────────────────────── */
|
||||||
.hero {
|
.hero {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex; align-items: center;
|
display: flex; align-items: center;
|
||||||
padding: calc(var(--nav-h) + 1rem) 2rem 10rem;
|
padding: calc(var(--nav-h) + 1rem) 2rem 6rem;
|
||||||
max-width: var(--max-w);
|
max-width: var(--max-w);
|
||||||
margin: 0 auto 250px auto;
|
margin: 0 auto;
|
||||||
gap: 4rem;
|
gap: 4rem;
|
||||||
position: relative; z-index: 1;
|
position: relative; z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -162,22 +208,48 @@ main { position: relative; z-index: 1; }
|
|||||||
letter-spacing: -0.06em;
|
letter-spacing: -0.06em;
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
|
||||||
.hero-name-accent {
|
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
-webkit-text-stroke: 2px var(--ink);
|
-webkit-text-stroke: 2px var(--ink);
|
||||||
text-shadow: 4px 4px 0 var(--yellow);
|
/* drop-shadow, not text-shadow: with per-glyph spans, Chromium mispaints
|
||||||
|
stroke + text-shadow together; the filter shadows the rendered whole */
|
||||||
|
filter: drop-shadow(4px 4px 0 var(--yellow));
|
||||||
|
}
|
||||||
|
/* Per-glyph spans for the anagram. letter-spacing is zeroed inside each
|
||||||
|
box because the h1's tracking already applies once at every inline-block
|
||||||
|
boundary — inheriting it too would double-tighten the name */
|
||||||
|
.hero-name .ltr {
|
||||||
|
display: inline-block;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
.hero-chips {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
.hero-role {
|
.hero-role {
|
||||||
|
display: flex;
|
||||||
|
gap: 1ch;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
margin-left: 0.5rem;
|
background: var(--yellow);
|
||||||
margin-bottom: 0.5rem;
|
border: var(--border-thin);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
/* 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 .reel { display: flex; flex-direction: column; }
|
||||||
|
/* nowrap: a hyphenated word (FULL-STACK) would otherwise line-break while
|
||||||
|
the slot is narrow mid-expansion, doubling a row's height and landing
|
||||||
|
the whole strip one row off */
|
||||||
|
.hero-role .reel span { display: block; text-align: center; white-space: nowrap; }
|
||||||
/* Hero social links */
|
/* Hero social links */
|
||||||
.hero-links {
|
.hero-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -189,7 +261,7 @@ main { position: relative; z-index: 1; }
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.6rem 1.1rem;
|
padding: 0.6rem 1.1rem;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-md);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
@@ -239,8 +311,13 @@ main { position: relative; z-index: 1; }
|
|||||||
.avatar-placeholder img { width: 100%; height: 100%; object-fit: cover; }
|
.avatar-placeholder img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
/* Spin the image inside the ring, not the ring itself: the ring's hard
|
/* Spin the image inside the ring, not the ring itself: the ring's hard
|
||||||
offset shadow would visibly orbit if the shadowed element rotated */
|
offset shadow would visibly orbit if the shadowed element rotated.
|
||||||
.avatar-ring.spinning .avatar-placeholder { animation: avatarSpin 0.8s var(--ease); }
|
The small icon circles have no offset shadow, so they spin whole. */
|
||||||
|
.avatar-ring.spinning .avatar-placeholder,
|
||||||
|
.skill-card-icon.spinning,
|
||||||
|
.project-icon.spinning,
|
||||||
|
.edu-icon.spinning,
|
||||||
|
.nav-logo.spinning { animation: avatarSpin 0.8s var(--ease); }
|
||||||
/* rotate is only keyed at 0%/100% so it sweeps through the scale midpoint
|
/* rotate is only keyed at 0%/100% so it sweeps through the scale midpoint
|
||||||
without easing to a stop at 180° */
|
without easing to a stop at 180° */
|
||||||
@keyframes avatarSpin {
|
@keyframes avatarSpin {
|
||||||
@@ -249,7 +326,11 @@ main { position: relative; z-index: 1; }
|
|||||||
100% { rotate: 360deg; scale: 1; }
|
100% { rotate: 360deg; scale: 1; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.avatar-ring.spinning .avatar-placeholder { animation: none; }
|
.avatar-ring.spinning .avatar-placeholder,
|
||||||
|
.skill-card-icon.spinning,
|
||||||
|
.project-icon.spinning,
|
||||||
|
.edu-icon.spinning,
|
||||||
|
.nav-logo.spinning { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -257,9 +338,56 @@ main { position: relative; z-index: 1; }
|
|||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
margin-left: 0.5rem;
|
background: var(--white);
|
||||||
margin-bottom: 0.75rem;
|
border: var(--border-thin);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
/* own stacking context, so the ::before flag layer sits between this
|
||||||
|
pill's background and its text instead of hiding behind the pill */
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
/* Non-binary flag sweep (click easter egg): four diagonal bands slide in,
|
||||||
|
fill the pill, rest a beat, then slide out — z-index -1 paints above the
|
||||||
|
pill's background but below its content */
|
||||||
|
.hero-pronouns::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; bottom: 0;
|
||||||
|
left: -20%;
|
||||||
|
width: 140%;
|
||||||
|
z-index: -1;
|
||||||
|
/* Wider than the pill, fading to transparent past diagonal stops on
|
||||||
|
both ends, so the yellow and black bands keep slanted edges instead
|
||||||
|
of ending at the panel's straight sides */
|
||||||
|
background: linear-gradient(
|
||||||
|
115deg,
|
||||||
|
transparent 0 8%,
|
||||||
|
#fcf434 8% 29%,
|
||||||
|
#ffffff 29% 50%,
|
||||||
|
#9c59d1 50% 71%,
|
||||||
|
#2c2c2c 71% 92%,
|
||||||
|
transparent 92% 100%
|
||||||
|
);
|
||||||
|
transform: translateX(-101%);
|
||||||
|
}
|
||||||
|
.hero-pronouns.waving::before { animation: nbSweep 1.35s linear; }
|
||||||
|
/* Text yields while the flag holds the pill (ink would drown in the
|
||||||
|
black band), then returns as the bands leave */
|
||||||
|
.hero-pronouns.waving { animation: nbTextFade 1.35s ease; }
|
||||||
|
/* No hold: heavy deceleration into the filled position, then a creeping
|
||||||
|
re-acceleration out — a near-stop rather than a stop */
|
||||||
|
@keyframes nbSweep {
|
||||||
|
0% { transform: translateX(-101%); animation-timing-function: cubic-bezier(0.1, 0.7, 0.45, 1); }
|
||||||
|
50% { transform: translateX(0); animation-timing-function: cubic-bezier(0.55, 0, 0.9, 0.3); }
|
||||||
|
100% { transform: translateX(101%); }
|
||||||
|
}
|
||||||
|
@keyframes nbTextFade {
|
||||||
|
0% { color: var(--ink); }
|
||||||
|
20%, 80% { color: transparent; }
|
||||||
|
100% { color: var(--ink); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── About ──────────────────────────────────────────────────── */
|
/* ── About ──────────────────────────────────────────────────── */
|
||||||
@@ -368,7 +496,7 @@ main { position: relative; z-index: 1; }
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
padding: 0.35rem 0.8rem;
|
padding: 0.35rem 0.8rem;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-md);
|
||||||
border: var(--border-thin);
|
border: var(--border-thin);
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -501,13 +629,77 @@ main { position: relative; z-index: 1; }
|
|||||||
/* ── Footer ─────────────────────────────────────────────────── */
|
/* ── Footer ─────────────────────────────────────────────────── */
|
||||||
footer {
|
footer {
|
||||||
position: relative; z-index: 1;
|
position: relative; z-index: 1;
|
||||||
background: var(--ink); color: var(--white);
|
background: var(--ink);
|
||||||
|
/* Deliberately dim: reads as boilerplate unless you actually look */
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 0.78rem;
|
font-size: 0.72rem;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-align: center; padding: 2.5rem 2rem;
|
text-align: center; padding: 1.25rem 2rem;
|
||||||
}
|
}
|
||||||
|
.delete-site-wrap {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 2rem 4rem;
|
||||||
|
}
|
||||||
|
.delete-site {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: #fff;
|
||||||
|
background: #c92a2a;
|
||||||
|
border: var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: background 150ms ease;
|
||||||
|
}
|
||||||
|
.delete-site:hover { transform: translate(-2px, -2px); box-shadow: 5px 5px 0 var(--ink); }
|
||||||
|
.delete-site:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--ink); }
|
||||||
|
|
||||||
|
/* ── Delete-website dialog ──────────────────────────────────── */
|
||||||
|
.delete-dialog {
|
||||||
|
margin: auto;
|
||||||
|
max-width: 26rem;
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--ink);
|
||||||
|
border: var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.delete-dialog::backdrop { background: rgba(23, 23, 23, 0.55); }
|
||||||
|
.delete-dialog-title {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.delete-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.dialog-button {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
border: var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: background 150ms ease;
|
||||||
|
}
|
||||||
|
.dialog-button--danger { background: #c92a2a; color: #fff; }
|
||||||
|
.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); }
|
||||||
|
|
||||||
/* ── Responsive ─────────────────────────────────────────────── */
|
/* ── Responsive ─────────────────────────────────────────────── */
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
@@ -515,6 +707,10 @@ footer {
|
|||||||
.hero-visual { display: none; }
|
.hero-visual { display: none; }
|
||||||
.hero { flex-direction: column; text-align: center; padding-top: calc(var(--nav-h) + 2rem); }
|
.hero { flex-direction: column; text-align: center; padding-top: calc(var(--nav-h) + 2rem); }
|
||||||
.hero-links { justify-content: center; }
|
.hero-links { justify-content: center; }
|
||||||
|
.hero-chips { justify-content: center; margin-left: 0; }
|
||||||
|
/* Stacked hero: the base clamp bottoms out too small; the ceiling still
|
||||||
|
keeps the longest anagram ("a joking deal") on one line at 360px */
|
||||||
|
.hero-name { font-size: clamp(3.2rem, 11vw, 4.8rem); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -524,7 +720,7 @@ footer {
|
|||||||
--shadow-sm: 3px 3px 0 var(--ink);
|
--shadow-sm: 3px 3px 0 var(--ink);
|
||||||
--radius-lg: 14px;
|
--radius-lg: 14px;
|
||||||
}
|
}
|
||||||
.hero-name-accent { -webkit-text-stroke-width: 1.5px; text-shadow: 3px 3px 0 var(--yellow); }
|
.hero-name { -webkit-text-stroke-width: 1.5px; filter: drop-shadow(3px 3px 0 var(--yellow)); }
|
||||||
.nav-links {
|
.nav-links {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: var(--nav-h);
|
top: var(--nav-h);
|
||||||
@@ -535,7 +731,7 @@ footer {
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
transform: translateY(-140%);
|
transform: translateY(-140%);
|
||||||
transition: transform 0.4s var(--ease);
|
transition: transform 0.4s var(--ease), opacity 0.3s, visibility 0.3s;
|
||||||
border-bottom: var(--border);
|
border-bottom: var(--border);
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
}
|
}
|
||||||
@@ -658,10 +854,62 @@ footer {
|
|||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── D20 roll on "Dungeons & Dragons" ───────────────────────── */
|
||||||
|
.dnd-roll {
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.die-scene {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
left: 50%;
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
margin-left: -55px;
|
||||||
|
perspective: 420px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
.die {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
.die-face {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
/* 6 and 9 get the disambiguating underline, like a real die */
|
||||||
|
.die-face--mark {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 0.09em;
|
||||||
|
text-underline-offset: 0.12em;
|
||||||
|
}
|
||||||
|
/* Nat-20 burst bits: DOM sprinkles that fly out above the page content */
|
||||||
|
.burst-bit {
|
||||||
|
position: fixed;
|
||||||
|
width: 5px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
z-index: 1200;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Reduced motion ─────────────────────────────────────────── */
|
/* ── Reduced motion ─────────────────────────────────────────── */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
html { scroll-behavior: auto; }
|
html { scroll-behavior: auto; }
|
||||||
.hero-link, .project-link, .lang-tag,
|
.hero-link, .project-link, .lang-tag,
|
||||||
.project-tags span, .timeline-tags span,
|
.project-tags span, .timeline-tags span,
|
||||||
.nav-link::after, #header { transition: none; }
|
.nav-link::after, #header, .hero-role .slot { transition: none; }
|
||||||
|
.scroll-hint.visible i { animation: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user