From a4ee12732d0beeeccdbdbe7d588383d3dc1d4088 Mon Sep 17 00:00:00 2001 From: achraf Date: Tue, 31 Mar 2026 00:28:00 +0200 Subject: [PATCH] Implement achraf's portfolio --- .claude/settings.local.json | 13 ++ app/globals.css | 267 ++++++++++++++++++++-- app/layout.tsx | 35 ++- app/page.tsx | 97 ++++---- components/Contact.tsx | 300 +++++++++++++++++++++++++ components/Education.tsx | 170 ++++++++++++++ components/Experience.tsx | 195 +++++++++++++++++ components/GridCanvas.tsx | 181 +++++++++++++++ components/Hero.tsx | 413 +++++++++++++++++++++++++++++++++++ components/Navigation.tsx | 219 +++++++++++++++++++ components/Projects.tsx | 322 +++++++++++++++++++++++++++ components/SafeImage.tsx | 62 ++++++ components/Skills.tsx | 227 +++++++++++++++++++ data/portfolio.json | 311 ++++++++++++++++++++++++++ public/CLIPortfolioLogo.png | Bin 0 -> 174322 bytes public/ensa.png | Bin 0 -> 110906 bytes public/foon-logo.svg | 9 + public/me.png | Bin 0 -> 463107 bytes public/typesonlogo-light.png | Bin 0 -> 47585 bytes public/veolia_logo.png | Bin 0 -> 29425 bytes 20 files changed, 2734 insertions(+), 87 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 components/Contact.tsx create mode 100644 components/Education.tsx create mode 100644 components/Experience.tsx create mode 100644 components/GridCanvas.tsx create mode 100644 components/Hero.tsx create mode 100644 components/Navigation.tsx create mode 100644 components/Projects.tsx create mode 100644 components/SafeImage.tsx create mode 100644 components/Skills.tsx create mode 100644 data/portfolio.json create mode 100644 public/CLIPortfolioLogo.png create mode 100644 public/ensa.png create mode 100644 public/foon-logo.svg create mode 100644 public/me.png create mode 100644 public/typesonlogo-light.png create mode 100644 public/veolia_logo.png diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5e14b5b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(npx tsc:*)", + "Bash(npm run:*)", + "Bash(cp /c/Users/User/Documents/dev/cli-portfolio/public/CLIPortfolioLogo.png /c/Users/User/Documents/dev/achraf-portfolio/public/)", + "Bash(cp /c/Users/User/Documents/dev/cli-portfolio/public/typesonlogo-light.png /c/Users/User/Documents/dev/achraf-portfolio/public/)", + "Bash(cp /c/Users/User/Documents/dev/cli-portfolio/public/foon-logo.svg /c/Users/User/Documents/dev/achraf-portfolio/public/)", + "Bash(cp /c/Users/User/Documents/dev/cli-portfolio/public/me.png /c/Users/User/Documents/dev/achraf-portfolio/public/)", + "Bash(cp /c/Users/User/Documents/dev/cli-portfolio/public/ensa.png /c/Users/User/Documents/dev/achraf-portfolio/public/)" + ] + } +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..8fd3c99 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,259 @@ @import "tailwindcss"; -:root { - --background: #ffffff; - --foreground: #171717; -} - @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --color-bg: #07080a; + --color-surface: #0e1014; + --color-border: #1c1f26; + --color-border-dim: #141619; + --color-text: #e2e4e9; + --color-muted: #4a5060; + --color-accent: #c8a96e; + --color-accent-bright: #e8c887; + --color-accent-dim: #6b5730; + --color-tag-bg: #131720; + --color-done: #3a7a5a; + --color-progress: #6b5730; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +* { box-sizing: border-box; } + +/* All page sections sit above the fixed GridCanvas (z-index 0) */ +main > section { position: relative; z-index: 5; } + +html { + scroll-behavior: smooth; + background: #07080a; } body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + background: #07080a; + color: #e2e4e9; + font-family: var(--font-lora), Georgia, serif; + overflow-x: hidden; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E"); + pointer-events: none; + z-index: 1; + opacity: 0.35; +} + +::-webkit-scrollbar { width: 4px; } +::-webkit-scrollbar-track { background: #07080a; } +::-webkit-scrollbar-thumb { background: #1c1f26; border-radius: 2px; } + +@keyframes fadeUp { + from { opacity: 0; transform: translateY(32px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes slideRight { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} +@keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 0 0 rgba(200, 169, 110, 0); } + 50% { box-shadow: 0 0 20px 4px rgba(200, 169, 110, 0.12); } +} +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} +@keyframes scroll-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(6px); } +} + +.animate-fade-up { + animation: fadeUp 0.75s ease both; +} + +.delay-1 { animation-delay: 0.15s; } +.delay-2 { animation-delay: 0.3s; } +.delay-3 { animation-delay: 0.45s; } +.delay-4 { animation-delay: 0.6s; } +.delay-5 { animation-delay: 0.75s; } +.delay-6 { animation-delay: 0.9s; } + +.reveal { + opacity: 0; + transform: translateY(24px); + transition: opacity 0.7s ease, transform 0.7s ease; +} +.reveal.visible { + opacity: 1; + transform: translateY(0); +} + +/* grid-bg: static CSS grid removed — replaced by GridCanvas (canvas-based distortion) */ +.grid-bg {} + +.project-card { + background: #0e1014; + border: 1px solid #1c1f26; + transition: border-color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease; + position: relative; + overflow: hidden; +} +.project-card::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(200,169,110,0.05) 0%, transparent 60%); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} +.project-card:hover { + border-color: #6b5730; + transform: translateY(-3px); + box-shadow: 0 16px 48px rgba(0,0,0,0.5); +} +.project-card:hover::before { opacity: 1; } + +.tech-tag { + font-family: var(--font-jetbrains), monospace; + font-size: 0.62rem; + letter-spacing: 0.08em; + padding: 2px 8px; + border: 1px solid #1c1f26; + color: #4a5060; + background: #131720; + display: inline-block; + transition: border-color 0.2s, color 0.2s; +} +.tech-tag:hover { + border-color: #6b5730; + color: #c8a96e; +} + +.nav-link { + font-family: var(--font-jetbrains), monospace; + font-size: 0.7rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #4a5060; + transition: color 0.2s; + position: relative; +} +.nav-link::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 1px; + background: #c8a96e; + transition: width 0.25s ease; +} +.nav-link:hover { color: #c8a96e; } +.nav-link:hover::after { width: 100%; } + +.status-done { + font-family: var(--font-jetbrains), monospace; + font-size: 0.58rem; + letter-spacing: 0.1em; + padding: 1px 6px; + border: 1px solid #3a7a5a; + color: #3a7a5a; + text-transform: uppercase; +} +.status-progress { + font-family: var(--font-jetbrains), monospace; + font-size: 0.58rem; + letter-spacing: 0.1em; + padding: 1px 6px; + border: 1px solid #6b5730; + color: #c8954a; + text-transform: uppercase; +} + +.section-label { + font-family: var(--font-jetbrains), monospace; + font-size: 0.62rem; + letter-spacing: 0.22em; + text-transform: uppercase; + color: #c8a96e; +} + +.cursor::after { + content: '_'; + animation: blink 1s step-end infinite; + color: #c8a96e; +} + +.scroll-indicator { + animation: scroll-bounce 1.8s ease-in-out infinite; +} + +.accent-bar { + display: block; + width: 32px; + height: 2px; + background: #c8a96e; + transform-origin: left; +} + +/* ── Responsive bento grid ───────────────────────────────────── */ +.hero-bento-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1px; + background: #1c1f26; + border: 1px solid #1c1f26; +} +@media (max-width: 1023px) { + .hero-bento-grid { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 599px) { + .hero-bento-grid { grid-template-columns: 1fr; } +} + +/* ── Hero header responsive ──────────────────────────────────── */ +.hero-header-strip { + display: flex; + align-items: flex-end; + justify-content: space-between; + flex-wrap: wrap; + gap: 1.5rem; + margin-bottom: 2.5rem; +} +@media (max-width: 599px) { + .hero-header-strip { flex-direction: column; align-items: flex-start; } +} + +/* ── Hero section padding ────────────────────────────────────── */ +.hero-section { + padding-top: 88px; + padding-bottom: 4rem; + padding-left: 2rem; + padding-right: 2rem; +} +@media (max-width: 599px) { + .hero-section { + padding-top: 76px; + padding-left: 1.25rem; + padding-right: 1.25rem; + } +} + +/* ── Headline cursor blink ───────────────────────────────────── */ +@keyframes cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} +.headline-cursor { + display: inline-block; + width: 3px; + background: #c8a96e; + margin-left: 4px; + animation: cursor-blink 1.1s step-end infinite; + vertical-align: baseline; } diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..ff90441 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,33 +1,44 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Bebas_Neue, JetBrains_Mono, Lora } from "next/font/google"; import "./globals.css"; +import GridCanvas from "@/components/GridCanvas"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const bebas = Bebas_Neue({ + weight: "400", + variable: "--font-bebas", subsets: ["latin"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const jetbrains = JetBrains_Mono({ + variable: "--font-jetbrains", subsets: ["latin"], + weight: ["400", "500"], +}); + +const lora = Lora({ + variable: "--font-lora", + subsets: ["latin"], + weight: ["400", "500"], }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Achraf Achkari — Technical Lead & Software Engineer", + description: + "Personal portfolio and project hub. Technical Lead at Kereval, building software with Java, TypeScript, and modern frameworks.", }; export default function RootLayout({ children, -}: Readonly<{ - children: React.ReactNode; -}>) { +}: Readonly<{ children: React.ReactNode }>) { return ( - {children} + + + {children} + ); } diff --git a/app/page.tsx b/app/page.tsx index 3f36f7c..a9db107 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,46 @@ -import Image from "next/image"; +import portfolioData from "@/data/portfolio.json"; +import Navigation from "@/components/Navigation"; +import Hero from "@/components/Hero"; +import Projects from "@/components/Projects"; +import Experience from "@/components/Experience"; +import Education from "@/components/Education"; +import Skills from "@/components/Skills"; +import Contact from "@/components/Contact"; export default function Home() { + const { about, projects, experiences, education } = portfolioData; + return ( -
-
- Next.js logo + +
+ + + + + + -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
-
+ ); } diff --git a/components/Contact.tsx b/components/Contact.tsx new file mode 100644 index 0000000..b689847 --- /dev/null +++ b/components/Contact.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import SafeImage from "./SafeImage"; + +interface ContactInfo { + email: string; + phone: string; + linkedin: string; + github: string; + Address: string; +} + +interface ContactProps { + contact: ContactInfo; + fullName: string; + image?: string; +} + +export default function Contact({ contact, fullName, image }: ContactProps) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const obs = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { el.classList.add("visible"); obs.disconnect(); } + }, + { threshold: 0.1 } + ); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + const links = [ + { + label: "Email", + value: contact.email, + href: `mailto:${contact.email}`, + icon: ( + + + + + ), + }, + { + label: "Phone", + value: contact.phone, + href: `tel:${contact.phone}`, + icon: ( + + + + ), + }, + { + label: "LinkedIn", + value: "achraf-achkari", + href: contact.linkedin, + icon: ( + + + + + + ), + }, + { + label: "GitHub", + value: "achachraf", + href: contact.github, + icon: ( + + + + ), + }, + ]; + + return ( +
+
+
+
Let's Connect
+

+ Contact +

+
+ +
+ {/* Left: profile */} +
+
+ {image && ( + n[0]).join("")} + style={{ width: "72px", height: "72px", objectFit: "cover", filter: "grayscale(0.3)" }} + containerStyle={{ + border: "1px solid #1c1f26", + fontSize: "1.2rem", + }} + /> + )} +
+

+ {fullName} +

+
+ Technical Lead · Kereval +
+
+
+ +
+ + + + + + {contact.Address} + +
+ +

+ Open to new opportunities, collaborations, and interesting + conversations. Drop a line — I'd love to hear from you. +

+
+ + {/* Right: links */} + +
+
+
+ + {/* Footer */} +
+
+ © {new Date().getFullYear()} {fullName}. All rights reserved. +
+
+ ACHRAF ACHKARI +
+
+
+ ); +} diff --git a/components/Education.tsx b/components/Education.tsx new file mode 100644 index 0000000..dcca859 --- /dev/null +++ b/components/Education.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import SafeImage from "./SafeImage"; + +interface EducationItem { + degree: string; + school: string; + startDate: string; + endDate: string; + logo?: string; +} + +interface EducationProps { + education: EducationItem[]; +} + +function EduCard({ item, index }: { item: EducationItem; index: number }) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const obs = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setTimeout(() => el.classList.add("visible"), index * 100); + obs.disconnect(); + } + }, + { threshold: 0.1 } + ); + obs.observe(el); + return () => obs.disconnect(); + }, [index]); + + return ( +
(e.currentTarget.style.borderColor = "#6b5730")} + onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#1c1f26")} + > + {/* Corner accent */} +
+ +
+ {item.startDate} — {item.endDate} +
+ + {item.logo && ( +
+ +
+ )} + +

+ {item.degree} +

+

+ {item.school} +

+
+ ); +} + +export default function Education({ education }: EducationProps) { + const headingRef = useRef(null); + + useEffect(() => { + const el = headingRef.current; + if (!el) return; + const obs = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { el.classList.add("visible"); obs.disconnect(); } + }, + { threshold: 0.1 } + ); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + return ( +
+
+
+
Academic Background
+

+ Education +

+
+
+ +
+ {education.map((item, i) => ( +
+ +
+ ))} +
+
+
+ ); +} diff --git a/components/Experience.tsx b/components/Experience.tsx new file mode 100644 index 0000000..38cb730 --- /dev/null +++ b/components/Experience.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import SafeImage from "./SafeImage"; + +interface Experience { + name: string; + company: string; + position: string; + description: string; + startDate: string; + endDate: string; + logo?: string; +} + +interface ExperienceProps { + experiences: Experience[]; +} + +function formatDate(d: string) { + if (d === "current") return "Present"; + const [, m, y] = d.split("-"); + const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + return `${months[parseInt(m) - 1]} ${y}`; +} + +function ExperienceItem({ exp, index }: { exp: Experience; index: number }) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const obs = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setTimeout(() => el.classList.add("visible"), index * 100); + obs.disconnect(); + } + }, + { threshold: 0.1 } + ); + obs.observe(el); + return () => obs.disconnect(); + }, [index]); + + const isCurrent = exp.endDate === "current"; + + return ( +
+ {/* Date */} +
+
+ {isCurrent && ( +
+ ● NOW +
+ )} + {formatDate(exp.startDate)} +

+ {formatDate(exp.endDate)} +
+
+ + {/* Line with dot */} +
+
+
+
+ + {/* Content */} +
+
+ {exp.logo && ( + + )} + + {exp.company} + +
+ +

+ {exp.position} +

+

+ {exp.description} +

+
+
+ ); +} + +export default function Experience({ experiences }: ExperienceProps) { + const headingRef = useRef(null); + + useEffect(() => { + const el = headingRef.current; + if (!el) return; + const obs = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { el.classList.add("visible"); obs.disconnect(); } + }, + { threshold: 0.1 } + ); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + return ( +
+
+
+
Career Path
+

+ Experience +

+
+
+ +
+ {experiences.map((exp, i) => ( + + ))} +
+
+
+ ); +} diff --git a/components/GridCanvas.tsx b/components/GridCanvas.tsx new file mode 100644 index 0000000..1287c06 --- /dev/null +++ b/components/GridCanvas.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** + * Full-page fixed canvas that draws a distorted grid background. + * The grid warps toward the mouse cursor like a gravitational lens / + * spacetime distortion — grid lines curve inward, intersections glow, + * and a soft mass-glow follows the cursor. + */ +export default function GridCanvas() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let raf: number; + + // Smoothed mouse position (lerped toward target) + let mx = -3000; + let my = -3000; + // Raw target from events + let tx = -3000; + let ty = -3000; + + const GRID = 64; // px between grid lines + const SIGMA = 190; // radius of distortion effect (px) + const STRENGTH = 52; // max pixel pull toward cursor + + // ── resize ─────────────────────────────────────────────────────────────── + const resize = () => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + }; + resize(); + window.addEventListener("resize", resize); + + // ── mouse tracking (global so it works across the whole page) ──────────── + const onMove = (e: MouseEvent) => { tx = e.clientX; ty = e.clientY; }; + const onLeave = () => { tx = -3000; ty = -3000; }; + window.addEventListener("mousemove", onMove); + document.documentElement.addEventListener("mouseleave", onLeave); + + // ── displacement: pull a point (px,py) toward the cursor ───────────────── + const warp = (px: number, py: number): [number, number] => { + const dx = px - mx; + const dy = py - my; + const d2 = dx * dx + dy * dy; + if (d2 < 1) return [px, py]; + const d = Math.sqrt(d2); + const f = STRENGTH * Math.exp(-d2 / (2 * SIGMA * SIGMA)); + return [px - (dx / d) * f, py - (dy / d) * f]; + }; + + // ── main draw loop ──────────────────────────────────────────────────────── + const draw = () => { + // Smooth-lerp mouse position (feels weighty / spacetime-like) + mx += (tx - mx) * 0.065; + my += (ty - my) * 0.065; + + const W = canvas.width; + const H = canvas.height; + const active = mx > -2000; + + ctx.clearRect(0, 0, W, H); + + // ── horizontal grid lines ───────────────────────────────────────────── + for (let gy = 0; gy <= H + GRID; gy += GRID) { + const lineDist = active ? Math.abs(gy - my) : 9999; + const lift = active ? Math.max(0, 1 - lineDist / (SIGMA * 1.4)) : 0; + ctx.beginPath(); + let first = true; + for (let x = 0; x <= W; x += 5) { + const [wx, wy] = active ? warp(x, gy) : [x, gy]; + first ? ctx.moveTo(wx, wy) : ctx.lineTo(wx, wy); + first = false; + } + ctx.strokeStyle = `rgba(200,169,110,${0.048 + lift * 0.12})`; + ctx.lineWidth = 0.5 + lift * 0.55; + ctx.stroke(); + } + + // ── vertical grid lines ─────────────────────────────────────────────── + for (let gx = 0; gx <= W + GRID; gx += GRID) { + const lineDist = active ? Math.abs(gx - mx) : 9999; + const lift = active ? Math.max(0, 1 - lineDist / (SIGMA * 1.4)) : 0; + ctx.beginPath(); + let first = true; + for (let y = 0; y <= H; y += 5) { + const [wx, wy] = active ? warp(gx, y) : [gx, y]; + first ? ctx.moveTo(wx, wy) : ctx.lineTo(wx, wy); + first = false; + } + ctx.strokeStyle = `rgba(200,169,110,${0.048 + lift * 0.12})`; + ctx.lineWidth = 0.5 + lift * 0.55; + ctx.stroke(); + } + + // ── intersection dots (lensed stars) ───────────────────────────────── + if (active) { + const cullR2 = (SIGMA * 2.2) * (SIGMA * 2.2); + for (let gx = 0; gx <= W + GRID; gx += GRID) { + for (let gy = 0; gy <= H + GRID; gy += GRID) { + const dx = gx - mx; + const dy = gy - my; + const d2 = dx * dx + dy * dy; + if (d2 > cullR2) continue; + + const [wx, wy] = warp(gx, gy); + const intensity = Math.exp(-d2 / (2 * SIGMA * SIGMA * 0.35)); + + // outer halo + ctx.beginPath(); + ctx.arc(wx, wy, 1.5 + intensity * 2, 0, Math.PI * 2); + ctx.fillStyle = `rgba(200,169,110,${0.1 + intensity * 0.5})`; + ctx.fill(); + + // bright core for very close intersections + if (intensity > 0.4) { + ctx.beginPath(); + ctx.arc(wx, wy, 0.8, 0, Math.PI * 2); + ctx.fillStyle = `rgba(232,200,135,${intensity * 0.7})`; + ctx.fill(); + } + } + } + } + + // ── gravitational mass glow at cursor ───────────────────────────────── + if (active) { + // wide soft halo + const halo = ctx.createRadialGradient(mx, my, 0, mx, my, SIGMA * 0.85); + halo.addColorStop(0, "rgba(200,169,110,0.07)"); + halo.addColorStop(0.5, "rgba(200,169,110,0.025)"); + halo.addColorStop(1, "rgba(200,169,110,0)"); + ctx.fillStyle = halo; + ctx.beginPath(); + ctx.arc(mx, my, SIGMA * 0.85, 0, Math.PI * 2); + ctx.fill(); + + // tight bright core (the "mass") + const core = ctx.createRadialGradient(mx, my, 0, mx, my, 22); + core.addColorStop(0, "rgba(240,210,150,0.22)"); + core.addColorStop(1, "rgba(200,169,110,0)"); + ctx.fillStyle = core; + ctx.beginPath(); + ctx.arc(mx, my, 22, 0, Math.PI * 2); + ctx.fill(); + } + + raf = requestAnimationFrame(draw); + }; + + draw(); + + return () => { + cancelAnimationFrame(raf); + window.removeEventListener("resize", resize); + window.removeEventListener("mousemove", onMove); + document.documentElement.removeEventListener("mouseleave", onLeave); + }; + }, []); + + return ( +