// Procedural artwork surfaces + ambient effects (no SVG art, no stock images)
const { useEffect, useRef, useState } = React;
// --- deterministic hash so each piece looks different but stable
function hash(seed) {
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
return () => {
h = (h * 1664525 + 1013904223) | 0;
return ((h >>> 0) % 100000) / 100000;
};
}
// Procedural artwork surface — abstract painted texture from a piece's palette.
// Reads as art, not as a placeholder gray box.
function ArtSurface({ piece, density = 1, className = "", style = {} }) {
const ref = useRef(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = canvas.clientWidth, h = canvas.clientHeight;
canvas.width = w * dpr; canvas.height = h * dpr;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
const rand = hash(piece.id);
const [c0, c1, c2, c3] = piece.palette;
// base gradient (deep ground)
const g = ctx.createLinearGradient(0, 0, w, h);
g.addColorStop(0, c0);
g.addColorStop(0.6, c1);
g.addColorStop(1, c0);
ctx.fillStyle = g; ctx.fillRect(0, 0, w, h);
// wash blobs (mid tone)
for (let i = 0; i < 28 * density; i++) {
const x = rand() * w, y = rand() * h, r = (40 + rand() * 280) * (0.6 + density * 0.4);
const rg = ctx.createRadialGradient(x, y, 0, x, y, r);
rg.addColorStop(0, c1 + "");
rg.addColorStop(1, "rgba(0,0,0,0)");
ctx.globalAlpha = 0.18 + rand() * 0.22;
ctx.fillStyle = rg;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
}
ctx.globalAlpha = 1;
// brushstroke streaks (highlight palette)
ctx.lineCap = "round";
for (let i = 0; i < 80 * density; i++) {
const x0 = rand() * w, y0 = rand() * h;
const ang = rand() * Math.PI * 2;
const len = 30 + rand() * 220;
const x1 = x0 + Math.cos(ang) * len, y1 = y0 + Math.sin(ang) * len;
const col = rand() > 0.7 ? c3 : c2;
ctx.strokeStyle = col;
ctx.globalAlpha = 0.04 + rand() * 0.18;
ctx.lineWidth = 0.6 + rand() * 3.5;
ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.stroke();
}
// fine grain (pigment)
const img = ctx.getImageData(0, 0, w, h);
for (let i = 0; i < img.data.length; i += 4) {
const n = (rand() - 0.5) * 22;
img.data[i] = Math.max(0, Math.min(255, img.data[i] + n));
img.data[i+1] = Math.max(0, Math.min(255, img.data[i+1] + n));
img.data[i+2] = Math.max(0, Math.min(255, img.data[i+2] + n));
}
ctx.putImageData(img, 0, 0);
// foil scratches (top-layer accent)
ctx.globalAlpha = 1;
for (let i = 0; i < 16 * density; i++) {
const x = rand() * w, y = rand() * h;
const ang = rand() * Math.PI * 2;
const len = 8 + rand() * 60;
ctx.strokeStyle = c3;
ctx.globalAlpha = 0.35 + rand() * 0.35;
ctx.lineWidth = 0.4 + rand() * 0.8;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + Math.cos(ang) * len, y + Math.sin(ang) * len);
ctx.stroke();
}
ctx.globalAlpha = 1;
// vignette
const v = ctx.createRadialGradient(w/2, h/2, Math.min(w,h)*0.3, w/2, h/2, Math.max(w,h)*0.75);
v.addColorStop(0, "rgba(0,0,0,0)");
v.addColorStop(1, "rgba(0,0,0,0.5)");
ctx.fillStyle = v; ctx.fillRect(0, 0, w, h);
}, [piece.id, density]);
return (
);
}
// Ambient ink canvas for the hero — slow, gravity-less particles
function InkField({ accent = "#a8825a" }) {
const ref = useRef(null);
const mouseRef = useRef({ x: 0.5, y: 0.5, active: false });
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let w, h, raf;
const resize = () => {
w = canvas.clientWidth; h = canvas.clientHeight;
canvas.width = w * dpr; canvas.height = h * dpr;
};
resize();
window.addEventListener("resize", resize);
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
const N = 90;
const parts = Array.from({ length: N }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.18,
vy: (Math.random() - 0.5) * 0.18,
r: 0.6 + Math.random() * 2.2,
life: Math.random(),
}));
const onMove = (e) => {
const r = canvas.getBoundingClientRect();
mouseRef.current.x = (e.clientX - r.left) / r.width;
mouseRef.current.y = (e.clientY - r.top) / r.height;
mouseRef.current.active = true;
};
window.addEventListener("mousemove", onMove);
let t = 0;
const tick = () => {
t += 0.005;
ctx.fillStyle = "rgba(12, 10, 8, 0.10)";
ctx.fillRect(0, 0, w, h);
// slow drifting nebula
const cx = (mouseRef.current.x) * w;
const cy = (mouseRef.current.y) * h;
const rg = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(w, h) * 0.5);
rg.addColorStop(0, accent + "26");
rg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = rg;
ctx.fillRect(0, 0, w, h);
for (const p of parts) {
// gentle field
const ang = Math.sin(p.x * 0.003 + t) + Math.cos(p.y * 0.003 - t);
p.vx += Math.cos(ang) * 0.004;
p.vy += Math.sin(ang) * 0.004;
// mouse repulsion
const dx = p.x - cx, dy = p.y - cy;
const d2 = dx*dx + dy*dy;
if (d2 < 22000) {
const f = (22000 - d2) / 22000;
p.vx += (dx / Math.sqrt(d2 + 1)) * f * 0.3;
p.vy += (dy / Math.sqrt(d2 + 1)) * f * 0.3;
}
p.vx *= 0.96; p.vy *= 0.96;
p.x += p.vx; p.y += p.vy;
if (p.x < 0) p.x += w; if (p.x > w) p.x -= w;
if (p.y < 0) p.y += h; if (p.y > h) p.y -= h;
ctx.beginPath();
ctx.fillStyle = "rgba(240, 232, 219, " + (0.18 + Math.sin(p.life * 9 + t * 4) * 0.12) + ")";
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
raf = requestAnimationFrame(tick);
};
tick();
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
window.removeEventListener("mousemove", onMove);
};
}, [accent]);
return ;
}
// Custom cursor — small dot + lagging ring
function Cursor() {
const dotRef = useRef(null);
const ringRef = useRef(null);
const stateRef = useRef({ x: 0, y: 0, tx: 0, ty: 0, hover: false });
useEffect(() => {
if (matchMedia("(pointer: coarse)").matches) return;
const onMove = (e) => {
stateRef.current.tx = e.clientX;
stateRef.current.ty = e.clientY;
if (dotRef.current) {
dotRef.current.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
}
};
const onOver = (e) => {
const t = e.target;
const hover = !!(t && t.closest && t.closest("[data-cursor]"));
stateRef.current.hover = hover;
const label = hover ? t.closest("[data-cursor]").getAttribute("data-cursor") : "";
if (ringRef.current) {
ringRef.current.dataset.hover = hover ? "1" : "0";
ringRef.current.querySelector(".c-label").textContent = label || "";
}
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseover", onOver);
let raf;
const tick = () => {
const s = stateRef.current;
s.x += (s.tx - s.x) * 0.18;
s.y += (s.ty - s.y) * 0.18;
if (ringRef.current) {
ringRef.current.style.transform = `translate(${s.x}px, ${s.y}px)`;
}
raf = requestAnimationFrame(tick);
};
tick();
document.documentElement.classList.add("has-custom-cursor");
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseover", onOver);
document.documentElement.classList.remove("has-custom-cursor");
};
}, []);
return (