This commit is contained in:
2026-08-08 15:54:38 +02:00
parent 81f8fab51e
commit ee5729e4b7
3 changed files with 173 additions and 252 deletions
+70 -62
View File
@@ -1,47 +1,3 @@
// ── Hero typewriter ────────────────────────────────────────────
function startHeroTypewriter(nameEl, fullName, restEls) {
const cursor = document.createElement('span');
cursor.className = 'hero-cursor';
nameEl.appendChild(cursor);
let i = 0;
function typeNext() {
if (i < fullName.length) {
nameEl.insertBefore(document.createTextNode(fullName[i]), cursor);
i++;
setTimeout(typeNext, 95);
} else {
// Cursor done — fade it out, then reveal the rest
cursor.style.transition = 'opacity 0.3s';
cursor.style.opacity = '0';
setTimeout(() => {
cursor.remove();
restEls.forEach((el, idx) => {
setTimeout(() => el.classList.add('visible'), idx * 110);
});
}, 0);
}
}
typeNext();
}
// ── Hero intro ────────────────────────────────────────────────
(function () {
const nameEl = document.querySelector('.hero-name');
const restEls = [...document.querySelectorAll('.hero-role, .hero-pronouns, .hero-links, .hero-visual')];
// Store the name and clear it so the typewriter can type it out
const fullName = nameEl.textContent.trim();
nameEl.textContent = '';
// Hide hero elements that reveal after typing. They're already hidden by the
// .js pre-reveal block in style.css; this just adds the transition.
restEls.forEach(el => el.classList.add('fade-up'));
startHeroTypewriter(nameEl, fullName, restEls);
})();
// ── Sprinkle background ────────────────────────────
(function () {
const canvas = document.getElementById('bgCanvas');
@@ -67,6 +23,11 @@ function startHeroTypewriter(nameEl, fullName, restEls) {
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() {
@@ -109,6 +70,9 @@ function startHeroTypewriter(nameEl, fullName, restEls) {
ctx.stroke();
ctx.restore();
// Reduced motion: sprinkles stay where they are
if (reduceMotion.matches) return;
// Drift
s.x += s.vx;
s.y += s.vy;
@@ -138,9 +102,17 @@ function startHeroTypewriter(nameEl, fullName, restEls) {
if (s.x > W + MAX_LEN) s.x = -MAX_LEN;
});
requestAnimationFrame(draw);
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;
@@ -148,7 +120,7 @@ function startHeroTypewriter(nameEl, fullName, restEls) {
mouseX = e.clientX;
mouseY = e.clientY;
}, { passive: true });
requestAnimationFrame(draw);
rafId = requestAnimationFrame(draw);
})();
@@ -193,25 +165,61 @@ function startHeroTypewriter(nameEl, fullName, restEls) {
})();
// ── Scroll fade-in ─────────────────────────────────────────────
// ── Avatar spin on click ──────────────────────────────────────
(function () {
// Keep this list in sync with the .js pre-reveal block in style.css
const targets = document.querySelectorAll(
'.section-header, .about-grid, .skill-card, .timeline-item, ' +
'.project-card, .edu-card'
);
targets.forEach(el => el.classList.add('fade-up'));
const avatar = document.querySelector('.avatar-ring');
const observer = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (!e.isIntersecting) return;
const siblings = [...e.target.parentElement.querySelectorAll('.fade-up')];
const idx = siblings.indexOf(e.target);
setTimeout(() => e.target.classList.add('visible'), idx * 80);
observer.unobserve(e.target);
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();
}
});
}, { threshold: 0.1 });
});
targets.forEach(el => observer.observe(el));
document.addEventListener('click', closeAll);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAll();
});
})();