// Main app for Korneel.dev portfolio (multi-page, hash-routed)
const { useEffect, useRef, useState, Fragment } = React;

// Gravity tuning (matches "lively" defaults).
window.__GRAVITY_G = 0.5;
window.__GRAVITY_R = 0.6;

// ---------- Project data ----------
const PROJECTS = [
{
  id: "transform",
  num: "01",
  title: "TransformWebsites",
  tagline: "The studio brand for retreat centers, wellness brands, and coaches who want a site that books calls.",
  stack: ["NEXT.JS", "SANITY", "VERCEL", "STRIPE", "GOHIGHLEVEL", "MAKE.COM"],
  role: "Founder, designer, developer.",
  status: "Live",
  url: "transformwebsites.com",
  placeholder: "transform",
  bullets: [
  "Full brand, positioning, and pricing model from scratch",
  "Conversion-focused 7-day sprint delivery system",
  "Productized stack: Next.js + Sanity + Vercel + Stripe, plus the integrations each project needs (CRM, email automation, booking systems, analytics)",
  "Live client base across Europe, Central America, and Southeast Asia"],
  long: "TransformWebsites is the studio brand I built to serve retreat centers, wellness brands, and coaches who need a site that does more than look beautiful. It has to convert browsers into bookings. The whole offer is productized: a fixed seven-day sprint, a transparent scope, and a delivery system that protects the client from open-ended timelines.",
  longExtra: "Every part of the brand was designed in-house: voice, pricing, positioning, the visual language. The site itself runs on Next.js with a Sanity studio configured so non-technical owners can update content themselves. Payments sit on Stripe, lifecycle email and CRM through GoHighLevel, and multi-system automations through Make.com. Any project gets the third-party integrations it actually needs.",
  stats: [
  { k: "Delivery", v: "7-day sprints" },
  { k: "Clients", v: "Across 3 continents" },
  { k: "Stack", v: "Next.js + Sanity + Stripe + integrations" },
  { k: "Launched", v: "January 2026" }],
  words: [
  { text: "studio", style: "gold", size: 36 },
  { text: "7-day sprint", style: "outline", size: 24 },
  { text: "productized", style: "gold", size: 26 },
  { text: "AI-first", style: "solid", size: 24 },
  { text: "ship fast", style: "outline", size: 22 },
  { text: "Sanity", mono: true, italic: false, style: "outline", size: 13 },
  { text: "Stripe", mono: true, italic: false, style: "outline", size: 13 },
  { text: "GoHighLevel", mono: true, italic: false, style: "outline", size: 13 },
  { text: "Make.com", mono: true, italic: false, style: "outline", size: 13 },
  { text: "founder", style: "outline", size: 22 }]
},
{
  id: "eagles",
  num: "02",
  title: "Eagles Nest Atitlan",
  tagline: "A six-language retreat platform on Lake Atitlan, Guatemala, with a custom booking system spanning Stripe, Checkfront, and Momence.",
  stack: ["NEXT.JS", "SANITY", "STRIPE", "CHECKFRONT", "MOMENCE"],
  role: "Designer, developer.",
  status: "Launching Q2 2026",
  url: "Coming soon",
  placeholder: "eagles",
  bullets: [
  "Content in six languages, all editable from a custom Sanity Studio",
  "Custom booking system spanning Stripe (payments), Checkfront (inventory), and Momence (communications)",
  "Custom promotion engine for seasonal offers and discount codes",
  "Booking-management dashboard for the team to handle reservations end to end",
  "SEO-optimized page structure across every language for retreat searches"],
  long: "Eagles Nest sits on the shores of Lake Atitlan in Guatemala, one of those places where the photography does most of the work. The brief was to build a site that respected the silence of the location while still moving the booking journey forward, in six languages, with payments and operations baked in.",
  longExtra: "Everything runs through a custom Sanity Studio so the owners can manage schedules, retreat descriptions, photos, and pricing across all six languages. The booking flow stitches Stripe, Checkfront, and Momence into a single coherent system, with a custom dashboard for the team to manage reservations and a custom promotion engine for seasonal offers and discount codes. SEO is tuned per language to capture retreat searches in the markets that matter.",
  stats: [
  { k: "Built in", v: "2 to 3 weeks" },
  { k: "Languages", v: "6" },
  { k: "Booking", v: "Stripe + Checkfront + Momence" },
  { k: "Status", v: "Launching Q2 2026" }],
  words: [
  { text: "Atitlán", style: "gold", size: 34 },
  { text: "6 languages", style: "gold", size: 26 },
  { text: "retreat", style: "outline", size: 26 },
  { text: "Guatemala", style: "solid", size: 24 },
  { text: "lake", style: "gold", size: 30 },
  { text: "Stripe", mono: true, italic: false, style: "outline", size: 13 },
  { text: "Checkfront", mono: true, italic: false, style: "outline", size: 13 },
  { text: "Momence", mono: true, italic: false, style: "outline", size: 13 },
  { text: "bookings", style: "outline", size: 22 }]
},
{
  id: "nichefinder",
  num: "03",
  title: "Nichefinder",
  tagline: "See what sells before you make it. A free AI tool that takes Etsy sellers from indecision to three matched niches in under a minute.",
  stack: ["NEXT.JS", "TYPESCRIPT", "CLAUDE API", "FIRECRAWL", "VERCEL"],
  role: "Solo build.",
  status: "Live",
  url: "nichefinder.shop",
  placeholder: "nichefinder",
  bullets: [
  "8-question quiz across 5 steps capturing time, budget, skills, equipment, aesthetic, timeline",
  "Live search-demand signals scraped via Firecrawl, n-gram analysis on Google result titles",
  "Claude Sonnet 4.6 scores 25 curated niches against an 8-component framework, with prompt caching saving ~40% input tokens",
  "Confidence labels per metric: grounded, modeled, or reasoned, so the methodology is auditable",
  "No signup until the end. No database, no auth, no analytics. Free, under 60 seconds.",
  "Shipped end-to-end in under 8 hours, built as a satellite app for a Creative Fabrica job application"],
  long: "Nichefinder is a free AI tool that takes aspiring Etsy sellers from indecision to three matched market opportunities in under a minute, with quantified demand signals and zero signup friction. I built it as a satellite app for a job application at Creative Fabrica, targeting the keyword 'what to sell on Etsy' (40K monthly searches).",
  longExtra: "An 8-question quiz across 5 steps captures the seller's constraints. The system pre-filters a curated 25-niche catalog against the profile, pulls live search-demand signals through Firecrawl, then uses Claude Sonnet 4.6 to score candidates against an 8-component framework and generate per-niche 90 to 120 word explanations. Each metric carries a confidence label, grounded, modeled, or reasoned, so anyone can audit where a signal came from. Built on Next.js 16 with the App Router, TypeScript, and Zod schema validation. No database, no auth, no analytics, just the answer. Shipped end-to-end in under 8 hours, Claude Code in the driver's seat.",
  stats: [
  { k: "Built in", v: "Under 8 hours" },
  { k: "AI", v: "Claude Sonnet 4.6" },
  { k: "Demand data", v: "Firecrawl + Google" },
  { k: "Status", v: "Live, MIT-licensed" }],
  words: [
  { text: "Etsy", style: "gold", size: 32 },
  { text: "8 questions", style: "gold", size: 26 },
  { text: "60 seconds", style: "outline", size: 24 },
  { text: "no signup", style: "solid", size: 24 },
  { text: "Claude Sonnet", mono: true, italic: false, style: "outline", size: 13 },
  { text: "Firecrawl", mono: true, italic: false, style: "outline", size: 13 },
  { text: "TypeScript", mono: true, italic: false, style: "outline", size: 13 },
  { text: "Next.js 16", mono: true, italic: false, style: "outline", size: 13 },
  { text: "live signals", style: "outline", size: 22 }]
},
{
  id: "meet",
  num: "04",
  title: "Meet'n Move",
  tagline: "A community-led platform connecting people through movement, sport, and shared space. A family startup where I'm shipping the MVP as full-stack engineer.",
  stack: ["NEXT.JS", "SANITY", "VERCEL"],
  role: "Full-stack engineer.",
  status: "Building MVP",
  url: "Coming soon",
  placeholder: "meet",
  bullets: [
  "Sole engineer on the platform: architecture, frontend, backend, and CMS",
  "Community-first content architecture",
  "Event and group flow management",
  "Sanity-driven editorial pipeline",
  "Low-friction sign-up and discovery flow"],
  long: "Meet'n Move is a platform built around the idea that people connect best when they're moving: running clubs, padel groups, surf meet-ups. The product needs to feel social and lightweight, not corporate.",
  longExtra: "It's a family startup, and I'm leading the engineering end to end, currently shipping the MVP. The experience is designed around discovery and joining: every entry point gets a user one click closer to a real event with real people. The CMS is structured for community managers, not engineers.",
  stats: [
  { k: "Type", v: "Community platform" },
  { k: "Role", v: "Full-stack engineer" },
  { k: "Stack", v: "Next.js + Sanity" },
  { k: "Status", v: "Building MVP" }],
  words: [
  { text: "community", style: "gold", size: 32 },
  { text: "padel", style: "outline", size: 26 },
  { text: "running", style: "outline", size: 24 },
  { text: "surf", style: "solid", size: 28 },
  { text: "events", style: "outline", size: 24 },
  { text: "low-friction", style: "outline", size: 22 },
  { text: "discovery", style: "gold", size: 28 },
  { text: "Sanity", mono: true, italic: false, style: "outline", size: 13 },
  { text: "social", style: "outline", size: 22 }]
}];

// ---------- Routing ----------
function parseRoute(hash) {
  const h = (hash || "").replace(/^#\/?/, "");
  if (!h) return { name: "home" };
  if (h === "about") return { name: "about" };
  if (h === "contact") return { name: "contact" };
  const m = h.match(/^case\/(.+)$/);
  if (m) return { name: "case", id: m[1] };
  return { name: "home" };
}
function navigate(path) {
  // path is like "/about" or "/case/transform"; we encode as hash
  window.location.hash = path === "/" ? "" : "#" + path;
}
function useRoute() {
  const [route, setRoute] = useState(() => parseRoute(window.location.hash));
  useEffect(() => {
    const onHash = () => {
      setRoute(parseRoute(window.location.hash));
      window.scrollTo({ top: 0, behavior: "instant" });
    };
    window.addEventListener("hashchange", onHash);
    return () => window.removeEventListener("hashchange", onHash);
  }, []);
  return route;
}

// ---------- Loader ----------
// Editorial brand-moment: name fades up, italic surname gold, role label
// follows. Whole thing lives ~1.5s, then dissolves into the home.
function Loader({ onDone }) {
  const [hide, setHide] = useState(false);

  useEffect(() => {
    const t = setTimeout(() => {
      setHide(true);
      setTimeout(onDone, 700);
    }, 1500);
    return () => clearTimeout(t);
  }, []);

  return (
    <div className={`loader ${hide ? "hide" : ""}`}>
      <h1 className="loader-name" aria-label="Korneel Kennes">
        <span className="loader-name-1">Korneel</span>{" "}
        <span className="loader-name-2"><em>Kennes</em></span>
      </h1>
      <p className="loader-role">Founder + Engineer · Studio of one</p>
      <div className="loader-cred">2026 · Barcelona</div>
    </div>);
}

// ---------- Word reveal ----------
function WordReveal({ text, delay = 0, italics = [] }) {
  const ref = useRef(null);
  useEffect(() => {
    const wordsEls = ref.current.querySelectorAll(".word");
    wordsEls.forEach((w, i) => {
      setTimeout(() => w.classList.add("in"), delay + i * 90);
    });
  }, []);
  return (
    <span ref={ref}>
      {text.split(" ").map((w, i) => {
        const isItalic = italics.includes(w.replace(/[.,]/g, ""));
        return (
          <Fragment key={i}>
            <span className={`word ${isItalic ? "it" : ""}`}>{isItalic ? <em>{w}</em> : w}</span>{" "}
          </Fragment>);
      })}
    </span>);
}

// ---------- Hero (home) ----------
function Hero() {
  const [hovered, setHovered] = useState(null);
  const [activeIdx, setActiveIdx] = useState(0);
  const trackRef = useRef(null);

  // Mobile carousel: track which card is currently centered, drive the
  // dots indicator so users see "1 of 4" at a glance.
  useEffect(() => {
    const track = trackRef.current;
    if (!track) return;
    const cards = track.querySelectorAll(".fcard");
    const obs = new IntersectionObserver(
      (entries) => {
        entries.forEach((e) => {
          if (e.isIntersecting && e.intersectionRatio >= 0.55) {
            const idx = Array.from(cards).indexOf(e.target);
            if (idx !== -1) setActiveIdx(idx);
          }
        });
      },
      { root: track, threshold: [0.55, 0.85] }
    );
    cards.forEach((c) => obs.observe(c));
    return () => obs.disconnect();
  }, []);

  return (
    <section className="hero" id="top">
      <div className="hero-stage">
        <div className="fan-frame">
          <div className={`hover-title ${hovered ? "show" : ""}`} aria-hidden="true">
            <em>{hovered ? hovered.title : " "}</em>
          </div>
          <div className="fan">
            <div className="fan-track" ref={trackRef}>
              {PROJECTS.map((p) =>
              <a
                key={p.id}
                className="fcard"
                href={`#/case/${p.id}`}
                onMouseEnter={() => setHovered(p)}
                onMouseLeave={() => setHovered(null)}>
                  <div className="fcard-img">
                    <Placeholder kind={p.placeholder} />
                  </div>
                  <div className="fcard-num">{p.num} · {p.stack[0]}</div>
                  <div className="fcard-mobile-label"><em>{p.title}</em></div>
                </a>
              )}
            </div>
            <div className="fan-dots" aria-hidden="true">
              {PROJECTS.map((p, i) =>
              <span key={p.id} className={i === activeIdx ? "active" : ""} />
              )}
              <span className="fan-dots-counter">{activeIdx + 1} / {PROJECTS.length}</span>
            </div>
          </div>
        </div>

        <h1 className="hero-tagline">
          <WordReveal
            text="Founder + engineer, AI-native by default, ships in days."
            italics={["engineer", "AI-native", "days"]} />
        </h1>
      </div>

      <div className="hero-foot">
        <div className="left">
          <a href="mailto:korneel@chamelio.co" aria-label="Email">
            <svg className="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
              <rect x="3" y="5" width="18" height="14" rx="2" />
              <path d="M3 7l9 6 9-6" />
            </svg>
            Email
          </a>
          <a href="https://linkedin.com/in/korneel-kennes" target="_blank" rel="noopener noreferrer" aria-label="LinkedIn">
            <svg className="ic" viewBox="0 0 24 24" fill="currentColor">
              <path d="M4.98 3.5C4.98 4.88 3.87 6 2.5 6S0 4.88 0 3.5 1.12 1 2.5 1s2.48 1.12 2.48 2.5zM.22 8h4.56v14H.22V8zm7.5 0h4.37v1.91h.06c.61-1.16 2.1-2.38 4.32-2.38 4.62 0 5.47 3.04 5.47 6.99V22h-4.56v-6.62c0-1.58-.03-3.62-2.2-3.62-2.21 0-2.55 1.72-2.55 3.5V22H7.72V8z" />
            </svg>
            LinkedIn
          </a>
          <a href="https://github.com/KorneelKennes" target="_blank" rel="noopener noreferrer" aria-label="GitHub">
            <svg className="ic" viewBox="0 0 24 24" fill="currentColor">
              <path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.92.58.1.79-.25.79-.56 0-.27-.01-1-.02-1.96-3.2.7-3.87-1.54-3.87-1.54-.52-1.32-1.27-1.67-1.27-1.67-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.02 1.75 2.68 1.25 3.34.96.1-.74.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.05 0 0 .96-.31 3.16 1.18.92-.26 1.9-.39 2.88-.39.98 0 1.96.13 2.88.39 2.2-1.49 3.16-1.18 3.16-1.18.62 1.59.23 2.76.11 3.05.74.81 1.18 1.84 1.18 3.1 0 4.42-2.69 5.39-5.25 5.68.41.36.78 1.07.78 2.16 0 1.56-.01 2.81-.01 3.19 0 .31.21.67.8.55C20.21 21.39 23.5 17.08 23.5 12 23.5 5.65 18.35.5 12 .5z" />
            </svg>
            GitHub
          </a>
        </div>
        <div className="right">
          From <b>Belgium</b> · Based in <b>Barcelona</b>
        </div>
      </div>
    </section>);
}

// ---------- Page header (about / contact) ----------
function PageHeader({ kicker, title, italic, lead }) {
  return (
    <section className="page-hero">
      <div className="page-hero-inner">
        <div className="label">{kicker}</div>
        <h1 className="page-hero-title">
          {title} <em>{italic}</em>
        </h1>
        {lead && <p className="page-hero-lead">{lead}</p>}
      </div>
    </section>);
}

// ---------- About page (letter design) ----------
function AboutLetter() {
  const roles = [
    { r: "Founder & Engineer", c: "Chamelio", d: "2022 to now" },
    { r: "Web designer & developer", c: "Freelance", d: "2020 to 2022" },
    { r: "Photographer & visual designer", c: "Agency", d: "2018 to 2020" }];
  const edu = [
    { r: "Graphic Designer Program", c: "Syntra AB", d: "2021 to 2022" },
    { r: "BA Audiovisual · Photography", c: "LUCA School of Arts", d: "2018 to 2021" }];
  // Format the dateline as "Month · Year"
  const now = new Date();
  const month = now.toLocaleString("en-GB", { month: "long" });
  const year = now.getFullYear();

  return (
    <main className="letter-page">
      <div className="letter-hi">
        <h1>Hi<span className="dot">.</span></h1>
        <div className="meta">
          A letter from <em>Barcelona</em><br />
          {month} · {year}
        </div>
      </div>
      <p className="letter-intro">
        I'm Korneel. Belgian, based in Barcelona, building products at the speed of an <em>AI-augmented team of one</em>.
      </p>

      <figure className="letter-polaroid">
        <div className="photo">
          <img src="/images/me/korneel.jpg" alt="Korneel Kennes, Barcelona" />
        </div>
        <div className="caption">Vallvidrera {year}</div>
      </figure>

      <div className="letter-stack">
        <span className="label">Currently shipping with</span>
        <div className="chips">
          <span>Next.js</span>
          <span>React</span>
          <span>TypeScript</span>
          <span>Sanity</span>
          <span>Vercel</span>
          <span>Stripe</span>
          <span>Claude Code</span>
          <span>Cursor</span>
        </div>
        <span className="label">AI tools, years of use</span>
        <div className="chips">
          <span>ChatGPT</span>
          <span>Claude</span>
          <span>Midjourney</span>
          <span>Custom agents</span>
          <span>Prompt engineering</span>
          <span>LLM automation</span>
        </div>
        <span className="label label-faded">Web background</span>
        <div className="chips chips-faded">
          <span>WordPress</span>
          <span>Elementor</span>
          <span>Webflow</span>
          <span>Framer</span>
        </div>
      </div>

      <div className="letter-body">
        <p>
          I started in <span className="underline">photography</span>, drifted into graphic design, and got pulled toward code by the same instinct that pulls anyone toward making things: the suspicion that you can do it better if you just <em>do it yourself</em>.
        </p>
        <p>
          In 2022, two things started at once. I founded Chamelio as a <em>design-led</em> studio, shipping client work with WordPress, Elementor, Webflow, and Framer. And I started experimenting with AI tools the moment they went public: <span className="underline">ChatGPT</span> from launch, Midjourney from early access, custom agent workflows and prompt engineering as new tools landed.
        </p>
        <p>
          For three years the two tracks ran in parallel. The web side stayed pragmatic and design-led. The AI side kept compounding: agents, automations, daily fluency with whatever shipped. December 2025 was the <em>convergence</em>. <span className="underline">Claude Code</span> arrived as a serious development environment, and years of AI sensibility finally had somewhere to land in the web stack. Within weeks I'd rebuilt the workflow around Next.js, Sanity, Vercel, Stripe. In January 2026 I launched <a className="case-inline" href="#/case/transform"><strong>TransformWebsites</strong></a> as the rebranded delivery model. Five years in across both stacks, that's <strong>25+ sites for 20+ clients</strong> across three continents. And counting.
        </p>
        <p>
          The work is now <em>AI-native</em>. Claude Code runs my dev environment, agents handle the routine code, and I keep the architecture, the taste, and the product judgment. That's the deal I make with every client: a real human, doing real thinking, at the velocity of an <em>AI-augmented team</em>.
        </p>
        <p>
          Right now I'm looking for the next thing. A senior engineering role at a fast-moving company. A growth or forward-deployed engineering slot. A co-founder gig. Anywhere autonomy and shipping matter more than process. If you've read this far, let's talk.
        </p>
      </div>

      <div className="letter-approach">
        <div className="label">My approach</div>
        <h2>Four things I <em>believe</em>.</h2>
        <div className="principle">
          <span className="num">01</span>
          <div>
            <h4>Ownership <em>end to end</em>.</h4>
            <p>From first call to live site to support email. The same person who scopes the work writes the code.</p>
          </div>
        </div>
        <div className="principle">
          <span className="num">02</span>
          <div>
            <h4>AI agents <em>as toolchain</em>.</h4>
            <p>Claude Code is my daily dev environment. Agents handle the routine; architecture and product judgment stay human.</p>
          </div>
        </div>
        <div className="principle">
          <span className="num">03</span>
          <div>
            <h4>Strategy <em>before code</em>.</h4>
            <p>Most of the value is upstream: choosing the right thing to build before writing a line.</p>
          </div>
        </div>
        <div className="principle">
          <span className="num">04</span>
          <div>
            <h4>Ship at <em>"good enough"</em>.</h4>
            <p>Done beats perfect, especially in week one. The real work starts after launch.</p>
          </div>
        </div>
      </div>

      <figure className="letter-testimonial">
        <blockquote>
          It looks like there are <em>digital minions</em> working for me.
        </blockquote>
        <figcaption>
          <span className="name">Dave Wiggers</span>
          <span className="role">Creative Producer & Educator · Anima Vinctum</span>
        </figcaption>
      </figure>

      <div className="letter-experience">
        <h3>Where I've worked</h3>
        {roles.map((r, i) =>
        <div className="row" key={i}>
          <div className="role">{r.r} · <em>{r.c}</em></div>
          <div className="yr">{r.d}</div>
        </div>)}

        <h3>Where I studied</h3>
        {edu.map((r, i) =>
        <div className="row" key={i}>
          <div className="role">{r.r} · <em>{r.c}</em></div>
          <div className="yr">{r.d}</div>
        </div>)}
      </div>

      <div className="letter-signoff">
        <p>Thanks for reading.<br />Drop a line if anything resonates.</p>
        <div className="name">Korneel</div>
        <p className="ps">
          P.S. If you're in Barcelona, I owe you a <b>coffee</b>.{" "}
          <a href="mailto:korneel@chamelio.co">korneel@chamelio.co</a>
        </p>
      </div>
    </main>);
}

function ContactSection() {
  return (
    <section id="contact">
      <div className="contact">
        <div className="contact-rows">
          <a className="contact-row" href="mailto:korneel@chamelio.co">
            <span className="k">Email</span>
            <span className="v">korneel@chamelio.co</span>
            <span className="arrow">→</span>
          </a>
          <a className="contact-row" href="https://linkedin.com/in/korneel-kennes" target="_blank" rel="noopener noreferrer">
            <span className="k">LinkedIn</span>
            <span className="v">in/korneel-kennes</span>
            <span className="arrow">→</span>
          </a>
          <a className="contact-row" href="#">
            <span className="k">Calendar</span>
            <span className="v">Book a 30-min call</span>
            <span className="arrow">→</span>
          </a>
        </div>
        <div className="contact-bottom">Based in Barcelona. Working across Europe.</div>
      </div>
    </section>);
}

// ---------- Case page (left text / right image with fade + gravity) ----------
function CasePage({ project }) {
  const idx = PROJECTS.findIndex((p) => p.id === project.id);
  const next = PROJECTS[(idx + 1) % PROJECTS.length];
  const hasUrl = /\.[a-z]{2,}(\/|$)/i.test(project.url);
  return (
    <section className="case-page">
      <div className="case-text">
        <a className="case-back" href="#/">← Back to work</a>
        <div className="case-num">{project.num} / Case</div>
        <h1 className="case-title">{project.title}.</h1>
        <p className="case-tagline">{project.tagline}</p>

        <div className="case-prose">
          <p>{project.long}</p>
          <p>{project.longExtra}</p>
        </div>

        <div className="case-meta-grid">
          {project.stats.map((s, i) =>
          <div className="case-stat" key={i}>
            <div className="k">{s.k}</div>
            <div className="v">{s.v}</div>
          </div>)}
        </div>

        <div className="case-built-label">{project.status === "Live" ? "What I built" : "What I'm building"}</div>
        <ul className="case-bullets">
          {project.bullets.map((b, i) =>
          <li key={i}><span>+</span>{b}</li>)}
        </ul>

        <div className="case-meta-row">
          <span><b>Role</b> · {project.role}</span>
          <span><b>Status</b> · {project.status}</span>
        </div>

        {hasUrl ?
          <a className="case-link" href={`https://${project.url.replace(/^https?:\/\//, "")}`} target="_blank" rel="noopener noreferrer">
            Visit {project.url} →
          </a> :
          <div className="case-link disabled">{project.url}</div>}

        <a className="case-next" href={`#/case/${next.id}`}>
          <span>Next case</span>
          <em>{next.title} →</em>
        </a>
      </div>

      <div className="case-visual">
        <div className="case-visual-image">
          <CaseVisual kind={project.placeholder} label="" />
        </div>
        <div className="case-visual-fade" aria-hidden="true" />
        <div className="case-visual-play">
          <Gravity words={project.words} />
          <div className="case-visual-hint">Drag the words. They bounce.</div>
        </div>
      </div>
    </section>);
}

// ---------- Gravity outro (home) ----------
function GravityOutro() {
  const words = [
    { text: "playful", style: "gold", size: 36 },
    { text: "made in Belgium", style: "outline", size: 22 },
    { text: "ship in days", style: "outline", size: 24 },
    { text: "AI-first", style: "solid", size: 28 },
    { text: "Barcelona", style: "outline", size: 32 },
    { text: "warm", style: "gold", size: 26 },
    { text: "fullstack", style: "outline", size: 24 },
    { text: "Claude Code", mono: true, italic: false, style: "outline", size: 14 },
    { text: "Next.js", mono: true, italic: false, style: "outline", size: 14 },
    { text: "studio of one", style: "outline", size: 22 },
    { text: "let's build", style: "gold", size: 30 },
    { text: "tinkerer", style: "outline", size: 26 },
    { text: "retreats", style: "outline", size: 22 },
    { text: "coffee?", style: "solid", size: 28 }];
  return (
    <section className="gravity-section" id="gravity">
      <div className="sec-head">
        <div className="label">In closing</div>
        <h2>A few words, <em>untethered</em>.</h2>
        <p style={{
          fontFamily: "var(--serif)", fontStyle: "italic",
          fontSize: 20, color: "rgba(246,243,235,.6)", marginTop: 16
        }}>
          Drag them around. They bounce.
        </p>
      </div>
      <Gravity words={words} />
      <div className="gravity-foot">
        <span>© 2026 Korneel Kennes · A studio of one (with AI)</span>
        <span>Barcelona, Spain</span>
        <a href="#top" style={{ color: "inherit" }}>↑ Back to top</a>
      </div>
    </section>);
}

// ---------- Site footer (about / contact / case pages) ----------
function SiteFooter() {
  return (
    <footer className="site-foot">
      <div className="site-foot-inner">
        <div className="site-foot-brand">
          <span className="brand-name-foot">Korneel <em>Kennes</em></span>
          <span className="brand-role-foot">Founder + Engineer · Studio of one · Barcelona</span>
        </div>
        <div className="site-foot-links">
          <a href="#/">Work</a>
          <a href="#/about">About</a>
          <a href="#/contact">Contact</a>
        </div>
        <div className="site-foot-meta">© 2026 Korneel Kennes</div>
      </div>
    </footer>);
}

// ---------- Custom cursor ----------
function Cursor() {
  const ref = useRef(null);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    let x = 0, y = 0, tx = 0, ty = 0;
    const move = (e) => { tx = e.clientX; ty = e.clientY; };
    window.addEventListener("pointermove", move);
    let raf;
    const loop = () => {
      x += (tx - x) * 0.22;
      y += (ty - y) * 0.22;
      el.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`;
      raf = requestAnimationFrame(loop);
    };
    loop();

    const enter = () => el.classList.add("large");
    const leave = () => el.classList.remove("large");
    const attach = () => {
      document.querySelectorAll("a, button, .fcard, .contact-row, .case-link, .gword").forEach((n) => {
        n.addEventListener("mouseenter", enter);
        n.addEventListener("mouseleave", leave);
      });
    };
    // re-bind when the route changes
    attach();
    const obs = new MutationObserver(() => { attach(); });
    obs.observe(document.body, { childList: true, subtree: true });

    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener("pointermove", move);
      obs.disconnect();
    };
  }, []);
  return <div ref={ref} className="cursor" />;
}

// ---------- Top nav ----------
function Nav({ route }) {
  const link = (path, label) => {
    const active =
      (path === "/" && route.name === "home") ||
      (path === "/about" && route.name === "about") ||
      (path === "/contact" && route.name === "contact");
    return (
      <a className={active ? "active" : ""} href={path === "/" ? "#/" : "#" + path}>{label}</a>);
  };
  return (
    <nav className="nav">
      <a className="brand" href="#/">
        <span className="brand-name">Korneel <em>Kennes</em></span>
        <span className="brand-role">Founder + Engineer · Studio of one</span>
      </a>
      <div className="links">
        {link("/", "Work")}
        {link("/about", "About")}
        {link("/contact", "Contact")}
      </div>
    </nav>);
}

// ---------- App ----------
function App() {
  const [loaded, setLoaded] = useState(false);
  const route = useRoute();

  // Mark which page kind we're on so CSS can adjust nav contrast etc.
  useEffect(() => {
    document.body.dataset.page = route.name;
  }, [route.name]);

  let page;
  if (route.name === "home") {
    page = <Hero />;
  } else if (route.name === "about") {
    page = (
      <Fragment>
        <AboutLetter />
        <SiteFooter />
      </Fragment>);
  } else if (route.name === "contact") {
    page = (
      <Fragment>
        <PageHeader kicker="Let's talk" title="Building something" italic="interesting?" lead="Or just visiting Barcelona? Always up for a coffee." />
        <ContactSection />
        <SiteFooter />
      </Fragment>);
  } else if (route.name === "case") {
    const project = PROJECTS.find((p) => p.id === route.id);
    if (project) {
      page = <CasePage project={project} />;
    } else {
      page = (
        <Fragment>
          <PageHeader kicker="404" title="Case not" italic="found." lead="The project you're looking for isn't here." />
          <SiteFooter />
        </Fragment>);
    }
  }

  return (
    <Fragment>
      {!loaded && <Loader onDone={() => setLoaded(true)} />}
      <Cursor />
      <Nav route={route} />
      {page}
    </Fragment>);
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
