furtka/website/assets/js/scene.js

99 lines
3 KiB
JavaScript
Raw Normal View History

feat(site): pimp homepage with animated 3D background and scroll reveals Adopts the visual feel of Pascal's prototype while keeping Furtka's voice, brand palette, and bilingual structure intact. What changed - Three.js wireframe torus-knot behind the hero, color/opacity tied to the existing --accent / --scene-opacity CSS vars so light and dark modes both work without a scene re-init. - Scroll-driven camera zoom + core scale + tilt; canvas opacity fades past hero so feature cards stay readable. - GSAP + ScrollTrigger reveal hero on load and stagger feature cards in as they enter the viewport. Lenis smooths scroll. - "What works today" / "What's coming next" lists move from markdown bullets into front-matter arrays and render as scroll-reveal cards (7 + 4 cards, EN/DE parallel; copy is 1:1 from the original lists). - Hero scaled up: gradient text on the wordmark (fg → accent), drop-shadow glow in dark mode, brighter lede color. - Primary CTA -> /releases listing on Forgejo (Forgejo has no /releases/latest), with a pulsing glow + arrow slide on hover. - Version bump 26.8-alpha -> 26.15-alpha to match the actual release. Performance / a11y - Vendor JS (Three.js r128, GSAP 3.12.2 + ScrollTrigger, Lenis 1.0.33) vendored locally under assets/js/vendor/ - no third-party CDN at runtime. ~728 KB total, fingerprinted via Hugo's pipeline with SRI. - Canvas + scripts gated to homepage only ({{ if .IsHome }}); the Impressum/Datenschutz pages stay plain. - prefers-reduced-motion: scene + GSAP early-return, CSS forces cards to their resting state. No-JS users see all content. - All scripts deferred so first paint isn't blocked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:14:21 +02:00
(function () {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (!window.WebGLRenderingContext || !window.THREE) return;
const canvas = document.getElementById('scene');
if (!canvas) return;
const root = document.documentElement;
const readVar = (name) => getComputedStyle(root).getPropertyValue(name).trim();
const readOpacity = () => parseFloat(readVar('--scene-opacity')) || 0.18;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
60, window.innerWidth / window.innerHeight, 0.1, 100
);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const geometry = new THREE.TorusKnotGeometry(2.5, 0.4, 130, 20);
const material = new THREE.MeshPhongMaterial({
color: readVar('--accent') || '#c03a28',
wireframe: true,
transparent: true,
opacity: readOpacity()
});
const core = new THREE.Mesh(geometry, material);
scene.add(core);
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
dir.position.set(5, 5, 5);
scene.add(dir);
const BASE_Z = 9;
camera.position.z = BASE_Z;
let scrollY = window.scrollY || 0;
window.addEventListener('scroll', () => {
scrollY = window.scrollY || 0;
}, { passive: true });
let baseOpacity = readOpacity();
let running = true;
function tick() {
if (!running) return;
requestAnimationFrame(tick);
// Continuous slow drift.
core.rotation.y += 0.0015;
core.rotation.z += 0.0006;
// Scroll-driven motion: zoom in, scale up, tilt.
const s = Math.min(scrollY, 2000);
camera.position.z = BASE_Z - s * 0.0022;
const scale = 1 + s * 0.00035;
core.scale.set(scale, scale, scale);
core.rotation.x = s * 0.0008;
// Fade past hero so feature cards stay readable.
const vh = window.innerHeight;
const fadeStart = vh * 0.5;
const fadeEnd = vh * 1.4;
const t = Math.max(0, Math.min(1, (scrollY - fadeStart) / (fadeEnd - fadeStart)));
material.opacity = baseOpacity * (1 - t * 0.92);
renderer.render(scene, camera);
}
tick();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight, false);
});
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
running = false;
} else if (!running) {
running = true;
tick();
}
});
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const updateTheme = () => {
const accent = readVar('--accent');
if (accent) material.color.set(accent);
baseOpacity = readOpacity();
};
if (mql.addEventListener) {
mql.addEventListener('change', updateTheme);
} else if (mql.addListener) {
mql.addListener(updateTheme);
}
})();