/* diceUI.jsx — Shared dice-pool UI for SWRPG (loaded via <script type="text/babel" src="...">).
 *
 * Depends on:
 *   - window.SWRPGDice (from diceEngine.js)
 *   - React + ReactDOM (global)
 *   - SWRPGIcons + Genesys fonts (loaded in <head>)
 *
 * Exposes (extends window.SWRPGDice):
 *   renderPool(pool)      — React fragment of pool symbols
 *   renderResults(r)      — React fragment of result symbols
 *   InlinePoolPreview     — component: compact symbol row
 *   RollResults           — component: results display
 *   DicePoolModal         — component: full builder modal (controlled)
 *   FloatingDiceButton    — component: opt-in floating opener
 *   openModal(preset)     — imperative opener; returns Promise<resolved>
 *
 * Design notes:
 * - This file is Babel-transpiled in the browser. Keep to ES2020 features that
 *   stable Babel-standalone handles (no top-level await, no decorators).
 * - All UI is opt-in; silent rolls use diceEngine directly.
 */
(function() {
  var D = window.SWRPGDice;
  if (!D) { console.warn('[diceUI] SWRPGDice not loaded'); return; }
  var h = React.createElement;

  // ── Symbol table (mirrors player.html FFG_SYMBOL but local so it works in both apps) ──
  var SYMBOLS = {
    // Die shapes (Genesys font)
    ability:     { char: 'k', color: '#45AD48', font: 'Genesys',    label: 'Ability' },
    proficiency: { char: 'l', color: '#FDE600', font: 'Genesys',    label: 'Proficiency' },
    boost:       { char: 'j', color: '#80CEC4', font: 'Genesys',    label: 'Boost' },
    difficulty:  { char: 'k', color: '#A47BC7', font: 'Genesys',    label: 'Difficulty' },
    challenge:   { char: 'l', color: '#CC3333', font: 'Genesys',    label: 'Challenge' },
    setback:     { char: 'j', color: '#AAAAAA', font: 'Genesys',    label: 'Setback' },
    force:       { char: '\ue904', color: '#EEEEEE', font: 'SWRPGIcons', label: 'Force' },
    // Result symbols (SWRPGIcons font)
    success:   { char: '\ue941', color: '#45AD48', font: 'SWRPGIcons', label: 'Success' },
    failure:   { char: '\ue93b', color: '#999999', font: 'SWRPGIcons', label: 'Failure' },
    advantage: { char: '\ue936', color: '#45AD48', font: 'SWRPGIcons', label: 'Advantage' },
    threat:    { char: '\ue943', color: '#999999', font: 'SWRPGIcons', label: 'Threat' },
    triumph:   { char: '\ue945', color: '#FDE600', font: 'SWRPGIcons', label: 'Triumph' },
    despair:   { char: '\ue93a', color: '#CC3333', font: 'SWRPGIcons', label: 'Despair' },
    light:     { char: '\ue93e', color: '#EEEEEE', font: 'SWRPGIcons', label: 'Light side' },
    dark:      { char: '\ue93e', color: '#555555', font: 'SWRPGIcons', label: 'Dark side' },
  };

  function symbolSpan(key, idx, size) {
    var sym = SYMBOLS[key];
    if (!sym) return null;
    return h('span', {
      key: idx,
      title: sym.label,
      'aria-label': sym.label,
      role: 'img',
      // Hide glyph character from AT (screen readers announce aria-label instead of the private-use codepoint)
      style: {
        fontFamily: '"' + sym.font + '"',
        color: sym.color,
        fontSize: (size || 1.3) + 'em',
        lineHeight: 1,
        display: 'inline-block',
        verticalAlign: '-0.12em',
        margin: '0 1px',
        fontWeight: 'normal',
      },
    }, sym.char);
  }

  // ── Pool symbol row ────────────────────────────────────────────────────
  // Renders: [positive dice] vs [negative dice] · [force]
  function renderPool(pool, opts) {
    pool = pool || {};
    opts = opts || {};
    var size = opts.size || 1.3;
    var items = [];
    var i = 0;
    for (var n = 0; n < (pool.proficiency || 0); n++) items.push(symbolSpan('proficiency', i++, size));
    for (var n = 0; n < (pool.ability || 0); n++) items.push(symbolSpan('ability', i++, size));
    for (var n = 0; n < (pool.boost || 0); n++) items.push(symbolSpan('boost', i++, size));
    if ((pool.difficulty || 0) + (pool.challenge || 0) + (pool.setback || 0) > 0) {
      items.push(h('span', { key: 'sep' + (i++), style: { margin: '0 4px', color: 'rgba(255,255,255,0.3)' } }, 'vs'));
    }
    for (var n = 0; n < (pool.challenge || 0); n++) items.push(symbolSpan('challenge', i++, size));
    for (var n = 0; n < (pool.difficulty || 0); n++) items.push(symbolSpan('difficulty', i++, size));
    for (var n = 0; n < (pool.setback || 0); n++) items.push(symbolSpan('setback', i++, size));
    if (pool.force > 0) {
      items.push(h('span', { key: 'fsep' + (i++), style: { margin: '0 4px', color: 'rgba(255,255,255,0.3)' } }, '·'));
      for (var n = 0; n < pool.force; n++) items.push(symbolSpan('force', i++, size));
    }
    if (items.length === 0) {
      return h('span', { style: { color: 'rgba(255,255,255,0.3)', fontFamily: 'Share Tech Mono', fontSize: '10px' } }, 'EMPTY POOL');
    }
    return h(React.Fragment, null, items);
  }

  // ── Results symbol row ─────────────────────────────────────────────────
  // Shows NET success/advantage and any triumph/despair/light/dark (these don't cancel)
  function renderResults(r, opts) {
    if (!r) return null;
    opts = opts || {};
    var size = opts.size || 1.4;
    var items = [];
    var i = 0;
    var net = r.success;
    var netAdv = r.advantage;
    // Success / failure
    if (net > 0) {
      for (var n = 0; n < net; n++) items.push(symbolSpan('success', i++, size));
    } else if (net < 0) {
      for (var n = 0; n < -net; n++) items.push(symbolSpan('failure', i++, size));
    }
    // Advantage / threat
    if (netAdv > 0) {
      for (var n = 0; n < netAdv; n++) items.push(symbolSpan('advantage', i++, size));
    } else if (netAdv < 0) {
      for (var n = 0; n < -netAdv; n++) items.push(symbolSpan('threat', i++, size));
    }
    // Triumph / despair / light / dark — don't cancel
    for (var n = 0; n < (r.triumph || 0); n++) items.push(symbolSpan('triumph', i++, size));
    for (var n = 0; n < (r.despair || 0); n++) items.push(symbolSpan('despair', i++, size));
    for (var n = 0; n < (r.light || 0); n++) items.push(symbolSpan('light', i++, size));
    for (var n = 0; n < (r.dark || 0); n++) items.push(symbolSpan('dark', i++, size));
    if (items.length === 0) {
      return h('span', { style: { color: 'rgba(255,255,255,0.35)', fontFamily: 'Share Tech Mono', fontSize: '11px', letterSpacing: '0.12em' } }, 'WASH');
    }
    return h(React.Fragment, null, items);
  }

  // ── InlinePoolPreview — small tappable pool row ─────────────────────────
  function InlinePoolPreview(props) {
    var pool = props.pool;
    var onClick = props.onClick;
    var ariaLabel = props.ariaLabel || (onClick ? 'Open dice pool' : null);
    return h('div', {
      onClick: onClick,
      // QA late #7: accessible + test-automatable clickable dice pool.
      role: onClick ? 'button' : null,
      tabIndex: onClick ? 0 : null,
      'aria-label': ariaLabel,
      onKeyDown: onClick ? function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(e); } } : null,
      style: {
        display: 'inline-flex', alignItems: 'center', gap: '2px',
        padding: '3px 8px', borderRadius: '4px',
        background: 'rgba(255,255,255,0.03)',
        border: '1px solid rgba(255,255,255,0.08)',
        cursor: onClick ? 'pointer' : 'default',
        transition: 'background 0.2s ease, border-color 0.2s ease',
      },
      onMouseEnter: onClick ? function(e) { e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.16)'; } : null,
      onMouseLeave: onClick ? function(e) { e.currentTarget.style.background = 'rgba(255,255,255,0.03)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)'; } : null,
    }, renderPool(pool, { size: 1.15 }));
  }

  // ── RollResults — results summary row ───────────────────────────────────
  // Shows symbols + plain-text summary + CRIT?! flag if weapon crit threshold met
  function RollResults(props) {
    var r = props.resolved;
    var critThreshold = props.critThreshold; // number or null
    if (!r) return null;
    // QA late #6: treat crit <= 0 / NaN / non-finite as "no crit" — training weapons with crit:"0"
    // were triggering a spurious CRIT?! on every success.
    var critT = (typeof critThreshold === 'number' && critThreshold > 0) ? critThreshold : null;
    var showCrit = critT != null && r.success > 0 && (r.advantage >= critT || r.triumph > 0);
    return h('div', { style: {
      padding: '12px 14px',
      background: 'rgba(255,255,255,0.03)',
      border: '1px solid rgba(255,255,255,0.1)',
      borderRadius: '8px',
      display: 'flex', flexDirection: 'column', gap: '8px',
    } },
      h('div', { style: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '2px', minHeight: '28px' } },
        renderResults(r, { size: 1.5 })
      ),
      h('div', { style: {
        fontFamily: 'Rajdhani', fontSize: '12px', lineHeight: 1.4,
        color: 'rgba(255,255,255,0.6)',
      } }, D.formatResults(r)),
      showCrit && h('div', {
        style: {
          fontFamily: 'Orbitron', fontWeight: 700, fontSize: '11px',
          letterSpacing: '0.2em', color: '#FFC857',
          padding: '4px 10px',
          background: 'rgba(255,200,87,0.1)',
          border: '1px solid rgba(255,200,87,0.4)',
          borderRadius: '4px',
          alignSelf: 'flex-start',
          textShadow: '0 0 8px #FFC85788',
        },
      }, 'CRIT?!'),
      // QA late #3: pip-spend hint. When the roll produced light/dark Force pips,
      // surface what they can be spent on so they don't get ignored.
      (r.light > 0 || r.dark > 0) && h('div', {
        style: {
          fontFamily: 'Rajdhani', fontSize: '11px', lineHeight: 1.45,
          color: 'rgba(255,255,255,0.72)',
          padding: '8px 10px',
          background: 'rgba(238,238,238,0.04)',
          border: '1px solid rgba(238,238,238,0.18)',
          borderLeft: '3px solid #EEEEEE',
          borderRadius: '4px',
        },
      },
        h('div', { style: { fontFamily: 'Share Tech Mono', fontSize: '8px', letterSpacing: '0.18em', color: 'rgba(238,238,238,0.55)', marginBottom: '4px' } }, 'PIPS TO SPEND'),
        r.light > 0 && h('div', null,
          h('span', { style: { fontFamily: 'Share Tech Mono', fontWeight: 700, color: '#EEEEEE' } }, r.light + 'L'),
          h('span', null, ' — fuel a Force power, generate Light side advantage, or feed an upgrade trigger.')
        ),
        r.dark > 0 && h('div', { style: { marginTop: '3px' } },
          h('span', { style: { fontFamily: 'Share Tech Mono', fontWeight: 700, color: '#B080FF' } }, r.dark + 'D'),
          h('span', null, ' — usable, but spending dark pips costs strain. Avoid if you can.')
        )
      )
    );
  }

  // ── Difficulty quick-picks ───────────────────────────────────────────────
  var DIFFICULTY_PRESETS = [
    { name: 'Simple',     difficulty: 0, label: '—' },
    { name: 'Easy',       difficulty: 1, label: 'I' },
    { name: 'Average',    difficulty: 2, label: 'II' },
    { name: 'Hard',       difficulty: 3, label: 'III' },
    { name: 'Daunting',   difficulty: 4, label: 'IV' },
    { name: 'Formidable', difficulty: 5, label: 'V' },
  ];

  // ── DicePoolModal — full builder ────────────────────────────────────────
  function DicePoolModal(props) {
    var open = props.open;
    var onClose = props.onClose;
    var preset = props.preset || {};
    var accent = props.accent || '#F5C842';
    var onRoll = props.onRoll;

    var initialPool = D.buildPool(preset);
    var initial = React.useMemo(function() { return {
      characteristic: preset.characteristic || 0,
      skillRank:      preset.skillRank || 0,
      boost:          preset.boost || 0,
      setback:        preset.setback || 0,
      difficulty:     preset.difficulty || 2,
      challenge:      preset.challenge || 0,
      force:          preset.force || 0,
      adversary:      preset.adversary || 0,
    }; }, [preset.characteristic, preset.skillRank, preset.boost, preset.setback, preset.difficulty, preset.challenge, preset.force, preset.adversary]);

    var _s = React.useState(initial);
    var spec = _s[0];
    var setSpec = _s[1];

    React.useEffect(function() { setSpec(initial); }, [initial]);

    var _r = React.useState(null);
    var lastRoll = _r[0];
    var setLastRoll = _r[1];

    var pool = D.buildPool(spec);

    function setField(name, delta, min, max) {
      setSpec(function(s) {
        var v = (s[name] || 0) + delta;
        if (v < (min||0)) v = (min||0);
        if (max != null && v > max) v = max;
        return Object.assign({}, s, { [name]: v });
      });
    }

    function setDifficultyPreset(n) {
      setSpec(function(s) { return Object.assign({}, s, { difficulty: n }); });
    }

    function handleRoll() {
      var result = D.rollAndResolve(pool);
      setLastRoll(result.resolved);
      if (onRoll) onRoll(result);
    }

    function handleReset() {
      setLastRoll(null);
      setSpec(initial);
    }

    // Accessibility (vet-r1 P2): Escape closes; focus jumps to the close
    // button on open and restores to the previously-focused element on close.
    var closeBtnRef = React.useRef(null);
    var prevFocusRef = React.useRef(null);
    React.useEffect(function() {
      if (!open) return;
      prevFocusRef.current = document.activeElement;
      // Defer focus until DOM mounts.
      setTimeout(function() { if (closeBtnRef.current) closeBtnRef.current.focus(); }, 0);
      function onKey(e) {
        if (e.key === 'Escape') { e.preventDefault(); if (onClose) onClose(); }
      }
      document.addEventListener('keydown', onKey);
      return function() {
        document.removeEventListener('keydown', onKey);
        var prev = prevFocusRef.current;
        if (prev && typeof prev.focus === 'function') {
          try { prev.focus(); } catch (e) { /* element may be gone */ }
        }
      };
    }, [open, onClose]);

    if (!open) return null;

    // Field row component
    function stepperRow(label, name, color, min, max) {
      return h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 0' } },
        h('span', { style: { fontFamily: 'Rajdhani', fontSize: '12px', color: color || '#fff', letterSpacing: '0.1em' } }, label),
        h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
          h('button', {
            type: 'button', onClick: function() { setField(name, -1, min, max); },
            style: {
              width: '30px', height: '30px', borderRadius: '50%',
              background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.1)',
              color: 'rgba(255,255,255,0.55)', cursor: 'pointer', fontSize: '15px',
              display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
            },
          }, '\u2212'),
          h('span', { style: {
            fontFamily: 'Orbitron', fontWeight: 700, fontSize: '16px',
            color: '#fff', minWidth: '24px', textAlign: 'center',
          } }, spec[name] || 0),
          h('button', {
            type: 'button', onClick: function() { setField(name, 1, min, max); },
            style: {
              width: '30px', height: '30px', borderRadius: '50%',
              background: (color || accent) + '14', border: '1px solid ' + (color || accent) + '44',
              color: color || accent, cursor: 'pointer', fontSize: '15px',
              display: 'flex', alignItems: 'center', justifyContent: 'center', lineHeight: 1,
            },
          }, '+')
        )
      );
    }

    return h('div', {
      onClick: onClose,
      role: 'dialog',
      'aria-modal': 'true',
      'aria-labelledby': 'dice-pool-title',
      style: {
        position: 'fixed', inset: 0, zIndex: 1000,
        background: 'rgba(2,1,12,0.65)',
        backdropFilter: 'blur(4px)',
        display: 'flex', alignItems: 'flex-end', justifyContent: 'center',
        animation: 'fadeIn 0.2s ease',
      },
    },
      h('div', {
        onClick: function(e) { e.stopPropagation(); },
        style: {
          width: '100%', maxWidth: '440px',
          maxHeight: '90vh', overflowY: 'auto',
          background: 'linear-gradient(180deg, #0a0818 0%, #050312 100%)',
          borderTop: '2px solid ' + accent + '55',
          borderRadius: '16px 16px 0 0',
          padding: '16px 18px 22px',
          boxShadow: '0 -8px 32px rgba(0,0,0,0.6), 0 0 40px ' + accent + '18',
          animation: 'slideUp 0.25s ease',
        },
      },
        // Header
        h('div', { style: { display: 'flex', alignItems: 'center', marginBottom: '12px' } },
          h('div', { style: { flex: 1 } },
            h('div', { id: 'dice-pool-title', style: {
              fontFamily: 'Orbitron', fontWeight: 700, fontSize: '12px',
              letterSpacing: '0.16em', color: accent,
              textShadow: '0 0 8px ' + accent + '88',
            } }, preset.forcePower ? 'FORCE: ' + String(preset.forcePower.name || '').toUpperCase() : 'DICE POOL'),
            // Veteran review Tier 2 fix: when rolling a Force power, surface its description.
            preset.forcePower && preset.forcePower.desc &&
              h('div', { style: { fontFamily: 'Rajdhani', fontSize: '11px', lineHeight: 1.4, color: 'rgba(255,255,255,0.55)', marginTop: '4px' } }, preset.forcePower.desc)
          ),
          h('button', {
            type: 'button',
            ref: closeBtnRef,
            'aria-label': 'Close dice pool',
            onClick: onClose,
            style: {
              background: 'none', border: 'none', cursor: 'pointer',
              fontFamily: 'Share Tech Mono', fontSize: '16px',
              color: 'rgba(255,255,255,0.55)',
              minWidth: '44px', minHeight: '44px',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              touchAction: 'manipulation',
            },
          }, '\u2715')
        ),

        // Force-power upgrades list (text-only). Renders before the pool so the player
        // sees what's possible with this power before rolling.
        preset.forcePower && preset.forcePower.upgrades && preset.forcePower.upgrades.length > 0 &&
          h('div', { style: {
            marginBottom: '12px', padding: '8px 10px',
            background: 'rgba(238,238,238,0.05)',
            border: '1px solid rgba(238,238,238,0.15)',
            borderLeft: '3px solid #EEEEEE',
            borderRadius: '5px',
          } },
            h('div', { style: { fontFamily: 'Share Tech Mono', fontSize: '7px', letterSpacing: '0.18em', color: 'rgba(238,238,238,0.55)', marginBottom: '4px' } }, 'UPGRADES OWNED'),
            preset.forcePower.upgrades.map(function(u, i) {
              var nm = typeof u === 'string' ? u : (u.name || '');
              var d = typeof u === 'object' ? (u.desc || u.description || '') : '';
              return h('div', { key: i, style: { fontFamily: 'Rajdhani', fontSize: '11px', color: 'rgba(255,255,255,0.7)', marginTop: '2px' } },
                h('span', { style: { fontWeight: 700, color: '#EEEEEE' } }, nm),
                d ? h('span', { style: { color: 'rgba(255,255,255,0.5)' } }, ' \u2014 ' + d) : null
              );
            })
          ),

        // Pool preview (big)
        h('div', {
          style: {
            padding: '14px',
            background: 'rgba(255,255,255,0.03)',
            border: '1px solid ' + accent + '22',
            borderRadius: '8px',
            marginBottom: '14px',
            minHeight: '48px',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            flexWrap: 'wrap', gap: '2px',
          },
        }, renderPool(pool, { size: 1.6 })),

        // Pure-Force-check mode: when the modal is opened for a Force power
        // with no skill (rollChar=0, skillRank=0, difficulty=0), hide the
        // CHECK and DIFFICULTY sections entirely. The pool is just Force +
        // any committed dice. QA late #2.
        (function() {
          var pf = preset.forcePower
            && (spec.characteristic | 0) === 0
            && (spec.skillRank | 0) === 0
            && (spec.difficulty | 0) === 0;
          return pf ? null :
            h(React.Fragment, null,
              // Character + skill
              h('div', { style: { marginBottom: '8px' } },
                h('div', { style: { fontFamily: 'Share Tech Mono', fontSize: '8px', letterSpacing: '0.2em', color: 'rgba(255,255,255,0.4)', marginBottom: '4px' } }, 'CHECK'),
                stepperRow('CHARACTERISTIC', 'characteristic', '#45AD48', 0, 8),
                stepperRow('SKILL RANK', 'skillRank', '#FDE600', 0, 8)
              ),
              // Difficulty presets
              h('div', { style: { marginTop: '14px', marginBottom: '8px' } },
                h('div', { style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: '6px' } },
                  h('div', { style: { fontFamily: 'Share Tech Mono', fontSize: '8px', letterSpacing: '0.2em', color: 'rgba(255,255,255,0.4)' } }, 'DIFFICULTY'),
                  preset.damageContext && preset.damageContext.rangeLabel &&
                    h('div', { style: { fontFamily: 'Share Tech Mono', fontSize: '7px', letterSpacing: '0.14em', color: 'rgba(164,123,199,0.75)' } },
                      'RANGE: ' + String(preset.damageContext.rangeLabel).toUpperCase())
                ),
                h('div', { style: { display: 'flex', gap: '4px', flexWrap: 'wrap' } },
                  DIFFICULTY_PRESETS.map(function(dp) {
                    var active = spec.difficulty === dp.difficulty;
                    return h('button', {
                      key: dp.name, type: 'button',
                      onClick: function() { setDifficultyPreset(dp.difficulty); },
                      style: {
                        flex: 1, minWidth: '50px', padding: '6px 2px',
                        background: active ? '#A47BC728' : 'rgba(255,255,255,0.03)',
                        border: '1px solid ' + (active ? '#A47BC7' : 'rgba(255,255,255,0.08)'),
                        borderRadius: '6px', cursor: 'pointer',
                        fontFamily: 'Rajdhani', fontWeight: 700, fontSize: '10px',
                        letterSpacing: '0.08em',
                        color: active ? '#fff' : 'rgba(255,255,255,0.55)',
                        transition: 'background 0.2s ease, border-color 0.2s ease',
                      },
                    },
                      h('div', { style: { fontSize: '10px' } }, dp.name.toUpperCase()),
                      h('div', { style: { fontFamily: 'Orbitron', fontSize: '9px', color: '#A47BC7', marginTop: '2px' } }, dp.label)
                    );
                  })
                )
              )
            );
        })(),

        // Modifiers
        h('div', { style: { marginTop: '14px' } },
          h('div', { style: { fontFamily: 'Share Tech Mono', fontSize: '8px', letterSpacing: '0.2em', color: 'rgba(255,255,255,0.4)', marginBottom: '4px' } }, 'MODIFIERS'),
          stepperRow('BOOST', 'boost', '#80CEC4', 0, 8),
          stepperRow('SETBACK', 'setback', '#AAAAAA', 0, 8),
          stepperRow('CHALLENGE (upgrade)', 'challenge', '#CC3333', 0, 8),
          stepperRow('FORCE', 'force', '#EEEEEE', 0, 4)
        ),

        // Roll buttons
        h('div', { style: { display: 'flex', gap: '8px', marginTop: '18px' } },
          h('button', {
            type: 'button', onClick: handleReset,
            style: {
              padding: '10px 14px',
              background: 'rgba(255,255,255,0.04)',
              border: '1px solid rgba(255,255,255,0.1)',
              borderRadius: '6px', cursor: 'pointer',
              fontFamily: 'Share Tech Mono', fontSize: '10px',
              letterSpacing: '0.14em', color: 'rgba(255,255,255,0.5)',
              minHeight: '44px',
            },
          }, 'RESET'),
          h('button', {
            type: 'button', onClick: handleRoll,
            style: {
              flex: 1, padding: '10px',
              background: 'linear-gradient(135deg, ' + accent + '22, ' + accent + '08)',
              border: '1px solid ' + accent + '66',
              borderRadius: '6px', cursor: 'pointer',
              fontFamily: 'Orbitron', fontWeight: 700, fontSize: '12px',
              letterSpacing: '0.18em', color: accent,
              textShadow: '0 0 10px ' + accent + '88',
              boxShadow: '0 0 12px ' + accent + '22',
              minHeight: '44px',
            },
          }, 'ROLL \u25BA')
        ),

        // Last roll results
        lastRoll && h('div', { style: { marginTop: '14px' } },
          h('div', { style: { fontFamily: 'Share Tech Mono', fontSize: '8px', letterSpacing: '0.2em', color: accent, marginBottom: '6px' } }, 'RESULT'),
          h(RollResults, { resolved: lastRoll, critThreshold: preset.critThreshold }),

          // Advantage/threat spend menu — Beginner Game p.17.
          // Mechanically-actionable spends become buttons that fire preset.onSpend(kind, amount, meta).
          // Narrative-only spends stay read-only text entries.
          (lastRoll.advantage !== 0 || lastRoll.triumph > 0 || lastRoll.despair > 0) && h('div', {
            style: {
              marginTop: '10px', padding: '10px 12px',
              background: 'rgba(255,255,255,0.02)',
              border: '1px solid rgba(255,255,255,0.08)',
              borderRadius: '6px',
            },
          },
            h('div', { style: { fontFamily: 'Share Tech Mono', fontSize: '8px', letterSpacing: '0.18em', color: 'rgba(255,255,255,0.45)', marginBottom: '6px' } }, 'SPEND OPTIONS'),
            (function() {
              // Build the items list with either {action, label, meta} or {narrative: '...'}.
              var items = [];
              if (lastRoll.advantage >= 1) {
                items.push({ action: 'recover_strain', amount: 1, label: '1 Adv \u2192 Recover 1 strain (self)' });
                items.push({ action: 'grant_boost',    amount: 1, label: '1 Adv \u2192 Grant Boost to next ally check' });
              }
              if (lastRoll.advantage >= 2) {
                items.push({ action: 'target_strain', amount: 1, label: '2 Adv \u2192 Target takes 1 strain' });
                items.push({ narrative: '2 Adv: enemy loses free maneuver next turn (GM adjudicates)' });
              }
              if (lastRoll.advantage >= 3) {
                items.push({ narrative: '3 Adv: activate weapon quality (Stun Setting, Pierce, etc); disarm or stagger target' });
              }
              if (preset.critThreshold != null && lastRoll.advantage >= preset.critThreshold && lastRoll.success > 0) {
                items.push({ action: 'apply_crit', meta: { source: 'advantage', cost: preset.critThreshold }, label: preset.critThreshold + ' Adv (\u2265 Crit rating) \u2192 Trigger Critical Injury' });
              }
              if (lastRoll.triumph > 0) {
                items.push({ action: 'apply_crit', meta: { source: 'triumph' }, label: 'Triumph \u2192 Trigger Critical Injury (regardless of Crit rating)' });
              }
              if (lastRoll.advantage <= -1) {
                items.push({ action: 'suffer_strain', amount: 1, label: '1 Threat \u2192 You suffer 1 strain' });
                items.push({ narrative: '1 Threat: add Setback to your next check (GM adjudicates)' });
              }
              if (lastRoll.advantage <= -2) {
                items.push({ narrative: '2 Threat: you lose free maneuver next turn' });
              }
              if (lastRoll.advantage <= -3) {
                items.push({ narrative: '3 Threat: weapon out of ammo / jam / hazard' });
              }
              if (lastRoll.despair > 0) {
                items.push({ narrative: 'Despair: weapon out of ammo / breaks, serious narrative setback' });
              }
              if (items.length === 0) return null;
              return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '5px' } },
                items.map(function(it, i) {
                  if (it.narrative) {
                    return h('div', {
                      key: i,
                      style: { fontFamily: 'Rajdhani', fontSize: '11px', lineHeight: 1.4, color: 'rgba(255,255,255,0.5)', padding: '4px 2px' },
                    }, '\u2022 ' + it.narrative);
                  }
                  return h('button', {
                    key: i, type: 'button',
                    onClick: function() {
                      if (preset.onSpend) preset.onSpend(it.action, it.amount, it.meta);
                    },
                    style: {
                      textAlign: 'left', padding: '6px 10px', cursor: 'pointer',
                      background: 'rgba(78,203,113,0.06)',
                      border: '1px solid rgba(78,203,113,0.3)',
                      borderLeft: '3px solid #4ECB71',
                      borderRadius: '5px',
                      fontFamily: 'Rajdhani', fontWeight: 600, fontSize: '12px', color: '#4ECB71',
                      letterSpacing: '0.02em',
                      minHeight: '32px',
                    },
                  }, it.label);
                })
              );
            })()
          ),

          // Force-pip spend panel (Tier 2 Force modal depth fix). Light-side pips fuel the power;
          // Dark-side pips can also fuel it but trigger Conflict. CRB p.280-281.
          // Show only when Force dice were in the pool AND there are pips to spend.
          spec.force > 0 && (lastRoll.light > 0 || lastRoll.dark > 0) && h('div', {
            style: {
              marginTop: '10px', padding: '10px 12px',
              background: 'rgba(238,238,238,0.03)',
              border: '1px solid rgba(238,238,238,0.15)',
              borderLeft: '3px solid #EEEEEE',
              borderRadius: '6px',
            },
          },
            h('div', { style: { fontFamily: 'Share Tech Mono', fontSize: '8px', letterSpacing: '0.18em', color: '#EEEEEE', marginBottom: '6px' } }, 'FORCE PIPS'),
            lastRoll.light > 0 && h('div', { style: { fontFamily: 'Rajdhani', fontSize: '12px', color: 'rgba(255,255,255,0.7)', lineHeight: 1.5 } },
              h('span', { style: { color: '#EEEEEE', fontWeight: 700 } }, lastRoll.light + ' light '),
              '\u2192 spend to fuel the power\'s basic ability or any upgrade you own.'
            ),
            lastRoll.dark > 0 && h('div', { style: { fontFamily: 'Rajdhani', fontSize: '12px', color: 'rgba(255,180,180,0.7)', lineHeight: 1.5, marginTop: '4px' } },
              h('span', { style: { color: '#FF8A8A', fontWeight: 700 } }, lastRoll.dark + ' dark '),
              '\u2192 may also fuel the power (each spent triggers 1 Conflict + 1 strain). CRB p.280.'
            ),
            h('div', { style: { fontFamily: 'Share Tech Mono', fontSize: '9px', letterSpacing: '0.12em', color: 'rgba(255,255,255,0.4)', marginTop: '6px' } },
              'Total: ' + (lastRoll.light || 0) + ' light \u00B7 ' + (lastRoll.dark || 0) + ' dark'
            )
          ),

          // Apply-damage button — only when the caller passed a damage context.
          // Server applies soak; the player only sees raw damage going in and
          // (optionally) the resolved wounds_dealt that comes back in the response.
          preset.damageContext && lastRoll.success > 0 && h('div', { style: { marginTop: '10px' } },
            (function() {
              var ctx = preset.damageContext;
              var weaponDmg = ctx.weaponDamage || 0;
              var addFromSuccess = Math.max(0, lastRoll.success);
              var rawDamage = weaponDmg + addFromSuccess;
              return h('button', {
                type: 'button',
                onClick: function() {
                  if (preset.onApplyDamage) {
                    preset.onApplyDamage({
                      group_id: ctx.groupId,
                      raw_damage: rawDamage,
                      strain: 0,
                      source_name: ctx.sourceName || '?',
                    });
                  }
                },
                style: {
                  width: '100%', padding: '10px',
                  background: 'linear-gradient(135deg, #FF555522, #FF555508)',
                  border: '1px solid #FF555566',
                  borderRadius: '6px', cursor: 'pointer',
                  fontFamily: 'Orbitron', fontWeight: 700, fontSize: '11px',
                  letterSpacing: '0.14em', color: '#FF8A8A',
                  textShadow: '0 0 8px #FF555566',
                  minHeight: '44px',
                  display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '3px', lineHeight: 1.2,
                },
              },
                h('span', null, 'APPLY TO ' + (ctx.targetName || 'TARGET').toUpperCase() + ' \u25B6'),
                h('span', { style: { fontFamily: 'Share Tech Mono', fontSize: '9px', letterSpacing: '0.1em', opacity: 0.75 } },
                  weaponDmg + ' base + ' + addFromSuccess + ' success = ' + rawDamage + ' raw damage'
                )
              );
            })()
          )
        )
      )
    );
  }

  // ── FloatingDiceButton — opt-in, renders modal when tapped ──────────────
  function FloatingDiceButton(props) {
    var accent = props.accent || '#F5C842';
    var _o = React.useState(false);
    var open = _o[0];
    var setOpen = _o[1];
    return h(React.Fragment, null,
      h('button', {
        type: 'button',
        onClick: function() { setOpen(true); },
        title: 'Open dice pool',
        'aria-label': 'Open dice pool',
        style: {
          position: 'fixed', bottom: '94px', right: '14px', zIndex: 50,
          width: '54px', height: '54px', borderRadius: '50%',
          background: 'linear-gradient(135deg, ' + accent + '28, ' + accent + '08)',
          border: '1px solid ' + accent + '66',
          color: accent, cursor: 'pointer',
          fontFamily: '"Genesys"', fontSize: '22px',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          boxShadow: '0 4px 16px rgba(0,0,0,0.4), 0 0 18px ' + accent + '28',
          transition: 'transform 0.15s ease, box-shadow 0.15s ease',
        },
        onMouseEnter: function(e) { e.currentTarget.style.transform = 'scale(1.05)'; },
        onMouseLeave: function(e) { e.currentTarget.style.transform = 'scale(1)'; },
      }, 'l'),
      h(DicePoolModal, {
        open: open,
        onClose: function() { setOpen(false); },
        preset: props.preset || {},
        accent: accent,
        onRoll: props.onRoll,
      })
    );
  }

  // ── Imperative opener ──────────────────────────────────────────────────
  // Mounts a one-shot modal into document.body. Use from non-React handlers.
  function openModal(preset) {
    preset = preset || {};
    return new Promise(function(resolve) {
      var host = document.createElement('div');
      host.setAttribute('data-swrpg-dice-host', '1');
      document.body.appendChild(host);
      var root = ReactDOM.createRoot ? ReactDOM.createRoot(host) : null;

      function close() {
        if (root) root.unmount(); else ReactDOM.unmountComponentAtNode(host);
        if (host.parentNode) host.parentNode.removeChild(host);
      }

      function OneShot() {
        var _o = React.useState(true);
        var open = _o[0];
        var setOpen = _o[1];
        var _last = React.useState(null);
        var lastRoll = _last[0];
        var setLastRoll = _last[1];
        // Wrap the caller's onApplyDamage so it also closes the modal
        var wrappedPreset = Object.assign({}, preset);
        if (preset.onApplyDamage) {
          wrappedPreset.onApplyDamage = function(payload) {
            try { preset.onApplyDamage(payload); }
            finally { setOpen(false); setTimeout(function() { close(); resolve(lastRoll); }, 100); }
          };
        }
        return h(DicePoolModal, {
          open: open,
          preset: wrappedPreset,
          accent: preset.accent || '#F5C842',
          onClose: function() { setOpen(false); setTimeout(function() { close(); resolve(lastRoll); }, 100); },
          onRoll: function(res) { setLastRoll(res); },
        });
      }

      if (root) root.render(h(OneShot, null));
      else ReactDOM.render(h(OneShot, null), host);
    });
  }

  // ── Exports ────────────────────────────────────────────────────────────
  Object.assign(D, {
    SYMBOLS: SYMBOLS,
    renderPool: renderPool,
    renderResults: renderResults,
    InlinePoolPreview: InlinePoolPreview,
    RollResults: RollResults,
    DicePoolModal: DicePoolModal,
    FloatingDiceButton: FloatingDiceButton,
    openModal: openModal,
    DIFFICULTY_PRESETS: DIFFICULTY_PRESETS,
  });
})();
