Files
narlblog/frontend/src/components/CyberFx.astro
T
nvrl 0da5b24dc3
CI / frontend (push) Failing after 0s
CI / backend (push) Failing after 1s
updated readme + just
2026-05-21 04:29:43 +02:00

137 lines
4.6 KiB
Plaintext

---
/*
* CyberFx — ambient + interactive layer for the `.cybersigil` theme.
*
* Renders an aria-hidden overlay root on every page. All visuals are CSS,
* scoped to `.cybersigil .cs-fx*` in global.css, so this is an inert,
* display:none no-op under every other theme. The bundled script only wires
* the scroll-entry databend on images and self-disables off-theme or under
* prefers-reduced-motion.
*/
---
<div class="cs-fx" aria-hidden="true">
<div class="cs-fx-halftone"></div>
<div class="cs-fx-wire"></div>
<div class="cs-fx-tear"></div>
<i class="cs-fx-corner cs-fx-corner--tl"></i>
<i class="cs-fx-corner cs-fx-corner--tr"></i>
<i class="cs-fx-corner cs-fx-corner--bl"></i>
<i class="cs-fx-corner cs-fx-corner--br"></i>
</div>
<script>
import { buildCybersigil } from '../lib/cybersigil';
let teardown: (() => void) | null = null;
function initCyberFx() {
const root = document.documentElement;
if (!root.classList.contains('cybersigil')) return;
const fx = document.querySelector<HTMLElement>('.cs-fx');
if (!fx) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ─── Generated sigil growths in the four corners ─── */
/* One sigil per page load; the four corner transforms in global.css
* (scaleX / scaleY / scale) splay it into each corner. */
const corners = document.querySelectorAll<HTMLElement>('.cs-fx-corner');
if (corners.length) {
const svg = buildCybersigil();
corners.forEach((c) => {
if (c.classList.contains('cs-fx-corner--sig')) return;
c.innerHTML = svg;
c.classList.add('cs-fx-corner--sig');
});
}
/* ─── One sigil filling the background. count:6 (was 9) — fewer branch
* nodes ⇒ far fewer perpetually-animating strokes, same silhouette. ─── */
const wire = document.querySelector<HTMLElement>('.cs-fx-wire');
if (wire && !wire.classList.contains('cs-fx-wire--sig')) {
wire.innerHTML = buildCybersigil({ count: 6 });
wire.classList.add('cs-fx-wire--sig');
}
/* ─── Scroll-entry databend on images ─── */
if (!reduced && 'IntersectionObserver' in window) {
const targets = document.querySelectorAll<HTMLElement>(
'.prose img, .prose figure img, .plate-image img'
);
if (targets.length) {
const io = new IntersectionObserver(
(entries) => {
for (const en of entries) {
if (!en.isIntersecting) continue;
const el = en.target as HTMLElement;
el.classList.remove('cs-databent');
// reflow so the animation can retrigger
void el.offsetWidth;
el.classList.add('cs-databent');
io.unobserve(el);
}
},
{ rootMargin: '0px 0px -12% 0px', threshold: 0.15 }
);
targets.forEach((t) => io.observe(t));
}
}
/* ─── Ambient: entrance fade-in + scroll recede + parallax ─── */
if (teardown) teardown();
const off: Array<() => void> = [];
let depth = 0, raf = 0;
let mx = 0, my = 0; // mouse relative (-1..1)
const apply = () => {
raf = 0;
fx.style.opacity = String(1 - 0.5 * depth);
// subtle parallax drift
root.style.setProperty('--cs-px', `${(mx * 15).toFixed(1)}px`);
root.style.setProperty('--cs-py', `${(my * 15).toFixed(1)}px`);
root.style.setProperty('--cs-cx', `${(mx * -8).toFixed(1)}px`);
root.style.setProperty('--cs-cy', `${(my * -8).toFixed(1)}px`);
};
const schedule = () => { if (!raf) raf = requestAnimationFrame(apply); };
const onScroll = () => {
const vh = window.innerHeight || 1;
depth = Math.max(0, Math.min(1, window.scrollY / vh));
schedule();
};
const onMouseMove = (e: MouseEvent) => {
mx = (e.clientX / window.innerWidth) * 2 - 1;
my = (e.clientY / window.innerHeight) * 2 - 1;
schedule();
};
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
window.addEventListener('mousemove', onMouseMove, { passive: true });
off.push(() => window.removeEventListener('scroll', onScroll));
off.push(() => window.removeEventListener('resize', onScroll));
off.push(() => window.removeEventListener('mousemove', onMouseMove));
onScroll();
/* Freeze every loop while the tab is hidden — idle-battery win. */
const onVis = () => fx.classList.toggle('is-paused', document.hidden);
document.addEventListener('visibilitychange', onVis);
off.push(() => document.removeEventListener('visibilitychange', onVis));
onVis();
teardown = () => {
if (raf) cancelAnimationFrame(raf);
off.forEach((fn) => fn());
teardown = null;
};
}
initCyberFx();
// MPA back/forward restores: re-arm if needed.
window.addEventListener('pageshow', (e) => {
if (e.persisted) initCyberFx();
});
</script>