/* Home — mirrors the live site's section order, driven entirely by content.js. */
const { GrainField, FrostPanel, Button, IconButton, Icon, Tag, Badge, Label, Divider, MetaRow, ImagePlate, ProjectCard } = window.GlacierDesignSystem_b5cdbc;
const { go, Sec, SectionHead, Nav, ContactFooter, SkipLink, useMediaQuery, F, WorkIndex } = window.FX;

/* The record — the old hero centrepiece, now the static fallback for the
   orbit (no WebGL 2, or reduced motion). The ring of text is real SVG
   <text>, crisp at any size. */
const VINYL_RING_TEXT = "FRAGMENT STUDIO  ·  STANG  ·  EST. 2019  ·  SOLO PRACTICE  ·  ";

function VinylRingText() {
  // viewBox is a fixed 0–200 unit space regardless of the disc's rendered
  // size, so the text scales with it and stays crisp — no raster image.
  const cx = 100, cy = 100, r = 68;
  const circlePath = `M ${cx - r},${cy} A ${r},${r} 0 1 1 ${cx + r},${cy} A ${r},${r} 0 1 1 ${cx - r},${cy}`;
  // The label is ONE revolution, fitted to the circumference. It used to be
  // .repeat(2) — roughly 826 units of text laid onto a 427-unit path, so it
  // wrapped nearly twice and the tail overprinted the head: the S of a later
  // word landed on FRAGMENT and the disc read "SFRAGMENT STUDIO".
  // textLength pins it to exactly one turn whatever the font metrics do; if
  // a renderer ignores it the natural length is ~413, so it still fits.
  const circumference = 2 * Math.PI * r;
  return (
    <svg className="gx-vinyl-ring" viewBox="0 0 200 200" aria-hidden="true">
      <path id="gxVinylRingPath" d={circlePath} fill="none" />
      <text className="gx-vinyl-ring-text">
        <textPath href="#gxVinylRingPath" startOffset="0%"
          textLength={circumference} lengthAdjust="spacing">{VINYL_RING_TEXT}</textPath>
      </text>
    </svg>
  );
}

/* Orbit — the hero's artwork (replaced the vinyl disc, 2026-10-01).
   A sphere of liquid-chrome rings (orbit-rings.js) with a 3D toy of Stang
   diving head-down inside it (orbit-character.js) and glass labels riding
   the orbits. Built and approved in orbit-lab.html / character-lab.html /
   orbit-test.html; a frozen copy lives in variations/orbit-hero-v1.

   Layout: the hero reserves a square slot between the headline and the
   calls to action. The artwork is NOT inside the hero — it is one big layer
   over the hero and the top of the next section (.gx-orbit-span), centred
   on that slot and about twice its size, so the liquid rides up behind the
   headline and drops roll down into the section below. Hero text sits above
   it; it sits above the next section's background. The layer ignores the
   pointer; the page listens instead, so text and buttons stay clickable
   and everywhere else touches the liquid. One WebGL canvas: far rings →
   diver → near rings → glass. Paused whenever it is off screen. With no
   WebGL 2, or with reduced motion, the static record stands in the slot. */
const ORBIT_LABELS = () => [
  { t: F.identity.brand, shape: "pill", r: 0.80, a: 2.35, om: -0.15 },
  { t: F.identity.person, shape: "sphere", r: 0.60, a: 0.35, om: 0.18, size: 0.105 },
  { t: `Est.<br>${F.counts.since}`, shape: "sphere", r: 0.96, a: 5.4, om: 0.12, size: 0.105 },
  { t: "Solo practice", shape: "pill", r: 0.72, a: 4.1, om: -0.11 },
  { t: `${F.counts.projects} projects`, shape: "pill", r: 1.03, a: 1.25, om: -0.09 },
  { t: "3D · AI · UX", shape: "pill", r: 0.90, a: 3.3, om: 0.21 },
];
const ORBIT_SPREAD = 2.1;   // canvas width ÷ slot side: how far the liquid travels sideways
const ORBIT_REACH = 3.2;    // canvas height ÷ slot side: up behind the headline, down into the next section

function StaticRecord() {
  return (
    <div className="gx-vinyl" aria-hidden="true">
      <div className="gx-vinyl-disc">
        <VinylRingText />
        <span className="gx-vinyl-label" style={{ backgroundImage: "url(assets/work/portrait.png)" }} />
        <span className="gx-vinyl-spindle" />
      </div>
    </div>
  );
}

/* The hero's look (2026-10-03, tuned in hero-lab.html): a bright white
   studio with a black flag behind for deep charcoal edges; a soft-orange
   fringe only at the light/dark peaks; a vector-clean, steady silhouette;
   a contrasty, sharply lit diver living in the same studio. */
const HERO_LOOK = {
  // Chrome: the natural high-contrast studio (deep blacks, bright bands,
  // thin bright rim lines), now smooth and with the soft warm/cool fringe.
  dark: 0, bg: [1, 1, 1], studio: 0, rim: 4, bump: 2.6, threshold: 0.62, exposure: 1.1,
  warm: 1, peak: 0.7, disperse: 1.7, aa: 1.25, narrow: 1.25, temporal: 0.65,
  haze: 0.05, ground: 0.05, bloom: 0.38,
  // Diver: exposure and contrast apply to him alone.
  midExpo: 1.15, midCon: 0.35, ao: 0.12,
};
const HERO_DIVER = { key: 3.0, rim: 2.4, fill: 0.25, hemi: 0.3, rough: 0.3, coat: 0.8, coatRough: 0.1, envI: 1.0, sheen: 0.15 };

const INTERACTIVE = "a,button,input,textarea,select,label,summary,[role=button],[role=link],[contenteditable]";

function OrbitStage() {
  const layerRef = React.useRef(null);
  const [state, setState] = React.useState("loading"); // loading | live | failed
  const [slot, setSlot] = React.useState(null);
  // Bumped to remount the whole engine (GPU context lost, or the watchdog
  // finds it stalled) — the effect below tears down and starts fresh.
  const [gen, setGen] = React.useState(0);

  React.useEffect(() => {
    const layer = layerRef.current;
    const slotEl = document.querySelector(".gx-hero-slot");
    setSlot(slotEl);
    if (!layer || !slotEl) return undefined;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setState("failed"); return undefined; }
    const span = layer.parentElement;
    let orbit = null, diver = null, io = null, ro = null, cancelled = false, inView = false, dog = 0;
    const remount = () => { if (!cancelled) setTimeout(() => setGen((g) => g + 1), 300); };
    layer.querySelectorAll(".gx-orbit-labels").forEach((n) => n.remove());   // leftovers from a previous mount
    const offs = [];
    const on = (el, type, fn, opts) => { el.addEventListener(type, fn, opts); offs.push(() => el.removeEventListener(type, fn, opts)); };

    // Centre the layer on the slot: ORBIT_SPREAD × the slot wide, ORBIT_REACH × tall.
    const place = () => {
      const sr = slotEl.getBoundingClientRect(), pr = span.getBoundingClientRect();
      const w = Math.round(sr.width * ORBIT_SPREAD), h = Math.round(sr.width * ORBIT_REACH);
      Object.assign(layer.style, {
        width: w + "px", height: h + "px",
        left: Math.round(sr.left - pr.left + sr.width / 2 - w / 2) + "px",
        top: Math.round(sr.top - pr.top + sr.height / 2 - h / 2) + "px",
      });
      // Rings keep the slot's size: the view spans ORBIT_SPREAD × the slot.
      if (orbit) orbit.setMargin(1.07 * ORBIT_SPREAD);
    };
    place();
    ro = new ResizeObserver(place); ro.observe(slotEl); ro.observe(span);

    const isUi = (e) => e.target && e.target.closest && e.target.closest(INTERACTIVE);

    const start = async () => {
      const { mountRings, mountChar } = window.OrbitHero;
      orbit = await mountRings(layer, {
        target: document.documentElement,                 // the layer itself ignores the pointer
        margin: 1.07 * ORBIT_SPREAD,
        dprCap: 2,                                        // full retina; the engine steps down by itself if a device can't keep up
        densityScale: 1,                                  // liquid surface at full resolution (the canvas only covers the visible window, so this is affordable)
        downFilter: (e) => !isUi(e),
        params: HERO_LOOK,
        onContextLost: remount,

        onFail: () => {},
      }).catch(() => null);
      if (cancelled || !orbit) { if (orbit) orbit.destroy(); if (!cancelled) setState("failed"); return; }
      // Scale: the same size against the rings as in the approved test page.
      diver = await mountChar(layer, { renderer: orbit.renderer, envMap: orbit.studioEnv(1), look: HERO_DIVER, scale: 0.307 * 1.14 / (1.07 * ORBIT_SPREAD), src: "assets/orbit/character-hq.glb?v=20261003" }).catch(() => null);
      if (cancelled) { if (diver) diver.destroy(); orbit.destroy(); return; }
      if (diver) orbit.setMiddle(diver);

      // Words ride the orbits; the glass around them is drawn by the shader.
      const words = document.createElement("div");
      words.className = "gx-orbit-labels";
      layer.appendChild(words);
      const narrow = window.matchMedia("(max-width: 640px)").matches;
      const B = ORBIT_LABELS().slice(0, narrow ? 4 : 6).map((L) => {
        const el = document.createElement("div");
        el.className = "gx-orbit-label"; el.innerHTML = L.t; words.appendChild(el);
        return { ...L, el, ox: 0, oy: 0, vx: 0, vy: 0, w: 0 };
      });
      const glass = B.map(() => ({ x: 0, y: 0, r: 0.1, halfLen: 0 }));
      orbit.setGlass(glass);
      orbit.onFrame((t, dt, m, v) => {
        const cx = v.w / 2, cy = v.h / 2;
        B.forEach((b, i) => {
          if (!b.w) b.w = b.el.offsetWidth;
          let rad, halfLen;
          if (b.shape === "sphere") { rad = Math.max(30 / v.px, b.size); halfLen = 0; }
          else { rad = Math.max(23, 0.042 * v.px) / v.px; halfLen = Math.max(0, (b.w / 2 + 18) / v.px - rad); }
          const a = b.a + b.om * t;
          const hx = Math.cos(a) * b.r, hy = Math.sin(a) * b.r;
          let fx = -b.ox * 10, fy = -b.oy * 10;
          const dx = hx + b.ox - m.x, dy = hy + b.oy - m.y, dist = Math.hypot(dx, dy) + 1e-5;
          const reach = rad + halfLen + 0.14;
          if (m.on > 0.01 && dist < reach) { const f = 1 - dist / reach; fx += dx / dist * f * 14 * m.on + m.vx * 1.5 * f; fy += dy / dist * f * 14 * m.on + m.vy * 1.5 * f; }
          b.vx = (b.vx + fx * dt) * Math.exp(-3 * dt); b.vy = (b.vy + fy * dt) * Math.exp(-3 * dt);
          b.ox += b.vx * dt; b.oy += b.vy * dt;
          b.ext = rad + halfLen;
          const gx = hx + b.ox, gy = hy + b.oy + Math.sin(t * 1.3 + b.a * 3) * 0.006;
          Object.assign(glass[i], { x: gx, y: gy, r: rad, halfLen });
          b.el.style.transform = `translate3d(${cx + gx * v.px}px,${cy - gy * v.px}px,0) translate(-50%,-50%)`;
        });
        // Labels never collide: when two glass lenses come within reach of
        // each other, a soft spring eases them apart (pills by their length).
        for (let i = 0; i < B.length; i++) for (let j = i + 1; j < B.length; j++) {
          const a = glass[i], c = glass[j];
          const wx = Math.max(a.halfLen, c.halfLen) * 0.8;
          const dx = (a.x - c.x), dy = (a.y - c.y), d = Math.hypot(dx / (1 + wx * 2), dy) + 1e-5;
          const need = (B[i].ext || 0.1) * 0.55 + (B[j].ext || 0.1) * 0.55 + 0.05;
          if (d < need) {
            const f = (need - d) * 9 * dt, ux = dx / d, uy = dy / d;
            B[i].vx += ux * f; B[i].vy += uy * f; B[j].vx -= ux * f; B[j].vy -= uy * f;
          }
        }
      });

      // A press on the diver turns him; elsewhere the liquid answers.
      let grab = null;
      const root = document.documentElement;
      on(root, "pointerdown", (e) => {
        if (!diver || isUi(e) || !diver.hitTest(e.clientX, e.clientY)) return;
        grab = { x: e.clientX, y: e.clientY }; diver.grab(); root.classList.add("gx-diver-grabbing");
        e.stopPropagation(); e.preventDefault();
      }, true);
      on(root, "pointermove", (e) => {
        if (grab) { diver.drag(e.clientX - grab.x, e.clientY - grab.y); grab.x = e.clientX; grab.y = e.clientY; e.stopPropagation(); return; }
        if (diver && e.pointerType === "mouse") root.classList.toggle("gx-diver-hover", !isUi(e) && diver.hitTest(e.clientX, e.clientY));
      }, true);
      const end = (e) => { if (!grab) return; diver.release(); grab = null; root.classList.remove("gx-diver-grabbing"); e.stopPropagation(); };
      on(root, "pointerup", end, true);
      on(root, "pointercancel", end, true);

      io = new IntersectionObserver((en) => { inView = en[0].isIntersecting; orbit.setActive(inView); }, { rootMargin: "120px" });
      io.observe(layer);
      // Watchdog: on screen, tab visible, yet no new frames → re-arm the loop;
      // still stalled a beat later → remount a fresh engine.
      let last = -1, stalls = 0;
      dog = setInterval(() => {
        if (!orbit || document.hidden || !inView) { last = -1; stalls = 0; return; }
        const f = orbit.frames;
        if (f === last) { stalls++; if (stalls === 1) orbit.kick(); else if (stalls >= 3) { clearInterval(dog); remount(); } }
        else stalls = 0;
        last = f;
      }, 1000);
      place();
      window.__gxOrbit = orbit; window.__gxDiver = diver;
      window.dispatchEvent(new Event("gx-orbit:live"));
      setState("live");
    };
    if (window.OrbitHero) start();
    else window.addEventListener("orbithero:ready", start, { once: true });

    return () => {
      cancelled = true;
      clearInterval(dog);
      window.removeEventListener("orbithero:ready", start);
      offs.forEach((f) => f());
      if (io) io.disconnect();
      if (ro) ro.disconnect();
      if (diver) diver.destroy();
      if (orbit) orbit.destroy();
      if (window.__gxOrbit === orbit) window.__gxOrbit = null;
    };
  }, [gen]);

  return (
    <React.Fragment>
      <div ref={layerRef} className={"gx-orbit-layer is-" + state} aria-hidden="true" />
      {state === "failed" && slot && ReactDOM.createPortal(<StaticRecord />, slot)}
    </React.Fragment>
  );
}

/* Hero, reordered around the artwork: the claim first and big, then the
   orbit (the slot it fills), then what to do about it — the line, the two
   actions, the facts — and finally the signature. Nothing removed. */
function Hero() {
  return (
    <GrainField atmosphere="abyss" style={{ isolation: "auto" }}>
      <Nav active="Home" />
      <Sec id="gx-hero" className="gx-hero" label="Introduction">
        <div className="gx-fore gx-hero-head">
          <h1 className="gx-hero-h1">{F.hero.headline}</h1>
        </div>
        {/* The stage: the artwork's slot in the middle; on wide screens the
            line + actions stand to its left and the facts + signature to its
            right, beside the sphere's lower half rather than on top of it.
            Narrower, they stack under it. */}
        <div className="gx-hero-stage">
          <div className="gx-hero-slot" />
          <div className="gx-fore gx-hero-foot">
            <p className="gx-hero-sub">{F.hero.sub}</p>
            <div className="gx-ctarow">
              <Button variant="primary" size="lg" onClick={() => go(F.links.call)}>{F.hero.cta}</Button>
              <Button variant="ghost" size="lg" iconRight={<Icon name="arrow-right" size={13} />} onClick={() => { window.location.href = "work.html"; }}>{F.hero.secondary}</Button>
            </div>
          </div>
          <div className="gx-fore gx-hero-sign">
            <MetaRow tone="faint" items={F.hero.meta} />
            <h2 className="gx-hero-person">Priyanshu Jaiswal</h2>
            <MetaRow tone="faint" items={[`EST. ${F.counts.since}`, `${F.counts.years} YRS`, "SOLO PRACTICE"]} />
          </div>
        </div>
      </Sec>
    </GrainField>
  );
}

/* Featured work — five glass project folders (2026-10-02, built and
   approved in folders-lab.html). Replaces the full project list on the
   home page; work.html keeps the list (shared.jsx WorkIndex). The engine
   is featured-folders.js; folders and their screenshots come from
   F.featured in content.js. */
function WorkSection() {
  const host = React.useRef(null);
  const touch = useMediaQuery("(hover: none), (max-width: 767px)");
  React.useEffect(() => {
    let api = null;
    const mount = () => {
      if (api || !host.current || !window.FeaturedFolders) return;
      api = window.FeaturedFolders.mount(host.current, F.featured);
    };
    mount();
    window.addEventListener("featuredfolders:ready", mount);
    return () => { window.removeEventListener("featuredfolders:ready", mount); api && api.destroy(); };
  }, []);
  return (
    <GrainField atmosphere="flat">
      <Sec id="work" className="gx-work-sec" label="Featured work">
        <SectionHead eyebrow="Featured · 01 — 05" caption={touch ? "Swipe to browse · tap twice to open" : "Hover to peek · double-click to open"} title="Five projects, opened." />
        <div ref={host} className="gx-featured" />
        <div className="gx-work-foot">
          <Button variant="ghost" size="sm" iconRight={<Icon name="arrow-right" size={12} />} onClick={() => { window.location.href = "work.html"; }}>{F.hero.secondary}</Button>
          <Button variant="ghost" size="sm" iconRight={<Icon name="arrow-up-right" size={12} />} onClick={() => go(F.links.behance)}>Full gallery on Behance</Button>
        </div>
      </Sec>
    </GrainField>
  );
}

function Recognition() {
  const q = F.quotes[0];
  return (
    <GrainField atmosphere="abyss" flare>
      <Sec style={{ paddingTop: "var(--space-12)", paddingBottom: "var(--space-12)" }} label="Recognition">
        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(300px,1fr))", gap: "var(--space-11)", alignItems: "start" }}>
          <div className="gx-reveal">
            <h2 className="gx-sr">{F.recognition.eyebrow}</h2>
            <Label wide>{F.recognition.eyebrow}</Label>
            <p style={{ font: "var(--type-body-lg)", color: "var(--text-primary)", margin: "var(--space-7) 0 var(--space-6)", maxWidth: "var(--measure-narrow)", textWrap: "pretty" }}>
              {F.recognition.body}
            </p>
            <Button variant="ghost" size="sm" iconRight={<Icon name="arrow-up-right" size={12} />} onClick={() => go(F.recognition.href)}>Read the feature</Button>
            {/* The stats trio that sat here repeated the hero's own meta row
                two screens later, in different numbers. Recognition now
                carries recognition; the counts live in the hero and on the
                studio page, stated once each. */}
          </div>
          {/* `pad` lands in an inline style, which no media query can reach —
              so it is handed a variable instead of a value, and the phone
              redefines the variable. Same lever as the type ramp. */}
          <FrostPanel className="gx-reveal" pad="var(--panel-pad)">
            <Label tone="accent" wide>Client</Label>
            <blockquote style={{ font: "var(--type-body-lg)", color: "var(--text-primary)", margin: "var(--space-7) 0 var(--space-8)", textWrap: "pretty" }}>
              “{q.body}”
            </blockquote>
            <div style={{ display: "flex", alignItems: "center", gap: "var(--space-5)" }}>
              <img src={q.avatar} alt={`${q.author}, ${q.role}`} width="48" height="48" style={{ borderRadius: "50%", objectFit: "cover", filter: "saturate(0.72)" }} />
              <div>
                <div style={{ font: "var(--type-subheading)", letterSpacing: "var(--tracking-tight)", color: "var(--text-primary)" }}>{q.author}</div>
                <Label>{q.role}</Label>
              </div>
            </div>
          </FrostPanel>
        </div>
      </Sec>
    </GrainField>
  );
}

/* "Brands." — a 360° cylinder on a pointer, a plain wall of plates on a
   phone. The cylinder is 13 cards each carrying a backdrop-filter blur and
   a shadow, re-transformed by a rAF loop every frame; that is a lot of
   compositing to ask of a handset so it can foreshorten logos past
   legibility behind an edge mask. Splitting into two components (rather
   than flattening the cylinder with CSS) means the rAF loop, the pointer
   drag and the spin controls never mount on a phone at all. */
function Brands() {
  const phone = useMediaQuery("(max-width:767px)");
  return phone ? <BrandsGrid /> : <BrandsRing />;
}

function BrandsGrid() {
  /* The two `unconfirmed` marks are unnamed placeholders whose only job was
     to fill out the cylinder's circumference. A static grid has no
     circumference, and a captionless plate among captioned ones reads as a
     missing asset — so they sit this layout out. */
  const BRANDS = F.brands.filter((b) => !b.unconfirmed);
  return (
    <GrainField atmosphere="flat">
      <Sec className="gx-brands-sec" label="Clients">
        <div className="gx-reveal">
          <SectionHead eyebrow={F.brandsCaption} caption={`${F.counts.clients} clients · ${BRANDS.length} shown`} title={F.brandsHeading} maxWidth="16ch" />
        </div>
        {/* Same plate, same mono caption as the cylinder's cards — the
            layout changes, the visual language does not. */}
        <ul className="gx-brandgrid gx-reveal-stagger">
          {BRANDS.map((b) => (
            <li key={b.src} className="gx-brandcell">
              <span className="gx-ring-tile"><img className="gx-ring-logo" src={b.src} alt="" loading="lazy" /></span>
              <span className="gx-ring-meta">{b.name}</span>
            </li>
          ))}
        </ul>
      </Sec>
    </GrainField>
  );
}

function BrandsRing() {
  /* Same filtered list the grid uses. The two `unconfirmed` marks are
     unnamed placeholders — one renders as a bare arrow glyph and reads as a
     missing asset next to eleven real logos — and showing 13 here against
     11 on a phone made the caption disagree with itself by breakpoint. */
  const BRANDS = F.brands.filter((b) => !b.unconfirmed);
  const ring = React.useRef(null);
  const st = React.useRef({ angle: 0, vel: 0, drag: null, radius: 520, reduced: false });
  const [paused, setPaused] = React.useState(false);
  const pausedRef = React.useRef(false);
  pausedRef.current = paused;
  const step = 360 / BRANDS.length;

  // Ring radius is derived from the rendered card width so the cylinder
  // stays closed at every breakpoint: r = (w/2 + gap/2) / tan(pi / n).
  React.useEffect(() => {
    const fit = () => {
      if (!ring.current) return;
      // offsetWidth, not getBoundingClientRect: the ring carries a 3D transform,
      // and measuring the projected box would feed the radius back into itself.
      const w = ring.current.offsetWidth || 252;
      st.current.radius = (w / 2 + 44) / Math.tan(Math.PI / BRANDS.length);
      ring.current.style.setProperty("--ring-r", st.current.radius + "px");
      // Paint one frame straight away so the cylinder is correct before rAF starts.
      ring.current.style.transform = "translateZ(" + -st.current.radius + "px) rotateY(" + st.current.angle + "deg)";
    };
    fit();
    const ro = new ResizeObserver(fit);
    if (ring.current) ro.observe(ring.current);
    window.addEventListener("resize", fit);
    return () => { ro.disconnect(); window.removeEventListener("resize", fit); };
  }, [BRANDS.length]);

  React.useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const sync = () => { st.current.reduced = mq.matches; };
    sync();
    mq.addEventListener("change", sync);

    let raf = 0, last = performance.now();
    const tick = (now) => {
      const dt = Math.min(now - last, 64) / 1000;
      last = now;
      const s = st.current;
      if (s.drag === null) {
        // 28s per full turn, plus whatever spin the last drag left behind.
        if (!pausedRef.current && !s.reduced) s.angle += 12.85 * dt;
        s.angle += s.vel * dt;
        s.vel *= Math.pow(0.12, dt);
        if (Math.abs(s.vel) < 0.4) s.vel = 0;
      }
      if (ring.current) ring.current.style.transform = "translateZ(" + -s.radius + "px) rotateY(" + s.angle + "deg)";
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => { cancelAnimationFrame(raf); mq.removeEventListener("change", sync); };
  }, []);

  const onDown = (e) => { st.current.drag = { x: e.clientX, t: performance.now() }; st.current.vel = 0; e.currentTarget.setPointerCapture(e.pointerId); };
  const onMove = (e) => {
    const d = st.current.drag;
    if (!d) return;
    const dx = e.clientX - d.x, now = performance.now();
    st.current.angle += dx * 0.28;
    st.current.vel = (dx * 0.28) / (Math.max(now - d.t, 1) / 1000);
    st.current.drag = { x: e.clientX, t: now };
  };
  const onUp = (e) => {
    if (st.current.drag) st.current.vel = Math.max(-540, Math.min(540, st.current.vel));
    st.current.drag = null;
    if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
  };
  const nudge = (dir) => { st.current.angle += dir * step; };
  const onKey = (e) => {
    if (e.key === "ArrowRight") { nudge(-1); e.preventDefault(); }
    else if (e.key === "ArrowLeft") { nudge(1); e.preventDefault(); }
    else if (e.key === " " || e.key === "Enter") { setPaused((p) => !p); e.preventDefault(); }
  };

  return (
    <GrainField atmosphere="flat">
      <Sec style={{ paddingTop: "var(--space-12)", paddingBottom: "var(--space-11)" }} label="Clients">
        <div className="gx-reveal">
          <SectionHead eyebrow={F.brandsCaption} caption={`${F.counts.clients} clients · ${BRANDS.length} shown`} title={F.brandsHeading} maxWidth="16ch" />
        </div>
        <div
          className="gx-ring-stage gx-reveal"
          tabIndex={0}
          role="group"
          aria-label="Rotating list of client brands. Use left and right arrow keys to turn, space to pause."
          onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
          onMouseEnter={() => setPaused(true)} onMouseLeave={() => setPaused(false)}
          onFocus={() => setPaused(true)} onBlur={() => setPaused(false)}
          onKeyDown={onKey}
        >
          <div className="gx-ring" ref={ring} aria-hidden="true">
            {BRANDS.map((b, i) => (
              <div key={b.src} className="gx-ring-card" style={{ transform: "rotateY(" + i * step + "deg) translateZ(var(--ring-r, 520px))" }}>
                <span className="gx-ring-tile"><img className="gx-ring-logo" src={b.src} alt="" /></span>
                {!b.unconfirmed && <span className="gx-ring-meta">{b.name}</span>}
              </div>
            ))}
          </div>
          <div className="gx-ring-reflect" aria-hidden="true" />
          <ul className="gx-sr">{BRANDS.filter((b) => !b.unconfirmed).map((b) => <li key={b.src}>{b.name}</li>)}</ul>
        </div>
        <div className="gx-ring-ctrls" style={{ marginTop: "var(--space-9)", justifyContent: "space-between" }}>
          <MetaRow tone="faint" items={["DRAG TO SPIN", "HOVER TO HOLD", "360° CYLINDER"]} />
          <div style={{ display: "flex", gap: "var(--space-4)" }}>
            <IconButton name="arrow-left" size={44} label="Turn left" onClick={() => nudge(1)} />
            <IconButton name={paused ? "play" : "pause"} size={44} label={paused ? "Resume rotation" : "Pause rotation"} onClick={() => setPaused((p) => !p)} />
            <IconButton name="arrow-right" size={44} label="Turn right" onClick={() => nudge(-1)} />
          </div>
        </div>
      </Sec>
    </GrainField>
  );
}

function App() {
  return (
    <React.Fragment>
      <SkipLink />
      <main id="main">
        {/* The orbit's layer spans the hero and the work list below it. */}
        <div className="gx-orbit-span">
          <Hero />
          <WorkSection />
          <OrbitStage />
        </div>
        <Recognition />
        <Brands />
        {/* Featured modules (Stiled, Refillable CAN, Re-Soil, Konnect) and
            the featured grid were replaced by the full project list above;
            each case study still has its own page. */}
      </main>
      <ContactFooter />
    </React.Fragment>
  );
}

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