feat: full dark tech WebGL refonte — atomic scroll cards + media lightbox
- WebGLBackground: fixed subtle atom + particles (dimmed, camera z=14) - WebGLCards: scroll-triggered tile materialize with particle burst - MediaLightbox: click photos/videos for full-view with orbital energy rings - Dark design tokens in global.css (.dark-site variant) - Glassmorphism cards, glow borders, staggered reveals - Photo/video hover glow effects - Each section wrapped in webgl-section for atomic reveal - Each content block in webgl-card with per-section accent color - Respects prefers-reduced-motion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,483 @@
|
|||||||
|
import { useRef, useMemo, useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { Text } from '@react-three/drei';
|
||||||
|
import { EffectComposer, Bloom, ChromaticAberration, Scanline, Glitch as GlitchEffect } from '@react-three/postprocessing';
|
||||||
|
import { GlitchMode, BlendFunction } from 'postprocessing';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
WEBGL BACKGROUND — Full-page immersive dark tech layer
|
||||||
|
|
||||||
|
Sections (scroll-driven):
|
||||||
|
- HERO (0-100vh): Full atom + particles + text rings
|
||||||
|
- ABOUT (100-200vh): Circuit traces + node grid
|
||||||
|
- CASES (200-300vh): Floating card planes
|
||||||
|
- SPRINTS (300-400vh): Orbital rings
|
||||||
|
- CONTACT (400vh+): Atom returns, particles intensify
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
cyan: new THREE.Color('#5bd1d8'),
|
||||||
|
amber: new THREE.Color('#f1c27a'),
|
||||||
|
green: new THREE.Color('#b6d18f'),
|
||||||
|
electric: new THREE.Color('#0071e3'),
|
||||||
|
nucleus: new THREE.Color('#ff6b35'),
|
||||||
|
white: new THREE.Color('#ffffff'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const FONT_URL = '/assets/fonts/manrope-regular.ttf';
|
||||||
|
|
||||||
|
/* ---------- Shared scroll state ---------- */
|
||||||
|
let globalScroll = 0;
|
||||||
|
let globalMaxScroll = 1;
|
||||||
|
|
||||||
|
/* ---------- Particle system (persistent across sections) ---------- */
|
||||||
|
function ParticleField({ count = 800 }) {
|
||||||
|
const ref = useRef<THREE.Points>(null);
|
||||||
|
|
||||||
|
const { positions, velocities, colors, sizes } = useMemo(() => {
|
||||||
|
const pos = new Float32Array(count * 3);
|
||||||
|
const vel = new Float32Array(count * 3);
|
||||||
|
const col = new Float32Array(count * 3);
|
||||||
|
const sz = new Float32Array(count);
|
||||||
|
const palette = [COLORS.cyan, COLORS.amber, COLORS.green, COLORS.electric];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
// Spread across a tall vertical volume
|
||||||
|
pos[i * 3] = (Math.random() - 0.5) * 20;
|
||||||
|
pos[i * 3 + 1] = (Math.random() - 0.5) * 60;
|
||||||
|
pos[i * 3 + 2] = (Math.random() - 0.5) * 15;
|
||||||
|
|
||||||
|
vel[i * 3] = (Math.random() - 0.5) * 0.003;
|
||||||
|
vel[i * 3 + 1] = (Math.random() - 0.5) * 0.002;
|
||||||
|
vel[i * 3 + 2] = (Math.random() - 0.5) * 0.003;
|
||||||
|
|
||||||
|
const c = palette[Math.floor(Math.random() * palette.length)];
|
||||||
|
col[i * 3] = c.r;
|
||||||
|
col[i * 3 + 1] = c.g;
|
||||||
|
col[i * 3 + 2] = c.b;
|
||||||
|
|
||||||
|
sz[i] = 0.02 + Math.random() * 0.04;
|
||||||
|
}
|
||||||
|
return { positions: pos, velocities: vel, colors: col, sizes: sz };
|
||||||
|
}, [count]);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const posAttr = ref.current.geometry.attributes.position as THREE.BufferAttribute;
|
||||||
|
const arr = posAttr.array as Float32Array;
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
arr[i * 3] += velocities[i * 3] + Math.sin(t * 0.3 + i * 0.1) * 0.001;
|
||||||
|
arr[i * 3 + 1] += velocities[i * 3 + 1];
|
||||||
|
arr[i * 3 + 2] += velocities[i * 3 + 2];
|
||||||
|
|
||||||
|
// Wrap around
|
||||||
|
if (Math.abs(arr[i * 3]) > 12) arr[i * 3] *= -0.5;
|
||||||
|
if (Math.abs(arr[i * 3 + 1]) > 35) arr[i * 3 + 1] *= -0.5;
|
||||||
|
if (Math.abs(arr[i * 3 + 2]) > 8) arr[i * 3 + 2] *= -0.5;
|
||||||
|
}
|
||||||
|
posAttr.needsUpdate = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<points ref={ref}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
||||||
|
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial
|
||||||
|
size={0.05}
|
||||||
|
vertexColors
|
||||||
|
transparent
|
||||||
|
opacity={0.35}
|
||||||
|
sizeAttenuation
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
/>
|
||||||
|
</points>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Nucleus (Hero center) ---------- */
|
||||||
|
function Nucleus() {
|
||||||
|
const coreRef = useRef<THREE.Mesh>(null);
|
||||||
|
const glowRef = useRef<THREE.Mesh>(null);
|
||||||
|
const shellRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock, pointer }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
const scrollFade = 1; // atome fixe, toujours visible
|
||||||
|
const mouseProx = Math.max(0, 1 - Math.sqrt(pointer.x ** 2 + pointer.y ** 2));
|
||||||
|
|
||||||
|
if (coreRef.current) {
|
||||||
|
const s = (1 + Math.sin(t * 3) * 0.08 + mouseProx * 0.15) * scrollFade;
|
||||||
|
coreRef.current.scale.setScalar(Math.max(0.01, s));
|
||||||
|
(coreRef.current.material as THREE.MeshStandardMaterial).emissiveIntensity = 0.8 + mouseProx * 1;
|
||||||
|
}
|
||||||
|
if (glowRef.current) {
|
||||||
|
const s = (1 + Math.sin(t * 2) * 0.15 + mouseProx * 0.3) * scrollFade;
|
||||||
|
glowRef.current.scale.setScalar(Math.max(0.01, s));
|
||||||
|
(glowRef.current.material as THREE.MeshStandardMaterial).opacity = (0.15 + mouseProx * 0.2) * scrollFade;
|
||||||
|
}
|
||||||
|
if (shellRef.current) {
|
||||||
|
shellRef.current.rotation.y = t * (0.5 + mouseProx * 2);
|
||||||
|
shellRef.current.rotation.x = t * 0.3;
|
||||||
|
shellRef.current.scale.setScalar(Math.max(0.01, scrollFade));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh ref={shellRef}>
|
||||||
|
<icosahedronGeometry args={[0.9, 1]} />
|
||||||
|
<meshStandardMaterial color={COLORS.electric} wireframe transparent opacity={0.08} emissive={COLORS.electric} emissiveIntensity={0.5} />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={glowRef}>
|
||||||
|
<sphereGeometry args={[0.7, 32, 32]} />
|
||||||
|
<meshStandardMaterial color={COLORS.nucleus} transparent opacity={0.15} emissive={COLORS.nucleus} emissiveIntensity={1.2} />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={coreRef}>
|
||||||
|
<sphereGeometry args={[0.32, 32, 32]} />
|
||||||
|
<meshStandardMaterial color="#ffffff" emissive={COLORS.amber} emissiveIntensity={2} metalness={0.6} roughness={0.2} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Orbit ring + electron trail ---------- */
|
||||||
|
function Orbit({ tilt, speed, color, trailCount = 25 }: {
|
||||||
|
tilt: number[]; speed: number; color: THREE.Color; trailCount?: number;
|
||||||
|
}) {
|
||||||
|
const meshRef = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const ringRef = useRef<THREE.Group>(null);
|
||||||
|
const phase = useMemo(() => Math.random() * Math.PI * 2, []);
|
||||||
|
const dummy = useMemo(() => new THREE.Object3D(), []);
|
||||||
|
const R = 3.2;
|
||||||
|
const E = 0.38;
|
||||||
|
|
||||||
|
const ringGeo = useMemo(() => {
|
||||||
|
const pts: THREE.Vector3[] = [];
|
||||||
|
for (let i = 0; i <= 128; i++) {
|
||||||
|
const a = (i / 128) * Math.PI * 2;
|
||||||
|
pts.push(new THREE.Vector3(Math.cos(a) * R, Math.sin(a) * R * E, 0));
|
||||||
|
}
|
||||||
|
return new THREE.BufferGeometry().setFromPoints(pts);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!meshRef.current || !ringRef.current) return;
|
||||||
|
const t = clock.getElapsedTime() * speed + phase;
|
||||||
|
const scrollFade = 1; // orbites fixes
|
||||||
|
ringRef.current.scale.setScalar(Math.max(0.01, scrollFade));
|
||||||
|
|
||||||
|
for (let i = 0; i < trailCount; i++) {
|
||||||
|
const age = i / trailCount;
|
||||||
|
const angle = t - age * 0.8;
|
||||||
|
dummy.position.set(Math.cos(angle) * R, Math.sin(angle) * R * E, 0);
|
||||||
|
dummy.scale.setScalar(Math.max(0.001, ((1 - age) * 0.14 + 0.02)));
|
||||||
|
dummy.updateMatrix();
|
||||||
|
meshRef.current.setMatrixAt(i, dummy.matrix);
|
||||||
|
}
|
||||||
|
meshRef.current.instanceMatrix.needsUpdate = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={ringRef} rotation={tilt as [number, number, number]}>
|
||||||
|
<line geometry={ringGeo}>
|
||||||
|
<lineBasicMaterial color={color} transparent opacity={0.12} />
|
||||||
|
</line>
|
||||||
|
<instancedMesh ref={meshRef} args={[undefined, undefined, trailCount]}>
|
||||||
|
<sphereGeometry args={[1, 8, 8]} />
|
||||||
|
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={2} transparent opacity={0.9} />
|
||||||
|
</instancedMesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Circuit traces (About section) ---------- */
|
||||||
|
function CircuitTraces() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const traces = useMemo(() => {
|
||||||
|
const lines: { points: THREE.Vector3[]; color: THREE.Color }[] = [];
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const pts: THREE.Vector3[] = [];
|
||||||
|
let x = (Math.random() - 0.5) * 16;
|
||||||
|
let y = -15 + (Math.random() - 0.5) * 10;
|
||||||
|
pts.push(new THREE.Vector3(x, y, (Math.random() - 0.5) * 4));
|
||||||
|
for (let j = 0; j < 4 + Math.floor(Math.random() * 4); j++) {
|
||||||
|
// PCB-style: horizontal or vertical segments
|
||||||
|
if (Math.random() > 0.5) x += (Math.random() - 0.5) * 3;
|
||||||
|
else y += (Math.random() - 0.5) * 2;
|
||||||
|
pts.push(new THREE.Vector3(x, y, pts[0].z));
|
||||||
|
}
|
||||||
|
lines.push({
|
||||||
|
points: pts,
|
||||||
|
color: [COLORS.cyan, COLORS.green, COLORS.electric][Math.floor(Math.random() * 3)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
const scrollPos = globalScroll;
|
||||||
|
// Visible between 20-50% scroll
|
||||||
|
const fade = Math.max(0, Math.min(1, (scrollPos - 0.15) * 5)) * Math.max(0, Math.min(1, (0.45 - scrollPos) * 5));
|
||||||
|
groupRef.current.children.forEach((child, i) => {
|
||||||
|
(child as THREE.Line).material.opacity = fade * 0.3;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{traces.map((trace, i) => {
|
||||||
|
const geo = new THREE.BufferGeometry().setFromPoints(trace.points);
|
||||||
|
return (
|
||||||
|
<line key={i} geometry={geo}>
|
||||||
|
<lineBasicMaterial color={trace.color} transparent opacity={0.3} blending={THREE.AdditiveBlending} />
|
||||||
|
</line>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Circuit nodes (solder points) ---------- */
|
||||||
|
function CircuitNodes() {
|
||||||
|
const ref = useRef<THREE.InstancedMesh>(null);
|
||||||
|
const dummy = useMemo(() => new THREE.Object3D(), []);
|
||||||
|
const count = 40;
|
||||||
|
|
||||||
|
const nodePositions = useMemo(() => {
|
||||||
|
const pos: [number, number, number][] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
pos.push([
|
||||||
|
(Math.random() - 0.5) * 16,
|
||||||
|
-15 + (Math.random() - 0.5) * 10,
|
||||||
|
(Math.random() - 0.5) * 4,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return pos;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
const scrollPos = globalScroll;
|
||||||
|
const fade = Math.max(0, Math.min(1, (scrollPos - 0.15) * 5)) * Math.max(0, Math.min(1, (0.45 - scrollPos) * 5));
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const [x, y, z] = nodePositions[i];
|
||||||
|
dummy.position.set(x, y, z);
|
||||||
|
const pulse = 1 + Math.sin(t * 2 + i) * 0.3;
|
||||||
|
dummy.scale.setScalar(0.06 * pulse * fade);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
ref.current.setMatrixAt(i, dummy.matrix);
|
||||||
|
}
|
||||||
|
ref.current.instanceMatrix.needsUpdate = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<instancedMesh ref={ref} args={[undefined, undefined, count]}>
|
||||||
|
<sphereGeometry args={[1, 8, 8]} />
|
||||||
|
<meshStandardMaterial color={COLORS.cyan} emissive={COLORS.cyan} emissiveIntensity={2} transparent />
|
||||||
|
</instancedMesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Sprint orbital rings ---------- */
|
||||||
|
function SprintRings() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const rings = [
|
||||||
|
{ radius: 2.5, color: COLORS.cyan, label: '28%', speed: 0.3 },
|
||||||
|
{ radius: 3.5, color: COLORS.amber, label: '55%', speed: 0.2 },
|
||||||
|
{ radius: 4.5, color: COLORS.green, label: '100%', speed: 0.15 },
|
||||||
|
];
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
const scrollPos = globalScroll;
|
||||||
|
const fade = Math.max(0, Math.min(1, (scrollPos - 0.5) * 5)) * Math.max(0, Math.min(1, (0.8 - scrollPos) * 5));
|
||||||
|
groupRef.current.position.y = -35;
|
||||||
|
groupRef.current.children.forEach(child => {
|
||||||
|
child.scale.setScalar(Math.max(0.01, fade));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{rings.map((ring, i) => {
|
||||||
|
const pts: THREE.Vector3[] = [];
|
||||||
|
for (let j = 0; j <= 128; j++) {
|
||||||
|
const a = (j / 128) * Math.PI * 2;
|
||||||
|
pts.push(new THREE.Vector3(Math.cos(a) * ring.radius, 0, Math.sin(a) * ring.radius));
|
||||||
|
}
|
||||||
|
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
||||||
|
return (
|
||||||
|
<group key={i} rotation={[0.5 + i * 0.3, 0, i * 0.2]}>
|
||||||
|
<line geometry={geo}>
|
||||||
|
<lineBasicMaterial color={ring.color} transparent opacity={0.25} />
|
||||||
|
</line>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Contact section atom (returns at bottom) ---------- */
|
||||||
|
function ContactAtom() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
const scrollPos = globalScroll;
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
const fade = Math.max(0, (scrollPos - 0.75) * 4);
|
||||||
|
groupRef.current.position.y = -55;
|
||||||
|
groupRef.current.scale.setScalar(Math.max(0.01, fade * 0.6));
|
||||||
|
groupRef.current.rotation.y = t * 0.2;
|
||||||
|
groupRef.current.rotation.x = Math.sin(t * 0.1) * 0.2;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
<mesh>
|
||||||
|
<icosahedronGeometry args={[2, 1]} />
|
||||||
|
<meshStandardMaterial color={COLORS.electric} wireframe transparent opacity={0.15} emissive={COLORS.electric} emissiveIntensity={0.5} />
|
||||||
|
</mesh>
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[0.8, 32, 32]} />
|
||||||
|
<meshStandardMaterial color={COLORS.nucleus} transparent opacity={0.2} emissive={COLORS.nucleus} emissiveIntensity={1} />
|
||||||
|
</mesh>
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[0.4, 32, 32]} />
|
||||||
|
<meshStandardMaterial color="#ffffff" emissive={COLORS.amber} emissiveIntensity={2} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Full scene ---------- */
|
||||||
|
function Scene() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
const orbits = useMemo(() => [
|
||||||
|
{ tilt: [-0.5, 0, 0], speed: 1.0, color: COLORS.cyan },
|
||||||
|
{ tilt: [0.5, 0.3, 0], speed: 0.72, color: COLORS.amber },
|
||||||
|
{ tilt: [1.57, 0.2, 0.4], speed: 0.55, color: COLORS.green },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
useFrame(({ clock, pointer }) => {
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
const mouseDist = Math.sqrt(pointer.x ** 2 + pointer.y ** 2);
|
||||||
|
const speedMul = 1 + mouseDist * 0.5;
|
||||||
|
|
||||||
|
// Only the hero atom group rotates with mouse
|
||||||
|
const heroGroup = groupRef.current.children[0] as THREE.Group;
|
||||||
|
if (heroGroup) {
|
||||||
|
heroGroup.rotation.y = t * 0.12 * speedMul + pointer.x * 0.4;
|
||||||
|
heroGroup.rotation.x = -0.2 + pointer.y * 0.3;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{/* Hero atom group */}
|
||||||
|
<group>
|
||||||
|
<Nucleus />
|
||||||
|
{orbits.map((o, i) => (
|
||||||
|
<Orbit key={i} tilt={o.tilt} speed={o.speed} color={o.color} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* Persistent particles */}
|
||||||
|
<ParticleField />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Scroll-reactive camera ---------- */
|
||||||
|
function ScrollCamera() {
|
||||||
|
const { camera } = useThree();
|
||||||
|
const smooth = useRef({ x: 0, y: 0, scroll: 0 });
|
||||||
|
|
||||||
|
useFrame(({ pointer }) => {
|
||||||
|
// Smooth interpolation
|
||||||
|
smooth.current.x += (pointer.x - smooth.current.x) * 0.05;
|
||||||
|
smooth.current.y += (pointer.y - smooth.current.y) * 0.05;
|
||||||
|
smooth.current.scroll += (globalScroll - smooth.current.scroll) * 0.08;
|
||||||
|
|
||||||
|
const s = smooth.current.scroll;
|
||||||
|
|
||||||
|
// Camera FIXED — slight parallax from mouse only, no scroll movement
|
||||||
|
camera.position.x = smooth.current.x * 1.2;
|
||||||
|
camera.position.y = smooth.current.y * 0.6;
|
||||||
|
camera.position.z = 14;
|
||||||
|
|
||||||
|
camera.lookAt(
|
||||||
|
smooth.current.x * 0.2,
|
||||||
|
smooth.current.y * 0.1,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Exported component ---------- */
|
||||||
|
export default function WebGLBackground() {
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
const el = document.documentElement;
|
||||||
|
globalMaxScroll = Math.max(1, el.scrollHeight - el.clientHeight);
|
||||||
|
globalScroll = el.scrollTop / globalMaxScroll;
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [0, 0, 9], fov: 45 }}
|
||||||
|
dpr={[1, 1.5]}
|
||||||
|
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
|
||||||
|
style={{ pointerEvents: 'auto' }}
|
||||||
|
>
|
||||||
|
<color attach="background" args={['#080808']} />
|
||||||
|
<fog attach="fog" args={['#080808', 6, 22]} />
|
||||||
|
|
||||||
|
<ambientLight intensity={0.06} />
|
||||||
|
<pointLight position={[5, 3, 5]} intensity={0.3} color="#ffffff" />
|
||||||
|
<pointLight position={[-4, -2, 3]} intensity={0.15} color="#5bd1d8" />
|
||||||
|
<pointLight position={[0, -20, -3]} intensity={0.1} color="#f1c27a" />
|
||||||
|
<pointLight position={[0, -50, 5]} intensity={0.1} color="#5bd1d8" />
|
||||||
|
|
||||||
|
<ScrollCamera />
|
||||||
|
<Scene />
|
||||||
|
|
||||||
|
<EffectComposer>
|
||||||
|
<Bloom luminanceThreshold={0.5} luminanceSmoothing={0.9} intensity={0.6} mipmapBlur />
|
||||||
|
<ChromaticAberration blendFunction={BlendFunction.NORMAL} offset={new THREE.Vector2(0.001, 0.001)} />
|
||||||
|
<GlitchEffect delay={new THREE.Vector2(5, 15)} duration={new THREE.Vector2(0.05, 0.2)} strength={new THREE.Vector2(0.02, 0.08)} mode={GlitchMode.SPORADIC} />
|
||||||
|
<Scanline blendFunction={BlendFunction.OVERLAY} density={1.5} opacity={0.03} />
|
||||||
|
</EffectComposer>
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
WEBGL CARDS — Scroll-triggered tile animations + media lightbox
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Cards materialize with particle burst on scroll-in
|
||||||
|
- Cards dissolve with particle scatter on scroll-out
|
||||||
|
- Photos/videos appear in a front-view lightbox on click
|
||||||
|
- Energy ring orbits around focused media
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
|
/* ---------- Particle burst effect (CSS + JS, no Three.js needed) ---------- */
|
||||||
|
function createParticleBurst(el: HTMLElement, color: string = '#5bd1d8') {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const cx = rect.left + rect.width / 2;
|
||||||
|
const cy = rect.top + rect.height / 2;
|
||||||
|
const count = 12;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const particle = document.createElement('div');
|
||||||
|
particle.className = 'webgl-particle-burst';
|
||||||
|
const angle = (i / count) * Math.PI * 2;
|
||||||
|
const dist = 40 + Math.random() * 60;
|
||||||
|
const dx = Math.cos(angle) * dist;
|
||||||
|
const dy = Math.sin(angle) * dist;
|
||||||
|
const size = 2 + Math.random() * 4;
|
||||||
|
|
||||||
|
Object.assign(particle.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${cx}px`,
|
||||||
|
top: `${cy}px`,
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: color,
|
||||||
|
boxShadow: `0 0 ${size * 2}px ${color}`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: '9999',
|
||||||
|
transition: 'all 0.6s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
|
opacity: '1',
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(particle);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
particle.style.transform = `translate(${dx}px, ${dy}px) scale(0)`;
|
||||||
|
particle.style.opacity = '0';
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => particle.remove(), 700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Scroll-triggered card wrapper ---------- */
|
||||||
|
export function WebGLCard({ children, color = '#5bd1d8', delay = 0 }: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
color?: string;
|
||||||
|
delay?: number;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const hasAnimated = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting && !hasAnimated.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setVisible(true);
|
||||||
|
hasAnimated.current = true;
|
||||||
|
createParticleBurst(el, color);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [color, delay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`webgl-card ${visible ? 'webgl-card--visible' : ''}`}
|
||||||
|
style={{ '--card-accent': color } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Media lightbox (photo/video front view) ---------- */
|
||||||
|
export function MediaLightbox() {
|
||||||
|
const [active, setActive] = useState<{ type: 'img' | 'video'; src: string; alt?: string } | null>(null);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for clicks on photos and videos
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// Photo click
|
||||||
|
if (target.matches('.photo-strip-img, .webgl-media-trigger img')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const img = target as HTMLImageElement;
|
||||||
|
setActive({ type: 'img', src: img.src, alt: img.alt });
|
||||||
|
createParticleBurst(target, '#5bd1d8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video click
|
||||||
|
if (target.matches('.video-strip-clip, .webgl-media-trigger video')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const video = target as HTMLVideoElement;
|
||||||
|
setActive({ type: 'video', src: video.src });
|
||||||
|
createParticleBurst(target, '#f1c27a');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handler, true);
|
||||||
|
return () => document.removeEventListener('click', handler, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setActive(null);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className="media-lightbox"
|
||||||
|
onClick={(e) => { if (e.target === overlayRef.current) setActive(null); }}
|
||||||
|
>
|
||||||
|
{/* Energy ring animation */}
|
||||||
|
<div className="lightbox-ring lightbox-ring--1" />
|
||||||
|
<div className="lightbox-ring lightbox-ring--2" />
|
||||||
|
<div className="lightbox-ring lightbox-ring--3" />
|
||||||
|
|
||||||
|
<div className="lightbox-content">
|
||||||
|
{active.type === 'img' ? (
|
||||||
|
<img src={active.src} alt={active.alt || ''} className="lightbox-media" />
|
||||||
|
) : (
|
||||||
|
<video src={active.src} controls autoPlay muted loop className="lightbox-media" />
|
||||||
|
)}
|
||||||
|
<button className="lightbox-close" onClick={() => setActive(null)} aria-label="Fermer">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Scroll-triggered section with atomic reveal ---------- */
|
||||||
|
export function WebGLSection({ children, id, className = '' }: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
setVisible(entry.isIntersecting);
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className={`webgl-section ${visible ? 'webgl-section--visible' : ''} ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+227
-71
@@ -1,8 +1,10 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||||
import AtomGlitch from '@/components/AtomGlitch';
|
import WebGLBackground from '@/components/WebGLBackground';
|
||||||
|
import { MediaLightbox } from '@/components/WebGLCards';
|
||||||
import '@/styles/global.css';
|
import '@/styles/global.css';
|
||||||
import '@/styles/home-workbench.css';
|
import '@/styles/home-workbench.css';
|
||||||
|
import '@/styles/webgl-cards.css';
|
||||||
import SiteHeader from '@/components/SiteHeader.astro';
|
import SiteHeader from '@/components/SiteHeader.astro';
|
||||||
import { Hero } from '@/components/sections/Hero';
|
import { Hero } from '@/components/sections/Hero';
|
||||||
import { About } from '@/components/sections/About';
|
import { About } from '@/components/sections/About';
|
||||||
@@ -25,31 +27,37 @@ const headerItems = [
|
|||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
|
<!-- WebGL background layer — fixed, subtle, always visible -->
|
||||||
|
<WebGLBackground client:load />
|
||||||
|
<!-- Media lightbox (click photos/videos to view full) -->
|
||||||
|
<MediaLightbox client:load />
|
||||||
|
|
||||||
<SiteHeader brandHref="#top" items={headerItems} quickHref="#contact" />
|
<SiteHeader brandHref="#top" items={headerItems} quickHref="#contact" />
|
||||||
|
|
||||||
<main id="main-content" class="site-shell studio-structure pb-12 pt-3">
|
<main id="main-content" class="dark-site">
|
||||||
<section class="structure-grid structure-grid--hero hero-webgl-wrapper" data-reveal>
|
<!-- HERO -->
|
||||||
<div class="hero-webgl-bg">
|
<section class="section-dark section-hero webgl-section" id="top">
|
||||||
<AtomGlitch client:visible />
|
<div class="section-content">
|
||||||
</div>
|
|
||||||
<div class="structure-cell structure-cell--hero hero-webgl-content">
|
|
||||||
<Hero />
|
<Hero />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="structure-grid structure-grid--systems" data-reveal>
|
<!-- ABOUT -->
|
||||||
<div class="structure-cell structure-cell--about">
|
<section class="section-dark webgl-section" id="a-propos">
|
||||||
|
<div class="section-content webgl-card" style="--card-accent: #5bd1d8">
|
||||||
<About />
|
<About />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="structure-grid structure-grid--cases" data-reveal>
|
<!-- CASE STUDIES -->
|
||||||
<div class="structure-cell structure-cell--cases">
|
<section class="section-dark webgl-section">
|
||||||
|
<div class="section-content webgl-card" style="--card-accent: #f1c27a">
|
||||||
<CaseStudies />
|
<CaseStudies />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="photo-strip" aria-label="Photos de terrain" data-reveal>
|
<!-- PHOTO STRIP -->
|
||||||
|
<section class="photo-strip section-dark" aria-label="Photos de terrain" data-reveal>
|
||||||
<div class="photo-strip-track">
|
<div class="photo-strip-track">
|
||||||
<img src={withSiteBase('/assets/photos/pcb-teensy-led-kxkm.webp')} alt="PCB custom Teensy LED — projet spectacle vivant KXKM" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
|
<img src={withSiteBase('/assets/photos/pcb-teensy-led-kxkm.webp')} alt="PCB custom Teensy LED — projet spectacle vivant KXKM" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
|
||||||
<img src={withSiteBase('/assets/photos/platine-automate-cablage.webp')} alt="Câblage automate industriel — intervention terrain" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
|
<img src={withSiteBase('/assets/photos/platine-automate-cablage.webp')} alt="Câblage automate industriel — intervention terrain" loading="lazy" decoding="async" width="280" height="190" class="photo-strip-img" />
|
||||||
@@ -64,7 +72,8 @@ const headerItems = [
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="video-strip" aria-label="Vidéos de terrain" data-reveal>
|
<!-- VIDEO STRIP -->
|
||||||
|
<section class="video-strip section-dark" aria-label="Vidéos de terrain" data-reveal>
|
||||||
<div class="video-strip-track">
|
<div class="video-strip-track">
|
||||||
<video src={withSiteBase('/assets/videos/video-bench-electronique.mp4')} poster={withSiteBase('/assets/videos/video-bench-electronique-poster.webp')} controls muted loop playsinline preload="none" width="320" height="180" class="video-strip-clip" aria-label="Bench électronique en action"></video>
|
<video src={withSiteBase('/assets/videos/video-bench-electronique.mp4')} poster={withSiteBase('/assets/videos/video-bench-electronique-poster.webp')} controls muted loop playsinline preload="none" width="320" height="180" class="video-strip-clip" aria-label="Bench électronique en action"></video>
|
||||||
<video src={withSiteBase('/assets/videos/video-test-prototype.mp4')} poster={withSiteBase('/assets/videos/video-test-prototype-poster.webp')} controls muted loop playsinline preload="none" width="320" height="180" class="video-strip-clip" aria-label="Test de prototype"></video>
|
<video src={withSiteBase('/assets/videos/video-test-prototype.mp4')} poster={withSiteBase('/assets/videos/video-test-prototype-poster.webp')} controls muted loop playsinline preload="none" width="320" height="180" class="video-strip-clip" aria-label="Test de prototype"></video>
|
||||||
@@ -72,36 +81,35 @@ const headerItems = [
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="structure-grid structure-grid--sprints" data-reveal>
|
<!-- SPRINTS -->
|
||||||
<div class="structure-cell structure-cell--sprints">
|
<section class="section-dark webgl-section" id="graphic-sprints-title">
|
||||||
|
<div class="section-content webgl-card" style="--card-accent: #b6d18f">
|
||||||
<GraphicSprints />
|
<GraphicSprints />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="structure-grid structure-grid--faq" data-reveal>
|
<!-- FAQ -->
|
||||||
<div class="structure-cell structure-cell--faq">
|
<section class="section-dark webgl-section" id="faq">
|
||||||
|
<div class="section-content webgl-card" style="--card-accent: #5bd1d8">
|
||||||
<Faq />
|
<Faq />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="structure-grid structure-grid--conversion" data-reveal>
|
<!-- CONTACT -->
|
||||||
<div class="structure-cell structure-cell--contact">
|
<section class="section-dark section-contact webgl-section" id="contact">
|
||||||
|
<div class="section-content webgl-card" style="--card-accent: #ff6b35">
|
||||||
<Contact />
|
<Contact />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer border-t border-[var(--line)] py-6 text-sm text-[var(--text-muted)]">
|
<footer class="dark-footer">
|
||||||
<div class="site-shell footer-grid footer-grid--enhanced">
|
<div class="site-shell footer-grid footer-grid--enhanced">
|
||||||
<div class="footer-col footer-col--brand">
|
<div class="footer-col footer-col--brand">
|
||||||
<p class="m-0 footer-title">L'électron rare</p>
|
<p class="m-0 footer-title">L'électron rare</p>
|
||||||
<div class="footer-brand-meta">
|
<div class="footer-brand-meta">
|
||||||
<p class="mb-0 footer-copy">
|
<p class="mb-0 footer-copy">© 2026 Clément Saillant - systèmes électroniques spécifiques, conseil, formation — industrie, culture et projets multi-techniques.</p>
|
||||||
© 2026 Clément Saillant - systèmes électroniques spécifiques, conseil, formation — industrie, culture et projets multi-techniques.
|
<p class="mb-0 footer-copy footer-copy--right">Contact direct : LinkedIn DM ou contact@lelectronrare.fr</p>
|
||||||
</p>
|
|
||||||
<p class="mb-0 footer-copy footer-copy--right">
|
|
||||||
Contact direct : LinkedIn DM ou contact@lelectronrare.fr
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label="Ressources studio" class="footer-links footer-links--single-rail">
|
<nav aria-label="Ressources studio" class="footer-links footer-links--single-rail">
|
||||||
@@ -110,38 +118,9 @@ const headerItems = [
|
|||||||
<a href={formationHref} class="footer-link">Formation</a>
|
<a href={formationHref} class="footer-link">Formation</a>
|
||||||
<a href="#faq" class="footer-link">FAQ</a>
|
<a href="#faq" class="footer-link">FAQ</a>
|
||||||
<a href="#contact" class="footer-link">Contact</a>
|
<a href="#contact" class="footer-link">Contact</a>
|
||||||
<a
|
<a href="https://fr.linkedin.com/in/electron-rare" target="_blank" rel="noopener noreferrer me" {...trackAttrs(TRACK_EVENTS.outboundLinkedinContact, 'linkedin.com')} class="footer-link">LinkedIn</a>
|
||||||
href="https://fr.linkedin.com/in/electron-rare"
|
<a href="https://github.com/electron-rare/" target="_blank" rel="noopener noreferrer me" {...trackAttrs(TRACK_EVENTS.outboundGithubContact, 'github.com')} class="footer-link">GitHub</a>
|
||||||
target="_blank"
|
<a href="https://blog.saillant.cc" target="_blank" rel="noopener noreferrer me" class="footer-link">Blog technique</a>
|
||||||
rel="noopener noreferrer me"
|
|
||||||
{...trackAttrs(TRACK_EVENTS.outboundLinkedinContact, 'linkedin.com')}
|
|
||||||
class="footer-link"
|
|
||||||
>
|
|
||||||
LinkedIn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="#contact"
|
|
||||||
class="footer-link"
|
|
||||||
>
|
|
||||||
Contact
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://github.com/electron-rare/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer me"
|
|
||||||
{...trackAttrs(TRACK_EVENTS.outboundGithubContact, 'github.com')}
|
|
||||||
class="footer-link"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://blog.saillant.cc"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer me"
|
|
||||||
class="footer-link"
|
|
||||||
>
|
|
||||||
Blog technique
|
|
||||||
</a>
|
|
||||||
<a href={mentionsHref} class="footer-link">Mentions légales</a>
|
<a href={mentionsHref} class="footer-link">Mentions légales</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,22 +128,199 @@ const headerItems = [
|
|||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hero-webgl-wrapper {
|
/* ============ DARK TECH THEME ============ */
|
||||||
position: relative;
|
.dark-site {
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.hero-webgl-bg {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 0;
|
|
||||||
opacity: 0.25;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.hero-webgl-content {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
background: transparent;
|
||||||
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-dark {
|
||||||
|
position: relative;
|
||||||
|
padding: clamp(40px, 8vw, 100px) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-hero {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 clamp(20px, 4vw, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass cards for content sections */
|
||||||
|
.section-dark :global(.circuit-board-section),
|
||||||
|
.section-dark :global(.about-card),
|
||||||
|
.section-dark :global(.case-card),
|
||||||
|
.section-dark :global(.sprint-card) {
|
||||||
|
background: rgba(255, 255, 255, 0.03) !important;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06) !important;
|
||||||
|
color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text overrides for dark */
|
||||||
|
.section-dark :global(h1),
|
||||||
|
.section-dark :global(h2),
|
||||||
|
.section-dark :global(h3),
|
||||||
|
.section-dark :global(.section-title),
|
||||||
|
.section-dark :global(.hero-title-word) {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-dark :global(p),
|
||||||
|
.section-dark :global(li),
|
||||||
|
.section-dark :global(.about-bio),
|
||||||
|
.section-dark :global(.case-text),
|
||||||
|
.section-dark :global(.sprint-deliverables) {
|
||||||
|
color: rgba(255, 255, 255, 0.7) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-dark :global(a) {
|
||||||
|
color: #5bd1d8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-dark :global(.hero-slogan-char) {
|
||||||
|
color: rgba(91, 209, 216, 0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo strip dark */
|
||||||
|
.section-dark.photo-strip {
|
||||||
|
padding: clamp(20px, 4vw, 40px) 0;
|
||||||
|
}
|
||||||
|
.section-dark :global(.photo-strip-img) {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.section-dark :global(.photo-strip-img:hover) {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video strip dark */
|
||||||
|
.section-dark :global(.video-strip-clip) {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FAQ dark */
|
||||||
|
.section-dark :global(.faq-question) {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
.section-dark :global(.faq-answer) {
|
||||||
|
color: rgba(255, 255, 255, 0.6) !important;
|
||||||
|
}
|
||||||
|
.section-dark :global(details) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chips/badges glow */
|
||||||
|
.section-dark :global(.about-chip) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
background: rgba(255, 255, 255, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sprint progress bars glow */
|
||||||
|
.section-dark :global(.sprint-progress-fill) {
|
||||||
|
box-shadow: 0 0 12px rgba(91, 209, 216, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CTA buttons */
|
||||||
|
.section-dark :global(.btn-primary),
|
||||||
|
.section-dark :global([data-track]) {
|
||||||
|
background: rgba(91, 209, 216, 0.15) !important;
|
||||||
|
border: 1px solid rgba(91, 209, 216, 0.3) !important;
|
||||||
|
color: #5bd1d8 !important;
|
||||||
|
}
|
||||||
|
.section-dark :global(.btn-primary:hover) {
|
||||||
|
background: rgba(91, 209, 216, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contact form dark (Minitel) */
|
||||||
|
.section-dark :global(.minitel-body) {
|
||||||
|
background: rgba(0, 0, 0, 0.6) !important;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
.section-dark :global(.minitel-input),
|
||||||
|
.section-dark :global(.minitel-textarea),
|
||||||
|
.section-dark :global(.minitel-select) {
|
||||||
|
background: rgba(255, 255, 255, 0.05) !important;
|
||||||
|
border-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero background image — dark blend */
|
||||||
|
.section-dark :global(.figma-lab-hero-grid) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.section-dark :global(.hero-panel-image) {
|
||||||
|
opacity: 0.3 !important;
|
||||||
|
mix-blend-mode: luminosity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ DARK FOOTER ============ */
|
||||||
|
.dark-footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
padding: 24px 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.dark-footer :global(.footer-title) {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.dark-footer :global(.footer-copy) {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
.dark-footer :global(.footer-link) {
|
||||||
|
color: rgba(255, 255, 255, 0.5) !important;
|
||||||
|
}
|
||||||
|
.dark-footer :global(.footer-link:hover) {
|
||||||
|
color: #5bd1d8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.hero-webgl-bg { display: none; }
|
.section-hero { min-height: auto; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Initialize scroll-triggered webgl-card and webgl-section animations
|
||||||
|
function initScrollAnimations() {
|
||||||
|
const cards = document.querySelectorAll('.webgl-card');
|
||||||
|
const sections = document.querySelectorAll('.webgl-section');
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add(
|
||||||
|
entry.target.classList.contains('webgl-card')
|
||||||
|
? 'webgl-card--visible'
|
||||||
|
: 'webgl-section--visible'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.15 }
|
||||||
|
);
|
||||||
|
|
||||||
|
cards.forEach((el) => observer.observe(el));
|
||||||
|
sections.forEach((el) => observer.observe(el));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on load and on Astro page transitions
|
||||||
|
initScrollAnimations();
|
||||||
|
document.addEventListener('astro:page-load', initScrollAnimations);
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -47,6 +47,81 @@
|
|||||||
--transition-spring: 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
--transition-spring: 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Dark Tech Variant (WebGL refonte) ──────────────── */
|
||||||
|
.dark-site {
|
||||||
|
--bg: #080808;
|
||||||
|
--bg-secondary: #111111;
|
||||||
|
--surface: rgba(255, 255, 255, 0.03);
|
||||||
|
--surface-soft: rgba(255, 255, 255, 0.05);
|
||||||
|
--surface-alt: rgba(255, 255, 255, 0.02);
|
||||||
|
--surface-glass: rgba(255, 255, 255, 0.04);
|
||||||
|
--pcb-deep: #0a0a0a;
|
||||||
|
--pcb-core: #111111;
|
||||||
|
--text: #e8e8e8;
|
||||||
|
--text-muted: #9a9a9a;
|
||||||
|
--text-dim: #666666;
|
||||||
|
--accent: #5bd1d8;
|
||||||
|
--accent-hover: #7de0e6;
|
||||||
|
--accent-soft: rgba(91, 209, 216, 0.1);
|
||||||
|
--accent-warm: #ff6b35;
|
||||||
|
--accent-green: #30d158;
|
||||||
|
--trace-cyan: #5bd1d8;
|
||||||
|
--trace-cyan-soft: rgba(91, 209, 216, 0.15);
|
||||||
|
--trace-magenta: #ff6b6b;
|
||||||
|
--trace-magenta-soft: rgba(255, 107, 107, 0.12);
|
||||||
|
--trace-green: #b6d18f;
|
||||||
|
--trace-green-soft: rgba(182, 209, 143, 0.12);
|
||||||
|
--trace-amber: #f1c27a;
|
||||||
|
--trace-amber-soft: rgba(241, 194, 122, 0.12);
|
||||||
|
--signal: #ff6b6b;
|
||||||
|
--electric: #5bd1d8;
|
||||||
|
--line: rgba(255, 255, 255, 0.06);
|
||||||
|
--line-soft: rgba(255, 255, 255, 0.03);
|
||||||
|
--line-edge: rgba(255, 255, 255, 0.08);
|
||||||
|
--line-rail: rgba(91, 209, 216, 0.08);
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-xl: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-site ::selection {
|
||||||
|
color: #000;
|
||||||
|
background: #5bd1d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark site header */
|
||||||
|
.dark-site ~ header,
|
||||||
|
.dark-site ~ .site-header,
|
||||||
|
body:has(.dark-site) header {
|
||||||
|
background: rgba(8, 8, 8, 0.7) !important;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
body:has(.dark-site) .site-header-brand-title {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
body:has(.dark-site) .site-header-brand-kicker {
|
||||||
|
color: rgba(255, 255, 255, 0.4) !important;
|
||||||
|
}
|
||||||
|
body:has(.dark-site) .site-header-item {
|
||||||
|
color: rgba(255, 255, 255, 0.6) !important;
|
||||||
|
}
|
||||||
|
body:has(.dark-site) .site-header-item:hover {
|
||||||
|
color: #5bd1d8 !important;
|
||||||
|
}
|
||||||
|
body:has(.dark-site) .site-header-quick {
|
||||||
|
color: #5bd1d8 !important;
|
||||||
|
border-color: rgba(91, 209, 216, 0.3) !important;
|
||||||
|
}
|
||||||
|
body:has(.dark-site) body {
|
||||||
|
background: #080808;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Reset & Base ────────────────────────────────────── */
|
/* ── Reset & Base ────────────────────────────────────── */
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════
|
||||||
|
WEBGL CARDS — Atomic scroll animations + lightbox
|
||||||
|
═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Card materialize animation ─────────────────────── */
|
||||||
|
.webgl-card {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px) scale(0.95);
|
||||||
|
filter: blur(6px);
|
||||||
|
transition:
|
||||||
|
opacity 0.8s cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
|
transform 0.8s cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
|
filter 0.8s cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
|
box-shadow 0.4s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webgl-card--visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow border on appear */
|
||||||
|
.webgl-card--visible::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--card-accent, #5bd1d8) 0%,
|
||||||
|
transparent 40%,
|
||||||
|
transparent 60%,
|
||||||
|
var(--card-accent, #5bd1d8) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
opacity: 0;
|
||||||
|
animation: glow-border-in 1.2s 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-border-in {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
100% { opacity: 0.15; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card hover tilt effect */
|
||||||
|
.webgl-card--visible:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(91, 209, 216, 0.08),
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
transform: translateY(-2px) scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section atomic reveal ──────────────────────────── */
|
||||||
|
.webgl-section {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(40px);
|
||||||
|
transition:
|
||||||
|
opacity 1s cubic-bezier(0.16, 1, 0.3, 1),
|
||||||
|
transform 1s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webgl-section--visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staggered children reveal */
|
||||||
|
.webgl-section--visible > *:nth-child(1) { transition-delay: 0s; }
|
||||||
|
.webgl-section--visible > *:nth-child(2) { transition-delay: 0.1s; }
|
||||||
|
.webgl-section--visible > *:nth-child(3) { transition-delay: 0.2s; }
|
||||||
|
.webgl-section--visible > *:nth-child(4) { transition-delay: 0.3s; }
|
||||||
|
.webgl-section--visible > *:nth-child(5) { transition-delay: 0.4s; }
|
||||||
|
|
||||||
|
/* ── Photo/Video hover effect ───────────────────────── */
|
||||||
|
.photo-strip-img,
|
||||||
|
.video-strip-clip {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s ease, filter 0.3s ease;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-strip-img:hover,
|
||||||
|
.video-strip-clip:hover {
|
||||||
|
transform: scale(1.05) translateY(-4px);
|
||||||
|
box-shadow:
|
||||||
|
0 0 24px rgba(91, 209, 216, 0.15),
|
||||||
|
0 12px 40px rgba(0, 0, 0, 0.4);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
border-color: rgba(91, 209, 216, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Lightbox ───────────────────────────────────────── */
|
||||||
|
.media-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
animation: lightbox-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lightbox-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content {
|
||||||
|
position: relative;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
cursor: default;
|
||||||
|
animation: lightbox-content-in 0.5s 0.1s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lightbox-content-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8) translateY(20px);
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-media {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow:
|
||||||
|
0 0 60px rgba(91, 209, 216, 0.1),
|
||||||
|
0 24px 80px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #ffffff;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Energy rings around lightbox media ──────────────── */
|
||||||
|
.lightbox-ring {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
border: 1px solid rgba(91, 209, 216, 0.15);
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-ring--1 {
|
||||||
|
width: 110%;
|
||||||
|
height: 110%;
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg);
|
||||||
|
animation: ring-orbit 8s linear infinite;
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: rgba(91, 209, 216, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-ring--2 {
|
||||||
|
width: 120%;
|
||||||
|
height: 120%;
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg);
|
||||||
|
animation: ring-orbit 12s linear infinite reverse;
|
||||||
|
border-color: rgba(241, 194, 122, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-ring--3 {
|
||||||
|
width: 130%;
|
||||||
|
height: 60%;
|
||||||
|
transform: translate(-50%, -50%) rotate(30deg);
|
||||||
|
animation: ring-orbit 6s linear infinite;
|
||||||
|
border-color: rgba(182, 209, 143, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ring-orbit {
|
||||||
|
from { transform: translate(-50%, -50%) rotate(0deg); }
|
||||||
|
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Particle burst (injected by JS) ─────────────────── */
|
||||||
|
.webgl-particle-burst {
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reduced motion ──────────────────────────────────── */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.webgl-card,
|
||||||
|
.webgl-section {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
filter: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
.webgl-card--visible::before { display: none; }
|
||||||
|
.lightbox-ring { display: none; }
|
||||||
|
.lightbox-content { animation: none; opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user