// Veska homepage sections — nav, hero, stats, ecosystem, spotlight, ticker, cta, footer
// All copy flows through useI18n() (veska-site/i18n.jsx); JA is the default.

function Logo() {
  // Always returns to the main page. On the home page it just scrolls to the top.
  const onClick = (e) => {
    const p = window.location.pathname;
    const isHome = p === '/' || p === '' || /\/(index\.html)?$/.test(p);
    if (isHome) {
      e.preventDefault();
      window.scrollTo({ top: 0, behavior: 'smooth' });
    }
  };
  return (
    <a className="logo" href="index.html" onClick={onClick} aria-label="Veska — home">
      <span className="logo-mark"><i></i><i></i></span>
      <span className="logo-word">ves<b>ka</b></span>
    </a>);

}

function LangToggle() {
  const { lang, setLang } = useI18n();
  return (
    <div className="lang-toggle" role="group" aria-label="Language">
      <button className={lang === 'ja' ? 'on' : ''} onClick={() => setLang('ja')}>日本語</button>
      <button className={lang === 'en' ? 'on' : ''} onClick={() => setLang('en')}>EN</button>
    </div>);

}

// reveal-on-scroll — pure rAF tween with manual rect checks (no IntersectionObserver,
// no CSS transitions): base CSS keeps .rv visible, so environments where IO or
// transitions never fire still render full content.
function useReveal() {
  React.useEffect(() => {
    const els = Array.from(document.querySelectorAll('.rv'));
    if (!els.length) return;
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const pending = new Set(els);
    els.forEach((el) => {el.style.opacity = '0';el.style.transform = 'translateY(26px)';});
    const tweens = [];
    let raf = null;
    const tick = (now) => {
      for (let i = tweens.length - 1; i >= 0; i--) {
        const tw = tweens[i];
        const p = Math.min(1, Math.max(0, (now - tw.t0) / 700));
        const e = 1 - Math.pow(1 - p, 3);
        tw.el.style.opacity = String(e);
        tw.el.style.transform = `translateY(${(26 * (1 - e)).toFixed(2)}px)`;
        if (p >= 1) {tw.el.style.opacity = '';tw.el.style.transform = '';tweens.splice(i, 1);}
      }
      const vh = window.innerHeight;
      pending.forEach((el) => {
        const r = el.getBoundingClientRect();
        if (r.top < vh * 0.88 && r.bottom > -20) {
          pending.delete(el);
          tweens.push({ el, t0: now + (el.classList.contains('rv2') ? 120 : 0) });
        }
      });
      if (pending.size || tweens.length) raf = requestAnimationFrame(tick);else
      raf = null;
    };
    const kick = () => {if (!raf) raf = requestAnimationFrame(tick);};
    kick();
    window.addEventListener('scroll', kick, { passive: true });
    return () => {
      window.removeEventListener('scroll', kick);
      if (raf) cancelAnimationFrame(raf);
      els.forEach((el) => {el.style.opacity = '';el.style.transform = '';});
    };
  }, []);
}

// hero photo tile — two stacked slots crossfade on a timer ("the photos change")
function HeroTile({ idBase, delay, srcA, srcB }) {
  const [front, setFront] = React.useState(0);
  React.useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const iv = setInterval(() => setFront((f) => 1 - f), 3400 + delay);
    return () => clearInterval(iv);
  }, [delay]);
  // The newly-active photo wipes up from the bottom, cutting over the one beneath it
  // (the previous photo stays in place, fully visible, until it's covered).
  const wipe = 'heroWipe 1s cubic-bezier(0.76, 0, 0.18, 1)';
  return (
    <span className="htile">
      <image-slot id={idBase + '-a'} radius="14" placeholder="b&w" src={srcA}
        style={{ zIndex: front === 0 ? 2 : 1, animation: front === 0 ? wipe : 'none' }}></image-slot>
      <image-slot id={idBase + '-b'} radius="14" placeholder="b&w alt" src={srcB}
        style={{ zIndex: front === 1 ? 2 : 1, animation: front === 1 ? wipe : 'none' }}></image-slot>
    </span>);

}

// Real B&W imagery wired into the author-controlled `src` of each slot.
// Every tile gets a distinct image — no repeats across the hero or band.
const HERO_IMG = [
  ['veska-site/img/hero-screen.jpg', 'veska-site/img/hero-code.jpg'],
  ['veska-site/img/hero-hands.jpg', 'veska-site/img/hero-mobile.jpg'],
  ['veska-site/img/hero-facade.jpg', 'veska-site/img/hero-tablet.jpg'],
  ['veska-site/img/hero-osaka.jpg', 'veska-site/img/hero-ceiling.jpg'],
];
const BAND_IMG = {
  'a-1': 'veska-site/img/band-meeting.jpg',
  'a-4': 'veska-site/img/band-server.jpg',
  'b-1': 'veska-site/img/band-wire.jpg',
};
const BRIDGE_URL = 'https://bridgewebdesign.jp';
const VESKA_EMAIL = 'veskagroup@gmail.com';

// the real Bridge Web Design wordmark, rendered in black & white
function BridgeMark() {
  return (
    <div className="bridge-mark" aria-label="Bridge Web Design">
      <span className="bridge-word">Bridge<span className="bridge-dot"></span></span>
    </div>);

}

// dark blob-wave between sections — swells while you scroll, settles when you stop
function Wave({ fill = 'var(--ink)' }) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    let raf,lastY = window.scrollY,vel = 0;
    const loop = (now) => {
      raf = requestAnimationFrame(loop);
      const el = ref.current;
      if (!el || !el.ownerSVGElement) return;
      const y = window.scrollY;
      // SIGNED velocity that decays to 0 when idle — so the blob is still when
      // you stop, bulges up while scrolling down, and dips down while scrolling up
      vel = vel * 0.85 + (y - lastY) * 0.15;
      lastY = y;
      const r = el.ownerSVGElement.getBoundingClientRect();
      if (r.bottom < -80 || r.top > window.innerHeight + 80) return;
      const k = reduced ? 0 : Math.max(-1, Math.min(1, vel / 22)); // -1 up .. +1 down
      const base = 150; // resting crest height (still when idle)
      const yC = 320 - (base + k * 130);
      const yL = 320 - (base * 0.74 + k * 100);
      const yR = 320 - (base * 0.66 + k * 110);
      const cx = 690;
      el.setAttribute('d',
      `M0 ${yL} C 340 ${yL - 12} ${cx - 290} ${yC} ${cx} ${yC} C ${cx + 290} ${yC} 1100 ${yR - 24} 1440 ${yR} L1440 322 L0 322 Z`);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, []);
  return (
    <svg className="hero-wave" viewBox="0 0 1440 320" preserveAspectRatio="none" aria-hidden="true">
      <path ref={ref} fill={fill} d="M0 320 L1440 320 L1440 322 L0 322 Z"></path>
    </svg>);

}
// vertical scroll cue — label vertical, arrow pointing straight down
function ScrollCue({ light }) {
  const { t } = useI18n();
  return (
    <div className={'scue' + (light ? ' light' : '')} aria-hidden="true">
      <span className="scue-label">{t.hero.scroll}</span>
      <span className="scue-arrow">↓</span>
    </div>);

}

// scroll-story chapters — photos ride a half-circular arc, rising + swapping as you scroll,
// while the statement types in. Photo motion is driven imperatively (refs) to stay smooth.
const STORY_FOCAL = 70 * Math.PI / 180; // angle (from top) where a photo is largest/active
const STORY_SPREAD = 210 * Math.PI / 180; // angular travel per full scroll-through
function Story() {
  const { t } = useI18n();
  const outerRef = React.useRef(null);
  const arcRef = React.useRef(null);
  const lineRef = React.useRef(null);
  const orbRefs = React.useRef([]);
  const geomRef = React.useRef({ cx: 0, cy: 0, R: 0 });
  const [st, setSt] = React.useState({ idx: 0, chars: 0 });
  const chapters = t.story.chapters;
  const N = chapters.length;

  // arc geometry — a vertical circle whose centre sits near the left edge, so the
  // visible right-hand bulge reads as a half-circular line. Recomputed on resize.
  React.useEffect(() => {
    const calc = () => {
      const W = window.innerWidth, H = window.innerHeight;
      const R = Math.min(H * 0.46, 470);
      const cx = Math.max(8, W * 0.03);
      const cy = H * 0.5;
      geomRef.current = { cx, cy, R };
      if (lineRef.current) {
        const s = lineRef.current.style;
        s.width = s.height = 2 * R + 'px';
        s.left = cx - R + 'px';
        s.top = cy - R + 'px';
      }
    };
    calc();
    window.addEventListener('resize', calc);
    return () => window.removeEventListener('resize', calc);
  }, []);

  React.useEffect(() => {
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const D2 = Math.PI / 180;
    const place = (orb, theta) => {
      const g = geomRef.current;
      const x = g.cx + g.R * Math.sin(theta);
      const y = g.cy - g.R * Math.cos(theta);
      const d = Math.abs(theta - STORY_FOCAL) / D2; // degrees from focal
      const op = Math.max(0, Math.min(1, 1 - d / 60));
      const sc = 0.6 + 0.4 * Math.max(0, Math.min(1, 1 - d / 92));
      orb.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-50%, -50%) scale(${sc.toFixed(3)})`;
      orb.style.opacity = op.toFixed(3);
      orb.style.zIndex = String(Math.max(1, Math.round(12 - d / 12)));
    };
    const frame = (p) => {
      orbRefs.current.forEach((orb, i) => {
        if (!orb) return;
        const theta = STORY_FOCAL + (p - (i + 0.5) / N) * STORY_SPREAD; // increases as p grows -> photos travel downward
        place(orb, theta);
      });
      const f = Math.min(N - 0.0001, p * N);
      const idx = Math.floor(f);
      const local = f - idx;
      const len = Array.from(chapters[idx].replace(/\n/g, '')).length;
      const chars = reduced ? len : Math.round(Math.min(1, local / 0.55) * len);
      setSt((s) => (s.idx === idx && s.chars === chars ? s : { idx, chars }));
    };
    const progress = () => {
      const el = outerRef.current;
      if (!el) return null;
      const total = el.offsetHeight - window.innerHeight;
      return Math.max(0, Math.min(1, -el.getBoundingClientRect().top / total));
    };

    if (reduced) {
      const onScroll = () => { const p = progress(); if (p !== null) frame(p); };
      onScroll();
      window.addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', onScroll);
      return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); };
    }

    let raf = null;
    const loop = () => {
      raf = requestAnimationFrame(loop);
      const el = outerRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const vh = window.innerHeight;
      if (rect.bottom < -40 || rect.top > vh + 40) return; // skip work off-screen
      const p = Math.max(0, Math.min(1, -rect.top / (el.offsetHeight - vh)));
      frame(p);
    };
    raf = requestAnimationFrame(loop);
    return () => { if (raf) cancelAnimationFrame(raf); };
  }, [chapters, N]);

  const lines = chapters[st.idx].split('\n');
  let used = 0;
  return (
    <section className="story" ref={outerRef}>
      <div className="story-stick">
        <span className="story-arc-line" ref={lineRef} aria-hidden="true"></span>
        <div className="story-arc" ref={arcRef} aria-hidden="true">
          {[0, 1, 2].map((i) =>
          <div className="story-orb" key={i} ref={(el) => { orbRefs.current[i] = el; }}>
            <image-slot id={'story-ph-' + (i + 1)} radius="14"
            placeholder={'b&w — chapter 0' + (i + 1)}
            src={'veska-site/img/story' + (i + 1) + '.jpg'}></image-slot>
          </div>
          )}
        </div>
        <h2 className="story-text">
          {lines.map((ln, i) => {
            const arr = Array.from(ln);
            const start = used;
            used += arr.length;
            const shown = Math.max(0, Math.min(arr.length, st.chars - start));
            return (
              <span className="story-line" key={st.idx + '-' + i}>
                {arr.slice(0, shown).join('')}
                {shown > 0 && <span className="story-cursor"></span>}
              </span>);

          })}
        </h2>
        <div className="story-side">
          <span className="tag">{String(st.idx + 1).padStart(2, '0')} / 0{chapters.length}</span>
          <a className="btn btn-fill" href="team.html">{t.story.btn}</a>
        </div>
        <ScrollCue />
      </div>
    </section>);

}

// sideways band — mixed serif/pixel type + photos, moves with scroll
function Band() {
  const { t } = useI18n();
  // Two rows that scroll continuously in opposite directions (seamless CSS marquee).
  // Each row's content is duplicated so translateX(-50%) loops without a seam.
  const renderRow = (items, srcKey, dup) => items.map((it, i) => it.img ?

  <span className="htile band-tile" key={i}>
        <image-slot id={'band-' + srcKey + dup + '-' + i} radius="14" placeholder="b&w" src={BAND_IMG[srcKey + '-' + i]}></image-slot>
      </span> :

  <span key={i} className={it.px ? 'b-pixel' : 'b-serif'}>{it.t}</span>);

  return (
    <section className="band">
      <div className="band-row band-left">{renderRow(t.band.row1, 'a', '')}{renderRow(t.band.row1, 'a', 'x')}</div>
      <div className="band-row band-row2 band-right">{renderRow(t.band.row2, 'b', '')}{renderRow(t.band.row2, 'b', 'x')}</div>
      <Wave fill="var(--ink)" />
    </section>);

}

// approach venn — starts as three pale circles, they merge into one, each topic
// takes a solo turn (dark, with its green motif), then the three split back out
// and the green intersection blooms at the center.
const APPROACH_END = [[235, 235], [465, 235], [350, 470]];
function Approach() {
  const { t } = useI18n();
  const A = t.approach;
  const outerRef = React.useRef(null);
  const circRefs = React.useRef([]);
  const patRefs = React.useRef([]);
  const labelGRefs = React.useRef([]);
  const labelTRefs = React.useRef([]);
  const lensRef = React.useRef(null);

  React.useEffect(() => {
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const WIN = [[0.26, 0.42], [0.42, 0.58], [0.58, 0.74]];
    const c01 = (x) => Math.max(0, Math.min(1, x));
    const win = (p, a, b) => Math.min(c01((p - a) / 0.05), c01((b - p) / 0.05), 1);
    let raf = null;
    const loop = (now) => {
      raf = requestAnimationFrame(loop);
      const el = outerRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const vh = window.innerHeight;
      if (rect.bottom < -80 || rect.top > vh + 80) return;
      const total = el.offsetHeight - vh;
      let p = c01(-rect.top / total);
      if (reduced) p = 1;
      const ts = now / 1000;
      const solos = WIN.map(([a, b]) => win(p, a, b));
      const soloSum = solos[0] + solos[1] + solos[2];
      const m = c01((p - 0.06) / 0.16);  // merge: three → one
      const s = c01((p - 0.74) / 0.18);  // split: one → three
      const mm = m * m * (3 - 2 * m);
      const ss = 1 - Math.pow(1 - s, 2);
      const f = c01(1 - mm * (1 - ss));  // 1 = spread, 0 = merged
      APPROACH_END.forEach((end, i) => {
        const cx = 350 + (end[0] - 350) * f;
        const cy = 350 + (end[1] - 350) * f;
        const dark = Math.max(solos[i], s);
        const vis = c01(1 - (soloSum - solos[i]) + s);
        const c = circRefs.current[i];
        if (c) {
          c.setAttribute('cx', cx.toFixed(1));
          c.setAttribute('cy', cy.toFixed(1));
          c.setAttribute('fill-opacity', (0.07 + 0.8 * dark).toFixed(3));
          c.setAttribute('stroke-opacity', (0.14 + 0.34 * Math.max(f * 0.7, dark)).toFixed(3));
          c.setAttribute('opacity', vis.toFixed(3));
        }
        const pat = patRefs.current[i];
        if (pat) pat.setAttribute('opacity', (solos[i] * (1 - s)).toFixed(3));
        const g = labelGRefs.current[i];
        if (g) {
          g.setAttribute('transform', `translate(${cx.toFixed(1)} ${cy.toFixed(1)})`);
          g.setAttribute('opacity', (Math.max(0.5 * (1 - soloSum), solos[i], s) * vis).toFixed(3));
        }
        const tx = labelTRefs.current[i];
        if (tx) {
          const v = Math.round(150 + 100 * dark);
          tx.setAttribute('fill', `rgb(${v},${v},${v})`);
        }
      });
      // motif life while on stage: rings rotate, rays drift, candles breathe
      if (!reduced) {
        const p0 = patRefs.current[0];
        if (p0) p0.setAttribute('transform', `rotate(${(ts * 7 % 360).toFixed(2)} 350 350)`);
        const p1 = patRefs.current[1];
        if (p1) {
          const d = (Math.sin(ts * 1.1) * 7).toFixed(2);
          p1.setAttribute('transform', `translate(${d} ${d})`);
        }
        const p2 = patRefs.current[2];
        if (p2) p2.setAttribute('transform', `translate(350 350) scale(1 ${(1 + 0.08 * Math.sin(ts * 1.7)).toFixed(3)}) translate(-350 -350)`);
      }
      if (lensRef.current) {
        const ls = c01((s - 0.5) / 0.5);
        lensRef.current.setAttribute('opacity', ls.toFixed(3));
        lensRef.current.setAttribute('transform', `translate(350 323.3) scale(${(0.5 + 0.5 * ls).toFixed(3)}) translate(-350 -323.3)`);
      }
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, []);

  return (
    <section className="approach" ref={outerRef}>
      <div className="approach-stick">
        <div className="approach-venn">
          <svg viewBox="0 0 700 700" aria-hidden="true">
            <defs>
              <clipPath id="vclip"><circle cx="350" cy="350" r="165"></circle></clipPath>
            </defs>
            <g style={{ mixBlendMode: 'multiply' }}>
              {[0, 1, 2].map((i) =>
              <circle key={i} ref={(el) => {circRefs.current[i] = el;}}
              cx={APPROACH_END[i][0]} cy={APPROACH_END[i][1]} r="165" fill="#232924" fillOpacity="0.07"
              stroke="#1b201c" strokeWidth="1.4" strokeOpacity="0.16"></circle>
              )}
            </g>
            {/* solo motifs — clipped to the centered circle */}
            <g clipPath="url(#vclip)">
              <g ref={(el) => {patRefs.current[0] = el;}} opacity="0" fill="none" stroke="var(--accent)" strokeWidth="2">
                {[62, 102, 142].map((r) =>
                <circle key={r} cx="350" cy="350" r={r} strokeDasharray="36 26" opacity="0.85"></circle>
                )}
              </g>
              <g ref={(el) => {patRefs.current[1] = el;}} opacity="0" stroke="var(--accent)" strokeWidth="2">
                {Array.from({ length: 6 }).map((_, i) => {
                  const sx = 232 + i * 19,sy = 214 + i * 7,len = (52 + i % 3 * 26) * 0.72;
                  return (
                    <g key={'a' + i}>
                      <line x1={sx} y1={sy} x2={sx + len} y2={sy + len}></line>
                      <circle cx={sx + len} cy={sy + len} r="3.5" fill="var(--accent)" stroke="none"></circle>
                    </g>);

                })}
                {Array.from({ length: 6 }).map((_, i) => {
                  const sx = 468 - i * 19,sy = 486 - i * 7,len = (52 + i % 3 * 26) * 0.72;
                  return (
                    <g key={'b' + i}>
                      <line x1={sx} y1={sy} x2={sx - len} y2={sy - len}></line>
                      <circle cx={sx - len} cy={sy - len} r="3.5" fill="var(--accent)" stroke="none"></circle>
                    </g>);

                })}
              </g>
              <g ref={(el) => {patRefs.current[2] = el;}} opacity="0" stroke="var(--accent)" strokeWidth="2">
                {Array.from({ length: 11 }).map((_, i) => {
                  const x = 262 + i * 18;
                  const h = 34 + i * 53 % 66;
                  const y = 350 - h / 2 + (i % 2 ? 52 : -52);
                  return (
                    <g key={i}>
                      <line x1={x} y1={y} x2={x} y2={y + h}></line>
                      <rect x={x - 3.5} y={y + h * 0.3} width="7" height={Math.max(8, h * 0.34)} fill="var(--accent)" stroke="none"></rect>
                    </g>);

                })}
              </g>
            </g>
            <path ref={lensRef} opacity="0" fill="var(--accent)"
            d="M317.1 308.3 A165 165 0 0 1 382.9 308.3 A165 165 0 0 1 350 353.3 A165 165 0 0 1 317.1 308.3 Z"></path>
            {[0, 1, 2].map((i) =>
            <g key={i} ref={(el) => {labelGRefs.current[i] = el;}} transform={`translate(${APPROACH_END[i][0]} ${APPROACH_END[i][1]})`}>
                <text ref={(el) => {labelTRefs.current[i] = el;}} className="venn-label"
              textAnchor="middle" y="10" fill="rgb(150,150,150)">{A.labels[i]}</text>
              </g>
            )}
          </svg>
        </div>
        <div className="approach-copy">
          <h2>{A.heading}<span style={{ color: 'var(--accent)' }}>_</span></h2>
          <p>{A.copy}</p>
          <a className="btn btn-fill" href="contact.html">{A.btn}</a>
        </div>
      </div>
      <Wave fill="var(--ink)" />
    </section>);

}

function Hero() {
  const { t, lang } = useI18n();
  const heroRef = React.useRef(null);

  // entrance: pure rAF tween writing inline styles — base CSS keeps lines fully
  // visible, so captures/exports/suppressed-anim contexts can never blank the hero
  React.useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const lines = heroRef.current ? Array.from(heroRef.current.querySelectorAll('.hline')) : [];
    if (!lines.length) return;
    const DUR = 850;
    const ease = (x) => 1 - Math.pow(1 - x, 3);
    const clear = () => lines.forEach((el) => {el.style.opacity = '';el.style.transform = '';});
    let raf,t0 = null;
    const step = (now) => {
      if (t0 === null) t0 = now;
      let allDone = true;
      lines.forEach((el, i) => {
        const p = Math.min(1, Math.max(0, (now - t0 - i * 130) / DUR));
        const e = ease(p);
        el.style.opacity = String(e);
        el.style.transform = `translateY(${(34 * (1 - e)).toFixed(2)}px)`;
        if (p < 1) allDone = false;
      });
      if (allDone) {clear();return;}
      raf = requestAnimationFrame(step);
    };
    raf = requestAnimationFrame(step);
    return () => {cancelAnimationFrame(raf);clear();};
  }, [lang]);

  return (
    <header className="hero-light" id="top" ref={heroRef}>
      <div className="hero-light-inner">
        <h1 className="hstate">
          {t.hero.lines.map((line, i) =>
          <span className="hline" key={lang + i}>
              {i % 2 === 1 && <HeroTile idBase={'ht-' + i} delay={i * 700} srcA={(HERO_IMG[i] || [])[0]} srcB={(HERO_IMG[i] || [])[1]} />}
              <span>{line}</span>
              {i % 2 === 0 && <HeroTile idBase={'ht-' + i} delay={i * 700} srcA={(HERO_IMG[i] || [])[0]} srcB={(HERO_IMG[i] || [])[1]} />}
            </span>
          )}
        </h1>
      </div>
      <ScrollCue light />
      <Wave />
    </header>);

}

// counter that ticks up when visible
function StatNum({ to, suffix, pad }) {
  const ref = React.useRef(null);
  const [v, setV] = React.useState(0);
  React.useEffect(() => {
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (reduced) {setV(to);return;}
    const io = new IntersectionObserver(([e]) => {
      if (!e.isIntersecting) return;
      io.disconnect();
      const t0 = performance.now(),dur = 1100;
      const step = (now) => {
        const p = Math.min(1, (now - t0) / dur);
        setV(Math.round(to * (1 - Math.pow(1 - p, 3))));
        if (p < 1) requestAnimationFrame(step);
      };
      requestAnimationFrame(step);
    }, { threshold: 0.5 });
    io.observe(ref.current);
    return () => io.disconnect();
  }, [to]);
  const txt = pad ? String(v).padStart(2, '0') : String(v);
  return <span ref={ref} className="stat-num">{txt}{suffix && <em>{suffix}</em>}</span>;
}

function Stats() {
  const { t } = useI18n();
  const nums = [{ to: 1, pad: true }, { to: 2, pad: true }, { to: 10, suffix: '+' }];
  return (
    <section>
      <div className="wrap">
        <div className="stats rv">
          {t.stats.map((label, i) =>
          <div className="stat" key={i}>
              <StatNum {...nums[i]} />
              <span className="tag">{label}</span>
            </div>
          )}
        </div>
      </div>
    </section>);

}

function Ecosystem({ motion, onOpen }) {
  const { t } = useI18n();
  return (
    <section id="ecosystem">
      <div className="wrap">
        <div className="sec-head rv">
          <h2>{t.eco.heading}<span style={{ color: 'var(--accent)' }}>_</span></h2>
        </div>
        <div className="rv rv2">
          <EcosystemMap motion={motion} onOpen={onOpen} />
        </div>
        <div className="rv rv2 eco-cta">
          <a className="btn btn-fill" href="ventures.html">{t.eco.cta}</a>
        </div>
      </div>
    </section>);

}

function Spotlight() {
  const { t } = useI18n();
  return (
    <section id="ventures">
      <div className="wrap">
        <div className="sec-head rv">
          <h2>{t.spot.heading}<span style={{ color: 'var(--accent)' }}>_</span></h2>
        </div>
        <div className="rv rv2">
          <div className="photo spot-photo">
            <BridgeMark />
          </div>
          <div className="spot-meta">
            <div>
              <h3>Bridge Web Design<span className="cursor"></span></h3>
              <span className="tag"><span className="dot">●</span> {t.spot.meta}</span>
            </div>
            <a className="btn btn-ghost" href={BRIDGE_URL} target="_blank" rel="noopener noreferrer">{t.spot.visit}</a>
          </div>
        </div>
      </div>
    </section>);

}

function Ticker() {
  const { t } = useI18n();
  const half =
  <React.Fragment>
      {t.ticker.map((it, i) => <span key={i}>{it}<i> ◦ </i></span>)}
    </React.Fragment>;

  return (
    <div className="ticker" aria-hidden="true">
      <div className="ticker-track">{half}{half}</div>
    </div>);

}

function Cta() {
  const { t } = useI18n();
  return (
    <section className="cta" id="contact">
      <div className="wrap">
        <h2 className="rv">{t.cta.heading}<span style={{ color: 'var(--accent)' }}>_</span></h2>
        <p className="rv rv2">{t.cta.copy}</p>
        <a className="btn btn-fill rv rv2" href={'mailto:' + VESKA_EMAIL}>{t.cta.btn}</a>
      </div>
    </section>);

}

function Foot() {
  const { t } = useI18n();
  return (
    <footer>
      <div className="wrap foot-inner">
        <Logo />
        <span className="tag">
          {t.menu.slice(1).map((m, i) =>
          <React.Fragment key={m.id}>{i > 0 && <span> · </span>}<a href={m.href}>{m.label}</a></React.Fragment>
          )}
        </span>
        <span className="tag">{t.foot.copyright}</span>
      </div>
    </footer>);

}

function VentureDrawer({ open, onClose }) {
  const { t } = useI18n();
  React.useEffect(() => {
    document.body.classList.toggle('drawer-open', open);
    const esc = (e) => {if (e.key === 'Escape') onClose();};
    window.addEventListener('keydown', esc);
    return () => {window.removeEventListener('keydown', esc);document.body.classList.remove('drawer-open');};
  }, [open, onClose]);
  return (
    <React.Fragment>
      <div className="drawer-veil" onClick={onClose}></div>
      <aside className="drawer" aria-hidden={!open}>
        <button className="drawer-close" onClick={onClose} aria-label="Close">×</button>
        <span className="tag"><span className="dot">●</span> {t.drawer.tag}</span>
        <h3>Bridge Web Design</h3>
        <div className="photo" style={{ height: 170 }}>
          <BridgeMark />
        </div>
        <p>{t.drawer.copy}</p>
        <p style={{ fontFamily: 'var(--font-tag)', fontSize: 12, letterSpacing: '0.14em', textTransform: 'uppercase' }}>{t.drawer.skills}</p>
        <div style={{ display: 'flex', gap: 12, marginTop: 'auto' }}>
          <a className="btn btn-fill" href={BRIDGE_URL} target="_blank" rel="noopener noreferrer">{t.drawer.visit}</a>
          <button className="btn btn-ghost" onClick={onClose}>{t.drawer.back}</button>
        </div>
      </aside>
    </React.Fragment>);

}

function Nav({ page, onMenu }) {
  // floating chrome (no bar). Light/dark variant follows whichever section is under it.
  React.useEffect(() => {
    let raf = null;
    const calc = () => {
      raf = null;
      let light = false;
      document.querySelectorAll('.hero-light, .band, .approach').forEach((s) => {
        const r = s.getBoundingClientRect();
        if (r.top < 48 && r.bottom > 48) light = true;
      });
      document.body.classList.toggle('on-light', light);
      document.body.classList.toggle('nav-scrolled', window.scrollY > 90);
    };
    const onScroll = () => {if (raf) return;raf = requestAnimationFrame(calc);};
    onScroll();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      if (raf) cancelAnimationFrame(raf);
      document.body.classList.remove('on-light');
    };
  }, []);

  return (
    <nav className="nav">
      <div className="wrap nav-inner">
        <Logo />
        <div className="nav-right">
          <LangToggle />
          <button className="menu-btn" onClick={onMenu} aria-label="Menu">
            <span className="menu-bars"><i></i><i></i><i></i></span>
          </button>
        </div>
      </div>
    </nav>);

}

// Compact dropdown anchored under the menu button (top-right) — options appear
// right where you clicked, instead of a full-screen overlay across the page.
function MenuOverlay({ open, page, onClose }) {
  const { t } = useI18n();
  React.useEffect(() => {
    const esc = (e) => {if (e.key === 'Escape') onClose();};
    window.addEventListener('keydown', esc);
    return () => window.removeEventListener('keydown', esc);
  }, [onClose]);
  return (
    <React.Fragment>
      <div className={'menu-veil' + (open ? ' on' : '')} onClick={onClose}></div>
      <div className={'menu-dd' + (open ? ' on' : '')} aria-hidden={!open}>
        <nav className="menu-dd-links">
          {t.menu.map((m, i) =>
          <a key={m.id} href={m.href} className={page === m.id ? 'on' : ''} onClick={onClose}>
              <span className="menu-num">0{i + 1}</span>{m.label}
            </a>
          )}
        </nav>
        <a className="menu-dd-mail tag" href={'mailto:' + VESKA_EMAIL}>{VESKA_EMAIL}</a>
      </div>
    </React.Fragment>);

}

// Shared page chrome: lang state, nav, menu overlay, footer, base tweaks.
function PageShell({ page, titleKey, extraTweaks, children }) {
  const [tw, setTweak] = useTweaks(PAGE_TWEAK_DEFAULTS);
  const [menuOpen, setMenuOpen] = React.useState(false);
  const [lang, setLang] = React.useState(() => {
    try {return localStorage.getItem('veska-lang') || 'ja';} catch (e) {return 'ja';}
  });
  useReveal();

  React.useEffect(() => {
    try {localStorage.setItem('veska-lang', lang);} catch (e) {}
    document.documentElement.lang = lang;
    const s = STRINGS[lang];
    document.title = titleKey ? titleKey.split('.').reduce((o, k) => o[k], s) : s.title;
    document.body.classList.toggle('lang-ja', lang === 'ja');
  }, [lang, titleKey]);

  React.useEffect(() => {
    document.documentElement.style.setProperty('--accent', tw.accent);
    document.body.classList.toggle('treat-duotone', tw.photoTreatment === 'duotone');
  }, [tw.accent, tw.photoTreatment]);

  React.useEffect(() => {
    // the team page is a light, textured page like the hero/band
    document.body.classList.toggle('theme-light', page === 'team');
    return () => { document.body.classList.remove('theme-light'); };
  }, [page]);

  return (
    <LangCtx.Provider value={{ lang, setLang }}>
      <Nav page={page} onMenu={() => setMenuOpen((o) => !o)} />
      {children}
      <Foot />
      <MenuOverlay open={menuOpen} page={page} onClose={() => setMenuOpen(false)} />
      <TweaksPanel>
        <TweakSection label="Brand" />
        <TweakColor label="Accent" value={tw.accent} options={['#2bee4b', '#93b799', '#2A6FDB']} onChange={(v) => setTweak('accent', v)} />
        <TweakRadio label="Photos" value={tw.photoTreatment} options={['mono', 'duotone']} onChange={(v) => setTweak('photoTreatment', v)} />
      </TweaksPanel>
    </LangCtx.Provider>);

}
const PAGE_TWEAK_DEFAULTS = { accent: '#2bee4b', photoTreatment: 'mono' };

// manifesto — statement chars light up as the section scrolls through the viewport
function Manifesto() {
  const { t } = useI18n();
  const ref = React.useRef(null);
  const [n, setN] = React.useState(0);
  const parts = React.useMemo(() => t.manifesto.split('|'), [t.manifesto]);
  const text = parts.join('');
  const chars = React.useMemo(() => Array.from(text), [text]);
  const accStart = parts.length > 1 ? Array.from(parts[0]).length : chars.length + 1;

  React.useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {setN(chars.length);return;}
    let raf = null;
    const onScroll = () => {
      if (raf) return;
      raf = requestAnimationFrame(() => {
        raf = null;
        if (!ref.current) return;
        const r = ref.current.getBoundingClientRect();
        const vh = window.innerHeight;
        const p = Math.max(0, Math.min(1, (vh * 0.88 - r.top) / (vh * 0.62)));
        setN(Math.round(p * chars.length));
      });
    };
    onScroll();
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => {window.removeEventListener('scroll', onScroll);if (raf) cancelAnimationFrame(raf);};
  }, [chars.length]);

  return (
    <section className="manifesto">
      <div className="wrap">
        <p ref={ref}>
          {chars.map((c, i) =>
          <span key={i} className={(i < n ? 'lit' : '') + (i >= accStart ? ' acc' : '')}>{c}</span>
          )}
        </p>
      </div>
    </section>);

}

Object.assign(window, { Logo, Nav, Hero, Stats, Ecosystem, Spotlight, Ticker, Cta, Foot, VentureDrawer, useReveal, LangToggle, Manifesto, MenuOverlay, PageShell, Wave, Story, Band, ScrollCue, Approach, BridgeMark });