// twinmere-bundle.jsx — AUTO-GENERATED from v2 sources, game name = TWINMERE.


/* ==================== demand-tracking helpers ==================== */
// 수요 데이터 수집: Firebase Firestore(compat) + GA4.
// index.html 쪽 config가 비어 있으면 모두 조용히 no-op 한다(사이트는 절대 깨지지 않음).

function getVisitorId() {
  const KEY = 'tm-visitor-id';
  try {
    let id = localStorage.getItem(KEY);
    if (!id) {
      id = (window.crypto && crypto.randomUUID)
        ? crypto.randomUUID()
        : 'v-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
      localStorage.setItem(KEY, id);
    }
    return id;
  } catch (e) {
    return 'v-anonymous'; // localStorage 불가(시크릿 모드 등) 시 폴백
  }
}

async function logDemand(collectionName, data) {
  try {
    if (!window.firebase || !firebase.apps || !firebase.apps.length) return; // 미초기화 → no-op
    await firebase.firestore().collection(collectionName).add({
      ...data,
      visitorId: getVisitorId(),
      lang: window.__TM_LANG__ || document.documentElement.lang || null,
      path: location.pathname,
      referrer: document.referrer || null,
      ua: navigator.userAgent,
      ts: firebase.firestore.FieldValue.serverTimestamp(),
    });
  } catch (e) {
    console.error('[twinmere] logDemand failed:', collectionName, e); // 절대 throw 하지 않음
  }
}

// 사전예약 저장 — 이메일(소문자)을 문서 ID로 사용해 중복을 감지한다.
// Firestore 규칙이 create만 허용하고 update를 금지하므로, 이미 존재하는
// 이메일에 대한 set()은 permission-denied 로 거부되며 이를 '중복'으로 본다.
// 반환값: 'created' | 'duplicate' | 'error'
async function submitWaitlist(email) {
  if (!window.firebase || !firebase.apps || !firebase.apps.length) return 'created'; // 백엔드 미설정 → 성공 UX 유지
  const docId = String(email).trim().toLowerCase();
  try {
    await firebase.firestore().collection('twinmere_waitlist').doc(docId).set({
      email,
      visitorId: getVisitorId(),
      lang: window.__TM_LANG__ || document.documentElement.lang || null,
      path: location.pathname,
      referrer: document.referrer || null,
      ua: navigator.userAgent,
      ts: firebase.firestore.FieldValue.serverTimestamp(),
    });
    return 'created';
  } catch (e) {
    const code = e && e.code ? String(e.code) : '';
    if (code.indexOf('permission-denied') !== -1) return 'duplicate'; // 기존 문서 update 거부 = 이미 등록됨
    console.error('[twinmere] submitWaitlist failed:', e);
    return 'error';
  }
}

function trackGA(eventName, params) {
  try {
    if (typeof window.gtag === 'function') window.gtag('event', eventName, params || {});
  } catch (e) { /* no-op */ }
}

// 최초 표시 언어 자동 결정:
//   1) 사용자가 이전에 직접 고른 값(localStorage) 우선
//   2) 브라우저 언어가 한국어(ko*)면 ko
//   3) 그 외 지역/언어는 en
function detectInitialLang() {
  try {
    const saved = localStorage.getItem('tm-lang');
    if (saved === 'ko' || saved === 'en') return saved;
  } catch (e) { /* localStorage 불가 */ }
  try {
    // 브라우저의 '주 언어'(첫 번째)만 본다. 목록에 한국어가 섞여만 있어도
    // ko로 잡히지 않도록 — 주 언어가 한국어일 때만 ko.
    const primary = (navigator.languages && navigator.languages.length)
      ? navigator.languages[0]
      : (navigator.language || navigator.userLanguage || '');
    if (String(primary).toLowerCase().indexOf('ko') === 0) return 'ko';
  } catch (e) { /* no-op */ }
  return 'en';
}

// ============================================================
// 사전예약 결과 모달 — 폼에서 dispatch하는 이벤트를 받아 화면 중앙 팝업을 띄운다.
// 인라인으로 폼을 바꾸지 않으므로, 같은 컴퓨터로 여러 명이 연달아 신청해도
// 매번 확실한 피드백이 뜨고 폼은 그대로 재사용된다.
// status: 'created'(신규 완료) | 'duplicate'(이미 등록됨)
// ============================================================
function showSignupPopup(status, lang) {
  try {
    window.dispatchEvent(new CustomEvent('tm-signup-popup', { detail: { status, lang } }));
  } catch (e) { /* no-op */ }
}

function SignupPopup() {
  const SHARE_URL = 'https://twinmere.com';
  const [data, setData] = React.useState(null); // { status, lang } | null
  const [copied, setCopied] = React.useState(false);
  const canNativeShare = typeof navigator !== 'undefined' && typeof navigator.share === 'function';
  React.useEffect(() => {
    const onEvt = (e) => { setCopied(false); setData((e && e.detail) || null); };
    window.addEventListener('tm-signup-popup', onEvt);
    return () => window.removeEventListener('tm-signup-popup', onEvt);
  }, []);
  React.useEffect(() => {
    if (!data) return;
    const onKey = (e) => { if (e.key === 'Escape') setData(null); };
    window.addEventListener('keydown', onKey); // 공유할 시간을 위해 자동닫힘은 제거
    return () => window.removeEventListener('keydown', onKey);
  }, [data]);
  if (!data) return null;
  const dup = data.status === 'duplicate';
  const empty = data.status === 'empty';
  const invalid = data.status === 'invalid';
  const warn = empty || invalid; // 입력값 문제 → 경고 스타일
  const ko = data.lang === 'ko';
  const title = empty
    ? (ko ? '이메일 주소를 입력해 주세요' : 'Please enter your email')
    : invalid
    ? (ko ? '이메일 주소를 다시 확인해 주세요' : 'Please check your email')
    : dup
    ? (ko ? '이미 사전예약된 이메일이에요' : "You're already on the list!")
    : (ko ? '사전예약이 완료되었습니다' : "You're on the list!");
  const sub = empty
    ? (ko ? '출시 소식을 보내드릴 주소가 필요해요.' : 'We need an address to send the news to.')
    : invalid
    ? (ko ? '형식을 확인해 주세요 — 예: name@example.com' : 'Make sure it looks like name@example.com')
    : dup
    ? (ko ? '이미 등록된 주소예요 — 출시 소식을 준비하고 있어요.' : 'This email is already registered — sit tight!')
    : (ko ? '출시 소식과 데모 초대를 가장 먼저 보내드릴게요.' : 'Demo news & invites — straight to your inbox.');
  const close = () => setData(null);
  const shareText = ko
    ? '트윈미어 — 일상과 모험, 두 세계를 사는 RPG. 지금 사전예약 중!'
    : 'Twinmere — one RPG, two worlds. Pre-register now!';
  const copyToClipboard = async () => {
    const fallbackCopy = () => {
      try {
        const ta = document.createElement('textarea');
        ta.value = SHARE_URL;
        ta.style.position = 'fixed'; ta.style.top = '-1000px'; ta.style.opacity = '0';
        document.body.appendChild(ta); ta.focus(); ta.select();
        const ok = document.execCommand('copy');
        document.body.removeChild(ta);
        return ok;
      } catch (e) { return false; }
    };
    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        await navigator.clipboard.writeText(SHARE_URL);
        return true;
      }
      return fallbackCopy();
    } catch (e) {
      return fallbackCopy(); // 비동기 클립보드 API 거부 시 execCommand로 폴백
    }
  };
  // 공유하기 — 모바일: OS 공유 시트(카카오톡·메시지 등) / 데스크톱: 링크 복사로 폴백
  const shareOrCopy = async () => {
    if (canNativeShare) {
      try {
        await navigator.share({ title: 'Twinmere', text: shareText, url: SHARE_URL });
        trackGA('twinmere_share', { method: 'native', lang: data.lang });
      } catch (e) { /* 사용자 취소 등 무시 */ }
      return;
    }
    const ok = await copyToClipboard();
    if (ok) {
      setCopied(true);
      trackGA('twinmere_share', { method: 'copy', lang: data.lang });
      setTimeout(() => setCopied(false), 2000);
    }
  };
  const xUrl = 'https://twitter.com/intent/tweet?text=' +
    encodeURIComponent(shareText) + '&url=' + encodeURIComponent(SHARE_URL);
  // 카카오톡 공유 — 한국어일 때만. JS 키가 없으면 Kakao 미초기화 → 버튼 자동 숨김.
  const canKakao = ko && typeof window.Kakao !== 'undefined'
    && typeof window.Kakao.isInitialized === 'function' && window.Kakao.isInitialized();
  const kakaoShare = () => {
    try {
      window.Kakao.Share.sendDefault({
        objectType: 'feed',
        content: {
          title: 'Twinmere — 일상과 모험, 두 세계를 사는 RPG',
          description: shareText,
          imageUrl: 'https://twinmere.com/assets/art-2d-town-og.jpg',
          link: { mobileWebUrl: SHARE_URL, webUrl: SHARE_URL },
        },
        buttons: [{
          title: '사전예약 하러 가기',
          link: { mobileWebUrl: SHARE_URL, webUrl: SHARE_URL },
        }],
      });
      trackGA('twinmere_share', { method: 'kakao', lang: data.lang });
    } catch (e) {
      console.error('[twinmere] Kakao share failed:', e);
    }
  };
  return (
    <div className="tm-modal-backdrop" onClick={close} role="presentation">
      <div className="tm-modal" role="dialog" aria-modal="true" aria-label={title}
           onClick={(e) => e.stopPropagation()}>
        <button className="tm-modal-x" aria-label={ko ? '닫기' : 'Close'} onClick={close}>✕</button>
        <span className={`tm-modal-icon${warn ? ' is-warn' : dup ? ' is-dup' : ''}`} aria-hidden="true">
          {warn ? (
            <svg width="30" height="30" viewBox="0 0 24 24" fill="none">
              <path d="M12 4.4 L21 19 L3 19 Z" stroke="#fff" strokeWidth="2"
                strokeLinejoin="round" />
              <path d="M12 10 L12 14" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" />
              <circle cx="12" cy="16.8" r="1.1" fill="#fff" />
            </svg>
          ) : dup ? (
            <svg width="30" height="30" viewBox="0 0 24 24" fill="none">
              <circle cx="12" cy="12" r="9.2" stroke="#1a0f06" strokeWidth="2" />
              <path d="M12 7.6 L12 13" stroke="#1a0f06" strokeWidth="2.6" strokeLinecap="round" />
              <circle cx="12" cy="16.6" r="1.15" fill="#1a0f06" />
            </svg>
          ) : (
            <svg width="30" height="30" viewBox="0 0 24 24" fill="none">
              <path d="M4 12.5 L9.5 18 L20 6.5" stroke="#1a0f06" strokeWidth="3.2"
                strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          )}
        </span>
        <div className="tm-modal-title">{title}</div>
        <div className="tm-modal-sub">{sub}</div>
        <div className="tm-modal-share">
          <div className="tm-modal-share-label">
            {ko ? '친구에게도 트윈미어를 알려주세요' : 'Tell your friends about Twinmere'}
          </div>
          <div className="tm-modal-share-btns">
            <button type="button" className="tm-share-btn primary" onClick={shareOrCopy}>
              <svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true"
                   style={{ marginRight: 7 }}>
                <path d="M12 3v12M12 3L8 7M12 3l4 4" stroke="currentColor" strokeWidth="2.2"
                      strokeLinecap="round" strokeLinejoin="round" />
                <path d="M5 13v6a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6" stroke="currentColor"
                      strokeWidth="2.2" strokeLinecap="round" />
              </svg>
              {copied
                ? (ko ? '링크 복사됨!' : 'Link copied!')
                : (ko ? '공유하기' : 'Share')}
            </button>
            {ko ? (canKakao && (
              <button type="button" className="tm-share-btn tm-share-kakao"
                      onClick={kakaoShare} aria-label="카카오톡으로 공유">
                <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
                     aria-hidden="true" style={{ marginRight: 6 }}>
                  <path d="M12 3C6.9 3 2.8 6.2 2.8 10.2c0 2.5 1.7 4.7 4.2 6l-1 3.7c-.1.3.2.5.5.4l4.4-2.9c.4 0 .7.1 1.1.1 5.1 0 9.2-3.2 9.2-7.3S17.1 3 12 3z" />
                </svg>
                카카오톡
              </button>
            )) : (
              <a className="tm-share-btn tm-share-x" href={xUrl} target="_blank"
                 rel="noopener noreferrer" aria-label="Share on X"
                 onClick={() => trackGA('twinmere_share', { method: 'x', lang: data.lang })}>𝕏</a>
            )}
          </div>
        </div>
        <button className="tm-modal-close-link" onClick={close}>{ko ? '닫기' : 'Close'}</button>
      </div>
    </div>
  );
}


/* ==================== tweaks-panel.jsx ==================== */

/* BEGIN USAGE */
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
//   TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
//   const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
//     "primaryColor": "#D97757",
//     "palette": ["#D97757", "#29261b", "#f6f4ef"],
//     "fontSize": 16,
//     "density": "regular",
//     "dark": false
//   }/*EDITMODE-END*/;
//
//   function App() {
//     const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
//     return (
//       <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
//         Hello
//         <TweaksPanel>
//           <TweakSection label="Typography" />
//           <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
//                        onChange={(v) => setTweak('fontSize', v)} />
//           <TweakRadio  label="Density" value={t.density}
//                        options={['compact', 'regular', 'comfy']}
//                        onChange={(v) => setTweak('density', v)} />
//           <TweakSection label="Theme" />
//           <TweakColor  label="Primary" value={t.primaryColor}
//                        options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
//                        onChange={(v) => setTweak('primaryColor', v)} />
//           <TweakColor  label="Palette" value={t.palette}
//                        options={[['#D97757', '#29261b', '#f6f4ef'],
//                                  ['#475569', '#0f172a', '#f1f5f9']]}
//                        onChange={(v) => setTweak('palette', v)} />
//           <TweakToggle label="Dark mode" value={t.dark}
//                        onChange={(v) => setTweak('dark', v)} />
//         </TweaksPanel>
//       </div>
//     );
//   }
//
// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to
// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
// options are many or long. For color tweaks always curate 3-4 options rather than
// a free picker; an option can also be a whole 2–5 color palette (the stored value
// is the array). The Tweak* controls are a floor, not a ceiling — build custom
// controls inside the panel if a tweak calls for UI they don't cover.
/* END USAGE */
// ─────────────────────────────────────────────────────────────────────────────

const __TWEAKS_STYLE = `
  .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
    max-height:calc(100vh - 32px);display:flex;flex-direction:column;
    transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
    background:rgba(250,249,247,.78);color:#29261b;
    -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
    border:.5px solid rgba(255,255,255,.6);border-radius:14px;
    box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
    font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
  .twk-hd{display:flex;align-items:center;justify-content:space-between;
    padding:10px 8px 10px 14px;cursor:move;user-select:none}
  .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
  .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
    width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
  .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
  .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
    overflow-y:auto;overflow-x:hidden;min-height:0;
    scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
  .twk-body::-webkit-scrollbar{width:8px}
  .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
  .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
    border:2px solid transparent;background-clip:content-box}
  .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
    border:2px solid transparent;background-clip:content-box}
  .twk-row{display:flex;flex-direction:column;gap:5px}
  .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
  .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
    color:rgba(41,38,27,.72)}
  .twk-lbl>span:first-child{font-weight:500}
  .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}

  .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
    color:rgba(41,38,27,.45);padding:10px 0 0}
  .twk-sect:first-child{padding-top:0}

  .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
    border:.5px solid rgba(0,0,0,.1);border-radius:7px;
    background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
  .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
  select.twk-field{padding-right:22px;
    background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
    background-repeat:no-repeat;background-position:right 8px center}

  .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
    border-radius:999px;background:rgba(0,0,0,.12);outline:none}
  .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
    width:14px;height:14px;border-radius:50%;background:#fff;
    border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
  .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
    background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}

  .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
    background:rgba(0,0,0,.06);user-select:none}
  .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
    background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
    transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
  .twk-seg.dragging .twk-seg-thumb{transition:none}
  .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
    background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
    border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
    overflow-wrap:anywhere}

  .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
    background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
  .twk-toggle[data-on="1"]{background:#34c759}
  .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
    background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
  .twk-toggle[data-on="1"] i{transform:translateX(14px)}

  .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
    border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
  .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
    user-select:none;padding-right:8px}
  .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
    font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
    outline:none;color:inherit;-moz-appearance:textfield}
  .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
    -webkit-appearance:none;margin:0}
  .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}

  .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
    background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
  .twk-btn:hover{background:rgba(0,0,0,.88)}
  .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
  .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}

  .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
    border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
    background:transparent;flex-shrink:0}
  .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
  .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
  .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}

  .twk-chips{display:flex;gap:6px}
  .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
    padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
    box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
    transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
  .twk-chip:hover{transform:translateY(-1px);
    box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
  .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
    0 2px 6px rgba(0,0,0,.15)}
  .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
    display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
  .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
  .twk-chip>span>i:first-child{box-shadow:none}
  .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
    filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;

// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
  const [values, setValues] = React.useState(defaults);
  // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
  // useState-style call doesn't write a "[object Object]" key into the persisted
  // JSON block.
  const setTweak = React.useCallback((keyOrEdits, val) => {
    const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
      ? keyOrEdits : { [keyOrEdits]: val };
    setValues((prev) => ({ ...prev, ...edits }));
    window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
    // Same-window signal so in-page listeners (deck-stage rail thumbnails)
    // can react — the parent message only reaches the host, not peers.
    window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
  }, []);
  return [values, setTweak];
}

// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
  const [open, setOpen] = React.useState(false);
  const dragRef = React.useRef(null);
  const offsetRef = React.useRef({ x: 16, y: 16 });
  const PAD = 16;

  const clampToViewport = React.useCallback(() => {
    const panel = dragRef.current;
    if (!panel) return;
    const w = panel.offsetWidth, h = panel.offsetHeight;
    const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
    const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
    offsetRef.current = {
      x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
      y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
    };
    panel.style.right = offsetRef.current.x + 'px';
    panel.style.bottom = offsetRef.current.y + 'px';
  }, []);

  React.useEffect(() => {
    if (!open) return;
    clampToViewport();
    if (typeof ResizeObserver === 'undefined') {
      window.addEventListener('resize', clampToViewport);
      return () => window.removeEventListener('resize', clampToViewport);
    }
    const ro = new ResizeObserver(clampToViewport);
    ro.observe(document.documentElement);
    return () => ro.disconnect();
  }, [open, clampToViewport]);

  React.useEffect(() => {
    const onMsg = (e) => {
      const t = e?.data?.type;
      if (t === '__activate_edit_mode') setOpen(true);
      else if (t === '__deactivate_edit_mode') setOpen(false);
    };
    window.addEventListener('message', onMsg);
    window.parent.postMessage({ type: '__edit_mode_available' }, '*');
    return () => window.removeEventListener('message', onMsg);
  }, []);

  const dismiss = () => {
    setOpen(false);
    window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
  };

  const onDragStart = (e) => {
    const panel = dragRef.current;
    if (!panel) return;
    const r = panel.getBoundingClientRect();
    const sx = e.clientX, sy = e.clientY;
    const startRight = window.innerWidth - r.right;
    const startBottom = window.innerHeight - r.bottom;
    const move = (ev) => {
      offsetRef.current = {
        x: startRight - (ev.clientX - sx),
        y: startBottom - (ev.clientY - sy),
      };
      clampToViewport();
    };
    const up = () => {
      window.removeEventListener('mousemove', move);
      window.removeEventListener('mouseup', up);
    };
    window.addEventListener('mousemove', move);
    window.addEventListener('mouseup', up);
  };

  if (!open) return null;
  return (
    <>
      <style>{__TWEAKS_STYLE}</style>
      <div ref={dragRef} className="twk-panel" data-omelette-chrome=""
           style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
        <div className="twk-hd" onMouseDown={onDragStart}>
          <b>{title}</b>
          <button className="twk-x" aria-label="Close tweaks"
                  onMouseDown={(e) => e.stopPropagation()}
                  onClick={dismiss}>✕</button>
        </div>
        <div className="twk-body">
          {children}
        </div>
      </div>
    </>
  );
}

// ── Layout helpers ──────────────────────────────────────────────────────────

function TweakSection({ label, children }) {
  return (
    <>
      <div className="twk-sect">{label}</div>
      {children}
    </>
  );
}

function TweakRow({ label, value, children, inline = false }) {
  return (
    <div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
      <div className="twk-lbl">
        <span>{label}</span>
        {value != null && <span className="twk-val">{value}</span>}
      </div>
      {children}
    </div>
  );
}

// ── Controls ────────────────────────────────────────────────────────────────

function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
  return (
    <TweakRow label={label} value={`${value}${unit}`}>
      <input type="range" className="twk-slider" min={min} max={max} step={step}
             value={value} onChange={(e) => onChange(Number(e.target.value))} />
    </TweakRow>
  );
}

function TweakToggle({ label, value, onChange }) {
  return (
    <div className="twk-row twk-row-h">
      <div className="twk-lbl"><span>{label}</span></div>
      <button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
              role="switch" aria-checked={!!value}
              onClick={() => onChange(!value)}><i /></button>
    </div>
  );
}

function TweakRadio({ label, value, options, onChange }) {
  const trackRef = React.useRef(null);
  const [dragging, setDragging] = React.useState(false);
  // The active value is read by pointer-move handlers attached for the lifetime
  // of a drag — ref it so a stale closure doesn't fire onChange for every move.
  const valueRef = React.useRef(value);
  valueRef.current = value;

  // Segments wrap mid-word once per-segment width runs out. The track is
  // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
  // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
  // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
  // back to a dropdown rather than wrap.
  const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
  const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
  const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
  if (!fitsAsSegments) {
    // <select> emits strings — map back to the original option value so the
    // fallback stays type-preserving (numbers, booleans) like the segment path.
    const resolve = (s) => {
      const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
      return m === undefined ? s : typeof m === 'object' ? m.value : m;
    };
    return <TweakSelect label={label} value={value} options={options}
                        onChange={(s) => onChange(resolve(s))} />;
  }
  const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
  const idx = Math.max(0, opts.findIndex((o) => o.value === value));
  const n = opts.length;

  const segAt = (clientX) => {
    const r = trackRef.current.getBoundingClientRect();
    const inner = r.width - 4;
    const i = Math.floor(((clientX - r.left - 2) / inner) * n);
    return opts[Math.max(0, Math.min(n - 1, i))].value;
  };

  const onPointerDown = (e) => {
    setDragging(true);
    const v0 = segAt(e.clientX);
    if (v0 !== valueRef.current) onChange(v0);
    const move = (ev) => {
      if (!trackRef.current) return;
      const v = segAt(ev.clientX);
      if (v !== valueRef.current) onChange(v);
    };
    const up = () => {
      setDragging(false);
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  };

  return (
    <TweakRow label={label}>
      <div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
           className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
        <div className="twk-seg-thumb"
             style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
                      width: `calc((100% - 4px) / ${n})` }} />
        {opts.map((o) => (
          <button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
            {o.label}
          </button>
        ))}
      </div>
    </TweakRow>
  );
}

function TweakSelect({ label, value, options, onChange }) {
  return (
    <TweakRow label={label}>
      <select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
        {options.map((o) => {
          const v = typeof o === 'object' ? o.value : o;
          const l = typeof o === 'object' ? o.label : o;
          return <option key={v} value={v}>{l}</option>;
        })}
      </select>
    </TweakRow>
  );
}

function TweakText({ label, value, placeholder, onChange }) {
  return (
    <TweakRow label={label}>
      <input className="twk-field" type="text" value={value} placeholder={placeholder}
             onChange={(e) => onChange(e.target.value)} />
    </TweakRow>
  );
}

function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
  const clamp = (n) => {
    if (min != null && n < min) return min;
    if (max != null && n > max) return max;
    return n;
  };
  const startRef = React.useRef({ x: 0, val: 0 });
  const onScrubStart = (e) => {
    e.preventDefault();
    startRef.current = { x: e.clientX, val: value };
    const decimals = (String(step).split('.')[1] || '').length;
    const move = (ev) => {
      const dx = ev.clientX - startRef.current.x;
      const raw = startRef.current.val + dx * step;
      const snapped = Math.round(raw / step) * step;
      onChange(clamp(Number(snapped.toFixed(decimals))));
    };
    const up = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
  };
  return (
    <div className="twk-num">
      <span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
      <input type="number" value={value} min={min} max={max} step={step}
             onChange={(e) => onChange(clamp(Number(e.target.value)))} />
      {unit && <span className="twk-num-unit">{unit}</span>}
    </div>
  );
}

// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
  const h = String(hex).replace('#', '');
  const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
  const n = parseInt(x.slice(0, 6), 16);
  if (Number.isNaN(n)) return true;
  const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
  return r * 299 + g * 587 + b * 114 > 148000;
}

const __TwkCheck = ({ light }) => (
  <svg viewBox="0 0 14 14" aria-hidden="true">
    <path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
          strokeLinecap="round" strokeLinejoin="round"
          stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
  </svg>
);

// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
  if (!options || !options.length) {
    return (
      <div className="twk-row twk-row-h">
        <div className="twk-lbl"><span>{label}</span></div>
        <input type="color" className="twk-swatch" value={value}
               onChange={(e) => onChange(e.target.value)} />
      </div>
    );
  }
  // Native <input type=color> emits lowercase hex per the HTML spec, so
  // compare case-insensitively. String() guards JSON.stringify(undefined),
  // which returns the primitive undefined (no .toLowerCase).
  const key = (o) => String(JSON.stringify(o)).toLowerCase();
  const cur = key(value);
  return (
    <TweakRow label={label}>
      <div className="twk-chips" role="radiogroup">
        {options.map((o, i) => {
          const colors = Array.isArray(o) ? o : [o];
          const [hero, ...rest] = colors;
          const sup = rest.slice(0, 4);
          const on = key(o) === cur;
          return (
            <button key={i} type="button" className="twk-chip" role="radio"
                    aria-checked={on} data-on={on ? '1' : '0'}
                    aria-label={colors.join(', ')} title={colors.join(' · ')}
                    style={{ background: hero }}
                    onClick={() => onChange(o)}>
              {sup.length > 0 && (
                <span>
                  {sup.map((c, j) => <i key={j} style={{ background: c }} />)}
                </span>
              )}
              {on && <__TwkCheck light={__twkIsLight(hero)} />}
            </button>
          );
        })}
      </div>
    </TweakRow>
  );
}

function TweakButton({ label, onClick, secondary = false }) {
  return (
    <button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
            onClick={onClick}>{label}</button>
  );
}

Object.assign(window, {
  useTweaks, TweaksPanel, TweakSection, TweakRow,
  TweakSlider, TweakToggle, TweakRadio, TweakSelect,
  TweakText, TweakNumber, TweakColor, TweakButton,
});


/* ==================== ornaments.jsx ==================== */
// ornaments.jsx — decorative SVG library for medieval fantasy atmosphere
// All ornaments are stroke-only by default so they accept currentColor.
// Sized via the `size` prop (px); rotate via wrapper transform.
//
// Exports: Flourish, CornerFlourish, Divider, WaxSeal, Crest, Banner,
//          CompassRose, InkSplatter, FleurDeLis, RuneRing,
//          DropCap, TorchGlow, ManuscriptBorder, QuillIcon, IlluminatedBorder

// ---- Single corner flourish (curling vine, ~60deg arc) ----
function CornerFlourish({ size = 120, color = 'currentColor', flip = false, vertical = false }) {
  // SVG drawn for top-left corner. Use scale to flip for other corners.
  const sx = flip ? -1 : 1;
  const sy = vertical ? -1 : 1;
  return (
    <svg width={size} height={size} viewBox="0 0 100 100" style={{
      transform: `scale(${sx}, ${sy})`, transformOrigin: 'center',
    }}>
      <g fill="none" stroke={color} strokeWidth="1.4" strokeLinecap="round">
        {/* main S-curve vine */}
        <path d="M 2 2 Q 30 5 38 22 Q 44 36 30 46 Q 18 54 24 70 Q 30 84 50 86" />
        {/* secondary branch */}
        <path d="M 22 18 Q 16 24 18 32 Q 22 38 30 36" />
        {/* curling tip end */}
        <path d="M 50 86 Q 58 86 60 80 Q 60 74 54 74" />
        {/* leaf 1 */}
        <path d="M 36 24 Q 44 18 48 24 Q 44 30 36 28 Z" fill={color} opacity="0.7" />
        {/* leaf 2 */}
        <path d="M 26 62 Q 18 60 16 68 Q 22 74 28 68 Z" fill={color} opacity="0.65" />
        {/* dot accent */}
        <circle cx="60" cy="80" r="1.5" fill={color} />
        <circle cx="2" cy="2" r="2" fill={color} />
      </g>
    </svg>
  );
}

// ---- Decorative horizontal divider ----
function Divider({ width = 280, color = 'currentColor', motif = 'fleur' }) {
  // diamond or fleur center motif with curling ends
  return (
    <svg width={width} height={24} viewBox="0 0 280 24">
      <g fill="none" stroke={color} strokeWidth="1.2" strokeLinecap="round">
        <line x1="20" y1="12" x2="115" y2="12" />
        <line x1="165" y1="12" x2="260" y2="12" />
        {/* end curls */}
        <path d="M 20 12 Q 14 12 14 6 M 20 12 Q 14 12 14 18" />
        <path d="M 260 12 Q 266 12 266 6 M 260 12 Q 266 12 266 18" />
        {/* small dots near ends */}
        <circle cx="6" cy="12" r="2" fill={color} />
        <circle cx="274" cy="12" r="2" fill={color} />
        {/* center motif */}
        {motif === 'diamond' ? (
          <>
            <path d="M 130 12 L 140 4 L 150 12 L 140 20 Z" fill={color} opacity="0.18" />
            <path d="M 130 12 L 140 4 L 150 12 L 140 20 Z" />
            <circle cx="140" cy="12" r="2" fill={color} />
          </>
        ) : (
          <>
            {/* fleur de lis simplified */}
            <path d="M 140 4 L 140 20" />
            <path d="M 140 9 Q 132 8 128 13 Q 132 18 140 14" />
            <path d="M 140 9 Q 148 8 152 13 Q 148 18 140 14" />
            <path d="M 132 18 L 148 18" />
          </>
        )}
      </g>
    </svg>
  );
}

// ---- Wax seal ----
function WaxSeal({ size = 88, color = 'var(--crimson)', imprint = '⚔', tilt = -8 }) {
  return (
    <div style={{
      width: size, height: size, position: 'relative',
      transform: `rotate(${tilt}deg)`,
      filter: 'drop-shadow(0 6px 8px rgba(0,0,0,0.4))',
    }}>
      <svg width={size} height={size} viewBox="0 0 100 100">
        {/* uneven wax blob */}
        <defs>
          <radialGradient id="wax-grad" cx="40%" cy="35%">
            <stop offset="0%" stopColor="#c44a4a" />
            <stop offset="55%" stopColor={color === 'var(--crimson)' ? '#8a1a1a' : color} />
            <stop offset="100%" stopColor="#3a0808" />
          </radialGradient>
        </defs>
        <path
          d="M 50 6
             Q 70 8 80 22
             Q 96 30 94 50
             Q 96 70 80 80
             Q 70 96 50 94
             Q 30 96 20 82
             Q 4 70 6 50
             Q 4 28 22 22
             Q 30 4 50 6 Z"
          fill="url(#wax-grad)"
        />
        {/* highlight */}
        <ellipse cx="38" cy="32" rx="14" ry="8" fill="rgba(255,255,255,0.18)" />
        {/* inner ring (pressed) */}
        <circle cx="50" cy="50" r="32" fill="none" stroke="rgba(0,0,0,0.3)" strokeWidth="1.2" />
        <circle cx="50" cy="50" r="32" fill="none" stroke="rgba(255,180,160,0.25)" strokeWidth="1" strokeDasharray="2,3" />
      </svg>
      {/* imprint character/glyph */}
      <div style={{
        position: 'absolute', inset: 0,
        display: 'grid', placeItems: 'center',
        fontFamily: 'var(--font-display)', fontSize: size * 0.4, fontWeight: 700,
        color: 'rgba(20,5,5,0.7)',
        textShadow: '1px 1px 0 rgba(255,160,140,0.25)',
      }}>
        {imprint}
      </div>
    </div>
  );
}

// ---- Heraldic crest — shield with dual sword + wheat ----
function Crest({ size = 160, color = 'currentColor', dark = false }) {
  // a shield: top half = wheat stalk (life), bottom half = crossed sword (action)
  const fillBg = dark ? '#1a1014' : 'var(--parch-hi)';
  const fillAccent = dark ? 'var(--gold)' : 'var(--ink-iron)';
  const fg = dark ? 'var(--gold)' : 'var(--ink-iron)';
  return (
    <svg width={size} height={size * 1.15} viewBox="0 0 100 115">
      <g>
        {/* shield outline */}
        <path
          d="M 8 8 L 92 8 L 92 60 Q 92 92 50 110 Q 8 92 8 60 Z"
          fill={fillBg} stroke={fg} strokeWidth="1.6"
        />
        {/* horizontal divider */}
        <line x1="8" y1="58" x2="92" y2="58" stroke={fg} strokeWidth="1.2" />
        {/* TOP: wheat stalk (life-sim half) */}
        <g stroke={fg} fill="none" strokeWidth="1.2" strokeLinecap="round">
          <line x1="50" y1="14" x2="50" y2="52" />
          {/* grains */}
          <ellipse cx="50" cy="20" rx="3" ry="4.5" fill={fillAccent} opacity="0.7" />
          <ellipse cx="44" cy="26" rx="3" ry="4.5" fill={fillAccent} opacity="0.7" transform="rotate(-25 44 26)" />
          <ellipse cx="56" cy="26" rx="3" ry="4.5" fill={fillAccent} opacity="0.7" transform="rotate(25 56 26)" />
          <ellipse cx="44" cy="34" rx="3" ry="4.5" fill={fillAccent} opacity="0.55" transform="rotate(-25 44 34)" />
          <ellipse cx="56" cy="34" rx="3" ry="4.5" fill={fillAccent} opacity="0.55" transform="rotate(25 56 34)" />
          <ellipse cx="44" cy="42" rx="3" ry="4.5" fill={fillAccent} opacity="0.4" transform="rotate(-25 44 42)" />
          <ellipse cx="56" cy="42" rx="3" ry="4.5" fill={fillAccent} opacity="0.4" transform="rotate(25 56 42)" />
        </g>
        {/* BOTTOM: crossed sword + scroll/quill */}
        <g stroke={fg} fill={fillAccent} strokeWidth="1" strokeLinecap="round">
          {/* sword diagonal */}
          <line x1="22" y1="72" x2="78" y2="98" strokeWidth="2.2" />
          <line x1="16" y1="78" x2="26" y2="68" strokeWidth="2" />
          <circle cx="20" cy="73" r="2.5" fill={fg} />
          {/* quill diagonal opposite */}
          <line x1="78" y1="72" x2="22" y2="98" strokeWidth="1.4" strokeDasharray="3,2" />
        </g>
        {/* small banner at top */}
        <g stroke={fg} fill={fillBg} strokeWidth="1">
          <path d="M 20 4 L 80 4 L 75 12 L 80 18 L 20 18 L 25 12 Z" />
        </g>
      </g>
    </svg>
  );
}

// ---- Ribbon banner ----
function Banner({ width = 320, color = 'currentColor', children, dark = false }) {
  const bg = dark ? 'var(--night-bg-2)' : 'var(--parch-hi)';
  return (
    <div style={{ position: 'relative', display: 'inline-block' }}>
      <svg width={width} height={56} viewBox={`0 0 ${width} 56`} style={{ display: 'block' }}>
        {/* end tails */}
        <path d={`M 0 28 L 18 8 L 30 28 L 18 48 Z`} fill={bg} stroke={color} strokeWidth="1.2" />
        <path d={`M ${width} 28 L ${width-18} 8 L ${width-30} 28 L ${width-18} 48 Z`} fill={bg} stroke={color} strokeWidth="1.2" />
        {/* main rectangle */}
        <rect x="18" y="6" width={width-36} height="44" fill={bg} stroke={color} strokeWidth="1.2" />
        {/* inner double-rule */}
        <rect x="22" y="10" width={width-44} height="36" fill="none" stroke={color} strokeWidth="0.6" opacity="0.5" />
      </svg>
      <div style={{
        position: 'absolute', inset: 0,
        display: 'grid', placeItems: 'center',
        padding: '0 30px',
      }}>
        {children}
      </div>
    </div>
  );
}

// ---- Compass rose ----
function CompassRose({ size = 160, color = 'currentColor' }) {
  return (
    <svg width={size} height={size} viewBox="0 0 100 100">
      <g fill="none" stroke={color} strokeWidth="0.8" strokeLinecap="round">
        <circle cx="50" cy="50" r="46" />
        <circle cx="50" cy="50" r="40" strokeDasharray="1,2" />
        <circle cx="50" cy="50" r="22" />
        {/* main points N/S/E/W */}
        <g>
          <path d="M 50 4 L 53 46 L 50 50 L 47 46 Z" fill={color} opacity="0.85" />
          <path d="M 50 96 L 47 54 L 50 50 L 53 54 Z" fill={color} opacity="0.4" />
          <path d="M 4 50 L 46 53 L 50 50 L 46 47 Z" fill={color} opacity="0.4" />
          <path d="M 96 50 L 54 47 L 50 50 L 54 53 Z" fill={color} opacity="0.4" />
        </g>
        {/* diagonals */}
        <g opacity="0.55">
          <path d="M 18 18 L 47 47 L 50 50 L 47 47 Z" />
          <path d="M 82 18 L 53 47 L 50 50 L 53 47 Z" />
          <path d="M 18 82 L 47 53 L 50 50 L 47 53 Z" />
          <path d="M 82 82 L 53 53 L 50 50 L 53 53 Z" />
        </g>
      </g>
      <g fontFamily="var(--font-display)" fontWeight="700" fontSize="7" fill={color} textAnchor="middle">
        <text x="50" y="14">N</text>
        <text x="50" y="92">S</text>
        <text x="10" y="52">W</text>
        <text x="90" y="52">E</text>
      </g>
    </svg>
  );
}

// ---- Ornate manuscript border (full rectangle) ----
function ManuscriptBorder({ color = 'currentColor', thickness = 1.4, inset = 24 }) {
  // Drawn at 100×100 and stretched via preserveAspectRatio
  return (
    <svg
      width="100%" height="100%"
      viewBox="0 0 1000 1000"
      preserveAspectRatio="none"
      style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
    >
      <g fill="none" stroke={color} strokeWidth={thickness} vectorEffect="non-scaling-stroke">
        {/* outer rectangle */}
        <rect x={inset} y={inset} width={1000 - inset*2} height={1000 - inset*2} />
        {/* inner rule */}
        <rect x={inset + 12} y={inset + 12} width={1000 - (inset + 12)*2} height={1000 - (inset + 12)*2} opacity="0.55" strokeDasharray="2,2" />
      </g>
      {/* corner motifs — diamonds */}
      <g fill={color}>
        <path d={`M ${inset} ${inset} l 0 -8 l 8 8 l -8 8 z`} opacity="0.6" />
      </g>
    </svg>
  );
}

// ---- Drop cap (illuminated initial) ----
function DropCap({ letter = 'B', size = 88, color = 'var(--crimson)', accent = 'var(--gold)' }) {
  return (
    <div style={{
      width: size, height: size,
      position: 'relative',
      float: 'left',
      marginRight: 14, marginTop: 4, marginBottom: 4,
      shapeOutside: `inset(0 round 2px)`,
    }}>
      {/* background ornamental box */}
      <svg width={size} height={size} viewBox="0 0 100 100" style={{ position: 'absolute', inset: 0 }}>
        <rect x="2" y="2" width="96" height="96" fill={color} />
        <rect x="2" y="2" width="96" height="96" fill="none" stroke={accent} strokeWidth="1.5" />
        <rect x="6" y="6" width="88" height="88" fill="none" stroke={accent} strokeWidth="0.6" opacity="0.6" />
        {/* corner flourishes */}
        <path d="M 4 4 Q 18 4 18 18" fill="none" stroke={accent} strokeWidth="1" opacity="0.7" />
        <path d="M 96 4 Q 82 4 82 18" fill="none" stroke={accent} strokeWidth="1" opacity="0.7" />
        <path d="M 4 96 Q 18 96 18 82" fill="none" stroke={accent} strokeWidth="1" opacity="0.7" />
        <path d="M 96 96 Q 82 96 82 82" fill="none" stroke={accent} strokeWidth="1" opacity="0.7" />
      </svg>
      <div style={{
        position: 'absolute', inset: 0,
        display: 'grid', placeItems: 'center',
        fontFamily: 'var(--font-decor)', fontWeight: 900,
        fontSize: size * 0.7, lineHeight: 1, color: accent,
        textShadow: '1px 1px 0 rgba(0,0,0,0.4)',
      }}>{letter}</div>
    </div>
  );
}

// ---- Torch glow (flickering radial gradient) ----
function TorchGlow({ size = 200, intensity = 0.7 }) {
  return (
    <div style={{
      width: size, height: size, position: 'absolute',
      background: `radial-gradient(circle,
        rgba(255, 180, 110, ${intensity * 0.7}) 0%,
        rgba(217, 119, 87, ${intensity * 0.35}) 25%,
        rgba(140, 60, 30, ${intensity * 0.15}) 55%,
        transparent 80%)`,
      mixBlendMode: 'screen',
      filter: 'blur(8px)',
      pointerEvents: 'none',
    }} />
  );
}

// ---- Quill icon ----
function QuillIcon({ size = 24, color = 'currentColor' }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24">
      <g fill="none" stroke={color} strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
        <path d="M 4 20 L 18 6 Q 22 4 20 8 L 8 20 Z" fill={color} fillOpacity="0.15" />
        <path d="M 10 14 L 16 8" />
        <path d="M 4 20 L 10 14" strokeWidth="2" />
      </g>
    </svg>
  );
}

// ---- Rune ring (small circular runic pattern) ----
function RuneRing({ size = 80, color = 'currentColor', n = 8 }) {
  // Decorative — draws small tick marks around a circle with a few "rune" glyphs
  const cx = 50, cy = 50, r = 38;
  const ticks = Array.from({ length: n * 4 }, (_, i) => {
    const a = (i / (n*4)) * Math.PI * 2 - Math.PI/2;
    const x1 = cx + Math.cos(a) * (r - 2);
    const y1 = cy + Math.sin(a) * (r - 2);
    const x2 = cx + Math.cos(a) * (r + 2);
    const y2 = cy + Math.sin(a) * (r + 2);
    return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke={color} strokeWidth="0.6" />;
  });
  const glyphs = ['ᚠ','ᚱ','ᚦ','ᛁ','ᛏ','ᛒ','ᛗ','ᛟ'].slice(0, n);
  const glyphEls = glyphs.map((g, i) => {
    const a = (i / n) * Math.PI * 2 - Math.PI/2;
    const x = cx + Math.cos(a) * (r - 9);
    const y = cy + Math.sin(a) * (r - 9);
    return (
      <text key={`g${i}`} x={x} y={y + 2} fontSize="7" fontFamily="serif"
            fill={color} textAnchor="middle" opacity="0.8">{g}</text>
    );
  });
  return (
    <svg width={size} height={size} viewBox="0 0 100 100">
      <g>
        {ticks}
        <circle cx={cx} cy={cy} r={r + 4} fill="none" stroke={color} strokeWidth="0.5" opacity="0.4" />
        <circle cx={cx} cy={cy} r={r - 4} fill="none" stroke={color} strokeWidth="0.5" opacity="0.4" />
        {glyphEls}
      </g>
    </svg>
  );
}

// ---- Illuminated border (4 corner flourishes + horizontal rule top/bottom) ----
function IlluminatedBorder({ color = 'currentColor', inset = 28, size = 96 }) {
  return (
    <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', color }}>
      <div style={{ position: 'absolute', top: inset, left: inset }}>
        <CornerFlourish size={size} color={color} />
      </div>
      <div style={{ position: 'absolute', top: inset, right: inset }}>
        <CornerFlourish size={size} color={color} flip />
      </div>
      <div style={{ position: 'absolute', bottom: inset, left: inset }}>
        <CornerFlourish size={size} color={color} vertical />
      </div>
      <div style={{ position: 'absolute', bottom: inset, right: inset }}>
        <CornerFlourish size={size} color={color} flip vertical />
      </div>
    </div>
  );
}

Object.assign(window, {
  CornerFlourish, Divider, WaxSeal, Crest, Banner, CompassRose,
  ManuscriptBorder, DropCap, TorchGlow, QuillIcon, RuneRing,
  IlluminatedBorder,
});


/* ==================== shared.jsx ==================== */
// shared.jsx — i18n + reusable components (medieval-fantasy rewrite)

const I18N = {
  ko: {
    tagline_short: '낮에는 쟁기, 밤에는 검',
    tagline_long: '양피지 같은 평화로운 마을에서, 잉크처럼 어두운 던전의 심장부까지.',
    farm_label: '농사 · 낚시 · 마을',
    action_label: '전투 · 퍼즐 · 모험',
    wishlist: '소식 받기',
    notify_me: '사전예약',
    coming_soon: '곧 출간',
    in_dev: '집필 중',
    by_studio: 'Bedrock inc.',
    mode_2d: '낮 · 일상',
    mode_3d: '밤 · 모험',
    chapter_one: '제1장 · 데모',
    drag_to_morph: '해를 끌어 밤을 부르라',
    five_regions: '다섯 대륙',
    three_classes: '세 직군',
    four_races: '네 종족',
    title_main: '트윈미어',
    title_sub: "TWINMERE",
    intro_2d: '쟁기를 갈고, 비구름을 부르고, 마을 광장에서 친구와 차를 나누는 일상. 일상에 깃든 마법의 시간.',
    intro_3d: '전사·마법사·궁수로 협동하여 다섯 대륙을 누비는 모험. 검 끝에서 빛나는 협동의 마법.',
    region_central: '중부 · 마을',
    region_north: '북부 · 설원',
    region_south: '남부 · 정글',
    region_west: '서부 · 사막',
    region_east: '동부 · 무공',
    email_placeholder: '서신을 받을 주소',
    by_day: '낮에는',
    by_night: '밤에는',
    a_farmer: '농부였으되',
    an_adventurer: '모험가가 되리',
    a_chronicle: '한 영혼이 두 세계를 살다',
    prologue: '서문',
    chapter_demo: '데모 — 제1장',
    seal_imprint: '◈',
    flip_world: '세계를 뒤집어라',
    journal_volume: '제1권 · 봄 1437년',
    plate: '도판',
    pg: 'pg.',
  },
  en: {
    tagline_short: 'By day, the plough. By night, the blade.',
    tagline_long: 'From a parchment-quiet village to the ink-dark heart of a dungeon.',
    farm_label: 'Farm · Fish · Town',
    action_label: 'Combat · Puzzles · Adventure',
    wishlist: 'Be told',
    notify_me: 'Pre-register',
    coming_soon: 'Forthcoming',
    in_dev: 'Being written',
    by_studio: 'Bedrock inc.',
    mode_2d: 'Day · Life',
    mode_3d: 'Night · Adventure',
    chapter_one: 'Chapter I · Demo',
    drag_to_morph: 'Drag the sun across the dial',
    five_regions: 'Five regions',
    three_classes: 'Three roles',
    four_races: 'Four races',
    title_main: "TWINMERE",
    title_sub: '트윈미어',
    intro_2d: 'Tend the field, summon a small rain, share tea in the square. Magic, woven into the dull and the daily.',
    intro_3d: 'Wander as Warrior, Mage, or Archer through five untamed regions. Magic, sharp at the blade-edge.',
    region_central: 'Central · Town',
    region_north: 'North · Frost',
    region_south: 'South · Jungle',
    region_west: 'West · Desert',
    region_east: 'East · Wuxia',
    email_placeholder: 'Address for dispatch',
    by_day: 'By day,',
    by_night: 'By night,',
    a_farmer: 'a farmer.',
    an_adventurer: 'a wanderer.',
    a_chronicle: 'One soul. Two worlds.',
    prologue: 'Prologue',
    chapter_demo: 'Demo — Chapter I',
    seal_imprint: '◈',
    flip_world: 'Turn the world',
    journal_volume: 'Vol. I · Spring of 1437',
    plate: 'Plate',
    pg: 'pg.',
  },
};

const useI18n = (lang) => (key) => I18N[lang]?.[key] ?? I18N.en[key] ?? key;

// ============================================================
// Language toggle
// ============================================================
function LangToggle({ lang, onChange, className = '' }) {
  return (
    <div className={`lang-toggle ${className}`}>
      <button className={lang === 'ko' ? 'is-active' : ''} onClick={() => onChange('ko')}>KO</button>
      <span className="div">·</span>
      <button className={lang === 'en' ? 'is-active' : ''} onClick={() => onChange('en')}>EN</button>
    </div>
  );
}

// ============================================================
// Day / Night mode toggle
// ============================================================
function ModeToggle({ mode, onChange, onDark, lang }) {
  const t = useI18n(lang);
  return (
    <div className={`mode-pill ${onDark ? 'on-dark' : ''}`}>
      <button className={mode === '2d' ? 'is-active' : ''} onClick={() => onChange('2d')}>
        ☼ {t('mode_2d')}
      </button>
      <button className={mode === '3d' ? 'is-active' : ''} onClick={() => onChange('3d')}>
        ☾ {t('mode_3d')}
      </button>
    </div>
  );
}

// ============================================================
// Wishlist signup (scroll-signing metaphor)
// ============================================================
function WishlistCTA({ lang, dark, compact }) {
  const t = useI18n(lang);
  const [email, setEmail] = React.useState('');
  const [submitting, setSubmitting] = React.useState(false);

  const submit = async (e) => {
    e.preventDefault();
    if (submitting) return;
    const value = email.trim();
    // 비었거나 형식이 틀리면 조용히 무시하지 않고 안내 팝업을 띄운다.
    if (!value) {
      trackGA('twinmere_signup_invalid', { reason: 'empty', lang });
      showSignupPopup('empty', lang);
      return;
    }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      trackGA('twinmere_signup_invalid', { reason: 'format', lang });
      showSignupPopup('invalid', lang);
      return;
    }
    setSubmitting(true);
    const res = await submitWaitlist(value); // 'created' | 'duplicate' | 'error'
    trackGA(res === 'duplicate' ? 'twinmere_signup_dup' : 'twinmere_signup', { lang });
    showSignupPopup(res === 'duplicate' ? 'duplicate' : 'created', lang); // 중앙 팝업
    setEmail('');          // 폼 초기화 → 같은 화면에서 다음 사람도 즉시 신청 가능
    setSubmitting(false);
  };

  return (
    <form
      onSubmit={submit}
      style={{
        display: 'inline-flex', alignItems: 'center', gap: 0,
        background: dark ? 'rgba(232,222,170,0.06)' : 'rgba(244,231,194,0.6)',
        border: `1.5px solid ${dark ? 'var(--gold-deep)' : 'var(--ink-soft)'}`,
        padding: 4, paddingLeft: 18,
      }}
    >
      <input
        type="email" value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder={t('email_placeholder')}
        style={{
          appearance: 'none', border: 'none', outline: 'none', background: 'transparent',
          font: 'inherit', fontFamily: 'var(--font-body)', fontSize: 16, fontStyle: 'italic',
          color: 'inherit', minWidth: compact ? 140 : 220, padding: '10px 8px',
        }}
      />
      <button type="submit" disabled={submitting} className={`cta cta-primary ${dark ? 'on-dark' : ''}`}
              style={{ padding: compact ? '10px 16px' : '12px 20px' }}>
        {t('notify_me')}
      </button>
    </form>
  );
}

// ============================================================
// Mini phone mockup
// ============================================================
function MiniPhone({ width = 220, children, style = {} }) {
  const aspect = 19.5 / 9;
  const screenH = (width - 14) * aspect;
  const r = Math.min(32, Math.round(width * 0.26));
  return (
    <div className="mini-phone" style={{ width, borderRadius: r, ...style }}>
      <div className="mini-phone-screen" style={{ height: screenH, borderRadius: r - 6 }}>
        <div className="mini-phone-notch" style={{ width: Math.round(width * 0.42), height: Math.max(10, Math.round(width * 0.1)) }} />
        {children}
      </div>
    </div>
  );
}

// ============================================================
// Meta badge
// ============================================================
function MetaBadge({ children, dark }) {
  return (
    <span style={{
      fontFamily: 'var(--font-body-sc)', fontSize: 12, letterSpacing: '0.2em',
      padding: '5px 12px',
      border: `1px solid ${dark ? 'rgba(232,222,170,0.4)' : 'rgba(42,24,16,0.45)'}`,
      display: 'inline-flex', gap: 8, alignItems: 'center',
    }}>{children}</span>
  );
}

// ============================================================
// CSS-only pixel-art day vignette
// ============================================================
function PixelScene({ size = 200 }) {
  const px = size / 16;
  const C = {
    grass:'#7ab85a', grass2:'#5c9a44', path: '#c8a66b', soil: '#7a4e2e',
    crop: '#e8c64a', tree: '#2e6b3a', wood: '#6b4220', roof: '#a73e3e',
    wall: '#e8d2a0', water:'#4a7ec9', flower:'#e88abb',
  };
  const M = [
    'GGGGGGGGGGGGGGGG','GGGGGGTGGGGGGGGG','GGGGGTTTGGGGGGGG','GGGGGGTGGGGGGGGG',
    'GGGGGGWGGGGGGGGG','GGGGGGWGGGGGGGGG','GGGGGGWGGGGGCCCG','GGGGGGWGGGGGCCCG',
    'GGGRRWGGGGGGCCCG','GGGRrWGGGGGGSSSG','GGGwwWGGGGGGSSSG','GGGwwWGGGGGGwwwG',
    'GGGGGWWWWWWWWWWG','GGGGGGGGGGGGGGGG','GGGfGGGGGfGGGGGG','GGGGGGGGGGGGGGGG',
  ];
  const colorFor = (ch) => ({
    G: C.grass, T: C.tree, W: C.path, w: C.water,
    R: C.roof, r: C.wood, C: C.crop, S: C.soil, f: C.flower,
  })[ch] || C.grass2;
  return (
    <div className="pixel-art" style={{
      width: size, height: size,
      display: 'grid',
      gridTemplateColumns: `repeat(16, ${px}px)`,
      gridTemplateRows: `repeat(16, ${px}px)`,
      background: C.grass2,
      boxShadow: 'inset 0 0 0 2px rgba(0,0,0,0.15)',
    }}>
      {M.flatMap((row, y) => row.split('').map((ch, x) => (
        <div key={`${x}-${y}`} style={{ background: colorFor(ch) }} />
      )))}
    </div>
  );
}

// ============================================================
// Dark 3D vignette — silhouette adventurer with torch
// ============================================================
function DarkScene({ size = 200 }) {
  return (
    <div style={{
      width: size, height: size, position: 'relative',
      background:
        'radial-gradient(ellipse 60% 40% at 60% 60%, #4a2818 0%, #1a0e08 60%, #050306 100%)',
      overflow: 'hidden',
    }}>
      {/* torch glow */}
      <div style={{
        position: 'absolute', top: '30%', right: '20%',
        width: size * 0.35, height: size * 0.35, borderRadius: '50%',
        background: 'radial-gradient(circle, #ffb872 0%, #d97757 40%, transparent 80%)',
        mixBlendMode: 'screen', filter: 'blur(4px)',
      }} />
      {/* silhouette */}
      <svg viewBox="0 0 100 100" width={size} height={size} style={{ position: 'absolute', inset: 0 }}>
        <rect x="0" y="84" width="100" height="16" fill="#000" opacity="0.85" />
        {/* sword */}
        <rect x="58" y="38" width="2.5" height="50" fill="#000" />
        <rect x="55" y="48" width="9" height="2" fill="#000" />
        {/* body */}
        <path
          d="M 45 88 L 44 60 Q 44 52 50 52 Q 56 52 56 60 L 55 88 Z
             M 50 50 Q 46 50 46 46 Q 46 41 50 41 Q 54 41 54 46 Q 54 50 50 50 Z
             M 44 65 L 39 78 L 41 80 L 45 70 Z"
          fill="#000"
        />
        {/* cape */}
        <path d="M 42 60 Q 32 74 36 88 L 44 88 L 44 65 Z" fill="#000" />
      </svg>
    </div>
  );
}

Object.assign(window, {
  I18N, useI18n,
  LangToggle, ModeToggle, WishlistCTA, MiniPhone, MetaBadge,
  PixelScene, DarkScene,
});


/* ==================== world-scenes.jsx (twinmere) ==================== */
// world-scenes.jsx — full-bleed fantasy world panoramas (1280×800), pure SVG.
// DayPanorama: golden pastoral valley with village, windmill, fields.
// NightPanorama: moonlit range, castle crag, pine forests, adventurer party.
// WorldScene: crossfades the two with a dusk wash. All ids namespaced by uid.

// deterministic rng
function mulberry(seed) {
  let t = seed >>> 0;
  return () => {
    t += 0x6D2B79F5;
    let r = Math.imul(t ^ (t >>> 15), 1 | t);
    r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}

// pine forest row as a single path (triangle-stack trees)
function pineRowPath({ w = 1320, baseY, minH, maxH, seed, step = 40 }) {
  const rnd = mulberry(seed);
  let d = '';
  let x = -30;
  while (x < w + 30) {
    const h = minH + rnd() * (maxH - minH);
    const w2 = h * 0.36;
    // 3-tier pine
    d += `M ${x - w2} ${baseY} L ${x} ${baseY - h * 0.52} L ${x + w2} ${baseY} Z `;
    d += `M ${x - w2 * 0.78} ${baseY - h * 0.3} L ${x} ${baseY - h * 0.78} L ${x + w2 * 0.78} ${baseY - h * 0.3} Z `;
    d += `M ${x - w2 * 0.55} ${baseY - h * 0.58} L ${x} ${baseY - h} L ${x + w2 * 0.55} ${baseY - h * 0.58} Z `;
    x += step * (0.6 + rnd() * 0.8);
  }
  // solid ground under the row
  d += `M -30 ${baseY - 1} L ${w + 30} ${baseY - 1} L ${w + 30} ${baseY + 60} L -30 ${baseY + 60} Z`;
  return d;
}

// smooth rolling hill through points, closed to bottom
function hillPath(pts, bottom = 820) {
  let d = `M ${pts[0][0]} ${pts[0][1]} `;
  for (let i = 1; i < pts.length; i++) {
    const [px, py] = pts[i - 1];
    const [x, y] = pts[i];
    const mx = (px + x) / 2;
    d += `Q ${px + (x - px) * 0.3} ${py} ${mx} ${(py + y) / 2} T ${x} ${y} `;
  }
  d += `L ${pts[pts.length - 1][0]} ${bottom} L ${pts[0][0]} ${bottom} Z`;
  return d;
}

// ============================================================
// Star field
// ============================================================
function Stars({ count = 120, w = 1280, h = 430, seed = 7 }) {
  const stars = React.useMemo(() => {
    const rnd = mulberry(seed);
    return Array.from({ length: count }, () => ({
      x: rnd() * w, y: rnd() * h * (0.3 + rnd() * 0.7),
      r: 0.4 + rnd() * 1.2, o: 0.25 + rnd() * 0.7,
      big: rnd() > 0.92,
    }));
  }, [count, w, h, seed]);
  return (
    <g>
      {stars.map((s, i) => s.big ? (
        <path key={i}
          d={`M ${s.x} ${s.y - 4} L ${s.x + 1} ${s.y - 1} L ${s.x + 4} ${s.y} L ${s.x + 1} ${s.y + 1} L ${s.x} ${s.y + 4} L ${s.x - 1} ${s.y + 1} L ${s.x - 4} ${s.y} L ${s.x - 1} ${s.y - 1} Z`}
          fill="#e8ecff" opacity={s.o * 0.9} />
      ) : (
        <circle key={i} cx={s.x} cy={s.y} r={s.r} fill="#dde4ff" opacity={s.o} />
      ))}
    </g>
  );
}

// ============================================================
// Little house (day/night aware)
// ============================================================
function House({ x, y, w = 66, h = 42, roof = '#a44f36', wall = '#efe0bc', night = false, flip = false, chimney = true }) {
  const rw = w * 1.16;
  return (
    <g transform={`translate(${x} ${y}) ${flip ? `scale(-1,1)` : ''}`}>
      {/* wall */}
      <rect x={-w / 2} y={-h} width={w} height={h} fill={night ? '#241a20' : wall} stroke={night ? '#0e0a10' : 'rgba(90,60,30,0.5)'} strokeWidth="1" />
      {/* roof */}
      <path d={`M ${-rw / 2} ${-h} L 0 ${-h - w * 0.52} L ${rw / 2} ${-h} Z`} fill={night ? '#161016' : roof} stroke={night ? '#0a0710' : 'rgba(60,30,16,0.6)'} strokeWidth="1" />
      {/* door */}
      <rect x={-w * 0.12} y={-h * 0.52} width={w * 0.24} height={h * 0.52} fill={night ? '#0e0a10' : '#6a4a26'} />
      {/* window */}
      <rect x={-w * 0.38} y={-h * 0.72} width={w * 0.2} height={h * 0.26} fill={night ? '#ffc46a' : '#8a6a3a'} opacity={night ? 1 : 0.75} />
      {night && <rect x={-w * 0.38} y={-h * 0.72} width={w * 0.2} height={h * 0.26} fill="#ffc46a" filter="blur(3px)" opacity="0.7" />}
      {/* chimney */}
      {chimney && <rect x={w * 0.18} y={-h - w * 0.38} width={w * 0.12} height={w * 0.24} fill={night ? '#161016' : '#8a5a40'} />}
      {/* smoke */}
      {chimney && (
        <g opacity={night ? 0.35 : 0.5}>
          <circle className="smoke-puff" style={{ '--sd': '5s' }} cx={w * 0.24} cy={-h - w * 0.44} r="4" fill={night ? '#8a90b8' : '#f6efe0'} />
          <circle className="smoke-puff" style={{ '--sd': '6.5s', animationDelay: '1.6s' }} cx={w * 0.24} cy={-h - w * 0.44} r="3" fill={night ? '#8a90b8' : '#f6efe0'} />
        </g>
      )}
    </g>
  );
}

// ============================================================
// DAY PANORAMA — golden pastoral valley
// ============================================================
function DayPanorama({ uid = 'day', par = { x: 0, y: 0 }, showSun = true }) {
  const P = (k) => `translate(${par.x * k} ${par.y * k * 0.5})`;
  return (
    <svg width="100%" height="100%" viewBox="0 0 1280 800" preserveAspectRatio="xMidYMid slice" style={{ display: 'block' }}>
      <defs>
        <linearGradient id={`${uid}-sky`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#fdf3cd" />
          <stop offset="42%" stopColor="#ffe2a2" />
          <stop offset="74%" stopColor="#ffc274" />
          <stop offset="100%" stopColor="#f2a266" />
        </linearGradient>
        <radialGradient id={`${uid}-sunhalo`}>
          <stop offset="0%" stopColor="#fffbe8" stopOpacity="0.95" />
          <stop offset="30%" stopColor="#ffe9b0" stopOpacity="0.55" />
          <stop offset="100%" stopColor="#ffdf9a" stopOpacity="0" />
        </radialGradient>
        <linearGradient id={`${uid}-field1`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#b8c86a" />
          <stop offset="100%" stopColor="#8fa84c" />
        </linearGradient>
        <linearGradient id={`${uid}-field2`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#d2b662" />
          <stop offset="100%" stopColor="#b4954a" />
        </linearGradient>
      </defs>

      {/* SKY */}
      <rect width="1280" height="800" fill={`url(#${uid}-sky)`} />

      {/* sun + rays */}
      {showSun && (
      <g transform={P(0.06)}>
        <circle cx="300" cy="200" r="210" fill={`url(#${uid}-sunhalo)`} />
        <circle cx="300" cy="200" r="52" fill="#fff6d8" />
        <circle cx="300" cy="200" r="44" fill="#fffdf2" />
        <g opacity="0.12" fill="#fff2c0">
          <path d="M 300 200 L 900 -80 L 1050 -80 Z" />
          <path d="M 300 200 L 1200 160 L 1200 260 Z" />
          <path d="M 300 200 L 800 800 L 950 800 Z" />
        </g>
      </g>
      )}

      {/* clouds */}
      <g transform={P(0.1)} fill="#fff6e2">
        <g className="cloud-drift" opacity="0.92">
          <ellipse cx="760" cy="130" rx="95" ry="26" />
          <ellipse cx="820" cy="112" rx="60" ry="20" />
          <ellipse cx="700" cy="116" rx="52" ry="18" />
        </g>
        <g className="cloud-drift-slow" opacity="0.8">
          <ellipse cx="1080" cy="220" rx="110" ry="24" />
          <ellipse cx="1150" cy="204" rx="60" ry="17" />
        </g>
      </g>

      {/* birds */}
      <g stroke="#7a5a34" strokeWidth="2" fill="none" opacity="0.7" transform={P(0.12)}>
        <path d="M 830 250 q 7 -8 14 0 q 7 -8 14 0" />
        <path d="M 880 232 q 6 -7 12 0 q 6 -7 12 0" />
        <path d="M 790 220 q 5 -6 10 0 q 5 -6 10 0" />
      </g>

      {/* FAR HILLS — hazy */}
      <g transform={P(0.15)}>
        <path d={hillPath([[-30, 470], [200, 430], [480, 462], [760, 420], [1040, 458], [1310, 432]])} fill="#dcb87e" opacity="0.75" />
      </g>

      {/* MID HILLS + windmill + far village */}
      <g transform={P(0.32)}>
        <path d={hillPath([[-30, 520], [240, 470], [560, 510], [900, 464], [1310, 508]])} fill="#c0a058" />
        {/* hedgerows */}
        <g fill="#8a7a3c" opacity="0.6">
          <ellipse cx="420" cy="502" rx="60" ry="9" />
          <ellipse cx="700" cy="488" rx="80" ry="10" />
          <ellipse cx="1050" cy="500" rx="70" ry="9" />
        </g>
        {/* windmill on the left hill */}
        <g transform="translate(228 468)">
          <path d="M -16 0 L -10 -66 L 10 -66 L 16 0 Z" fill="#7a5230" stroke="#4a3018" strokeWidth="1.4" />
          <circle cx="0" cy="-66" r="5" fill="#3a2414" />
          <g className="mill-blades" stroke="#3a2414" strokeWidth="4" strokeLinecap="round">
            <line x1="0" y1="-66" x2="0" y2="-118" />
            <line x1="0" y1="-66" x2="50" y2="-58" />
            <line x1="0" y1="-66" x2="-50" y2="-58" />
            <line x1="0" y1="-66" x2="0" y2="-16" opacity="0.9" />
          </g>
          <path d="M -30 4 L 30 4 L 22 12 L -22 12 Z" fill="#8a6a3a" opacity="0.5" />
        </g>
        {/* church spire distant right */}
        <g transform="translate(980 470)">
          <rect x="-8" y="-42" width="16" height="42" fill="#9a7a4a" />
          <path d="M -11 -42 L 0 -70 L 11 -42 Z" fill="#6a4a2a" />
          <circle cx="0" cy="-50" r="3" fill="#4a3018" />
        </g>
      </g>

      {/* VILLAGE CLUSTER on its own rise */}
      <g transform={P(0.5)}>
        <path d={hillPath([[-30, 620], [300, 560], [680, 596], [1000, 548], [1310, 590]])} fill="#a08648" />
        <House x={560} y={584} w={58} roof="#a44f36" />
        <House x={640} y={572} w={70} roof="#b85c40" flip />
        <House x={730} y={584} w={54} roof="#8f4630" />
        <House x={848} y={562} w={64} roof="#a44f36" />
        <House x={935} y={574} w={50} roof="#b85c40" flip chimney={false} />
        {/* round trees between houses */}
        <g>
          {[[500, 590, 20], [790, 578, 16], [900, 586, 18], [1010, 566, 15]].map(([x, y, r], i) => (
            <g key={i}>
              <rect x={x - 2} y={y - r * 0.4} width="4" height={r * 0.7} fill="#5a3c20" />
              <circle cx={x} cy={y - r * 0.8} r={r} fill="#4d7434" />
              <circle cx={x - r * 0.4} cy={y - r} r={r * 0.6} fill="#5d8a3e" />
            </g>
          ))}
        </g>
        {/* stone wall winding */}
        <path d="M 440 606 Q 560 596 700 604 Q 860 612 1000 596" fill="none" stroke="#8a7a5a" strokeWidth="5" strokeDasharray="8,4" opacity="0.7" />
      </g>

      {/* FOREGROUND FIELDS */}
      <g transform={P(0.8)}>
        <path d={hillPath([[-30, 700], [340, 656], [720, 690], [1080, 648], [1310, 680]])} fill={`url(#${uid}-field1)`} />
        {/* crop strips */}
        <g stroke="#7c9440" strokeWidth="3" opacity="0.55" fill="none">
          {Array.from({ length: 9 }, (_, i) => (
            <path key={i} d={`M ${-20 + i * 40} 810 Q ${180 + i * 44} ${700 - i * 3} ${420 + i * 52} 810`} />
          ))}
        </g>
        {/* golden wheat plot right */}
        <path d={hillPath([[700, 740], [900, 700], [1120, 726], [1310, 700]], 820)} fill={`url(#${uid}-field2)`} />
        <g stroke="#a8842c" strokeWidth="2.5" opacity="0.7">
          {Array.from({ length: 14 }, (_, i) => (
            <line key={i} x1={760 + i * 38} y1={810} x2={766 + i * 38} y2={716 + (i % 3) * 8} />
          ))}
        </g>

        {/* tiny farmer with personal rain-cloud (the magic!) */}
        <g transform="translate(430 690)">
          {/* plot */}
          <g stroke="#6a5230" strokeWidth="3" opacity="0.8">
            <line x1="-44" y1="18" x2="44" y2="18" />
            <line x1="-44" y1="10" x2="44" y2="10" />
          </g>
          {/* sprouts */}
          {[-32, -12, 8, 28].map((sx, i) => (
            <path key={i} d={`M ${sx} 10 q -3 -8 0 -12 q 3 4 0 12`} fill="#3e7028" />
          ))}
          {/* farmer */}
          <g transform="translate(-58 6)">
            <circle cx="0" cy="-24" r="5.5" fill="#3a2414" />
            <path d="M -9 -26 L 9 -26 L 5 -30 L -5 -30 Z" fill="#c8a04c" />
            <path d="M -5 -18 L 5 -18 L 4 2 L -4 2 Z" fill="#3a2414" />
            <line x1="4" y1="-12" x2="14" y2="-6" stroke="#3a2414" strokeWidth="2.5" />
          </g>
          {/* rain cloud hovering over the plot */}
          <g transform="translate(0 -46)">
            <ellipse cx="0" cy="0" rx="26" ry="10" fill="#e6eef6" />
            <ellipse cx="14" cy="-5" rx="14" ry="8" fill="#f2f7fc" />
            <ellipse cx="-14" cy="-4" rx="12" ry="7" fill="#dbe7f2" />
            <g stroke="#7a9ac4" strokeWidth="1.6" opacity="0.85">
              {[-16, -6, 4, 14].map((rx, i) => (
                <line key={i} x1={rx} y1="8" x2={rx - 3} y2="20" />
              ))}
            </g>
            {/* sparkle — it's magic */}
            <path d="M 30 -12 l 1.4 3.4 3.4 1.4 -3.4 1.4 -1.4 3.4 -1.4 -3.4 -3.4 -1.4 3.4 -1.4 Z" fill="#c8a04c" />
          </g>
        </g>
      </g>

      {/* BIG OAK — right frame */}
      <g transform={P(1)}>
        <g transform="translate(1150 730)">
          <path d="M -12 0 Q -8 -60 -20 -95 M -12 0 Q -14 -50 6 -90 M -12 0 L -10 -70" stroke="#4a3018" strokeWidth="16" strokeLinecap="round" fill="none" />
          <circle cx="-30" cy="-130" r="52" fill="#3e6030" />
          <circle cx="30" cy="-118" r="46" fill="#476c36" />
          <circle cx="-2" cy="-165" r="44" fill="#527a3c" />
          <circle cx="-56" cy="-96" r="34" fill="#365428" />
          <circle cx="48" cy="-86" r="30" fill="#3e6030" />
        </g>
        {/* grass tufts bottom */}
        <g stroke="#6a8a3c" strokeWidth="2.5" fill="none" opacity="0.9">
          {[60, 200, 420, 660, 900, 1060].map((gx, i) => (
            <g key={i}>
              <path d={`M ${gx} 800 q -4 -16 -10 -20`} />
              <path d={`M ${gx + 6} 800 q 0 -18 -2 -24`} />
              <path d={`M ${gx + 12} 800 q 4 -14 10 -18`} />
            </g>
          ))}
        </g>
      </g>

      {/* warm vignette */}
      <rect width="1280" height="800" fill="url(#grad-vign-day)" opacity="0" />
      <radialGradient id="grad-vign-day" cx="50%" cy="42%" r="75%">
        <stop offset="60%" stopColor="transparent" />
        <stop offset="100%" stopColor="rgba(140,70,20,0.3)" />
      </radialGradient>
      <rect width="1280" height="800" fill={`url(#${uid}-vign)`} />
      <defs>
        <radialGradient id={`${uid}-vign`} cx="50%" cy="42%" r="78%">
          <stop offset="58%" stopColor="rgba(0,0,0,0)" />
          <stop offset="100%" stopColor="rgba(120,55,15,0.32)" />
        </radialGradient>
      </defs>
    </svg>
  );
}

// ============================================================
// NIGHT PANORAMA — moonlit range, castle crag, pines
// ============================================================
function NightPanorama({ uid = 'night', par = { x: 0, y: 0 }, showParty = true, showMoon = true }) {
  const P = (k) => `translate(${par.x * k} ${par.y * k * 0.5})`;
  return (
    <svg width="100%" height="100%" viewBox="0 0 1280 800" preserveAspectRatio="xMidYMid slice" style={{ display: 'block' }}>
      <defs>
        <linearGradient id={`${uid}-sky`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#04050e" />
          <stop offset="40%" stopColor="#0a1030" />
          <stop offset="72%" stopColor="#1c2850" />
          <stop offset="100%" stopColor="#33406e" />
        </linearGradient>
        <radialGradient id={`${uid}-moonhalo`}>
          <stop offset="0%" stopColor="#f2f0e0" stopOpacity="0.55" />
          <stop offset="28%" stopColor="#cdd4f2" stopOpacity="0.18" />
          <stop offset="100%" stopColor="#aab4e8" stopOpacity="0" />
        </radialGradient>
        <linearGradient id={`${uid}-river`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#b8c8f4" stopOpacity="0.4" />
          <stop offset="100%" stopColor="#5a6aa8" stopOpacity="0.08" />
        </linearGradient>
        <filter id={`${uid}-glow`} x="-80%" y="-80%" width="260%" height="260%">
          <feGaussianBlur stdDeviation="3" result="b" />
          <feMerge>
            <feMergeNode in="b" /><feMergeNode in="SourceGraphic" />
          </feMerge>
        </filter>
      </defs>

      {/* SKY */}
      <rect width="1280" height="800" fill={`url(#${uid}-sky)`} />
      <g transform={P(0.04)}>
        <Stars seed={11} />
        {/* shooting star */}
        <line x1="180" y1="90" x2="290" y2="130" stroke="#dde4ff" strokeWidth="1.4" opacity="0.5" strokeLinecap="round" />
      </g>

      {/* MOON */}
      {showMoon && (
      <g transform={P(0.07)}>
        <circle cx="960" cy="180" r="230" fill={`url(#${uid}-moonhalo)`} />
        <circle cx="960" cy="180" r="78" fill="#eee9d4" />
        <g fill="#d4cdb2" opacity="0.8">
          <circle cx="935" cy="158" r="14" />
          <circle cx="985" cy="205" r="10" />
          <circle cx="972" cy="150" r="6" />
          <circle cx="940" cy="210" r="7" />
        </g>
        <circle cx="960" cy="180" r="78" fill="none" stroke="#fffdf0" strokeWidth="1" opacity="0.5" />
      </g>
      )}

      {/* FAR RANGE */}
      <g transform={P(0.14)}>
        <path d="M -30 520 L 90 420 L 200 480 L 330 380 L 470 470 L 600 400 L 740 480 L 880 410 L 1010 470 L 1150 400 L 1310 480 L 1310 620 L -30 620 Z"
              fill="#232c58" opacity="0.92" />
        {/* snow caps */}
        <g fill="#4a5890" opacity="0.85">
          <path d="M 330 380 L 352 402 L 338 400 L 326 412 L 312 398 Z" />
          <path d="M 880 410 L 898 428 L 886 426 L 874 436 L 862 424 Z" />
          <path d="M 1150 400 L 1170 420 L 1156 418 L 1146 430 L 1132 418 Z" />
        </g>
      </g>

      {/* MID RANGE + CASTLE CRAG */}
      <g transform={P(0.28)}>
        <path d="M -30 580 L 120 470 L 260 545 L 420 440 L 560 540 L 700 470 L 830 555 L 1310 555 L 1310 700 L -30 700 Z"
              fill="#161c40" />
        {/* castle crag — steep rock on the right */}
        <path d="M 860 620 L 920 430 L 965 460 L 1000 390 L 1060 440 L 1120 400 L 1180 620 Z" fill="#111631" />
        {/* castle silhouette */}
        <g fill="#0c1026">
          {/* main keep */}
          <rect x="975" y="330" width="52" height="80" />
          <path d="M 973 330 L 977 318 L 983 330 L 989 318 L 995 330 L 1001 318 L 1007 330 L 1013 318 L 1019 330 L 1025 318 L 1029 330 Z" />
          {/* left tower */}
          <rect x="944" y="352" width="24" height="70" />
          <path d="M 941 352 L 956 326 L 971 352 Z" />
          {/* right tower */}
          <rect x="1034" y="346" width="26" height="76" />
          <path d="M 1031 346 L 1047 318 L 1063 346 Z" />
          {/* wall */}
          <rect x="920" y="400" width="160" height="26" />
        </g>
        {/* flags */}
        <g stroke="#0c1026" strokeWidth="1.6">
          <line x1="956" y1="326" x2="956" y2="306" />
          <line x1="1047" y1="318" x2="1047" y2="298" />
        </g>
        <path d="M 956 306 L 974 312 L 956 318 Z" fill="#5a1a1a" />
        <path d="M 1047 298 L 1065 304 L 1047 310 Z" fill="#5a1a1a" />
        {/* lit windows */}
        <g fill="#ffc46a" filter={`url(#${uid}-glow)`}>
          <rect x="988" y="352" width="5" height="9" />
          <rect x="1002" y="366" width="5" height="9" />
          <rect x="951" y="372" width="4" height="8" />
          <rect x="1042" y="360" width="4" height="8" />
          <rect x="1046" y="380" width="4" height="8" />
          <rect x="992" y="382" width="5" height="9" />
        </g>
        {/* winding cliff path with lantern dots */}
        <path d="M 890 610 Q 930 560 970 540 Q 1010 520 1005 470" fill="none" stroke="#252e5c" strokeWidth="5" opacity="0.8" />
        <g fill="#ffb860" filter={`url(#${uid}-glow)`}>
          <circle cx="905" cy="588" r="2.5" />
          <circle cx="955" cy="548" r="2.5" />
          <circle cx="1002" cy="500" r="2.5" />
        </g>
      </g>

      {/* moon river */}
      <g transform={P(0.34)}>
        <path d="M 620 560 Q 560 620 420 660 Q 250 706 60 716 L 60 800 L 700 800 Q 660 690 640 620 Z"
              fill={`url(#${uid}-river)`} />
        {/* moonlight streak */}
        <path d="M 640 575 Q 600 640 500 680" stroke="#cfdaf8" strokeWidth="6" fill="none" opacity="0.3" strokeLinecap="round" />
        <path d="M 630 590 Q 596 646 520 676" stroke="#eef2ff" strokeWidth="2" fill="none" opacity="0.4" strokeLinecap="round" />
      </g>

      {/* mist band */}
      <g className="mist-slide" transform={P(0.4)}>
        <ellipse cx="480" cy="600" rx="440" ry="34" fill="#7a8ac8" opacity="0.16" />
        <ellipse cx="1000" cy="622" rx="360" ry="26" fill="#7a8ac8" opacity="0.13" />
      </g>

      {/* PINE ROWS */}
      <g transform={P(0.5)}>
        <path d={pineRowPath({ baseY: 648, minH: 46, maxH: 84, seed: 21 })} fill="#0d1128" />
      </g>
      <g className="mist-slide" style={{ animationDelay: '-12s' }} transform={P(0.58)}>
        <ellipse cx="700" cy="660" rx="520" ry="30" fill="#8a98d4" opacity="0.12" />
      </g>
      <g transform={P(0.72)}>
        <path d={pineRowPath({ baseY: 726, minH: 68, maxH: 120, seed: 33 })} fill="#070a1c" />
      </g>

      {/* FOREGROUND CLIFF + PARTY */}
      <g transform={P(1)}>
        <path d="M -30 800 L -30 700 Q 80 686 170 690 Q 300 692 360 712 Q 420 736 430 800 Z" fill="#04060f" />
        {/* grass tufts on cliff */}
        <g stroke="#0e1226" strokeWidth="2" fill="none">
          <path d="M 80 692 q -3 -12 -8 -15" /><path d="M 86 692 q 0 -13 -2 -17" />
          <path d="M 200 692 q 3 -12 8 -15" /><path d="M 194 692 q 0 -13 2 -17" />
          <path d="M 300 702 q -3 -12 -8 -15" />
        </g>
        {showParty && (
          <g>
            {/* torch glow */}
            <circle cx="212" cy="640" r="60" fill="#ff9a50" opacity="0.14" filter={`url(#${uid}-glow)`} />
            {/* warrior — planted greatsword */}
            <g transform="translate(160 690)" fill="#01030a">
              <circle cx="0" cy="-38" r="6" />
              <path d="M -7 -32 L 7 -32 L 5 0 L -5 0 Z" />
              <path d="M -7 -30 Q -14 -18 -11 -2 L -6 -2 Z" />
              <line x1="12" y1="-44" x2="12" y2="0" stroke="#01030a" strokeWidth="3" />
              <line x1="7" y1="-36" x2="17" y2="-36" stroke="#01030a" strokeWidth="2.5" />
            </g>
            {/* mage — staff with faint orb */}
            <g transform="translate(210 686)">
              <g fill="#01030a">
                <circle cx="0" cy="-40" r="5.5" />
                <path d="M -8 -34 L 8 -34 L 4 0 L -4 0 Z" />
                <path d="M -3 -46 L 3 -46 L 6 -38 L -6 -38 Z" />
              </g>
              <line x1="13" y1="-52" x2="13" y2="0" stroke="#01030a" strokeWidth="2.5" />
              <circle cx="13" cy="-54" r="4" fill="#9ad4ff" filter={`url(#${uid}-glow)`} opacity="0.9" />
            </g>
            {/* archer — bow silhouette, holds torch */}
            <g transform="translate(258 692)">
              <g fill="#01030a">
                <circle cx="0" cy="-36" r="5.5" />
                <path d="M -6 -30 L 6 -30 L 4 0 L -4 0 Z" />
                <path d="M -12 -34 Q -20 -20 -12 -6" stroke="#01030a" strokeWidth="2" fill="none" />
              </g>
              <line x1="9" y1="-32" x2="16" y2="-46" stroke="#01030a" strokeWidth="2.5" />
              <circle cx="17" cy="-48" r="3.5" fill="#ffb860" filter={`url(#${uid}-glow)`} />
              <path d="M 14 -52 Q 17 -56 20 -52 Q 19 -48 17 -49 Z" fill="#ffd89a" filter={`url(#${uid}-glow)`} />
            </g>
            {/* banner spear with pennant */}
            <g transform="translate(120 690)">
              <line x1="0" y1="0" x2="0" y2="-64" stroke="#01030a" strokeWidth="2.5" />
              <path d="M 0 -64 L 22 -58 L 0 -50 Z" fill="#3a1010" />
            </g>
          </g>
        )}
      </g>

      {/* gnarled framing branch, top-left */}
      <g transform={P(0.9)}>
        <g fill="none" stroke="#030510" strokeLinecap="round">
          <path d="M -20 40 Q 120 60 210 40 Q 300 22 360 60" strokeWidth="14" />
          <path d="M 200 44 Q 240 70 250 100" strokeWidth="7" />
          <path d="M 300 38 Q 340 30 366 8" strokeWidth="6" />
        </g>
        <g fill="#050814">
          <ellipse cx="255" cy="106" rx="26" ry="12" transform="rotate(30 255 106)" />
          <ellipse cx="370" cy="10" rx="30" ry="13" transform="rotate(-20 370 10)" />
          <ellipse cx="150" cy="52" rx="34" ry="12" transform="rotate(8 150 52)" />
        </g>
      </g>

      {/* vignette */}
      <rect width="1280" height="800" fill={`url(#${uid}-vign)`} />
      <defs>
        <radialGradient id={`${uid}-vign`} cx="50%" cy="45%" r="78%">
          <stop offset="52%" stopColor="rgba(0,0,0,0)" />
          <stop offset="100%" stopColor="rgba(0,2,12,0.6)" />
        </radialGradient>
      </defs>
    </svg>
  );
}

// ============================================================
// WORLD SCENE — crossfade day↔night with dusk wash
// ============================================================
function WorldScene({ pos = 0, uid, par = { x: 0, y: 0 }, showParty = true, showSun = true, showMoon = true }) {
  const dusk = Math.sin(Math.min(1, Math.max(0, pos)) * Math.PI);
  return (
    <div style={{ position: 'absolute', inset: 0, overflow: 'hidden', background: '#04050e' }}>
      <div style={{ position: 'absolute', inset: 0, opacity: 1 - pos }}>
        <DayPanorama uid={`${uid}-d`} par={par} showSun={showSun} />
      </div>
      <div style={{ position: 'absolute', inset: 0, opacity: pos }}>
        <NightPanorama uid={`${uid}-n`} par={par} showParty={showParty} showMoon={showMoon} />
      </div>
      {/* dusk wash peaks mid-transition */}
      <div style={{
        position: 'absolute', inset: 0, pointerEvents: 'none',
        background: 'linear-gradient(180deg, rgba(90,58,122,0.55) 0%, rgba(170,80,90,0.4) 60%, rgba(220,110,70,0.3) 100%)',
        opacity: dusk * 0.55, mixBlendMode: 'multiply',
      }} />
      <div style={{
        position: 'absolute', inset: 0, pointerEvents: 'none',
        background: 'radial-gradient(ellipse 70% 40% at 50% 55%, rgba(255,140,80,0.28), transparent 70%)',
        opacity: dusk * 0.7, mixBlendMode: 'screen',
      }} />
    </div>
  );
}

// ============================================================
// Fireflies overlay (HTML particles)
// ============================================================
function Fireflies({ count = 22, opacity = 1, seed = 5 }) {
  const flies = React.useMemo(() => {
    const rnd = mulberry(seed);
    return Array.from({ length: count }, () => ({
      x: rnd() * 100, y: 40 + rnd() * 55,
      s: 2.5 + rnd() * 3,
      dd: 8 + rnd() * 10, fd: 2 + rnd() * 3,
      delay: rnd() * -12,
    }));
  }, [count, seed]);
  return (
    <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity }}>
      {flies.map((f, i) => (
        <span key={i} className="firefly" style={{
          left: `${f.x}%`, top: `${f.y}%`, width: f.s, height: f.s,
          boxShadow: `0 0 ${f.s * 2.5}px #ffdf8a`,
          '--ffd': `${f.dd}s`, '--fff': `${f.fd}s`,
          animationDelay: `${f.delay}s, ${f.delay * 0.6}s`,
        }} />
      ))}
    </div>
  );
}

// ============================================================
// Rising embers (for campfire)
// ============================================================
function RisingEmbers({ count = 14, x = 50, y = 78, spread = 6, seed = 9 }) {
  const sparks = React.useMemo(() => {
    const rnd = mulberry(seed);
    return Array.from({ length: count }, () => ({
      x: x + (rnd() - 0.5) * spread, y: y + (rnd() - 0.5) * 3,
      ex: (rnd() - 0.5) * 60,
      d: 2.2 + rnd() * 2.6, delay: rnd() * -5,
      s: 2.5 + rnd() * 2.5,
    }));
  }, [count, x, y, spread, seed]);
  return (
    <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
      {sparks.map((s, i) => (
        <span key={i} className="ember-spark" style={{
          left: `${s.x}%`, top: `${s.y}%`, width: s.s, height: s.s,
          '--ex': `${s.ex}px`, '--ed': `${s.d}s`,
          animationDelay: `${s.delay}s`,
        }} />
      ))}
    </div>
  );
}

// ============================================================
// GAME LOGO — metallic gold lockup
// ============================================================
function GameLogo({ lang = 'ko', scale = 1, sub = true, eyebrow = false, night = true, fluid = false, crest = false }) {
  // TWINMERE is the title; the optional eyebrow above it is left empty by default.
  // fluid adds vw+vh clamps so the hero stack shrinks on narrow AND short viewports.
  const mainSize = fluid ? `min(${120 * scale}px, ${7 * scale}vw, ${15 * scale}vh)` : 120 * scale;
  const ebSize = fluid ? `min(${17 * scale}px, ${1.8 * scale}vw)` : 17 * scale;
  const crestSize = fluid ? `min(${104 * scale}px, ${10.5 * scale}vh)` : 104 * scale;
  const metal = {
    background: 'linear-gradient(180deg, #fff4c8 0%, #f0d078 24%, #c8a04c 46%, #8a6a28 58%, #e8c868 74%, #6a4a18 100%)',
    WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent',
  };
  const subColor = night ? '#f0dfa8' : '#5a3c14';
  const eyebrowColor = night ? '#e8d8a0' : '#6a4a1c';
  const ruleGrad = night ? '#e8c868' : '#8a6a28';
  return (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', position: 'relative' }}>
      {crest && (
        <div style={{ display: 'flex', justifyContent: 'center', marginBottom: 12 * scale }}>
          <img src="assets/crest-logo.webp" alt="Twinmere crest"
            style={{
              height: crestSize, width: 'auto',
              filter: 'drop-shadow(0 8px 26px rgba(0,0,0,0.6)) drop-shadow(0 0 18px rgba(200,160,76,0.25))',
            }} />
        </div>
      )}
      {eyebrow && (
        <div style={{
          display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16,
          marginBottom: 6 * scale, whiteSpace: 'nowrap',
        }}>
          <span style={{ display: 'inline-block', width: 52 * scale, height: 1, background: `linear-gradient(to left, ${ruleGrad}, transparent)` }} />
          <span style={{
            fontFamily: 'var(--font-display)', fontWeight: 600, fontSize: ebSize,
            letterSpacing: '0.42em', textIndent: '0.42em',
            color: eyebrowColor, textShadow: night ? '0 1px 4px rgba(0,0,0,0.8)' : '0 1px 3px rgba(255,244,210,0.7)',
          }}>
            
          </span>
          <span style={{ display: 'inline-block', width: 52 * scale, height: 1, background: `linear-gradient(to right, ${ruleGrad}, transparent)` }} />
        </div>
      )}
      <div style={{ position: 'relative', display: 'inline-block', lineHeight: 0.92, whiteSpace: 'nowrap' }}>
        {/* depth layer */}
        <div className="wordmark" aria-hidden="true" style={{
          position: 'absolute', inset: 0,
          fontFamily: 'var(--font-decor)', fontWeight: 900, fontSize: mainSize,
          letterSpacing: '0.08em', textIndent: '0.08em', color: '#160c02', whiteSpace: 'nowrap',
          transform: 'translate(0px, 6px)', filter: 'blur(4px)', opacity: 0.85,
        }}>
          TWINMERE
        </div>
        {/* metal face */}
        <h1 className="wordmark" style={{
          position: 'relative', margin: 0,
          fontFamily: 'var(--font-decor)', fontWeight: 900, fontSize: mainSize,
          letterSpacing: '0.08em', textIndent: '0.08em', whiteSpace: 'nowrap', ...metal,
          filter: 'drop-shadow(0 0 26px rgba(200,160,76,0.42))',
        }}>
          TWINMERE
        </h1>
      </div>
      {sub && lang === 'ko' && (
        <div style={{
          marginTop: 10 * scale,
          fontFamily: 'var(--font-ko)', fontWeight: 700, fontSize: 19 * scale,
          letterSpacing: '0.6em', textIndent: '0.6em', whiteSpace: 'nowrap',
          color: subColor, textShadow: night ? '0 1px 6px rgba(0,0,0,0.75)' : '0 1px 3px rgba(255,244,210,0.6)',
        }}>
          트윈미어
        </div>
      )}
    </div>
  );
}

Object.assign(window, {
  DayPanorama, NightPanorama, WorldScene, Fireflies, RisingEmbers, GameLogo,
  Stars, House, pineRowPath, hillPath, mulberry,
});


/* ==================== landing-parts.jsx ==================== */
// landing.jsx — Twinmere demand-test landing page
// Single scrolling page built on the real key art.
// Sections: Hero → Two Worlds → The World (regions/races) → Roles → Poll → Signup

const L = {
  ko: {
    nav_cta: '사전예약',
    hero_sub: '아늑한 픽셀 마을과 시네마틱 액션 던전 — 한 영혼이 두 세계를 삽니다.',
    hero_cta_label: '데모가 나오면 가장 먼저 알려드릴게요',
    scroll_cue: '아래로',
    // two worlds
    tw_eyebrow: '하나의 세계, 두 개의 얼굴',
    tw_title: '낮에는 쟁기, 밤에는 검',
    tw_day_h: '아늑한 일상 — 2D 픽셀 마을',
    tw_day_p: '농사 · 낚시 · 요리 · 목축 · 채광 · 채집. 자신만의 공간을 가꾸고, 친구를 초대하고, 마을 광장에서 다른 플레이어들과 어울리세요. 비구름을 불러 밭에 물을 주는, 마법이 일상에 스며든 세계입니다.',
    tw_night_h: '협동 액션 — 3D 벨트스크롤 모험',
    tw_night_p: '검과 방패, 마법, 활 — 최대 3인의 협동 액션. 전투 구간과 협동 퍼즐 구간을 오가며 다섯 지역의 던전을 정복하세요. 시네마틱한 조명과 실루엣이 만드는 무게감 있는 액션.',
    tw_hint: '두 세계를 전환해 보세요',
    day_pill: '☼ 낮 · 마을',
    night_pill: '☾ 밤 · 원정',
    // world
    world_eyebrow: '다섯 지역, 네 종족',
    world_title: '마법이 일상에 스며든 대륙',
    world_races: '인간 · 오크 · 언데드 · 엘프',
    world_races_p: '마법은 전투만의 것이 아닙니다. 인간은 학문으로, 오크는 주술로, 언데드는 저주로, 엘프는 자연과의 교감으로 — 농사와 목축, 채광까지 저마다의 방식으로 마법을 살아갑니다.',
    // roles
    roles_eyebrow: '차별화',
    roles_title: '직업은 없다, 삶이 곧 역할이다',
    roles_p: '고정된 클래스 대신, 장비와 숙련도가 당신의 역할을 결정합니다. 매일 나무를 베면 힘이 자라 방패와 검이 손에 익고, 농작물을 돌보면 지능이 자라 마법이 깊어집니다. 어제의 농부가 오늘의 탱커가 되는 세계.',
    role_warrior: '전사', role_warrior_d: '검과 방패 — 전면에서 파티를 지킵니다',
    role_mage: '마법사', role_mage_d: '마법 — 전장을 뒤흔드는 원소의 힘',
    role_archer: '궁수', role_archer_d: '원거리 — 정밀한 한 발로 흐름을 바꿉니다',
    // poll
    poll_eyebrow: '수요 조사',
    poll_title: '당신은 어느 쪽인가요?',
    poll_sub: '한 번의 선택이 개발의 방향이 됩니다.',
    poll_cozy_h: '아늑한 생활',
    poll_cozy_d: '내 농장, 내 집, 마을의 계절 — 경쟁 없는 하루',
    poll_action_h: '협동 액션',
    poll_action_d: '친구 셋이서 던전으로 — 전투와 퍼즐의 원정',
    poll_both_h: '둘 다',
    poll_both_d: '쟁기도, 검도 — 둘 다 놓칠 수 없다',
    poll_thanks: '기록되었습니다. 아래에서 사전예약하시면 데모로 보답하겠습니다.',
    // signup
    su_eyebrow: '사전예약',
    su_title: '출시 전, 먼저 만나보세요',
    su_p: '데모 출시 소식과 테스트 초대를 이메일로 가장 먼저 보내드립니다.',
    su_done: '사전예약 완료 — 출시 소식을 가장 먼저 보내드릴게요.',
    su_mobile: '모바일 전용 — iOS · Android',
    footer_note: '개발 중인 게임의 사전 검증 페이지입니다. 모든 이미지는 인게임 목표 아트 컨셉입니다.',
  },
  en: {
    nav_cta: 'Pre-register',
    hero_sub: 'A cozy pixel village and a cinematic action dungeon — one soul, two worlds.',
    hero_cta_label: 'Be first to hear when the demo lands',
    scroll_cue: 'Scroll',
    tw_eyebrow: 'One world, two faces',
    tw_title: 'By day the plough, by night the blade',
    tw_day_h: 'Cozy life — the 2D pixel town',
    tw_day_p: 'Farm, fish, cook, herd, mine, forage. Keep a home of your own, invite friends in, and mingle in the town square. Summon a small rain-cloud over your field — magic seeps into the everyday here.',
    tw_night_h: 'Co-op action — the 3D beat \u2019em up',
    tw_night_p: 'Sword and board, spellwork, or the bow — co-op action for up to three. Alternate combat gauntlets with team puzzles across the dungeons of five regions, in cinematic light and heavy silhouette.',
    tw_hint: 'Flip between the two worlds',
    day_pill: '☼ Day · Town',
    night_pill: '☾ Night · Expedition',
    world_eyebrow: 'Five regions, four races',
    world_title: 'A continent steeped in everyday magic',
    world_races: 'Human · Orc · Undead · Elf',
    world_races_p: 'Magic is not just for battle. Humans study it, orcs ritualize it, the undead curse with it, elves commune through it — farming, herding, and mining each in their own way.',
    roles_eyebrow: 'What sets it apart',
    roles_title: 'No classes. Your life is your build.',
    roles_p: 'No fixed class at character creation — gear and practiced skill decide your role. Chop wood daily and your strength grows into sword-and-shield work; tend crops and your intellect deepens your magic. Yesterday\u2019s farmer is today\u2019s tank.',
    role_warrior: 'Warrior', role_warrior_d: 'Sword & shield — holds the line',
    role_mage: 'Mage', role_mage_d: 'Spellwork — elemental force that turns the field',
    role_archer: 'Archer', role_archer_d: 'Ranged — one precise shot changes everything',
    poll_eyebrow: 'Tell us',
    poll_title: 'Which side are you?',
    poll_sub: 'One tap steers the development.',
    poll_cozy_h: 'The cozy life',
    poll_cozy_d: 'My farm, my home, the town\u2019s seasons — no contest, no clock',
    poll_action_h: 'Co-op action',
    poll_action_d: 'Three friends, one dungeon — combat and puzzles',
    poll_both_h: 'Both',
    poll_both_d: 'The plough and the blade — both',
    poll_thanks: 'Noted. Pre-register below and we\u2019ll repay you with a demo.',
    su_eyebrow: 'Pre-register',
    su_title: 'Get in before launch',
    su_p: 'Demo news and test invitations, delivered to your inbox first.',
    su_done: 'You are registered — we will reach out first.',
    su_mobile: 'Mobile-first — iOS · Android',
    footer_note: 'A pre-production validation page. All imagery is target in-game art concept.',
  },
};

const useL = (lang) => (k) => L[lang]?.[k] ?? L.en[k] ?? k;

// ============================================================
// Scroll reveal
// ============================================================
const REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

function Reveal({ children, delay = 0, style = {} }) {
  const ref = React.useRef(null);
  const [vis, setVis] = React.useState(REDUCED);
  React.useEffect(() => {
    if (REDUCED) return;
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) { setVis(true); io.disconnect(); }
    }, { threshold: 0.12 });
    if (ref.current) io.observe(ref.current);
    return () => io.disconnect();
  }, []);
  return (
    <div ref={ref} style={{
      opacity: vis ? 1 : 0,
      transform: vis ? 'none' : 'translateY(26px)',
      transition: 'opacity 0.8s ease, transform 0.8s ease',
      transitionDelay: `${delay}ms`,
      ...style,
    }}>{children}</div>
  );
}

// ============================================================
// Section heading
// ============================================================
function SectionHead({ eyebrow, title, dark, sub }) {
  return (
    <div style={{ textAlign: 'center', marginBottom: 54 }}>
      {eyebrow && (
        <div style={{
          fontFamily: 'var(--font-ko)', fontWeight: 600, fontSize: 13, letterSpacing: '0.32em',
          color: dark ? 'var(--gold)' : 'var(--ink-rust)', opacity: 0.9, marginBottom: 14,
        }}>{eyebrow}</div>
      )}
      <h2 style={{
        margin: 0, fontFamily: 'var(--font-ko)', fontWeight: 900,
        fontSize: 'clamp(30px, 4.2vw, 50px)', lineHeight: 1.2, letterSpacing: '-0.01em',
        color: dark ? '#efe4c2' : 'var(--ink-iron)',
        textWrap: 'balance',
      }}>{title}</h2>
      {sub && (
        <p style={{
          margin: '16px 0 0', fontFamily: 'var(--font-ko)', fontStyle: 'italic',
          fontSize: 18, color: dark ? 'var(--night-soft)' : 'var(--ink-soft)',
        }}>{sub}</p>
      )}
      <div style={{ display: 'flex', justifyContent: 'center', marginTop: 22, color: dark ? 'var(--gold)' : 'var(--ink-rust)' }}>
        <Divider width={240} color="currentColor" motif="diamond" />
      </div>
    </div>
  );
}

// ============================================================
// Signup (persistent)
// ============================================================
function ScrollSignup({ lang, dark = true, big = false }) {
  const l = useL(lang);
  const [email, setEmail] = React.useState('');
  const [submitting, setSubmitting] = React.useState(false);
  const submit = async (e) => {
    e.preventDefault();
    if (submitting) return;
    const value = email.trim();
    // 비었거나 형식이 틀리면 조용히 무시하지 않고 안내 팝업을 띄운다.
    if (!value) {
      trackGA('twinmere_signup_invalid', { reason: 'empty', lang });
      showSignupPopup('empty', lang);
      return;
    }
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
      trackGA('twinmere_signup_invalid', { reason: 'format', lang });
      showSignupPopup('invalid', lang);
      return;
    }
    setSubmitting(true);
    const res = await submitWaitlist(value); // 'created' | 'duplicate' | 'error'
    trackGA(res === 'duplicate' ? 'twinmere_signup_dup' : 'twinmere_signup', { lang });
    showSignupPopup(res === 'duplicate' ? 'duplicate' : 'created', lang); // 중앙 팝업
    setEmail('');          // 폼 초기화 → 같은 화면에서 다음 사람도 즉시 신청 가능
    setSubmitting(false);
  };
  return (
    <form onSubmit={submit} noValidate className="r-signup" style={{
      display: 'inline-flex', alignItems: 'center', gap: 7,
      background: dark ? 'rgba(232,222,170,0.05)' : 'rgba(244,231,194,0.65)',
      border: `1.5px solid ${dark ? 'var(--gold-deep)' : 'var(--ink-soft)'}`,
      padding: 5,
    }}>
      {/* 입력칸임이 한눈에 보이도록 — 어두운 폼 위에 흰 필드 + 봉투 아이콘 */}
      <span className="tm-field" style={{
        display: 'inline-flex', alignItems: 'center', gap: 9,
        flex: '1 1 auto', minWidth: 0, padding: '0 12px',
        background: '#fffdf7',
        border: '1px solid rgba(42,24,16,0.25)',
        color: 'var(--ink-iron)',
      }}>
        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"
             style={{ flexShrink: 0, opacity: 0.6 }}>
          <rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" strokeWidth="1.8" />
          <path d="M3.6 6.6 L12 12.9 L20.4 6.6" stroke="currentColor" strokeWidth="1.8"
                strokeLinecap="round" strokeLinejoin="round" />
        </svg>
        <input
          className="tm-email-input"
          type="email" value={email} onChange={(e) => setEmail(e.target.value)}
          placeholder={lang === 'ko' ? '이메일 주소를 입력하세요' : 'Enter your email'}
          style={{
            appearance: 'none', border: 'none', outline: 'none', background: 'transparent',
            fontFamily: 'var(--font-ko)', fontSize: big ? 16 : 15,
            color: 'inherit', width: '100%', minWidth: big ? 220 : 180, padding: '12px 0',
          }}
        />
      </span>
      <button type="submit" disabled={submitting} className={`cta cta-primary ${dark ? 'on-dark' : ''}`}
        style={{ padding: big ? '15px 26px' : '12px 20px', flexShrink: 0 }}>
        {lang === 'ko' ? '사전예약' : 'Pre-register'}
      </button>
    </form>
  );
}

// ============================================================
// Two-world art viewer (crossfade)
// ============================================================
function ArtViewer({ lang, mode, setMode }) {
  const l = useL(lang);
  const night = mode === '3d';
  return (
    <div style={{ position: 'relative' }}>
      {/* ornate frame */}
      <div style={{
        position: 'relative', border: '2px solid var(--gold-deep)',
        boxShadow: '0 40px 90px -30px rgba(0,0,0,0.85), 0 0 0 1px rgba(0,0,0,0.6)',
        background: '#050308',
      }}>
        <div style={{ position: 'absolute', inset: 8, border: '1px solid rgba(200,160,76,0.35)', zIndex: 3, pointerEvents: 'none' }} />
        {/* corner diamonds */}
        {[{ top: -7, left: -7 }, { top: -7, right: -7 }, { bottom: -7, left: -7 }, { bottom: -7, right: -7 }].map((p, i) => (
          <div key={i} style={{
            position: 'absolute', width: 12, height: 12, background: 'var(--gold)',
            transform: 'rotate(45deg)', zIndex: 4, ...p,
          }} />
        ))}
        {/* stacked art */}
        <div style={{ position: 'relative', aspectRatio: '1844 / 853', overflow: 'hidden' }}>
          <img src="assets/art-2d-town.webp" alt="2D pixel town — cozy life"
            style={{
              position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover',
              opacity: night ? 0 : 1, transition: 'opacity 0.8s ease',
            }} />
          <img src="assets/art-3d-combat.webp" alt="3D co-op combat — the party vs an ogre"
            style={{
              position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover',
              opacity: night ? 1 : 0, transition: 'opacity 0.8s ease',
            }} />
          {/* inner vignette */}
          <div style={{
            position: 'absolute', inset: 0, pointerEvents: 'none',
            boxShadow: 'inset 0 0 120px rgba(0,0,0,0.55)',
          }} />
        </div>
        {/* toggle — floats on the art */}
        <div style={{ position: 'absolute', top: 20, right: 20, zIndex: 5 }}>
          <div className="mode-pill on-dark" style={{ background: 'rgba(5,3,8,0.65)', backdropFilter: 'blur(4px)' }}>
            <button className={!night ? 'is-active' : ''} onClick={() => setMode('2d')}>{l('day_pill')}</button>
            <button className={night ? 'is-active' : ''} onClick={() => setMode('3d')}>{l('night_pill')}</button>
          </div>
        </div>
      </div>
      {/* caption */}
      <div style={{
        marginTop: 18, textAlign: 'center',
        fontFamily: 'var(--font-ko)', fontStyle: 'italic', fontSize: 15,
        color: 'var(--night-soft)',
      }}>
        {night
          ? (lang === 'ko' ? '3D 파트 — 벨트스크롤 협동 전투 (목표 아트)' : '3D part — co-op beat \u2019em up (target art)')
          : (lang === 'ko' ? '2D 파트 — 픽셀 아트 마을 광장 (목표 아트)' : '2D part — pixel-art town square (target art)')}
      </div>
    </div>
  );
}

// ============================================================
// Small line icons
// ============================================================
function RoleIcon({ kind, size = 44, color = 'var(--gold)' }) {
  const s = { fill: 'none', stroke: color, strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round' };
  return (
    <svg width={size} height={size} viewBox="0 0 48 48">
      {kind === 'warrior' && (
        <g {...s}>
          <path d="M 24 6 L 24 30" strokeWidth="2.4" />
          <path d="M 16 14 L 32 14" strokeWidth="2.2" />
          <path d="M 21 30 L 27 30 L 26 36 L 22 36 Z" fill={color} stroke="none" />
          <path d="M 10 26 Q 10 40 18 43 M 38 26 Q 38 40 30 43" opacity="0.6" />
        </g>
      )}
      {kind === 'mage' && (
        <g {...s}>
          <path d="M 20 42 L 30 10" strokeWidth="2.2" />
          <circle cx="31.5" cy="7.5" r="4" fill={color} fillOpacity="0.25" />
          <circle cx="31.5" cy="7.5" r="1.6" fill={color} stroke="none" />
          <path d="M 12 20 q 4 -2 6 2 M 34 30 q 4 2 2 6" opacity="0.55" />
        </g>
      )}
      {kind === 'archer' && (
        <g {...s}>
          <path d="M 14 8 Q 34 24 14 40" strokeWidth="2.2" />
          <path d="M 14 8 L 14 40" opacity="0.7" />
          <path d="M 14 24 L 38 24 M 33 20 L 38 24 L 33 28" />
        </g>
      )}
    </svg>
  );
}

function RegionIcon({ kind, size = 26, color = 'currentColor' }) {
  const s = { fill: 'none', stroke: color, strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round' };
  return (
    <svg width={size} height={size} viewBox="0 0 28 28">
      {kind === 'central' && <g {...s}><path d="M 5 14 L 14 6 L 23 14" /><path d="M 8 13 L 8 22 L 20 22 L 20 13" /><path d="M 12 22 L 12 16 L 16 16 L 16 22" /></g>}
      {kind === 'north' && <g {...s}><path d="M 14 4 L 14 24 M 5 9 L 23 19 M 23 9 L 5 19" /><path d="M 14 4 L 11 7 M 14 4 L 17 7" opacity="0.7" /></g>}
      {kind === 'south' && <g {...s}><path d="M 22 6 Q 8 8 8 20 Q 20 20 22 6 Z" /><path d="M 8 20 Q 14 14 20 9" opacity="0.7" /></g>}
      {kind === 'west' && <g {...s}><circle cx="19" cy="9" r="3.5" opacity="0.8" /><path d="M 3 22 Q 9 16 15 20 Q 21 24 26 20" /><path d="M 5 15 Q 10 11 14 14" opacity="0.6" /></g>}
      {kind === 'east' && <g {...s}><path d="M 4 10 Q 14 4 24 10" /><path d="M 7 10 L 7 22 M 21 10 L 21 22" /><path d="M 5 16 L 23 16" opacity="0.7" /></g>}
    </svg>
  );
}

// ============================================================
// Poll — the demand question
// ============================================================
function Poll({ lang }) {
  const l = useL(lang);
  const KEY = 'br-demand-vote';
  const [vote, setVote] = React.useState(() => {
    try { return localStorage.getItem(KEY); } catch (e) { return null; }
  });
  const choose = (id) => {
    // 같은 선택 재클릭은 중복 기록하지 않음 (연타로 state가 stale할 수 있어 localStorage 기준으로 판정)
    let prev = vote;
    try { prev = localStorage.getItem(KEY) || vote; } catch (e) {}
    if (id === prev) return;
    try { localStorage.setItem(KEY, id); } catch (e) {} // UI 중복투표 방지용 (유지)
    setVote(id);
    // 선택 변경 시 이전 문서는 지우지 않고 changedFrom을 남긴다.
    // 집계 시 visitorId별 최신 ts 문서만 세면 최종 선택이 된다.
    logDemand('twinmere_poll', prev ? { choice: id, changedFrom: prev } : { choice: id });
    trackGA('twinmere_poll_vote', { choice: id, lang });
  };
  const opts = [
    { id: 'cozy', h: l('poll_cozy_h'), d: l('poll_cozy_d'), icon: '🌾', img: 'assets/art-2d-town.webp' },
    { id: 'action', h: l('poll_action_h'), d: l('poll_action_d'), icon: '⚔', img: 'assets/art-3d-combat.webp' },
    { id: 'both', h: l('poll_both_h'), d: l('poll_both_d'), icon: '◐', img: null },
  ];
  return (
    <div>
      <div className="r-poll" style={{
        display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 22,
        maxWidth: 980, margin: '0 auto',
      }}>
        {opts.map((o) => {
          const active = vote === o.id;
          const dim = vote && !active;
          return (
            <button key={o.id} onClick={() => choose(o.id)} style={{
              appearance: 'none', cursor: 'pointer', textAlign: 'left', padding: 0,
              background: '#0d0a14',
              border: `1.5px solid ${active ? 'var(--gold)' : 'rgba(200,160,76,0.3)'}`,
              boxShadow: active ? '0 0 34px rgba(200,160,76,0.25), 0 22px 40px -20px rgba(0,0,0,0.8)' : '0 22px 40px -24px rgba(0,0,0,0.8)',
              opacity: dim ? 0.55 : 1,
              transform: active ? 'translateY(-4px)' : 'none',
              transition: 'all 0.3s ease',
              overflow: 'hidden', position: 'relative',
            }}>
              {/* art strip */}
              <div style={{ height: 110, overflow: 'hidden', position: 'relative', background: '#08060e' }}>
                {o.img ? (
                  <img src={o.img} alt="" style={{
                    width: '100%', height: '100%', objectFit: 'cover',
                    objectPosition: o.id === 'cozy' ? 'center 62%' : 'center 40%',
                    filter: dim ? 'saturate(0.6)' : 'none', transition: 'filter 0.3s ease',
                  }} />
                ) : (
                  <div style={{ position: 'absolute', inset: 0, display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
                    <img src="assets/art-2d-town.webp" alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 62%' }} />
                    <img src="assets/art-3d-combat.webp" alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 40%' }} />
                  </div>
                )}
                <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, transparent 30%, #0d0a14 100%)' }} />
                {active && (
                  <div style={{
                    position: 'absolute', top: 10, right: 10, width: 26, height: 26, borderRadius: '50%',
                    background: 'var(--gold)', color: '#1a0e02', display: 'grid', placeItems: 'center',
                    fontSize: 15, fontWeight: 700,
                  }}>✓</div>
                )}
              </div>
              <div style={{ padding: '16px 18px 20px' }}>
                <div style={{
                  fontFamily: 'var(--font-ko)', fontWeight: 900, fontSize: 20,
                  color: active ? 'var(--gold-hi)' : '#efe4c2', marginBottom: 6,
                }}>{o.h}</div>
                <div style={{
                  fontFamily: 'var(--font-ko)', fontSize: 13.5, lineHeight: 1.55,
                  color: 'var(--night-soft)',
                }}>{o.d}</div>
              </div>
            </button>
          );
        })}
      </div>
      <div style={{
        textAlign: 'center', marginTop: 26, minHeight: 24,
        fontFamily: 'var(--font-ko)', fontStyle: 'italic', fontSize: 15.5,
        color: 'var(--gold)', opacity: vote ? 1 : 0, transition: 'opacity 0.5s ease',
      }}>
        {l('poll_thanks')}
      </div>
    </div>
  );
}

window.LandingShared = { L, useL, Reveal, SectionHead, ScrollSignup, ArtViewer, RoleIcon, RegionIcon, Poll };
Object.assign(window, { L, useL, Reveal, SectionHead, ScrollSignup, ArtViewer, RoleIcon, RegionIcon, Poll });


/* ==================== site2-strings.jsx ==================== */
// site2-strings.jsx — full i18n strings for the new overview site (from the spec PDF)

const S2 = {
  ko: {
    nav_cta: '사전예약',
    // hero
    hero_tag: '일상과 모험 — 두 세계를 사는 RPG',
    hero_sub: '던전을 누비는 협동 액션, 그리고 아늑한 픽셀 아트 마을에서의 하루. 하나의 캐릭터에게 두 가지 삶이 있습니다.',
    hero_left: '⚔ 협동 모험',
    hero_right: '⌂ 아늑한 일상',
    hero_drag: '경계를 끌어 두 세계를 오가보세요',
    hero_meta: 'iOS · Android 우선 — PC · Mac 추후 지원 · 한국어 / 영어',
    // concept
    con_eyebrow: '어떤 게임인가',
    con_title: '힐링 게임에 액션을 더하다',
    con_p: '한가로운 2D 마을에서의 삶과, 던전을 누비는 3D 협동 액션 — 언뜻 어울리지 않는 두 장르를 하나의 세계로 엮었습니다. 내 집과 농장을 가꾸며 느긋한 하루를 보내다가도, 마음 맞는 친구들과 새로운 땅으로 모험을 떠나세요. 쉬고 싶은 날에도, 짜릿함이 당기는 날에도 — 트윈미어는 당신의 방식대로 즐길 수 있습니다.',
    con_genre_2d: '2D 탑뷰 RPG 커뮤니케이션',
    con_genre_x: '×',
    con_genre_3d: '3D 플랫폼 & 벨트스크롤',
    // 2D part
    p2_eyebrow: '2D 파트',
    p2_title: '나의 공간, 우리의 마을',
    p2_private_h: 'Private Zone — 개인 공간',
    p2_private_p: '자신만의 집과 농장을 가꿉니다. 이 공간엔 나와 내가 초대한 친구만 들어올 수 있습니다. 나만의 공간을 꾸며보세요.',
    p2_town_h: 'Town — 공유 마을',
    p2_town_p: '마을로 나가면, 다른 플레이어들과 만나고 어울릴 수 있습니다. 다른 플레이어들과 함께 모험을 떠나보세요.',
    p2_life_label: '생활',
    p2_skill_label: '기술',
    p2_craft_label: '제작',
    p2_life: ['농사', '낚시', '목축', '채광', '채집'],
    p2_skill: ['제련', '가공', '재봉', '목공'],
    p2_craft: ['요리', '대장장이', '연금술'],
    p2_portrait_eyebrow: '모바일 우선 설계',
    p2_portrait_h: '한 손으로 즐기는 아늑함',
    p2_portrait_p: '2D 파트는 세로 화면으로도 플레이 가능합니다. 출근길 지하철에서도, 잠들기 전 침대에서도 — 한 손 엄지 하나로 밭을 갈고, 낚싯대를 던지고, 마을을 거닐 수 있습니다.',
    p2_portrait_chips: ['세로 화면', '한 손 엄지 조작', '언제 어디서든'],
    p2_art_caption: '2D 파트 — 픽셀 아트로 그린 마을 광장의 하루',
    // 3D part
    p3_eyebrow: '3D 파트',
    p3_title: '함께 모험하는 던전',
    p3_p: '벨트스크롤 액션과 퍼즐 플랫폼이 결합된 모험 파트. 혼자서도, 최대 3인 협동으로도 즐길 수 있습니다.',
    p3_combat_h: '전투 구간',
    p3_combat_p: '등장하는 적을 처치하는 액션 플레이 — 검과 방패, 마법, 활이 만드는 합',
    p3_puzzle_h: '협동 퍼즐 구간',
    p3_puzzle_p: '팀워크로 풀어내는 퍼즐 — 세 역할이 능력을 모아야만 길이 열리는 스테이지 디자인',
    p3_regions: '다섯 지역의 던전을 실제로 탐험합니다 — 북부의 얼어붙은 길을 불 마법으로 녹이고, 남부의 독성 늪을 건너고, 서부의 고대 유적을 파헤치세요.',
    p3_art_caption: '3D 파트 — 시네마틱 조명과 실루엣이 만드는 협동 전투',
    p3_anim_note: '텍스처 디테일보다 애니메이션 품질 우선 — 3D 캐릭터는 항상 2D 캐릭터와 일치하도록 설계됩니다',
    // roles
    ro_eyebrow: '차별점',
    ro_title: '직업은 없다 — 장비가 역할을 만든다',
    ro_p: '게임 초반에 클래스를 고르지 않습니다. 착용한 장비와 쌓아온 숙련도가 당신의 역할을 결정합니다. 장비를 바꿔 입으면, 같은 캐릭터가 다른 역할이 됩니다.',
    ro_try: '장비를 바꿔보세요',
    ro_tank_gear: '무거운 갑옷 + 방패와 검',
    ro_tank_role: '전면 탱커',
    ro_tank_d: '파티의 최전선에서 공격을 받아냅니다',
    ro_mage_gear: '가벼운 로브 + 마법 지팡이',
    ro_mage_role: '지원형 마법사',
    ro_mage_d: '원소의 힘으로 전장을 뒤흔듭니다',
    ro_archer_gear: '가죽 갑옷 + 활',
    ro_archer_role: '원거리 궁수',
    ro_archer_d: '정밀한 한 발로 흐름을 바꿉니다',
    ro_same: '— 전부 같은 캐릭터입니다',
    // neigong (inner energy)
    ne_eyebrow: '특징',
    ne_title: '내공 — 기본기가 비기가 된다',
    ne_p: '동방에서 전해지는 내공(內功). 화려한 신규 스킬을 사들이는 대신, 몸에 쌓은 내공을 이미 익힌 기본기에 흘려넣어 강화합니다. 같은 검격 한 수가 내공이 깊어질수록 더 넓게, 더 매섭게 — 끝내 새로운 오의로 피어납니다.',
    ne_try: '내공을 끌어올려 보세요',
    ne_channel: '내공 주입',
    ne_release: '거두기',
    ne_skill: '기본 검격',
    ne_t0_name: '기본기',
    ne_t0_d: '입문자의 한 수 — 단순한 베기',
    ne_t1_name: '1성 · 강기(剛氣)',
    ne_t1_d: '검에 내공을 실어 사거리와 위력이 늘어난다',
    ne_t2_name: '2성 · 검기(劍氣)',
    ne_t2_d: '베는 궤적을 따라 검기가 뻗어 먼 적을 노린다',
    ne_t3_name: '3성 · 오의(奧義)',
    ne_t3_d: '내공을 터뜨리는 광역 일섬 — 하나의 비기로 완성된다',
    ne_note: '내공은 수련과 휴식으로 쌓이며, 모든 기본기는 저마다의 오의로 뻗어나갈 수 있습니다.',
    // features grid
    ft_eyebrow: '게임 특징',
    ft_title: '무엇이 다른가',
    ft_1h: '유동적인 역할',
    ft_1p: '고정된 직업이 없습니다. 착용한 장비와 숙련도가 역할을 정하고, 갈아입으면 같은 캐릭터가 탱커도 마법사도 됩니다.',
    ft_2h: '내공으로 기본기 강화',
    ft_2p: '새 스킬을 사는 대신, 쌓은 내공을 기본기에 흘려넣어 강화합니다. 기본 검격이 검기로, 끝내 오의로 피어납니다.',
    ft_3h: '쉬어야 강해진다',
    ft_3p: '스태미너는 HP와 별개인 하루의 체력. 음식이 아닌 휴식으로만 회복되고, 잘 쉬면 더 큰 보상이 돌아옵니다.',
    ft_4h: '모든 것에 존재 이유',
    ft_4p: '잠깐 쓰고 버려지는 요소는 없습니다. 시작용 칼조차 전설의 검과 다른 나름의 쓸모를 지닙니다.',
    ft_5h: '모든 것과 상호작용',
    ft_5p: '의자 하나도 앉을 수 있어야 합니다. 분위기용 장식이 아니라, 세계의 모든 것이 플레이에 관여합니다.',
    ft_6h: '솔로 & 최대 3인 협동',
    ft_6p: '혼자서도, 최대 세 명이 함께서도. 전투 구간과 협동 퍼즐이 번갈아 이어집니다.',
    // world
    wo_eyebrow: '세계관',
    wo_title: '마법이 일상에 스며든 대륙',
    wo_p: '중세 판타지 세계 — 마법은 전투만의 것이 아닙니다. 비구름을 불러 밭에 물을 주고, 성장 마법으로 작물을 키우고, 아픈 가축을 치유 마법으로 돌봅니다.',
    wo_region_label: '다섯 지역',
    wo_race_label: '네 종족, 네 가지 마법',
    wo_trade: '열대 작물 · 설원 광물 · 사막의 희귀 광물 — 지역 자원을 교역하고 가공해 부가가치를 만드는 경제',
    race_human: '인간', race_human_m: '학문적 연구의 마법',
    race_orc: '오크', race_orc_m: '주술과 부족 의식 — 의식으로 작물의 성장을 재촉합니다',
    race_undead: '언데드', race_undead_m: '죽음과 저주 계열 — 좀비 가축을 키우기도 합니다',
    race_elf: '엘프', race_elf_m: '자연과의 교감',
    // philosophy
    ph_eyebrow: '설계 원칙',
    ph_title: '다섯 가지 약속',
    ph_1_h: '모든 요소는 의미가 있어야 한다',
    ph_1_p: '시작용 칼조차 전설의 검과 비교해 나름의 쓸모가 있습니다. 잠깐 쓰고 버려지는 요소는 없습니다.',
    ph_2_h: '모든 것은 상호작용 가능해야 한다',
    ph_2_p: '단순한 의자라도 최소한 앉을 수는 있어야 합니다. 분위기만을 위한 오브젝트는 지양합니다.',
    ph_3_h: 'UI는 필요한 순간에만',
    ph_3_p: '화면은 기본적으로 깨끗하게 — 꼭 필요한 정보가 꼭 필요한 순간에만 자연스럽게 등장합니다.',
    ph_4_h: '모든 조작은 직관적이어야 한다',
    ph_4_p: '설명서 없이도 즉시 이해되는 조작. 복잡함이 아닌 직관성이 핵심 가치입니다.',
    ph_5_h: '쉬어야 강해진다',
    ph_5_p: '스태미너는 HP와 분리된 하루의 체력입니다. 음식만으로는 회복되지 않습니다 — 수면, 모닥불, 온천, 사회적 활동 같은 진짜 휴식으로만 회복되며, 잘 쉰 플레이어는 경험치 보너스와 채집·제작 성공률 상승으로 보답받습니다.',
    // stamina demo
    st_title: '스태미너 체계 — 직접 확인해보세요',
    st_bar: '스태미너',
    st_act_farm: '밭 갈기',
    st_act_fish: '낚시',
    st_act_fight: '강공격',
    st_rest_fire: '모닥불에서 쉬기',
    st_rest_sleep: '수면',
    st_state_ok: '컨디션 양호 — 모든 활동 가능',
    st_state_tired: '지침 — 효율이 떨어지기 시작합니다',
    st_state_red: '탈진 — 오로지 수면으로만 회복됩니다',
    st_bonus: '푹 잤다! 경험치 보너스 +10% · 채집 확률 ↑',
    st_fire_blocked: '너무 지쳐서 모닥불로는 부족합니다',
    // demo roadmap
    de_eyebrow: '데모 버전',
    de_title: '데모에서 만나게 될 것',
    de_1_h: 'Private Zone',
    de_1_t: '2D · 개인 공간',
    de_1_p: '농사·채집·채굴이 가능한 아늑한 개인 공간. 간단한 전투와 세로 화면 플레이 지원.',
    de_2_h: 'Town',
    de_2_t: '2D · 마을',
    de_2_p: '플레이어들이 만나는 사회적 허브. Private Zone에서 이동 수단으로 연결됩니다.',
    de_3_h: 'Stage 1',
    de_3_t: '3D · 액션',
    de_3_p: '전투·플랫폼·퍼즐이 결합된 첫 스테이지. 클리어하면 전리품을 획득합니다.',
    de_lang: '데모부터 한국어 · 영어 동시 지원',
    // poll & signup
    po_eyebrow: '수요 조사',
    po_title: '당신은 어느 쪽인가요?',
    po_sub: '한 번의 선택이 개발의 방향이 됩니다.',
    su_eyebrow: '사전예약',
    su_title: '출시 전, 먼저 만나보세요',
    su_p: '데모 출시 소식과 테스트 초대를 이메일로 가장 먼저 보내드립니다.',
    footer_note: '개발 중인 게임의 사전 검증 페이지입니다. 현재 이미지는 AI로 생성된 게임 예시 이미지이며, 개발 과정에서 달라질 수 있습니다.',
  },
  en: {
    nav_cta: 'Pre-register',
    hero_tag: 'Everyday life and adventure — one RPG, two worlds',
    hero_sub: 'Co-op action in the dungeon, and a day in a cozy pixel-art town. One character, two lives.',
    hero_left: '⚔ The expedition',
    hero_right: '⌂ The cozy life',
    hero_drag: 'Drag the seam to cross worlds',
    hero_meta: 'iOS · Android first — PC · Mac later · KO / EN',
    con_eyebrow: 'What it is',
    con_title: 'A healing game, with action',
    con_p: 'A cozy 2D village life and co-op 3D dungeon action — two genres you would not expect to meet, woven into one world. Tend your home and farm at your own pace, then set out with friends for somewhere new. On the days you want to rest, and the days you crave a thrill, Twinmere meets you where you are.',
    con_genre_2d: '2D top-view RPG communication',
    con_genre_x: '×',
    con_genre_3d: '3D platformer & beat \u2019em up',
    p2_eyebrow: 'The 2D part',
    p2_title: 'My space, our town',
    p2_private_h: 'Private Zone',
    p2_private_p: 'Tend a house and farm of your own. Only you and the friends you invite may enter. Make the space yours.',
    p2_town_h: 'Town — the shared world',
    p2_town_p: 'Head out to Town and you can meet and mingle with other players. Set off on adventures together with them.',
    p2_life_label: 'Life',
    p2_skill_label: 'Skills',
    p2_craft_label: 'Crafting',
    p2_life: ['Farming', 'Fishing', 'Herding', 'Mining', 'Foraging'],
    p2_skill: ['Smelting', 'Processing', 'Sewing', 'Woodwork'],
    p2_craft: ['Cooking', 'Blacksmithing', 'Alchemy'],
    p2_portrait_eyebrow: 'Mobile-first by design',
    p2_portrait_h: 'Coziness in one hand',
    p2_portrait_p: 'The 2D part can also be played in portrait mode. On the morning train, in bed before sleep — till the field, cast a line, and stroll the town with a single thumb.',
    p2_portrait_chips: ['Portrait mode', 'One-thumb play', 'Anywhere, anytime'],
    p2_art_caption: '2D part — a day in the town square, drawn in pixel art',
    p3_eyebrow: 'The 3D part',
    p3_title: 'Adventure the dungeon together',
    p3_p: 'An adventure part fusing beat \u2019em up combat with puzzle platforming. Solo-friendly, co-op up to three.',
    p3_combat_h: 'Combat gauntlets',
    p3_combat_p: 'Action encounters — sword and board, spellwork, and the bow in concert',
    p3_puzzle_h: 'Co-op puzzle sections',
    p3_puzzle_p: 'Teamwork puzzles — stages that only open when the three roles combine their strengths',
    p3_regions: 'You\u2019ll explore the dungeons of all five regions — melt the frozen north with fire magic, wade the toxic southern swamps, dig through the ancient ruins of the west.',
    p3_art_caption: '3D part — co-op combat in cinematic light and silhouette',
    p3_anim_note: 'Animation quality over texture detail — the 3D character always matches your 2D character',
    ro_eyebrow: 'What sets it apart',
    ro_title: 'No classes — gear makes the role',
    ro_p: 'You never pick a class at the start. Equipment and practiced skill decide your role — change what you wear, and the same character becomes someone new.',
    ro_try: 'Try the loadouts',
    ro_tank_gear: 'Heavy armor + sword & shield',
    ro_tank_role: 'Frontline tank',
    ro_tank_d: 'Holds the line at the front of the party',
    ro_mage_gear: 'Light robe + magic staff',
    ro_mage_role: 'Support mage',
    ro_mage_d: 'Elemental force that turns the field',
    ro_archer_gear: 'Leather + bow',
    ro_archer_role: 'Ranged archer',
    ro_archer_d: 'One precise shot changes everything',
    ro_same: '— all the same character',
    // neigong (inner energy)
    ne_eyebrow: 'A signature system',
    ne_title: 'Neigong — basics become mastery',
    ne_p: 'Inner energy (Neigong), carried from the East. Instead of buying flashy new skills, you pour the qi you’ve cultivated into the basics you already know. The same sword stroke reaches farther and bites harder as your inner energy deepens — until it blooms into a technique all its own.',
    ne_try: 'Channel your inner energy',
    ne_channel: 'Channel qi',
    ne_release: 'Release',
    ne_skill: 'Basic slash',
    ne_t0_name: 'Basics',
    ne_t0_d: 'A novice’s stroke — a simple cut',
    ne_t1_name: 'Tier 1 · Force',
    ne_t1_d: 'Qi rides the blade — greater reach and power',
    ne_t2_name: 'Tier 2 · Blade-aura',
    ne_t2_d: 'A crescent of energy flies along the arc, striking at range',
    ne_t3_name: 'Tier 3 · Mastery',
    ne_t3_d: 'Burst the qi for a wide single flash — a technique of your own',
    ne_note: 'Inner energy builds through training and rest — every basic can branch into its own mastery.',
    // features grid
    ft_eyebrow: 'Game features',
    ft_title: 'What sets Twinmere apart',
    ft_1h: 'Fluid roles',
    ft_1p: 'No fixed class. Gear and practiced skill decide your role — change loadout and the same character becomes tank or mage.',
    ft_2h: 'Enhance basics with qi',
    ft_2p: 'Instead of buying new skills, pour cultivated inner energy into the basics — a plain slash grows into blade-aura, then into a mastery.',
    ft_3h: 'Rest makes you stronger',
    ft_3p: 'Stamina is a daily resource, apart from HP. Only true rest restores it — and resting well pays back with bonuses.',
    ft_4h: 'Everything has a reason',
    ft_4p: 'Nothing is throwaway. Even the starter knife holds its own value beside a legendary blade.',
    ft_5h: 'Everything interacts',
    ft_5p: 'Even a chair can be sat on. No purely decorative props — the whole world answers to play.',
    ft_6h: 'Solo & up to 3 co-op',
    ft_6p: 'Alone or with two friends — combat gauntlets alternate with team puzzles.',
    wo_eyebrow: 'The world',
    wo_title: 'A continent steeped in everyday magic',
    wo_p: 'A medieval fantasy world where magic is not just for battle: summon a rain-cloud over your field, hasten crops with growth magic, heal a sick animal with a touch.',
    wo_region_label: 'Five regions',
    wo_race_label: 'Four races, four magics',
    wo_trade: 'Tropical crops · frost ore · rare desert minerals — trade regional resources, process them, and profit',
    race_human: 'Human', race_human_m: 'Scholarly, studied magic',
    race_orc: 'Orc', race_orc_m: 'Shamanic rites — rituals that hasten the harvest',
    race_undead: 'Undead', race_undead_m: 'Death and curse work — some even keep zombie livestock',
    race_elf: 'Elf', race_elf_m: 'Communion with nature',
    ph_eyebrow: 'Design principles',
    ph_title: 'Five promises',
    ph_1_h: 'Every element must mean something',
    ph_1_p: 'Even the starter knife has its own worth beside a legendary blade. Nothing is used once and discarded.',
    ph_2_h: 'Everything can be interacted with',
    ph_2_p: 'Even a plain chair can at least be sat on. No object exists for atmosphere alone.',
    ph_3_h: 'UI only when it\u2019s needed',
    ph_3_p: 'The screen stays clean — exactly the right information appears at exactly the right moment.',
    ph_4_h: 'Every control must be intuitive',
    ph_4_p: 'Understood instantly, no manual required. Intuition over complexity.',
    ph_5_h: 'Rest makes you stronger',
    ph_5_p: 'Stamina is a daily resource, separate from HP. Food alone won\u2019t restore it — only true rest: sleep, campfires, hot springs, time with others. Rest well and the game repays you: bonus XP, better foraging, higher craft success.',
    st_title: 'The stamina loop — try it',
    st_bar: 'Stamina',
    st_act_farm: 'Till the field',
    st_act_fish: 'Fish',
    st_act_fight: 'Heavy attack',
    st_rest_fire: 'Rest by the fire',
    st_rest_sleep: 'Sleep',
    st_state_ok: 'In good shape — everything available',
    st_state_tired: 'Tired — efficiency starts to drop',
    st_state_red: 'Exhausted — only sleep will restore you',
    st_bonus: 'Well slept! +10% XP bonus · better foraging',
    st_fire_blocked: 'Too exhausted — the fire isn\u2019t enough',
    de_eyebrow: 'The demo',
    de_title: 'What the demo will hold',
    de_1_h: 'Private Zone',
    de_1_t: '2D · personal space',
    de_1_p: 'A cozy personal space for farming, foraging, and mining. Simple combat, portrait-play support.',
    de_2_h: 'Town',
    de_2_t: '2D · social hub',
    de_2_p: 'The shared hub where players meet — reached from your Private Zone by mount.',
    de_3_h: 'Stage 1',
    de_3_t: '3D · action',
    de_3_p: 'The first stage of combat, platforming, and puzzles. Clear it and claim the loot.',
    de_lang: 'Korean · English from the demo onward',
    po_eyebrow: 'Tell us',
    po_title: 'Which side are you?',
    po_sub: 'One tap steers the development.',
    su_eyebrow: 'Pre-register',
    su_title: 'Get in before launch',
    su_p: 'Demo news and test invitations, delivered to your inbox first.',
    footer_note: 'A pre-production validation page. Current images are AI-generated concept examples and may change during development.',
  },
};

const useS2 = (lang) => (k) => S2[lang]?.[k] ?? S2.en[k] ?? k;

Object.assign(window, { S2, useS2 });


/* ==================== site2-widgets.jsx ==================== */
// site2-widgets.jsx — interactive widgets for the overview site
// WipeHero, FramedArt, StaminaDemo, LoadoutDemo, RaceSigil, Chip

// ============================================================
// WipeHero — drag the seam between 3D (left) and 2D (right)
// ============================================================
function WipeHero({ lang, children, initial = 0.5, onDragChange, labelsPos = 'corner', pos2d = 'center 45%', pos3d = 'center 30%', focal3d = 0.38 }) {
  const s = useS2(lang);
  const [p, setP] = React.useState(initial);
  const [w, setW] = React.useState(0);  // measured px width
  const [hh, setHh] = React.useState(0); // measured px height
  const ref = React.useRef(null);
  const dragging = React.useRef(false);

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const measure = () => { setW(el.clientWidth); setHh(el.clientHeight); };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const update = (clientX) => {
    const rect = ref.current.getBoundingClientRect();
    setP(Math.max(0.12, Math.min(0.88, (clientX - rect.left) / rect.width)));
  };
  const down = (e) => { dragging.current = true; onDragChange && onDragChange(true); e.currentTarget.setPointerCapture?.(e.pointerId); update(e.clientX); };
  const move = (e) => { if (dragging.current) update(e.clientX); };
  const up = () => { dragging.current = false; onDragChange && onDragChange(false); };

  return (
    <div ref={ref} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
      {/* base: 2D town art (visible on the RIGHT of the seam) — panned so the
          party (at image center) stays centered within the visible 2D strip:
          image center -> (1+p)/2 of the screen */}
      <img src="assets/art-2d-town.webp" alt="2D pixel town"
        style={{
          position: 'absolute', top: 0, left: 0, height: '100%',
          width: w ? `${w}px` : '100vw', maxWidth: 'none',
          objectFit: 'cover', objectPosition: pos2d,
          transform: w ? `translateX(${(w * p) / 2}px)` : 'none',
          transition: dragging.current ? 'none' : 'transform 0.45s ease',
        }} />
      {/* top: 3D night art (original orientation) — clipped to the LEFT of the
          seam; slides in lockstep with the 2D side (party held centered in its
          strip) so both worlds move together naturally. */}
      <div style={{
        position: 'absolute', top: 0, bottom: 0, left: 0,
        width: `${p * 100}%`, overflow: 'hidden',
        transition: dragging.current ? 'none' : 'width 0.45s ease',
      }}>
        {(() => {
          const AR3 = 1697 / 927;               // art aspect
          const rw = hh ? hh * AR3 : 0;         // rendered width at full height
          const canPan = w > 0 && rw >= 0.88 * w;
          if (!canPan) {
            return (
              <img src="assets/art-3d-combat.webp" alt="3D co-op action"
                style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: pos3d }} />
            );
          }
          // Pure lockstep: hold the party centered in the visible 3D strip, so
          // both worlds slide together as the seam moves.
          let left3 = (p / 2) * w - focal3d * rw;
          left3 = Math.min(left3, 0);              // no gap on the left
          left3 = Math.max(left3, p * w - rw);     // cover the strip's right edge
          return (
            <img src="assets/art-3d-combat.webp" alt="3D co-op action"
              style={{
                position: 'absolute', top: 0, left: 0, height: '100%',
                width: `${rw}px`, maxWidth: 'none',
                transform: `translateX(${left3}px)`,
                transition: dragging.current ? 'none' : 'transform 0.45s ease',
              }} />
          );
        })()}
      </div>

      {/* seam glow */}
      <div style={{
        position: 'absolute', top: 0, bottom: 0, left: `${p * 100}%`, width: 3,
        transform: 'translateX(-50%)',
        background: 'linear-gradient(180deg, transparent, var(--ember-bright) 20%, var(--ember) 80%, transparent)',
        boxShadow: '0 0 24px rgba(255,138,76,0.8), 0 0 64px rgba(217,119,87,0.4)',
        transition: dragging.current ? 'none' : 'left 0.45s ease',
        zIndex: 3,
      }} />

      {/* drag capture strip + handle */}
      <div
        onPointerDown={down} onPointerMove={move} onPointerUp={up}
        style={{
          position: 'absolute', top: 0, bottom: 0, left: `${p * 100}%`, width: 88,
          // pan-y: 세로 스크롤은 브라우저에 넘기고(모바일에서 페이지가 안 잠김),
          // 가로 방향만 우리 드래그 핸들러가 처리한다.
          transform: 'translateX(-50%)', cursor: 'ew-resize', zIndex: 4, touchAction: 'pan-y',
          transition: dragging.current ? 'none' : 'left 0.45s ease',
        }}
      >
        <div style={{
          position: 'absolute', left: '50%', top: '58%', transform: 'translate(-50%, -50%)',
          width: 56, height: 56, borderRadius: '50%',
          background: 'radial-gradient(circle at 35% 32%, #2a1d12 0%, #0c0810 100%)',
          border: '2px solid var(--gold)',
          boxShadow: '0 0 26px rgba(200,160,76,0.5), 0 10px 26px rgba(0,0,0,0.6)',
          display: 'grid', placeItems: 'center',
          color: 'var(--gold-hi)', fontSize: 17, letterSpacing: 2, textIndent: 2,
          fontFamily: 'var(--font-ui, monospace)',
          userSelect: 'none',
        }}>
          ◂▸
        </div>
      </div>

      {/* side labels */}
      {labelsPos === 'corner' && (<>
      <button onClick={() => setP(0.82)} style={{
        appearance: 'none', border: 'none', cursor: 'pointer',
        position: 'absolute', left: 26, bottom: 24, zIndex: 5,
        background: 'rgba(6,4,10,0.55)', backdropFilter: 'blur(3px)',
        padding: '9px 16px', color: '#efe4c2',
        fontFamily: 'var(--font-ko)', fontWeight: 700, fontSize: 14,
        border: '1px solid rgba(200,160,76,0.4)',
        opacity: p > 0.4 ? 1 : 0.55, transition: 'opacity 0.3s ease',
      }}>{s('hero_left')}</button>
      <button onClick={() => setP(0.18)} style={{
        appearance: 'none', border: 'none', cursor: 'pointer',
        position: 'absolute', right: 26, bottom: 24, zIndex: 5,
        background: 'rgba(6,4,10,0.55)', backdropFilter: 'blur(3px)',
        padding: '9px 16px', color: '#efe4c2',
        fontFamily: 'var(--font-ko)', fontWeight: 700, fontSize: 14,
        border: '1px solid rgba(200,160,76,0.4)',
        opacity: p < 0.6 ? 1 : 0.55, transition: 'opacity 0.3s ease',
      }}>{s('hero_right')}</button>
      </>)}
      {labelsPos === 'edge' && (<>
      <button onClick={() => setP(0.8)} title={s('hero_left')} style={{
        appearance: 'none', cursor: 'pointer',
        position: 'absolute', left: 16, top: '50%', transform: 'translateY(-50%)', zIndex: 5,
        width: 46, height: 46, borderRadius: '50%',
        background: 'rgba(6,4,10,0.55)', backdropFilter: 'blur(3px)',
        border: '1px solid rgba(200,160,76,0.45)', color: '#efe4c2', fontSize: 19,
        display: 'grid', placeItems: 'center',
        opacity: dragging.current ? 0.15 : (p < 0.6 ? 1 : 0.5), transition: 'opacity 0.3s ease',
      }}>⚔</button>
      <button onClick={() => setP(0.2)} title={s('hero_right')} style={{
        appearance: 'none', cursor: 'pointer',
        position: 'absolute', right: 16, top: '50%', transform: 'translateY(-50%)', zIndex: 5,
        width: 46, height: 46, borderRadius: '50%',
        background: 'rgba(6,4,10,0.55)', backdropFilter: 'blur(3px)',
        border: '1px solid rgba(200,160,76,0.45)', color: '#efe4c2', fontSize: 19,
        display: 'grid', placeItems: 'center',
        opacity: dragging.current ? 0.15 : (p > 0.4 ? 1 : 0.5), transition: 'opacity 0.3s ease',
      }}>⌂</button>
      </>)}

      {children}
    </div>
  );
}

// ============================================================
// FramedArt — single image in the ornate gold frame
// ============================================================
function FramedArt({ src, alt, caption, position = 'center' }) {
  return (
    <div>
      <div style={{
        position: 'relative', border: '2px solid var(--gold-deep)',
        boxShadow: '0 40px 90px -30px rgba(0,0,0,0.85), 0 0 0 1px rgba(0,0,0,0.6)',
        background: '#050308',
      }}>
        <div style={{ position: 'absolute', inset: 8, border: '1px solid rgba(200,160,76,0.35)', zIndex: 3, pointerEvents: 'none' }} />
        {[{ top: -7, left: -7 }, { top: -7, right: -7 }, { bottom: -7, left: -7 }, { bottom: -7, right: -7 }].map((pp, i) => (
          <div key={i} style={{
            position: 'absolute', width: 12, height: 12, background: 'var(--gold)',
            transform: 'rotate(45deg)', zIndex: 4, ...pp,
          }} />
        ))}
        <div style={{ position: 'relative', aspectRatio: '1844 / 853', overflow: 'hidden' }}>
          <img src={src} alt={alt} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: position, display: 'block' }} />
          <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', boxShadow: 'inset 0 0 120px rgba(0,0,0,0.5)' }} />
        </div>
      </div>
      {caption && (
        <div style={{
          marginTop: 14, textAlign: 'center', fontFamily: 'var(--font-ko)',
          fontStyle: 'italic', fontSize: 14, color: 'var(--night-soft)',
        }}>{caption}</div>
      )}
    </div>
  );
}

// ============================================================
// ScrollArt — wide image explored by dragging/scrolling left↔right
// ============================================================
function ScrollArt({ src, alt, caption, hint, aspect = '1.8 / 1' }) {
  const ref = React.useRef(null);
  const drag = React.useRef({ on: false, x: 0, left: 0 });
  const down = (e) => { drag.current = { on: true, x: e.clientX, left: ref.current.scrollLeft }; ref.current.setPointerCapture?.(e.pointerId); };
  const move = (e) => { if (!drag.current.on) return; ref.current.scrollLeft = drag.current.left - (e.clientX - drag.current.x); };
  const up = () => { drag.current.on = false; };
  React.useEffect(() => {
    const el = ref.current; if (!el) return;
    el.scrollLeft = (el.scrollWidth - el.clientWidth) / 2; // start centered
  }, []);
  return (
    <div>
      <div style={{
        position: 'relative', border: '2px solid var(--gold-deep)',
        boxShadow: '0 40px 90px -30px rgba(0,0,0,0.85), 0 0 0 1px rgba(0,0,0,0.6)', background: '#050308',
      }}>
        <div style={{ position: 'absolute', inset: 8, border: '1px solid rgba(200,160,76,0.35)', zIndex: 4, pointerEvents: 'none' }} />
        {[{ top: -7, left: -7 }, { top: -7, right: -7 }, { bottom: -7, left: -7 }, { bottom: -7, right: -7 }].map((pp, i) => (
          <div key={i} style={{ position: 'absolute', width: 12, height: 12, background: 'var(--gold)', transform: 'rotate(45deg)', zIndex: 5, ...pp }} />
        ))}
        <div ref={ref} className="scrollart" onPointerDown={down} onPointerMove={move} onPointerUp={up}
          style={{ aspectRatio: aspect, width: '100%', overflowX: 'auto', overflowY: 'hidden', cursor: 'grab',
            // pan-x pan-y: 갤러리는 가로로 넘기고, 세로 스와이프는 페이지 스크롤로 통과시킨다.
            touchAction: 'pan-x pan-y' }}>
          <img src={src} alt={alt} draggable={false}
            style={{ height: '100%', width: 'auto', maxWidth: 'none', display: 'block', userSelect: 'none', pointerEvents: 'none' }} />
        </div>
        {/* edge fades */}
        <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 64, background: 'linear-gradient(90deg, rgba(5,3,8,0.75), transparent)', pointerEvents: 'none', zIndex: 3 }} />
        <div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 64, background: 'linear-gradient(270deg, rgba(5,3,8,0.75), transparent)', pointerEvents: 'none', zIndex: 3 }} />
        {hint && (
          <div style={{
            position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center', zIndex: 4, pointerEvents: 'none',
            fontFamily: 'var(--font-ko)', fontStyle: 'italic', fontSize: 13, color: 'rgba(240,230,196,0.92)',
            textShadow: '0 1px 8px rgba(0,0,0,0.95)',
          }}>← {hint} →</div>
        )}
      </div>
      {caption && (
        <div style={{
          marginTop: 14, textAlign: 'center', fontFamily: 'var(--font-ko)',
          fontStyle: 'italic', fontSize: 14, color: 'var(--night-soft)',
        }}>{caption}</div>
      )}
    </div>
  );
}

// ============================================================
// Gallery — browse a set of 2D screenshots (drag each, arrows + thumbs)
// ============================================================
function Gallery({ items, hint }) {
  const [i, setI] = React.useState(0);
  const cur = items[i];
  const go = (d) => setI((i + d + items.length) % items.length);
  const arrow = (side) => ({
    appearance: 'none', cursor: 'pointer', position: 'absolute', top: '50%', [side]: 10,
    transform: 'translateY(-50%)', zIndex: 6, width: 44, height: 44, borderRadius: '50%',
    background: 'rgba(6,4,10,0.6)', backdropFilter: 'blur(3px)', border: '1px solid rgba(200,160,76,0.5)',
    color: '#efe4c2', fontSize: 22, lineHeight: 1, display: 'grid', placeItems: 'center',
  });
  return (
    <div>
      <div style={{ position: 'relative' }}>
        {/* fixed-size frame (constant across images) — image fills via cover */}
        <div style={{
          position: 'relative', border: '2px solid var(--gold-deep)',
          boxShadow: '0 40px 90px -30px rgba(0,0,0,0.85), 0 0 0 1px rgba(0,0,0,0.6)', background: '#050308',
        }}>
          <div style={{ position: 'absolute', inset: 8, border: '1px solid rgba(200,160,76,0.35)', zIndex: 4, pointerEvents: 'none' }} />
          {[{ top: -7, left: -7 }, { top: -7, right: -7 }, { bottom: -7, left: -7 }, { bottom: -7, right: -7 }].map((pp, idx) => (
            <div key={idx} style={{ position: 'absolute', width: 12, height: 12, background: 'var(--gold)', transform: 'rotate(45deg)', zIndex: 5, ...pp }} />
          ))}
          <div style={{ position: 'relative', aspectRatio: '1844 / 853', overflow: 'hidden' }}>
            <img key={cur.src} src={cur.src} alt={cur.alt || ''}
              style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: cur.pos || 'center', display: 'block' }} />
            <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', boxShadow: 'inset 0 0 120px rgba(0,0,0,0.5)' }} />
          </div>
          {items.length > 1 && (<>
            <button aria-label="prev" onClick={() => go(-1)} style={arrow('left')}>‹</button>
            <button aria-label="next" onClick={() => go(1)} style={arrow('right')}>›</button>
          </>)}
          <div style={{
            position: 'absolute', top: 12, right: 12, zIndex: 6,
            fontFamily: 'var(--font-ui, monospace)', fontSize: 12, letterSpacing: '0.14em',
            color: '#efe4c2', background: 'rgba(6,4,10,0.6)', padding: '4px 10px',
            border: '1px solid rgba(200,160,76,0.4)',
          }}>{i + 1} / {items.length}</div>
        </div>
      </div>
      {cur.cap && (
        <div style={{
          marginTop: 14, textAlign: 'center', fontFamily: 'var(--font-ko)',
          fontStyle: 'italic', fontSize: 14, color: 'var(--night-soft)',
        }}>{cur.cap}</div>
      )}
      {/* thumbnail rail */}
      <div style={{ display: 'flex', gap: 10, justifyContent: 'center', marginTop: 18, flexWrap: 'wrap' }}>
        {items.map((it, idx) => (
          <button key={it.src} onClick={() => setI(idx)} aria-label={`screenshot ${idx + 1}`} style={{
            appearance: 'none', padding: 0, cursor: 'pointer',
            width: 104, height: 58, overflow: 'hidden', background: '#050308',
            border: `2px solid ${idx === i ? 'var(--gold)' : 'rgba(200,160,76,0.3)'}`,
            opacity: idx === i ? 1 : 0.6, transition: 'opacity 0.2s ease, border-color 0.2s ease',
          }}>
            <img src={it.src} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
          </button>
        ))}
      </div>
    </div>
  );
}

// ============================================================
// Chip — small label token
// ============================================================
function Chip({ children, dark }) {
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center',
      padding: '7px 14px',
      fontFamily: 'var(--font-ko)', fontWeight: 600, fontSize: 14,
      color: dark ? '#efe4c2' : 'var(--ink-iron)',
      background: dark ? 'rgba(232,222,170,0.05)' : 'rgba(42,24,16,0.05)',
      border: `1px solid ${dark ? 'rgba(200,160,76,0.35)' : 'rgba(90,61,40,0.4)'}`,
    }}>{children}</span>
  );
}

// ============================================================
// StaminaDemo — the rest-to-grow loop, playable
// ============================================================
function StaminaDemo({ lang }) {
  const s = useS2(lang);
  const [st, setSt] = React.useState(72);
  const [flash, setFlash] = React.useState(null); // {text, tone}
  const timer = React.useRef(null);

  const red = st <= 25;
  const tired = st <= 50 && !red;

  const say = (text, tone = 'gold') => {
    setFlash({ text, tone });
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setFlash(null), 2600);
  };

  const act = (cost, name) => {
    if (red) return;
    const eff = tired ? (lang === 'ko' ? ' (효율 감소)' : ' (reduced yield)') : '';
    setSt(v => Math.max(0, v - cost));
    say(`${name}${eff}  −${cost}`, tired ? 'amber' : 'plain');
  };
  const fire = () => {
    if (red) { say(s('st_fire_blocked'), 'red'); return; }
    setSt(v => Math.min(100, v + 22));
    say(`${s('st_rest_fire')}  +22`, 'gold');
  };
  const sleep = () => {
    setSt(100);
    say(s('st_bonus'), 'gold');
  };

  const barColor = red ? '#a83232' : tired ? '#c8a04c' : '#6a9a4a';
  const stateText = red ? s('st_state_red') : tired ? s('st_state_tired') : s('st_state_ok');

  const btn = (enabled) => ({
    appearance: 'none', cursor: enabled ? 'pointer' : 'not-allowed',
    fontFamily: 'var(--font-ko)', fontWeight: 700, fontSize: 14,
    padding: '11px 16px',
    background: 'rgba(232,222,170,0.04)',
    color: enabled ? '#efe4c2' : 'rgba(239,228,194,0.35)',
    border: `1px solid ${enabled ? 'rgba(200,160,76,0.4)' : 'rgba(200,160,76,0.15)'}`,
    transition: 'all 0.2s ease',
  });

  return (
    <div style={{
      maxWidth: 620, margin: '0 auto', padding: '30px 30px 26px',
      background: 'rgba(5,3,9,0.55)',
      border: '1px solid rgba(200,160,76,0.3)',
      boxShadow: '0 30px 60px -30px rgba(0,0,0,0.8)',
    }}>
      <div style={{
        fontFamily: 'var(--font-body-sc)', fontSize: 12, letterSpacing: '0.3em',
        color: 'var(--gold)', textAlign: 'center', marginBottom: 20,
      }}>{s('st_title')}</div>

      {/* bar */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
        <span style={{ fontFamily: 'var(--font-ko)', fontWeight: 700, fontSize: 13, color: '#c3b795', whiteSpace: 'nowrap' }}>
          {s('st_bar')}
        </span>
        <div style={{
          flex: 1, height: 18, background: 'rgba(0,0,0,0.55)',
          border: '1px solid rgba(200,160,76,0.35)', position: 'relative', overflow: 'hidden',
        }}>
          {/* red-zone marker */}
          <div style={{ position: 'absolute', left: '25%', top: 0, bottom: 0, width: 1, background: 'rgba(200,80,80,0.6)', zIndex: 2 }} />
          <div style={{
            position: 'absolute', left: 0, top: 0, bottom: 0, width: `${st}%`,
            background: `linear-gradient(180deg, ${barColor}, color-mix(in oklab, ${barColor} 62%, #000))`,
            transition: 'width 0.45s ease, background 0.45s ease',
          }} />
        </div>
        <span style={{ fontFamily: 'var(--font-ui, monospace)', fontSize: 13, color: barColor, minWidth: 38, textAlign: 'right', fontWeight: 700 }}>
          {st}
        </span>
      </div>
      <div style={{
        marginTop: 8, textAlign: 'center', fontFamily: 'var(--font-ko)', fontStyle: 'italic',
        fontSize: 13.5, color: red ? '#d88a8a' : tired ? '#d4b86a' : '#9ab884',
        transition: 'color 0.3s ease',
      }}>{stateText}</div>

      {/* actions */}
      <div style={{ display: 'flex', gap: 10, justifyContent: 'center', marginTop: 18, flexWrap: 'wrap' }}>
        <button style={btn(!red)} onClick={() => act(18, s('st_act_farm'))}>⚘ {s('st_act_farm')}</button>
        <button style={btn(!red)} onClick={() => act(12, s('st_act_fish'))}>⚓ {s('st_act_fish')}</button>
        <button style={btn(!red)} onClick={() => act(24, s('st_act_fight'))}>⚔ {s('st_act_fight')}</button>
      </div>
      <div style={{ display: 'flex', gap: 10, justifyContent: 'center', marginTop: 10 }}>
        <button style={{ ...btn(!red), borderColor: red ? 'rgba(200,160,76,0.15)' : 'rgba(217,119,87,0.55)' }} onClick={fire}>
          ☲ {s('st_rest_fire')}
        </button>
        <button style={{ ...btn(true), background: 'var(--gold)', color: '#1a0e02', borderColor: 'var(--gold)' }} onClick={sleep}>
          ✦ {s('st_rest_sleep')}
        </button>
      </div>

      {/* flash line */}
      <div style={{
        marginTop: 16, minHeight: 20, textAlign: 'center',
        fontFamily: 'var(--font-ko)', fontSize: 14, fontWeight: 600,
        color: flash?.tone === 'red' ? '#d88a8a' : flash?.tone === 'amber' ? '#d4b86a' : flash?.tone === 'gold' ? 'var(--gold-hi)' : '#c3b795',
        opacity: flash ? 1 : 0, transition: 'opacity 0.3s ease',
      }}>
        {flash?.text}
      </div>
    </div>
  );
}

// ============================================================
// LoadoutDemo — same character, different gear = different role
// ============================================================
function LoadoutDemo({ lang }) {
  const s = useS2(lang);
  const [k, setK] = React.useState('tank');
  const opts = [
    { k: 'tank', gear: s('ro_tank_gear'), role: s('ro_tank_role'), d: s('ro_tank_d') },
    { k: 'mage', gear: s('ro_mage_gear'), role: s('ro_mage_role'), d: s('ro_mage_d') },
    { k: 'archer', gear: s('ro_archer_gear'), role: s('ro_archer_role'), d: s('ro_archer_d') },
  ];
  const cur = opts.find(o => o.k === k);

  return (
    <div style={{
      display: 'grid', gridTemplateColumns: '300px 1fr', gap: 40, alignItems: 'center',
      maxWidth: 860, margin: '0 auto',
    }}>
      {/* character stage */}
      <div style={{
        position: 'relative', height: 320,
        background: 'radial-gradient(ellipse 70% 55% at 50% 68%, rgba(217,119,87,0.16), transparent 70%), radial-gradient(ellipse 90% 80% at 50% 50%, #131022 0%, #08060f 100%)',
        border: '1px solid rgba(200,160,76,0.3)',
        display: 'grid', placeItems: 'center', overflow: 'hidden',
      }}>
        {/* distant foes — small monsters behind the hero */}
        {[
          { r: 26, b: 58, s: 36, o: 0.5 },
          { r: 76, b: 92, s: 27, o: 0.4 },
          { r: 14, b: 110, s: 22, o: 0.3 },
        ].map((m, i) => (
          <svg key={i} width={m.s} height={m.s} viewBox="0 0 40 40" style={{ position: 'absolute', right: m.r, bottom: m.b, opacity: m.o, zIndex: 1, pointerEvents: 'none' }}>
            <g fill="#0a0812" stroke="#c8a04c" strokeOpacity="0.3" strokeWidth="1">
              <ellipse cx="20" cy="31" rx="12" ry="8" />
              <circle cx="20" cy="21" r="9" />
              <path d="M 12 15 L 8 6 L 15 13 Z" />
              <path d="M 28 15 L 32 6 L 25 13 Z" />
            </g>
            <circle cx="16" cy="22" r="1.6" fill="#d97757" />
            <circle cx="24" cy="22" r="1.6" fill="#d97757" />
          </svg>
        ))}
        {/* ground */}
        <div style={{ position: 'absolute', left: 0, right: 0, bottom: 46, height: 1, background: 'rgba(200,160,76,0.25)' }} />
        <div style={{ transform: 'translateX(-34px)', position: 'relative', zIndex: 2 }}>
          <CharacterFigure kind={k} />
        </div>
        <div style={{
          position: 'absolute', bottom: 14, left: 0, right: 0, textAlign: 'center',
          fontFamily: 'var(--font-body-sc)', fontSize: 11, letterSpacing: '0.26em',
          color: 'var(--gold)', opacity: 0.85,
        }}>
          {cur.role.toUpperCase?.() ?? cur.role}{s('ro_same')}
        </div>
      </div>

      {/* loadout buttons */}
      <div>
        <div style={{
          fontFamily: 'var(--font-body-sc)', fontSize: 12, letterSpacing: '0.3em',
          color: 'var(--gold)', marginBottom: 16,
        }}>{s('ro_try')}</div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          {opts.map(o => {
            const on = o.k === k;
            return (
              <button key={o.k} onClick={() => setK(o.k)} style={{
                appearance: 'none', cursor: 'pointer', textAlign: 'left',
                padding: '16px 20px',
                background: on ? 'rgba(200,160,76,0.1)' : 'rgba(232,222,170,0.03)',
                border: `1.5px solid ${on ? 'var(--gold)' : 'rgba(200,160,76,0.25)'}`,
                transition: 'all 0.25s ease',
                transform: on ? 'translateX(6px)' : 'none',
              }}>
                <div style={{
                  fontFamily: 'var(--font-ko)', fontSize: 13.5, color: '#c3b795', marginBottom: 4,
                }}>{o.gear}</div>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 12 }}>
                  <span style={{
                    fontFamily: 'var(--font-ko)', fontWeight: 900, fontSize: 19,
                    color: on ? 'var(--gold-hi)' : '#efe4c2',
                  }}>→ {o.role}</span>
                  <span style={{ fontFamily: 'var(--font-ko)', fontSize: 13, color: 'var(--night-soft)' }}>{o.d}</span>
                </div>
              </button>
            );
          })}
        </div>
      </div>
    </div>
  );
}

// The character silhouette with gear variants
function CharacterFigure({ kind }) {
  return (
    <svg width="200" height="240" viewBox="0 0 200 240" style={{ position: 'relative', zIndex: 2 }}>
      <defs>
        <filter id="cf-glow" x="-60%" y="-60%" width="220%" height="220%">
          <feGaussianBlur stdDeviation="2.5" result="b" />
          <feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge>
        </filter>
      </defs>
      {/* shadow */}
      <ellipse cx="100" cy="196" rx="46" ry="8" fill="#000" opacity="0.55" />

      {/* BODY — same in all loadouts */}
      <g fill="#0a0812" stroke="#c8a04c" strokeOpacity="0.35" strokeWidth="1">
        <circle cx="100" cy="86" r="15" />
        <path d="M 84 100 Q 82 96 88 96 L 112 96 Q 118 96 116 100 L 112 160 L 88 160 Z" />
        <path d="M 90 160 L 86 194 L 96 194 L 99 164 Z" />
        <path d="M 110 160 L 114 194 L 104 194 L 101 164 Z" />
      </g>

      {kind === 'tank' && (
        <g>
          {/* heavy pauldrons + helm crest */}
          <path d="M 80 100 Q 74 94 82 92 L 92 94 L 90 104 Z" fill="#1c1626" stroke="#c8a04c" strokeOpacity="0.5" strokeWidth="1" />
          <path d="M 120 100 Q 126 94 118 92 L 108 94 L 110 104 Z" fill="#1c1626" stroke="#c8a04c" strokeOpacity="0.5" strokeWidth="1" />
          <path d="M 88 78 Q 100 66 112 78 L 112 86 L 88 86 Z" fill="#1c1626" stroke="#c8a04c" strokeOpacity="0.5" strokeWidth="1" />
          <path d="M 100 66 L 100 78" stroke="#a83232" strokeWidth="3" />
          {/* round shield left */}
          <circle cx="66" cy="128" r="24" fill="#141020" stroke="#c8a04c" strokeOpacity="0.7" strokeWidth="2" />
          <circle cx="66" cy="128" r="15" fill="none" stroke="#c8a04c" strokeOpacity="0.4" strokeWidth="1" />
          <circle cx="66" cy="128" r="4" fill="#c8a04c" opacity="0.7" />
          {/* sword right */}
          <line x1="138" y1="88" x2="138" y2="150" stroke="#d4c9a8" strokeWidth="4" filter="url(#cf-glow)" opacity="0.9" />
          <line x1="130" y1="102" x2="146" y2="102" stroke="#c8a04c" strokeWidth="4" />
          <line x1="138" y1="150" x2="138" y2="160" stroke="#6a4a18" strokeWidth="5" />
        </g>
      )}

      {kind === 'mage' && (
        <g>
          {/* robe */}
          <path d="M 84 100 L 112 100 L 122 194 L 74 194 Z" fill="#0e0a1a" stroke="#c8a04c" strokeOpacity="0.4" strokeWidth="1" />
          {/* hood */}
          <path d="M 82 84 Q 82 64 98 64 Q 114 64 114 84 Q 106 76 98 76 Q 90 76 82 84 Z" fill="#0e0a1a" stroke="#c8a04c" strokeOpacity="0.4" strokeWidth="1" />
          {/* right arm reaching to grip the staff */}
          <path d="M 108 112 Q 120 116 125 127" fill="none" stroke="#0e0a1a" strokeWidth="7" strokeLinecap="round" />
          {/* gripping hand */}
          <circle cx="126" cy="128" r="4.5" fill="#0e0a1a" stroke="#c8a04c" strokeOpacity="0.5" strokeWidth="1" />
          {/* staff, held upright beside the mage */}
          <line x1="127" y1="196" x2="120" y2="52" stroke="#6a4a18" strokeWidth="4" />
          <line x1="127" y1="196" x2="120" y2="52" stroke="#8a6a28" strokeWidth="1.4" opacity="0.7" />
          {/* orb at the staff tip — the magic source */}
          <circle cx="119" cy="48" r="9" fill="#9ad4ff" filter="url(#cf-glow)" opacity="0.95" />
          <circle cx="119" cy="48" r="17" fill="#9ad4ff" opacity="0.18" />
          {/* magic streaming OUT of the staff toward the foes (same sparkle shape) */}
          <path d="M 122 46 Q 146 32 174 40" fill="none" stroke="#9ad4ff" strokeWidth="1.2" opacity="0.32" strokeLinecap="round" />
          <g fill="#9ad4ff" filter="url(#cf-glow)">
            <path d="M 134 41 l 2.2 5.4 5.4 2.2 -5.4 2.2 -2.2 5.4 -2.2 -5.4 -5.4 -2.2 5.4 -2.2 Z" opacity="0.9" />
            <path d="M 154 34 l 1.7 4.2 4.2 1.7 -4.2 1.7 -1.7 4.2 -1.7 -4.2 -4.2 -1.7 4.2 -1.7 Z" opacity="0.7" />
            <path d="M 170 40 l 1.3 3.2 3.2 1.3 -3.2 1.3 -1.3 3.2 -1.3 -3.2 -3.2 -1.3 3.2 -1.3 Z" opacity="0.55" />
          </g>
        </g>
      )}

      {kind === 'archer' && (
        <g>
          {/* light hood + cape */}
          <path d="M 86 82 Q 88 66 100 66 Q 112 66 114 82 L 106 78 L 94 78 Z" fill="#101322" stroke="#c8a04c" strokeOpacity="0.4" strokeWidth="1" />
          <path d="M 84 100 Q 70 140 80 186 L 88 186 L 90 120 Z" fill="#101322" opacity="0.9" />
          {/* bow */}
          <path d="M 140 80 Q 166 128 140 176" fill="none" stroke="#d4c9a8" strokeWidth="3.5" opacity="0.9" />
          <line x1="140" y1="80" x2="140" y2="176" stroke="#c8a04c" strokeWidth="1.2" opacity="0.8" />
          {/* nocked arrow */}
          <line x1="108" y1="128" x2="152" y2="128" stroke="#c8a04c" strokeWidth="2.5" filter="url(#cf-glow)" />
          <path d="M 152 128 L 144 123 M 152 128 L 144 133" stroke="#c8a04c" strokeWidth="2" />
          {/* quiver */}
          <path d="M 74 104 L 84 100 L 88 136 L 78 140 Z" fill="#141020" stroke="#c8a04c" strokeOpacity="0.4" strokeWidth="1" />
          <line x1="78" y1="102" x2="74" y2="92" stroke="#c8a04c" strokeWidth="1.6" />
          <line x1="82" y1="101" x2="80" y2="90" stroke="#c8a04c" strokeWidth="1.6" />
        </g>
      )}
    </svg>
  );
}

// ============================================================
// RaceSigil — tiny icons for the four races
// ============================================================
function RaceSigil({ kind, size = 34, color = 'var(--ink-rust)' }) {
  const st = { fill: 'none', stroke: color, strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round' };
  return (
    <svg width={size} height={size} viewBox="0 0 36 36">
      {kind === 'human' && (
        <g {...st}>
          <path d="M 18 8 Q 10 5 6 8 L 6 28 Q 10 25 18 28 Q 26 25 30 28 L 30 8 Q 26 5 18 8 Z" />
          <path d="M 18 8 L 18 28" />
          <path d="M 10 13 L 15 13 M 10 17 L 15 17 M 21 13 L 26 13 M 21 17 L 26 17" opacity="0.7" />
        </g>
      )}
      {kind === 'orc' && (
        <g {...st}>
          <path d="M 18 4 L 18 32" strokeWidth="2.2" />
          <path d="M 10 10 L 26 10 M 12 16 L 24 16 M 14 22 L 22 22" strokeWidth="2" />
          <circle cx="18" cy="28" r="3" fill={color} stroke="none" opacity="0.8" />
          <path d="M 10 10 L 8 6 M 26 10 L 28 6" opacity="0.7" />
        </g>
      )}
      {kind === 'undead' && (
        <g {...st}>
          <path d="M 18 6 Q 8 6 8 16 Q 8 22 12 24 L 12 28 L 24 28 L 24 24 Q 28 22 28 16 Q 28 6 18 6 Z" />
          <circle cx="14" cy="16" r="2.4" fill={color} stroke="none" />
          <circle cx="22" cy="16" r="2.4" fill={color} stroke="none" />
          <path d="M 15 28 L 15 31 M 18 28 L 18 32 M 21 28 L 21 31" opacity="0.8" />
        </g>
      )}
      {kind === 'elf' && (
        <g {...st}>
          <path d="M 18 32 Q 6 22 10 10 Q 18 6 26 10 Q 30 22 18 32 Z" />
          <path d="M 18 30 L 18 12 M 18 18 Q 13 16 12 12 M 18 22 Q 23 20 24 16" opacity="0.8" />
        </g>
      )}
    </svg>
  );
}

// ============================================================
// NeigongDemo — pour inner energy (內功) into a basic skill to enhance it
// ============================================================
function NeigongDemo({ lang }) {
  const s = useS2(lang);
  const [tier, setTier] = React.useState(0);
  const tiers = [
    { name: s('ne_t0_name'), d: s('ne_t0_d') },
    { name: s('ne_t1_name'), d: s('ne_t1_d') },
    { name: s('ne_t2_name'), d: s('ne_t2_d') },
    { name: s('ne_t3_name'), d: s('ne_t3_d') },
  ];
  const cur = tiers[tier];
  const pct = (tier / 3) * 100;

  return (
    <div style={{
      display: 'grid', gridTemplateColumns: '1fr 340px', gap: 40, alignItems: 'center',
      maxWidth: 900, margin: '0 auto',
    }}>
      {/* skill stage */}
      <div style={{
        position: 'relative', height: 320, overflow: 'hidden',
        background: 'radial-gradient(ellipse 70% 60% at 40% 60%, rgba(200,160,76,0.14), transparent 70%), radial-gradient(ellipse 90% 80% at 50% 50%, #14101f 0%, #08060f 100%)',
        border: '1px solid rgba(200,160,76,0.3)',
      }}>
        {/* ground */}
        <div style={{ position: 'absolute', left: 0, right: 0, bottom: 60, height: 1, background: 'rgba(200,160,76,0.22)' }} />
        {/* swordsman + slash, redraws on tier change */}
        <svg key={tier} width="100%" height="100%" viewBox="0 0 420 320" style={{ position: 'absolute', inset: 0 }}>
          <defs>
            <filter id="ne-glow" x="-60%" y="-60%" width="220%" height="220%">
              <feGaussianBlur stdDeviation="4" result="b" /><feMerge><feMergeNode in="b" /><feMergeNode in="SourceGraphic" /></feMerge>
            </filter>
            <linearGradient id="ne-arc" x1="0" y1="0" x2="1" y2="0">
              <stop offset="0%" stopColor="#fff4c8" /><stop offset="55%" stopColor="#f0d078" /><stop offset="100%" stopColor="#c8862c" />
            </linearGradient>
          </defs>
          {/* swordsman silhouette (facing right) */}
          <g transform="translate(120 250)" fill="#05040c" stroke="#c8a04c" strokeOpacity="0.35" strokeWidth="1">
            <ellipse cx="0" cy="8" rx="34" ry="9" fill="#000" stroke="none" opacity="0.5" />
            <circle cx="0" cy="-96" r="13" />
            <path d="M -12 -84 L 12 -84 L 9 -20 L -9 -20 Z" />
            <path d="M -9 -20 L -13 6 L -5 6 L 0 -18 Z" />
            <path d="M 9 -20 L 14 6 L 6 6 L 2 -18 Z" />
            {/* raised sword arm */}
            <path d="M 8 -78 L 34 -104" stroke="#05040c" strokeWidth="7" strokeLinecap="round" />
          </g>
          {/* sword blade (qi-lit by tier) */}
          <line x1="154" y1="146" x2="196" y2="104" stroke={tier === 0 ? '#8a8f9c' : 'url(#ne-arc)'} strokeWidth={3 + tier} strokeLinecap="round"
                filter={tier > 0 ? 'url(#ne-glow)' : 'none'} />

          {/* TIER 0: small plain arc */}
          {tier === 0 && (
            <path d="M 176 96 A 60 60 0 0 1 214 150" fill="none" stroke="#aeb4c2" strokeWidth="3" strokeLinecap="round" opacity="0.85">
              <animate attributeName="opacity" values="0;0.9;0.85" dur="0.5s" />
            </path>
          )}
          {/* TIER 1: bigger glowing arc */}
          {tier >= 1 && (
            <path d="M 172 78 A 92 92 0 0 1 232 176" fill="none" stroke="url(#ne-arc)" strokeWidth={4 + tier} strokeLinecap="round" filter="url(#ne-glow)">
              <animate attributeName="stroke-dasharray" values="0 400;400 0" dur="0.5s" fill="freeze" />
            </path>
          )}
          {/* TIER 2: projected crescent flying right */}
          {tier >= 2 && (
            <path d="M 250 150 q 44 -34 92 0 q -44 22 -92 0 Z" fill="url(#ne-arc)" filter="url(#ne-glow)" opacity="0.9">
              <animateTransform attributeName="transform" type="translate" values="-30 0;60 0" dur="0.7s" fill="freeze" />
              <animate attributeName="opacity" values="0;1;0.85" dur="0.7s" fill="freeze" />
            </path>
          )}
          {/* TIER 3: wide radial flash + second crescent */}
          {tier >= 3 && (
            <g filter="url(#ne-glow)">
              <circle cx="210" cy="150" r="120" fill="none" stroke="#fff4c8" strokeWidth="3" opacity="0.5">
                <animate attributeName="r" values="20;150" dur="0.6s" fill="freeze" />
                <animate attributeName="opacity" values="0.9;0" dur="0.7s" fill="freeze" />
              </circle>
              <path d="M 250 150 q 60 -46 122 0 q -60 30 -122 0 Z" fill="url(#ne-arc)" opacity="0.9">
                <animateTransform attributeName="transform" type="translate" values="-20 0;40 0" dur="0.6s" fill="freeze" />
              </path>
              {[...Array(7)].map((_, i) => {
                const a = (-50 + i * 16) * Math.PI / 180;
                return <line key={i} x1="210" y1="150" x2={210 + Math.cos(a) * 150} y2={150 + Math.sin(a) * 150}
                             stroke="#f0d078" strokeWidth="1.5" opacity="0.4">
                  <animate attributeName="opacity" values="0.7;0" dur="0.7s" fill="freeze" /></line>;
              })}
            </g>
          )}
          {/* star rating */}
          <g transform="translate(300 40)">
            {[0, 1, 2].map(i => (
              <text key={i} x={i * 22} y="0" fontSize="20" fill={i < tier ? '#f0d078' : '#3a3020'}
                    filter={i < tier ? 'url(#ne-glow)' : 'none'}>★</text>
            ))}
          </g>
        </svg>

        {/* skill label */}
        <div style={{
          position: 'absolute', top: 16, left: 20,
          fontFamily: 'var(--font-body-sc)', fontSize: 12, letterSpacing: '0.24em',
          color: 'var(--gold)', opacity: 0.9,
        }}>{s('ne_skill')}</div>
        {/* tier name + desc */}
        <div style={{ position: 'absolute', bottom: 16, left: 20, right: 20 }}>
          <div style={{ fontFamily: 'var(--font-ko)', fontWeight: 900, fontSize: 19, color: '#f0d078' }}>{cur.name}</div>
          <div style={{ fontFamily: 'var(--font-ko)', fontSize: 13.5, color: '#c3b795', marginTop: 3, lineHeight: 1.5 }}>{cur.d}</div>
        </div>
      </div>

      {/* qi gauge + controls */}
      <div>
        <div style={{ fontFamily: 'var(--font-body-sc)', fontSize: 12, letterSpacing: '0.3em', color: 'var(--gold)', marginBottom: 16 }}>
          {s('ne_try')}
        </div>
        {/* gauge */}
        <div style={{ position: 'relative', height: 14, background: 'rgba(232,222,170,0.06)', border: '1px solid rgba(200,160,76,0.3)', marginBottom: 8 }}>
          <div style={{
            position: 'absolute', inset: 0, width: `${pct}%`,
            background: 'linear-gradient(90deg, #8a6a28, #f0d078)', transition: 'width 0.4s ease',
            boxShadow: '0 0 14px rgba(240,208,120,0.6)',
          }} />
          {[1, 2].map(i => (
            <div key={i} style={{ position: 'absolute', top: -3, bottom: -3, left: `${(i / 3) * 100}%`, width: 1, background: 'rgba(200,160,76,0.4)' }} />
          ))}
        </div>
        <div style={{ display: 'flex', justifyContent: 'space-between', fontFamily: 'var(--font-body-sc)', fontSize: 10, letterSpacing: '0.18em', color: 'var(--night-soft)', marginBottom: 22 }}>
          <span>{lang === 'ko' ? '내공' : 'QI'}</span>
          <span>{'★'.repeat(tier)}{'☆'.repeat(3 - tier)}</span>
        </div>
        <div style={{ display: 'flex', gap: 10 }}>
          <button onClick={() => setTier(Math.min(3, tier + 1))} disabled={tier >= 3}
            className="cta cta-primary on-dark" style={{ flex: 1, justifyContent: 'center', padding: '13px 16px', opacity: tier >= 3 ? 0.4 : 1, cursor: tier >= 3 ? 'default' : 'pointer' }}>
            {s('ne_channel')} ⟳
          </button>
          <button onClick={() => setTier(0)} disabled={tier === 0}
            className="cta cta-secondary" style={{ padding: '13px 16px', color: '#c3b795', border: '1px solid rgba(200,160,76,0.4)', opacity: tier === 0 ? 0.35 : 1, cursor: tier === 0 ? 'default' : 'pointer' }}>
            {s('ne_release')}
          </button>
        </div>
        <p style={{ marginTop: 20, fontFamily: 'var(--font-ko)', fontSize: 13, lineHeight: 1.65, color: 'var(--night-soft)' }}>
          {s('ne_note')}
        </p>
      </div>
    </div>
  );
}

// ============================================================
// FeatureGrid — multiple game-feature cards with clean line icons
// ============================================================
function FeatureIcon({ kind, size = 34 }) {
  const p = { fill: 'none', stroke: 'var(--gold)', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' };
  return (
    <svg width={size} height={size} viewBox="0 0 40 40">
      {kind === 'role' && <g {...p}><path d="M 8 15 h 20 l -5 -5 M 32 25 h -20 l 5 5" /><circle cx="20" cy="20" r="15" opacity="0.28" /></g>}
      {kind === 'qi' && <g {...p}><path d="M 20 20 m 0 -12 a 12 12 0 1 1 -8.5 3.5 a 8 8 0 1 0 5 -1.5 a 4 4 0 1 1 -2.5 1" /><circle cx="20" cy="20" r="1.6" fill="var(--gold)" stroke="none" /></g>}
      {kind === 'rest' && <g {...p}><path d="M 26 22 A 11 11 0 1 1 18 8 a 9 9 0 0 0 8 14 Z" /><path d="M 28 10 l 1.2 3 3 1.2 -3 1.2 -1.2 3 -1.2 -3 -3 -1.2 3 -1.2 Z" fill="var(--gold)" stroke="none" opacity="0.8" /></g>}
      {kind === 'gem' && <g {...p}><path d="M 12 9 h 16 l 6 8 -14 15 -14 -15 Z" /><path d="M 6 17 h 28 M 16 9 l -4 8 8 15 M 24 9 l 4 8 -8 15" opacity="0.55" /></g>}
      {kind === 'touch' && <g {...p}><path d="M 16 20 V 11 a 2.5 2.5 0 0 1 5 0 v 8 l 6 2 a 3 3 0 0 1 2 3 l -1 6 a 4 4 0 0 1 -4 3 h -5 a 5 5 0 0 1 -4 -2 l -6 -8 a 2.5 2.5 0 0 1 4 -3 l 3 3" /></g>}
      {kind === 'coop' && <g {...p}><circle cx="20" cy="12" r="4" /><circle cx="11" cy="26" r="4" /><circle cx="29" cy="26" r="4" /><path d="M 20 16 v 6 M 15 24 l 3 -3 M 25 24 l -3 -3" opacity="0.55" /></g>}
    </svg>
  );
}

function FeatureGrid({ lang }) {
  const s = useS2(lang);
  const cards = [
    { icon: 'role', h: s('ft_1h'), p: s('ft_1p') },
    { icon: 'coop', h: s('ft_6h'), p: s('ft_6p') },
  ];
  return (
    <div className="r-features" style={{
      display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 360px))', gap: 18,
      justifyContent: 'center', maxWidth: 1000, margin: '0 auto',
      // r-features hook below via wrapper className
    }}>
      {cards.map((c, i) => (
        <div key={i} style={{
          padding: '28px 24px 26px',
          background: 'rgba(232,222,170,0.035)',
          border: '1px solid rgba(200,160,76,0.28)',
          transition: 'border-color 0.25s ease, transform 0.25s ease',
        }}>
          <div style={{ marginBottom: 16 }}><FeatureIcon kind={c.icon} /></div>
          <div style={{ fontFamily: 'var(--font-ko)', fontWeight: 900, fontSize: 18, color: '#efe4c2', marginBottom: 8 }}>{c.h}</div>
          <div style={{ fontFamily: 'var(--font-ko)', fontSize: 13.5, lineHeight: 1.65, color: 'var(--night-soft)' }}>{c.p}</div>
        </div>
      ))}
    </div>
  );
}

Object.assign(window, { WipeHero, FramedArt, ScrollArt, Gallery, Chip, StaminaDemo, LoadoutDemo, NeigongDemo, FeatureGrid, CharacterFigure, RaceSigil, FeatureIcon });


/* ==================== site2-hero-v2.jsx ==================== */
// site2-hero-v2.jsx — v2 hero: art-first.
// 3D expedition on the LEFT (original orientation), 2D town on the RIGHT;
// both parties stay centered in their visible strips. UI hugs the top and
// bottom edges, and every overlay fades to near-nothing while you drag.

function HeroV2({ lang, particles }) {
  const s = useS2(lang);
  const [drag, setDrag] = React.useState(false);
  const fade = { opacity: drag ? 0.08 : 1, transition: 'opacity 0.4s ease' };

  return (
    <header data-screen-label="Hero" style={{
      position: 'relative', height: '100vh', minHeight: 620, overflow: 'hidden',
    }}>
      <WipeHero
        lang={lang}
        initial={0.5}
        onDragChange={setDrag}
        labelsPos="edge"
        pos2d="center 46%"
        pos3d="30% center"
        focal3d={0.34}
      >
        {/* scrims — much lighter than v1; art stays visible */}
        <div style={{
          position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 2,
          background: 'linear-gradient(180deg, rgba(4,3,10,0.55) 0%, rgba(4,3,10,0) 20%, rgba(4,3,10,0) 60%, rgba(6,4,11,0.9) 100%)',
        }} />
      </WipeHero>

      {particles && <Fireflies count={10} opacity={0.6} seed={23} />}

      {/* (title lockup lives above the Concept section, not over the art) */}

      {/* bottom bar — drag hint + centered headline + copy */}
      <div style={{
        position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 6,
        padding: '14px 34px 24px',
        display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center',
        gap: 14, ...fade,
      }}>
        <div className="hero-drag-hint" style={{
          fontFamily: 'var(--font-ko)', fontStyle: 'italic', fontSize: 13,
          color: 'rgba(240,230,196,0.92)', textShadow: '0 1px 10px rgba(0,0,0,0.95)',
        }}>
          ↤ {s('hero_drag')} ↦
        </div>
        <div>
          <div style={{
            fontFamily: 'var(--font-ko)', fontWeight: 700,
            fontSize: 'clamp(17px, 2vw, 24px)', color: '#f0e6c4',
            textShadow: '0 2px 12px rgba(0,0,0,0.92)', letterSpacing: '0.02em',
          }}>
            {s('hero_tag')}
          </div>
          <div style={{
            margin: '8px auto 0', maxWidth: 560,
            fontFamily: 'var(--font-ko)', fontSize: 14, lineHeight: 1.6,
            color: '#cfc4a0', textShadow: '0 1px 8px rgba(0,0,0,0.9)', textWrap: 'balance',
          }}>
            {s('hero_sub')}
          </div>
        </div>
      </div>
    </header>
  );
}

window.HeroV2 = HeroV2;


/* ==================== site2-app.jsx (twinmere) ==================== */
// site2-app.jsx — page assembly: Twinmere overview site (from the spec)

function Site2App() {
  const [t, setTweak] = useTweaks({ ...(/*EDITMODE-BEGIN*/{
    lang: 'ko',
    particles: true,
  }/*EDITMODE-END*/), lang: detectInitialLang() });
  const lang = t.lang;
  // 언어 토글 시 사용자의 선택을 저장 → 다음 방문 때 자동감지보다 우선한다.
  const changeLang = React.useCallback((v) => {
    try { localStorage.setItem('tm-lang', v); } catch (e) {}
    setTweak('lang', v);
  }, [setTweak]);
  const s = useS2(lang);
  const [region, setRegion] = React.useState('central');
  const heroV2 = typeof window !== 'undefined' && !!window.__HERO_V2__;

  // 현재 언어를 전역에 공유 (logDemand가 lang 필드로 기록)
  React.useEffect(() => {
    window.__TM_LANG__ = lang;
    try { document.documentElement.lang = lang; } catch (e) {}
  }, [lang]);

  // GA4 페이지 계측: 스크롤 깊이(25/50/75/100%) + 사전예약 CTA 노출(twinmere_cta_view) 1회
  React.useEffect(() => {
    const fired = {};
    const onScroll = () => {
      const doc = document.documentElement;
      const max = doc.scrollHeight - window.innerHeight;
      if (max <= 0) return;
      const pct = (window.scrollY / max) * 100;
      [25, 50, 75, 100].forEach((mark) => {
        if (pct >= mark && !fired[mark]) {
          fired[mark] = true;
          trackGA('scroll_depth', { percent: mark });
        }
      });
    };
    window.addEventListener('scroll', onScroll, { passive: true });
    onScroll();

    let io = null;
    const cta = document.getElementById('signup');
    if (cta && 'IntersectionObserver' in window) {
      io = new IntersectionObserver((entries) => {
        if (entries.some((en) => en.isIntersecting)) {
          trackGA('twinmere_cta_view', { lang: window.__TM_LANG__ });
          if (io) { io.disconnect(); io = null; }
        }
      }, { threshold: 0.3 });
      io.observe(cta);
    }
    return () => {
      window.removeEventListener('scroll', onScroll);
      if (io) io.disconnect();
    };
  }, []);

  const regions = [
    { k: 'central', ko: '중부', en: 'Central',
      d: lang === 'ko' ? '온화한 기후 아래 여러 종족이 어우러져 사는 마을. 농사·낚시·사냥 — 모든 생활이 여기서 시작됩니다.' : 'Mild fields where the races live side by side. Farming, fishing, hunting — every life begins here.',
      ev: lang === 'ko' ? '장터 · 축제' : 'Markets · festivals' },
    { k: 'north', ko: '북부', en: 'North',
      d: lang === 'ko' ? '눈과 얼음의 혹한. 설원 몬스터와 냉기 마법 생물이 서식하고, 불 마법으로 언 땅을 잠시 녹여 희귀 작물을 거둡니다.' : 'Frost and ice. Snowfield monsters roam; melt the frozen ground with fire magic to coax out rare crops.',
      ev: lang === 'ko' ? '눈사태 · 몬스터 습격' : 'Avalanches · monster raids' },
    { k: 'south', ko: '남부', en: 'South',
      d: lang === 'ko' ? '무성한 정글과 늪. 열대 과일, 독버섯, 진귀한 약초 — 농사보다 채집이 지배하는 땅입니다.' : 'Dense jungle and swamp. Tropical fruit, toxic fungi, rare herbs — a land ruled by foraging, not farming.',
      ev: lang === 'ko' ? '폭우 · 자연재해' : 'Downpours · natural hazards' },
    { k: 'west', ko: '서부', en: 'West',
      d: lang === 'ko' ? '극심한 더위와 모래폭풍의 사막. 물 마법으로 식수를 얻고, 반쯤 묻힌 고대 유적과 희귀 광물 지대를 탐사합니다.' : 'Desert of heat and sandstorm. Draw water by magic; probe half-buried ruins and veins of rare ore.',
      ev: lang === 'ko' ? '모래폭풍 · 유적 탐사' : 'Sandstorms · ruin delves' },
    { k: 'east', ko: '동부', en: 'East',
      d: lang === 'ko' ? '무공과 주술이 흐르는 동양풍의 미지 세계. 강호 문파, 전통 축제, 약초와 차(茶)의 문화가 살아 숨쉽니다.' : 'An eastern realm of martial arts and shamanic craft — sects of the rivers and lakes, festivals, herbs and tea.',
      ev: lang === 'ko' ? '무공 대회 · 전통 축제' : 'Martial tournaments · festivals' },
  ];
  const curRegion = regions.find(r => r.k === region);

  const races = [
    { k: 'human', name: s('race_human'), m: s('race_human_m') },
    { k: 'orc', name: s('race_orc'), m: s('race_orc_m') },
    { k: 'undead', name: s('race_undead'), m: s('race_undead_m') },
    { k: 'elf', name: s('race_elf'), m: s('race_elf_m') },
  ];

  const principles = [
    { n: 'I', h: s('ph_1_h'), p: s('ph_1_p') },
    { n: 'II', h: s('ph_2_h'), p: s('ph_2_p') },
    { n: 'III', h: s('ph_3_h'), p: s('ph_3_p') },
    { n: 'IV', h: s('ph_4_h'), p: s('ph_4_p') },
    { n: 'V', h: s('ph_5_h'), p: s('ph_5_p'), big: true },
  ];

  return (
    <div style={{ background: '#07050c', minHeight: '100vh', fontFamily: 'var(--font-ko)' }}>
      <SignupPopup />
      <style>{`
        html { scroll-behavior: smooth; word-break: keep-all; }
        @keyframes cue-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(8px); } }
        .scroll-cue { animation: cue-bob 2.2s ease-in-out infinite; }
        /* short-viewport safety: drop non-essential hero lines so logo & CTA never collide */
        @media (max-height: 760px) {
          .hero-drag-hint { display: none; }
        }
        @media (max-height: 680px) {
          .hero-meta-line { display: none; }
          .hero-tagline { display: none; }
        }
      `}</style>

      {/* ============ NAV ============ */}
      <nav style={{
        position: 'fixed', top: 0, left: 0, right: 0, zIndex: 50,
        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        padding: '16px 34px',
        background: 'linear-gradient(180deg, rgba(4,3,8,0.9), rgba(4,3,8,0))',
        color: '#e8dfc0',
      }}>
        <div className="nav-brand" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
          <img className="nav-crest" src="assets/crest-logo.webp" alt="" style={{ height: 34, width: 'auto' }} />
          <span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 10 }}>
            <span className="nav-wordmark" style={{ fontFamily: 'var(--font-display)', fontSize: 16, letterSpacing: '0.32em', fontWeight: 700 }}>
              TWINMERE
            </span>
          </span>
        </div>
        <div className="nav-actions" style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
          <LangToggle lang={lang} onChange={changeLang} />
          <a href="#preregister" className="cta cta-primary on-dark" style={{ padding: '9px 18px', fontSize: 12 }}>
            {s('nav_cta')}
          </a>
        </div>
      </nav>

      {/* ============ HERO — the wipe ============ */}
      {heroV2 ? <HeroV2 lang={lang} particles={t.particles} /> : (
      <header data-screen-label="Hero" style={{
        position: 'relative', height: '100vh', minHeight: 640, overflow: 'hidden',
      }}>
        <WipeHero lang={lang}>
          {/* dark scrims for legibility */}
          <div style={{
            position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 2,
            background: 'linear-gradient(180deg, rgba(4,3,10,0.62) 0%, rgba(4,3,10,0.05) 36%, rgba(4,3,10,0.08) 58%, rgba(6,4,11,0.92) 100%)',
          }} />
        </WipeHero>

        {t.particles && <Fireflies count={12} opacity={0.7} seed={23} />}

        {/* logo + copy */}
        <div style={{
          position: 'absolute', top: 'clamp(48px, 11vh, 92px)', left: 0, right: 0, zIndex: 6,
          textAlign: 'center', pointerEvents: 'none', padding: '0 24px',
        }}>
          <GameLogo lang={lang} scale={1.05} night fluid crest />
          <div className="hero-tagline" style={{
            marginTop: 'clamp(10px, 2.4vh, 22px)', fontFamily: 'var(--font-ko)', fontWeight: 700,
            fontSize: 'clamp(15px, 1.6vw, 19px)', color: '#f0e6c4',
            textShadow: '0 2px 12px rgba(0,0,0,0.9)', letterSpacing: '0.04em',
          }}>
            {s('hero_tag')}
          </div>
          <div className="hero-drag-hint" style={{
            marginTop: 12, fontFamily: 'var(--font-ko)', fontStyle: 'italic', fontSize: 13.5,
            color: 'rgba(240,230,196,0.9)', textShadow: '0 1px 8px rgba(0,0,0,0.9)',
          }}>
            ↤ {s('hero_drag')} ↦
          </div>
        </div>

        {/* bottom center: sub + CTA + hint */}
        <div style={{
          position: 'absolute', bottom: 'clamp(36px, 8.5vh, 76px)', left: 0, right: 0, zIndex: 6,
          textAlign: 'center', padding: '0 24px', pointerEvents: 'none',
        }}>
          <p style={{
            margin: '0 auto 20px', maxWidth: 520, fontSize: 15.5, lineHeight: 1.7,
            color: '#ddd2ae', textShadow: '0 2px 10px rgba(0,0,0,0.9)', textWrap: 'balance',
          }}>
            {s('hero_sub')}
          </p>
          <div style={{ pointerEvents: 'auto', display: 'inline-block' }}>
            <ScrollSignup lang={lang} dark big />
          </div>
          <div className="hero-meta-line" style={{
            marginTop: 14, fontFamily: 'var(--font-body-sc)', fontSize: 11,
            letterSpacing: '0.24em', color: '#b8ab88',
          }}>
            {s('hero_meta')}
          </div>
        </div>

        {/* (drag hint lives under the tagline in the top block) */}
      </header>
      )}

      {/* ============ CONCEPT ============ */}
      <section data-screen-label="Concept" className="nightfall" style={{ position: 'relative', padding: '104px 34px' }}>
        <div style={{ position: 'relative', zIndex: 2, maxWidth: 880, margin: '0 auto', textAlign: 'center' }}>
          <Reveal>
            <div style={{ marginBottom: 40 }}>
              <GameLogo lang={lang} scale={1.3} night fluid />
            </div>
          </Reveal>
          <Reveal delay={40}>
            <SectionHead dark title={s('con_title')} />
          </Reveal>
          <Reveal delay={60}>
            {/* genre lockup */}
            <div style={{
              display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 18,
              flexWrap: 'wrap', marginBottom: 34,
            }}>
              <span style={{
                padding: '13px 22px', border: '1.5px solid rgba(200,160,76,0.5)',
                fontFamily: 'var(--font-ko)', fontWeight: 700, fontSize: 16, color: '#efe4c2',
                background: 'rgba(232,222,170,0.04)',
              }}>{s('con_genre_2d')}</span>
              <span style={{ fontFamily: 'var(--font-decor)', fontSize: 26, color: 'var(--gold)' }}>{s('con_genre_x')}</span>
              <span style={{
                padding: '13px 22px', border: '1.5px solid rgba(200,160,76,0.5)',
                fontFamily: 'var(--font-ko)', fontWeight: 700, fontSize: 16, color: '#efe4c2',
                background: 'rgba(232,222,170,0.04)',
              }}>{s('con_genre_3d')}</span>
            </div>
            <p style={{
              margin: '0 auto', maxWidth: 660, fontSize: 17, lineHeight: 1.85, color: '#c3b795',
              wordBreak: 'keep-all', textWrap: 'pretty',
            }}>{s('con_p')}</p>
          </Reveal>
          <Reveal delay={120}>
            <div id="preregister" style={{ marginTop: 42, scrollMarginTop: 88, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
              <ScrollSignup lang={lang} dark big />
              <div style={{
                fontFamily: 'var(--font-body-sc)', fontSize: 10.5,
                letterSpacing: '0.22em', color: 'var(--night-soft)',
              }}>
                {s('hero_meta')}
              </div>
            </div>
          </Reveal>
        </div>
      </section>

      {/* ============ 2D PART ============ */}
      <section data-screen-label="2D Part" className="nightfall" style={{ position: 'relative', padding: '104px 34px' }}>
        <div style={{ position: 'relative', zIndex: 2, maxWidth: 1150, margin: '0 auto' }}>
          <Reveal>
            <SectionHead dark eyebrow={s('p2_eyebrow')} title={s('p2_title')} />
          </Reveal>
          <Reveal delay={60}>
            <Gallery
              hint={lang === 'ko' ? '드래그하여 둘러보세요' : 'Drag to explore'}
              items={[
                { src: 'assets/art-2d-town.webp', alt: '2D town', cap: lang === 'ko' ? '마을 광장에서 이웃과 어울리는 시간' : 'Mingling in the town square' },
                { src: 'assets/art-2d-farm.webp', alt: '2D farm', cap: lang === 'ko' ? '마법으로 밭에 물을 주는 하루' : 'Watering the field with everyday magic' },
                { src: 'assets/art-2d-fishing.webp', alt: '2D fishing', cap: lang === 'ko' ? '숲속 연못에서의 한가로운 낚시' : 'A quiet cast at the forest pond' },
                { src: 'assets/art-2d-home.webp', alt: '2D home interior', pos: 'center 38%', cap: lang === 'ko' ? '나만의 집을 꾸미는 즐거움' : 'Make the home your own' },
              ]}
            />
          </Reveal>

          <div className="r-2col" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 44, marginTop: 56 }}>
            <Reveal delay={40}>
              <h3 style={{ margin: 0, fontFamily: 'var(--font-ko)', fontWeight: 900, fontSize: 22, color: '#efe4c2' }}>
                ⌂ {s('p2_private_h')}
              </h3>
              <p style={{ margin: '12px 0 0', fontSize: 15.5, lineHeight: 1.8, color: '#c3b795' }}>
                {s('p2_private_p')}
              </p>
            </Reveal>
            <Reveal delay={110}>
              <h3 style={{ margin: 0, fontFamily: 'var(--font-ko)', fontWeight: 900, fontSize: 22, color: '#efe4c2' }}>
                ⚘ {s('p2_town_h')}
              </h3>
              <p style={{ margin: '12px 0 0', fontSize: 15.5, lineHeight: 1.8, color: '#c3b795' }}>
                {s('p2_town_p')}
              </p>
            </Reveal>
          </div>

          {/* content chips */}
          <Reveal delay={80}>
            <div style={{ marginTop: 48, display: 'grid', gap: 16 }}>
              {[
                { label: s('p2_life_label'), items: s('p2_life') },
                { label: s('p2_skill_label'), items: s('p2_skill') },
                { label: s('p2_craft_label'), items: s('p2_craft') },
              ].map((row) => (
                <div key={row.label} style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
                  <span style={{
                    fontFamily: 'var(--font-body-sc)', fontSize: 12, letterSpacing: '0.3em',
                    color: 'var(--gold)', minWidth: 64,
                  }}>{row.label}</span>
                  <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
                    {row.items.map((c) => <Chip key={c} dark>{c}</Chip>)}
                  </div>
                </div>
              ))}
            </div>
          </Reveal>

          {/* portrait-play feature — mobile-first coziness */}
          <Reveal delay={120}>
            <div className="r-portrait" style={{
              marginTop: 64,
              display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 52, alignItems: 'center',
              maxWidth: 780, marginLeft: 'auto', marginRight: 'auto',
              padding: '42px 48px',
              background: 'rgba(232,222,170,0.04)',
              border: '1.5px solid rgba(200,160,76,0.28)',
              boxShadow: '0 20px 44px -22px rgba(0,0,0,0.6)',
            }}>
              {/* portrait phone — 2D art cropped tall, thumb-reach arc */}
              <div style={{ transform: 'rotate(-3deg)' }}>
                <MiniPhone width={158}>
                  <div style={{ position: 'absolute', inset: 0, overflow: 'hidden', background: '#0a0a10' }}>
                    <img src="assets/art-2d-town.webp" alt="" style={{
                      position: 'absolute', height: '100%', width: 'auto', left: '50%', transform: 'translateX(-50%)',
                    }} />
                    {/* legibility gradient */}
                    <div style={{
                      position: 'absolute', inset: 0,
                      background: 'linear-gradient(180deg, transparent 55%, rgba(6,4,10,0.55) 100%)',
                    }} />
                    {/* thumb-reach arc, bottom-right */}
                    <div style={{
                      position: 'absolute', right: -78, bottom: -78, width: 156, height: 156,
                      border: '1.5px dashed rgba(240,216,120,0.85)', borderRadius: '50%',
                    }} />
                    <div style={{
                      position: 'absolute', right: 22, bottom: 68, width: 9, height: 9, borderRadius: '50%',
                      background: '#f0d878', boxShadow: '0 0 10px rgba(240,216,120,0.9)',
                    }} />
                  </div>
                </MiniPhone>
              </div>
              <div>
                <h3 style={{
                  margin: 0, fontFamily: 'var(--font-ko)', fontWeight: 900,
                  fontSize: 'clamp(24px, 2.6vw, 32px)', color: '#efe4c2', lineHeight: 1.3,
                }}>
                  {s('p2_portrait_h')}
                </h3>
                <p style={{ margin: '14px 0 0', fontSize: 15.5, lineHeight: 1.8, color: '#c3b795' }}>
                  {s('p2_portrait_p')}
                </p>
                <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 18 }}>
                  {s('p2_portrait_chips').map((c) => <Chip key={c} dark>{c}</Chip>)}
                </div>
              </div>
            </div>
          </Reveal>
        </div>
      </section>

      {/* ============ 3D PART ============ */}
      <section data-screen-label="3D Part" className="nightfall" style={{ position: 'relative', padding: '104px 34px' }}>
        <div style={{ position: 'relative', zIndex: 2, maxWidth: 1150, margin: '0 auto' }}>
          <Reveal>
            <SectionHead dark eyebrow={s('p3_eyebrow')} title={s('p3_title')} sub={s('p3_p')} />
          </Reveal>
          <Reveal delay={60}>
            <Gallery
              hint={lang === 'ko' ? '드래그하여 둘러보세요' : 'Drag to explore'}
              items={[
                { src: 'assets/art-3d-combat.webp', alt: '3D co-op combat', cap: s('p3_art_caption') },
                { src: 'assets/art-3d-desert.webp', alt: '3D desert battle', cap: lang === 'ko' ? '서부 사막 — 고대 수호자와의 협동 전투' : 'West desert — co-op battle against an ancient guardian' },
                { src: 'assets/art-3d-snow.webp', alt: '3D snowfield battle', cap: lang === 'ko' ? '북부 설원 — 서리 트롤과 얼음 괴수 무리' : 'North frostlands — a frost troll and ice-beast pack' },
                { src: 'assets/art-3d-jungle.webp', alt: '3D jungle swamp battle', cap: lang === 'ko' ? '남부 정글 늪 — 도마뱀 부족과의 협동 전투' : 'South jungle swamp — co-op battle against a lizardfolk tribe' },
              ]}
            />
          </Reveal>

          <div className="r-2col" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 22, marginTop: 56 }}>
            <Reveal delay={40}>
              <div style={{
                height: '100%', padding: '26px 26px 24px',
                background: 'rgba(232,222,170,0.03)', border: '1px solid rgba(200,160,76,0.28)',
              }}>
                <div style={{ fontFamily: 'var(--font-ko)', fontWeight: 900, fontSize: 20, color: '#efe4c2' }}>
                  ⚔ {s('p3_combat_h')}
                </div>
                <p style={{ margin: '10px 0 0', fontSize: 15, lineHeight: 1.75, color: '#c3b795' }}>
                  {s('p3_combat_p')}
                </p>
              </div>
            </Reveal>
            <Reveal delay={110}>
              <div style={{
                height: '100%', padding: '26px 26px 24px',
                background: 'rgba(232,222,170,0.03)', border: '1px solid rgba(200,160,76,0.28)',
              }}>
                <div style={{ fontFamily: 'var(--font-ko)', fontWeight: 900, fontSize: 20, color: '#efe4c2' }}>
                  ⚙ {s('p3_puzzle_h')}
                </div>
                <p style={{ margin: '10px 0 0', fontSize: 15, lineHeight: 1.75, color: '#c3b795' }}>
                  {s('p3_puzzle_p')}
                </p>
              </div>
            </Reveal>
          </div>

          <Reveal delay={90}>
            <p style={{
              margin: '40px auto 0', maxWidth: 700, textAlign: 'center',
              fontSize: 16, lineHeight: 1.85, color: '#c3b795',
            }}>{s('p3_regions')}</p>
          </Reveal>
          {/* region tabs */}
          <Reveal delay={60}>
            <div style={{
              fontFamily: 'var(--font-body-sc)', fontSize: 12, letterSpacing: '0.3em',
              color: 'var(--gold)', textAlign: 'center', marginTop: 46, marginBottom: 18,
            }}>{s('wo_region_label')}</div>
            <div style={{ display: 'flex', justifyContent: 'center', gap: 8, flexWrap: 'wrap' }}>
              {regions.map((r) => {
                const on = r.k === region;
                return (
                  <button key={r.k} onClick={() => setRegion(r.k)} style={{
                    appearance: 'none', cursor: 'pointer',
                    display: 'inline-flex', alignItems: 'center', gap: 8,
                    padding: '11px 18px',
                    fontFamily: 'var(--font-ko)', fontWeight: 700, fontSize: 15,
                    color: on ? '#1a0e02' : '#efe4c2',
                    background: on ? 'var(--gold)' : 'rgba(232,222,170,0.04)',
                    border: `1.5px solid ${on ? 'var(--gold)' : 'rgba(200,160,76,0.3)'}`,
                    transition: 'all 0.25s ease',
                  }}>
                    <RegionIcon kind={r.k} color={on ? '#1a0e02' : 'var(--gold)'} />
                    {lang === 'ko' ? r.ko : r.en}
                  </button>
                );
              })}
            </div>
            {/* region panel */}
            <div style={{
              maxWidth: 720, margin: '26px auto 0', padding: '28px 34px',
              background: 'rgba(232,222,170,0.05)',
              border: '1px solid rgba(200,160,76,0.25)',
              boxShadow: '0 14px 30px -18px rgba(0,0,0,0.6)',
              textAlign: 'center', minHeight: 148,
            }}>
              <div style={{
                fontFamily: 'var(--font-ko)', fontWeight: 900, fontSize: 21, color: '#efe4c2',
              }}>
                {lang === 'ko' ? curRegion.ko : curRegion.en}
                <span style={{
                  fontFamily: 'var(--font-body-sc)', fontWeight: 400, fontSize: 12,
                  letterSpacing: '0.24em', marginLeft: 10, color: '#c3b795',
                }}>{lang === 'ko' ? curRegion.en.toUpperCase() : curRegion.ko}</span>
              </div>
              <p style={{ margin: '12px 0 0', fontSize: 15.5, lineHeight: 1.8, color: '#c3b795' }}>
                {curRegion.d}
              </p>
              <div style={{
                marginTop: 14, fontFamily: 'var(--font-body-sc)', fontSize: 12,
                letterSpacing: '0.2em', color: 'var(--gold)',
              }}>
                {lang === 'ko' ? '지역 이벤트' : 'REGIONAL EVENTS'} — {curRegion.ev}
              </div>
            </div>
          </Reveal>

          <Reveal delay={120}>
            <div style={{
              marginTop: 12, textAlign: 'center', fontFamily: 'var(--font-ko)', fontStyle: 'italic',
              fontSize: 13.5, color: 'var(--night-soft)',
            }}>
              {s('p3_anim_note')}
            </div>
          </Reveal>
        </div>
      </section>

      {/* ============ GAME FEATURES ============ */}
      <section data-screen-label="Features" style={{
        position: 'relative', padding: '104px 34px',
        background: 'linear-gradient(180deg, #07050c 0%, #0d0916 50%, #07050c 100%)',
      }}>
        <div style={{ position: 'relative', zIndex: 2, maxWidth: 1050, margin: '0 auto' }}>
          <Reveal>
            <SectionHead dark eyebrow={s('ft_eyebrow')} />
          </Reveal>
          <Reveal delay={100}>
            <FeatureGrid lang={lang} />
          </Reveal>
        </div>
      </section>

      {/* ============ POLL ============ */}
      <section data-screen-label="Poll" style={{
        position: 'relative', padding: '104px 34px',
        background: 'linear-gradient(180deg, #07050c 0%, #0c0916 55%, #07050c 100%)',
      }}>
        <div style={{ position: 'relative', zIndex: 2, maxWidth: 1150, margin: '0 auto' }}>
          <Reveal>
            <SectionHead dark eyebrow={s('po_eyebrow')} title={s('po_title')} sub={s('po_sub')} />
          </Reveal>
          <Reveal delay={80}>
            <Poll lang={lang} />
          </Reveal>
        </div>
      </section>

      {/* ============ SIGNUP ============ */}
      <section id="signup" data-screen-label="Signup" className="nightfall" style={{ position: 'relative', padding: '104px 34px 88px' }}>
        {t.particles && <Fireflies count={12} opacity={0.7} seed={31} />}
        <div style={{ position: 'relative', zIndex: 2, maxWidth: 760, margin: '0 auto', textAlign: 'center' }}>
          <Reveal>
            <SectionHead dark eyebrow={s('su_eyebrow')} title={s('su_title')} sub={s('su_p')} />
          </Reveal>
          <Reveal delay={60}>
            <ScrollSignup lang={lang} dark big />
            <div style={{
              marginTop: 18, fontFamily: 'var(--font-body-sc)', fontSize: 12,
              letterSpacing: '0.26em', color: 'var(--gold)', opacity: 0.8,
            }}>
              {s('hero_meta')}
            </div>
          </Reveal>
        </div>
      </section>

      {/* ============ FOOTER ============ */}
      <footer className="r-footer" style={{
        position: 'relative', padding: '32px 34px 38px',
        borderTop: '1px solid rgba(200,160,76,0.2)',
        background: '#050309',
        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        color: '#8a8268',
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
          <img src="assets/crest-logo.webp" alt="" style={{ height: 28, width: 'auto' }} />
          <span style={{ fontFamily: 'var(--font-body-sc)', fontSize: 11, letterSpacing: '0.26em' }}>
            BEDROCK INC. · 2026
          </span>
        </div>
        <div style={{
          fontFamily: 'var(--font-ko)', fontStyle: 'italic', fontSize: 12.5, opacity: 0.8,
          maxWidth: 420, textAlign: 'right', lineHeight: 1.6,
        }}>
          {s('footer_note')}
        </div>
      </footer>

      {/* ============ TWEAKS ============ */}
      <TweaksPanel title="Tweaks">
        <TweakSection label={lang === 'ko' ? '페이지' : 'Page'} />
        <TweakRadio
          label={lang === 'ko' ? '언어' : 'Language'}
          value={lang}
          options={['ko', 'en']}
          onChange={changeLang}
        />
        <TweakToggle
          label={lang === 'ko' ? '반딧불 입자' : 'Firefly particles'}
          value={t.particles}
          onChange={(v) => setTweak('particles', v)}
        />
        <TweakButton
          label={lang === 'ko' ? '투표·서명 기록 초기화' : 'Reset vote & signup'}
          onClick={() => {
            try {
              localStorage.removeItem('br-demand-vote');
              localStorage.removeItem('br-signup-email');
            } catch (e) {}
            location.reload();
          }}
        />
      </TweaksPanel>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<Site2App />);

