/* Portfolio Dark theme behaviour. Dependency-free; every block is a no-op when its markup is absent, so it is safe on blog, shop and builder pages alike. */ (function () { 'use strict'; var body = document.body; if (!body || !body.classList.contains('portfolio-dark')) { return; } // Reveal-on-scroll hides content until it is seen, so it only applies once JS is running. body.classList.add('pdk-js'); var $ = function (s, c) { return (c || document).querySelector(s); }; var $$ = function (s, c) { return Array.prototype.slice.call((c || document).querySelectorAll(s)); }; var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; // Header state + scroll progress var header = $('.header'); var progress = $('.progress'); function onScroll() { if (header) { header.classList.toggle('scrolled', window.scrollY > 16); } if (progress) { var h = document.documentElement.scrollHeight - window.innerHeight; progress.style.width = ((h > 0 ? window.scrollY / h : 0) * 100) + '%'; } } window.addEventListener('scroll', onScroll, { passive: true }); onScroll(); // Mobile menu var toggle = $('.menu-toggle'); var menu = $('.mobile-menu'); function setMenu(open) { if (!menu) { return; } menu.classList.toggle('open', open); body.style.overflow = open ? 'hidden' : ''; if (toggle) { toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); } } if (toggle && menu) { toggle.addEventListener('click', function () { setMenu(!menu.classList.contains('open')); }); $$('a', menu).forEach(function (a) { a.addEventListener('click', function () { setMenu(false); }); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { setMenu(false); } }); } // Current page in the menus var here = location.pathname.replace(/\/+$/, '') || '/'; $$('.nav-links a, .mobile-menu a').forEach(function (a) { try { var p = new URL(a.href, location.href).pathname.replace(/\/+$/, '') || '/'; if (p === here) { a.classList.add('active'); a.setAttribute('aria-current', 'page'); } } catch (e) { /* ignore malformed hrefs */ } }); // Reveal on scroll var reveals = $$('.reveal'); if ('IntersectionObserver' in window && !reduced) { var io = new IntersectionObserver(function (entries) { entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } }); }, { threshold: 0.12 }); reveals.forEach(function (el) { io.observe(el); }); } else { reveals.forEach(function (el) { el.classList.add('in'); }); } // Magnetic buttons (pointer devices only) if (window.matchMedia && window.matchMedia('(hover: hover)').matches && !reduced) { $$('.magnetic').forEach(function (el) { el.addEventListener('pointermove', function (e) { var r = el.getBoundingClientRect(); el.style.transform = 'translate(' + ((e.clientX - r.left - r.width / 2) * 0.05) + 'px,' + ((e.clientY - r.top - r.height / 2) * 0.05) + 'px)'; }); el.addEventListener('pointerleave', function () { el.style.transform = ''; }); }); } // Tabs $$('.tabs-layout').forEach(function (shell) { var btns = $$('.tab-btn', shell); var panes = $$('.tab-pane', shell); function select(key) { btns.forEach(function (b) { var on = b.getAttribute('data-tab') === key; b.classList.toggle('active', on); b.setAttribute('aria-selected', on ? 'true' : 'false'); b.setAttribute('tabindex', on ? '0' : '-1'); }); panes.forEach(function (p) { p.hidden = p.getAttribute('data-pane') !== key; }); } btns.forEach(function (btn, i) { btn.addEventListener('click', function () { select(btn.getAttribute('data-tab')); }); btn.addEventListener('keydown', function (e) { var next = e.key === 'ArrowDown' || e.key === 'ArrowRight' ? i + 1 : (e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? i - 1 : null); if (next === null) { return; } e.preventDefault(); var target = btns[(next + btns.length) % btns.length]; target.focus(); select(target.getAttribute('data-tab')); }); }); }); // Masonry grid (photo carousel section, Display = Masonry): each card spans as many 2px grid rows as it is tall. // Without this script the cards still show, in plain rows. $$('.photo-masonry').forEach(function (grid) { var track = $('.carousel-track', grid); if (!track) { return; } var pack = function () { grid.classList.add('is-packed'); $$('.carousel-item', track).forEach(function (card) { card.style.gridRowEnd = 'auto'; card.style.gridRowEnd = 'span ' + Math.ceil(card.getBoundingClientRect().height / 2 + 7); }); }; pack(); $$('img', track).forEach(function (img) { if (!img.complete) { img.addEventListener('load', pack); } }); window.addEventListener('resize', pack); if (document.fonts && document.fonts.ready) { document.fonts.ready.then(pack); } }); // Carousels $$('.photo-carousel:not(.photo-masonry)').forEach(function (car) { var track = $('.carousel-track', car); if (!track) { return; } var step = function (dir) { track.scrollBy({ left: dir * track.clientWidth * 0.8, behavior: reduced ? 'auto' : 'smooth' }); }; var prev = $('.carousel-prev', car); var next = $('.carousel-next', car); if (prev) { prev.addEventListener('click', function () { step(-1); }); } if (next) { next.addEventListener('click', function () { step(1); }); } }); // Accordions $$('.acc').forEach(function (acc) { var btn = $('button', acc); var panel = $('.panel', acc); if (!btn || !panel) { return; } btn.setAttribute('aria-expanded', 'false'); btn.addEventListener('click', function () { var open = acc.classList.toggle('open'); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); panel.style.maxHeight = open ? panel.scrollHeight + 'px' : '0px'; }); }); // Terminal typing. The lines are rendered into the page for crawlers and no-JS readers; // this replays them as a typing animation once the terminal scrolls into view. $$('.terminal-body[data-lines]').forEach(function (term) { var lines; try { lines = JSON.parse(term.getAttribute('data-lines')); } catch (e) { return; } if (!Array.isArray(lines) || !lines.length || reduced || !('IntersectionObserver' in window)) { return; } var rows = [], row = 0, col = 0; function paint() { term.innerHTML = ''; rows.forEach(function (r) { var d = document.createElement('div'); if (r.cls) { d.className = r.cls; } d.textContent = r.text; term.appendChild(d); }); } function draw() { if (row >= lines.length) { return; } var cur = lines[row]; rows[row] = { cls: cur[0], text: String(cur[1]).slice(0, col) }; paint(); col++; if (col > String(cur[1]).length) { row++; col = 0; setTimeout(draw, 220); } else { setTimeout(draw, 16 + Math.random() * 18); } } var obs = new IntersectionObserver(function (es) { if (es[0].isIntersecting) { obs.disconnect(); term.setAttribute('aria-label', term.textContent.trim()); draw(); } }, { threshold: 0.4 }); obs.observe(term); }); })(); /* Timeline loop: repeat the steps so the row can slide by exactly one set, forever. The copies are decorative (aria-hidden, data-nosnippet). Skipped for reduced motion, where the row stays a normal scrollable list. */ (function () { 'use strict'; if (!document.body || !document.body.classList.contains('portfolio-dark')) { return; } var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; var wraps = Array.prototype.slice.call(document.querySelectorAll('.timeline-loop')); if (!wraps.length || reduced) { return; } function build(wrap) { var track = wrap.querySelector('.timeline-track'); if (!track) { return; } Array.prototype.slice.call(track.querySelectorAll('[data-loop-copy]')).forEach(function (n) { n.remove(); }); var cards = Array.prototype.slice.call(track.children); if (!cards.length) { return; } var last = cards[cards.length - 1]; var setWidth = last.offsetLeft + last.offsetWidth + (parseFloat(getComputedStyle(last).marginRight) || 0) - cards[0].offsetLeft; if (!setWidth) { return; } var copies = Math.ceil(wrap.clientWidth / setWidth) + 1; for (var i = 0; i < copies; i++) { cards.forEach(function (card) { var copy = card.cloneNode(true); copy.setAttribute('aria-hidden', 'true'); copy.setAttribute('data-nosnippet', ''); copy.setAttribute('data-loop-copy', '1'); track.appendChild(copy); }); } wrap.style.setProperty('--tl-shift', setWidth + 'px'); wrap.classList.add('is-looping'); } var timer; wraps.forEach(build); window.addEventListener('resize', function () { clearTimeout(timer); timer = setTimeout(function () { wraps.forEach(build); }, 250); }); })();