/* ===================================================================
   dmtools.jsx — DM authoring tools
     · YouTubeBar      shared audio-only player (3 UI variants)
     · CharacterComposer  add a custom enemy / ally (portrait + 3 notes)
     · GeofenceConfig  configure a map trigger zone
   Exposed on window for app.jsx.
   =================================================================== */
const { useState, useRef, useEffect } = React;

/* ---------- YouTube URL parsing ----------
   Returns { kind:'video'|'playlist', id, label } or null.
   Channel trick: /channel/UC… → uploads playlist UU… (no API key needed). */
function parseYouTube(raw) {
  if (!raw) return null;
  let url = raw.trim();
  if (!/^https?:\/\//.test(url)) url = 'https://' + url;
  let u; try { u = new URL(url); } catch { return null; }
  const host = u.hostname.replace(/^www\./, '');
  const qs = u.searchParams;
  // explicit playlist
  const list = qs.get('list');
  if (list && !/^(RDMM|RD)/.test(list)) return { kind: 'playlist', id: list, label: 'Playlist' };
  // youtu.be short
  if (host === 'youtu.be') { const id = u.pathname.slice(1); if (id) return { kind: 'video', id, label: 'Track' }; }
  if (host.endsWith('youtube.com') || host.endsWith('youtube-nocookie.com')) {
    const v = qs.get('v'); if (v) return { kind: 'video', id: v, label: 'Track' };
    const parts = u.pathname.split('/').filter(Boolean);
    if (parts[0] === 'embed' && parts[1]) return { kind: 'video', id: parts[1], label: 'Track' };
    if (parts[0] === 'shorts' && parts[1]) return { kind: 'video', id: parts[1], label: 'Track' };
    if (parts[0] === 'playlist' && list) return { kind: 'playlist', id: list, label: 'Playlist' };
    if (parts[0] === 'channel' && parts[1] && /^UC/.test(parts[1]))
      return { kind: 'playlist', id: 'UU' + parts[1].slice(2), label: 'Channel uploads' };
    // /@handle and /c/ and /user/ need an API key to resolve a channel id
    if (parts[0]?.startsWith('@') || parts[0] === 'c' || parts[0] === 'user')
      return { kind: 'handle', id: null, label: 'Channel' };
  }
  return null;
}
window.parseYouTube = parseYouTube;

/* ---------- shared YT iframe-API loader ---------- */
function ensureYT(cb) {
  if (window.YT && window.YT.Player) { cb(); return; }
  const prev = window.onYouTubeIframeAPIReady;
  window.onYouTubeIframeAPIReady = () => { prev && prev(); cb(); };
  if (!document.getElementById('yt-api-script')) {
    const s = document.createElement('script');
    s.id = 'yt-api-script'; s.src = 'https://www.youtube.com/iframe_api';
    document.body.appendChild(s);
  }
}

/* ---------- YouTubeBar: the table's shared audio ----------
   Audio-only (the video element is parked off-screen). DM drives it;
   players just see the now-playing readout. Registers an imperative
   API on controllerRef so geofences can start a track. */
function YouTubeBar({ isDM, style, controllerRef, onToast }) {
  const variant = style || 'bar';
  const holderRef = useRef(null);
  const playerRef = useRef(null);
  const [ready, setReady] = useState(false);
  const [open, setOpen] = useState(false);     // DM url popover
  const [draft, setDraft] = useState('');
  const [now, setNow] = useState(null);         // { title, kind, label }
  const [playing, setPlaying] = useState(false);
  const [vol, setVol] = useState(60);
  const [hint, setHint] = useState('');

  // build the hidden player once the API is ready
  useEffect(() => {
    ensureYT(() => {
      if (playerRef.current || !holderRef.current) return;
      // Mount the player on a child node WE create, not on a React-managed div.
      // The YT API replaces its target node with an <iframe>; if that target is
      // a React-owned element, the next render throws during reconciliation and
      // (without a boundary) unmounts the whole app. An imperative child node is
      // invisible to React, so the swap can't corrupt the tree.
      const mount = document.createElement('div');
      holderRef.current.appendChild(mount);
      playerRef.current = new window.YT.Player(mount, {
        height: '120', width: '200',
        playerVars: { playsinline: 1, controls: 0, disablekb: 1 },
        events: {
          onReady: () => { setReady(true); try { playerRef.current.setVolume(vol); } catch {} },
          onStateChange: (e) => {
            const YT = window.YT;
            setPlaying(e.data === YT.PlayerState.PLAYING);
            try {
              const d = playerRef.current.getVideoData();
              if (d && d.title) setNow(n => ({ ...(n || {}), title: d.title }));
            } catch {}
          },
          onError: () => { setHint('That track could not be played — try another link.'); }
        }
      });
    });
  }, []);

  useEffect(() => { if (ready) { try { playerRef.current.setVolume(vol); } catch {} } }, [vol, ready]);

  const playUrl = (url, meta) => {
    const p = parseYouTube(url);
    if (!p) { setHint('Paste a YouTube video, playlist, or channel link.'); return false; }
    if (p.kind === 'handle') { setHint('For an @handle channel, paste a playlist or video link from it.'); return false; }
    const start = () => {
      const pl = playerRef.current;
      try {
        if (p.kind === 'playlist') pl.loadPlaylist({ list: p.id, listType: 'playlist', index: 0 });
        else pl.loadVideoById(p.id);
        pl.setVolume(vol);
        setNow({ title: meta?.title || p.label + '…', kind: p.kind, label: meta?.label || p.label });
        setHint(''); setOpen(false); setDraft('');
        onToast && onToast(meta?.toast || (p.kind === 'playlist' ? 'Playlist started for the table ♪' : 'Now playing for the table ♪'));
      } catch { setHint('Could not start playback.'); }
    };
    if (ready) start(); else ensureYT(() => setTimeout(start, 350));
    return true;
  };

  // play the top YouTube result for a search query (no hard-coded video IDs)
  const playSearch = (query, meta) => {
    const start = () => {
      const pl = playerRef.current;
      try {
        pl.loadPlaylist({ list: query, listType: 'search', index: 0 });
        pl.setVolume(vol);
        setNow({ title: meta?.title || query, kind: 'search', label: meta?.label || 'Audio' });
        setHint('');
        onToast && onToast(meta?.toast || 'Now playing for the table ♪');
      } catch { setHint('Could not start playback.'); }
    };
    if (ready) start(); else ensureYT(() => setTimeout(start, 350));
    return true;
  };

  const toggle = () => { const pl = playerRef.current; if (!pl) return; playing ? pl.pauseVideo() : pl.playVideo(); };
  const next   = () => { try { playerRef.current.nextVideo(); } catch {} };
  const stop   = () => { try { playerRef.current.stopVideo(); } catch {} setNow(null); setPlaying(false); };

  // expose to geofences and timeout / waiting-room screens
  useEffect(() => {
    if (controllerRef) controllerRef.current = {
      play: (url, meta) => playUrl(url, meta),
      playSearch: (query, meta) => playSearch(query, meta),
      stop,
      isReady: () => ready,
    };
  }, [ready, vol]);

  const transport = isDM && (
    <div className="yt-transport">
      <button className="yt-ctl" onClick={toggle} title={playing ? 'Pause' : 'Play'} aria-label={playing ? 'Pause' : 'Play'}>{playing ? '❚❚' : '►'}</button>
      <button className="yt-ctl" onClick={next} title="Next track" aria-label="Next">⏭</button>
      <button className="yt-ctl" onClick={stop} title="Stop" aria-label="Stop">■</button>
      <span className="yt-vol" title="Volume">
        <span aria-hidden="true">🔊</span>
        <input type="range" min="0" max="100" value={vol} onChange={e => setVol(+e.target.value)} aria-label="Volume" />
      </span>
    </div>
  );

  const eq = <span className={`yt-eq ${playing ? 'on' : ''}`} aria-hidden="true"><i /><i /><i /></span>;
  const titleLine = now
    ? <><span className="yt-kind">{now.label || 'Audio'}</span><span className="yt-title" title={now.title}>{now.title}</span></>
    : <span className="yt-idle">{isDM ? 'No track playing' : 'The table is quiet'}</span>;

  // park the real iframe far off-screen — we only want its sound
  const sink = <div className="yt-sink" aria-hidden="true" ref={holderRef} />;

  if (!isDM && !now) return sink; // players see nothing until the DM plays

  const addBtn = isDM && (
    <div className="yt-add-wrap">
      <button className="yt-add" onClick={() => setOpen(o => !o)} title="Play audio for the table" aria-expanded={open}>♪ Music</button>
      {open && (
        <div className="yt-pop parchment stitched">
          <div className="eyebrow">Play to the whole table</div>
          <input className="yt-input" autoFocus value={draft} onChange={e => setDraft(e.target.value)}
            onKeyDown={e => e.key === 'Enter' && playUrl(draft)}
            placeholder="Paste a YouTube video / playlist / channel link" />
          <div className="yt-pop-actions">
            <button className="btn" onClick={() => playUrl(draft)}>Play for table</button>
            <button className="btn ghost" onClick={() => { setOpen(false); setHint(''); }}>Cancel</button>
          </div>
          {hint && <div className="yt-hint">{hint}</div>}
          <div className="yt-tip">Audio only — the video stays hidden. Channels play their latest uploads.</div>
        </div>
      )}
    </div>
  );

  return (
    <div className={`yt-wrap yt-${variant}`}>
      {sink}
      <div className="yt-bar leather stitched">
        {eq}
        <div className="yt-now">{titleLine}</div>
        {transport}
        {addBtn}
        {!isDM && now && <span className="yt-dm-flag" title="The DM is playing this">DM ♪</span>}
      </div>
    </div>
  );
}

/* ---------- CharacterComposer: add a custom enemy / ally ---------- */
const ENEMY_SPRITES = ['👺', '💀', '🐺', '🕷️', '🐉', '👹', '🦇', '🜏', '🧟', '👿'];
const ALLY_SPRITES  = ['🛡️', '🧝', '🐎', '🦅', '🧚', '⚔️', '🪄', '🐕', '🧙', '✨'];
const ENEMY_RING = '#a23644';
const ALLY_RING  = '#3f7d4a';

function CharacterComposer({ onAdd, onClose, canUpload = true, onLocked }) {
  const [side, setSide]   = useState('enemy');
  const [name, setName]   = useState('');
  const [portrait, setPortrait] = useState(null);
  const [sprite, setSprite] = useState(ENEMY_SPRITES[0]);
  const [color, setColor] = useState(ENEMY_RING);
  const [hp, setHp]       = useState(12);
  const [ac, setAc]       = useState(13);
  const [reveal, setReveal] = useState('manual');
  const [notes, setNotes] = useState(['', '', '']);
  const [upErr, setUpErr] = useState('');
  const [uploading, setUploading] = useState(false);
  const fileRef = useRef(null);
  const dialogRef = useRef(null);
  const closeRef = useRef(null);

  const palette = side === 'enemy' ? ENEMY_SPRITES : ALLY_SPRITES;

  // Modal focus management: initial focus, Escape-to-close, Tab focus trap, and
  // focus return to the invoking control on close (SC 2.1.2 / 2.4.3 / 2.4.7).
  useEffect(() => {
    const prev = document.activeElement;
    closeRef.current?.focus();
    const node = dialogRef.current;
    const onKey = (e) => {
      if (e.key === 'Escape') { e.preventDefault(); onClose(); return; }
      if (e.key !== 'Tab' || !node) return;
      const all = node.querySelectorAll(
        'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])',
      );
      const list = Array.prototype.filter.call(all, (el) => el.offsetParent !== null);
      if (list.length === 0) return;
      const first = list[0], last = list[list.length - 1];
      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
    };
    node?.addEventListener('keydown', onKey);
    return () => {
      node?.removeEventListener('keydown', onKey);
      if (prev && typeof prev.focus === 'function') prev.focus();
    };
  }, []);

  const flipSide = (s) => {
    setSide(s);
    setColor(s === 'enemy' ? ENEMY_RING : ALLY_RING);
    setSprite((s === 'enemy' ? ENEMY_SPRITES : ALLY_SPRITES)[0]);
    if (s === 'ally') setReveal('visible');
    else setReveal('manual');
  };

  // Uploads go through the server (sniff + strip metadata + quota); it returns
  // a served URL string we store in place of the old inline data-URL.
  const onFile = async (e) => {
    const f = e.target.files && e.target.files[0]; e.target.value = '';
    if (!f || !f.type.startsWith('image/')) return;
    setUpErr(''); setUploading(true);
    try {
      const url = await window.Ent.uploadImage(f, 'portrait');
      setPortrait(url);
    } catch (err) {
      setUpErr(err && err.message ? err.message : 'Upload failed.');
    } finally { setUploading(false); }
  };

  const save = () => {
    const finalName = name.trim() || (side === 'enemy' ? 'Unnamed Foe' : 'Unnamed Ally');
    const cleanNotes = notes.map(n => n.trim()).filter(Boolean).slice(0, 3);
    onAdd({
      id: 'f' + Date.now(),
      name: finalName, char: finalName,
      portrait: portrait || null,
      sprite: portrait ? '' : sprite,
      color,
      allegiance: side,
      hp: +hp || 1, hpMax: +hp || 1, ac: +ac || 10,
      reveal: side === 'ally' ? 'visible' : reveal,
      notes: cleanNotes,
      custom: true,
    });
  };

  return (
    <div className="lobby-overlay" onMouseDown={e => e.target === e.currentTarget && onClose()}>
      <div ref={dialogRef} className="composer-card parchment stitched" role="dialog" aria-modal="true" aria-labelledby="composer-title">
        <div className="composer-hd">
          <div className="eyebrow">Stage a character · DM only</div>
          <button ref={closeRef} className="composer-x" onClick={onClose} aria-label="Close">✕</button>
        </div>
        <h2 id="composer-title" className="display composer-title">Add to the encounter</h2>

        <div className="side-toggle" role="group" aria-label="Allegiance">
          <button className={`side-enemy ${side === 'enemy' ? 'on' : ''}`} onClick={() => flipSide('enemy')}>⚔ Enemy</button>
          <button className={`side-ally ${side === 'ally' ? 'on' : ''}`} onClick={() => flipSide('ally')}>🛡 Ally</button>
        </div>

        <div className="creator-top">
          <button className={`portrait-drop ${canUpload?'':'locked'}`} style={{ '--ring': color }}
            onClick={() => canUpload ? fileRef.current?.click() : onLocked?.()}
            title={canUpload?'Upload an image for this character':'Image uploads are a supporter feature'}
            aria-label={canUpload?'Upload character image':'Image uploads are a supporter feature'}>
            {portrait ? <img src={portrait} alt="" /> : <span className="pd-emoji">{sprite}</span>}
            <span className="pd-badge">{canUpload?'⤓':'🔒'}</span>
          </button>
          <input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp,image/gif" style={{ display: 'none' }} onChange={onFile} />
          <div className="creator-fields">
            {uploading && <p className="save-note" role="status">Uploading…</p>}
            {upErr && <p className="save-note err" role="alert">{upErr}</p>}
            <input className="lobby-input" value={name} onChange={e => setName(e.target.value)}
              placeholder={side === 'enemy' ? 'e.g. Bandit Captain' : 'e.g. Sir Garrick'} aria-label="Character name" />
            <div className="stat-row">
              <label>HP<input type="number" min="1" value={hp} onChange={e => setHp(e.target.value)} /></label>
              <label>AC<input type="number" min="1" value={ac} onChange={e => setAc(e.target.value)} /></label>
              <label className="reveal-field">Appears
                <select value={reveal} onChange={e => setReveal(e.target.value)}>
                  {side === 'ally' && <option value="visible">Visible to all</option>}
                  <option value="manual">When I reveal it</option>
                  <option value="proximity">When a hero draws near</option>
                </select>
              </label>
            </div>
          </div>
        </div>

        {!portrait && (
          <div className="sprite-pick">
            <span className="eyebrow">Or pick a token</span>
            <div className="sprite-row">
              {palette.map(s => (
                <button key={s} className={`sprite-chip ${sprite === s ? 'on' : ''}`} onClick={() => setSprite(s)} aria-label={`Token ${s}`}>{s}</button>
              ))}
            </div>
          </div>
        )}

        <div className="color-row">
          <span className="eyebrow">Token ring</span>
          <div className="color-swatches">
            {[side === 'enemy' ? ENEMY_RING : ALLY_RING, '#c1933f', '#5b7fd0', '#5b4b8a', '#2f8478', '#9a6f28', '#7d2330'].map(c => (
              <button key={c} className={`csw ${color === c ? 'on' : ''}`} style={{ background: c }} onClick={() => setColor(c)} aria-label={`Ring ${c}`} />
            ))}
          </div>
        </div>

        <div className="notes-block">
          <span className="eyebrow">DM notes — only you see these (up to 3)</span>
          {notes.map((n, i) => (
            <div className="note-input-row" key={i}>
              <span className="note-bullet">•</span>
              <input className="lobby-input" value={n} maxLength={90}
                onChange={e => setNotes(ns => ns.map((x, j) => j === i ? e.target.value : x))}
                placeholder={['Tactics, secret weakness, a twist…', 'e.g. Flees below 10 HP', 'e.g. Carries the iron key'][i]} />
            </div>
          ))}
        </div>

        <div className="lobby-actions">
          <button className="btn" style={{ flex: 1 }} onClick={save}>Add &amp; place on map</button>
          <button className="btn ghost" onClick={onClose}>Cancel</button>
        </div>
      </div>
    </div>
  );
}

/* ---------- GeofenceConfig: set up a trigger zone ---------- */
function GeofenceConfig({ geo, foes, onSave, onDelete, onClose }) {
  const [r, setR]           = useState(geo.r || 2);
  const [ytUrl, setYtUrl]   = useState(geo.ytUrl || '');
  const [revealRef, setRevealRef] = useState(geo.revealRef || '');
  const [narration, setNarration] = useState(geo.narration || '');
  const [once, setOnce]     = useState(geo.once !== false);
  const hidden = foes.filter(f => f.reveal !== 'visible');

  const ytOk = !ytUrl.trim() || !!parseYouTube(ytUrl);

  const save = () => onSave({ ...geo, r: +r, ytUrl: ytUrl.trim(), revealRef: revealRef || null, narration: narration.trim(), once, armed: true, triggered: false });

  return (
    <div className="lobby-overlay" onMouseDown={e => e.target === e.currentTarget && onClose()}>
      <div className="composer-card parchment stitched geo-card">
        <div className="composer-hd">
          <div className="eyebrow">Trigger zone · DM only</div>
          <button className="composer-x" onClick={onClose} aria-label="Close">✕</button>
        </div>
        <h2 className="display composer-title">When a hero steps in…</h2>
        <p className="geo-sub">A hidden ring around cell {geo.x},{geo.y}. Players can't see it — until it fires.</p>

        <label className="geo-field">
          <span className="eyebrow">Radius — {r} squares ({r * 5} ft)</span>
          <input type="range" min="1" max="6" value={r} onChange={e => setR(+e.target.value)} />
        </label>

        <label className="geo-field">
          <span className="eyebrow">♪ Play this audio for everyone</span>
          <input className={`lobby-input ${ytOk ? '' : 'bad'}`} value={ytUrl} onChange={e => setYtUrl(e.target.value)}
            placeholder="YouTube link (optional)" />
          {!ytOk && <span className="geo-warn">Doesn't look like a YouTube link.</span>}
        </label>

        <label className="geo-field">
          <span className="eyebrow">👁 Reveal a hidden sprite from the fog</span>
          <select className="lobby-input" value={revealRef} onChange={e => setRevealRef(e.target.value)}>
            <option value="">— none —</option>
            {hidden.map(f => <option key={f.id} value={f.id}>{f.name}</option>)}
          </select>
        </label>

        <label className="geo-field">
          <span className="eyebrow">❧ Drop a narration line into chat</span>
          <textarea className="lobby-input geo-narr" rows="2" value={narration} onChange={e => setNarration(e.target.value)}
            placeholder="“A low growl rolls out of the dark…”" />
        </label>

        <label className="geo-once">
          <input type="checkbox" checked={once} onChange={e => setOnce(e.target.checked)} />
          <span>Fire once, then disarm <i>(off = re-trigger every entry)</i></span>
        </label>

        <div className="lobby-actions">
          <button className="btn" style={{ flex: 1 }} onClick={save}>Arm this zone</button>
          {onDelete && <button className="btn wine" onClick={onDelete}>Delete</button>}
          <button className="btn ghost" onClick={onClose}>Cancel</button>
        </div>
      </div>
    </div>
  );
}

/* ---------- ElementsPanel: the DM's Elements / Power palette ----------
   Pick a target on the field, then click an element orb to unleash it.
   Damage/heal elements roll their dice and adjust HP; control/buff
   elements pin a status badge on the target. Every cast drops a
   narration line into the shared chat so the table sees what happened. */
const ELEMENT_TIERS = [
  { key: 'basic',     label: 'Basic Elements' },
  { key: 'complex',   label: 'Complex Elements' },
  { key: 'legendary', label: 'Legendary Elements' },
];

function elementEffect(el) {
  if (el.kind === 'damage') return `${el.dice} dmg`;
  if (el.kind === 'heal')   return `heal ${el.dice}`;
  if (el.kind === 'buff')   return el.status || 'boon';
  return el.status || 'effect';
}

function ElementsPanel({ targets, getEnt, onCast, conditions, onClearCond, castFx }) {
  const list = targets || [];
  const [sel, setSel] = useState(list[0] || null);
  // keep the selected target valid as the field changes
  useEffect(() => { if (sel && !list.includes(sel)) setSel(list[0] || null);
    else if (!sel && list.length) setSel(list[0]); }, [list.join(',')]);

  const elems = (typeof ELEMENTS !== 'undefined') ? ELEMENTS : [];
  const selEnt = sel ? getEnt(sel) : null;
  const selName = selEnt ? (selEnt.char ? selEnt.char.split(' ')[0] : selEnt.name) : '';
  const active = (sel && conditions && conditions[sel]) || [];

  return (
    <div className="elements-panel panel-pad">
      <div className="eyebrow">Elements / Power · DM</div>
      <p className="el-sub">Choose who it lands on, then unleash an element.</p>

      <div className="el-targets" role="group" aria-label="Choose a target">
        {list.map(ref => {
          const e = getEnt(ref);
          const nm = e.char ? e.char.split(' ')[0] : e.name;
          return (
            <button key={ref} className={`el-target ${sel === ref ? 'on' : ''}`} style={{ '--ring': e.color }}
              onClick={() => setSel(ref)} aria-pressed={sel === ref} title={`Target ${nm}`}>
              <span className="el-tg-sprite">{e.portrait ? <img src={e.portrait} alt="" /> : e.sprite}</span>
              <span className="el-tg-name">{nm}</span>
              {e.hpMax && <span className="el-tg-hp">{e.hp}/{e.hpMax}</span>}
            </button>
          );
        })}
        {!list.length && <span className="el-empty">No combatants on the field yet — stage a foe or seat a hero.</span>}
      </div>

      {selEnt && (
        <div className="el-stage">
          <span className="el-stage-lbl">Unleashing on</span>
          <b className="el-stage-name" style={{ color: selEnt.color }}>{selName}</b>
          <span key={castFx && castFx.ref === sel ? castFx.key : 'idle'}
            className={`el-cast-flash ${castFx && castFx.ref === sel ? 'go' : ''}`}
            style={{ color: castFx?.color }} aria-hidden="true">
            {castFx && castFx.ref === sel ? castFx.glyph : ''}
          </span>
        </div>
      )}

      {active.length > 0 && (
        <div className="el-active">
          <span className="eyebrow">Active on {selName}</span>
          <div className="el-badges">
            {active.map(c => (
              <span key={c.id} className={`el-badge k-${c.kind}`} style={{ '--c': c.color }}>
                <span className="elb-glyph">{c.glyph}</span>{c.name}
                <button onClick={() => onClearCond(sel, c.id)} aria-label={`Clear ${c.name} from ${selName}`}>✕</button>
              </span>
            ))}
          </div>
        </div>
      )}

      {ELEMENT_TIERS.map(tier => (
        <div key={tier.key} className={`el-tier tier-${tier.key}`}>
          <div className="el-tier-hd">{tier.label}</div>
          <div className="el-grid">
            {elems.filter(el => el.tier === tier.key).map(el => (
              <button key={el.id} className={`el-orb k-${el.kind}`} style={{ '--c': el.color }}
                disabled={!sel} onClick={() => sel && onCast(el, sel)}
                title={`${el.name} — ${elementEffect(el)}${sel ? ` → ${selName}` : ''}`}
                aria-label={`${el.name}, ${elementEffect(el)}${sel ? `, on ${selName}` : ''}`}>
                <span className="el-glyph" aria-hidden="true">{el.glyph}</span>
                <span className="el-name">{el.name}</span>
                <span className="el-eff">{elementEffect(el)}</span>
              </button>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

Object.assign(window, { YouTubeBar, CharacterComposer, GeofenceConfig, parseYouTube, ElementsPanel });
