<div class="oep-root" ng-class="{'oep-knock': knockFlash}">
<style>
/* ===== Windows XP "Luna" theme ===== */
.oep-root {
  width:100%; height:100%; box-sizing:border-box; overflow:hidden;
  font-family: Tahoma, 'Segoe UI', sans-serif; font-size:11px; color:#000;
  background:#ECE9D8; border:1px solid #0A246A; border-radius:8px 8px 0 0;
  user-select:none; position:relative; display:flex; flex-direction:column;
}
.oep-root.oep-collapsed { width:48px; height:26px; min-width:48px; min-height:26px; margin-left:calc(100% - 48px); overflow:hidden; background:transparent; border:0; border-radius:0; }
.oep-root.oep-knock { box-shadow:0 0 0 2px #e0252f inset; }
/* Collapse is driven entirely by the .oep-collapsed class (toggled synchronously in
   toggleCollapse) so it can't desync across repeated hides like the old mix of
   ng-class / ng-show / ng-if did. */
.oep-main { display:flex; flex-direction:column; flex:1 1 auto; min-height:0; height:auto; }
.oep-root.oep-collapsed .oep-main { display:none !important; }
.oep-root:not(.oep-collapsed) .oep-expand { display:none !important; }
.ng-hide { display:none !important; }

/* expand pill when collapsed (XP button) */
.oep-expand { display:flex; align-items:center; justify-content:center; gap:1px; width:46px; height:24px; font-size:12px; font-weight:bold; color:#fff;
  background:linear-gradient(180deg,#3C8BF0,#1C5FD6); border:1px solid #08338A; border-radius:4px; cursor:pointer; box-shadow:0 2px 6px rgba(0,0,0,.55); text-shadow:1px 1px 1px rgba(0,0,0,.4); }
.oep-expand:hover { background:linear-gradient(180deg,#5BA3FF,#2A78E4); border-color:#FFC73C; }

/* title bar (XP blue gradient) */
.oep-header { display:flex; align-items:center; justify-content:space-between; height:26px; padding:0 4px 0 7px;
  background:linear-gradient(180deg,#3C8BF0 0%,#2A78E4 9%,#1C5FD6 42%,#1A5AD2 88%,#3C8BF0 100%);
  border-bottom:1px solid #08338A; }
.oep-brand { font-size:12px; font-weight:bold; font-style:normal; letter-spacing:0; color:#fff; cursor:move;
  display:flex; align-items:center; gap:6px; text-shadow:1px 1px 1px rgba(0,0,0,.45); }
.oep-brand b { color:#FFD23B; }
.oep-brand small { font-size:8px; letter-spacing:1px; color:#cfe0ff; display:inline; margin-left:6px; font-weight:normal; text-shadow:none; }
.oep-grip { font-style:normal; font-weight:bold; color:#cfe0ff; cursor:move; }
.oep-headright { display:flex; align-items:center; gap:5px; }
.oep-conn { font-size:9px; font-weight:bold; padding:1px 6px; border-radius:2px; border:1px solid #08338A; }
.oep-conn.on { background:#5FBA46; color:#fff; }
.oep-conn.off { background:#D24A3A; color:#fff; }
.oep-conn.mon { background:#E8A33D; color:#3a2600; }
.oep-induct { font-size:8px; font-weight:bold; padding:1px 5px; border-radius:2px; background:#ECE9D8; color:#444; border:1px solid #08338A; }
.oep-induct.turbo { background:#FFD23B; color:#5a3b00; }
/* XP window button (collapse) */
.oep-hide { width:21px; height:21px; display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:bold; color:#fff;
  background:linear-gradient(#7FB3F4,#2A6FD8 50%,#1A55C0); border:1px solid #0A2E7A; border-radius:3px; cursor:pointer; }
.oep-hide:hover { background:linear-gradient(#9CC6FF,#3D86EC 50%,#2A66CE); }

/* menu bar */
/* V3.0.9 THE STATUS BADGES MUST NEVER BE THE THING THAT GETS CUT OFF. This row was nowrap, fixed at
   21px and not scrollable, so once its content outgrew the width the overflow was simply clipped - and
   because the mirror-only badges render LAST, the two indicators the whole remote write model depends
   on (WRITER / WRITER BUSY / READ ONLY, and the APPLIED / REJECTED / STALLED phase) were the first
   things to silently disappear. Measured with the real CSS: at the Basic mirror's 780px default host
   the row overflows by 127px and BOTH badges are gone; in a Pro Studio centre column on a 1366 laptop
   (885px) it overflows by 22px and the phase badge is gone. That is why a rejected remote command read
   as "the button does nothing" - the word REJECTED was rendered just past the right edge, with no
   scrollbar and no hint. Wrapping costs one extra 21px row on narrow layouts and can never hide state. */
.oep-menu { display:flex; flex-wrap:wrap; align-items:center; background:#ECE9D8; border-bottom:1px solid #ACA899; min-height:21px; }
.oep-menu button { background:transparent; border:1px solid transparent; color:#000; padding:1px 8px; font-size:11px; cursor:pointer; height:19px; white-space:nowrap; }
.oep-menu > button { flex:0 0 auto; }
.oep-langbtns { margin-right:5px; display:inline-flex; }
.oep-langbtns button { font-size:8px; font-weight:bold; height:17px; padding:0 4px; border:1px solid #808080; border-right:0; background:#fff; color:#444; cursor:pointer; }
.oep-langbtns button:last-child { border-right:1px solid #808080; }
.oep-langbtns button.on { background:#316AC5; color:#fff; border-color:#0A246A; }
.oep-menu button:hover { background:#316AC5; color:#fff; border:1px solid #0A246A; }
.oep-menu .spacer { flex:1 1 auto; min-width:0; }
.oep-menu .oep-langbtns, .oep-menu .oep-statusblk { flex:0 0 auto; white-space:nowrap; }
.oep-statusblk { font-size:8px; font-weight:bold; padding:1px 6px; margin-right:3px; border:1px solid #808080; }
.oep-statusblk.g { background:#5FBA46; color:#fff; } .oep-statusblk.r { background:#D24A3A; color:#fff; } .oep-statusblk.y { background:#E8A33D; color:#3a2600; }

/* toolbar + general XP beveled buttons */
.oep-toolbar { display:flex; flex-wrap:wrap; gap:3px; padding:4px 6px; background:#ECE9D8; border-bottom:1px solid #ACA899; }
.oep-toolbar button, .oep-edit button, .oep-seg button, .oep-maptabs button, .oep-slot button {
  background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #003C74; color:#000; padding:2px 8px; font-size:10px; border-radius:3px; cursor:pointer; }
.oep-toolbar button:hover, .oep-edit button:hover, .oep-seg button:hover, .oep-maptabs button:hover, .oep-slot button:hover {
  border-color:#E59700; box-shadow:0 0 4px #FFC73C inset; }
.oep-toolbar button:active, .oep-edit button:active, .oep-slot button:active { background:linear-gradient(#D6D2C2,#ECE9D8); }
.oep-toolbar button.prim { font-weight:bold; }
.oep-toolbar button.warn { color:#9a0000; font-weight:bold; }
.oep-toolbar button:disabled { opacity:.45; cursor:default; box-shadow:none; border-color:#808080; }
.oep-edit button.on { background:#316AC5; border-color:#0A246A; color:#fff; }
.oep-edit button:disabled { opacity:.45; cursor:default; box-shadow:none; border-color:#808080; }
.oep-mapbank-grid { display:grid; grid-template-columns:repeat(2,minmax(245px,1fr)); gap:6px; margin:5px 0 7px; }
.oep-mapbank-card { min-width:0; border:1px solid #7F9DB9; background:#fff; box-shadow:inset 1px 1px 0 #eef4ff; }
.oep-mapbank-card.active { border-color:#1557B0; box-shadow:0 0 0 1px #5C9DF0 inset; }
.oep-mapbank-card.pending { border-color:#C58A00; background:#FFF8DA; }
.oep-mapbank-card.empty { background:#F3F1E8; color:#666; }
.oep-mapbank-head { display:flex; align-items:center; gap:4px; padding:3px 5px; background:#E7EFFB; border-bottom:1px solid #B7C9E4; }
.oep-mapbank-card.pending .oep-mapbank-head { background:#FFE6A1; }
.oep-mapbank-id { color:#0A246A; font-weight:bold; font-size:12px; margin-right:auto; }
.oep-mapbank-badge { padding:1px 4px; border:1px solid #808080; background:#ECE9D8; color:#444; font-size:8px; font-weight:bold; }
.oep-mapbank-badge.active { border-color:#236817; background:#5FBA46; color:#fff; }
.oep-mapbank-badge.pending { border-color:#9A6800; background:#FFD23B; color:#4b3000; }
.oep-mapbank-badge.default { border-color:#0A246A; background:#316AC5; color:#fff; }
.oep-mapbank-body { padding:5px; }
.oep-mapbank-name { display:flex; gap:4px; margin-bottom:4px; }
.oep-mapbank-name input { flex:1; min-width:0; }
.oep-mapbank-actions { display:flex; flex-wrap:wrap; gap:3px; }
.oep-mapbank-actions button, .oep-mapbank-name button { background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #003C74; color:#000; padding:2px 6px; font-size:9px; border-radius:3px; cursor:pointer; }
.oep-mapbank-actions button:hover, .oep-mapbank-name button:hover { border-color:#E59700; box-shadow:0 0 4px #FFC73C inset; }
.oep-mapbank-actions button:disabled, .oep-mapbank-name button:disabled { opacity:.42; cursor:default; box-shadow:none; border-color:#808080; }
.oep-mapbank-stats { margin-top:4px; font-size:8px; color:#666; }
.oep-working-guard { position:absolute; inset:0; z-index:320; display:flex; align-items:flex-start; justify-content:center; padding-top:72px; background:rgba(0,0,0,.16); }
.oep-working-guard-box { width:360px; max-width:calc(100% - 24px); border:1px solid #0A246A; border-radius:4px 4px 0 0; background:#ECE9D8; color:#000; box-shadow:2px 3px 8px rgba(0,0,0,.45); font-size:10px; }
.oep-working-guard-title { padding:4px 7px; background:linear-gradient(180deg,#3C8BF0,#1C5FD6); color:#fff; font-weight:bold; }
.oep-working-guard-body { padding:8px 9px 5px; line-height:1.35; }
.oep-working-guard-note { margin-top:3px; color:#555; font-size:9px; }
.oep-working-guard-actions { display:flex; justify-content:flex-end; flex-wrap:wrap; gap:4px; padding:5px 8px 8px; }
.oep-working-guard-actions button { background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #003C74; color:#000; padding:2px 7px; font-size:9px; border-radius:3px; cursor:pointer; }
.oep-working-guard-actions button:hover { border-color:#E59700; box-shadow:0 0 4px #FFC73C inset; }
.oep-working-guard-actions button.prim { font-weight:bold; }
.oep-working-guard-actions button.warn { color:#9a0000; font-weight:bold; }
/* V3.0.7: the armed state of the Table resolution presets. The two rules above are scoped to their
   own containers, so without this the CONFIRM state would read as an ordinary button. */
.oep-ctrl button.warn { color:#9a0000; font-weight:bold; }
@media (max-width:720px) { .oep-mapbank-grid { grid-template-columns:1fr; } }
.oep-help-list { margin:3px 0 0 0; padding-left:16px; }
.oep-help-list li { margin-bottom:2px; }
.oep-scopebadge { display:inline-block; vertical-align:2px; margin-left:6px; padding:1px 5px; border:1px solid #808080; background:#ECE9D8; color:#444; font-size:8px; font-weight:bold; letter-spacing:.4px; }
.oep-scopebadge.permap { border-color:#0A246A; background:#DCE9FB; color:#0A246A; }
.oep-scopebadge.shared { border-color:#6d6d6d; background:#EFEDE2; color:#555; }
.oep-banner .oep-banner-cancel { margin-left:8px; padding:1px 8px; background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #003C74; border-radius:3px; font-size:9px; font-weight:bold; cursor:pointer; }
.oep-banner .oep-banner-cancel:hover { border-color:#E59700; box-shadow:0 0 4px #FFC73C inset; }
.oep-shape-grid tr.inert td, .oep-shape-grid td.inert { opacity:.45; }
.oep-shape-grid th small.colnote { display:block; font-weight:normal; color:#8a6d00; font-size:8px; }
.oep-seg button.oep-shape-nav { font-style:italic; background:linear-gradient(#fbfbf6,#efecdd 45%,#e0dcc9); }
.oep-shape-setting.inert label, .oep-shape-setting.inert small { opacity:.55; }

/* tabs (XP tab strip) */
.oep-tabs { display:flex; gap:2px; padding:4px 6px 0 6px; background:#ECE9D8; border-bottom:1px solid #808080; }
.oep-tabs button { background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #808080; border-bottom:0; color:#000; padding:3px 12px; font-size:10px; border-radius:4px 4px 0 0; cursor:pointer; position:relative; top:1px; }
.oep-tabs button.active { background:#fff; top:0; padding-bottom:4px; font-weight:bold; }

/* body */
.oep-body { flex:1; display:flex; min-height:0; background:#ECE9D8; }
.oep-nav { width:152px; flex:0 0 152px; overflow-y:auto; background:#fff; border:1px solid #7F9DB9; margin:4px 0 4px 4px; }
.oep-nav .grp { font-size:9px; color:#fff; margin:0; padding:3px 6px; font-weight:bold; background:linear-gradient(180deg,#5C9DF0,#2A6FD8); border-top:1px solid #fff; }
.oep-nav .item { padding:3px 6px 3px 12px; font-size:11px; color:#0A246A; cursor:pointer; }
.oep-nav .item:hover { background:#D8E6FF; }
.oep-nav .item.active { background:#EF9F27; color:#412402; font-weight:bold; }
.oep-work { flex:1; overflow:auto; padding:6px 8px; min-width:0; background:#ECE9D8; }
.oep-pagetitle { font-size:13px; font-weight:bold; color:#0A246A; margin-bottom:2px; }
.oep-help { font-size:10px; color:#444; margin-bottom:7px; line-height:1.35; }
.oep-mix-status,.oep-mix-reasons,.oep-mix-live { display:grid; gap:3px; margin:4px 0; font-variant-numeric:tabular-nums; }
.oep-mix-status { grid-template-columns:1.35fr repeat(5,minmax(58px,1fr)); }
.oep-mix-reasons { grid-template-columns:repeat(3,minmax(0,1fr)); }
.oep-mix-live { grid-template-columns:repeat(6,minmax(0,1fr)); margin-bottom:7px; }
.oep-mix-box { min-width:0; padding:3px 5px; border:1px solid #ACA899; background:#fff; box-shadow:inset 1px 1px 0 #ddd; overflow:hidden; }
.oep-mix-box .lbl { display:block; color:#555; font-size:8px; line-height:1.1; text-transform:uppercase; white-space:nowrap; }
.oep-mix-box .num { display:block; color:#0A246A; font-size:11px; font-weight:bold; line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.oep-mix-box .sub { display:block; color:#666; font-size:8px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.oep-mix-axis { padding:3px 6px; margin:3px 0; border:1px solid #B7A55B; background:#FFF7D6; color:#654f00; font-size:9px; font-variant-numeric:tabular-nums; }
.oep-mix-axis.inside { border-color:#78A96A; background:#EAF7E6; color:#245d1d; }

/* caption bars (blue, like PCLink "Boost Target 1 - Active") */
.oep-cap { background:linear-gradient(180deg,#3C8BF0,#1C5FD6); color:#fff; font-size:10px; font-weight:bold; padding:2px 6px; margin-top:6px; border:1px solid #08338A; }

/* runtime rows (sunken white fields) */
.oep-rt { display:flex; gap:8px; }
.oep-rt .col { flex:1; min-width:0; }
.oep-row { display:flex; justify-content:space-between; padding:2px 6px; background:#fff; border:1px solid #ACA899; border-top:0; font-size:10px; color:#000; }
.oep-row.alarm { background:#D24A3A; color:#fff; }
.oep-row .val { font-weight:bold; color:#000; }
.oep-row.alarm .val { color:#fff; }
.oep-c-cyan { color:#0A6CA8; } .oep-c-magenta { color:#A0008C; } .oep-c-yellow { color:#8a6d00; } .oep-c-green { color:#1a7a1a; } .oep-c-orange { color:#b35a00; } .oep-c-white { color:#000; }

/* gauges */
.oep-gauge { margin:5px 0; }
.oep-gauge .lab { font-size:10px; color:#000; margin-bottom:2px; display:flex; justify-content:space-between; }
.oep-gbar { height:16px; background:#fff; border:1px solid #7F9DB9; box-shadow: inset 1px 1px 1px #b8c4d0; overflow:hidden; }
.oep-gbar > div { height:100%; }
/* PCLink-style analog dials (V2.6) */
.oep-dialgrid { display:flex; flex-wrap:wrap; gap:8px; margin-top:6px; }
.oep-dialtile { background:#17171c; border:1px solid #3a3a44; box-shadow: 1px 1px 3px rgba(0,0,0,0.45); width:178px; }
.oep-dialtile .oep-dialhead { background:linear-gradient(#3d3d47,#26262c); color:#e2e2e6; font-size:10px; font-weight:bold; padding:2px 7px; border-bottom:1px solid #0c0c0f; }
.oep-dialtile canvas { display:block; margin:3px auto 5px; }

/* settings controls */
.oep-ctrl { margin:5px 0; }
.oep-ctrl .lab { font-size:10px; color:#000; display:flex; justify-content:space-between; margin-bottom:2px; }
/* V3.0.9: live caption for a slider whose real bound comes from the fitted engine rather than the
   control spec. margin-right:auto keeps it beside the label so the value stays hard right, and it is
   the only thing in this row that ever turns red - the warn state means the ECU has accepted a number
   the engine will not honour. */
.oep-ctrl .lab .oep-ctrlhint { margin-left:7px; margin-right:auto; color:#666; font-size:9px; font-weight:normal; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.oep-ctrl .lab .oep-ctrlhint.warn { color:#9a0000; font-weight:bold; }
.oep-ctrl .lab b { color:#0A246A; font-weight:bold; }
.oep-ro { display:flex; justify-content:space-between; padding:2px 6px; background:#fff; border:1px solid #ACA899; font-size:10px; margin:2px 0; }
.oep-ro .val { color:#0A246A; font-weight:bold; }
.oep-seg { display:flex; gap:3px; flex-wrap:wrap; }
.oep-seg button.on { background:#316AC5; border-color:#0A246A; color:#fff; }
.oep-chk { background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #003C74; color:#000; font-size:10px; padding:3px 8px; border-radius:3px; cursor:pointer; display:inline-block; }
.oep-chk.on { background:#5FBA46; border-color:#2a6e1c; color:#fff; }

/* Boost-by-gear/speed worksheet: reuse the compact XP language of the tuner. */
.oep-shape-mode { display:flex; align-items:center; gap:3px; flex-wrap:wrap; margin:0 0 5px; padding:4px 5px; background:#F5F3E9; border:1px solid #ACA899; border-top:0; }
.oep-shape-mode button.on { background:#316AC5; border-color:#0A246A; color:#fff; }
.oep-shape-mode button:disabled { opacity:.45; cursor:default; box-shadow:none; border-color:#808080; }
.oep-shape-sep { width:1px; height:17px; margin:0 2px; background:#ACA899; border-right:1px solid #fff; }
.oep-shape-settings { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:5px; margin:5px 0 6px; }
.oep-shape-setting { min-width:0; border:1px solid #ACA899; background:#fff; box-shadow:inset 1px 1px 0 #ddd; padding:3px 5px; font-size:9px; }
.oep-shape-setting label { display:block; font-weight:bold; color:#0A246A; margin-bottom:2px; }
.oep-shape-inputrow { display:flex; align-items:center; gap:4px; white-space:nowrap; }
.oep-shape-unit { color:#444; }
.oep-shape-setting small { display:block; color:#666; font-size:8px; line-height:1.15; margin-top:2px; }
.oep-shape-setting input, .oep-shape-grid input { -webkit-appearance:textfield; appearance:textfield; box-sizing:border-box; height:19px; padding:1px 4px; background:#fff; border:1px solid #7F9DB9; box-shadow:inset 1px 1px 1px #d5dbe1; color:#000; font:10px Tahoma,Arial,sans-serif; }
.oep-shape-stepper { display:inline-flex; align-items:center; gap:1px; vertical-align:middle; }
.oep-shape-stepper button { flex:0 0 17px; width:17px; height:19px; padding:0; line-height:15px; background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #003C74; border-radius:2px; color:#000; cursor:pointer; font:bold 10px Tahoma,Arial,sans-serif; }
.oep-shape-stepper button:hover { border-color:#E59700; box-shadow:0 0 3px #FFC73C inset; }
.oep-shape-stepper button:active { background:linear-gradient(#D6D2C2,#ECE9D8); }
.oep-shape-stepper button:disabled { opacity:.42; cursor:default; box-shadow:none; border-color:#808080; }
.oep-shape-setting .oep-shape-stepper input { width:58px; text-align:right; }
.oep-shape-setting input::-webkit-inner-spin-button, .oep-shape-setting input::-webkit-outer-spin-button, .oep-shape-grid input::-webkit-inner-spin-button, .oep-shape-grid input::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; }
.oep-shape-setting input:focus, .oep-shape-grid input:focus { outline:1px solid #B9D0F1; outline-offset:-2px; border-color:#0A246A; }
.oep-shape-setting input:disabled, .oep-shape-grid input:disabled { opacity:1; color:#777; background:#ECE9D8; border-color:#ACA899; box-shadow:none; }
.oep-shape-tables { display:flex; gap:6px; align-items:flex-start; flex-wrap:wrap; }
.oep-shape-box { flex:1 1 270px; min-width:0; border:1px solid #7F9DB9; background:#fff; }
.oep-shape-box h4 { display:flex; justify-content:space-between; align-items:center; gap:6px; margin:0; padding:2px 5px; background:linear-gradient(180deg,#5C9DF0,#2A6FD8); color:#fff; border-bottom:1px solid #08338A; font-size:10px; }
.oep-shape-box h4 small { color:#DCEAFF; font-size:8px; font-weight:normal; white-space:nowrap; min-width:0; overflow:hidden; text-overflow:ellipsis; }
.oep-shape-grid { width:100%; border-collapse:collapse; table-layout:fixed; font-size:10px; }
.oep-shape-grid th { background:linear-gradient(#FDFDFD,#DEDAC9); color:#333; border:1px solid #C7C3B5; padding:2px 3px; line-height:1.2; }
.oep-shape-grid td { height:28px; border:1px solid #D7D3C6; padding:2px 3px; text-align:center; }
.oep-shape-grid td:first-child { width:44px; font-weight:bold; color:#0A246A; background:#F7F6EF; }
.oep-shape-grid .oep-shape-stepper input { width:48px; text-align:right; }
.oep-shape-grid small { display:block; color:#666; font-size:8px; line-height:1.1; min-height:9px; }
.oep-shape-live { display:grid; grid-template-columns:repeat(3,minmax(145px,1fr)); gap:3px; margin-top:6px; }
.oep-shape-live .oep-ro { margin:0; }
.oep-shape-flow { background:#fff; border:1px solid #ACA899; box-shadow:inset 1px 1px 0 #ddd; color:#333; padding:4px 6px; margin:5px 0; font-size:10px; }
.oep-shape-flow b { color:#0A246A; }

/* XP trackbar sliders */
.oep-ctrl input[type=range] { -webkit-appearance:none; appearance:none; width:100%; height:20px; background:transparent; cursor:pointer; }
.oep-ctrl input[type=range]::-webkit-slider-runnable-track { height:4px; background:#9d9a8e; border:1px solid #5b584e; box-shadow: inset 1px 1px 0 #6f6c62; }
.oep-ctrl input[type=range]::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:12px; height:20px; margin-top:-9px;
  background:linear-gradient(#fdfdfd,#E3DFCF 55%,#C9C4B2); border:1px solid #404040;
  clip-path:polygon(0 0,100% 0,100% 60%,50% 100%,0 60%); }
.oep-ctrl input[type=range]:hover::-webkit-slider-thumb { background:linear-gradient(#fff,#EFEBDB 55%,#D8D3C1); }

/* map tables */
.oep-maptabs { display:flex; gap:2px; margin-bottom:4px; }
.oep-maptabs button.on { background:#fff; font-weight:bold; }
.oep-edit { display:flex; flex-wrap:wrap; gap:3px; align-items:center; margin-bottom:5px; }
.oep-edit input { width:46px; background:#fff; border:1px solid #7F9DB9; color:#000; font-size:10px; padding:2px 3px; }
.oep-edit .sep { width:1px; height:16px; background:#ACA899; margin:0 2px; }
.oep-afrbar { display:flex; gap:11px; align-items:center; background:#1c3e6e; color:#fff; padding:3px 8px; margin-bottom:5px; font-size:11px; border:1px solid #12406e; }
.oep-afrbar .tag { background:#4a7ac0; padding:1px 5px; font-weight:bold; font-size:9px; letter-spacing:1px; }
.oep-afrbar b { font-size:13px; }
.oep-afrbar .dim { color:#aecbec; }
.oep-afrbar b.bad { color:#ff6b5e; }
.oep-afrbar b.rich { color:#ffd24a; }
.oep-tablewrap { display:flex; gap:8px; }
.oep-kbtrap { position:absolute; opacity:0; width:2px; height:2px; border:0; padding:0; margin:0; pointer-events:none; }
.oep-kbhint { font-size:9px; color:#5a4a00; background:#fff3c2; border:1px solid #d8c26a; padding:3px 6px; margin:1px 0 4px; }
.oep-kbhint b { color:#8a2500; }
canvas.oep-big3d { background:#000; border:1px solid #7F9DB9; display:block; max-width:100%; cursor:grab; }
.oep-3dcap { font-size:9px; color:#777; margin-top:2px; }
.oep-grid { border-collapse:collapse; background:#fff; border:1px solid #7F9DB9; }
.oep-grid td, .oep-grid th { border:1px solid #c9c4b2; text-align:center; font-size:10px; padding:2px 0; min-width:44px; color:#000; font-family:Consolas,'Courier New',monospace; }
/* V3.0.7 FILL, DON'T SHRINK. The table used to be content-sized with a per-cell min-width, which is
   why a wide grid overflowed while a narrow one left most of the pane empty. Filling the container
   is both: 16 columns stop scrolling AND 7 columns stop leaving 400px of dead space. table-layout
   fixed makes every data column exactly equal, which a calibration table should be anyway - the old
   auto layout let a 4-character cell sit wider than a 3-character one.
   Only the FONT scales with column count now; the geometry is handled by the fill. */
.oep-grid { width:100%; table-layout:fixed; }
/* V3.0.9: 54px was sized to nothing in particular - the widest label the load column ever holds is 4
   characters ("68.2", "100"). 46px is the CSS floor for the tables nobody fits (Mixture Map, Pro
   Studio); the fitted map editor overrides it inline from the real label width. */
.oep-grid th:first-child, .oep-grid td.axis { width:46px; }
/* Font shrink is now a LAST RESORT, not the default for a wide grid. Once the table fills its
   container the cells are not cramped: at 16 columns in a ~595px pane a cell is ~34px and "1.04" at
   10px Consolas needs ~22px, so 10px fits every preset. The first version dropped to 8px at 13+
   columns and - because `.oep-grid.dense3 td` (0,2,1) outranks `.oep-pro-grid td` (0,1,1) - it also
   overrode Pro Studio's deliberate 10px white-on-dark down to 8px, on cells ~70px wide. Only a
   hand-built axis past 16 columns gets any reduction now, and never below 9px. */
.oep-grid.dense2 td, .oep-grid.dense2 th { font-size:9px; }
/* Pro Studio opts out entirely: its cells have a 48px floor and their own horizontal scroll, and the
   dark theme needs the full 10px to stay legible against the saturated heatmap. */
.oep-pro-grid.dense2 td, .oep-pro-grid.dense2 th { font-size:10px; }
/* VERTICAL FILL. Scoped to the map-editor page ONLY (.oep-mappage), so the other ~30 pages keep the
   block layout they were written against. .oep-work is flex:1 inside a min-height:0 column, so it has
   a real height for min-height:100% to resolve against; the page then hands its spare height to the
   table wrapper, and a table at height:100% distributes it across the rows. Rows never shrink below
   their content, so this can only ever grow them into space that was previously blank. */
/* V3.0.9: min-height:100% is GONE. The LIVE AFR bar (app.html:602) is a SIBLING of this page inside
   .oep-work, so "100% of .oep-work" plus the bar always added up to more than .oep-work - the map page
   has been ~26px taller than its container the whole time it has been connected, which is a scrollbar
   and a strip of dead space below the fold. Nothing needs the percentage now: fitGrid() measures the
   real remaining space and writes explicit heights, so the page is exactly as tall as its content. */
.oep-mappage { display:flex; flex-direction:column; }
/* ...but Pro Studio still needs it. .oep-pro-map is a flex CHILD of this page and fills with
   flex:1 1 auto (app.html:307), which resolves against nothing once the parent is content-height -
   dropping the percentage outright would silently undo the V3.0.7 Pro Studio fill. Pro Studio has no
   fitGrid (its table opts out) and no AFR-bar overflow worth chasing in this pass, so it keeps the
   old behaviour exactly. */
.oep-mappage.oep-mappage-fill { min-height:100%; }
/* V3.0.9 THE TABLE IS THE PAGE. The wrapper is now a COLUMN: calibration table on top, the info boxes
   as a strip underneath. Two things fall out of that. (1) The grid gets the full pane width back -
   the old right-hand column cost it a permanent 162px while standing mostly empty below the boxes.
   (2) The strip mirrors .oep-pro-table-status, which has always been a horizontal strip under the Pro
   Studio table binding this exact same data, so the two surfaces finally agree. */
.oep-mappage .oep-tablewrap { flex:1 1 auto; min-height:0; flex-direction:column; gap:5px; }
/* min-height:0 matters now that the wrapper is a COLUMN: the main axis is vertical, so the automatic
   minimum size is the table's full height and the scroller would refuse to shrink, pushing the strip
   out of the pane. min-width:0 (inherited from the base rule) only ever guarded the horizontal axis. */
.oep-mappage .oep-gridscroll { overflow:auto; flex:0 1 auto; min-height:0; }
/* Fixed height, not a flex remainder: the Surface canvas below has a fixed backing store and would
   blur or distort if its box changed shape. A short table therefore leaves some slack rather than
   inflating the boxes - see fitGrid(), which spends that slack on taller rows first. */
.oep-mappage .oep-tablewrap > .oep-side { flex:0 0 94px; display:flex; gap:5px; align-items:stretch; }
.oep-mappage .oep-tablewrap > .oep-side > .box { flex:1 1 0; min-width:0; margin:0; overflow:hidden; }
.oep-mappage .oep-tablewrap > .oep-side > .box.surf { flex:0 0 210px; padding:3px 4px 4px; }
.oep-mappage .oep-tablewrap > .oep-side .selrow { display:flex; flex-wrap:wrap; gap:2px 10px; }
/* Still used by the two tables fitGrid does NOT touch: Mixture Map and Pro Studio. */
.oep-grid.rows-tall td, .oep-grid.rows-tall th { padding:7px 0; }
.oep-grid.rows-mid td, .oep-grid.rows-mid th { padding:4px 0; }
/* Geometry for the fitted table is computed in fitGrid() and written inline, so these only have to
   get out of the way: the cell font inherits from the table (one write instead of one per cell), and
   the padding stops competing with the measured row height. ORDER IS LOAD-BEARING - these are the same
   (0,2,1) specificity as the rows-* pair above, so they must stay BELOW them to win the tie. Note the
   rows-* padding is `7px 0` / `4px 0` - zero HORIZONTAL padding - so losing this tie would also put
   glyphs flush against the cell borders at exactly the larger font sizes fitGrid now picks. */
.oep-grid.oep-fit td, .oep-grid.oep-fit th { font-size:inherit; padding:0 2px; }
.oep-grid.oep-fit th:first-child { font-size:8px; line-height:1.05; }
/* V3.0.7 STICKY LOAD COLUMN. Even at the tightest density a wide grid can still scroll (small app
   window, Pro Studio, a hand-built 20-column axis). Losing the kPa labels when you scroll right is
   what actually makes a wide table unreadable, so pin the axis column and the corner header.
   border-collapse drops the shared border on a stuck cell, hence the explicit box-shadow divider. */
.oep-gridscroll { flex:1; min-width:0; overflow-x:auto; }
.oep-grid td.axis, .oep-grid th:first-child { position:sticky; left:0; z-index:2; box-shadow:1px 0 0 #08338A; }
.oep-grid th { background:linear-gradient(#5C9DF0,#2A6FD8); color:#fff; font-weight:bold; border-color:#08338A; }
.oep-grid td.axis { background:linear-gradient(#5C9DF0,#2A6FD8); color:#fff; font-weight:bold; border-color:#08338A; }
.oep-grid td.cell { color:#000; cursor:crosshair; text-shadow:none; }
.oep-grid td.cell.sel { color:#000; font-weight:bold; box-shadow:inset 0 0 0 2px #ffd700; }
.oep-grid td.cell.live { color:#000; font-weight:bold; box-shadow:inset 0 0 0 2px #00a8ff; }

/* Browser Pro Studio map workspace. The Basic Studio and in-game app never
   create this branch, so their compact table/3D toggle remains untouched. */
/* V3.0.7 PRO STUDIO VERTICAL FILL. The workspace used to be pinned in pixels - 382px for the
   table/surface row plus 214px for the instrument row - so on any browser window taller than ~640px
   the bottom of the page was dead space while the calibration table stayed cramped. Studio's own
   chrome already fills correctly (.deck is flex:1 and the iframe is height:100%), and .oep-mappage
   is a flex column, so the fill just has to be handed down: the map workspace takes the height, the
   table/surface row absorbs ALL of the surplus, and the instrument strip keeps its designed size
   because gauges gain nothing from being stretched. min-height is retained as the floor so a short
   window degrades to exactly today's layout rather than collapsing. */
.oep-pro-map { min-width:760px; color:#cccccc; flex:1 1 auto; min-height:0; display:flex; flex-direction:column; }
.oep-pro-map-main { display:grid; grid-template-columns:minmax(500px,1.35fr) minmax(340px,.9fr); gap:5px; flex:1 1 auto; min-height:330px; }
.oep-pro-pane { position:relative; min-width:0; min-height:0; display:flex; flex-direction:column; overflow:hidden; background:#252526; border:1px solid #0f0f10; }
.oep-pro-pane-head { flex:0 0 23px; display:flex; align-items:center; justify-content:center; padding:0 7px; color:#cccccc; background:#333337; border-bottom:1px solid #0f0f10; font-size:11px; }
.oep-pro-pane-head.active { color:#eef8ff; background:#007acc; box-shadow:inset 0 0 0 1px #0098ff; }
.oep-pro-pane-head .oep-pro-headmeta { margin-left:auto; color:#969696; font-size:8px; letter-spacing:.4px; }
.oep-pro-table-labels { flex:0 0 22px; display:flex; align-items:center; justify-content:space-between; padding:0 8px 0 30px; color:#b0b0b0; background:#2d2d30; border-bottom:1px solid #151516; font-size:9px; }
.oep-pro-table-shell { position:relative; flex:1; min-height:0; display:grid; grid-template-columns:28px minmax(0,1fr); background:#1e1e1e; }
.oep-pro-load-label { min-width:0; display:flex; align-items:center; justify-content:center; overflow:hidden; color:#b0b0b0; font-size:9px; letter-spacing:.35px; pointer-events:none; }
.oep-pro-load-label > span { display:block; flex:0 0 auto; white-space:nowrap; transform:rotate(-90deg); transform-origin:center; }
.oep-pro-table-scroll { flex:1; min-width:0; min-height:0; overflow:auto; padding:4px 5px 5px 2px; }
.oep-pro-grid { width:max-content; min-width:100%; height:100%; table-layout:fixed; border-collapse:collapse; background:#151516; border:1px solid #0f0f10; }
.oep-pro-grid th, .oep-pro-grid td { height:25px; min-width:48px; padding:2px 3px; border:1px solid #3e3e42; text-align:center; font-family:Consolas,'Courier New',monospace; font-size:10px; color:#fff; }
.oep-pro-grid th:first-child, .oep-pro-grid td.axis { min-width:58px; background:#285b82; border-color:#003d66; color:#fff; font-weight:bold; }
.oep-pro-grid td.cell { color:#fff; cursor:crosshair; text-shadow:0 1px 1px #000; }
.oep-pro-grid td.cell.sel { outline:2px solid #ffdc45; outline-offset:-2px; font-weight:bold; }
.oep-pro-grid td.cell.live { box-shadow:inset 0 0 0 2px #00d2ff; }
.oep-pro-table-status { flex:0 0 32px; display:flex; align-items:center; gap:12px; min-width:0; padding:0 7px; color:#b0b0b0; background:#252526; border-top:1px solid #0f0f10; font-size:9px; }
.oep-pro-table-status b { color:#0098ff; }
.oep-pro-table-status .risk { margin-left:auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#f2c84b; }
.oep-pro-surface-body { position:relative; flex:1; min-height:0; overflow:hidden; background:#181818; }
.oep-pro-surface-body canvas { width:100%; height:100%; border:0; cursor:grab; }
.oep-pro-surface-readout { position:absolute; left:9px; top:7px; z-index:3; color:#ffffff; font:11px Consolas,'Courier New',monospace; line-height:1.45; pointer-events:none; }
.oep-pro-surface-readout b { display:block; color:#fff; font-size:16px; }
.oep-pro-zoom { position:absolute; right:8px; top:8px; z-index:4; display:grid; grid-template-columns:22px 22px; grid-template-rows:22px 22px 22px; gap:2px; padding:3px; background:#e0e4e8; border:1px solid #808080; box-shadow:1px 1px 2px rgba(0,0,0,.4); }
.oep-pro-zoom button { height:22px; padding:0; color:#111; background:linear-gradient(#f5f7fa,#c8cdd2); border:1px solid #6a737c; border-radius:2px; font:bold 13px Tahoma,sans-serif; cursor:pointer; }
.oep-pro-zoom button:hover { background:linear-gradient(#fff,#d8dde2); border-color:#4a90c8; }
.oep-pro-zoom button:active { background:linear-gradient(#b8bec4,#e0e4e8); }
.oep-pro-surface-foot { flex:0 0 27px; display:flex; align-items:center; gap:10px; padding:0 7px; color:#b0b0b0; background:#252526; border-top:1px solid #0f0f10; font-size:9px; }
.oep-pro-surface-foot .on { color:#0098ff; }
/* flex-basis rather than height: the instrument strip keeps its designed 214px and never grows or
   shrinks, so every pixel the window gains goes to the table above it. */
.oep-pro-map-bottom { display:grid; grid-template-columns:minmax(235px,1fr) minmax(215px,.88fr) minmax(355px,1.35fr); gap:5px; flex:0 0 214px; margin-top:5px; }
.oep-pro-data-list { flex:1; min-height:0; overflow:auto; background:#252526; }
.oep-pro-data-row { min-height:15px; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:1px 6px; border-bottom:1px solid #151516; color:#cccccc; font-size:9px; }
.oep-pro-data-row:nth-child(even) { background:#2d2d30; }
.oep-pro-data-row b { color:#0098ff; font-weight:bold; }
.oep-pro-data-row.alarm { background:#913238; color:#fff; }
.oep-pro-data-row.alarm b { color:#fff; }
.oep-pro-instruments { flex:1; min-height:0; display:grid; grid-template-columns:82px 82px minmax(150px,1fr) 72px; align-items:stretch; background:#1e1e1e; }
.oep-pro-tempstack { display:grid; grid-template-rows:1fr 1fr; border-right:1px solid #0f0f10; }
.oep-pro-temp { display:flex; flex-direction:column; align-items:center; justify-content:center; overflow:hidden; border-bottom:1px solid #0f0f10; }
.oep-pro-temp:last-child { border-bottom:0; }
.oep-pro-temp span { color:#ff9d57; font-size:10px; font-weight:bold; }
.oep-pro-temp.iat span { color:#57d7ff; }
.oep-pro-temp b { color:#fff; font:32px/1 Consolas,'Courier New',monospace; }  /* 32px so a 4-digit EGT (~1650degF) fits the 82px tile instead of bleeding onto IAT + the lambda dial */
.oep-pro-lambda { position:relative; min-width:0; display:flex; align-items:center; justify-content:center; border-right:1px solid #0f0f10; }
.oep-pro-lambda canvas { display:block; width:184px; max-width:100%; height:184px; }
.oep-pro-lambda-label { position:absolute; top:7px; left:0; right:0; text-align:center; color:#57d7ff; font-size:10px; font-weight:bold; pointer-events:none; }
.oep-pro-tps { min-width:0; display:flex; flex-direction:column; align-items:center; padding:7px 5px 5px; }
.oep-pro-tps span { color:#ff4f55; font-size:10px; font-weight:bold; }
.oep-pro-tps b { color:#fff; font:24px/1.2 Consolas,'Courier New',monospace; }
.oep-pro-tps-track { position:relative; flex:1; width:18px; min-height:105px; margin-top:5px; overflow:hidden; background:#0f0f10; border:1px solid #4a4a4e; }
.oep-pro-tps-fill { position:absolute; left:2px; right:2px; bottom:2px; min-height:2px; max-height:calc(100% - 4px); background:#007acc; }
.oep-pro-tps-scale { position:absolute; inset:3px -10px 3px 3px; background:repeating-linear-gradient(to bottom,rgba(255,255,255,.7) 0,rgba(255,255,255,.7) 1px,transparent 1px,transparent 10%); pointer-events:none; }
@media (max-width:980px) {
  .oep-pro-map { min-width:675px; }
  .oep-pro-map-main { grid-template-columns:minmax(410px,1.25fr) minmax(260px,.75fr); }
  .oep-pro-map-bottom { grid-template-columns:minmax(200px,1fr) minmax(185px,.9fr) minmax(280px,1.2fr); }
  .oep-pro-instruments { grid-template-columns:70px minmax(135px,1fr) 62px; }
  .oep-pro-tempstack + .oep-pro-tempstack { display:none; }
}
@media (max-height:650px) {
  /* V3.0.7: floor + basis, not height - the row still grows if the window has room, it just cannot
     drop below what a short window can show. Overriding `height` here would have re-pinned it. */
  .oep-pro-map-main { min-height:282px; }
  .oep-pro-map-bottom { flex:0 0 180px; }
  .oep-pro-grid th, .oep-pro-grid td { height:29px; }
  .oep-pro-temp b { font-size:32px; }
  .oep-pro-lambda canvas { width:154px; height:154px; }
}

/* side panels */
.oep-side { flex:0 0 154px; }
.oep-side .box { background:#fff; border:1px solid #7F9DB9; padding:5px; font-size:10px; margin-bottom:5px; color:#000; }
.oep-side .box b { color:#0A246A; }
canvas.oep-surface, canvas.oep-dyno, canvas.oep-log, canvas.oep-lognav { width:100%; background:#000; border:1px solid #7F9DB9; display:block; }
/* Pin the height in the strip. The shared rule above sets width:100% and leaves height auto, which
   only keeps the aspect right while the box happens to match the 200x72 backing store; a hard height
   means a box that drifts scales the bitmap uniformly instead of shearing it. drawSurface() works off
   cv.width/cv.height in fractions, so it re-lays itself out at any size (app.js:3496). */
.oep-mappage .oep-side > .box.surf canvas.oep-surface { height:72px; }
canvas.oep-lognav { margin-top:3px; cursor:ew-resize; }
.oep-paramlist { display:grid; grid-template-columns:1fr 1fr; gap:0 14px; }

/* slots / notes / status / misc */
.oep-slot { display:flex; align-items:center; gap:4px; margin:2px 0; font-size:10px; }
.oep-slot .nm { flex:1; color:#000; }
.oep-slot button { font-size:9px; padding:1px 6px; }
.oep-note { font-size:10px; color:#000; padding:2px 0; border-bottom:1px solid #d8d4c4; }
.oep-note .tm { color:#0A6CA8; }
.oep-status { display:flex; gap:10px; align-items:center; padding:2px 8px; background:#ECE9D8; border-top:1px solid #fff; box-shadow:inset 0 1px 0 #ACA899; font-size:10px; color:#333; height:20px; }
.oep-status .req { color:#9a0000; font-weight:bold; } .oep-status .ok { color:#1a7a1a; font-weight:bold; }
.oep-toast { position:absolute; bottom:26px; left:50%; transform:translateX(-50%); background:#FFFFCC; color:#000; font-size:10px; padding:4px 12px; border:1px solid #808080; box-shadow:1px 1px 3px rgba(0,0,0,.4); }
.oep-banner { background:#FFFFCC; border:1px solid #C0A000; color:#7a5a00; font-size:10px; padding:4px 7px; margin-bottom:6px; }
.oep-banner.mon { background:#FFE3B3; border-color:#C07000; color:#7a4300; margin:4px 6px 0; }
input[type=text].oep-in { background:#fff; border:1px solid #7F9DB9; color:#000; font-size:11px; padding:3px 5px; }
pre.oep-helptext { white-space:pre-wrap; font-size:10px; color:#000; line-height:1.4; font-family:Tahoma,sans-serif; }

/* XP scrollbars */
.oep-root ::-webkit-scrollbar { width:16px; height:16px; }
.oep-root ::-webkit-scrollbar-track { background:#ECE9D8; }
.oep-root ::-webkit-scrollbar-thumb { background:linear-gradient(90deg,#fdfdfd,#C9C4B2); border:1px solid #808080; border-radius:2px; }
.oep-root ::-webkit-scrollbar-button { background:linear-gradient(#fdfdfd,#C9C4B2); border:1px solid #808080; height:16px; width:16px; }
/* ===== Help Me / Tune Doctor ===== */
.oep-diag { background:#fff; border:1px solid #ACA899; border-left:5px solid #888; border-radius:3px; padding:6px 8px; margin:5px 0; }
.oep-diag.sev-danger { border-left-color:#D24A3A; background:#fdecea; }
.oep-diag.sev-warn { border-left-color:#E59700; background:#fff7e6; }
.oep-diag.sev-info { border-left-color:#2A6FD8; background:#eef4ff; }
.oep-diag.sev-good { border-left-color:#5FBA46; background:#eef7ee; }
.oep-diag-title { font-size:11px; font-weight:bold; color:#0A246A; }
.oep-diag.sev-danger .oep-diag-title { color:#9a0000; }
.oep-diag.sev-good .oep-diag-title { color:#1a7a1a; }
.oep-diag-advice { font-size:10px; color:#333; line-height:1.4; margin:3px 0 5px; }
.oep-diag-go { background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #003C74; color:#000; font-size:9px; padding:2px 8px; border-radius:3px; cursor:pointer; }
.oep-diag-go:hover { border-color:#E59700; }

/* ===== tutorial / coaching ===== */
.oep-blink { animation: oepblink 0.7s infinite alternate; position:relative; z-index:5; border-radius:3px; }
@keyframes oepblink {
  from { box-shadow: 0 0 0 2px #ff9d00, 0 0 6px 2px rgba(255,180,40,.55); }
  to   { box-shadow: 0 0 0 3px #ffd23b, 0 0 14px 4px rgba(255,180,40,.9); }
}
.oep-tut { flex:0 0 auto; margin:3px 6px 4px;
  background:linear-gradient(180deg,#fffef0,#ECE9D8); border:1px solid #08338A; border-top:3px solid #2A6FD8;
  border-radius:5px; box-shadow:0 -2px 8px rgba(0,0,0,.35); padding:6px 9px; }
.oep-tut-head { font-size:11px; font-weight:bold; color:#0A246A; display:flex; justify-content:space-between; align-items:center; }
.oep-tut-prog { font-size:9px; color:#555; font-weight:normal; }
.oep-tut-text { font-size:11px; color:#1a1a1a; line-height:1.4; margin:4px 0 7px; }
.oep-tut-verify { font-size:9px; line-height:1.3; color:#5b4700; background:#fff7cf; border:1px solid #d2b85a; margin:-2px 0 6px; padding:3px 5px; }
.oep-tut-verify.pass { color:#145c14; background:#e9f7e5; border-color:#5FBA46; }
.oep-tut-verify-badge { display:inline-block; min-width:50px; margin-right:4px; padding:1px 4px; color:#fff; background:#2A6FD8; font-size:8px; font-weight:bold; text-align:center; }
.oep-tut-verify.pass .oep-tut-verify-badge { background:#3f9a2c; }
.oep-tut-actions { display:flex; gap:4px; justify-content:flex-end; }
.oep-tut-actions button { background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #003C74; color:#000; font-size:10px; padding:3px 9px; border-radius:3px; cursor:pointer; }
.oep-tut-actions button:hover { border-color:#E59700; }
.oep-tut-actions button:disabled { cursor:default; opacity:.5; border-color:#999; }
.oep-tut-next { font-weight:bold; opacity:0.55; }
.oep-tut-next.ready { opacity:1; background:linear-gradient(#9be88a,#5FBA46 55%,#3f9a2c) !important; border-color:#2a6e1c !important; color:#fff !important; animation:oepnext 0.7s infinite alternate; }
@keyframes oepnext { from { box-shadow:0 0 2px #5FBA46; } to { box-shadow:0 0 9px 2px rgba(95,186,70,.9); } }
.oep-welcome { position:absolute; inset:0; z-index:40; background:rgba(10,20,40,.55); display:flex; align-items:center; justify-content:center; }
.oep-welcome-box { width:330px; max-height:94%; overflow-y:auto; background:#ECE9D8; border:1px solid #08338A; border-radius:6px; box-shadow:0 6px 24px rgba(0,0,0,.6); }
.oep-welcome-title { background:linear-gradient(180deg,#3C8BF0,#1C5FD6); color:#fff; font-size:13px; font-weight:bold; padding:6px 10px; }
.oep-welcome-title b { color:#FFD23B; }
.oep-welcome-sub { font-size:10px; color:#333; padding:8px 10px 4px; line-height:1.4; }
.oep-chapter { font-size:10px; font-weight:bold; color:#08338A; margin:9px 10px 2px; padding-bottom:2px; border-bottom:1px solid #B0C4DE; display:flex; align-items:center; }
.oep-chapter-prog { margin-left:auto; font-size:8px; color:#1a7a1a; font-weight:900; }
.oep-chapcard { margin:5px 10px; padding:8px 9px; background:#fff; border:1px solid #7F9DB9; border-radius:3px; cursor:pointer; }
.oep-chapcard:hover { background:#D8E6FF; border-color:#2A6FD8; }
.oep-chapcard-head { font-size:12px; color:#0A246A; font-weight:bold; display:flex; align-items:center; }
.oep-chapnav { display:flex; align-items:center; gap:8px; margin:7px 10px 3px; font-size:11px; font-weight:bold; color:#08338A; }
.oep-back { background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #003C74; color:#000; font-size:10px; padding:2px 9px; border-radius:3px; cursor:pointer; }
.oep-back:hover { background:linear-gradient(#fff,#E8EEF8 45%,#CcD8E8); border-color:#2A6FD8; }
.oep-lesson { margin:3px 10px; padding:5px 8px; background:#fff; border:1px solid #7F9DB9; border-radius:3px; cursor:pointer; }
.oep-lesson:hover { background:#D8E6FF; border-color:#2A6FD8; }
.oep-lesson.done { background:#eef7ee; border-color:#5FBA46; }
.oep-lesson.unavailable, .oep-lesson.unavailable:hover { background:#F1EFE6; border-color:#aaa; opacity:.62; cursor:not-allowed; }
.oep-lesson.unavailable .oep-lesson-head, .oep-lesson.unavailable .oep-lesson-go { color:#666; }
.oep-lesson-head { font-size:11px; color:#0A246A; font-weight:bold; display:flex; align-items:center; }
.oep-lesson.done .oep-lesson-head { color:#1a7a1a; }
.oep-lesson-go { color:#2A6FD8; margin-right:5px; font-weight:900; }
.oep-lesson.done .oep-lesson-go { color:#1a7a1a; }
.oep-lesson-diff { margin-left:auto; font-size:8px; font-weight:900; letter-spacing:.5px; color:#fff; background:#7a8089; border-radius:2px; padding:1px 5px; }
.oep-lesson-desc { font-size:9px; color:#555; font-weight:normal; margin-top:2px; }
.oep-welcome-actions { display:flex; justify-content:flex-end; padding:6px 10px 9px; }
.oep-welcome-actions button { background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #003C74; color:#000; font-size:10px; padding:3px 10px; border-radius:3px; cursor:pointer; }

/* ===== challenges (V3.0.9) ===== */
.oep-chal-intro { font-size:10px; color:#333; line-height:1.45; background:#fff; border:1px solid #7F9DB9; padding:6px 8px; margin-bottom:6px; }
.oep-chal-intro b { color:#08338A; }
.oep-chal-lock { font-size:10px; color:#7a3b00; background:#fff6e2; border:1px solid #E59700; padding:5px 8px; margin-bottom:6px; font-weight:bold; }
.oep-chal { margin:0 0 5px; padding:6px 9px; background:#fff; border:1px solid #7F9DB9; border-radius:3px; cursor:pointer; }
.oep-chal:hover { background:#D8E6FF; border-color:#2A6FD8; }
.oep-chal.unavailable, .oep-chal.unavailable:hover { background:#F1EFE6; border-color:#aaa; opacity:.62; cursor:not-allowed; }
.oep-chal.unavailable .oep-chal-head { color:#666; }
.oep-chal-head { font-size:11.5px; color:#0A246A; font-weight:bold; display:flex; align-items:center; gap:6px; }
.oep-chal-go { color:#2A6FD8; font-weight:900; }
.oep-chal-desc { font-size:9px; color:#555; margin-top:3px; line-height:1.4; }
.oep-chal-kind { font-size:7.5px; font-weight:900; letter-spacing:.6px; color:#fff; background:#7a8089; border-radius:2px; padding:1px 5px; }
.oep-chal-kind.power { background:#a8324f; }
.oep-chal-kind.guided { background:#2f6f4f; }
.oep-chal-note { font-size:9px; line-height:1.4; margin-top:4px; padding:3px 6px; color:#5b4700; background:#fff7cf; border:1px solid #d2b85a; }
.oep-chal-note.quiet { color:#555; background:#f6f6f0; border-color:#cfcfc4; }
.oep-chres-nudge { font-size:9.5px; line-height:1.45; color:#5b4700; background:#fff7cf; border:1px solid #d2b85a; margin:0 10px 8px; padding:5px 7px; }
.oep-chal-foot { display:flex; align-items:center; gap:6px; margin-top:4px; font-size:8.5px; color:#555; }
.oep-medal { font-size:8px; font-weight:900; letter-spacing:.5px; color:#3a2c00; border-radius:2px; padding:1px 6px; border:1px solid #8a7330; background:linear-gradient(#ffe9a8,#e8c256); }
.oep-medal.silver { color:#2c3238; border-color:#8b949c; background:linear-gradient(#f4f7fa,#c3ccd4); }
.oep-medal.bronze { color:#40240c; border-color:#9c6a3c; background:linear-gradient(#f0cba4,#cd9358); }
.oep-medal.none { color:#555; border-color:#aaa; background:linear-gradient(#fbfbf6,#e4e2d8); }
/* quiz - the diagnosis gate inside the coaching box */
.oep-quiz { margin:2px 0 7px; }
.oep-quiz-q { font-size:9.5px; color:#5b4700; background:#fff7cf; border:1px solid #d2b85a; padding:3px 6px; margin-bottom:4px; line-height:1.35; }
.oep-quiz-opt { display:block; width:100%; text-align:left; margin:0 0 3px; padding:4px 7px; font-size:10px; line-height:1.35; cursor:pointer;
  background:linear-gradient(#fdfdfd,#ECE9D8 45%,#D6D2C2); border:1px solid #003C74; border-radius:3px; color:#000; }
.oep-quiz-opt:hover { border-color:#E59700; background:linear-gradient(#fff,#E8EEF8 45%,#CcD8E8); }
.oep-quiz-opt.right { background:linear-gradient(#9be88a,#5FBA46 55%,#3f9a2c) !important; border-color:#2a6e1c !important; color:#fff !important; font-weight:bold; }
.oep-quiz-opt.wrong { background:linear-gradient(#ffd6d6,#f0a9a9 55%,#e08a8a) !important; border-color:#a83232 !important; color:#5a1010 !important; }
.oep-quiz-why { font-size:9px; line-height:1.4; color:#5a1010; background:#fdeaea; border:1px solid #d79999; padding:4px 6px; margin-top:2px; }
/* result card */
.oep-chres { position:absolute; inset:0; z-index:45; background:rgba(10,20,40,.6); display:flex; align-items:center; justify-content:center; }
.oep-chres-box { width:340px; max-height:94%; overflow-y:auto; background:#ECE9D8; border:1px solid #08338A; border-radius:6px; box-shadow:0 6px 24px rgba(0,0,0,.6); }
.oep-chres-title { background:linear-gradient(180deg,#3C8BF0,#1C5FD6); color:#fff; font-size:13px; font-weight:bold; padding:6px 10px; }
.oep-chres-title.failed { background:linear-gradient(180deg,#d0553f,#a8321c); }
.oep-chres-medal { text-align:center; padding:9px 10px 4px; }
.oep-chres-medal .oep-medal { font-size:13px; letter-spacing:1.5px; padding:4px 16px; }
.oep-chres-gain { text-align:center; font-size:11px; font-weight:bold; color:#08338A; padding:2px 10px 7px; }
.oep-chres-new { text-align:center; font-size:9px; font-weight:900; color:#1a7a1a; letter-spacing:.5px; padding-bottom:6px; }
.oep-chres-fail { font-size:10px; line-height:1.45; color:#5a1010; background:#fdeaea; border:1px solid #d79999; margin:0 10px 8px; padding:6px 8px; }
.oep-chres-crit { display:flex; gap:6px; align-items:flex-start; font-size:10px; color:#333; margin:0 10px 3px; padding:3px 6px; background:#fff; border:1px solid #cfd6e0; }
.oep-chres-crit.ok { background:#eef7ee; border-color:#5FBA46; color:#145c14; }
.oep-chres-crit-mark { font-weight:900; color:#a83232; }
.oep-chres-crit.ok .oep-chres-crit-mark { color:#3f9a2c; }
.oep-chres-stats { font-size:9px; color:#555; line-height:1.5; margin:7px 10px 0; padding-top:5px; border-top:1px solid #B0C4DE; }
.oep-chres-stats span { display:inline-block; min-width:50%; }

/* ===== Build Card (Windows XP / Luna themed spec sheet) ===== */
.oep-snap .oep-menu, .oep-snap .oep-toolbar, .oep-snap .oep-tabs, .oep-snap .oep-nav, .oep-snap .oep-status { display:none !important; }
.oep-card { background:#ECE9D8; border:1px solid #0A246A; border-radius:3px; margin:6px auto; max-width:520px; box-shadow:0 2px 6px rgba(0,0,0,.35); overflow:hidden; }
.oep-card-hd { display:flex; align-items:baseline; gap:8px; padding:3px 8px;
  background:linear-gradient(180deg,#3C8BF0 0%,#2A78E4 9%,#1C5FD6 42%,#1A5AD2 88%,#3C8BF0 100%); border-bottom:1px solid #08338A; }
.oep-card-car { font-size:15px; font-weight:bold; color:#fff; letter-spacing:.3px; text-shadow:1px 1px 1px rgba(0,0,0,.4); }
.oep-card-cfg { font-size:10px; color:#cfe0ff; flex:1; }
.oep-card-badge { font-size:9px; font-weight:bold; padding:1px 6px; border-radius:2px; background:#ECE9D8; color:#444; border:1px solid #08338A; }
.oep-card-badge.turbo { background:#FFD23B; color:#5a3b00; }
.oep-card-power { display:flex; gap:10px; justify-content:center; margin:8px 0 4px; }
.oep-card-num { text-align:center; background:#fff; border:1px solid #7F9DB9; box-shadow:inset 1px 1px 2px rgba(0,0,0,.15); border-radius:2px; padding:4px 18px; min-width:120px; }
.oep-card-num .big { font-size:38px; font-weight:bold; line-height:1; color:#0A246A; font-family:Tahoma,'Segoe UI',sans-serif; }
.oep-card-num .unit { font-size:10px; color:#666; letter-spacing:2px; margin-top:1px; }
.oep-card-num .at { font-size:9px; color:#888; }
.oep-card-stamp { text-align:center; font-size:10px; font-weight:bold; letter-spacing:1px; padding:2px; margin:4px 8px 8px; border-radius:2px; background:linear-gradient(#f3f1e6,#ddd9c6); border:1px solid #ACA899; color:#666; }
.oep-card-stamp.ver { background:linear-gradient(#dff5e1,#bce3c2); color:#155724; border:1px solid #4a8a5a; }
.oep-card-stamp.rate { background:linear-gradient(#fff6d9,#f5e3a8); color:#6a5311; border:1px solid #b89a3a; }
.oep-card-stamp.peak { background:linear-gradient(#deebfb,#bcd4ee); color:#0A246A; border:1px solid #5c85c7; }
.oep-ver { color:#9fc0f5; font-size:9px; margin-left:6px; font-weight:normal; letter-spacing:.5px; }
.oep-card-gain { display:flex; gap:8px; justify-content:center; align-items:center; margin:0 8px 6px; padding:3px 6px; font-size:11px; color:#333; background:#fff; border:1px solid #7F9DB9; border-radius:2px; }
.oep-card-gain b { color:#0A246A; font-size:13px; }
.oep-card-gain .arrow { color:#888; font-weight:bold; }
.oep-card-gain .pct { font-size:14px; font-weight:bold; color:#0f7a2b; padding:0 6px; }
.oep-card-gain .pct.neg { color:#b33; }
.oep-card-specs { display:grid; grid-template-columns:1fr 1fr; gap:0; margin:0 8px 8px; border:1px solid #ACA899; }
.oep-card-specs .row { display:flex; justify-content:space-between; font-size:10px; background:#fff; padding:2px 6px; border-bottom:1px solid #E4E0D2; }
.oep-card-specs .row span { color:#555; }
.oep-card-specs .row b { color:#0A246A; }
.oep-card-parts { margin:0 8px 8px; border:1px solid #ACA899; }
.oep-card-parts .hd { font-size:10px; font-weight:bold; color:#fff; letter-spacing:.5px; padding:2px 6px; background:linear-gradient(180deg,#5C9DF0,#2A6FD8); border-bottom:1px solid #08338A; }
.oep-card-parts .part { display:flex; justify-content:space-between; font-size:10px; padding:2px 6px; background:#fff; border-bottom:1px solid #E4E0D2; }
.oep-card-parts .part:nth-child(even) { background:#F5F3EA; }
.oep-card-parts .part span { color:#666; }
.oep-card-parts .part b { color:#0A246A; }
.oep-card-parts .part.muted { color:#888; justify-content:center; font-style:italic; }
.oep-card-ft { display:flex; justify-content:space-between; align-items:center; padding:3px 8px; background:#ECE9D8; border-top:1px solid #ACA899; font-size:11px; font-weight:bold; color:#0A246A; }
.oep-card-ft b { color:#1C5FD6; }
.oep-card-ft .muted { font-size:9px; color:#777; font-weight:normal; }
</style>

<!-- collapsed pill -->
<div class="oep-expand" ng-click="toggleCollapse()" title="Show OE ProTune ECU">OE&#9656;</div>

<div class="oep-main" ng-class="{'oep-snap': snapshot && activePage==='buildcard'}">

  <!-- header -->
  <div class="oep-header">
    <div class="oep-brand" ng-mousedown="startWindowDrag($event)" title="Drag to move">
      <span class="oep-grip">&#9783;</span><span>OE <b>PRO</b>TUNE<small>TRAINING ECU</small><small class="oep-ver">v{{appVersion}}</small></span>
    </div>
    <div class="oep-headright">
      <div class="oep-induct" ng-class="{turbo: base.hasTurbo}">{{base.hasTurbo ? 'TURBO' : 'NA'}}</div>
      <div class="oep-conn" ng-class="!connected ? 'off' : (controlling ? 'on' : 'mon')">{{!connected ? 'OFFLINE' : (controlling ? 'LIVE' : 'MONITOR')}}</div>
      <div class="oep-hide" ng-click="toggleCollapse()" title="Hide">&#8211;</div>
    </div>
  </div>

  <!-- menu -->
  <div class="oep-menu">
    <button ng-repeat="m in menu" ng-click="setPage(m.go)">{{tr(m.label)}}</button>
    <span class="spacer"></span>
    <span class="oep-langbtns" title="Language / Idioma / Langue"><button ng-class="{on: lang==='en'}" ng-click="setLang('en')">EN</button><button ng-class="{on: lang==='es'}" ng-click="setLang('es')">ES</button><button ng-class="{on: lang==='fr'}" ng-click="setLang('fr')">FR</button><button ng-class="{on: lang==='pt'}" ng-click="setLang('pt')">PT</button></span>
    <button ng-click="toggleUnits()" title="Switch boost / MAP units between kPa and PSI">{{psi ? 'PSI' : 'kPa'}}</button>
    <button ng-click="toggleTemp()" title="Switch temperatures between Celsius and Fahrenheit">{{tempF ? '°F' : '°C'}}</button>
    <button ng-click="toggleSpeedUnits()" title="Switch live ground-speed readouts between km/h and mph (display only - the speed table and recorded logs are always stored in km/h)">{{mph ? 'mph' : 'km/h'}}</button>
    <button ng-click="toggleAfr()" title="Switch the Lambda Target table between Lambda and AFR (display only - the table is always stored in lambda)">{{afr ? 'AFR' : 'λ'}}</button>
    <button ng-if="hasTour()" ng-click="startTour()" title="What does this page do? A 20-second guided tour" style="font-weight:bold">?</button>
    <span class="oep-statusblk" ng-class="!connected ? 'r' : (controlling ? 'g' : 'y')">{{!connected ? tr('Offline') : (controlling ? tr('Controlling') : tr('Monitoring'))}}</span>
    <span class="oep-statusblk" ng-class="storeRequired ? 'r' : 'g'" title="{{storeRequired ? 'The calibration package (all four MAPs + working tune) changed since the last Store ECU recovery autosave' : 'The whole calibration package is stored (recovery autosave)'}}">{{storeRequired ? (isMirror ? 'STORE ON GAME PC' : tr('Store Req')) : tr('Stored')}}</span>
    <span class="oep-statusblk r" ng-if="mapBank.workingDirty" title="{{isMirror ? 'The working tune has edits not saved to M' + mapBank.active + ' - Save or Revert them on the game PC' : 'The working tune has edits not saved to M' + mapBank.active + ' - use Save to M' + mapBank.active + ' (or Undo edits) on the Tune Files page'}}">M{{mapBank.active}} EDITED</span>
    <span class="oep-statusblk" ng-if="isMirror" ng-class="remoteClient.authenticated ? 'g' : (remoteClient.writerConnected ? 'y' : 'r')">{{remoteClient.authenticated ? 'WRITER' : (remoteClient.writerConnected ? 'WRITER BUSY' : 'READ ONLY')}}</span>
    <span class="oep-statusblk" ng-if="isMirror && remoteClient.commandPhase" ng-class="remoteClient.commandPhase === 'applied' ? 'g' : (remoteClient.commandPhase === 'rejected' || remoteClient.commandPhase === 'stalled' ? 'r' : 'y')">{{remoteClient.commandPhase | uppercase}}</span>
  </div>

  <!-- toolbar -->
  <div class="oep-toolbar">
    <button class="prim" ng-class="{warn: controlling}" ng-click="toggleConnect()" title="Connect to control the engine with your tune, or disconnect to just watch live data">{{controlling ? tr('Disconnect') : tr('Connect')}}</button>
    <button class="prim" ng-if="!isMirror" ng-click="store()" ng-disabled="mapBankMutationPending" title="Save the whole calibration package (all four MAPs + the working tune) to this car's recovery store. This never writes the working tune into a MAP slot - use Save to M1-M4 on Tune Files for that.">{{tr('Store ECU')}}</button>
    <button ng-if="!isMirror || isProStudio" ng-click="setPage('tunes')" title="Switchable MAPs, quick-save backups, presets and the tune library">{{tr('Open Tune')}}</button>
    <button ng-click="setPage('tune_compare')" title="Compare the current tune to a saved baseline">{{tr('Compare')}}</button>
    <button ng-click="setPage('logging')" title="Record and review a pull on a time graph">{{tr('Record Log')}}</button>
    <button ng-click="setPage('runtime')" title="Live ECU values">{{tr('Runtime')}}</button>
    <button ng-click="setPage('gauges')" title="At-a-glance warning gauges">{{tr('Gauges')}}</button>
    <button ng-click="setPage('dyno')" title="Measure power - before/after pulls">{{tr('Dyno')}}</button>
    <button ng-click="setPage('mixture_map')" title="Ratio-based fuel recommendations from accepted steady points or clean WOT pulls">{{tr('Mixture')}}</button>
    <button class="warn" ng-click="setPage('faults')" title="Fault codes / reset engine damage">{{tr('Faults')}}</button>
    <button class="prim" ng-click="setPage('diagnose')" title="Tune Doctor - tells you what's wrong and how to fix it">{{tr('Help Me')}}</button>
  </div>

  <div class="oep-banner" ng-if="mapBank.pending"><span ng-if="mapBank.pendingMode==='reload'">Revert to saved M{{mapBank.pending}}</span><span ng-if="mapBank.pendingMode!=='reload'">M{{mapBank.pending}} switch</span> pending &mdash; {{t.mapSwitchBlockReason || 'lift throttle to apply safely'}}.<button class="oep-banner-cancel" ng-if="!isMirror" ng-click="cancelPendingMapBank()" title="Cancel the pending switch and stay on M{{mapBank.active}}">Cancel</button></div>

  <!-- monitoring banner -->
  <div class="oep-banner mon" ng-if="connected && !controlling">Monitoring only - live data is shown but your tune is <b>not</b> affecting the car. Hit <b>Connect</b> to control the engine.</div>

  <!-- V2.9.7 wave 2: loud hydration - saved MAP bodies that did not re-sync after a relink -->
  <div class="oep-banner" ng-if="mapBankHydrationWarning">{{mapBankHydrationWarning}}</div>

  <!-- page tabs -->
  <div class="oep-tabs">
    <button ng-repeat="tab in pageTabs" ng-class="{active: tabActive(tab)}" ng-click="setTab(tab)">{{tr(tab.label)}}</button>
  </div>

  <!-- body -->
  <div class="oep-body">
    <!-- nav tree -->
    <div class="oep-nav">
      <div ng-repeat="grp in navTree">
        <div class="grp">{{tr(grp.title)}}</div>
        <div class="item" ng-repeat="it in grp.items" data-tut="nav:{{it.id}}" title="{{navTip(it.id)}}" ng-class="{active: navSel === it.id}" ng-click="setPage(it.id)">{{tr(it.label)}}</div>
      </div>
    </div>

    <!-- workspace -->
    <div class="oep-work">

      <!-- V2.9.x version-skew banner: persistent on EVERY page (the old one-shot toast was missed in
           the field and users saw a dyno/controls that silently did nothing). Mirrors real gating:
           mutations are identity-blocked until controller and app match. -->
      <div class="oep-banner alarm" ng-if="connected && versionSkew">APP v{{appVersion}} IS PAIRED WITH AN OLDER/CACHED VEHICLE CONTROLLER{{ctrlVersion ? ' (v' + ctrlVersion + ')' : ''}} &mdash; edits, resets and the DYNO are disabled until they match. Close BeamNG COMPLETELY and relaunch (the game caches vehicle code). Still here after a restart? Delete older OE ProTune zips from your mods folder, then use Clear Cache in the launcher.</div>

      <!-- live AFR / lambda readout while tuning a table (DISPLAY ONLY - reads telemetry, changes nothing) -->
      <div class="oep-afrbar" ng-if="(isMapPage(activePage) || activePage === 'mixture_map') && connected">
        <span class="tag">LIVE</span>
        <span ng-if="!t.isDiesel">&lambda; <b ng-class="afrClass()">{{t.lambdaActual | number:2}}</b> <span class="dim">/ tgt {{t.lambdaTarget | number:2}}</span></span>
        <span>AFR <b ng-class="afrClass()">{{afrLive()}}</b></span>
        <span ng-if="t.isDiesel">Smoke <b>{{t.smoke | number:0}}%</b></span>
        <span>Knock <b ng-class="{bad: t.knockLevel > 1.2}">{{t.knockLevel | number:1}}</b></span>
        <span class="dim">{{t.rpm | number:0}} rpm</span>
      </div>

      <!-- RUNTIME -->
      <div ng-if="activePage === 'runtime'">
        <div class="oep-pagetitle">{{tr('Runtime Values')}}</div>
        <div class="oep-rt">
          <div class="col">
            <div ng-repeat="panel in runtimeLeft">
              <div class="oep-cap">{{tr(panel.title)}}</div>
              <div class="oep-row" ng-repeat="row in panel.rows" ng-if="rowShow(row)" ng-class="{alarm: rowAlarm(row)}">
                <span ng-class="'oep-c-' + row[3]">{{rowLabel(tr(row[0]))}}</span>
                <span class="val">{{tv(row[1], row[2])}}</span>
              </div>
            </div>
          </div>
          <div class="col">
            <div ng-repeat="panel in runtimeRight">
              <div class="oep-cap">{{tr(panel.title)}}</div>
              <div class="oep-row" ng-repeat="row in panel.rows" ng-if="rowShow(row)" ng-class="{alarm: rowAlarm(row)}">
                <span ng-class="'oep-c-' + row[3]">{{rowLabel(tr(row[0]))}}</span>
                <span class="val">{{tv(row[1], row[2])}}</span>
              </div>
            </div>
          </div>
        </div>
      </div>

      <!-- GAUGES -->
      <div ng-if="activePage === 'gauges'">
        <div class="oep-pagetitle">{{tr('Runtime Gauges')}}</div>
        <div class="oep-help">{{tr('Yellow = investigate, red = lift. The values a tuner watches during a pull.')}}</div>
        <div class="oep-edit">
          <button ng-class="{on: gaugeStyle===2}" ng-click="setGaugeStyle(2)" title="Round analog dials with needle and warn/danger arcs - the real tuner-software look">Dials</button>
          <button ng-class="{on: gaugeStyle===1}" ng-click="setGaugeStyle(1)" title="Compact horizontal bars">Bars</button>
        </div>
        <div ng-if="gaugeStyle===1">
          <div class="oep-gauge" ng-repeat="g in gauges" ng-if="gaugeShow(g)">
            <div class="lab"><span>{{tr(g.label)}}</span><span>{{tv(g.key, g.unit)}}</span></div>
            <div class="oep-gbar"><div ng-style="{width: gaugeFrac(g) + '%', 'background-color': gaugeColor(g)}"></div></div>
          </div>
        </div>
        <div ng-if="gaugeStyle===2" class="oep-dialgrid">
          <div class="oep-dialtile" ng-repeat="g in gauges" ng-if="gaugeShow(g)">
            <div class="oep-dialhead">{{tr(g.label)}}</div>
            <canvas data-dial="{{g.key}}" width="172" height="148"></canvas>
          </div>
        </div>
      </div>

      <!-- DYNO -->
      <div ng-if="activePage === 'dyno'">
        <div class="oep-pagetitle">{{tr('Dyno / AFR')}}</div>
        <div class="oep-help">{{tr('Full-throttle pull from low RPM to the limiter. Red = engine (crank) HP, yellow = torque (lb-ft). First pull saves a baseline. Runtime wheel HP is MEASURED through the drivetrain like a hub dyno - the loss slider on Limiters is just your crank-estimate factor.')}}</div>
        <div class="oep-edit">
          <button class="prim" data-tut="dyno:start" ng-click="toggleDyno()">{{(dynoState==='armed'||dynoState==='running') ? 'CANCEL' : 'START PULL'}}</button>
          <button ng-click="setDynoBaseline()">Set Baseline</button>
          <button ng-click="toggleDynoBase()">{{showDynoBase ? 'Hide Base' : 'Show Base'}}</button>
          <button ng-click="clearDyno()">Clear</button>
          <span class="sep"></span>
          <button ng-click="toggleDynoAfr()" ng-style="{color: dynoShowAfr ? '#1565a8' : '#555', 'font-weight': dynoShowAfr ? '700' : '400'}">AFR</button>
          <button ng-click="toggleDynoBoost()" ng-style="{color: dynoShowBoost ? '#a82d86' : '#555', 'font-weight': dynoShowBoost ? '700' : '400'}">Boost</button>
        </div>
        <div class="oep-edit">
          <button ng-click="toggleDynoAuto()" ng-style="{color: dynoAuto ? '#0a7a1f' : '#555', 'font-weight': dynoAuto ? '700' : '400'}" title="Auto-start a pull when you floor it from low RPM in the chosen gear, auto-stop on lift or near redline, and re-arm for the next pass - no need to click START PULL">Auto pull: {{dynoAuto ? 'ON' : 'OFF'}}</button>
          <span class="sep"></span>
          <span style="font-size:12px; color:#222; font-weight:bold; align-self:center;">Start gear</span>
          <button ng-click="setDynoStartGear(0)" ng-style="{color: dynoStartGear===0 ? '#7a4e00' : '#555', 'font-weight': dynoStartGear===0 ? '700' : '400'}">Any</button>
          <button ng-click="setDynoStartGear(1)" ng-style="{color: dynoStartGear===1 ? '#7a4e00' : '#555', 'font-weight': dynoStartGear===1 ? '700' : '400'}">1</button>
          <button ng-click="setDynoStartGear(2)" ng-style="{color: dynoStartGear===2 ? '#7a4e00' : '#555', 'font-weight': dynoStartGear===2 ? '700' : '400'}">2</button>
          <button ng-click="setDynoStartGear(3)" ng-style="{color: dynoStartGear===3 ? '#7a4e00' : '#555', 'font-weight': dynoStartGear===3 ? '700' : '400'}">3</button>
          <button ng-click="setDynoStartGear(4)" ng-style="{color: dynoStartGear===4 ? '#7a4e00' : '#555', 'font-weight': dynoStartGear===4 ? '700' : '400'}">4</button>
          <button ng-click="setDynoStartGear(5)" ng-style="{color: dynoStartGear===5 ? '#7a4e00' : '#555', 'font-weight': dynoStartGear===5 ? '700' : '400'}">5</button>
          <button ng-click="setDynoStartGear(6)" ng-style="{color: dynoStartGear===6 ? '#7a4e00' : '#555', 'font-weight': dynoStartGear===6 ? '700' : '400'}">6</button>
          <span class="sep"></span>
          <span style="font-size:12px; color:#222; font-weight:bold; align-self:center;">Start RPM</span>
          <input type="number" ng-model="$parent.dynoStartRpm" ng-change="setDynoStartRpm()" min="0" max="9000" step="250" style="width:64px; color:#000;" title="0 = auto (begin just off idle, full curve). Set a value to begin the pull at that RPM, in the chosen gear at full throttle - e.g. a 4th-gear pull from 3000.">
          <span style="font-size:12px; color:#555; align-self:center;" ng-if="!dynoStartRpm">(auto)</span>
        </div>
        <canvas class="oep-dyno" width="560" height="220"></canvas>
        <div class="oep-ro"><span>Status</span><span class="val">{{dynoStatus}}</span></div>
        <div class="oep-help" style="margin-top:4px">Tip: pull in 3rd gear or higher for comparable numbers - low gears read lower because the engine spins up so fast its own inertia eats torque, exactly like a real inertia dyno.</div>
        <div class="oep-ro" ng-if="dynoBasePeak()"><span>Baseline (crank)</span><span class="val">{{dynoBasePeak().hp.toFixed(1)}} hp @ {{dynoBasePeak().hpRpm}} | {{dynoBasePeak().tq.toFixed(1)}} lb-ft</span></div>
        <div class="oep-ro" ng-if="dynoCurPeak()"><span>This pull (crank)</span><span class="val">{{dynoCurPeak().hp.toFixed(1)}} hp @ {{dynoCurPeak().hpRpm}} | {{dynoCurPeak().tq.toFixed(1)}} lb-ft</span></div>
        <div class="oep-ro" ng-if="dynoCurPeak()"><span>&#8776; wheel HP (-{{(settings.drivetrainLoss||15)}}%)</span><span class="val">{{(dynoCurPeak().hp*(1-(settings.drivetrainLoss||15)/100)).toFixed(1)}} whp</span></div>
        <div class="oep-ro" ng-if="dynoHasCompare()"><span>Gain vs baseline</span><span class="val" ng-style="{color: dynoGain('hp') >= 0 ? '#1a7a1a' : '#9a0000'}">{{dynoGain('hp') >= 0 ? '+' : ''}}{{dynoGain('hp').toFixed(1)}} hp ({{dynoGainPct() >= 0 ? '+' : ''}}{{dynoGainPct().toFixed(1)}}%) / {{dynoGain('tq') >= 0 ? '+' : ''}}{{dynoGain('tq').toFixed(1)}} lb-ft</span></div>
        <div class="oep-ro"><span>Cursor</span><span class="val">{{dynoText || 'hover the graph to scrub'}}</span></div>
        <div class="oep-ro"><span>Live AFR</span><span class="val">{{afrText()}}</span></div>
        <div class="oep-ro"><span>Wheel power (live est.)</span><span class="val">{{tv('wheelPowerHp','whp')}}</span></div>
      </div>

      <!-- BUILD CARD -->
      <div ng-if="activePage === 'buildcard'">
        <div class="oep-pagetitle" ng-hide="snapshot">{{tr('Build Card')}}</div>
        <div class="oep-help" ng-hide="snapshot">{{tr('A shareable spec sheet for your build. Do a dyno pull to lock in VERIFIED power, then hit Snapshot Mode and screenshot it to flex on the crew.')}}</div>
        <div class="oep-edit" ng-hide="snapshot">
          <button class="prim" ng-click="toggleSnapshot()">{{tr('Snapshot Mode')}}</button>
          <button ng-click="refreshBuild()">{{tr('Refresh Parts')}}</button>
          <button ng-click="setPage('dyno')">{{tr('Go to Dyno')}}</button>
        </div>

        <div class="oep-card">
          <div class="oep-card-hd">
            <div class="oep-card-car">{{cardCar()}}</div>
            <div class="oep-card-cfg">{{build.config || tr('Custom build')}}</div>
            <div class="oep-card-badge" ng-class="{turbo: base.hasTurbo}">{{base.hasTurbo ? 'TURBO' : 'NA'}}</div>
          </div>

          <div class="oep-card-power">
            <div class="oep-card-num">
              <div class="big">{{cardPower() ? cardPower().hp.toFixed(0) : '--'}}</div>
              <div class="unit">HP</div>
              <div class="at" ng-if="cardPower() && cardPower().hpRpm">@ {{cardPower().hpRpm}} rpm</div>
            </div>
            <div class="oep-card-num">
              <div class="big">{{cardPower() ? cardPower().tq.toFixed(0) : '--'}}</div>
              <div class="unit">LB-FT</div>
              <div class="at" ng-if="cardPower() && cardPower().tqRpm">@ {{cardPower().tqRpm}} rpm</div>
            </div>
          </div>
          <div class="oep-card-gain" ng-if="cardGain()">
            <span>{{tr('BASE')}} <b>{{cardGain().base.toFixed(0)}}</b> HP</span>
            <span class="arrow">&#8594;</span>
            <span>{{tr('TUNED')}} <b>{{cardGain().now.toFixed(0)}}</b> HP</span>
            <span class="pct" ng-class="{neg: cardGain().pct < 0}">{{cardGain().pct >= 0 ? '+' : ''}}{{cardGain().pct.toFixed(0)}}%</span>
          </div>
          <div class="oep-card-stamp" ng-class="{ver: cardPower() && cardPower().verified, peak: cardPower() && cardPower().ecupeak, rate: cardPower() && !cardPower().verified && !cardPower().ecupeak}">
            {{!cardPower() ? tr('NO DATA - drive it hard or dyno it') : (cardPower().verified ? (tr('DYNO VERIFIED - crank') + (cardPower().date ? ' · ' + cardPower().date : '')) : (cardPower().ecupeak ? tr('ECU PEAK - logged while driving (dyno it for the curve + gains)') : tr('RATED - run a dyno to verify')))}}
          </div>

          <div class="oep-card-specs">
            <div class="row" ng-if="cardEngine()"><span>{{tr('Engine')}}</span><b>{{cardEngine()}}</b></div>
            <div class="row"><span>{{tr('Induction')}}</span><b>{{cardInduction()}}</b></div>
            <div class="row"><span>{{tr('Fuel')}}</span><b>{{cardFuel()}}</b></div>
            <div class="row"><span>{{tr('Block')}}</span><b>{{cardBlock() ? cardBlock() + ' · ' + cardStrength() : cardStrength()}}</b></div>
            <div class="row" ng-if="cardNos()"><span>NOS</span><b>{{cardNos()}}</b></div>
            <div class="row"><span>{{tr('Tune status')}}</span><b ng-style="{color: tuneStatus().ok ? '#0f7a2b' : '#b33'}">{{tr(tuneStatus().text)}}</b></div>
            <div class="row" ng-if="settings.revLimit > 0"><span>{{tr('Rev limit')}}</span><b>{{settings.revLimit}} rpm</b></div>
          </div>

          <div class="oep-card-parts" ng-if="build.parts.length">
            <div class="hd">{{tr('Engine build')}}</div>
            <div class="part" ng-repeat="p in build.parts"><span>{{p.slot}}</span><b>{{p.name}}</b></div>
          </div>
          <div class="oep-card-parts" ng-if="build.loaded && build.error">
            <div class="part muted">{{tr('Parts list unavailable on this setup')}}</div>
          </div>

          <div class="oep-card-ft">
            <span>OE <b>PRO</b>TUNE</span>
            <span class="muted">{{tr('Tuned on OE ProTune ECU')}} v{{appVersion}}</span>
          </div>
        </div>

        <div class="oep-edit" ng-if="snapshot" style="justify-content:center;margin-top:6px;">
          <button class="prim" ng-click="toggleSnapshot()">{{tr('Exit Snapshot')}}</button>
        </div>
      </div>

      <!-- LOGGING -->
      <div ng-if="activePage === 'logging'">
        <div class="oep-pagetitle">{{tr('Log Analysis')}}</div>
        <div class="oep-edit">
          <button ng-click="toggleLogging()">{{logging ? 'Logging: ON' : 'Logging: OFF'}}</button>
          <button ng-click="clearLog()">Clear</button>
          <button ng-if="!isMirror" ng-click="exportLogCsv()" title="Save this log as a CSV in your userfolder (settings/oeProTune)">Export CSV</button>
          <button ng-if="!isMirror" ng-click="openLogFolder()" title="Open the export folder in your file explorer (settings/oeProTune - CSV logs + tune backups live here)">Open folder</button>
          <span class="sep"></span>
          <button ng-click="toggleLane('rpm')" ng-style="{color: logLanes.rpm ? '#39c8ff' : '#777'}">RPM</button>
          <button ng-click="toggleLane('map')" ng-style="{color: logLanes.map ? '#cde84a' : '#777'}">MAP</button>
          <button ng-click="toggleLane('tps')" ng-style="{color: logLanes.tps ? '#39ff70' : '#777'}">TPS</button>
          <button ng-click="toggleLane('lambda')" ng-style="{color: logLanes.lambda ? '#ffa028' : '#777'}">Lambda</button>
          <button ng-click="toggleLane('knock')" ng-style="{color: logLanes.knock ? '#ff5bd0' : '#777'}">Knock</button>
          <button ng-click="toggleLane('egt')" ng-style="{color: logLanes.egt ? '#ff5a3c' : '#777'}">EGT</button>
          <button ng-click="toggleLane('temp')" ng-style="{color: logLanes.temp ? '#ffd23b' : '#777'}">Temps</button>
        </div>
        <canvas class="oep-log" width="560" height="360"></canvas>
        <div class="oep-ro"><span>Samples</span><span class="val">{{logRows.length}}</span></div>
        <div class="oep-ro"><span>Peak knock / lambda</span><span class="val">{{logStat('knockLevel','max')}} / {{logStat('lambdaActual','max')}}</span></div>
        <div class="oep-ro"><span>Min health / Peak IDC</span><span class="val">{{logStat('engineHealth','min')}}% / {{logStat('injectorDuty','max')}}%</span></div>
      </div>

      <!-- LOG NAVIGATOR -->
      <div ng-if="activePage === 'log_navigator'">
        <div class="oep-pagetitle">{{tr('Log Navigator')}}</div>
        <canvas class="oep-log" width="560" height="360"></canvas>
        <canvas class="oep-lognav" width="560" height="56"></canvas>
        <div style="font-size:10px; color:#8a8f96; margin:1px 0 2px;">Drag on the lanes to scrub. Drag the window on the Navigator strip to pan; drag its edges to zoom.</div>
        <div class="oep-edit">
          <button ng-click="moveCursor(-1)">&#9664;</button>
          <button ng-click="moveCursor(1)">&#9654;</button>
          <button ng-click="logZoomFit()" title="Zoom out to the whole log">Fit</button>
          <span class="sep"></span>
          <button ng-click="jumpToEvent('lean')">Lean</button>
          <button ng-click="jumpToEvent('knock')">Knock</button>
          <button ng-click="jumpToEvent('idc')">IDC</button>
          <button ng-click="jumpToEvent('misfire')">Misfire</button>
          <button ng-click="jumpToEvent('health')">Health</button>
          <span class="sep"></span>
          <button ng-if="!isMirror" ng-click="exportLogCsv()" title="Save this log as a CSV in your userfolder (settings/oeProTune)">Export CSV</button>
          <button ng-if="!isMirror" ng-click="openLogFolder()" title="Open the export folder in your file explorer (settings/oeProTune - CSV logs + tune backups live here)">Open folder</button>
        </div>
        <div ng-if="logCursorRow()">
          <div class="oep-pagetitle" style="margin-top:4px;">Parameter list (at cursor)</div>
          <div class="oep-paramlist">
            <div class="oep-ro" ng-repeat="p in logParams() track by $index"><span>{{p.label}}</span><span class="val">{{p.val}}</span></div>
          </div>
        </div>
        <div ng-if="!logCursorRow()" class="oep-help">Record a log (Connect + drive), then scrub the cursor to read every channel here.</div>
      </div>

      <!-- MIXTURE MAP -->
      <div ng-if="activePage === 'mixture_map'">
        <div class="oep-pagetitle">{{tr('Mixture Map')}}</div>
        <div class="oep-help">Auto learns from strict steady-state driving and clean WOT pulls. Pull learning starts only after tip-in fuel has cleared, TPS is steady, RPM is rising in one forward gear and no limiter, shift, power-adder, misfire or fuel-pressure fault is active. Pull-only cells need two agreeing passes before Apply marks them ready. Idle control and true closed-throttle coast are ignored; real low-throttle cruise is allowed. Evidence expires after 30 minutes. Start below the region you need and use a taller gear on very fast cars. Red adds fuel; blue removes it.</div>
        <div class="oep-mix-axis inside"><b>Applying to: {{mixApplyTarget()}}</b> &nbsp; Apply changes Ready cells only, consumes their evidence, then requires a re-drive.</div>
        <div class="oep-edit">
          <button class="prim" ng-click="applyMixture(0.5)" title="Apply half of each ready ratio-based correction, consume the evidence, then require fresh visits.">Apply 50% - re-drive</button>
          <button class="prim" ng-click="applyMixture(1)" title="Apply each ready ratio-based correction, consume the evidence, then require fresh visits.">Apply 100% - re-drive</button>
          <button ng-click="clearMixture()" title="Clear Mixture Map evidence and confidence only. Fuel Main, VE, STFT and LTFT are unchanged.">Clear learned data</button>
          <span class="sep"></span><span class="muted">Learn:</span>
          <button ng-class="{on: mixLearnMode==='auto'}" ng-click="setMixLearnMode('auto')" title="Default. Uses strict steady-state data, then automatically accepts a clean high-throttle RPM sweep once transients have settled.">Auto</button>
          <button ng-class="{on: mixLearnMode==='steady'}" ng-click="setMixLearnMode('steady')" title="Require RPM, MAP and TPS to remain inside a strict rolling ~300 ms steady-state window.">Steady</button>
          <button ng-class="{on: mixLearnMode==='pull'}" ng-click="setMixLearnMode('pull')" title="Require a clean WOT pull: at least 85% TPS, rising RPM, stable gear and no transient fuel, shift or limiter activity.">WOT Pull</button>
          <span class="sep"></span><span class="muted">View:</span>
          <button ng-class="{on: mixDisplayMode==='correction'}" ng-click="setMixDisplayMode('correction')" title="Canonical percentage correction calculated from measured lambda divided by target lambda.">Correction %</button>
          <button ng-class="{on: mixDisplayMode==='veDelta'}" ng-click="setMixDisplayMode('veDelta')" ng-disabled="settings.fuelEquationMode != 2" title="Derived VE-point change from the VE snapshot that produced the accepted samples.">VE delta</button>
          <button ng-class="{on: mixDisplayMode==='suggestedVE'}" ng-click="setMixDisplayMode('suggestedVE')" ng-disabled="settings.fuelEquationMode != 2" title="Derived absolute VE recommendation from the VE snapshot that produced the accepted samples.">Suggested VE</button>
          <span class="sep"></span><span class="muted">O2 delay</span>
          <input type="number" ng-model="mixO2DelayDraftMs" min="0" max="1000" step="50" ng-disabled="true" title="Fixed at 0 ms because OE ProTune currently uses BeamNG's instantaneous internal lambda. Delay will be enabled only when a genuinely delayed external sensor source exists.">
          <button disabled title="BeamNG's internal simulated lambda is instantaneous.">BeamNG internal: 0 ms</button>
        </div>
        <!-- V2.9.7 beta feedback (D3): always-visible mode readout in both the game UI and the Pro Studio mirror -->
        <div class="oep-mix-axis inside"><b>Learn: {{mixLearnModeLabel()}}</b> &nbsp;|&nbsp; <b>View: {{mixDisplayModeLabel()}}</b></div>
        <div class="oep-mix-status">
          <div class="oep-mix-box" title="{{mixUi.state}}"><span class="lbl">State</span><span class="num">{{mixUi.phase}}</span><span class="sub">{{mixUi.state}}</span></div>
          <div class="oep-mix-box"><span class="lbl">Ready</span><span class="num">{{mixUi.readyCells}}</span><span class="sub">cells</span></div>
          <div class="oep-mix-box"><span class="lbl">Learning</span><span class="num">{{mixUi.learningCells}}</span><span class="sub">cells</span></div>
          <div class="oep-mix-box"><span class="lbl">Accepted samples</span><span class="num">{{mixUi.accepted}}</span><span class="sub">steady {{mixUi.acceptedSteady}} / pull {{mixUi.acceptedPull}}</span></div>
          <div class="oep-mix-box" title="Normal frames that did not meet the selected learning conditions"><span class="lbl">Skipped</span><span class="num">{{mixUi.skipped}}</span><span class="sub">normal / not eligible</span></div>
          <div class="oep-mix-box" title="Eligible frames with invalid or outlier lambda data"><span class="lbl">Rejected</span><span class="num">{{mixUi.rejected}}</span><span class="sub">bad measurement</span></div>
        </div>
        <div class="oep-mix-reasons">
          <div class="oep-mix-box" title="{{mixUi.lastSkip}}"><span class="lbl">Last skipped</span><span class="sub">{{mixUi.lastSkip}}</span></div>
          <div class="oep-mix-box" title="{{mixUi.lastReject}}"><span class="lbl">Last rejected</span><span class="sub">{{mixUi.lastReject}}</span></div>
          <div class="oep-mix-box" title="{{mixUi.resetReason}}"><span class="lbl">Last reset</span><span class="sub">{{mixUi.resetReason}}</span></div>
        </div>
        <div class="oep-mix-live">
          <div class="oep-mix-box"><span class="lbl">RPM</span><span class="num">{{mixUi.rpm}}</span></div>
          <div class="oep-mix-box" title="{{mixUi.mapSource}}"><span class="lbl">{{mixUi.loadLabel}}</span><span class="num">{{mixUi.map}}</span><span class="sub">{{mixUi.mapSource}}</span></div>
          <div class="oep-mix-box"><span class="lbl">Vacuum / boost</span><span class="num">{{mixUi.vacuumBoost}}</span></div>
          <div class="oep-mix-box"><span class="lbl">TPS</span><span class="num">{{mixUi.tps}}</span></div>
          <div class="oep-mix-box"><span class="lbl">Lambda act / tgt</span><span class="num">{{mixUi.lambda}}</span></div>
          <div class="oep-mix-box"><span class="lbl">Barometric</span><span class="num">{{mixUi.baro}}</span></div>
        </div>
        <div class="oep-mix-axis" ng-class="{inside: mixUi.axisInside}">{{mixUi.axisStatus}}</div>
        <table class="oep-grid" ng-class="gridDensityClass()">
          <tr><th>{{axisUnit('fuel')}}\RPM</th><th ng-repeat="r in rpmAxis">{{r}}</th></tr>
          <tr ng-repeat="mp in activeLoadAxis('fuel') track by $index">
            <td class="axis">{{axisLab(mp, 'fuel')}}</td>
            <td ng-repeat="rp in rpmAxis track by $index" ng-style="mixCellStyle($parent.$index, $index)" title="{{mixCellTitle($parent.$index, $index)}}">{{mixCellText($parent.$index, $index)}}</td>
          </tr>
        </table>
      </div>

      <!-- TUNE FILES -->
      <div ng-if="activePage === 'tunes'">
        <div class="oep-pagetitle">{{tr('Tune Files')}}</div>
        <div class="oep-help" ng-if="isProStudio">This is the game PC's authoritative tune catalog. Loading, presets and reset apply to the same live ECU; saving, deleting and importing remain on the game PC.</div>
        <div class="oep-help" ng-if="isProStudio && !remoteTuneCatalog.generation">Waiting for saved tunes from the game PC...</div>
        <div class="oep-ctrl"><div class="lab"><span>Tune name</span></div><input type="text" class="oep-in" ng-model="$parent.tuneName" ng-readonly="isMirror" style="width:200px"></div>
        <div class="oep-cap">Presets</div>
        <div class="oep-edit">
          <button ng-click="applyPreset('stock')">Stock base</button>
          <button ng-click="applyPreset('stage1')">Safe Stage 1</button>
          <button ng-click="applyPreset('aggressive')">Aggressive</button>
          <button class="warn" ng-click="applyPreset('bad')">Bad lean demo</button>
        </div>
        <div class="oep-cap">Factory reset</div>
        <div class="oep-edit">
          <button class="warn" ng-click="factoryReset()" ng-style="{fontWeight: frArmed ? 'bold' : ''}" title="Wipe everything back to stock: all tables, axes, settings, damage, faults, STFT/LTFT state and Mixture Map learning. Requires a second click. Stock base resets tune tables and stock-linked fuel setup but keeps the rest of the ECU setup.">{{frArmed ? 'CONFIRM — wipe everything' : 'Full factory reset'}}</button>
        </div>
        <div class="oep-cap">Switchable MAPs</div>
        <div class="oep-help"><b>Four live calibrations, one working tune.</b> You always edit a separate working copy; a MAP slot only changes when you press Save.
          <ul class="oep-help-list">
            <li>Tune the car live first, then press <b>Save to M1&ndash;M4</b>. The saved slot becomes the <b>ACTIVE</b> MAP (save-as); the previous slot keeps its last saved copy.</li>
            <li>Empty slots don't need activating first. Saving over a <i>different</i> occupied MAP asks for a second CONFIRM click &mdash; and so does Clear.</li>
            <li>Per-MAP: rev limit, launch, boost control incl. Boost by Gear / Speed, pops and the other performance settings. Shared by all four: engine hardware, sensors, safety systems and table axes.</li>
            <li>If an action would replace working edits, the tuner asks whether to Save, Discard or Cancel. <b>Store ECU</b> saves the whole recovery package and never overwrites a MAP slot.</li>
          </ul>
        </div>
        <div class="oep-mapbank-grid">
          <div class="oep-mapbank-card" ng-repeat="b in mapBank.slots track by b.index" ng-class="{active: mapBank.active===b.index, pending: mapBank.pending===b.index, empty: !b.configured}">
            <div class="oep-mapbank-head">
              <span class="oep-mapbank-id">M{{b.index}}</span>
              <span class="oep-mapbank-badge active" ng-if="mapBank.active===b.index">ACTIVE</span>
              <span class="oep-mapbank-badge pending" ng-if="mapBank.pending===b.index">PENDING</span>
              <span class="oep-mapbank-badge default" ng-if="mapBank.default===b.index">DEFAULT</span>
              <span class="oep-mapbank-badge" ng-if="!b.configured">EMPTY</span>
            </div>
            <div class="oep-mapbank-body">
              <div class="oep-mapbank-name">
                <input type="text" class="oep-in" maxlength="28" ng-model="b.draftName" ng-readonly="isMirror || mapBankSaveLocked()" placeholder="MAP name">
                <button ng-if="!isMirror" ng-click="renameMapBank(b.index)" ng-disabled="mapBankMutationPending || mapBankSaveLocked() || !b.configured">Rename</button>
              </div>
              <div class="oep-mapbank-actions">
                <button ng-click="(mapBank.active===b.index && mapBank.pending) ? cancelPendingMapBank() : selectMapBank(b.index)" ng-disabled="mapBankMutationPending || mapBankSaveLocked() || !b.configured || (mapBank.active===b.index && !mapBank.pending) || (isMirror && mapBank.workingDirty)" title="{{(mapBank.active===b.index && mapBank.pending) ? ('Cancel the pending M' + mapBank.pending + ' switch and stay on M' + b.index) : 'Request this MAP - it applies on the next safe frame (throttle lifted, boost down)'}}">{{(mapBank.active===b.index && mapBank.pending) ? 'Cancel switch' : 'Activate'}}</button>
                <button ng-if="!isMirror" class="prim" ng-class="{warn: mapBankSaveArmed===b.index}" ng-click="saveWorkingToMapBank(b.index)" ng-disabled="mapBankMutationPending || mapBankSaveLocked()" title="{{mapBankSaveLocked() ? 'Finish or exit the temporary lesson before saving a MAP' : 'Save the live working tune to this MAP - the saved slot becomes the ACTIVE MAP (save-as)'}}">{{mapBankSaveLabel(b.index)}}</button>
                <button ng-if="!isMirror" ng-click="setDefaultMapBank(b.index)" ng-disabled="mapBankMutationPending || mapBankSaveLocked() || !b.configured || mapBank.default===b.index" title="Select this MAP automatically when the vehicle spawns">Set default</button>
                <button ng-if="!isMirror" class="warn" ng-click="clearMapBank(b.index)" ng-disabled="mapBankMutationPending || mapBankSaveLocked() || !b.configured || mapBank.active===b.index" title="Delete this MAP's saved tune (asks for a second CONFIRM click)">{{mapBankClearLabel(b.index)}}</button>
                <button ng-if="!isMirror && mapBank.active===b.index && mapBank.workingDirty" class="warn" ng-click="requestDiscardWorkingMapBank()" ng-disabled="mapBankMutationPending || mapBankSaveLocked()" title="Throw away the working-tune edits and reload the saved M{{b.index}} copy">Undo edits</button>
              </div>
              <div class="oep-mapbank-stats" ng-if="b.configured"><span ng-if="mapBank.active===b.index && mapBank.workingDirty"><b>Saved copy unchanged</b> &middot; live working tune has edits</span><span ng-if="mapBank.active!==b.index || !mapBank.workingDirty">{{b.name}}<span ng-if="b.peakBoostKpa"> &middot; base target peak {{b.peakBoostKpa | number:0}} kPa abs</span></span></div>
              <div class="oep-mapbank-stats" ng-if="!b.configured">Name it, tune live, then Save to M{{b.index}}.</div>
            </div>
          </div>
        </div>
        <div class="oep-cap">Wheel / keyboard bindings</div>
        <div class="oep-help">Open BeamNG's native binding editor so keys, controllers and steering-wheel buttons work normally and conflicts are visible. No default keys are assigned.</div>
        <div class="oep-edit">
          <button class="prim" ng-click="bindMapAction('oep_map_next')">Bind Next MAP</button>
          <button ng-click="bindMapAction('oep_map_previous')">Bind Previous MAP</button>
          <button ng-click="bindMapAction('oep_map_1')">Bind M1</button>
          <button ng-click="bindMapAction('oep_map_2')">Bind M2</button>
          <button ng-click="bindMapAction('oep_map_3')">Bind M3</button>
          <button ng-click="bindMapAction('oep_map_4')">Bind M4</button>
          <button ng-click="bindMapAction('oep_rolling_antilag')">Bind Rolling Anti-Lag</button>
        </div>
        <div class="oep-cap">Quick-save backups</div>
        <div class="oep-help">Backups store the whole tune package, including all four Switchable MAPs, and restore all four when loaded. A backup made <i>before</i> the Switchable MAPs update holds a single tune: loading it leaves your MAPs alone and becomes the unsaved working tune instead.</div>
        <div class="oep-slot" ng-if="hasPrevStore()">
          <span class="nm">Previous Store ECU save (kept automatically)</span>
          <button ng-click="restorePreviousStore()" title="Every Store ECU keeps the prior stored package as a one-step fallback. Restore it if a Store went wrong, then press Store ECU again to keep it.">Restore</button>
        </div>
        <div class="oep-slot" ng-repeat="s in slots">
          <span class="nm">Backup {{s.index+1}}: {{s.name}}</span>
          <button ng-if="!isMirror" ng-click="saveSlot(s)">Save</button>
          <button ng-click="loadSlot(s)" ng-disabled="isMirror && !s.saved">Load</button>
          <button ng-if="!isMirror" ng-click="clearSlot(s)">Clear</button>
        </div>
        <div class="oep-cap">Tune library (Save As / Open)</div>
        <div class="oep-help">Opening a tune saved before the Switchable MAPs update never touches your MAPs: it loads as the unsaved working tune, and you choose <b>Save to M1&ndash;M4</b> to keep it on a slot (an empty slot takes the tune's name automatically). Newer library saves carry all four MAPs and restore them.</div>
        <div class="oep-edit" ng-if="!isMirror"><button class="prim" ng-click="saveLibrary()">Save As "{{tuneName}}"</button></div>
        <div class="oep-slot" ng-repeat="e in library">
          <span class="nm">{{e.name}}</span>
          <button ng-click="openLibrary(e)">Open</button>
          <button ng-if="!isMirror" ng-click="deleteLibrary(e)">Delete</button>
        </div>
        <div class="oep-cap">Share this tune</div>
        <div class="oep-edit">
          <button class="prim" ng-click="makeTuneCode()" title="Copies the active MAP calibration as one compact pasteable code - drop it in Discord, a friend pastes it below and clicks Import">Copy active-MAP tune code</button>
          <button ng-if="!isMirror" ng-click="exportTuneFile()" title="Saves the complete tune, including all four Switchable Maps, as a readable .oeptune.json file in settings/oeProTune/tunes">Export complete tune file</button>
          <button ng-if="!isMirror" ng-click="openTuneFolder()" title="Open the shared-tunes folder in your file explorer">Open folder</button>
        </div>
        <textarea class="oep-in" ng-if="shareCode" ng-model="$parent.shareCode" readonly rows="3" style="width:100%; font-size:10px;" onclick="this.select()"></textarea>
        <div class="oep-cap">Import a shared tune</div>
        <div class="oep-help">A pasted tune code carries one tune, so importing it never touches your four MAPs &mdash; it becomes the unsaved working tune and you choose where to Save it. A complete <i>tune file</i> carries all four MAPs and replaces them.</div>
        <div class="oep-edit" ng-if="!isMirror">
          <input type="text" class="oep-in" ng-model="$parent.shareCodeIn" style="flex:1; min-width:200px" placeholder="Paste a tune code (OEPT1:...) here">
          <button class="prim" ng-click="importTuneCode()">Import code</button>
          <button ng-click="refreshTuneFiles()" title="Re-scan settings/oeProTune/tunes for .oeptune.json files (drop a friend's file there, then refresh)">Refresh files</button>
        </div>
        <div class="oep-help" ng-if="isProStudio">Shared tune files already found on the game PC are listed below. Import or refresh files from Basic Studio on the game PC.</div>
        <div class="oep-slot" ng-repeat="f in shareFiles">
          <span class="nm">{{f.name}}</span>
          <button ng-click="loadTuneFile(f)">Load</button>
        </div>
      </div>

      <!-- NOTES -->
      <div ng-if="activePage === 'notes'">
        <div class="oep-pagetitle">{{tr('Calibration Notes')}}</div>
        <div class="oep-edit">
          <input type="text" class="oep-in" ng-model="$parent.noteDraft" style="flex:1; min-width:160px" placeholder="What changed and why...">
          <button ng-click="addNote()">Add</button>
          <button ng-click="snapshotNote()">Snapshot</button>
          <button ng-click="clearNotes()">Clear</button>
        </div>
        <div class="oep-note" ng-repeat="n in notes"><span class="tm">[{{n.time}}]</span> {{n.txt}}</div>
        <div class="oep-help" ng-if="!notes.length">No notes yet.</div>
      </div>

      <!-- TUNE COMPARE -->
      <div ng-if="activePage === 'tune_compare'">
        <div class="oep-pagetitle">{{tr('Tune Compare')}}</div>
        <div class="oep-edit">
          <button class="prim" ng-click="saveCompare()">Save baseline</button>
          <button ng-click="restoreCompare()">Restore baseline</button>
        </div>
        <div class="oep-help" ng-if="!compareBase">No baseline saved.</div>
        <div class="oep-ro" ng-repeat="row in compareRows()"><span>{{row.label}}</span><span class="val">{{row.from}}{{row.from!==''?' -> ':''}}{{row.to}}</span></div>
        <div class="oep-help" ng-if="compareBase && !compareRows().length">No differences from baseline.</div>
      </div>

      <!-- AXIS SETUP -->
      <div ng-if="activePage === 'axis_setup'">
        <div class="oep-pagetitle">{{tr('Axis Setup')}}</div>
        <!-- V3.0.7: the count is interpolated from the live shared bound rather than written into the
             translation key, so raising axisLimits can never leave a stale "4-12" on screen in any
             language. The translated sentence deliberately no longer names numbers. -->
        <div class="oep-help">{{tr('Edit RPM columns and the load-row breakpoints. Alpha-N TPS must have exactly one value per table row.')}} <b>{{axisLimits.min}}-{{axisLimits.max}}</b> {{tr('values per axis.')}}</div>
        <!-- V3.0.7 TABLE RESOLUTION. Presets only; arbitrary counts stay behind the breakpoint
             editors below. Captions and the cell count come from the LIVE axes, so this row can
             never disagree with the tables on screen. -->
        <!-- ng-if="!isMirror": the browser Studio cannot perform a reshape - setGridResolution is not
             in R.commands nor in remote.html's allowlist - so the row is not offered there at all. -->
        <div class="oep-ctrl" ng-if="!isMirror"><div class="lab"><span>{{tr('Table resolution')}}</span></div>
          <button ng-repeat="p in GRID_PRESETS" ng-class="{on: gridIsPreset(p), warn: gridArmed === p.key}" ng-disabled="!gridPresetAvailable(p)"
                  ng-click="setGridResolution(p)" title="{{gridPresetTitle(p)}}">{{gridArmed === p.key ? tr('CONFIRM') : tr(p.label)}} {{p.rows}} &times; {{p.cols}}</button>
          <span class="oep-shape-unit">{{tr('Now')}}: {{gridRows()}} &times; {{gridCols()}} = {{gridCells()}} {{tr('cells')}}</span>
        </div>
        <div class="oep-help" ng-if="!isMirror"><b>Standard 6 &times; 7 is the default and stays the default</b> &mdash; nothing changes unless you switch. A finer grid gives you more control where the engine actually runs, the way a real standalone does, but it is not free: your tune is <b>refined</b> onto the new breakpoints, so the surface shape is kept while every cell in between is <b>interpolated rather than tuned by you</b> &mdash; re-check the map before trusting it. Learned fuel trims and the Mixture Map are cleared, and 256 cells need far more driving to learn than 42. Switching up keeps a <b>snapshot</b> so you can come straight back; switching down without one loses detail for good. Every change asks twice.</div>
        <div class="oep-banner" ng-if="gridRestoreRows && !isMirror">Snapshot held: <b>{{gridRestoreRows}} &times; {{gridRestoreCols}}</b> — switching back to that shape restores your pre-conversion calibration exactly. Edits made on the current grid are discarded.</div>
        <div class="oep-edit"><span class="oep-chk" ng-class="{on: settings.autoFitAxes}" ng-click="toggle('autoFitAxes')" title="Automatically grow the table breakpoints to cover your top Boost Target and any raised rev limit, so you don't have to set them by hand. Grows only (never shrinks a wider axis), and bilinearly resamples every table so the tuned surface shape is preserved. Turn OFF for fully manual axes.">{{tr('Auto-fit axes to my tune (boost target + rev limit)')}}: {{settings.autoFitAxes ? 'ON' : 'OFF'}}</span></div>
        <div class="oep-ctrl"><div class="lab"><span>RPM axis</span></div><input id="oep-axis-rpm" type="text" class="oep-in" ng-model="$parent.axisDraftRpm" style="width:100%"></div>
        <div class="oep-edit"><button class="prim" ng-click="applyAxis('rpm')">Apply RPM axis</button></div>
        <div class="oep-ctrl"><div class="lab"><span>MAP axis ({{psi ? 'gauge psi - 0 = live atmosphere, vacuum negative' : 'absolute kPa'}})</span></div><input id="oep-axis-map" type="text" class="oep-in" ng-model="$parent.axisDraftMap" style="width:100%"></div>
        <div class="oep-edit"><button class="prim" ng-click="applyAxis('map')">Apply MAP axis</button></div>
        <div class="oep-ctrl"><div class="lab"><span>Alpha-N load axis (TPS %, one value per row)</span></div><input id="oep-axis-alpha" type="text" class="oep-in" ng-model="$parent.axisDraftAlpha" style="width:100%"></div>
        <div class="oep-help">Used by Fuel Main, Ignition, Lambda Target and VE only when Fuel Setup → Load source is Alpha-N. Boost Target and WG Duty always use MAP. Changing these breakpoints changes where the existing row values apply; re-check the tune afterward.</div>
        <div class="oep-edit"><button class="prim" ng-click="applyAxis('alpha')">Apply Alpha-N axis</button>
          <button ng-click="resetAxesUi()" title="Put the factory RPM/MAP breakpoints back. Your table VALUES are kept and refitted to the factory grid.">{{tr('Reset axes to factory')}}</button></div>
      </div>

      <!-- FAULTS -->
      <div ng-if="activePage === 'faults'">
        <div class="oep-pagetitle">{{tr('Faults / Diagnostics')}}</div>
        <div class="oep-edit">
          <button ng-click="clearFaults()">Clear Faults</button>
          <button class="warn" ng-click="resetDamage()">Reset Engine Damage</button>
        </div>
        <div class="oep-ro"><span>Engine health</span><span class="val">{{tv('engineHealth','%')}}</span></div>
        <div class="oep-ro"><span>Limp mode</span><span class="val">{{tv('limpMode')}}</span></div>
        <div class="oep-ro"><span>Damage cause</span><span class="val">{{t.damageReason || 'OK'}}</span></div>
        <div class="oep-cap">Active fault codes</div>
        <div class="oep-help" ng-if="!faultArray().length">No active faults.</div>
        <div class="oep-row alarm" ng-repeat="f in faultArray()" style="display:block">
          <b>{{f}}</b><div style="font-size:9px; font-weight:400; margin-top:2px">{{dtcDefs[f]}}</div>
        </div>
      </div>

      <!-- REMOTE DASH / OE PROTUNE STUDIO -->
      <div ng-if="activePage === 'remote_dash'">
        <div class="oep-pagetitle">Remote Dashboard</div>
        <div ng-if="!isMirror">
        <div class="oep-help">Live gauges in any browser &mdash; second monitor, laptop, or a phone/tablet on the same private local network. Read-only: the dash shows the ECU, it can never change the tune. Network addresses and QR codes stay hidden by default for stream safety.</div>
        <div class="oep-edit">
          <button ng-click="toggleRemote()" ng-style="{color: remote.on ? '#ffc428' : '', fontWeight: remote.on ? 'bold' : ''}" title="Start/stop the dash server.">{{remote.on ? (remote.healthy ? 'Server ON — click to stop' : 'Server loaded — click to stop/restart') : 'Enable dash server'}}</button>
          <!-- REVIEW FIX 2026-07-29: the two loopback buttons no longer require remote.healthy. The
               address is always 127.0.0.1 and always safe to try, and gating them behind a startup
               probe that can produce a false negative left the user with no way to reach their own
               working dashboard - the exact trap that forced people to fall back to OpenDashboard.html.
               Connecting a browser this way also proves the bind and clears the false verdict. -->
          <button ng-if="remote.on && remote.pcUrl" ng-click="openDashInBrowser()" title="Opens a folder with an 'OpenDashboard' launcher — double-click it to open the dashboard in your browser (no copy/paste). Loopback address, nothing sensitive.">Open in Browser</button>
          <button ng-if="remote.on && remote.pcUrl" ng-click="copyRemoteUrl('pc')" title="Copy the Basic Dashboard address for this PC or a second monitor">Copy PC URL</button>
          <button ng-if="remote.on && remote.healthy && remote.lanConfigured && remote.lanUrl" ng-click="copyRemoteUrl('phone')" title="Copy the Basic Dashboard address for a phone or laptop on the same network">Copy Phone / Laptop URL</button>
          <button ng-if="remote.on && remote.healthy && remote.lanConfigured && remote.lanAddressCount > 1 && !hideRemoteAddr" ng-click="cycleRemoteLanUrl()" title="Try the next verified private-network address if this PC has more than one physical adapter">Try another address ({{remote.lanUrlIndex + 1}}/{{remote.lanAddressCount}})</button>
          <button ng-if="remote.on && remote.healthy" ng-click="toggleRemoteAddr()" ng-style="{color: hideRemoteAddr ? '' : '#ff6b6b', fontWeight: hideRemoteAddr ? '' : 'bold'}" title="Streaming? The address and QR below show this PC's private network address. Hidden by default and automatically hidden again after 60 seconds or when you leave this page.">{{hideRemoteAddr ? 'Reveal address / QR' : 'Hide address / QR'}}</button>
          <button ng-if="remote.on && (!remote.healthy || !remote.lanConfigured)" ng-click="openRemoteSetup()" title="Opens the folder with EnableRemoteDash_LAN.bat - double-click it and accept the Windows administrator prompt">Open setup folder</button>
          <button ng-if="remote.on" ng-click="copyRemoteDiagnostics()" title="Copies a support report with all addresses, URLs, adapter/WiFi names, user names and paths removed">Copy Safe Diagnostics</button>
        </div>
        <div class="oep-ro"><span>Server</span><span class="val">{{remote.healthy ? 'RUNNING' : (remote.on ? 'NEEDS RESTART' : 'Off')}}</span></div>
        <div class="oep-ro" ng-if="remote.on && remote.healthy"><span>On this PC (any browser here)</span><span class="val" style="user-select:text">{{shownUrl(remote.pcUrl)}}</span></div>
        <div class="oep-ro" ng-if="remote.on"><span>Phone setup</span><span class="val">{{remote.setupCode === 'legacy_setup' ? 'WORKING (from earlier setup)' : (remote.lanConfigured ? 'VERIFIED' : (remote.setupMessage || 'Not verified yet'))}}</span></div>
        <div class="oep-help" ng-if="remote.on && remote.setupCode === 'legacy_setup'" style="margin-top:3px">Phone access is working from a setup done by an earlier version, so nothing is broken and you do not have to do anything. When convenient, run <b>EnableRemoteDash_LAN.bat</b> once to get the full verified check (network profile, firewall, IP Helper, both ports and the relay).</div>
        <div class="oep-ro" ng-if="remote.on && remote.lanConfigured"><span>Windows relay self-test</span><span class="val">{{remote.lanReachable === true ? 'PASS' : (remote.lanReachable === false ? 'NEEDS REPAIR' : 'CHECKING')}}</span></div>
        <!-- REVIEW FIX 2026-07-29: this used to assert the bind had FAILED. The startup self-test is a
             race (full WebSocket handshake inside 8 pump iterations with no wall-clock time), so it can
             report failure on a perfectly working port - and the old verdict latched for the session,
             which made the banner's own stop/restart advice useless. Wording now matches what is
             actually known. It clears itself the moment a browser connects or the deferred probe answers. -->
        <div class="oep-banner alarm" ng-if="remote.on && !remote.healthy">SERVER PORT NOT VERIFIED YET &mdash; the startup check did not get an answer on port {{remote.port || 8236}}. <b>This is often a false alarm:</b> click <b>Open in Browser</b> above and see if the dashboard loads. If it does, the server is fine and this message will clear on its own. If it does not load, an older Remote Dash relay is probably holding the port &mdash; click <b>Open setup folder</b>, double-click <b>EnableRemoteDash_LAN.bat</b>, accept the Windows administrator prompt, then click the server button once to stop and again to restart.</div>
        <!-- one-time LAN setup: covers brand-new users AND anyone upgrading from the old pinned-IP relay -->
        <div class="oep-banner" ng-if="remote.on && remote.healthy && !remote.lanConfigured">PHONE ACCESS NEEDS A ONE-TIME VERIFIED SETUP &mdash; click <b>Open setup folder</b>, double-click <b>EnableRemoteDash_LAN.bat</b>, and accept the Windows administrator prompt. It selects a physical Private adapter, checks IP Helper, the firewall, both ports and the relay before reporting success. Then toggle the server OFF and ON here. No address is printed or copied by the setup tool.</div>
        <div class="oep-banner alarm" ng-if="remote.on && !remote.lanConfigured && remote.setupCode && remote.setupCode !== 'ready'">{{remote.setupMessage}} <span ng-if="remote.setupCode === 'public_network'">Windows Settings &rarr; Network &amp; Internet &rarr; your active connection &rarr; Network profile type &rarr; Private.</span></div>
        <!-- relay went down after it was working (sleep / network change): honest banner + one-click revive -->
        <div class="oep-banner alarm" ng-if="remote.on && remote.lanConfigured && remote.lanUrl && remote.lanReachable === false">PHONE ACCESS DROPPED &mdash; the Windows relay stopped (usually after the PC sleeps or the network reconnects). The PC address above still works. Double-click <b>ReviveRemoteDash.bat</b> and accept the administrator prompt. <button ng-click="openRemoteSetup()" title="Opens the folder with ReviveRemoteDash.bat" style="margin-left:6px">Open setup folder</button></div>
        <!-- configured, but the game can't serve the LAN right now (stale relay rule holding the port, or no address detected) -->
        <div class="oep-banner alarm" ng-if="remote.on && remote.healthy && remote.lanConfigured && !remote.lanUrl">PHONE ACCESS CAN'T START &mdash; no safe private-network address was found this session. Disconnect VPNs, confirm the Windows connection is Private, then run <b>EnableRemoteDash_LAN.bat</b> again and restart the server. <button ng-click="openRemoteSetup()" title="Opens the setup folder" style="margin-left:6px">Open setup folder</button></div>
        <div class="oep-ro" ng-if="remote.on && remote.lanConfigured && remote.lanUrl"><span>Phone / LAN address</span><span class="val" style="user-select:text">{{shownUrl(remote.lanUrl)}}</span></div>
        <div class="oep-ro" ng-if="remote.on"><span>Screens connected</span><span class="val">{{remote.clients}}</span></div>
        <div class="oep-ro" ng-if="remote.on"><span>Protocol</span><span class="val">v{{remote.protocolVersion}}</span></div>
        <div class="oep-help" ng-if="remote.on && hideRemoteAddr" style="margin-top:3px">Network address &amp; QR are <b>hidden</b> &mdash; safe to show on stream. Browser clients are not sent the PC's adapter list or setup diagnostics, and <b>Copy Safe Diagnostics</b> removes addresses, URLs, adapter/WiFi names, user names and paths. Reveal the address only when entering it on your own device; it hides again automatically after 60 seconds or when you leave this page.</div>
        <div class="oep-ro" ng-if="remote.on && remote.write"><span>Studio writer</span><span class="val">{{remote.writerConnected ? (remote.writerLabel || 'CONNECTED') : 'Waiting for first tuning browser'}}</span></div>
        <div class="oep-edit" ng-if="remote.on && remote.write">
          <button ng-if="remote.writerConnected" ng-click="revokeRemoteWriter()">Revoke writer</button>
          <!-- V3.0.9: say what this actually unlocks. "Allow dangerous commands" gives no hint that it
               is the switch standing between the browser and arming a dyno pull or switching MAPs, so a
               user hunting for those features never pressed it. Disabled until a browser holds the
               writer lease, because the server rejects it before then. -->
          <button class="warn" ng-click="toggleRemoteDangerous()" ng-disabled="!remote.dangerous && !remote.writerConnected"
                  title="{{remote.dangerous ? 'Browser tuners can currently arm the dyno and switch MAPs. Click to block.' : (remote.writerConnected ? 'Lets the connected browser arm dyno pulls and switch MAPs. Resets whenever the writer lease changes.' : 'Open Pro Studio in a browser first - this only takes effect once a browser holds the writer lease.')}}">{{remote.dangerous ? 'Dangerous commands ON — click to block' : 'Allow dangerous commands (dyno arm, MAP switch)'}}</button>
        </div>
        <div ng-if="remote.on && remote.lanConfigured && remote.lanUrl && remote.lanReachable !== false" style="display:flex; gap:10px; align-items:center; margin:6px 0 2px">
          <canvas class="oep-qr" width="170" height="170" style="border:1px solid #7F9DB9; background:#fff; flex:0 0 auto"></canvas>
          <div class="oep-help" style="margin:0">Point your phone, tablet or laptop camera at this code. Use the same private local network; Guest WiFi, VPNs and router client-isolation modes can block device-to-device access.</div>
        </div>
        <div class="oep-cap">How to use</div>
        <div class="oep-help">Both copied addresses open the Basic Dashboard first. On a second monitor, use the PC URL. On a phone or laptop, use the Phone / Laptop URL ending in <b>:8237</b> or scan the QR code &mdash; never use <b>127.0.0.1</b> on another device. Both devices must be on the same private local subnet. Avoid Guest WiFi, disconnect VPNs while testing, and temporarily disable phone cellular data if it keeps switching away from WiFi. If support is needed, use <b>Copy Safe Diagnostics</b>.</div>
        <div class="oep-cap">Basic Dashboard, Basic Studio and Pro Studio</div>
        <div class="oep-help">BASIC DASHBOARD is the original gauges, Dyno, Runtime and Graph display. BASIC STUDIO is the approachable full tuning app. PRO STUDIO is the professional desktop workspace with the same live calibration and includes its own Logging workspace. Gauge and logging views remain read-only.</div>
        <div class="oep-cap">Pro Studio editing</div>
        <div class="oep-edit" ng-if="remote.on && remote.healthy">
          <button ng-click="toggleRemoteWrite()" ng-style="{color: remote.write ? '#ff6b6b' : '', fontWeight: remote.write ? 'bold' : ''}" title="Allow or block browser tuning for this game session.">{{remote.write ? 'STUDIO EDITING ON — click to lock' : 'Allow Pro Studio Editing'}}</button>
          <span class="val" style="align-self:center">{{remote.write ? 'First tuning browser gets the one writer slot automatically' : 'Locked — all browsers are view-only'}}</span>
        </div>
        <div class="oep-banner alarm" ng-if="remote.write">PRO STUDIO EDITING IS ON FOR THIS GAME SESSION. The first Basic Studio or Pro Studio browser becomes the only writer automatically; other browsers remain read-only until that writer closes or you revoke it. Dangerous reset/preset/axis commands stay separately blocked above. Keep this in-game app open while tuning remotely; closing it saves recovery and locks browser editing.</div>
        <div class="oep-help" ng-if="!remote.on">Enable the dash server above first, then a remote-editing switch appears here.</div>
        </div>

        <div ng-if="isMirror">
          <div class="oep-help">This browser mirrors the live ECU. Tune files, Store, server setup and writer administration stay on the game PC.</div>
          <div class="oep-ro"><span>Editing role</span><span class="val">{{remoteClient.authenticated ? 'WRITER — changes enabled' : (remoteClient.writerConnected ? 'READ ONLY — another Studio is writer' : 'READ ONLY — enable Pro Studio Editing in BeamNG')}}</span></div>
          <div class="oep-ro"><span>Last command</span><span class="val">{{remoteClient.commandPhase ? (remoteClient.commandPhase | uppercase) : 'None'}} {{remoteClient.commandMessage}}</span></div>
          <div class="oep-ro"><span>Calibration</span><span class="val">rev {{calibrationRevision >= 0 ? calibrationRevision : '?'}} / {{calibrationDirty ? 'UNSAVED ON ECU' : 'stored'}}</span></div>
          <div class="oep-banner alarm" ng-if="calibrationDirty">The live ECU has unsaved changes. Store them from the in-game app before changing vehicle or closing the game.</div>
        </div>
      </div>

      <!-- HELP -->
      <div ng-if="activePage === 'help'">
        <div class="oep-pagetitle">{{tr('Help')}}</div>
        <pre class="oep-helptext">{{help}}</pre>
      </div>

      <!-- HELP ME / TUNE DOCTOR -->
      <div ng-if="activePage === 'challenges'">
        <div class="oep-pagetitle">{{tr('Challenges')}}</div>
        <div class="oep-chal-intro">
          Scored exercises on one fixed vanilla car, so a result means the same thing for everybody.
          <b>Repair</b> cases load a tune carrying exactly one deliberate fault &mdash; find it, name it,
          fix it, and prove the fix on the dyno. <b>Power Hunt</b> is open-ended: make as much as you can
          and keep the engine alive. Scores are the percent gained against a baseline you measure during
          the run, so nothing depends on the car you own or the mods you have installed.
          Your own calibration is backed up when a challenge starts and handed back the moment it ends.
          Starting one also clears accumulated engine damage, which a rollback cannot undo.
        </div>
        <div class="oep-chal-lock" ng-if="isMirror">Challenges run on the game PC &mdash; start one from the in-game app.</div>
        <div class="oep-chal-lock" ng-if="!isMirror && !challengeCarReady()">{{challengeCarHint()}}</div>
        <div class="oep-chal" ng-repeat="c in challengeList" ng-class="{unavailable: !lessonAvailable(c)}" ng-click="chStart(c.index)" title="{{lessonRequirement(c)}}">
          <div class="oep-chal-head">
            <span class="oep-chal-go">{{c.best ? '&#9733;' : '&#9654;'}}</span>{{c.title}}
            <span class="oep-chal-kind" ng-class="{power: c.kind === 'power', guided: c.kind === 'guided'}">{{c.kind === 'power' ? 'POWER HUNT' : (c.kind === 'guided' ? 'MASTERCLASS' : 'REPAIR')}}</span>
            <span class="oep-lesson-diff">{{c.difficulty}}</span>
          </div>
          <div class="oep-chal-desc">{{c.desc}}</div>
          <div class="oep-chal-note" ng-if="c.kind === 'guided' && chMasterclassNudge()">Stuck on the Power Hunt? Start here &mdash; this is the same car and the same scoring, with the whole method spelled out.</div>
          <div class="oep-chal-note quiet" ng-if="c.kind === 'guided' && !chMasterclassNudge()">Best attempted after you have had a go at the Power Hunt yourself.</div>
          <div class="oep-chal-foot">
            <span class="oep-medal" ng-class="c.best.medal" ng-if="c.best">{{chMedalLabel(c.best.medal)}}</span>
            <span ng-if="c.best">best {{c.best.gainPct >= 0 ? '+' : ''}}{{c.best.gainPct}}% &middot; {{c.best.hp}} hp &middot; {{c.best.date}}</span>
            <span ng-if="!c.best">not attempted yet</span>
          </div>
        </div>
      </div>

      <div ng-if="activePage === 'diagnose'">
        <div class="oep-pagetitle">{{tr('Help Me &mdash; Tune Doctor')}}</div>
        <div class="oep-help">{{diagIntro()}}</div>
        <div class="oep-diag" ng-class="'sev-' + d.sev" ng-repeat="d in diagnose()">
          <div class="oep-diag-title">{{d.title}}</div>
          <div class="oep-diag-advice">{{d.advice}}</div>
          <button class="oep-diag-go" ng-if="d.page" ng-click="gotoFix(d)">{{tr('Take me there')}} &#9654;</button>
        </div>
      </div>

      <!-- MAP TABLES (fuel/ignition/boost/lambda) -->
      <div class="oep-mappage" ng-class="{'oep-mappage-fill': isProStudio}" ng-if="isMapPage(activePage)">
        <div class="oep-maptabs">
          <button ng-class="{on: selectedMap==='fuel'}" ng-click="setPage('fuel')">Fuel Main</button>
          <button ng-class="{on: selectedMap==='ignition'}" ng-click="setPage('ignition')">Ignition</button>
          <button ng-class="{on: selectedMap==='boost'}" ng-click="setPage('boost')">Boost Target</button>
          <button ng-class="{on: selectedMap==='lambda'}" ng-click="setPage('lambda')">Lambda</button>
          <button ng-class="{on: selectedMap==='ve'}" ng-click="setPage('ve')">VE</button>
          <button ng-class="{on: selectedMap==='wgduty'}" ng-click="setPage('wgduty')" ng-if="base.hasTurbo">WG Duty</button>
        </div>
        <div class="oep-banner" ng-if="selectedMap==='boost' && !base.hasTurbo">Naturally aspirated engine — the Boost table has no effect on this car.</div>
        <div class="oep-banner" ng-if="selectedMap==='ve' && settings.fuelEquationMode != 2">VE Table fuels the engine in MODELLED mode only — switch Fuel equation on Fuel Setup to Modelled to use it. In Traditional mode the VE table is ignored. Editing VE invalidates Mixture Map evidence learned from the old values.</div>
        <div class="oep-banner" ng-if="selectedMap==='wgduty'">Shifts your Base duty slider per cell — 50 = neutral. Sculpt high-RPM columns DOWN to calm big-turbo boost hunting.</div>
        <div class="oep-edit">
          <span title="How much each +/- click changes a cell">Amt</span><input type="number" ng-model="$parent.editAmount" step="0.5" title="How much each +/- click changes a cell">
          <button data-tut="edit:plus" ng-click="addSel(1)" title="Add to the selected cells">+ {{stepAmountU()}}</button>
          <button data-tut="edit:minus" ng-click="addSel(-1)" title="Subtract from the selected cells">- {{stepAmountU()}}</button>
          <span class="sep"></span>
          <button data-tut="edit:scaleplus" ng-click="scaleSel(2)" title="Scale selected cells up 2%">+2%</button>
          <button data-tut="edit:scaleminus" ng-click="scaleSel(-2)" title="Scale selected cells down 2%">-2%</button>
          <input type="number" ng-model="$parent.fillValue" ng-keydown="fillKey($event)" ng-blur="fillBlur()" placeholder="val" title="Type a value, then Fill (or press Enter)"><button data-tut="edit:fill" ng-click="fillSel()" title="Set all selected cells to the value">Fill</button>
          <span class="sep"></span>
          <button ng-click="smoothSel()" title="Average the selection to smooth it out">Smooth</button>
          <button ng-click="interpSel()" title="Linearly fill between the corners of the selection">Interp</button>
          <button ng-click="copySel()" title="Copy the selected cells">Copy</button>
          <button ng-click="pasteSel()" title="Paste copied cells">Paste</button>
          <button data-tut="edit:all" ng-click="selectAll()" title="Select the whole table">All</button>
          <span class="sep"></span>
          <button ng-if="!isMirror" ng-click="toggleKb()" ng-style="{color: kbMode ? '#ffc428' : '', fontWeight: kbMode ? 'bold' : ''}" title="PCLink style keyboard tuning: arrows move the cell, Q/A adjust, type a number then Enter, O smooth, Ctrl+I interp. Pauses driving while on. Esc exits.">Keys</button>
          <button ng-if="!isProStudio" ng-click="toggle3d()" ng-style="{color: tableView === '3d' ? '#ffc428' : '', fontWeight: tableView === '3d' ? 'bold' : ''}" title="Hondata style 3D surface view of this table. Drag rotates, mouse wheel zooms, click a point to select that cell (Ctrl = whole column, Shift = whole row). K in keyboard mode.">3D</button>
          <span class="sep"></span>
          <button ng-click="snapBaseline()" title="Snapshot THIS table to compare changes against - each table keeps its own baseline">Baseline</button>
          <button ng-click="toggleDelta()" ng-style="{color: showDelta ? '#ffc428' : '', fontWeight: showDelta ? 'bold' : ''}" title="Delta view: every cell shows its CHANGE vs this table's baseline instead of the value (amber = on). No baseline yet? Toggling on snapshots one automatically.">&#916; Delta</button>
          <button class="warn" ng-click="resetCurrentMap()" title="Reset this table to its factory values. Other tables are left alone. Resetting Fuel Main or VE invalidates Mixture Map evidence learned from the old table; re-drive before applying another correction. Use Tune Files → Stock base to reset all tune tables.">Reset</button>
        </div>
        <input class="oep-kbtrap" type="text" ng-keydown="tableKey($event)" ng-focus="kbFocus(true)" ng-blur="kbFocus(false)" tabindex="-1" aria-hidden="true">
        <div class="oep-kbhint" ng-show="kbMode && !isMirror"><b>KEYBOARD TUNING ON</b> (driving paused, Esc exits) &nbsp;arrows move, Shift+arrows select, Q / A adjust (Shift = x5), type a number then Enter, O smooth, Ctrl+I interp, Ctrl+C/V copy paste, K 3D view, F I L V B W jump tables, Ctrl+S store</div>
        <div class="oep-tablewrap" ng-if="!isProStudio">
          <div ng-if="tableView === '3d'">
            <canvas class="oep-big3d" width="640" height="380"></canvas>
            <div class="oep-3dcap">Drag rotates, wheel zooms, click a point to select that cell (Ctrl = column, Shift = row). Edit with the bar above or keyboard mode.</div>
          </div>
          <!-- V3.0.7: the table used to be a direct flex child, so it had no scroll context of its
               own and the sticky load column had nothing to stick against. -->
          <div class="oep-gridscroll" ng-if="tableView !== '3d'">
          <table class="oep-grid oep-fit" ng-class="gridDensityClass()">
            <tr><th>{{axisUnit()}}\RPM</th><th ng-repeat="r in rpmAxis track by $index">{{r}}</th></tr>
            <tr ng-repeat="mp in activeLoadAxis() track by $index">
              <td class="axis">{{axisLab(mp)}}</td>
              <td class="cell" ng-repeat="rp in rpmAxis track by $index"
                  ng-class="{sel: cellSelected($parent.$index, $index), live: cellLive($parent.$index, $index)}"
                  ng-style="cellStyle($parent.$index, $index)"
                  ng-mousedown="beginSelect($parent.$index, $index, $event)"
                  ng-mouseenter="extendSelect($parent.$index, $index)"
                  ng-mouseup="endSelect()">
                {{ showDelta && curBaseline() ? cellDeltaStr($parent.$index,$index) : fmtCellU(curMap()[$parent.$index][$index]) }}
              </td>
            </tr>
          </table>
          </div>
          <!-- V3.0.9: this is a horizontal strip under the table now, not a right-hand column, so the
               "Selected cell" readout wraps across the strip instead of stacking on <br>s. -->
          <div class="oep-side">
            <div class="box">
              <b>Selected cell</b><br>
              <span class="selrow" ng-if="selInfo()"><span>{{axisLab(selInfo().map)}} {{axisUnit()}} / {{selInfo().rpm}} rpm</span><span>Value: <b>{{fmtCellU(selInfo().val)}}</b> {{cellUnitU()}}</span><span>Selection: {{selCount()}} cells</span></span>
              <span ng-if="!selInfo()">—</span>
            </div>
            <div class="box"><b>Hint</b><br>{{riskHint()}}</div>
            <!-- 210x72 is not arbitrary: .box.surf is a content-box with flex-basis:210px, and the
                 shared canvas rule is width:100%, so the drawing surface lands on exactly 210x72 and
                 the bitmap maps 1:1 with no resampling. Change one, change the other. -->
            <div class="box surf" ng-if="surfaceOn && tableView !== '3d'"><b>Surface</b><canvas class="oep-surface" width="210" height="72"></canvas></div>
          </div>
        </div>

        <!-- Browser-only professional calibration desk. It reuses the exact
             same maps, selectors and edit handlers as the compact table above. -->
        <div class="oep-pro-map" ng-if="isProStudio" aria-label="Professional map tuning workspace">
          <div class="oep-pro-map-main">
            <section class="oep-pro-pane oep-pro-table-pane" aria-label="Calibration table">
              <div class="oep-pro-pane-head active">
                {{proMapTitle()}}
                <span class="oep-pro-headmeta">{{isMirror && kbMode ? 'KEYBOARD ACTIVE · ESC' : 'ACTIVE CALIBRATION'}}</span>
              </div>
              <div class="oep-pro-table-labels">
                <span>{{proMapTitle()}} ({{cellUnitU()}})</span>
                <span>Engine Speed (RPM)</span>
              </div>
              <div class="oep-pro-table-shell">
                <div class="oep-pro-load-label"><span>{{loadAxisTitle()}} ({{axisUnit()}})</span></div>
                <div class="oep-pro-table-scroll">
                  <table class="oep-grid oep-pro-grid" ng-class="gridDensityClass()">
                    <tr><th>{{axisUnit()}}\RPM</th><th ng-repeat="r in rpmAxis track by $index">{{r}}</th></tr>
                    <tr ng-repeat="mp in activeLoadAxis() track by $index">
                      <td class="axis">{{axisLab(mp)}}</td>
                      <td class="cell" ng-repeat="rp in rpmAxis track by $index"
                          ng-class="{sel: cellSelected($parent.$index, $index), live: cellLive($parent.$index, $index)}"
                          ng-style="cellStyle($parent.$index, $index)"
                          ng-mousedown="beginSelect($parent.$index, $index, $event)"
                          ng-mouseenter="extendSelect($parent.$index, $index)"
                          ng-mouseup="endSelect()">
                        {{ showDelta && curBaseline() ? cellDeltaStr($parent.$index,$index) : fmtCellU(curMap()[$parent.$index][$index]) }}
                      </td>
                    </tr>
                  </table>
                </div>
              </div>
              <div class="oep-pro-table-status">
                <span ng-if="selInfo()"><b>{{selCount() ? 'SELECTED' : 'LIVE CELL'}}:</b> {{axisLab(selInfo().map)}} {{axisUnit()}} / {{selInfo().rpm}} RPM / {{fmtCellU(selInfo().val)}} {{cellUnitU()}}</span>
                <span ng-if="!selInfo()"><b>NO CELL SELECTED</b></span>
                <span>{{selCount()}} cell{{selCount() === 1 ? '' : 's'}}</span>
                <span class="risk">{{riskHint()}}</span>
              </div>
            </section>

            <section class="oep-pro-pane oep-pro-surface-pane" aria-label="Interactive 3D calibration surface">
              <div class="oep-pro-pane-head active">
                {{proMapTitle()}} &mdash; 3D Surface
                <span class="oep-pro-headmeta">LIVE CELL LINK</span>
              </div>
              <div class="oep-pro-surface-body">
                <div class="oep-pro-surface-readout" ng-if="selInfo()">
                  {{selInfo().rpm}} RPM<br>
                  {{axisLab(selInfo().map)}} {{axisUnit()}}
                  <b>{{fmtCellU(selInfo().val)}} {{cellUnitU()}}</b>
                </div>
                <div class="oep-pro-surface-readout" ng-if="!selInfo()">
                  {{(t.rpm || 0) | number:0}} RPM<br>
                  {{axisLab(activeLoadValue(selectedMap, t))}} {{axisUnit()}}
                  <b>LIVE TRACE</b>
                </div>
                <div class="oep-pro-zoom" aria-label="3D surface controls">
                  <button ng-click="pro3dZoom(1)" title="Zoom in">+</button>
                  <button ng-click="pro3dRotate(0,-1)" title="Tilt up">&#9650;</button>
                  <button ng-click="pro3dZoom(-1)" title="Zoom out">&minus;</button>
                  <button ng-click="pro3dRotate(0,1)" title="Tilt down">&#9660;</button>
                  <button ng-click="pro3dRotate(-1,0)" title="Rotate left">&#9664;</button>
                  <button ng-click="pro3dRotate(1,0)" title="Rotate right">&#9654;</button>
                </div>
                <canvas class="oep-big3d" width="720" height="390"></canvas>
              </div>
              <div class="oep-pro-surface-foot">
                <span class="on">&#9745; 3D</span><span class="on">&#9745; Colours</span>
                <span>Drag to rotate &bull; wheel to zoom &bull; click a node to select</span>
              </div>
            </section>
          </div>

          <div class="oep-pro-map-bottom" aria-label="Live tuning instruments">
            <section class="oep-pro-pane" aria-label="Live tuning values">
              <div class="oep-pro-pane-head">Tuning<span class="oep-pro-headmeta">LIVE ECU</span></div>
              <div class="oep-pro-data-list">
                <div class="oep-pro-data-row" ng-repeat="row in runtimeLeft[0].rows | limitTo:12" ng-if="rowShow(row)" ng-class="{alarm:rowAlarm(row)}">
                  <span ng-class="'oep-c-' + row[3]">{{rowLabel(tr(row[0]))}}</span><b>{{tv(row[1],row[2])}}</b>
                </div>
              </div>
            </section>

            <section class="oep-pro-pane" aria-label="Limits and engine protection">
              <div class="oep-pro-pane-head">Limits / Engine Protection<span class="oep-pro-headmeta">SAFETY</span></div>
              <div class="oep-pro-data-list">
                <div class="oep-pro-data-row" ng-repeat="row in runtimeRight[0].rows" ng-if="rowShow(row)" ng-class="{alarm:rowAlarm(row)}">
                  <span ng-class="'oep-c-' + row[3]">{{rowLabel(tr(row[0]))}}</span><b>{{tv(row[1],row[2])}}</b>
                </div>
                <div class="oep-pro-data-row" ng-class="{alarm:(t.knockLevel || 0) > (settings.knockThreshold || 1.2)}"><span class="oep-c-cyan">Knock Level</span><b>{{tv('knockLevel','')}}</b></div>
                <div class="oep-pro-data-row"><span class="oep-c-cyan">Knock Retard</span><b>{{tv('knockRetard',' deg')}}</b></div>
              </div>
            </section>

            <section class="oep-pro-pane" aria-label="Live instruments">
              <div class="oep-pro-pane-head">Live Instruments<span class="oep-pro-headmeta">SENSORS</span></div>
              <div class="oep-pro-instruments">
                <div class="oep-pro-tempstack">
                  <div class="oep-pro-temp"><span>ECT ({{tempU()}})</span><b>{{tempVal('ectC')}}</b></div>
                  <div class="oep-pro-temp iat"><span>IAT ({{tempU()}})</span><b>{{tempVal('iatC')}}</b></div>
                </div>
                <div class="oep-pro-tempstack">
                  <div class="oep-pro-temp"><span>OIL ({{tempU()}})</span><b>{{tempVal('oilC')}}</b></div>
                  <div class="oep-pro-temp iat"><span>EGT ({{tempU()}})</span><b>{{tempVal('egtC')}}</b></div>
                </div>
                <div class="oep-pro-lambda">
                  <div class="oep-pro-lambda-label">{{t.isDiesel ? 'AFR' : 'LAMBDA 1'}}</div>
                  <canvas width="190" height="190" data-dial="{{t.isDiesel ? 'afr' : 'lambdaActual'}}"></canvas>
                </div>
                <div class="oep-pro-tps">
                  <span>TP (Main)</span><b>{{(t.tps || 0) | number:0}}%</b>
                  <div class="oep-pro-tps-track"><div class="oep-pro-tps-fill" ng-style="{height:proPct('tps') + '%'}"></div><div class="oep-pro-tps-scale"></div></div>
                </div>
              </div>
            </section>
          </div>
        </div>
      </div>

      <!-- BOOST TARGET SHAPING: compact pressure/duty tables like a real motorsport ECU -->
      <div ng-if="activePage === 'boost_shaping'">
        <div class="oep-pagetitle">Boost by Gear / Speed</div>
        <div class="oep-help">The Boost Target table is the base aim; targets are <b>absolute kPa</b> (zero gauge boost = live barometric pressure). In Closed Loop these tables add a pressure trim; in Open Loop pressure trims are ignored. Typical target trims are &plusmn;10&ndash;40 kPa (1.5&ndash;6 psi). Wastegate duty trims work in both modes and multiply feed-forward duty (60% base with -30% trim = 42%); in Gear + Speed the two duty trims <b>add first</b> and the sum is capped at &plusmn;100% (-30% and -20% act as -50%). Speed uses BeamNG ground speed, not driven-wheel speed. Each M1-M4 MAP saves its own shaping tables.</div>
        <div class="oep-banner mon" ng-if="!base.hasTurbocharger">Not available on this engine. A naturally aspirated engine has no boost actuator; a supercharger's boost is mechanical.</div>
        <div class="oep-banner" ng-if="settings.boostMode == 1 && settings.boostShapeMode != 1"><b>OPEN LOOP:</b> only WG duty trims are active. Target-pressure trims require Closed Loop.</div>
        <div class="oep-banner" ng-if="settings.boostMode == 3 && settings.boostShapeMode == 1"><b>LEGACY PROFILE ACTIVE:</b> first gear uses 60% and second uses 80% of gauge target. Select a modern mode below to replace it with explicit tables.</div>
        <div class="oep-banner" ng-if="settings.boostMode == 3 && settings.boostShapeMode != 1"><b>LEGACY PROFILE SUPERSEDED:</b> with a shaping mode selected the loop behaves as plain Closed Loop &mdash; the 60/80% profile is inactive. Pick Closed Loop on Boost Control to retire the label.</div>
        <div class="oep-banner" ng-if="base.hasTurbocharger && base.wgSpringTargetPSI && settings.boostMode != 1 && mapBank.workingBoostTableFactory"><b>FACTORY BOOST TARGET:</b> the Boost Target table is untouched, so the base aim is your wastegate spring / stock boost ({{tv('boostBaseTargetKpa','kPa abs')}}) &mdash; not the table's seed value. Edit the Boost Target table to aim it yourself.</div>

        <div class="oep-cap">Target shaping source</div>
        <div class="oep-shape-mode oep-seg">
          <button ng-repeat="mode in boostShapeModes track by $index" data-tut="shape:mode:{{$index}}" ng-class="{on:boostShapeModeActive($index)}" ng-click="setBoostShapeMode($index)" ng-disabled="!base.hasTurbocharger" title="{{boostShapeModeTip($index)}}">{{mode}}</button>
          <span class="oep-shape-sep" aria-hidden="true"></span>
          <button class="oep-shape-nav" ng-click="setPage('boost_control')" title="Opens the Boost Control page (loop mode, base duty, PID gains)">Loop / wastegate setup &raquo;</button>
          <button class="oep-shape-nav" ng-click="setPage('boost')" title="Opens the base Boost Target table these trims are added to">Open base Boost Target &raquo;</button>
        </div>

        <div class="oep-shape-settings">
          <div class="oep-shape-setting" ng-class="{inert: settings.boostMode == 1}" title="Limits how fast an INCREASED target is chased - the anti-spike ramp. 0 = no ramp (target jumps). Runs only in Closed Loop with a shaping mode selected."><label>Target rise rate</label><div class="oep-shape-inputrow"><span class="oep-shape-stepper"><button type="button" ng-click="stepBoostShape('boostRampUpKpaS',-1,'rate',25)" ng-disabled="!base.hasTurbocharger || settings.boostShapeMode == 1" title="{{boostShapeStepTitle(-1,'rate',25)}}" aria-label="Decrease target rise rate">&minus;</button><input type="number" step="any" ng-model="boostShapeEditors.boostRampUpKpaS" ng-model-options="{getterSetter:true}" ng-keydown="boostShapeKeyStep($event,'boostRampUpKpaS','rate',25)" ng-attr-min="{{boostShapeDisplayLimit('rate',0)}}" ng-attr-max="{{boostShapeDisplayLimit('rate',2000)}}" ng-disabled="!base.hasTurbocharger || settings.boostShapeMode == 1" aria-label="Target rise rate"><button type="button" ng-click="stepBoostShape('boostRampUpKpaS',1,'rate',25)" ng-disabled="!base.hasTurbocharger || settings.boostShapeMode == 1" title="{{boostShapeStepTitle(1,'rate',25)}}" aria-label="Increase target rise rate">+</button></span><span class="oep-shape-unit">{{boostShapeUnit('rate')}}</span></div><small>Closed Loop only &middot; 0 = immediate</small></div>
          <div class="oep-shape-setting" ng-class="{inert: settings.boostMode == 1}" title="Limits how fast a DECREASED target is released. 0 = drop immediately (the safe direction). Runs only in Closed Loop with a shaping mode selected."><label>Target fall rate</label><div class="oep-shape-inputrow"><span class="oep-shape-stepper"><button type="button" ng-click="stepBoostShape('boostRampDownKpaS',-1,'rate',25)" ng-disabled="!base.hasTurbocharger || settings.boostShapeMode == 1" title="{{boostShapeStepTitle(-1,'rate',25)}}" aria-label="Decrease target fall rate">&minus;</button><input type="number" step="any" ng-model="boostShapeEditors.boostRampDownKpaS" ng-model-options="{getterSetter:true}" ng-keydown="boostShapeKeyStep($event,'boostRampDownKpaS','rate',25)" ng-attr-min="{{boostShapeDisplayLimit('rate',0)}}" ng-attr-max="{{boostShapeDisplayLimit('rate',2000)}}" ng-disabled="!base.hasTurbocharger || settings.boostShapeMode == 1" aria-label="Target fall rate"><button type="button" ng-click="stepBoostShape('boostRampDownKpaS',1,'rate',25)" ng-disabled="!base.hasTurbocharger || settings.boostShapeMode == 1" title="{{boostShapeStepTitle(1,'rate',25)}}" aria-label="Increase target fall rate">+</button></span><span class="oep-shape-unit">{{boostShapeUnit('rate')}}</span></div><small>Closed Loop only &middot; 0 = immediate / safest</small></div>
          <div class="oep-shape-setting" title="Boost hold after an upshift: when the new gear requests a HIGHER trim, keep the previous (lower) one for this long - the anti-spike hold real ECUs run open-loop through a shift. Reductions always apply instantly, and each new rise (including a live table edit) restarts the hold."><label>Gear-up hold</label><div class="oep-shape-inputrow"><span class="oep-shape-stepper"><button type="button" ng-click="stepBoostShape('boostShiftHoldMs',-1,'time',25)" ng-disabled="!base.hasTurbocharger || !boostGearShapeEnabled()" title="{{boostShapeStepTitle(-1,'time',25)}}" aria-label="Decrease gear-up hold">&minus;</button><input type="number" step="any" ng-model="boostShapeEditors.boostShiftHoldMs" ng-model-options="{getterSetter:true}" ng-keydown="boostShapeKeyStep($event,'boostShiftHoldMs','time',25)" min="0" max="1000" ng-disabled="!base.hasTurbocharger || !boostGearShapeEnabled()" aria-label="Gear-up hold"><button type="button" ng-click="stepBoostShape('boostShiftHoldMs',1,'time',25)" ng-disabled="!base.hasTurbocharger || !boostGearShapeEnabled()" title="{{boostShapeStepTitle(1,'time',25)}}" aria-label="Increase gear-up hold">+</button></span><span class="oep-shape-unit">{{boostShapeUnit('time')}}</span></div><small>Gear modes only &middot; only delays an increase</small></div>
        </div>

        <div class="oep-shape-tables">
          <section class="oep-shape-box">
            <h4><span>Gear correction</span><small title="These trims add to the base Boost Target table. Neutral / reverse uses the G1 row.">Adds to base target &middot; N/R uses G1</small></h4>
            <table class="oep-shape-grid">
              <thead><tr><th>Gear</th><th>Target trim ({{boostShapeUnit('pressure')}})<small class="colnote" ng-if="settings.boostMode == 1">inactive in Open Loop</small></th><th>WG duty trim (%)</th></tr></thead>
              <tbody><tr ng-repeat="row in boostGearRows track by row.gear" ng-class="{inert: base.forwardGears && row.gear > base.forwardGears}" ng-attr-title="{{(base.forwardGears && row.gear > base.forwardGears) ? 'Not on this gearbox - top gear is G' + base.forwardGears : undefined}}">
                <td>G{{row.gear}}</td>
                <td ng-class="{inert: settings.boostMode == 1}" title="Added to the base boost target while in G{{row.gear}} (Closed Loop only). Typical trims are ±10-40 kPa."><span class="oep-shape-stepper" data-tut="shape:gear-target:{{row.gear}}"><button type="button" ng-click="stepBoostShape(row.targetKey,-1,'pressure',5)" ng-disabled="!boostGearShapeEnabled() || !base.hasTurbocharger" title="{{boostShapeStepTitle(-1,'pressure',5)}}" aria-label="Decrease G{{row.gear}} target trim">&minus;</button><input type="number" step="any" ng-model="boostShapeEditors[row.targetKey]" ng-model-options="{getterSetter:true}" ng-keydown="boostShapeKeyStep($event,row.targetKey,'pressure',5)" ng-attr-min="{{boostShapeDisplayLimit('pressure',-500)}}" ng-attr-max="{{boostShapeDisplayLimit('pressure',500)}}" ng-disabled="!boostGearShapeEnabled() || !base.hasTurbocharger" aria-label="G{{row.gear}} target trim in {{boostShapeUnit('pressure')}}"><button type="button" ng-click="stepBoostShape(row.targetKey,1,'pressure',5)" ng-disabled="!boostGearShapeEnabled() || !base.hasTurbocharger" title="{{boostShapeStepTitle(1,'pressure',5)}}" aria-label="Increase G{{row.gear}} target trim">+</button></span><small>target delta</small></td>
                <td title="Multiplies base wastegate duty in G{{row.gear}}, in Open AND Closed Loop (60% base with -30% = 42%). In Gear + Speed the gear and speed duty trims add first, capped at ±100%."><span class="oep-shape-stepper"><button type="button" ng-click="stepBoostShape(row.dutyKey,-1,'duty',1)" ng-disabled="!boostGearShapeEnabled() || !base.hasTurbocharger" title="{{boostShapeStepTitle(-1,'duty',1)}}" aria-label="Decrease G{{row.gear}} wastegate duty trim">&minus;</button><input type="number" step="any" ng-model="boostShapeEditors[row.dutyKey]" ng-model-options="{getterSetter:true}" ng-keydown="boostShapeKeyStep($event,row.dutyKey,'duty',1)" min="-100" max="100" ng-disabled="!boostGearShapeEnabled() || !base.hasTurbocharger" aria-label="G{{row.gear}} wastegate duty trim"><button type="button" ng-click="stepBoostShape(row.dutyKey,1,'duty',1)" ng-disabled="!boostGearShapeEnabled() || !base.hasTurbocharger" title="{{boostShapeStepTitle(1,'duty',1)}}" aria-label="Increase G{{row.gear}} wastegate duty trim">+</button></span><small>&times; base duty</small></td>
              </tr></tbody>
            </table>
          </section>
          <section class="oep-shape-box">
            <h4><span>Ground-speed correction</span><small title="These trims add to the base Boost Target table. Values interpolate linearly between rows; the end rows are held outside 0-240 km/h.">Adds to base target &middot; interpolated</small></h4>
            <table class="oep-shape-grid">
              <thead><tr><th title="Fixed axis (always stored in km/h) - the ECU interpolates between rows">{{speedUnit()}}</th><th>Target trim ({{boostShapeUnit('pressure')}})<small class="colnote" ng-if="settings.boostMode == 1">inactive in Open Loop</small></th><th>WG duty trim (%)</th></tr></thead>
              <tbody><tr ng-repeat="row in boostSpeedRows track by row.speed">
                <td>{{boostSpeedLabel(row.speed)}}</td>
                <td ng-class="{inert: settings.boostMode == 1}" title="Added to the base boost target around {{boostSpeedLabel(row.speed)}} {{speedUnit()}} (Closed Loop only), interpolated to the next row. Typical trims are ±10-40 kPa."><span class="oep-shape-stepper" data-tut="shape:speed-target:{{row.speed}}"><button type="button" ng-click="stepBoostShape(row.targetKey,-1,'pressure',5)" ng-disabled="!boostSpeedShapeEnabled() || !base.hasTurbocharger" title="{{boostShapeStepTitle(-1,'pressure',5)}}" aria-label="Decrease {{row.speed}} km/h target trim">&minus;</button><input type="number" step="any" ng-model="boostShapeEditors[row.targetKey]" ng-model-options="{getterSetter:true}" ng-keydown="boostShapeKeyStep($event,row.targetKey,'pressure',5)" ng-attr-min="{{boostShapeDisplayLimit('pressure',-500)}}" ng-attr-max="{{boostShapeDisplayLimit('pressure',500)}}" ng-disabled="!boostSpeedShapeEnabled() || !base.hasTurbocharger" aria-label="{{row.speed}} km/h target trim in {{boostShapeUnit('pressure')}}"><button type="button" ng-click="stepBoostShape(row.targetKey,1,'pressure',5)" ng-disabled="!boostSpeedShapeEnabled() || !base.hasTurbocharger" title="{{boostShapeStepTitle(1,'pressure',5)}}" aria-label="Increase {{row.speed}} km/h target trim">+</button></span><small>target delta</small></td>
                <td title="Multiplies base wastegate duty around {{boostSpeedLabel(row.speed)}} {{speedUnit()}}, in Open AND Closed Loop. In Gear + Speed the gear and speed duty trims add first, capped at ±100%."><span class="oep-shape-stepper"><button type="button" ng-click="stepBoostShape(row.dutyKey,-1,'duty',1)" ng-disabled="!boostSpeedShapeEnabled() || !base.hasTurbocharger" title="{{boostShapeStepTitle(-1,'duty',1)}}" aria-label="Decrease {{row.speed}} km/h wastegate duty trim">&minus;</button><input type="number" step="any" ng-model="boostShapeEditors[row.dutyKey]" ng-model-options="{getterSetter:true}" ng-keydown="boostShapeKeyStep($event,row.dutyKey,'duty',1)" min="-100" max="100" ng-disabled="!boostSpeedShapeEnabled() || !base.hasTurbocharger" aria-label="{{row.speed}} km/h wastegate duty trim"><button type="button" ng-click="stepBoostShape(row.dutyKey,1,'duty',1)" ng-disabled="!boostSpeedShapeEnabled() || !base.hasTurbocharger" title="{{boostShapeStepTitle(1,'duty',1)}}" aria-label="Increase {{row.speed}} km/h wastegate duty trim">+</button></span><small>&times; base duty</small></td>
              </tr></tbody>
            </table>
          </section>
        </div>

        <div class="oep-cap">Live calculation</div>
        <div class="oep-shape-flow" data-tut="shape:math" ng-if="boostTargetMath()" title="The target math spelled out: the base Boost Target table cell plus each active trim gives the shaped request the loop chases (ramping may lag it - see the flow line below)."><b>{{boostTargetMath()}}</b></div>
        <div class="oep-shape-flow" data-tut="shape:live" title="Targets are absolute manifold pressure (zero gauge boost = live barometric pressure); the trims below are deltas on top of the base.">Base target <b>{{tv('boostBaseTargetKpa','kPa abs')}}</b> &rarr; shaped request <b>{{tv('boostRequestedTargetKpa','kPa abs')}}</b> &rarr; active/ramped target <b>{{tv('boostTargetKpa','kPa abs')}}</b> &nbsp; | &nbsp; Base duty <b>{{tv('boostBaseWastegateDuty','%',1)}}</b> &rarr; feed-forward <b>{{tv('boostFeedforwardDuty','%',1)}}</b> &rarr; final WG <b>{{tv('wastegateDuty','%',1)}}</b></div>
        <div class="oep-shape-live">
          <div class="oep-ro"><span>Gear / signal</span><span class="val">{{t.boostGearValid ? ('G' + t.boostGear) : 'G1 fallback'}}</span></div>
          <div class="oep-ro"><span>Ground speed</span><span class="val">{{speedDisp(t.boostSpeedKmh)}}</span></div>
          <div class="oep-ro"><span>Speed source</span><span class="val">{{t.boostSpeedSource || '--'}}</span></div>
          <div class="oep-ro"><span>Gear target trim</span><span class="val">{{tv('boostGearTargetTrimKpa','kPa',1)}}</span></div>
          <div class="oep-ro"><span>Speed target trim</span><span class="val">{{tv('boostSpeedTargetTrimKpa','kPa',1)}}</span></div>
          <div class="oep-ro"><span>Applied target trim</span><span class="val">{{tv('boostTargetTrimAppliedKpa','kPa',1)}}</span></div>
          <div class="oep-ro"><span>Gear WG trim</span><span class="val">{{tv('boostGearDutyTrimPct','%',1)}}</span></div>
          <div class="oep-ro"><span>Speed WG trim</span><span class="val">{{tv('boostSpeedDutyTrimPct','%',1)}}</span></div>
          <div class="oep-ro"><span>Combined WG trim</span><span class="val">{{tv('boostDutyTrimPct','%',1)}}</span></div>
          <div class="oep-ro"><span>Boost shaping</span><span class="val">{{t.boostShapeStatus || '--'}}</span></div>
          <div class="oep-ro"><span>Target source</span><span class="val">{{t.boostLimitReason || '--'}}</span></div>
          <div class="oep-ro"><span>Boost control</span><span class="val">{{t.boostControlStatus || '--'}}</span></div>
          <div class="oep-ro" title="The lowest absolute pressure the wastegate hardware can regulate down to (spring floor) - targets below it read WASTEGATE FLOOR, not failed table math"><span>Hardware floor</span><span class="val">{{tv('boostHardwareFloorKpa','kPa abs')}}</span></div>
        </div>
      </div>

      <!-- GENERIC SETTINGS PAGES -->
      <div ng-if="isSettingsPage()">
        <div class="oep-pagetitle">{{tr(pageDef().title)}}<span class="oep-scopebadge permap" ng-if="pageMapScope()==='per-map'" title="These settings are saved per switchable MAP - they change when you switch M1-M4">PER-MAP (M{{mapBank.active}})</span><span class="oep-scopebadge shared" ng-if="pageMapScope()==='shared'" title="These settings are shared by all four switchable MAPs - changing them affects every MAP">ALL MAPs</span><span class="oep-scopebadge" ng-if="pageMapScope()==='mixed'" title="Some settings on this page switch with the MAP, others are shared by all four MAPs">MIXED MAP SCOPE</span></div>
        <div class="oep-help">{{tr(pageDef().help)}}</div>
        <!-- V2.9.x hardware gate (community server-fairness report): nitrous only works with a real
             N2O part installed, mirroring how boost features gate on a real turbo. -->
        <div class="oep-banner mon" ng-if="activePage === 'nitrous' && connected && base && !base.hasNitrous">NO NITROUS SYSTEM INSTALLED &mdash; an ECU can only control hardware the car actually has. Fit a N2O kit in the vehicle Parts menu; these settings stay saved and go live the moment a kit is installed.</div>
        <div ng-repeat="ctrl in pageDef().controls">
          <div ng-switch="ctrl.type" ng-if="ctrlShow(ctrl)">
            <div ng-switch-when="slider" class="oep-ctrl" data-tut="set:{{ctrl.key}}" title="{{tipFor(ctrl)}}">
              <div class="lab"><span>{{tr(ctrl.label)}}</span><span class="oep-ctrlhint" ng-if="ctrlHint(ctrl)" ng-class="{warn: ctrlHintWarn(ctrl)}">{{ctrlHint(ctrl)}}</span><b ng-if="!psiSlider(ctrl) && !sldZeroStock(ctrl)">{{getNum(ctrl.key) | number:(ctrl.step<1?2:0)}}{{ctrl.fmt}}</b><b ng-if="sldZeroStock(ctrl)">{{sldZeroStockText(ctrl)}}</b><b ng-if="psiSlider(ctrl)">{{slDisp(ctrl)}}</b></div>
              <input type="range" ng-model="settings[ctrl.key]" ng-change="onSlider(ctrl)" ng-attr-min="{{ctrl.min}}" ng-attr-max="{{ctrl.max}}" ng-attr-step="{{ctrl.step}}" title="{{tipFor(ctrl)}}">
            </div>
            <div ng-switch-when="check" class="oep-ctrl" data-tut="set:{{ctrl.key}}" title="{{tipFor(ctrl)}}">
              <span class="oep-chk" ng-class="{on: settings[ctrl.key]}" ng-click="toggle(ctrl.key)" title="{{tipFor(ctrl)}}">{{tr(ctrl.label)}}: {{settings[ctrl.key] ? 'ON' : 'OFF'}}</span>
            </div>
            <div ng-switch-when="select" class="oep-ctrl" data-tut="set:{{ctrl.key}}" title="{{tipFor(ctrl)}}">
              <div class="lab"><span>{{tr(ctrl.label)}}</span><span class="oep-ctrlhint" ng-if="ctrlHint(ctrl)" ng-class="{warn: ctrlHintWarn(ctrl)}">{{ctrlHint(ctrl)}}</span></div>
              <div class="oep-seg"><button ng-repeat="opt in ctrl.options track by $index" ng-class="{on: selActive(ctrl, $index)}" ng-click="selOpt(ctrl, $index)">{{tr(opt)}}</button></div>
            </div>
            <div ng-switch-when="readout" class="oep-ro" title="{{tipFor(ctrl)}}"><span>{{tr(ctrl.label)}}</span><span class="val">{{tv(ctrl.tkey, ctrl.unit, ctrl.digits)}}</span></div>
            <div ng-switch-when="note" style="margin:6px 2px; padding:6px 9px; background:#fff3cf; border:1px solid #d2b85a; color:#5a4600; font-size:12px; line-height:1.4;">{{ctrl.text}}</div>
          </div>
        </div>
      </div>

    </div>
  </div>

  <!-- tutorial instruction box (in-flow so it never covers the tables) -->
  <div class="oep-tut" ng-if="tutorial.active">
    <div class="oep-tut-head">{{tutTitle()}}<span class="oep-tut-prog">Step {{tutProgress()}}</span></div>
    <div class="oep-tut-text">{{tutStepText()}}</div>
    <!-- challenge diagnosis gate: Next stays locked until the right fault is named -->
    <div class="oep-quiz" ng-if="chQuiz()">
      <div class="oep-quiz-q">{{chQuiz().q}}</div>
      <button class="oep-quiz-opt" ng-repeat="o in chQuiz().opts"
        ng-class="{right: tutorial.quizPick === $index && o.ok, wrong: tutorial.quizTried[$index]}"
        ng-click="chAnswer($index)">{{o.t}}</button>
      <div class="oep-quiz-why" ng-if="tutorial.quizWhy">{{tutorial.quizWhy}}</div>
    </div>
    <div class="oep-tut-verify" ng-if="tutVerifyText()" ng-class="{pass:tutCanNext()}"><span class="oep-tut-verify-badge">{{tutCanNext() ? 'PASS' : 'LIVE CHECK'}}</span>{{tutVerifyText()}}</div>
    <div class="oep-tut-actions">
      <button ng-click="tutExit(false)">Exit</button>
      <button ng-click="tutRestart()">Restart</button>
      <button ng-click="tutBack()">Back</button>
      <button ng-click="tutSkip()">Skip</button>
      <button class="oep-tut-next" ng-class="{ready: tutCanNext()}" ng-disabled="!tutCanNext()" ng-click="tutNext()">{{tutNextLabel()}}<span ng-if="tutCanNext()"> &#10003;</span></button>
    </div>
  </div>

  <div class="oep-working-guard" ng-if="workingTunePrompt" ng-click="cancelWorkingTuneAction()">
    <div class="oep-working-guard-box" role="dialog" aria-modal="true" aria-labelledby="oep-working-guard-title" ng-click="$event.stopPropagation()">
      <div class="oep-working-guard-title" id="oep-working-guard-title">Unsaved working tune</div>
      <div class="oep-working-guard-body">
        <b>{{workingTunePrompt.label}}</b> would replace the edits currently associated with M{{mapBank.active}}.
        <div class="oep-working-guard-note" ng-if="!workingTunePrompt.remote && !workingTunePrompt.discardOnly">Save them to the current MAP, discard them, or cancel and keep tuning.</div>
        <div class="oep-working-guard-note" ng-if="!workingTunePrompt.remote && workingTunePrompt.discardOnly">Discard the working edits, or cancel and keep tuning.</div>
        <div class="oep-working-guard-note" ng-if="workingTunePrompt.remote">Save or revert the working tune from the game PC before continuing.</div>
      </div>
      <div class="oep-working-guard-actions">
        <button class="prim" ng-if="!workingTunePrompt.remote && !workingTunePrompt.discardOnly" ng-click="resolveWorkingTuneAction('save')">Save M{{mapBank.active}} &amp; Continue</button>
        <button class="warn" ng-if="!workingTunePrompt.remote" ng-click="resolveWorkingTuneAction('discard')">{{workingTunePrompt.discardOnly ? 'Discard edits' : 'Discard & Continue'}}</button>
        <button ng-click="cancelWorkingTuneAction()">{{workingTunePrompt.remote ? 'OK' : 'Cancel'}}</button>
      </div>
    </div>
  </div>

  <!-- status strip -->
  <div class="oep-status">
    <span>{{carName || 'No vehicle'}}</span>
    <span>Tune: {{tuneName}}</span>
    <span ng-class="{req: mapBank.workingDirty}" title="{{'Active MAP: M' + mapBank.active + ' - ' + mapBankActiveName() + (mapBank.workingDirty ? ' (working tune has unsaved edits)' : '')}}">M{{mapBank.active}} {{mapBankActiveName()}}{{mapBank.workingDirty ? ' · EDITED' : ''}}</span>
    <span>RPM {{tv('rpm')}}</span>
    <span>λ {{tv('lambdaActual')}}</span>
    <span>Knock {{tv('knockLevel')}}</span>
    <span ng-class="storeRequired ? 'req' : 'ok'">{{storeRequired ? (isMirror ? 'STORE REQUIRED — ON GAME PC' : 'STORE REQUIRED') : 'STORED'}}</span>
  </div>

  <div class="oep-toast" ng-if="toast">{{toast}}</div>

  <!-- challenge result card -->
  <div class="oep-chres" ng-if="chResult" ng-click="closeChResult()">
    <div class="oep-chres-box" role="dialog" aria-modal="true" ng-click="$event.stopPropagation()">
      <div class="oep-chres-title" ng-class="{failed: chResult.failed}">{{chResult.failed ? 'ENGINE LOST - ' : ''}}{{chResult.title}}</div>
      <div class="oep-chres-fail" ng-if="chResult.failed">
        <b>The run ended when the engine went past saving.</b><br>{{chResult.failReason}}
      </div>
      <div class="oep-chres-medal"><span class="oep-medal" ng-class="chResult.medal">{{chMedalLabel(chResult.medal)}}</span></div>
      <div class="oep-chres-gain">{{chGainText(chResult)}}</div>
      <div class="oep-chres-new" ng-if="chResult.newRecord">NEW PERSONAL BEST</div>
      <div class="oep-chres-nudge" ng-if="chResult.suggestMasterclass">
        Left something on the table? <b>Power Hunt Masterclass</b> walks this same car through the whole
        method in order &mdash; which lever first, and how far each one goes before it stops paying.
      </div>
      <div class="oep-chres-crit" ng-repeat="c in chResult.criteria" ng-class="{ok: c.ok}">
        <span class="oep-chres-crit-mark">{{c.ok ? '&#10004;' : '&#10008;'}}</span><span>{{c.label}}</span>
      </div>
      <div class="oep-chres-stats">
        <span>Dyno pulls: {{chResult.pulls}}</span>
        <span>Wrong diagnoses: {{chResult.quizWrong}}</span>
        <span>Lowest health: {{chResult.minHealth | number:0}}%</span>
        <span>Peak knock: {{chResult.maxKnock | number:2}}</span>
        <span>Peak EGT: {{chResult.maxEgt | number:0}} C</span>
        <span>Faults seen (whole run): {{chResult.faultsSeen}}</span>
      </div>
      <div class="oep-welcome-actions"><button ng-click="closeChResult()">Close</button></div>
    </div>
  </div>

  <!-- tutorial welcome / lesson picker -->
  <div class="oep-welcome" ng-if="showWelcome">
    <div class="oep-welcome-box">
      <div class="oep-welcome-title">Welcome to OE <b>ProTune</b></div>
      <!-- chapter picker -->
      <div ng-if="tutChapter === null">
        <div class="oep-welcome-sub">New to tuning? Pick a chapter - each is a short set of step-by-step lessons that guide you and highlight what to click. <b>{{lessonsDone()}}/{{lessons.length}} complete</b></div>
        <div class="oep-chapcard" ng-repeat="ch in chapters" ng-click="openChapter($index)">
          <div class="oep-chapcard-head"><span class="oep-lesson-go">&#9654;</span> {{ch.title}}<span class="oep-chapter-prog">{{ch.doneN}}/{{ch.total}}</span></div>
          <div class="oep-lesson-desc">{{ch.total}} lessons</div>
        </div>
      </div>

      <!-- lessons inside the chosen chapter -->
      <div ng-if="tutChapter !== null">
        <div class="oep-chapnav"><button class="oep-back" ng-click="backToChapters()">&#8249; Chapters</button> <span>{{chapters[tutChapter].title}}</span></div>
        <div class="oep-lesson" ng-class="{done: ls.done, unavailable: !lessonAvailable(ls)}" ng-repeat="ls in chapters[tutChapter].lessons" ng-click="startLesson(ls.index)" title="{{lessonRequirement(ls)}}">
          <div class="oep-lesson-head"><span class="oep-lesson-go">{{ls.done ? '✓' : '▶'}}</span> {{lTitle(ls)}}<span class="oep-lesson-diff">{{ls.difficulty}}</span></div>
          <div class="oep-lesson-desc">{{lessonRequirement(ls) || lDesc(ls)}}</div>
        </div>
      </div>

      <div class="oep-welcome-actions"><button ng-click="closeWelcome()">{{tr('Maybe later')}}</button></div>
    </div>
  </div>
</div>
</div>
