/* OE ProTune - shared floating gauge widget engine (Phase 2)
 * Draggable/resizable gauge or numeric "apps" that stay open across every tab,
 * page, and studio frame. Read-only: renders from the ECU telemetry the server
 * already broadcasts. Used by dash.html, remote.html (Basic Studio), studio.html
 * (Pro Studio shell) and widget.html (single-gauge pop-out).
 *
 * OEPWidgets.init(opts) - opts:
 *   root        DOM node the overlay attaches to (default document.body)
 *   key         localStorage layout key (default 'oepdash_widgets'; shared so a
 *               user's gauges follow them across every browser surface)
 *   addButton   existing button (element or id) that toggles the picker; if
 *               omitted the engine floats its own compact launcher
 *   autoConnect true = open our own WebSocket and self-update (Basic/Pro Studio,
 *               pop-out); false = the host calls OEPWidgets.update(t) each frame
 *   single      { type, style } => single-gauge pop-out mode (fills the window)
 *   force       ignore the in-frame guard (normally embedded frames skip so the
 *               enclosing studio shell owns the one overlay)
 * OEPWidgets.update(t)  - feed a telemetry sample (host-fed mode)
 * OEPWidgets.setPsi(b)  - switch boost/pressure units (shared 'oepdash_psi' key)
 * OEPWidgets.setTempF(b)- switch temperature units (shared 'oepdash_tempF' key)
 */
(function (global) {
  'use strict';

  var KPA2PSI = 0.1450377;
  var GREEN = '#4dff8f', AMBER = '#ffb84d', RED = '#ff5b52';
  var Z_GREEN = '#2fbf6e', Z_AMBER = '#e0a52e', Z_RED = '#e0342a';
  var TICK = '#8b9199', TICKMAJ = '#d7dbe0', NUMCOL = '#c2c7cd', LABCOL = '#7c828a';
  var START = -135 * Math.PI / 180, SWEEP = 270 * Math.PI / 180;

  function num(v, d) { v = Number(v); return isFinite(v) ? v.toFixed(d) : '–'; }
  function state3(v, warn, bad) { return v >= bad ? RED : (v >= warn ? AMBER : GREEN); }
  function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }
  function zoneColor(v, zones) { for (var i = 0; i < zones.length; i++) { if (v >= zones[i].from - 1e-9 && v <= zones[i].to + 1e-9) { return zones[i].color; } } return null; }

  // Analog gauge renderer - identical to the Basic Dashboard's built-in gauges.
  function drawGaugeDial(cv, o) {
    var ctx = cv.getContext('2d'), W = cv.width, cx = W / 2, cy = W / 2, R = W * 0.42;
    ctx.clearRect(0, 0, W, W);
    var span = o.max - o.min;
    var ang = function (v) { var f = Math.max(0, Math.min(1, (v - o.min) / span)); return START + SWEEP * f; };
    var pt = function (r, a) { return [cx + r * Math.sin(a), cy - r * Math.cos(a)]; };
    ctx.save();
    ctx.shadowColor = 'rgba(0,0,0,.55)'; ctx.shadowBlur = W * 0.03; ctx.shadowOffsetY = W * 0.012;
    var mg = ctx.createLinearGradient(0, cy - R * 1.16, 0, cy + R * 1.16);
    mg.addColorStop(0, '#8d929b'); mg.addColorStop(0.28, '#4a4e55'); mg.addColorStop(0.55, '#2b2e33'); mg.addColorStop(1, '#0c0e10');
    ctx.beginPath(); ctx.arc(cx, cy, R * 1.16, 0, 6.2832); ctx.fillStyle = mg; ctx.fill();
    ctx.restore();
    ctx.beginPath(); ctx.arc(cx, cy, R * 1.07, 0, 6.2832); ctx.fillStyle = '#0b0c0e'; ctx.fill();
    var fgr = ctx.createRadialGradient(cx, cy - R * 0.35, R * 0.1, cx, cy, R * 1.04);
    fgr.addColorStop(0, '#1a1d21'); fgr.addColorStop(0.75, '#0a0b0d'); fgr.addColorStop(1, '#040506');
    ctx.beginPath(); ctx.arc(cx, cy, R * 1.02, 0, 6.2832); ctx.fillStyle = fgr; ctx.fill();
    var steps = Math.round(span / o.minor);
    for (var i = 0; i <= steps; i++) {
      var v = o.min + i * o.minor, a = ang(v);
      var maj = Math.abs((v - o.min) / o.major - Math.round((v - o.min) / o.major)) < 1e-6;
      var zc = zoneColor(v, o.zones);
      var rOut = R * 0.98, rIn = maj ? R * 0.82 : R * 0.90;
      var col = zc || (maj ? TICKMAJ : TICK);
      var lw = zc ? (maj ? W * 0.016 : W * 0.013) : (maj ? W * 0.011 : W * 0.006);
      var p1 = pt(rOut, a), p2 = pt(rIn, a);
      ctx.beginPath(); ctx.moveTo(p1[0], p1[1]); ctx.lineTo(p2[0], p2[1]); ctx.strokeStyle = col; ctx.lineWidth = lw; ctx.lineCap = 'butt'; ctx.stroke();
      if (maj) { var np = pt(R * 0.68, a); ctx.fillStyle = NUMCOL; ctx.font = '600 ' + Math.round(W * 0.046) + 'px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(o.numFmt(v), np[0], np[1]); }
    }
    ctx.fillStyle = LABCOL; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    if (o.label2) { ctx.font = '600 ' + Math.round(W * 0.06) + 'px Arial'; ctx.fillText(o.label, cx, cy - R * 0.4); ctx.font = '600 ' + Math.round(W * 0.042) + 'px Arial'; ctx.fillText(o.label2, cx, cy - R * 0.24); }
    else { ctx.font = '600 ' + Math.round(W * 0.046) + 'px Arial'; ctx.fillText(o.label, cx, cy - R * 0.32); }
    var bw = R * 0.86, bh = R * 0.32, bx = cx - bw / 2, by = cy + R * 0.32;
    roundRect(ctx, bx, by, bw, bh, bh * 0.18); ctx.fillStyle = '#080a0b'; ctx.fill();
    ctx.lineWidth = W * 0.004; ctx.strokeStyle = '#232829'; ctx.stroke();
    var fs = bh * 0.6; ctx.font = '700 ' + Math.round(fs) + 'px "Consolas","Menlo",monospace';
    while (ctx.measureText(o.digital).width > bw * 0.86 && fs > 8) { fs -= 1; ctx.font = '700 ' + Math.round(fs) + 'px "Consolas","Menlo",monospace'; }
    ctx.fillStyle = o.digitalColor; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(o.digital, cx, by + bh * 0.54);
    var na = ang(o.value), tip = pt(R * 0.9, na);
    var bA1 = na + Math.PI / 2, bA2 = na - Math.PI / 2, bwid = W * 0.017;
    var b1 = [cx + bwid * Math.sin(bA1), cy - bwid * Math.cos(bA1)], b2 = [cx + bwid * Math.sin(bA2), cy - bwid * Math.cos(bA2)], tail = pt(R * 0.2, na);
    ctx.beginPath(); ctx.moveTo(b1[0], b1[1]); ctx.lineTo(tail[0], tail[1]); ctx.lineTo(b2[0], b2[1]); ctx.closePath(); ctx.fillStyle = '#a81c15'; ctx.fill();
    ctx.save(); ctx.shadowColor = 'rgba(0,0,0,.5)'; ctx.shadowBlur = W * 0.012; ctx.shadowOffsetX = W * 0.006; ctx.shadowOffsetY = W * 0.006;
    ctx.beginPath(); ctx.moveTo(b1[0], b1[1]); ctx.lineTo(tip[0], tip[1]); ctx.lineTo(b2[0], b2[1]); ctx.closePath();
    var ng = ctx.createLinearGradient(cx, cy, tip[0], tip[1]); ng.addColorStop(0, '#ff6259'); ng.addColorStop(1, '#c81f16'); ctx.fillStyle = ng; ctx.fill(); ctx.restore();
    var hg = ctx.createRadialGradient(cx - R * 0.03, cy - R * 0.03, R * 0.01, cx, cy, R * 0.14);
    hg.addColorStop(0, '#565b61'); hg.addColorStop(0.55, '#24272c'); hg.addColorStop(1, '#0b0c0e');
    ctx.beginPath(); ctx.arc(cx, cy, R * 0.13, 0, 6.2832); ctx.fillStyle = hg; ctx.fill();
    ctx.beginPath(); ctx.arc(cx, cy, R * 0.05, 0, 6.2832); ctx.fillStyle = '#111316'; ctx.fill();
  }

  // ================= AEM X-Series widget skin (mirrors the Basic Dashboard skin) =================
  // Same 24-LED / 7-seg / emblem treatment as dash.html, self-contained so pop-out widget windows
  // (widget.html) work standalone. Shares the 'oepdash_gaugeskin' pref; renders the SAME opts() `o`.
  var gaugeSkin = 'dial';
  try { gaugeSkin = localStorage.getItem('oepdash_gaugeskin') || 'dial'; } catch (e) {}
  if (gaugeSkin !== 'aem' && gaugeSkin !== 'dial') { gaugeSkin = 'dial'; }
  var AEM_LOGO = null, AEM_LOGO_BB = null;
  (function loadAemLogo() {
    try {
      var img = new Image();
      img.onload = function () {
        try {
          var oc = document.createElement('canvas'); oc.width = img.width; oc.height = img.height;
          var og = oc.getContext('2d'); og.drawImage(img, 0, 0);
          var im = og.getImageData(0, 0, oc.width, oc.height), p = im.data;
          var minx = oc.width, miny = oc.height, maxx = 0, maxy = 0, f = false;
          for (var y = 0; y < oc.height; y++) for (var x = 0; x < oc.width; x++) {
            var idx = (y * oc.width + x) * 4;
            if (p[idx] > 232 && p[idx + 1] > 232 && p[idx + 2] > 232) { p[idx + 3] = 0; }
            else if (p[idx + 3] > 10) { f = true; if (x < minx) minx = x; if (x > maxx) maxx = x; if (y < miny) miny = y; if (y > maxy) maxy = y; }
          }
          og.putImageData(im, 0, 0);
          AEM_LOGO = oc; AEM_LOGO_BB = f ? { x: minx, y: miny, w: maxx - minx + 1, h: maxy - miny + 1 } : { x: 0, y: 0, w: oc.width, h: oc.height };
          if (gaugeSkin === 'aem') { try { if (single) { redraw(single, feedLast); } for (var ri = 0; ri < items.length; ri++) { redraw(items[ri], feedLast); } } catch (e2) {} }
        } catch (e) {}
      };
      img.src = '/ui/modules/apps/OEProTune/assets/aem-logo.png';
    } catch (e) {}
  })();
  var AEM_SEG = { '0':[1,1,1,1,1,1,0],'1':[0,1,1,0,0,0,0],'2':[1,1,0,1,1,0,1],'3':[1,1,1,1,0,0,1],'4':[0,1,1,0,0,1,1],'5':[1,0,1,1,0,1,1],'6':[1,0,1,1,1,1,1],'7':[1,1,1,0,0,0,0],'8':[1,1,1,1,1,1,1],'9':[1,1,1,1,0,1,1],'-':[0,0,0,0,0,0,1],' ':[0,0,0,0,0,0,0] };
  function aemSeg7(ctx, x, y, w, h, ch, onC, offC) {
    var s = AEM_SEG[ch] || AEM_SEG[' '], t = Math.max(2, w * 0.16), o = t * 0.55;
    function bar(x1, y1, x2, y2, on) {
      ctx.strokeStyle = on ? onC : offC; ctx.lineWidth = t; ctx.lineCap = 'round';
      if (on) { ctx.shadowColor = onC; ctx.shadowBlur = t * 1.1; } else ctx.shadowBlur = 0;
      ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.shadowBlur = 0;
    }
    var xl = x + o, xr = x + w - o, yt = y + o, ym = y + h / 2, yb = y + h - o;
    bar(xl + o, yt, xr - o, yt, s[0]); bar(xr, yt + o, xr, ym - o, s[1]); bar(xr, ym + o, xr, yb - o, s[2]);
    bar(xl + o, yb, xr - o, yb, s[3]); bar(xl, ym + o, xl, yb - o, s[4]); bar(xl, yt + o, xl, ym - o, s[5]); bar(xl + o, ym, xr - o, ym, s[6]);
  }
  function aemDigits(ctx, cx, cy, str, size) {
    var dw = size * 0.62, dh = size, gap = size * 0.18, dot = size * 0.24, tot = 0, i;
    for (i = 0; i < str.length; i++) { tot += (str[i] === '.') ? dot : dw; if (i) tot += gap; }
    var x = cx - tot / 2, onC = '#ff2f1c', offC = 'rgba(120,18,8,0.24)';
    for (i = 0; i < str.length; i++) {
      if (str[i] === '.') { ctx.fillStyle = onC; ctx.shadowColor = onC; ctx.shadowBlur = size * 0.16;
        ctx.beginPath(); ctx.arc(x + dot * 0.5, cy + dh * 0.5 - size * 0.06, size * 0.088, 0, 7); ctx.fill(); ctx.shadowBlur = 0; x += dot + gap; }
      else { aemSeg7(ctx, x, cy - dh / 2, dw, dh, str[i], onC, offC); x += dw + gap; }
    }
  }
  // rich->lean colour; handles both lambda-scale (v<3) and AFR-scale gauges
  function aemAfrColor(v) { var afr = v < 3 ? v * 14.7 : v; if (afr < 11.8) return '#ff7a12'; if (afr < 12.8) return '#ffd21c'; if (afr < 15.2) return '#39d42a'; if (afr < 16.5) return '#eaf23a'; return '#ff3626'; }
  function drawGaugeAEM(cv, o) {
    var ctx = cv.getContext('2d'), W = cv.width, cx = W / 2, cy = W * 0.47, R = W * 0.40, DEG = Math.PI / 180;
    ctx.clearRect(0, 0, W, cv.height);
    var isAfr = !!o.aemAfr;
    var bz = ctx.createRadialGradient(cx - R * 0.3, cy - R * 0.35, R * 0.2, cx, cy, R * 1.16);
    bz.addColorStop(0, '#3a3d42'); bz.addColorStop(0.55, '#17181b'); bz.addColorStop(1, '#050506');
    ctx.fillStyle = bz; ctx.beginPath(); ctx.arc(cx, cy, R * 1.16, 0, 7); ctx.fill();
    ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.stroke();
    var fg = ctx.createRadialGradient(cx - R * 0.25, cy - R * 0.3, R * 0.1, cx, cy, R);
    fg.addColorStop(0, '#131417'); fg.addColorStop(1, '#050506');
    ctx.fillStyle = fg; ctx.beginPath(); ctx.arc(cx, cy, R, 0, 7); ctx.fill();
    var N = 24, start = 135, span = 270, ledR = R * 0.88, lw = R * 0.13;
    var frac = Math.max(0, Math.min(1, (o.value - o.min) / (o.max - o.min))), lit = Math.round(frac * N);
    for (var i = 0; i < N; i++) {
      var a0 = (start + i / N * span + 1.4) * DEG, a1 = (start + (i + 1) / N * span - 1.4) * DEG, on = i < lit;
      var segVal = o.min + (i + 0.5) / N * (o.max - o.min);
      var col = on ? (isAfr ? aemAfrColor(segVal) : (zoneColor(segVal, o.zones) || '#39d42a')) : 'rgba(40,60,40,0.5)';
      ctx.strokeStyle = col; ctx.lineWidth = lw; ctx.lineCap = 'butt';
      if (on) { ctx.shadowColor = col; ctx.shadowBlur = lw * 0.9; } else ctx.shadowBlur = 0;
      ctx.beginPath(); ctx.arc(cx, cy, ledR, a0, a1); ctx.stroke(); ctx.shadowBlur = 0;
    }
    var steps = Math.round((o.max - o.min) / o.major);
    for (var t2 = 0; t2 <= steps; t2++) {
      var tv = o.min + t2 * o.major, tf = (tv - o.min) / (o.max - o.min), ta = (start + tf * span) * DEG, ca = Math.cos(ta), sa = Math.sin(ta);
      ctx.strokeStyle = '#f2f5f7'; ctx.lineWidth = Math.max(1.5, R * 0.02); ctx.lineCap = 'round';
      ctx.beginPath(); ctx.moveTo(cx + ca * R * 0.79, cy + sa * R * 0.79); ctx.lineTo(cx + ca * R * 0.72, cy + sa * R * 0.72); ctx.stroke();
      ctx.fillStyle = '#f2f5f7'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = '700 ' + (R * 0.115) + 'px Arial';
      ctx.fillText(o.numFmt(tv), cx + ca * R * 0.60, cy + sa * R * 0.60);
    }
    ctx.fillStyle = 'rgba(0,0,0,0.45)';
    var wx = cx - R * 0.66, wy = cy - R * 0.30, ww = R * 1.32, wh = R * 0.60, rr = R * 0.06;
    ctx.beginPath(); ctx.moveTo(wx + rr, wy); ctx.arcTo(wx + ww, wy, wx + ww, wy + wh, rr); ctx.arcTo(wx + ww, wy + wh, wx, wy + wh, rr);
    ctx.arcTo(wx, wy + wh, wx, wy, rr); ctx.arcTo(wx, wy, wx + ww, wy, rr); ctx.fill();
    if (AEM_LOGO) { var tw = R * 0.60, th = tw * AEM_LOGO_BB.h / AEM_LOGO_BB.w; ctx.drawImage(AEM_LOGO, AEM_LOGO_BB.x, AEM_LOGO_BB.y, AEM_LOGO_BB.w, AEM_LOGO_BB.h, cx - tw / 2, cy - R * 0.15 - th / 2, tw, th); }
    var m = String(o.digital).match(/-?\d+(\.\d+)?/), digStr = m ? m[0] : '----';
    var dl = digStr.replace(/[.\-]/g, '').length, dsize = R * (dl >= 4 ? 0.34 : 0.40);
    aemDigits(ctx, cx, cy + R * 0.15, digStr, dsize);
    var pname = o.title || o.label || '';
    ctx.fillStyle = '#ffffff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    var lf = R * 0.13; ctx.font = '800 ' + lf + 'px Arial';
    while (ctx.measureText(pname).width > R * 1.5 && lf > 6) { lf -= 1; ctx.font = '800 ' + lf + 'px Arial'; }
    ctx.fillText(pname, cx, cy + R * 0.52);
    ctx.fillStyle = '#c4c9cf'; ctx.font = '600 ' + (R * 0.08) + 'px Arial';
    ctx.fillText(o.unit || o.label || '', cx, cy + R * 0.64);
    var btns = isAfr ? ['MODE', 'SEL'] : ['WARN', 'PEAK'];
    for (var b = 0; b < 2; b++) {
      var bx = cx + (b ? 1 : -1) * R * 0.40, by = cy + R * 0.86;
      var bg2 = ctx.createLinearGradient(bx, by - 5, bx, by + 7); bg2.addColorStop(0, '#2b2d31'); bg2.addColorStop(1, '#0c0d0f');
      ctx.fillStyle = bg2; ctx.beginPath(); ctx.arc(bx, by, R * 0.09, 0, 7); ctx.fill(); ctx.strokeStyle = '#000'; ctx.lineWidth = 1.5; ctx.stroke();
      ctx.fillStyle = '#9aa0a6'; ctx.font = '700 ' + (R * 0.07) + 'px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      ctx.fillText(btns[b], bx, by + R * 0.19);
    }
  }
  function drawGauge(cv, o) { if (gaugeSkin === 'aem') { drawGaugeAEM(cv, o); } else { drawGaugeDial(cv, o); } }

  // ---- units (shared with the dashboard's PSI/kPa toggle) ----
  var psi = true;
  try { var sv = localStorage.getItem('oepdash_psi'); if (sv !== null) { psi = sv === '1'; } } catch (e) {}

  // ---- temperature display units (shared 'oepdash_tempF' key: '1' = °F, else °C) ----
  var tempF = false;
  try { tempF = localStorage.getItem('oepdash_tempF') === '1'; } catch (e) {}
  function tempFOn() { return tempF; }
  function cToF(c) { return c * 9 / 5 + 32; }

  // ---- channel catalog: t=title, u=unit, d=decimals, v=value(t), c=color(t), g=gauge scale(t) ----
  function lim(t) { return Number(t.activeRevLimit) || Number(t.commandedRevLimit) || 8000; }
  // V3.0: the boost gauge used to be a fixed 30 psi / 200 kPa scale - a 100+ psi pull-rig build kept
  // it pegged. It now steps up through fixed tiers as the session peak (boost seen OR active target)
  // grows; monotonic within a car, reset on vehicle switch. Mirrors the dash.html boost dial.
  // Tier geometry is deliberate: (top - min) stays an exact multiple of major with 0 on a tick -
  // the dial AND AEM tick loops assume integral spans (a fractional span draws a phantom tick past
  // full scale). kPa tops out at 900 GAUGE = the 1000 kPa absolute ceiling minus atmosphere.
  var boostPeakGaugeKpa = 0;
  var BOOST_TIERS_PSI = [{ top: 30, min: -20, major: 10 }, { top: 60, min: -20, major: 20 }, { top: 100, min: -20, major: 20 }, { top: 150, min: -30, major: 30 }];
  var BOOST_TIERS_KPA = [{ top: 200, min: -100, major: 50 }, { top: 400, min: -100, major: 100 }, { top: 600, min: -100, major: 100 }, { top: 900, min: -100, major: 100 }];
  function boostGaugeTier(tiers, peak) { for (var i = 0; i < tiers.length; i++) { if (peak * 1.04 <= tiers[i].top) { return tiers[i]; } } return tiers[tiers.length - 1]; }
  function lamColor(t) { var d = (Number(t.lambdaActual) || 0) - (Number(t.lambdaTarget) || 1); return d > 0.05 ? RED : (d < -0.05 ? AMBER : GREEN); }
  var lam2 = function (x) { return isFinite(x) ? x.toFixed(2) : '–'; };
  var k1000 = function (x) { return String(Math.round(x / 1000)); };
  var CH = {
    rpm:    { t: 'RPM',     u: 'rpm',  d: 0, v: function (t) { return Number(t.rpm) || 0; }, c: function (t) { var l = lim(t), r = Number(t.rpm) || 0; return r >= l ? RED : (r >= l - 500 ? AMBER : GREEN); }, g: function (t) { var mx = Math.max(8000, Math.ceil((lim(t) + 800) / 1000) * 1000); return { min: 0, max: mx, major: 1000, minor: 200, numFmt: k1000, label: 'x1000' }; } },
    speed:  { t: 'SPEED',   u: 'km/h', d: 0, v: function (t) { return Number(t.speedKmh) || 0; }, c: function () { return GREEN; }, g: function () { return { min: 0, max: 280, major: 40, minor: 10 }; } },
    boost:  { t: 'BOOST',   u: function () { return psi ? 'psi' : 'kPa'; }, d: function () { return psi ? 1 : 0; }, v: function (t) { var baro = Number(t.baroKpa) || 100; var b = (Number(t.mapKpa) || 100) - baro; var tg = (Number(t.boostTargetKpa) || baro) - baro; if (b > boostPeakGaugeKpa) { boostPeakGaugeKpa = b; } if (tg > boostPeakGaugeKpa) { boostPeakGaugeKpa = tg; } return psi ? b * KPA2PSI : b; }, c: function (t) { return t.boostMaxed ? AMBER : GREEN; }, g: function () { var bt = psi ? boostGaugeTier(BOOST_TIERS_PSI, boostPeakGaugeKpa * KPA2PSI) : boostGaugeTier(BOOST_TIERS_KPA, boostPeakGaugeKpa); return { min: bt.min, max: bt.top, major: bt.major, minor: bt.major / 5, zones: [{ from: bt.top * 0.75, to: bt.top, color: Z_RED }] }; } },
    lambda: { t: 'LAMBDA',  u: 'λ',    d: 2, v: function (t) { return Number(t.lambdaActual) || 0; }, c: lamColor, g: function () { return { min: 0.70, max: 1.30, major: 0.10, minor: 0.02, numFmt: lam2, label: 'λ', zones: [{ from: 0.88, to: 1.03, color: Z_GREEN }] }; } },
    afr:    { t: 'AFR',     u: 'AFR',  d: 1, v: function (t) { return (Number(t.lambdaActual) || 0) * (Number(t.stoichRatio) || 14.7); }, c: lamColor, g: function () { return { min: 8, max: 20, major: 2, minor: 0.5 }; } },
    ign:    { t: 'IGN ADV', u: '°',    d: 1, v: function (t) { return Number(t.ignitionAdvance) || 0; }, c: function (t) { return (Number(t.knockRetard) || 0) > 0.1 ? AMBER : GREEN; }, g: function () { return { min: -25, max: 75, major: 10, minor: 2, zones: [{ from: 50, to: 75, color: Z_AMBER }] }; } },
    knock:  { t: 'KNOCK',   u: '°',    d: 1, v: function (t) { return Number(t.knockRetard) || 0; }, c: function (t) { var k = Number(t.knockRetard) || 0; return k > 5 ? RED : (k > 0.1 ? AMBER : GREEN); }, g: function () { return { min: 0, max: 30, major: 5, minor: 1, zones: [{ from: 15, to: 24, color: Z_AMBER }, { from: 24, to: 30, color: Z_RED }] }; } },
    egt:    { t: 'EGT',     u: '°C',   d: 0, v: function (t) { return Number(t.egtC) || 0; }, c: function (t) { return state3(Number(t.egtC) || 0, 850, 950); }, g: function () { return { min: 0, max: 1200, major: 200, minor: 50, zones: [{ from: 850, to: 950, color: Z_AMBER }, { from: 950, to: 1200, color: Z_RED }] }; } },
    ect:    { t: 'COOLANT', u: '°C',   d: 0, v: function (t) { return Number(t.ectC) || 0; }, c: function (t) { return state3(Number(t.ectC) || 0, 104, 112); }, g: function () { return { min: 40, max: 120, major: 20, minor: 5, zones: [{ from: 82, to: 104, color: Z_GREEN }, { from: 110, to: 120, color: Z_RED }] }; } },
    oil:    { t: 'OIL TEMP',u: '°C',   d: 0, v: function (t) { return Number(t.oilC) || 0; }, c: function (t) { return state3(Number(t.oilC) || 0, 120, 135); }, g: function () { return { min: 40, max: 160, major: 20, minor: 10, zones: [{ from: 140, to: 160, color: Z_RED }] }; } },
    iat:    { t: 'INTAKE',  u: '°C',   d: 0, v: function (t) { return Number(t.iatC) || 0; }, c: function (t) { return state3(Number(t.iatC) || 0, 50, 70); }, g: function () { return { min: 0, max: 100, major: 20, minor: 5, zones: [{ from: 50, to: 70, color: Z_AMBER }, { from: 70, to: 100, color: Z_RED }] }; } },
    duty:   { t: 'INJ DUTY',u: '%',    d: 0, v: function (t) { return Number(t.injectorDuty) || 0; }, c: function (t) { return state3(Number(t.injectorDuty) || 0, 80, 92); }, g: function () { return { min: 0, max: 100, major: 20, minor: 5, zones: [{ from: 80, to: 92, color: Z_AMBER }, { from: 92, to: 100, color: Z_RED }] }; } },
    map:    { t: 'ECU MAP', u: 'slot', d: 0, v: function (t) { return Number(t.mapSlot) || 1; }, c: function (t) { return (Number(t.mapSlotPending) || 0) > 0 ? AMBER : GREEN; }, g: function () { return { min: 0, max: 4, major: 1, minor: 1 }; } }
  };
  var ORDER = ['rpm', 'speed', 'boost', 'lambda', 'afr', 'ign', 'knock', 'egt', 'ect', 'oil', 'iat', 'duty', 'map'];

  function unitOf(ch) { return typeof ch.u === 'function' ? ch.u() : ch.u; }
  function decOf(ch) { return typeof ch.d === 'function' ? ch.d() : ch.d; }
  var TEMP_CH = { egt: 1, ect: 1, oil: 1, iat: 1 };   // channel ids whose native °C display converts to °F
  function opts(ch, t, key) {
    var g = ch.g(t), v = ch.v(t);
    var min = g.min, max = g.max, major = g.major, minor = g.minor, zones = g.zones || [], unit = unitOf(ch);
    if (tempFOn() && TEMP_CH[key]) {   // display-only: value + full scale render in °F, thresholds/zones stay native °C
      v = cToF(v); min = cToF(min); max = cToF(max); major = major * 9 / 5; minor = minor * 9 / 5;
      zones = zones.map(function (z) { return { from: cToF(z.from), to: cToF(z.to), color: z.color }; });
      unit = '°F';
    }
    return { min: min, max: max, major: major, minor: minor, zones: zones,
      numFmt: g.numFmt || function (x) { return num(x, 0); },
      label: (g.label != null ? g.label : unit), value: v, digital: num(v, decOf(ch)), unit: unit, digitalColor: ch.c(t),
      title: ch.t, aemAfr: (key === 'lambda' || key === 'afr') };   // extras for the AEM skin (dial skin ignores them)
  }

  var CSS = [
    '#oepw-layer{position:fixed;inset:0;z-index:2147483000;pointer-events:none;}',
    '#oepw-layer.oepw-link-backdrop{background:#050607;}',
    '#oepw-shield{position:fixed;inset:0;z-index:2147482999;background:transparent;}',
    '.oepw{position:absolute;pointer-events:auto;display:flex;flex-direction:column;overflow:hidden;font-family:"Segoe UI",Roboto,Arial,sans-serif;',
    'background:rgba(10,11,13,.92);border:1px solid #2a2e33;border-radius:8px;box-shadow:0 6px 22px rgba(0,0,0,.55);}',
    '.oepw-bar{flex:0 0 auto;height:22px;display:flex;align-items:center;gap:4px;padding:0 4px 0 8px;background:#111316;border-bottom:1px solid #1c1f24;cursor:move;touch-action:none;font-size:10px;font-weight:800;letter-spacing:1px;color:#aeb4ba;}',
    '.oepw-bar .t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
    '.oepw-bar button{background:transparent;border:0;color:#6b7178;font:inherit;font-size:12px;line-height:1;cursor:pointer;padding:2px 3px;}',
    '.oepw-bar button:hover{color:#ffc233;}',
    '.oepw-body{flex:1 1 auto;min-height:0;position:relative;display:flex;align-items:center;justify-content:center;}',
    '.oepw-body canvas{display:block;}',
    '.oepw-num{display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1;padding:2px;}',
    '.oepw-num-v{font-weight:800;font-variant-numeric:tabular-nums;}',
    '.oepw-num-u{font-size:11px;font-weight:700;letter-spacing:1px;color:#7c828a;margin-top:4px;}',
    '.oepw-rz{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;touch-action:none;}',
    '.oepw-rz::after{content:"";position:absolute;right:3px;bottom:3px;width:7px;height:7px;border-right:2px solid #4a5058;border-bottom:2px solid #4a5058;}',
    '.oepw-single{position:fixed;inset:0;border:0;border-radius:0;box-shadow:none;background:#060708;}',
    '.oepw-linkdash{background:transparent;}',
    '.oepw-single.oepw-linkdash .oepw-bar{display:none;}',
    '#oepw-fab{position:fixed;right:14px;bottom:14px;z-index:2147483001;display:flex;flex-direction:column;gap:6px;}',
    '#oepw-fab button{width:34px;height:34px;border-radius:50%;border:1px solid #2a2e33;background:#14171b;line-height:1;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.5);touch-action:none;padding:0;}',
    '#oepw-fab button:hover{background:#1b1e22;}',
    '#oepw-launch{color:#ffc233;font-size:17px;font-weight:800;}',
    '#oepw-fs{color:#aeb4ba;font-size:16px;}',
    '#oepw-fs:hover{color:#e8eaed;}',
    '#oepw-picker{position:fixed;right:14px;bottom:56px;z-index:2147483002;width:224px;max-height:64vh;overflow-y:auto;background:#0c0d0f;border:1px solid #2a2e33;border-radius:8px;box-shadow:0 8px 26px rgba(0,0,0,.6);padding:8px;font-family:"Segoe UI",Roboto,Arial,sans-serif;}',
    '.oepw-pk-head{display:flex;align-items:center;margin-bottom:8px;font-size:11px;font-weight:800;letter-spacing:1.5px;color:#d3d7dc;}',
    '.oepw-pk-head span{flex:1;}',
    '.oepw-pk-row{display:flex;align-items:center;gap:6px;margin-bottom:8px;font-size:11px;font-weight:700;color:#8b9199;}',
    '.oepw-pk-row .lbl{flex:1;}',
    '.oepw-pk-head button,.oepw-pk-seg button,.oepw-pk-grid button,.oepw-pk-row button{background:#111316;border:1px solid #24282e;border-radius:4px;color:#c8ccd2;font:inherit;cursor:pointer;}',
    '.oepw-pk-head button{border:0;background:transparent;color:#6b7178;font-size:12px;}',
    '.oepw-pk-head button:hover{color:#e8eaed;}',
    '.oepw-pk-row button{font-size:11px;font-weight:700;padding:3px 9px;}',
    '.oepw-pk-row button.on{color:#ffc233;border-color:#3a3e44;background:#1b1e22;}',
    '.oepw-pk-seg{display:flex;gap:4px;margin-bottom:8px;}',
    '.oepw-pk-seg button{flex:1;color:#8b9199;font-size:11px;font-weight:700;padding:5px;}',
    '.oepw-pk-seg button.on{color:#ffc233;border-color:#3a3e44;background:#1b1e22;}',
    '.oepw-pk-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px;}',
    '.oepw-pk-grid button{font-size:11px;font-weight:700;letter-spacing:.5px;padding:7px 4px;}',
    '.oepw-pk-grid button:hover{color:#ffc233;border-color:#3a3e44;}',
    '.oepw-pk-dash{grid-column:1 / -1;color:#ffc233 !important;border-color:#4a3c1c !important;background:#191512 !important;}',
    '#oepw-dashcfg{position:fixed;z-index:2147483003;width:238px;background:#0c0d0f;border:1px solid #2a2e33;border-radius:8px;box-shadow:0 8px 26px rgba(0,0,0,.6);padding:8px;font-family:"Segoe UI",Roboto,Arial,sans-serif;}',
    '.oepw-sel{background:#111316;border:1px solid #24282e;border-radius:4px;color:#c8ccd2;font:inherit;font-size:11px;font-weight:700;padding:3px 4px;max-width:132px;}',
    '.oepw-pk-hint{font-size:10px;color:#6b7178;line-height:1.45;margin-top:6px;}'
  ].join('');

  function injectCSS() { if (document.getElementById('oepw-css')) { return; } var s = document.createElement('style'); s.id = 'oepw-css'; s.textContent = CSS; (document.head || document.documentElement).appendChild(s); }
  function el(tag, cls) { var e = document.createElement(tag); if (cls) { e.className = cls; } return e; }
  function owns(o, k) { return !!o && Object.prototype.hasOwnProperty.call(o, k); }

  // ---- engine state ----
  var BARH = 22, MINW = 78;
  var layer = null, picker = null, launcher = null, pickerStyle = 'gauge', pkUnitBtns = null, pkTempBtns = null;
  var items = [], spawn = 0, LSKEY = 'oepdash_widgets', feedLast = null, feedLastAt = 0, feedRedrawPending = false, single = null, feedMode = false;

  function clampBox(w) {
    var vw = window.innerWidth, vh = window.innerHeight;
    w.w = Math.max(MINW, Math.min(w.w, vw)); w.h = Math.max(BARH + 40, Math.min(w.h, vh));
    w.x = Math.max(0, Math.min(w.x, vw - w.w)); w.y = Math.max(0, Math.min(w.y, vh - w.h));
  }
  function refreshLinkBackdrop() {
    if (!layer) { return; }
    var active = items.some(function (w) {
      if (!w || w.type !== 'linkdash') { return false; }
      var maxW = Math.min(window.innerWidth, Math.max(1, window.innerHeight - BARH) * dashAspect(w));
      return w.w >= maxW * 0.88;
    });
    layer.classList.toggle('oepw-link-backdrop', active);
  }
  function place(w) { w.node.style.left = w.x + 'px'; w.node.style.top = w.y + 'px'; w.node.style.width = w.w + 'px'; w.node.style.height = w.h + 'px'; redraw(w, feedLast); refreshLinkBackdrop(); }
  function dashAspect(w) { return (global.OEPLinkDash && global.OEPLinkDash.aspect(w.cfg)) || 1.86; }
  // Persisted cfg survives mod updates, so never trust it verbatim: merge known keys over
  // fresh defaults and drop channel keys the current build no longer has.
  function sanitizeDashCfg(cfg) {
    var LD = global.OEPLinkDash, def = LD.defaults();
    if (!cfg || typeof cfg !== 'object') { return def; }
    // Skin revisions are visual migrations. V7 locks the clean Link MXG 7
    // tach geometry, live RPM pointer, sharp backing store, and button UI state.
    var savedRevision = Number(cfg.skinVersion), currentRevision = Number(def.skinVersion || 0);
    if (!isFinite(savedRevision) || savedRevision < currentRevision) {
      // Preserve user channel/brightness preferences while resetting the old
      // mock page and bezel version to the photographed MXG Street target.
      if (cfg.model === '5' || cfg.model === '7') { def.model = cfg.model; }
      if (cfg.brightness === 'day' || cfg.brightness === 'night' || cfg.brightness === 'auto') { def.brightness = cfg.brightness; }
      if (isFinite(Number(cfg.brightnessLevel))) { def.brightnessLevel = Number(cfg.brightnessLevel); }
      if (cfg.shiftStartPct != null && isFinite(Number(cfg.shiftStartPct))) { def.shiftStartPct = Number(cfg.shiftStartPct); }
      if (cfg.shiftColors === 'std' || cfg.shiftColors === 'classic' || cfg.shiftColors === 'red') { def.shiftColors = cfg.shiftColors; }
      if (Array.isArray(cfg.boxes)) {
        for (var mi = 0; mi < def.boxes.length; mi++) {
          if (owns(LD.channels, cfg.boxes[mi])) { def.boxes[mi] = cfg.boxes[mi]; }
        }
      }
      if (LD.pages && LD.pages.indexOf(cfg.page) >= 0) { def.page = cfg.page; }
      if (cfg.showReference === true) { def.showReference = true; }
      if (cfg.showGlare === false) { def.showGlare = false; }
      if (cfg.showPixelGrid === false) { def.showPixelGrid = false; }
      if (def.shiftStartPct != null) { def.shiftStartPct = Math.max(20, Math.min(100, Number(def.shiftStartPct) || 80)); }
      def.brightnessLevel = Math.max(0, Math.min(100, Number(def.brightnessLevel) || 50));
      return def;
    }
    Object.keys(def).forEach(function (k) {
      if (k === 'boxes' || cfg[k] === undefined) { return; }
      def[k] = cfg[k];
    });
    def.model = def.model === '5' ? '5' : '7';
    def.version = def.version === 'race' ? 'race' : 'street';
    def.brightness = (def.brightness === 'night' || def.brightness === 'auto') ? def.brightness : 'day';
    def.shiftColors = (def.shiftColors === 'classic' || def.shiftColors === 'red') ? def.shiftColors : 'std';
    if (def.shiftStartPct != null) { def.shiftStartPct = Math.max(20, Math.min(100, Number(def.shiftStartPct) || 80)); }
    def.brightnessLevel = Math.max(0, Math.min(100, Number(def.brightnessLevel) || 50));
    if (Array.isArray(cfg.boxes)) {
      for (var i = 0; i < def.boxes.length; i++) {
        if (owns(LD.channels, cfg.boxes[i])) { def.boxes[i] = cfg.boxes[i]; }
      }
    }
    if (!LD.pages || LD.pages.indexOf(def.page) < 0) { def.page = (LD.pages && LD.pages[0]) || 'ring'; }
    return def;
  }
  function redraw(w, t) {
    t = t || {};
    if (w.type === 'linkdash') {
      if (!global.OEPLinkDash || !w.canvas) { return; }
      var ar = dashAspect(w);
      var cssW = Math.max(120, w.body.clientWidth), cssH = Math.round(cssW / ar);
      if (w.body.clientHeight > 40 && cssH > w.body.clientHeight) { cssH = w.body.clientHeight; cssW = Math.round(cssH * ar); }
      // A high-DPI backing store keeps the thin segmented tach band and display
      // lettering sharp on phones/tablets while retaining the 10 Hz update rate.
      var pixelRatio = Math.max(1, Math.min(2, Number(global.devicePixelRatio) || 1));
      var dw = Math.max(120, Math.round(Math.min(cssW * pixelRatio, 2400)));
      var dh = Math.max(65, Math.round(dw / ar));
      if (dh > 1300) { dh = 1300; dw = Math.round(dh * ar); }
      w.canvas.style.width = cssW + 'px'; w.canvas.style.height = cssH + 'px';
      if (w.canvas.width !== dw || w.canvas.height !== dh) { w.canvas.width = dw; w.canvas.height = dh; }
      // isolate renderer faults: one bad persisted dash must never abort init/load or freeze siblings
      try { global.OEPLinkDash.draw(w.canvas, t, w.dash, w.cfg); }
      catch (e) {
        if (!w.renderErrorLogged && global.console && global.console.error) {
          w.renderErrorLogged = true; global.console.error('LINK MXG renderer failed', e);
        }
      }
      return;
    }
    var ch = CH[w.type]; if (!ch) { return; }
    if (w.style === 'num') {
      var o = opts(ch, t, w.type);
      w.vEl.textContent = o.digital; w.vEl.style.color = o.digitalColor; w.uEl.textContent = o.unit;
      w.vEl.style.fontSize = Math.max(14, Math.min(w.body.clientHeight * 0.5, w.body.clientWidth * 0.44)) + 'px';
    } else {
      var s = Math.max(40, Math.min(w.body.clientWidth, w.body.clientHeight));
      if (w.canvas.width !== s) { w.canvas.width = s; w.canvas.height = s; }
      drawGauge(w.canvas, opts(ch, t, w.type));
    }
  }
  function buildBody(w) {
    while (w.body.firstChild) { w.body.removeChild(w.body.firstChild); }
    if (w.type === 'linkdash') {
      w.canvas = el('canvas'); w.canvas.width = 400; w.canvas.height = 215; w.body.appendChild(w.canvas);
      w.canvas.style.touchAction = 'manipulation';
      w.canvas.addEventListener('click', function (e) {
        if (!global.OEPLinkDash) { return; }
        var r = w.canvas.getBoundingClientRect(), sx = w.canvas.width / r.width, sy = w.canvas.height / r.height;
        var action = global.OEPLinkDash.click(w.canvas, (e.clientX - r.left) * sx, (e.clientY - r.top) * sy, w.dash, w.cfg);
        if (action) {
          redraw(w, feedLast);
          // Clear the physical-button highlight and any one-shot message even
          // when telemetry is paused.  Live feeds will naturally redraw sooner.
          setTimeout(function () { redraw(w, feedLast); }, 220);
          setTimeout(function () { redraw(w, feedLast); }, 980);
          if (!w.single) { persist(); }   // a pop-out has no layout items; persisting there would wipe the saved layout
        }
      });
      return;
    }
    if (w.style === 'num') {
      var box = el('div', 'oepw-num'); w.vEl = el('div', 'oepw-num-v'); w.uEl = el('div', 'oepw-num-u');
      box.appendChild(w.vEl); box.appendChild(w.uEl); w.body.appendChild(box);
    } else { w.canvas = el('canvas'); w.canvas.width = 120; w.canvas.height = 120; w.body.appendChild(w.canvas); }
  }

  // Drag shield: a transparent full-viewport layer shown while dragging/resizing so pointer
  // events keep flowing to THIS document instead of being swallowed by an iframe underneath
  // (Pro Studio composes its workspaces as iframes - without this, drag/resize is glitchy).
  var shield = null;
  function showShield(cursor) {
    if (!shield) { shield = el('div'); shield.id = 'oepw-shield'; document.body.appendChild(shield); }
    shield.style.cursor = cursor || 'default';
  }
  function hideShield() { if (shield && shield.parentNode) { shield.parentNode.removeChild(shield); } shield = null; }

  function startDrag(w, e) {
    if (e.target.tagName === 'BUTTON') { return; }
    e.preventDefault(); showShield('move');
    var sx = e.clientX, sy = e.clientY, ox = w.x, oy = w.y;
    function mv(ev) { w.x = ox + (ev.clientX - sx); w.y = oy + (ev.clientY - sy); clampBox(w); w.node.style.left = w.x + 'px'; w.node.style.top = w.y + 'px'; }
    function up() { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); document.removeEventListener('pointercancel', up); window.removeEventListener('blur', up); hideShield(); persist(); }
    document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up); document.addEventListener('pointercancel', up); window.addEventListener('blur', up);
  }
  function startResize(w, e) {
    e.preventDefault(); e.stopPropagation(); showShield('nwse-resize');
    var sx = e.clientX, sy = e.clientY, ow = w.w, oh = w.h;
    function mv(ev) {
      var nw = ow + (ev.clientX - sx);
      if (w.type === 'linkdash') { nw = Math.max(220, nw); w.w = nw; w.h = BARH + Math.round(nw / dashAspect(w)); }
      else if (w.style === 'gauge') { nw = Math.max(MINW, nw); w.w = nw; w.h = BARH + nw; }
      else { w.w = Math.max(MINW, nw); w.h = Math.max(BARH + 40, oh + (ev.clientY - sy)); }
      clampBox(w); place(w);
    }
    function up() { document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); document.removeEventListener('pointercancel', up); window.removeEventListener('blur', up); hideShield(); persist(); }
    document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up); document.addEventListener('pointercancel', up); window.addEventListener('blur', up);
  }

  // Returns the opened window (or null if blocked). Carries the full widget state in the URL so
  // the pop-out is self-contained and the original can be removed - "move to its own window",
  // never a second copy.
  function openPopout(w) {
    // Absolute path: the default dashboard is served at the site root "/", where a relative
    // 'widget.html' would 404. Forward demo=1 so a demo session's pop-outs also show data.
    var demo = /[?&]demo=1(?:&|$)/.test(location.search) ? '&demo=1' : '';
    var url = '/ui/modules/apps/OEProTune/widget.html?w=' + encodeURIComponent(w.type) + '&style=' + encodeURIComponent(w.style) + demo;
    var big = w.type === 'linkdash';
    if (big && w.cfg) {
      try { url += '&cfg=' + encodeURIComponent(JSON.stringify(w.cfg)) + '&page=' + encodeURIComponent((w.dash && w.dash.page) || w.cfg.page); } catch (e) {}
    }
    try { return window.open(url, 'oepw_' + w.type + '_' + Date.now(), 'width=' + (big ? 540 : 280) + ',height=' + (big ? 320 : 300)); } catch (e) { return null; }
  }
  function create(spec) {
    spec = (spec && typeof spec === 'object') ? spec : {};
    var isDash = spec.type === 'linkdash';
    if (isDash && !global.OEPLinkDash) { return null; }
    var ch = owns(CH, spec.type) ? CH[spec.type] : null;
    if (!isDash && !ch) { return null; }
    var sw = Number(spec.w), sh = Number(spec.h), sx0 = Number(spec.x), sy0 = Number(spec.y);
    if (!isFinite(sw) || sw <= 0) { sw = isDash ? 520 : 150; }
    if (!isFinite(sh) || sh <= 0) { sh = isDash ? 303 : (spec.style === 'num' ? 93 : BARH + sw); }
    sw = Math.min(sw, Math.max(MINW, window.innerWidth || sw));
    sh = Math.min(sh, Math.max(BARH + 40, window.innerHeight || sh));
    var w = { type: spec.type, style: isDash ? 'dash' : (spec.style === 'num' ? 'num' : 'gauge'),
      x: isFinite(sx0) ? sx0 : 40, y: isFinite(sy0) ? sy0 : 64, w: sw, h: sh };
    if (isDash) {
      var savedSkin = spec.cfg && Number(spec.cfg.skinVersion), currentSkin = Number(global.OEPLinkDash.defaults().skinVersion || 0);
      var migrateSkin = !spec.cfg || !isFinite(savedSkin) || savedSkin < currentSkin;
      w.cfg = sanitizeDashCfg(spec.cfg);
      w.dash = { page: migrateSkin ? w.cfg.page : (spec.dashPage || w.cfg.page) };
      if (migrateSkin) { w.h = BARH + Math.round(w.w / dashAspect(w)); }
    }
    w.node = el('div', 'oepw' + (isDash ? ' oepw-linkdash' : ''));
    var bar = el('div', 'oepw-bar'), title = el('span', 't'); title.textContent = isDash ? ('LINK ' + (w.cfg.model === '5' ? 'MXS 5"' : 'MXG 7"')) : ch.t;
    w.titleEl = title;
    var bpo = el('button'); bpo.title = 'Pop out to its own window'; bpo.textContent = '⇱';
    var bcl = el('button'); bcl.title = 'Close'; bcl.textContent = '✕';
    bar.appendChild(title); bar.appendChild(bpo);
    if (isDash) {
      var bcf = el('button'); bcf.title = 'Dash setup'; bcf.textContent = '⚙';
      bar.appendChild(bcf);
      bcf.addEventListener('click', function () { openDashConfig(w); });
    } else {
      var bst = el('button'); bst.title = 'Switch gauge / number'; bst.textContent = '◱';
      bar.appendChild(bst);
      bst.addEventListener('click', function () { w.style = (w.style === 'gauge' ? 'num' : 'gauge'); w.h = (w.style === 'gauge') ? (BARH + w.w) : Math.max(BARH + 40, Math.round(w.w * 0.62)); buildBody(w); clampBox(w); place(w); persist(); });
    }
    bar.appendChild(bcl);
    w.body = el('div', 'oepw-body');
    var rz = el('div', 'oepw-rz');
    w.node.appendChild(bar); w.node.appendChild(w.body); w.node.appendChild(rz);
    buildBody(w);
    bar.addEventListener('pointerdown', function (e) { startDrag(w, e); });
    rz.addEventListener('pointerdown', function (e) { startResize(w, e); });
    bpo.addEventListener('click', function () {
      var win = openPopout(w);
      if (win) { remove(w); }   // move to its own window, don't leave a duplicate behind
      // if the pop-up was blocked, keep the gauge in place rather than losing it
    });
    bcl.addEventListener('click', function () { remove(w); });
    layer.appendChild(w.node);
    clampBox(w); place(w);
    return w;
  }
  function remove(w) {
    var i = items.indexOf(w); if (i >= 0) { items.splice(i, 1); }
    if (w.node && w.node.parentNode) { w.node.parentNode.removeChild(w.node); }
    if (dashCfgPanel && dashCfgFor === w) { closeDashConfig(); }   // no zombie setup panel for a removed dash
    refreshLinkBackdrop();
    persist();
  }
  function add(type, style) {
    if (!layer) { return; }
    var off = (spawn++ % 6) * 26, w = null;
    if (type === 'linkdash') {
      if (!global.OEPLinkDash) { return; }
      var dwid = Math.min(460, Math.max(280, Math.round(window.innerWidth * 0.36)));
      w = create({ type: 'linkdash', x: 40 + off, y: 64 + off, w: dwid, h: BARH + Math.round(dwid / 1.86) });
    } else {
      if (!owns(CH, type)) { return; }
      style = (style === 'num' ? 'num' : 'gauge');
      var base = 150;
      w = create({ type: type, style: style, x: 40 + off, y: 64 + off, w: base, h: (style === 'num' ? Math.round(base * 0.62) : BARH + base) });
    }
    if (w) { items.push(w); refreshLinkBackdrop(); persist(); }
  }
  var dormant = [];   // persisted entries this page cannot mount (e.g. linkdash.js missing) - kept, never dropped
  function persist() {
    try {
      var out = items.map(function (w) { var s = { type: w.type, style: w.style, x: w.x, y: w.y, w: w.w, h: w.h }; if (w.cfg) { s.cfg = w.cfg; s.dashPage = w.dash && w.dash.page; } return s; });
      localStorage.setItem(LSKEY, JSON.stringify(out.concat(dormant)));
    } catch (e) {}
  }
  function load() {
    var arr = []; try { arr = JSON.parse(localStorage.getItem(LSKEY) || '[]') || []; } catch (e) { arr = []; }
    if (!Array.isArray(arr)) { arr = []; }
    arr.forEach(function (s) {
      if (!s || typeof s !== 'object') { return; }
      // LINK dash is "coming soon": a saved linkdash entry falls through to dormant[] (preserved,
      // re-persisted, never mounted). Restore the `|| s.type === 'linkdash'` clause to bring it back.
      var w = owns(CH, s.type) ? create(s) : null;
      if (w) { items.push(w); } else { dormant.push(s); }
    });
    refreshLinkBackdrop();
  }

  // ---- LINK dash config popup (model / version / brightness / shift / channel boxes) ----
  var dashCfgPanel = null, dashCfgFor = null;
  function closeDashConfig() {
    if (dashCfgPanel && dashCfgPanel.parentNode) { dashCfgPanel.parentNode.removeChild(dashCfgPanel); }
    dashCfgPanel = null; dashCfgFor = null;
  }
  function openDashConfig(w) {
    closeDashConfig();
    var LD = global.OEPLinkDash; if (!LD) { return; }
    var p = el('div'); p.id = 'oepw-dashcfg';
    var head = el('div', 'oepw-pk-head'), h = el('span'); h.textContent = 'LINK DASH SETUP';
    var x = el('button'); x.textContent = '✕'; x.addEventListener('click', closeDashConfig);
    head.appendChild(h); head.appendChild(x); p.appendChild(head);
    function row(label, node) { var r = el('div', 'oepw-pk-row'), l = el('span', 'lbl'); l.textContent = label; r.appendChild(l); r.appendChild(node); p.appendChild(r); }
    function sel(optList, cur, fn) {
      var s = el('select', 'oepw-sel');
      optList.forEach(function (o) { var op = el('option'); op.value = o[0]; op.textContent = o[1]; if (o[0] === cur) { op.selected = true; } s.appendChild(op); });
      s.addEventListener('change', function () { fn(s.value); redraw(w, feedLast); persist(); });
      return s;
    }
    function chk(cur, fn, label) {
      var b = el('button', 'oepw-sel'); b.textContent = (cur ? 'ON' : 'OFF') + (label ? ' — ' + label : ''); b.style.maxWidth = 'none'; b.style.cursor = 'pointer';
      b.addEventListener('click', function () { var nv = !cur; cur = nv; b.textContent = (nv ? 'ON' : 'OFF') + (label ? ' — ' + label : ''); fn(nv); redraw(w, feedLast); persist(); });
      return b;
    }
    function rangeInput(min, max, value, fn, label) {
      var wrap = el('span'); wrap.style.display = 'flex'; wrap.style.alignItems = 'center'; wrap.style.gap = '5px';
      var inp = el('input'); inp.type = 'range'; inp.min = String(min); inp.max = String(max); inp.value = String(value); inp.className = 'oepw-sel'; inp.style.width = '92px';
      var val = el('span'); val.textContent = String(value) + '%'; val.style.color = '#c8ccd2'; val.style.fontSize = '10px'; val.style.minWidth = '28px';
      inp.addEventListener('input', function () { var v = Math.max(min, Math.min(max, Number(inp.value) || min)); val.textContent = String(v) + '%'; fn(v); });
      wrap.appendChild(inp); wrap.appendChild(val);
      if (label) { var lab = el('span'); lab.textContent = label; lab.style.color = '#6b7178'; lab.style.fontSize = '10px'; wrap.appendChild(lab); }
      return wrap;
    }
    row('Model', sel([['7', 'MXG 7"'], ['5', 'MXS 5"']], w.cfg.model, function (v) { w.cfg.model = v; if (w.titleEl) { w.titleEl.textContent = 'LINK ' + (v === '5' ? 'MXS 5"' : 'MXG 7"'); } w.h = BARH + Math.round(w.w / dashAspect(w)); clampBox(w); place(w); }));
    row('Version', sel([['street', 'Street (icons)'], ['race', 'Race (LED 1-8)']], w.cfg.version, function (v) { w.cfg.version = v; }));
    var pageOpts = LD.pages ? LD.pages.map(function (p) {
      var names = { ring: 'Street Ring', race: 'Race', bars: 'Bar Graph' };
      return [p, names[p] || p];
    }) : [['ring', 'Street Ring']];
    row('Start page', sel(pageOpts, w.cfg.page || 'ring', function (v) {
      var oldPage = w.cfg.page || 'ring';
      w.cfg.page = v; if (w.dash) { w.dash.page = v; }
      if (w.dash && oldPage !== v) { w.dash._transition = { from: oldPage, to: v, start: Date.now(), duration: 180, snap: null }; }
      redraw(w, feedLast); persist();
    }));
    row('Brightness', sel([['day', 'Day'], ['night', 'Night'], ['auto', 'Auto (headlights)']], w.cfg.brightness, function (v) { w.cfg.brightness = v; }));
    row('Brightness level', rangeInput(0, 100, w.cfg.brightnessLevel || 50, function (v) { w.cfg.brightnessLevel = v; redraw(w, feedLast); persist(); }));

    // Per-car shift-light profile editor with live LED preview.
    var prof = LD.loadShiftProfile(feedLast || {}, w.cfg);
    var firstPct = prof.firstPct, lastPct = prof.lastPct;
    var firstWrap = rangeInput(20, 100, firstPct, function (v) { firstPct = v; updateProfile(); }, 'first LED');
    var lastWrap = rangeInput(20, 100, lastPct, function (v) { lastPct = v; updateProfile(); }, 'last LED');
    row('Shift first LED', firstWrap);
    row('Shift last LED', lastWrap);
    var preview = el('canvas'); preview.width = 160; preview.height = 22;
    preview.style.background = '#020304'; preview.style.border = '1px solid #24282e'; preview.style.borderRadius = '4px'; preview.style.display = 'block';
    row('Shift preview', preview);
    function updateProfile() {
      if (feedLast && feedLast.carName) { LD.saveShiftProfile(feedLast, { firstPct: firstPct, lastPct: lastPct }); }
      redraw(w, feedLast);
    }
    var SHIFT_PREVIEW_COLORS = ['#32d6f4', '#32d6f4', '#d345d7', '#d345d7', '#ff2632', '#ff2632', '#ffad22', '#ffad22', '#f8fbff', '#f8fbff'];
    function updateShiftPreview() {
      var px = preview.getContext('2d'), pw = preview.width, ph = preview.height;
      px.clearRect(0, 0, pw, ph);
      var t = feedLast || {}, rpm = Number(t.rpm) || 0;
      var thresholds = LD.shiftThresholds(t, w.cfg);
      var gap = 2, ledW = (pw - gap * 9) / 10;
      for (var i = 0; i < 10; i++) {
        var on = rpm >= thresholds[i];
        px.fillStyle = on ? SHIFT_PREVIEW_COLORS[i] : '#1b2334';
        if (on) { px.shadowColor = SHIFT_PREVIEW_COLORS[i]; px.shadowBlur = 5; } else { px.shadowBlur = 0; }
        px.fillRect(i * (ledW + gap), (ph - 10) / 2, ledW, 10);
        px.shadowBlur = 0;
      }
    }
    var previewTimer = setInterval(function () {
      if (!dashCfgPanel || dashCfgPanel !== p) { clearInterval(previewTimer); return; }
      updateShiftPreview();
    }, 100);

    row('Shift colours', sel([['std', 'Link RGB'], ['classic', 'Green-Org-Red'], ['red', 'Red only']], w.cfg.shiftColors || 'std', function (v) { w.cfg.shiftColors = v; }));
    var chOpts = LD.channelOrder.map(function (k) { return [k, LD.channels[k].label]; });
    for (var bi = 0; bi < 3; bi++) {
      (function (idx) {
        row('Box ' + (idx + 1), sel(chOpts, w.cfg.boxes[idx], function (v) { w.cfg.boxes[idx] = v; }));
      })(bi);
    }
    var mm = el('button', 'oepw-sel'); mm.textContent = 'Reset MIN/MAX recall';
    mm.style.maxWidth = 'none'; mm.style.cursor = 'pointer';
    mm.addEventListener('click', function () { if (w.dash) { w.dash.minmax = {}; w.dash.boxMode = {}; } redraw(w, feedLast); });
    row('Recall', mm);
    row('Ref. overlay', chk(!!w.cfg.showReference, function (v) { w.cfg.showReference = v; }, 'dev alignment'));
    row('Glass glare', chk(w.cfg.showGlare !== false, function (v) { w.cfg.showGlare = v; }, 'screen reflection'));
    row('Pixel grid', chk(w.cfg.showPixelGrid !== false, function (v) { w.cfg.showPixelGrid = v; }, 'LCD texture'));
    var hint = el('div', 'oepw-pk-hint');
    hint.textContent = 'Hardware keys follow the MX Strada: MENU opens the device menu, >> cycles Street/Race/Bar pages, MEM/OK opens data recall, and VIEW/QUIT exits overlays. Click a live value to cycle LIVE / MAX / MIN. The reference overlay is for aligning the rendered dash against the product photo.';
    p.appendChild(hint);
    document.body.appendChild(p);
    dashCfgPanel = p; dashCfgFor = w;
    updateShiftPreview();
    var r = w.node.getBoundingClientRect();
    p.style.left = Math.max(6, Math.min(window.innerWidth - 246, r.right + 8)) + 'px';
    p.style.top = Math.max(6, Math.min(window.innerHeight - 340, r.top)) + 'px';
  }

  function updatePickerUnits() {
    if (pkUnitBtns) { pkUnitBtns[0].className = psi ? 'on' : ''; pkUnitBtns[1].className = psi ? '' : 'on'; }
    if (pkTempBtns) { pkTempBtns[0].className = tempF ? '' : 'on'; pkTempBtns[1].className = tempF ? 'on' : ''; }
  }
  function buildPicker() {
    picker = el('div'); picker.id = 'oepw-picker'; picker.style.display = 'none';
    var head = el('div', 'oepw-pk-head'), h = el('span'); h.textContent = 'ADD GAUGE';
    var x = el('button'); x.textContent = '✕'; x.title = 'Close'; x.addEventListener('click', function () { togglePicker(false); });
    head.appendChild(h); head.appendChild(x);
    // Boost-units toggle only when this engine owns units (Basic/Pro Studio, autoConnect). In
    // dash feed-mode the host page has its own header PSI/kPa button, so a second control here
    // would flip only the widgets and desync from the main gauge.
    var urow = null, trow = null;
    if (!feedMode) {
      urow = el('div', 'oepw-pk-row'); var ul = el('span', 'lbl'); ul.textContent = 'Boost units';
      var up = el('button'); up.textContent = 'PSI'; var uk = el('button'); uk.textContent = 'kPa';
      up.addEventListener('click', function () { setPsi(true); }); uk.addEventListener('click', function () { setPsi(false); });
      pkUnitBtns = [up, uk]; urow.appendChild(ul); urow.appendChild(up); urow.appendChild(uk);
      trow = el('div', 'oepw-pk-row'); var tl = el('span', 'lbl'); tl.textContent = 'Temp units';
      var tcb = el('button'); tcb.textContent = '°C'; var tfb = el('button'); tfb.textContent = '°F';
      tcb.addEventListener('click', function () { setTempF(false); }); tfb.addEventListener('click', function () { setTempF(true); });
      pkTempBtns = [tcb, tfb]; trow.appendChild(tl); trow.appendChild(tcb); trow.appendChild(tfb);
    }
    var seg = el('div', 'oepw-pk-seg'), bg = el('button'); bg.textContent = 'Gauge'; bg.className = 'on';
    var bnv = el('button'); bnv.textContent = 'Number';
    bg.addEventListener('click', function () { pickerStyle = 'gauge'; bg.className = 'on'; bnv.className = ''; });
    bnv.addEventListener('click', function () { pickerStyle = 'num'; bnv.className = 'on'; bg.className = ''; });
    seg.appendChild(bg); seg.appendChild(bnv);
    var grid = el('div', 'oepw-pk-grid');
    if (global.OEPLinkDash) {
      // LINK MXS/MXG dash temporarily disabled ("coming soon"): greyed, tooltip on hover, click no-ops.
      var bd = el('button', 'oepw-pk-dash'); bd.textContent = 'LINK MXG 7" DASH';
      bd.title = 'LINK MXS/MXG dash — coming soon';
      bd.style.opacity = '0.4'; bd.style.cursor = 'not-allowed';
      bd.addEventListener('click', function (e) { e.stopPropagation(); });
      grid.appendChild(bd);
    }
    ORDER.forEach(function (k) { var b = el('button'); b.textContent = CH[k].t; b.addEventListener('click', function () { add(k, pickerStyle); }); grid.appendChild(b); });
    picker.appendChild(head); if (urow) { picker.appendChild(urow); } if (trow) { picker.appendChild(trow); } picker.appendChild(seg); picker.appendChild(grid);
    document.body.appendChild(picker); updatePickerUnits();
  }
  function togglePicker(force) {
    if (!picker) { return; }
    var show = (typeof force === 'boolean') ? force : (picker.style.display === 'none');
    picker.style.display = show ? 'block' : 'none';
    if (show) { positionPicker(); }
  }
  function toggleFullscreen() {
    var d = document, root = d.documentElement;
    try {
      if (d.fullscreenElement) {
        if (d.exitFullscreen) { var r = d.exitFullscreen(); if (r && r.catch) { r.catch(function () {}); } }
      } else if (root.requestFullscreen) {
        var r2 = root.requestFullscreen(); if (r2 && r2.catch) { r2.catch(function () {}); }
      }
    } catch (e) {}
  }
  var fab = null;
  function buildLauncher() {
    fab = el('div'); fab.id = 'oepw-fab';
    try {
      var pos = JSON.parse(localStorage.getItem('oepw_fab') || 'null');
      if (pos && isFinite(pos.x) && isFinite(pos.y)) { fab.style.left = pos.x + 'px'; fab.style.top = pos.y + 'px'; fab.style.right = 'auto'; fab.style.bottom = 'auto'; }
    } catch (e) {}
    document.body.appendChild(fab);
    if (fab.style.left) {
      var fr = fab.getBoundingClientRect();
      fab.style.left = Math.max(2, Math.min(window.innerWidth - Math.max(34, fr.width) - 2, fr.left)) + 'px';
      fab.style.top = Math.max(2, Math.min(window.innerHeight - Math.max(34, fr.height) - 2, fr.top)) + 'px';
    }

    // Each button is a click when tapped, and drags the whole cluster when moved past a small
    // threshold - so you can park it off Pro Studio's status bar. Reuses the iframe drag shield.
    function mkBtn(id, glyph, title, onClick) {
      var b = el('button'); b.id = id; b.textContent = glyph; b.title = title;
      b.addEventListener('pointerdown', function (e) {
        e.preventDefault();
        var dx = e.clientX, dy = e.clientY, moved = false;
        var r = fab.getBoundingClientRect(), ox = r.left, oy = r.top;
        function mv(ev) {
          if (!moved && Math.abs(ev.clientX - dx) + Math.abs(ev.clientY - dy) < 5) { return; }
          if (!moved) { moved = true; showShield('move'); }
          var nx = Math.max(2, Math.min(window.innerWidth - fab.offsetWidth - 2, ox + (ev.clientX - dx)));
          var ny = Math.max(2, Math.min(window.innerHeight - fab.offsetHeight - 2, oy + (ev.clientY - dy)));
          fab.style.left = nx + 'px'; fab.style.top = ny + 'px'; fab.style.right = 'auto'; fab.style.bottom = 'auto';
        }
        function up(ev) {
          document.removeEventListener('pointermove', mv); document.removeEventListener('pointerup', up); document.removeEventListener('pointercancel', up); window.removeEventListener('blur', up);
          if (moved) { hideShield(); var rr = fab.getBoundingClientRect(); try { localStorage.setItem('oepw_fab', JSON.stringify({ x: rr.left, y: rr.top })); } catch (e2) {} }
          else if (!ev || ev.type === 'pointerup') { onClick(); }
        }
        document.addEventListener('pointermove', mv); document.addEventListener('pointerup', up); document.addEventListener('pointercancel', up); window.addEventListener('blur', up);
      });
      return b;
    }

    if (document.documentElement && document.documentElement.requestFullscreen) {
      fab.appendChild(mkBtn('oepw-fs', '⛶', 'Full screen (drag to move)', toggleFullscreen));
    }
    launcher = mkBtn('oepw-launch', '⊞', 'Add a floating gauge (drag to move)', function () { togglePicker(); });
    fab.appendChild(launcher);
  }
  function positionPicker() {
    if (!picker || !fab) { return; }
    var r = fab.getBoundingClientRect();
    // Prefer above the launcher, but keep the entire menu reachable when the
    // floating button has been parked at a top or side edge.
    var pw = picker.offsetWidth || 242, ph = picker.offsetHeight || 360;
    var left = Math.max(6, Math.min(window.innerWidth - pw - 6, r.right - pw));
    var top = r.top - ph - 8;
    if (top < 6) { top = r.bottom + 8; }
    top = Math.max(6, Math.min(window.innerHeight - ph - 6, top));
    picker.style.right = 'auto'; picker.style.bottom = 'auto';
    picker.style.left = left + 'px'; picker.style.top = top + 'px';
  }

  function setPsi(v) {
    psi = !!v; try { localStorage.setItem('oepdash_psi', psi ? '1' : '0'); } catch (e) {}
    if (single) { redraw(single, feedLast); }
    for (var i = 0; i < items.length; i++) { redraw(items[i], feedLast); }
    updatePickerUnits();
  }
  function setTempF(v) {
    tempF = !!v; try { localStorage.setItem('oepdash_tempF', tempF ? '1' : '0'); } catch (e) {}
    if (single) { redraw(single, feedLast); }
    for (var i = 0; i < items.length; i++) { redraw(items[i], feedLast); }
    updatePickerUnits();
  }
  function setGaugeSkin(s) {
    gaugeSkin = (s === 'aem') ? 'aem' : 'dial'; try { localStorage.setItem('oepdash_gaugeskin', gaugeSkin); } catch (e) {}
    if (single) { redraw(single, feedLast); }
    for (var i = 0; i < items.length; i++) { redraw(items[i], feedLast); }
  }
  function redrawFeedNow() {
    feedRedrawPending = false;
    if (single) { redraw(single, feedLast); return; }
    for (var i = 0; i < items.length; i++) { redraw(items[i], feedLast); }
  }
  function scheduleFeedRedraw() {
    if (feedRedrawPending) { return; }
    feedRedrawPending = true;
    if (global.requestAnimationFrame) { global.requestAnimationFrame(redrawFeedNow); }
    else { setTimeout(redrawFeedNow, 0); }
  }
  function clearFeed() { feedLast = null; feedLastAt = 0; scheduleFeedRedraw(); }
  // V3.0: the boost-tier peak resets on a REAL vehicle change, detected HERE from the vehId the
  // telemetry itself carries - update() is the one funnel every surface shares (feed mode on the
  // Basic Dashboard, autoConnect in the Studios, pop-outs), and vehId is stable across WS
  // reconnects so a 2 s relay blip can never shrink the dial mid-session.
  var feedVehId = null;
  function update(t) {
    feedLast = (t && typeof t === 'object') ? t : null;
    if (feedLast && typeof feedLast.vehId === 'number' && feedLast.vehId !== feedVehId) {
      if (feedVehId !== null) { boostPeakGaugeKpa = 0; }
      feedVehId = feedLast.vehId;
    }
    feedLastAt = feedLast ? Date.now() : 0;
    scheduleFeedRedraw();
  }

  // ---- single-gauge pop-out ----
  function mountSingle(spec) {
    var isDash = spec.type === 'linkdash' && !!global.OEPLinkDash;
    if (!isDash && !owns(CH, spec.type)) { spec.type = 'rpm'; }
    single = { type: spec.type, style: isDash ? 'dash' : (spec.style === 'num' ? 'num' : 'gauge'), single: true };
    if (isDash) {
      // Prefer the cfg the moved widget carried in the URL (so the pop-out matches exactly what
      // was on the page); fall back to the saved layout's dash cfg for a manually-opened pop-out.
      var stored = spec.cfg || null;
      if (!stored) {
        try {
          var arr = JSON.parse(localStorage.getItem(LSKEY) || '[]') || [];
          if (!Array.isArray(arr)) { arr = []; }
          for (var i = 0; i < arr.length; i++) { if (arr[i] && arr[i].type === 'linkdash' && arr[i].cfg) { stored = arr[i].cfg; break; } }
        } catch (e) {}
      }
      single.cfg = sanitizeDashCfg(stored);
      single.dash = { page: spec.page || single.cfg.page };
    }
    var node = el('div', 'oepw oepw-single' + (isDash ? ' oepw-linkdash' : ''));
    var bar = el('div', 'oepw-bar'), title = el('span', 't'); title.textContent = isDash ? ('LINK ' + (single.cfg.model === '5' ? 'MXS 5"' : 'MXG 7"')) : CH[single.type].t;
    bar.appendChild(title);
    if (!isDash) {
      var bst = el('button'); bst.title = 'Switch gauge / number'; bst.textContent = '◱';
      bar.appendChild(bst);
      bst.addEventListener('click', function () { single.style = (single.style === 'gauge' ? 'num' : 'gauge'); buildBody(single); redraw(single, feedLast); });
    }
    single.body = el('div', 'oepw-body');
    node.appendChild(bar); node.appendChild(single.body);
    document.body.appendChild(node); single.node = node;
    buildBody(single);
    window.addEventListener('resize', function () { redraw(single, feedLast); });
    redraw(single, feedLast);
  }

  // ---- self telemetry feed (Basic/Pro Studio, pop-out) ----
  function demoFeed() {
    var ph = 0;
    setInterval(function () {
      ph += 0.03; var s = (Math.sin(ph) + 1) / 2;
      update({ status: 'Controlling', controlling: true, carName: 'Demo Mode', engineFp: 'G-T-850-7000-320', rpm: 900 + s * 6600, speedKmh: s * 172,
        gearIndex: Math.min(6, 1 + Math.floor(s * 172 / 32)), activeRevLimit: 7000, commandedRevLimit: 7000, stoichRatio: 14.7,
        lambdaActual: 0.98 + Math.sin(ph * 4.7) * 0.05, lambdaTarget: 0.98, mapKpa: 100 + Math.max(0, s - 0.25) * 190, baroKpa: 100, boostTargetKpa: 217, wastegateDuty: 42 + s * 18, hasTurbo: true, boostMaxed: false,
        ignitionAdvance: 8 + s * 16, ignitionTrim: -0.7, knockRetard: s > 0.94 ? 3.5 : 0, egtC: 400 + s * 520, iatC: 28 + s * 30, ectC: 90, oilC: 100, injectorDuty: 12 + s * 74,
        oilPressureBar: 0.5 + s * 3.5, batteryVoltage: 13.8,
        fuelPressureKpa: 300 + Math.max(0, s - 0.25) * 190, fuelDiffPressureKpa: 300, tps: s * 100, engineHealth: 100, faults: 'None', limpMode: false, limiterActive: false, limiterStatus: 'Inactive' });
    }, 100);
  }
  function autoConnect() {
    if (/[?&]demo=1(?:&|$)/.test(location.search)) { demoFeed(); return; }
    var activeVehId = null, tryProto = true;
    function matches(d) { return typeof activeVehId === 'number' && activeVehId >= 0 && d && typeof d.vehId === 'number' && d.vehId === activeVehId; }
    function onEnvelope(m) {
      if (m.hook === 'OEPRemoteStatus' && m.data) {
        var nextVehId = (typeof m.data.playerVehId === 'number') ? m.data.playerVehId : -1;
        if (nextVehId !== activeVehId) { activeVehId = nextVehId; clearFeed(); }   // boost-tier peak reset lives in update() (vehId-keyed), NOT here - ws.onclose nulls activeVehId, so this branch also fires on a mere reconnect of the SAME car
        return;
      }
      if (m.hook === 'OEProTuneTelemetry' && matches(m.data)) { update(m.data); }
    }
    setInterval(function () { if (feedLastAt && Date.now() - feedLastAt > 2500) { clearFeed(); } }, 1000);
    function connect() {
      var ws;
      var wsScheme = location.protocol === 'https:' ? 'wss://' : 'ws://';
      try { ws = tryProto ? new WebSocket(wsScheme + location.host + '/', 'oeprotune-dash-v1') : new WebSocket(wsScheme + location.host + '/'); }
      catch (e) { tryProto = !tryProto; setTimeout(connect, 2000); return; }
      ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (e) { return; } if (m && typeof m === 'object' && m.hook) { onEnvelope(m); } };
      ws.onclose = function () { activeVehId = null; clearFeed(); tryProto = !tryProto; setTimeout(connect, 2000); };
      ws.onerror = function () { try { ws.close(); } catch (e) {} };
    }
    connect();
  }

  function init(o) {
    o = o || {};
    if (o.single) { injectCSS(); mountSingle(o.single); if (o.autoConnect !== false) { autoConnect(); } return; }
    var inFrame = false; try { inFrame = window.top !== window.self; } catch (e) { inFrame = true; }
    if (inFrame && !o.force) { return; }   // embedded frames defer to the enclosing shell's one overlay
    if (o.key) { LSKEY = o.key; }
    feedMode = !!o.addButton;   // host owns a units toggle => hide the picker's units row
    injectCSS();
    layer = el('div'); layer.id = 'oepw-layer'; (o.root || document.body).appendChild(layer);
    buildPicker();
    var b = o.addButton ? (typeof o.addButton === 'string' ? document.getElementById(o.addButton) : o.addButton) : null;
    if (b) { b.addEventListener('click', function () { togglePicker(); }); } else { buildLauncher(); }
    load();
    window.addEventListener('resize', function () { items.forEach(function (w) { clampBox(w); place(w); }); });
    if (o.autoConnect) { autoConnect(); }
  }

  global.OEPWidgets = { init: init, update: update, setPsi: setPsi, setTempF: setTempF, setGaugeSkin: setGaugeSkin, add: add };
})(window);
