Some checks failed
CI / lint (push) Successful in 1m24s
CI / test (push) Successful in 2m24s
CI / validate-json (push) Successful in 57s
CI / markdown-links (push) Successful in 29s
Deploy site / deploy (push) Successful in 7s
Build ISO / build-iso (push) Failing after 14m59s
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>
98 lines
3 KiB
JavaScript
98 lines
3 KiB
JavaScript
(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);
|
|
}
|
|
})();
|