/* dust.jsx — site-wide falling gold-dust background + Tweaks panel.
Self-contained: owns the tweak state, renders a fixed
full-viewport canvas behind all content, and the Tweaks panel that drives it. */
const { useRef: useRefD, useEffect: useEffectD } = React;
const DUST_DEFAULTS = /*EDITMODE-BEGIN*/{
"dustOn": true,
"dustDirection": "falling",
"dustAmount": 80,
"dustSpeed": 1,
"dustSize": 1,
"dustGlow": true,
"dustPalette": ["#c19a51", "#e8cf94", "#fff4d8", "#b3893f", "#d99a52"]
}/*EDITMODE-END*/;
const DUST_PALETTES = [
["#c19a51", "#e8cf94", "#fff4d8", "#b3893f", "#d99a52"], // classic gold
["#e8cf94", "#fff4d8", "#fffaf0", "#f0dca8"], // champagne
["#d98c6a", "#e7c2a0", "#f6e3cf", "#c47044"], // warm copper
["#dfe7ef", "#ffffff", "#cdd8e6", "#aebfd4"], // silver / snow
];
const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* build a soft round sprite per color once, reuse via drawImage (fast) */
function makeSprite(color, glow) {
const S = 64;
const c = document.createElement('canvas');
c.width = c.height = S;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
g.addColorStop(0, color);
g.addColorStop(glow ? 0.35 : 0.5, color);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(S / 2, S / 2, S / 2, 0, Math.PI * 2);
ctx.fill();
return c;
}
function GoldDustCanvas({ on, direction, amount, speed, size, glow, palette }) {
const canvasRef = useRefD(null);
const sim = useRefD({ parts: [], sprites: [], raf: 0, w: 0, h: 0, dpr: 1 });
// (re)build sprites when palette / glow changes
useEffectD(() => {
sim.current.sprites = palette.map((c) => makeSprite(c, glow));
}, [palette, glow]);
useEffectD(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const s = sim.current;
const resize = () => {
s.dpr = Math.min(window.devicePixelRatio || 1, 2);
s.w = window.innerWidth; s.h = window.innerHeight;
canvas.width = s.w * s.dpr; canvas.height = s.h * s.dpr;
canvas.style.width = s.w + 'px'; canvas.style.height = s.h + 'px';
ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);
};
resize();
const spawn = (atEdge) => {
const falling = direction === 'falling';
const drift = direction === 'drift';
const r = (1.1 + Math.random() * 3.4) * size;
return {
x: Math.random() * s.w,
y: atEdge
? (falling ? -10 - Math.random() * s.h * 0.3 : s.h + 10 + Math.random() * s.h * 0.3)
: Math.random() * s.h,
r,
// base vertical velocity (px/frame @60fps); drift is gentle both ways
vy: (drift ? (Math.random() - 0.5) * 0.25 : (0.18 + Math.random() * 0.5))
* (falling ? 1 : -1) * (1 + (r - 2) * 0.18),
sway: 0.3 + Math.random() * 0.9,
swayPhase: Math.random() * Math.PI * 2,
swaySpeed: 0.006 + Math.random() * 0.012,
baseAlpha: 0.35 + Math.random() * 0.5,
twPhase: Math.random() * Math.PI * 2,
twSpeed: 0.01 + Math.random() * 0.02,
spriteI: (Math.random() * Math.max(1, s.sprites.length)) | 0,
};
};
const build = () => {
const N = reduceMotion ? Math.min(20, amount) : amount;
s.parts = Array.from({ length: N }, () => spawn(false));
};
build();
let last = performance.now();
const tick = (now) => {
const dt = Math.min(2.4, (now - last) / 16.67); // frames elapsed, capped
last = now;
ctx.clearRect(0, 0, s.w, s.h);
const falling = direction === 'falling';
const drift = direction === 'drift';
for (const p of s.parts) {
p.y += p.vy * speed * dt;
p.swayPhase += p.swaySpeed * dt;
p.x += Math.sin(p.swayPhase) * p.sway * speed * dt;
p.twPhase += p.twSpeed * dt;
// recycle off-screen
if (falling && p.y - p.r > s.h) { Object.assign(p, spawn(true), { y: -10 - Math.random() * 40 }); }
else if (!falling && !drift && p.y + p.r < 0) { Object.assign(p, spawn(true), { y: s.h + 10 + Math.random() * 40 }); }
else if (drift) {
if (p.y - p.r > s.h) p.y = -10;
if (p.y + p.r < 0) p.y = s.h + 10;
}
if (p.x < -20) p.x = s.w + 20; else if (p.x > s.w + 20) p.x = -20;
const tw = 0.6 + 0.4 * Math.sin(p.twPhase);
ctx.globalAlpha = Math.max(0, Math.min(1, p.baseAlpha * tw));
const spr = s.sprites[p.spriteI % s.sprites.length];
if (spr) {
const d = p.r * (glow ? 4.2 : 2.6);
ctx.drawImage(spr, p.x - d / 2, p.y - d / 2, d, d);
}
}
ctx.globalAlpha = 1;
s.raf = requestAnimationFrame(tick);
};
if (on) s.raf = requestAnimationFrame(tick);
window.addEventListener('resize', resize);
return () => { cancelAnimationFrame(s.raf); window.removeEventListener('resize', resize); };
}, [on, direction, amount, speed, size, glow, palette]);
if (!on) return null;
return (
);
}
function GoldDustControls() {
const [t, setTweak] = useTweaks(DUST_DEFAULTS);
return (
<>
setTweak('dustOn', v)} />
setTweak('dustDirection', v)} />
setTweak('dustAmount', v)} />
setTweak('dustSpeed', v)} />
setTweak('dustSize', v)} />
setTweak('dustGlow', v)} />
setTweak('dustPalette', v)} />
>
);
}
Object.assign(window, { GoldDustControls });