Torn Merit Build Planner

Pick a merit build and view priorities with target levels.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Torn Merit Build Planner
// @namespace    https://www.torn.com/
// @version      1.1.1
// @description  Pick a merit build and view priorities with target levels.
// @author       PFangy
// @match        https://www.torn.com/page.php?sid=awards*
// @grant        none
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  // Usage:
  // 1) Open Torn merits page.
  // 2) Pick a build in the build panel.
  // 3) Use the Edit button to open bulk merit editor.
  // 4) Save priority + target values for your active build.
  // Presets are only starter templates and can be changed any time.

  const SCRIPT_GUARD_KEY = "__tornMeritBuildPlannerLoaded";
  if (window[SCRIPT_GUARD_KEY]) return;
  window[SCRIPT_GUARD_KEY] = true;

  const STORAGE_KEYS = {
    activeRoleId: "tmrp.activeRoleId",
    customRoles: "tmrp.customRoles",
    showOnlyPrioritized: "tmrp.showOnlyPrioritized"
  };

  // These are starter presets. Players can clone and adapt them.
  const ROLE_PRESETS = {
    chain_attacker: { label: "Chain Attacker", rules: {
      "brawn": { priority: "high", target: 8, reason: "Core damage for fast chain hits." },
      "protection": { priority: "high", target: 8, reason: "Useful sustain in long chains." },
      "sharpness": { priority: "high", target: 8, reason: "Hit consistency and tempo." },
      "evasion": { priority: "high", target: 8, reason: "Defense support during heavy activity." },
      "critical hit rate": { priority: "high", target: 7, reason: "Strong chain conversion value." },
      "life points": { priority: "medium", target: 6, reason: "Extra safety between fights." },
      "stealth": { priority: "medium", target: 5, reason: "Situational outgoing attack utility." }
    } },
    ranked_war: { label: "Ranked War Fighter", rules: {
      "brawn": { priority: "high", target: 8, reason: "Primary offense in wars." },
      "protection": { priority: "high", target: 9, reason: "Very high survivability value." },
      "sharpness": { priority: "high", target: 7, reason: "Keeps hit chance stable." },
      "evasion": { priority: "high", target: 7, reason: "Helps avoid incoming damage." },
      "life points": { priority: "high", target: 7, reason: "Longer war endurance." },
      "critical hit rate": { priority: "medium", target: 6, reason: "Good but not first priority." },
      "hospitalizing": { priority: "medium", target: 6, reason: "War pressure utility." }
    } },
    mugger: { label: "Mugger", rules: {
      "masterful looting": { priority: "high", target: 10, reason: "Main income multiplier for mugging." },
      "stealth": { priority: "high", target: 8, reason: "Very useful for outgoing attacks." },
      "brawn": { priority: "high", target: 7, reason: "Needed to secure wins quickly." },
      "sharpness": { priority: "medium", target: 6, reason: "Supports reliable hits." },
      "critical hit rate": { priority: "medium", target: 6, reason: "Better combat efficiency." }
    } },
  };

  const PRIORITY_ORDER = ["high", "medium", "low"];
  const MERIT_CARD_SELECTORS = [
    "li[class*='merit']",
    "div[class*='merit-item']",
    "div[class*='meritItem']",
    "div[class*='merit_row']",
    "div[class*='meritRow']",
    "article[class*='merit']",
    "div[class*='merit']"
  ].join(",");
  const TITLE_SELECTORS = ["h1","h2","h3","h4","h5","h6","strong","b","[class*='title']","[class*='name']","span"].join(",");
  const RENDER_DEBOUNCE_MS = 45;

  let mutationObserver;
  let processTimer;
  let lastUrl = location.href;
  let cachedPanel;
  let cachedPanelRoleSelect;
  let cachedPanelOnlyToggle;
  let panelRoleOptionsSignature = "";
  let cachedModal;
  let cachedImportModal;
  let cachedExportModal;
  let isProcessingPage = false;
  let observerPaused = false;
  const cardSegmentsCache = new WeakMap();
  const DEBUG_PERF = localStorage.getItem("tmrp.debugPerf") === "1";

  function normalizeKey(value) {
    return (value || "").toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
  }
  function escapeHtml(value) {
    return String(value || "")
      .replace(/&/g, "&")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#39;");
  }
  function debugPerf(...parts) {
    if (!DEBUG_PERF) return;
    // Keep logging simple and explicit so users can copy/paste evidence.
    console.log("[TMRP PERF]", ...parts);
  }
  function withPerfLabel(label, fn) {
    const start = performance.now();
    const result = fn();
    const elapsed = performance.now() - start;
    if (elapsed > 12) debugPerf(`${label} took ${elapsed.toFixed(1)}ms`);
    return result;
  }
  function getCookieValue(name) {
    const parts = (document.cookie || "").split(";");
    for (const part of parts) {
      const [rawKey, ...rest] = part.trim().split("=");
      if (rawKey === name) return rest.join("=");
    }
    return "";
  }
  function isDarkModeEnabled() {
    const cookieValue = (getCookieValue("darkModeEnabled") || "").toLowerCase();
    if (cookieValue === "true" || cookieValue === "1") return true;
    if (cookieValue === "false" || cookieValue === "0") return false;

    // Fallback: detect likely dark UI when cookie is missing.
    const bodyClass = (document.body?.className || "").toLowerCase();
    if (bodyClass.includes("dark")) return true;
    if (bodyClass.includes("light")) return false;
    return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches === true;
  }
  function applyThemeMode() {
    document.documentElement.setAttribute("data-tmrp-theme", isDarkModeEnabled() ? "dark" : "light");
  }
  function safeParse(raw, fallback) {
    try { return JSON.parse(raw); } catch (_) { return fallback; }
  }
  function isMeritsPage() {
    if (/sid=merits|tab=merits|merits\.php/i.test(location.href)) return true;
    const pageTitle = document.querySelector("h1, h2, .title-black, .title");
    return /merits/i.test(pageTitle?.textContent || "");
  }
  function clearPlannerUiForNonMerits() {
    if (cachedPanel?.isConnected) cachedPanel.remove();
    cachedPanel = null;
    cachedPanelRoleSelect = null;
    cachedPanelOnlyToggle = null;
    panelRoleOptionsSignature = "";

    for (const legend of document.querySelectorAll(".tmrp-table-legend")) {
      legend.remove();
    }
    for (const segment of document.querySelectorAll("[data-tmrp-segment='1']")) {
      segment.removeAttribute("style");
      segment.removeAttribute("data-tmrp-segment");
    }
    for (const row of document.querySelectorAll(".tmrp-priority-row, .tmrp-hidden")) {
      row.classList.remove("tmrp-priority-row", "p-high", "p-medium", "p-low", "tmrp-hidden");
      delete row.dataset.tmrpRingSignature;
    }
  }

  const state = {
    activeRoleId: localStorage.getItem(STORAGE_KEYS.activeRoleId) || "combat",
    customRoles: safeParse(localStorage.getItem(STORAGE_KEYS.customRoles), {}) || {},
    showOnlyPrioritized: localStorage.getItem(STORAGE_KEYS.showOnlyPrioritized) === "1"
  };

  function saveState() {
    localStorage.setItem(STORAGE_KEYS.activeRoleId, state.activeRoleId);
    localStorage.setItem(STORAGE_KEYS.customRoles, JSON.stringify(state.customRoles));
    localStorage.setItem(STORAGE_KEYS.showOnlyPrioritized, state.showOnlyPrioritized ? "1" : "0");
  }
  function getAllRoles() {
    return { ...ROLE_PRESETS, ...state.customRoles };
  }
  function getActiveRole() {
    const roles = getAllRoles();
    if (!roles[state.activeRoleId]) state.activeRoleId = "combat";
    return roles[state.activeRoleId] || ROLE_PRESETS.combat;
  }
  function findTitleElement(card) {
    for (const el of card.querySelectorAll(TITLE_SELECTORS)) {
      const text = (el.textContent || "").trim();
      if (text.length < 2 || text.length > 80) continue;
      return el;
    }
    return null;
  }
  function getMeritCards() {
    return Array.from(document.querySelectorAll(MERIT_CARD_SELECTORS)).filter((card) => {
      const text = (card.textContent || "").trim();
      if (!(card instanceof HTMLElement)) return false;
      if (text.length < 2 || text.length > 280) return false;

      // Only process real merit rows, not top summary blocks.
      const aria = card.getAttribute("aria-label") || "";
      if (card.tagName === "LI" && /upgraded\s*:/i.test(aria)) return true;

      // Fallback for possible DOM changes: row must include icon + merit info/title.
      const hasIconWrap = !!card.querySelector("div[class*='iconWrap']");
      const hasInfo = !!card.querySelector("div[class*='meritInfo']");
      const hasTitle = !!card.querySelector("p[class*='title']");
      return hasIconWrap && hasInfo && hasTitle;
    });
  }
  function getCardSegments(card) {
    let segments = cardSegmentsCache.get(card);
    if (!segments || segments.some((segment) => !segment.isConnected)) {
      segments = Array.from(card.querySelectorAll("svg[class*='progressBar'] path[class*='segment']"));
      cardSegmentsCache.set(card, segments);
    }
    return segments;
  }
  function clearTargetRing(card) {
    for (const segment of card.querySelectorAll("[data-tmrp-segment='1']")) {
      segment.removeAttribute("style");
      segment.removeAttribute("data-tmrp-segment");
    }
    delete card.dataset.tmrpRingSignature;
  }
  function getPriorityMarkerClass(priority) {
    if (priority === "high") return "p-high";
    if (priority === "medium") return "p-medium";
    return "p-low";
  }
  function hasPlannedRuleData(rule) {
    if (!rule) return false;
    const hasPriority = PRIORITY_ORDER.includes(rule.priority);
    const target = Number.parseInt(rule.target, 10);
    const hasTarget = Number.isInteger(target) && target > 0;
    const hasReason = typeof rule.reason === "string" && rule.reason.trim().length > 0;
    return hasPriority || hasTarget || hasReason;
  }
  function renderTargetRing(card, rule) {
    const segments = getCardSegments(card);
    if (!segments.length) return;
    const target = Number.parseInt(rule?.target, 10);
    if (!Number.isInteger(target) || target < 1 || target > 10) {
      clearTargetRing(card);
      return;
    }

    // Keep Torn's existing filled progress (green) untouched.
    const currentProgressCount = segments.filter((segment) =>
      (segment.className?.baseVal || segment.className || "").includes("progress")
    ).length;
    const ringSignature = `${target}|${currentProgressCount}|${segments.length}`;
    if (card.dataset.tmrpRingSignature === ringSignature) return;
    clearTargetRing(card);

    const targetClamped = Math.min(target, segments.length);
    for (let i = 0; i < segments.length; i += 1) {
      // Only color the "remaining-to-target" part. Never override current progress.
      if (i < currentProgressCount || i >= targetClamped) continue;
      const segment = segments[i];
      segment.dataset.tmrpSegment = "1";
      segment.style.stroke = "var(--tmrp-target-fill)";
      segment.style.opacity = "1";
    }
    card.dataset.tmrpRingSignature = ringSignature;
  }
  function renderTableLegend() {
    const existingLegends = document.querySelectorAll(".tmrp-table-legend");
    if (existingLegends.length > 1) {
      for (let i = 1; i < existingLegends.length; i += 1) existingLegends[i].remove();
    }
    const existingLegend = existingLegends[0];
    if (existingLegend?.isConnected) {
      if (existingLegend.closest("#merits-panel, div[class*='meritsTab']")) return;
      existingLegend.remove();
    }

    const legend = document.createElement("div");
    legend.className = "tmrp-table-legend";
    legend.innerHTML = "Priority line colors: <span class='tmrp-legend-high'>red</span> = high, <span class='tmrp-legend-medium'>yellow</span> = medium, <span class='tmrp-legend-low'>blue</span> = low. <br><br><span class='tmrp-legend-target'>Purple</span> segments show remaining steps to target.";

    // Place legend in merits panel right below top divider and above first merit list.
    const meritsPanel = document.querySelector("#merits-panel, div[class*='meritsTab']");
    const topDivider = meritsPanel?.querySelector("div[class*='blackLine']");
    if (topDivider?.parentElement) {
      topDivider.insertAdjacentElement("afterend", legend);
      return;
    }

    // Fallback for future DOM changes.
    const cards = getMeritCards();
    if (!cards.length) return;
    const firstCard = cards[0];
    const parent = firstCard.parentElement;
    if (!parent) return;
    parent.insertBefore(legend, firstCard);
  }
  function getRuleForMerit(role, meritTitle) {
    const meritKey = normalizeKey(meritTitle);
    if (!meritKey || !role?.rules) return null;
    if (role.rules[meritKey]) return role.rules[meritKey];
    const keys = Object.keys(role.rules);
    const fuzzy = keys.find((k) => meritKey.includes(k) || k.includes(meritKey));
    return fuzzy ? role.rules[fuzzy] : null;
  }
  function ensureEditableRole() {
    if (!ROLE_PRESETS[state.activeRoleId]) return state.activeRoleId;
    const base = getActiveRole();
    const id = `custom_${Date.now()}`;
    state.customRoles[id] = { label: `${base.label} (Custom)`, rules: { ...base.rules } };
    state.activeRoleId = id;
    saveState();
    return id;
  }

  function createRole() {
    openEditModal(true);
  }
  function getMeritTitle(card, titleEl) {
    if (card.dataset.tmrpMeritTitle) return card.dataset.tmrpMeritTitle;
    const raw = (titleEl?.childNodes?.[0]?.textContent || titleEl?.textContent || "").trim();
    const clean = raw.replace(/\s+/g, " ").trim();
    if (clean) card.dataset.tmrpMeritTitle = clean;
    return clean;
  }

  function getCurrentLevel(card) {
    const aria = card.getAttribute("aria-label") || "";
    const match = aria.match(/upgraded:\s*(\d+)\s*\/\s*10/i);
    if (match) return Number.parseInt(match[1], 10) || 0;

    // Fallback when aria-label format changes.
    const title = card.querySelector("p[class*='title']");
    const text = (title?.textContent || "").replace(/\s+/g, " ");
    const textMatch = text.match(/(\d+)\s*\/\s*10/);
    return textMatch ? Number.parseInt(textMatch[1], 10) || 0 : 0;
  }
  function getMeritRowsForEditor() {
    const rows = [];
    const seen = new Set();
    for (const card of getMeritCards()) {
      const titleEl = findTitleElement(card);
      const meritTitle = getMeritTitle(card, titleEl);
      const meritKey = normalizeKey(meritTitle);
      if (!meritKey || seen.has(meritKey)) continue;
      seen.add(meritKey);
      rows.push({ title: meritTitle, key: meritKey, current: getCurrentLevel(card) });
    }
    return rows;
  }
  function pauseObserverDuringModal(isPaused) {
    observerPaused = !!isPaused;
    debugPerf("observerPaused =", observerPaused);
  }
  function shieldPlannerInputEvents(root) {
    if (!root || root.dataset.tmrpEventShield === "1") return;
    root.dataset.tmrpEventShield = "1";
    const stopIfPlannerModalEvent = (event) => {
      const target = event.target;
      if (!(target instanceof Element)) return;
      const modal = target.closest(".tmrp-modal,.tmrp-data-modal");
      if (!modal) return;
      // Keep planner modal events local. This blocks heavy delegated page handlers.
      event.stopPropagation();
      if (event.type === "change" && target.matches("select")) {
        debugPerf("modal select change", {
          className: target.className,
          value: target.value
        });
      }
    };
    // Use bubbling phase so target behavior remains fully native.
    const bubbleEvents = ["click", "mousedown", "mouseup", "pointerdown", "pointerup", "keydown", "keyup", "input", "change"];
    for (const eventName of bubbleEvents) {
      root.addEventListener(eventName, stopIfPlannerModalEvent, false);
    }
    root.addEventListener("pointerdown", (event) => {
      const target = event.target;
      if (!(target instanceof Element)) return;
      const select = target.closest("select");
      if (!select || !select.closest(".tmrp-modal,.tmrp-data-modal")) return;
      debugPerf("modal select pointerdown", {
        className: select.className,
        value: select.value
      });
    }, true);
  }
  function closeEditModal() {
    if (!cachedModal) return;
    cachedModal.classList.remove("is-open");
    if (!isAnyPlannerModalOpen()) pauseObserverDuringModal(false);
  }
  function getRoleNameInputValue() {
    return (cachedModal?.querySelector(".tmrp-role-name-input")?.value || "").trim();
  }
  function isRoleNameTaken(name, ignoreRoleId) {
    const normalized = normalizeKey(name);
    if (!normalized) return false;
    for (const [id, role] of Object.entries(getAllRoles())) {
      if (ignoreRoleId && id === ignoreRoleId) continue;
      if (normalizeKey(role?.label) === normalized) return true;
    }
    return false;
  }
  function validateRoleName(name, ignoreRoleId) {
    if (!name) {
      window.alert("Build name cannot be empty.");
      return false;
    }
    if (isRoleNameTaken(name, ignoreRoleId)) {
      window.alert("A build with this name already exists.");
      return false;
    }
    return true;
  }
  function openEditModal(isNewRoleMode) {
    if (!cachedModal || !cachedModal.isConnected) {
      cachedModal = document.createElement("div");
      cachedModal.className = "tmrp-modal-backdrop";
      cachedModal.innerHTML = `<div class="tmrp-modal" role="dialog" aria-modal="true" aria-label="Edit merit build plan"><div class="tmrp-modal-header"><h5>Edit Build Plan</h5></div><div class="tmrp-modal-role-row"><label>Build Name</label><input type="text" class="tmrp-role-name-input" maxlength="60" placeholder="Type build name"></div><div class="tmrp-modal-body"><table class="tmrp-edit-table"><thead><tr><th>Merit</th><th>Now</th><th>Priority</th><th>Target</th><th>Reason</th></tr></thead><tbody></tbody></table></div><div class="tmrp-modal-actions"><button type="button" class="tmrp-modal-reset">Reset Build</button><button type="button" class="tmrp-modal-delete">Delete Build</button><button type="button" class="tmrp-modal-save-new">Save As New</button><button type="button" class="tmrp-modal-cancel">Cancel</button><button type="button" class="tmrp-modal-save">Save</button></div></div>`;
      document.body.appendChild(cachedModal);
      cachedModal.addEventListener("click", (e) => {
        if (e.target === cachedModal) closeEditModal();
      });
      shieldPlannerInputEvents(cachedModal);
    }

    const role = isNewRoleMode ? { label: "", rules: {} } : getActiveRole();
    const editingRoleId = isNewRoleMode ? "" : state.activeRoleId;
    cachedModal.dataset.mode = isNewRoleMode ? "new" : "existing";
    cachedModal.dataset.editingRoleId = editingRoleId;
    cachedModal.querySelector(".tmrp-role-name-input").value = role?.label || "";

    const tbody = cachedModal.querySelector("tbody");
    const meritRows = getMeritRowsForEditor();
    tbody.innerHTML = meritRows.map((row) => {
      const rule = role?.rules?.[row.key] || null;
      const priority = rule?.priority || "none";
      const target = Number.isInteger(rule?.target) ? rule.target : 0;
      const reason = escapeHtml(rule?.reason || "");
      return `<tr data-merit-key="${row.key}" data-merit-title="${row.title}"><td>${row.title}</td><td>${row.current}/10</td><td><select class="tmrp-priority-input"><option value="none"${priority === "none" ? " selected" : ""}>none</option><option value="high"${priority === "high" ? " selected" : ""}>high</option><option value="medium"${priority === "medium" ? " selected" : ""}>medium</option><option value="low"${priority === "low" ? " selected" : ""}>low</option></select></td><td><input class="tmrp-target-input" type="number" min="0" max="10" step="1" value="${target}"></td><td><input class="tmrp-reason-input" type="text" maxlength="180" placeholder="Optional reason" value="${reason}"></td></tr>`;
    }).join("");

    cachedModal.querySelector(".tmrp-modal-cancel").onclick = closeEditModal;
    cachedModal.querySelector(".tmrp-modal-save").onclick = saveEditModal;
    cachedModal.querySelector(".tmrp-modal-save-new").onclick = saveEditModalAsNew;
    cachedModal.querySelector(".tmrp-modal-delete").onclick = deleteActiveRoleFromModal;
    cachedModal.querySelector(".tmrp-modal-reset").onclick = resetCurrentRoleFromModal;
    const showExistingOnly = !isNewRoleMode;
    const isPredefinedRole = showExistingOnly && !!ROLE_PRESETS[state.activeRoleId];
    cachedModal.querySelector(".tmrp-modal-save-new").hidden = !showExistingOnly;
    cachedModal.querySelector(".tmrp-modal-delete").hidden = !showExistingOnly;
    cachedModal.querySelector(".tmrp-modal-reset").hidden = !isPredefinedRole;
    cachedModal.classList.add("is-open");
    pauseObserverDuringModal(true);
  }
  function collectRulesFromModalRows() {
    const newRules = {};
    const rows = cachedModal?.querySelectorAll("tbody tr[data-merit-key]") || [];
    for (const row of rows) {
      const meritKey = row.getAttribute("data-merit-key") || "";
      const priority = row.querySelector(".tmrp-priority-input")?.value || "none";
      const targetRaw = row.querySelector(".tmrp-target-input")?.value || "0";
      const reasonRaw = row.querySelector(".tmrp-reason-input")?.value || "";
      const target = Math.max(0, Math.min(10, Number.parseInt(targetRaw, 10) || 0));
      const reason = reasonRaw.trim();
      const hasPriority = PRIORITY_ORDER.includes(priority);
      if (!meritKey) continue;
      // Save rows when at least one user field has meaningful content.
      if (!hasPriority && target === 0 && !reason) continue;
      newRules[meritKey] = {
        priority: hasPriority ? priority : "none",
        target,
        reason
      };
    }
    return newRules;
  }
  function saveEditModal() {
    const roleName = getRoleNameInputValue();
    const isNewMode = cachedModal?.dataset.mode === "new";
    const editingRoleId = cachedModal?.dataset.editingRoleId || "";
    const resolvedEditingRoleId = editingRoleId || (!isNewMode ? state.activeRoleId : "");
    const isEditingPredefined = !isNewMode && !!ROLE_PRESETS[resolvedEditingRoleId];
    const ignoreRoleIdForValidation = isNewMode || isEditingPredefined ? "" : resolvedEditingRoleId;
    if (!validateRoleName(roleName, ignoreRoleIdForValidation)) return;

    let roleId;
    if (isNewMode) {
      roleId = `custom_${Date.now()}`;
      state.customRoles[roleId] = { label: roleName, rules: {} };
      state.activeRoleId = roleId;
    } else {
      // For custom builds, update the same id; for presets, create editable copy.
      roleId = isEditingPredefined ? ensureEditableRole() : (resolvedEditingRoleId || ensureEditableRole());
      state.activeRoleId = roleId;
    }
    const role = state.customRoles[roleId];
    role.label = roleName;
    role.rules = collectRulesFromModalRows();
    saveState();
    closeEditModal();
    scheduleProcess(true);
  }
  function saveEditModalAsNew() {
    const label = getRoleNameInputValue();
    const editingRoleId = cachedModal?.dataset.editingRoleId || state.activeRoleId;
    const sourceLabel = getAllRoles()?.[editingRoleId]?.label || "";
    if (normalizeKey(label) === normalizeKey(sourceLabel)) {
      window.alert("Change the build name before using Save As New.");
      return;
    }
    if (!validateRoleName(label, "")) return;
    const id = `custom_${Date.now()}`;
    state.customRoles[id] = { label, rules: collectRulesFromModalRows() };
    state.activeRoleId = id;
    saveState();
    closeEditModal();
    scheduleProcess(true);
  }
  function resetCurrentRoleFromModal() {
    if (!ROLE_PRESETS[state.activeRoleId]) return;
    if (!window.confirm("Reset current predefined build view to default values?")) return;
    // Predefined roles are immutable; this just discards unsaved modal edits.
    closeEditModal();
    scheduleProcess(true);
  }
  function deleteActiveRoleFromModal() {
    if (!state.customRoles[state.activeRoleId]) {
      window.alert("Built-in preset builds cannot be deleted.");
      return;
    }
    if (!window.confirm("Delete current custom build?")) return;
    delete state.customRoles[state.activeRoleId];
    state.activeRoleId = "combat";
    saveState();
    closeEditModal();
    scheduleProcess(true);
  }
  function buildRolePayload() {
    const active = getActiveRole();
    return {
      version: 1,
      exportedAt: new Date().toISOString(),
      build: {
        name: active?.label || "Imported Build",
        rules: { ...(active?.rules || {}) }
      }
    };
  }
  function sanitizeImportedRules(rawRules) {
    const rules = {};
    if (!rawRules || typeof rawRules !== "object") return rules;
    for (const [key, value] of Object.entries(rawRules)) {
      const meritKey = normalizeKey(key);
      const priority = String(value?.priority || "").toLowerCase();
      const targetRaw = Number.parseInt(String(value?.target ?? ""), 10);
      const target = Number.isInteger(targetRaw) ? Math.max(0, Math.min(10, targetRaw)) : 0;
      const hasPriority = PRIORITY_ORDER.includes(priority);
      const reason = typeof value?.reason === "string" ? value.reason.trim() : "";
      if (!meritKey) continue;
      if (!hasPriority && target === 0 && !reason) continue;
      rules[meritKey] = {
        priority: hasPriority ? priority : "none",
        target,
        reason
      };
    }
    return rules;
  }
  function extractRoleNameFromJsonText(rawText) {
    const parsed = safeParse(rawText, null);
    if (!parsed || typeof parsed !== "object") return "";
    const buildData = parsed.build && typeof parsed.build === "object" ? parsed.build : parsed;
    return String(buildData.name || buildData.label || "").trim();
  }
  function importRoleFromJsonText(rawText, providedName) {
    const parsed = safeParse(rawText, null);
    if (!parsed || typeof parsed !== "object") throw new Error("Invalid JSON content.");
    const buildData = parsed.build && typeof parsed.build === "object" ? parsed.build : parsed;
    const importedName = String(buildData.name || buildData.label || "Imported Build").trim() || "Imported Build";
    const importedRules = sanitizeImportedRules(buildData.rules);
    if (!Object.keys(importedRules).length) throw new Error("No valid merit rules found in import.");
    const finalName = String(providedName || importedName).trim() || importedName;

    const id = `custom_${Date.now()}`;
    state.customRoles[id] = { label: finalName, rules: importedRules };
    state.activeRoleId = id;
    saveState();
  }
  function closeImportModal() {
    if (!cachedImportModal) return;
    cachedImportModal.querySelector(".tmrp-data-textarea").value = "";
    cachedImportModal.querySelector(".tmrp-file-name").textContent = "No file selected";
    cachedImportModal.querySelector(".tmrp-import-file").value = "";
    cachedImportModal.querySelector(".tmrp-import-role-name").value = "";
    cachedImportModal.querySelector(".tmrp-file-picker").classList.remove("is-dragging");
    cachedImportModal.classList.remove("is-open");
    if (!isAnyPlannerModalOpen()) pauseObserverDuringModal(false);
  }
  function openImportModal() {
    if (!cachedImportModal || !cachedImportModal.isConnected) {
      cachedImportModal = document.createElement("div");
      cachedImportModal.className = "tmrp-data-modal-backdrop";
      cachedImportModal.innerHTML = `<div class="tmrp-data-modal" role="dialog" aria-modal="true" aria-label="Import merit build"><div class="tmrp-data-modal-header"><h5>Import Build</h5></div><div class="tmrp-data-modal-body"><p>Paste build JSON below or load from file.</p><div class="tmrp-data-role-row"><label>Build Name</label><input type="text" class="tmrp-import-role-name" maxlength="60" placeholder="Type build name"></div><textarea class="tmrp-data-textarea" spellcheck="false" wrap="soft"></textarea><div class="tmrp-data-tools"><input type="file" class="tmrp-import-file" accept=".json,application/json,text/plain"><button type="button" class="tmrp-file-picker"><span class="tmrp-file-picker-icon">📄</span><span class="tmrp-file-picker-label">Click to choose JSON file</span><span class="tmrp-file-name">No file selected</span></button></div></div><div class="tmrp-data-modal-actions"><button type="button" class="tmrp-import-cancel">Cancel</button><button type="button" class="tmrp-import-apply">Import</button></div></div>`;
      document.body.appendChild(cachedImportModal);
      const readImportedFile = (file) => {
        if (!file) return;
        cachedImportModal.querySelector(".tmrp-file-name").textContent = file.name;
        const reader = new FileReader();
        reader.onload = () => {
          const text = String(reader.result || "");
          cachedImportModal.querySelector(".tmrp-data-textarea").value = text;
          const suggestedName = extractRoleNameFromJsonText(text);
          const nameInput = cachedImportModal.querySelector(".tmrp-import-role-name");
          if (suggestedName && !nameInput.value.trim()) nameInput.value = suggestedName;
        };
        reader.readAsText(file);
      };
      cachedImportModal.addEventListener("click", (e) => {
        if (e.target === cachedImportModal) closeImportModal();
      });
      shieldPlannerInputEvents(cachedImportModal);
      cachedImportModal.querySelector(".tmrp-import-cancel").onclick = closeImportModal;
      cachedImportModal.querySelector(".tmrp-data-textarea").addEventListener("input", (e) => {
        const suggestedName = extractRoleNameFromJsonText(e.target.value || "");
        const nameInput = cachedImportModal.querySelector(".tmrp-import-role-name");
        if (suggestedName && !nameInput.value.trim()) nameInput.value = suggestedName;
      });
      cachedImportModal.querySelector(".tmrp-file-picker").onclick = () => cachedImportModal.querySelector(".tmrp-import-file").click();
      cachedImportModal.querySelector(".tmrp-import-file").addEventListener("change", (e) => {
        readImportedFile(e.target.files?.[0]);
      });
      const picker = cachedImportModal.querySelector(".tmrp-file-picker");
      picker.addEventListener("dragover", (e) => {
        e.preventDefault();
        picker.classList.add("is-dragging");
      });
      picker.addEventListener("dragleave", () => {
        picker.classList.remove("is-dragging");
      });
      picker.addEventListener("drop", (e) => {
        e.preventDefault();
        picker.classList.remove("is-dragging");
        readImportedFile(e.dataTransfer?.files?.[0]);
      });
      cachedImportModal.querySelector(".tmrp-import-apply").onclick = () => {
        const text = cachedImportModal.querySelector(".tmrp-data-textarea").value.trim();
        if (!text) return window.alert("Paste JSON content or load a file first.");
        const buildName = (cachedImportModal.querySelector(".tmrp-import-role-name").value || "").trim();
        if (!validateRoleName(buildName, "")) return;
        try {
          importRoleFromJsonText(text, buildName);
          closeImportModal();
          closeEditModal();
          scheduleProcess(true);
        } catch (error) {
          window.alert(`Import failed: ${error.message}`);
        }
      };
    }
    cachedImportModal.querySelector(".tmrp-data-textarea").value = "";
    cachedImportModal.querySelector(".tmrp-file-name").textContent = "No file selected";
    cachedImportModal.querySelector(".tmrp-import-file").value = "";
    cachedImportModal.querySelector(".tmrp-import-role-name").value = "";
    cachedImportModal.querySelector(".tmrp-file-picker").classList.remove("is-dragging");
    cachedImportModal.classList.add("is-open");
    pauseObserverDuringModal(true);
  }
  function closeExportModal() {
    if (!cachedExportModal) return;
    cachedExportModal.classList.remove("is-open");
    if (!isAnyPlannerModalOpen()) pauseObserverDuringModal(false);
  }
  function exportActiveRole() {
    const payload = buildRolePayload();
    const jsonText = JSON.stringify(payload, null, 2);
    if (!cachedExportModal || !cachedExportModal.isConnected) {
      cachedExportModal = document.createElement("div");
      cachedExportModal.className = "tmrp-data-modal-backdrop";
      cachedExportModal.innerHTML = `<div class="tmrp-data-modal" role="dialog" aria-modal="true" aria-label="Export merit build"><div class="tmrp-data-modal-header"><h5>Export Build</h5></div><div class="tmrp-data-modal-body"><p>Copy this JSON or download it as file.</p><textarea class="tmrp-data-textarea" spellcheck="false" readonly wrap="soft"></textarea></div><div class="tmrp-data-modal-actions"><button type="button" class="tmrp-export-copy">Copy</button><button type="button" class="tmrp-export-file">Download</button><button type="button" class="tmrp-export-close">Close</button></div></div>`;
      document.body.appendChild(cachedExportModal);
      cachedExportModal.addEventListener("click", (e) => {
        if (e.target === cachedExportModal) closeExportModal();
      });
      shieldPlannerInputEvents(cachedExportModal);
      cachedExportModal.querySelector(".tmrp-export-close").onclick = closeExportModal;
      cachedExportModal.querySelector(".tmrp-export-copy").onclick = async () => {
        const text = cachedExportModal.querySelector(".tmrp-data-textarea").value;
        try {
          await navigator.clipboard.writeText(text);
          window.alert("Build JSON copied.");
        } catch (_) {
          window.alert("Could not copy automatically. Please copy manually.");
        }
      };
      cachedExportModal.querySelector(".tmrp-export-file").onclick = () => {
        const text = cachedExportModal.querySelector(".tmrp-data-textarea").value;
        const buildName = String(cachedExportModal.dataset.buildName || "build").replace(/[^a-z0-9_-]/gi, "_");
        const blob = new Blob([text], { type: "application/json;charset=utf-8" });
        const url = URL.createObjectURL(blob);
        const anchor = document.createElement("a");
        anchor.href = url;
        anchor.download = `${buildName || "build"}-merit-build.json`;
        document.body.appendChild(anchor);
        anchor.click();
        anchor.remove();
        URL.revokeObjectURL(url);
      };
    }
    cachedExportModal.querySelector(".tmrp-data-textarea").value = jsonText;
    cachedExportModal.dataset.buildName = payload.build?.name || "build";
    cachedExportModal.classList.add("is-open");
    pauseObserverDuringModal(true);
  }

  function renderPanel() {
    const rootHost = document.querySelector("#awards-root");
    const fallbackAnchor = document.querySelector("h4, h3, .title-black, .content-title h4");
    const panelHost = rootHost || fallbackAnchor?.parentElement || null;
    if (!panelHost) return;
    if (!cachedPanel || !cachedPanel.isConnected) {
      cachedPanel = document.createElement("div");
      cachedPanel.className = "tmrp-panel";
      cachedPanel.innerHTML = `<label>Build <select class="tmrp-role"></select></label><button class="tmrp-edit-toggle tmrp-icon-btn" title="Open bulk editor" aria-label="Open bulk editor">✎</button><button class="tmrp-new tmrp-icon-btn" title="Create new build" aria-label="Create new build">+</button><button class="tmrp-import tmrp-icon-btn" title="Import build" aria-label="Import build">⇩</button><button class="tmrp-export tmrp-icon-btn" title="Export build" aria-label="Export build">⇧</button><label class="tmrp-filter"><input type="checkbox" class="tmrp-only"> Show only planned merits</label>`;
      cachedPanelRoleSelect = cachedPanel.querySelector(".tmrp-role");
      cachedPanelOnlyToggle = cachedPanel.querySelector(".tmrp-only");
      cachedPanel.querySelector(".tmrp-edit-toggle").addEventListener("click", () => {
        openEditModal(false);
      });
      cachedPanel.querySelector(".tmrp-new").addEventListener("click", createRole);
      cachedPanel.querySelector(".tmrp-import").addEventListener("click", openImportModal);
      cachedPanel.querySelector(".tmrp-export").addEventListener("click", exportActiveRole);
      cachedPanelRoleSelect.addEventListener("change", (e) => {
        state.activeRoleId = e.target.value;
        saveState();
        // Keep this event short so the native selected value paints instantly.
        scheduleProcess({ immediate: true });
      });
      cachedPanelOnlyToggle.addEventListener("change", (e) => { state.showOnlyPrioritized = !!e.target.checked; saveState(); scheduleProcess({ immediate: true }); });
    }
    // Keep panel mounted at the top of awards root to avoid layout constraints.
    if (cachedPanel.parentElement !== panelHost || panelHost.firstElementChild !== cachedPanel) {
      panelHost.prepend(cachedPanel);
    }
    const select = cachedPanelRoleSelect;
    const only = cachedPanelOnlyToggle;
    const roles = getAllRoles();
    const rolesSignature = Object.entries(roles).map(([id, role]) => `${id}:${role.label}`).join("|");
    if (rolesSignature !== panelRoleOptionsSignature) {
      panelRoleOptionsSignature = rolesSignature;
      select.innerHTML = Object.entries(roles).map(([id, role]) => `<option value="${id}">${role.label}</option>`).join("");
    }
    const desiredRoleId = state.activeRoleId in roles ? state.activeRoleId : "combat";
    if (select.value !== desiredRoleId) select.value = desiredRoleId;
    if (only.checked !== state.showOnlyPrioritized) only.checked = state.showOnlyPrioritized;
  }

  function annotateMerits() {
    const role = getActiveRole();
    const handledCards = [];
    const seenMeritKeys = new Set();

    for (const card of getMeritCards()) {
      // Selector list can match nested wrappers. Handle only first (outer) card.
      if (handledCards.some((root) => root.contains(card))) continue;

      let meritTitle = card.dataset.tmrpMeritTitle || "";
      if (!meritTitle) {
        const titleEl = findTitleElement(card);
        meritTitle = getMeritTitle(card, titleEl);
      }
      if (!meritTitle) continue;

      const meritKey = normalizeKey(meritTitle);
      if (!meritKey || seenMeritKeys.has(meritKey)) continue;
      seenMeritKeys.add(meritKey);

      handledCards.push(card);

      const rule = getRuleForMerit(role, meritTitle);
      if (!rule) {
        card.classList.toggle("tmrp-hidden", state.showOnlyPrioritized);
        if (card.classList.contains("tmrp-priority-row") || card.classList.contains("p-high") || card.classList.contains("p-medium") || card.classList.contains("p-low")) {
          card.classList.remove("tmrp-priority-row", "p-high", "p-medium", "p-low");
        }
        clearTargetRing(card);
        continue;
      }

      const isPrioritized = PRIORITY_ORDER.includes(rule.priority);
      const shouldShowWhenFiltered = hasPlannedRuleData(rule);
      if (isPrioritized) {
        const desiredPriorityClass = getPriorityMarkerClass(rule.priority);
        card.classList.add("tmrp-priority-row");
        if (!card.classList.contains(desiredPriorityClass)) {
          card.classList.remove("p-high", "p-medium", "p-low");
          card.classList.add(desiredPriorityClass);
        }
      } else {
        card.classList.remove("p-high", "p-medium", "p-low");
        card.classList.remove("tmrp-priority-row");
      }
      renderTargetRing(card, rule);
      card.classList.toggle("tmrp-hidden", state.showOnlyPrioritized && !shouldShowWhenFiltered);
    }
  }

  function processPage() {
    if (!isMeritsPage()) {
      clearPlannerUiForNonMerits();
      return;
    }
    isProcessingPage = true;
    try {
      withPerfLabel("processPage", () => {
        applyThemeMode();
        renderPanel();
        renderTableLegend();
        annotateMerits();
      });
    } finally {
      isProcessingPage = false;
    }
  }
  function isAnyPlannerModalOpen() {
    return !!(
      cachedModal?.classList.contains("is-open") ||
      cachedImportModal?.classList.contains("is-open") ||
      cachedExportModal?.classList.contains("is-open")
    );
  }
  function scheduleProcess(options) {
    const immediate = !!(typeof options === "object" ? options.immediate : options);
    const force = !!(typeof options === "object" ? options.force : false);
    window.clearTimeout(processTimer);
    if (!force && isAnyPlannerModalOpen()) return;
    if (immediate) {
      debugPerf("scheduleProcess immediate");
      processTimer = window.setTimeout(processPage, 0);
      return;
    }
    debugPerf("scheduleProcess debounced");
    processTimer = window.setTimeout(processPage, RENDER_DEBOUNCE_MS);
  }
  function installStyles() {
    if (document.getElementById("tmrp-style")) return;
    const style = document.createElement("style");
    style.id = "tmrp-style";
    style.textContent = `:root[data-tmrp-theme='dark']{
      --tmrp-panel-bg:rgba(0,0,0,.24);
      --tmrp-panel-border:rgba(130,130,130,.35);
      --tmrp-control-bg:rgba(0,0,0,.35);
      --tmrp-control-border:rgba(150,150,150,.4);
      --tmrp-control-text:#e4e4e4;
      --tmrp-toggle-on-bg:rgba(67,128,85,.5);
      --tmrp-toggle-on-border:rgba(120,220,150,.6);
      --tmrp-target-fill:#ae7cff;
      --tmrp-target-empty:rgba(100,110,125,.45);
      --tmrp-priority-high:#ff6f6f;
      --tmrp-priority-medium:#ffd45a;
      --tmrp-priority-low:#71b8ff;
      --tmrp-modal-bg:#1f2228;
      --tmrp-modal-border:rgba(150,150,150,.45);
      --tmrp-modal-overlay:rgba(0,0,0,.74);
      --tmrp-modal-row-bg:rgba(255,255,255,.03);
      --tmrp-modal-row-alt-bg:rgba(255,255,255,.06);
      --tmrp-option-bg:#2a2f38;
      --tmrp-option-text:#f0f0f0;
    }
    :root[data-tmrp-theme='light']{
      --tmrp-panel-bg:rgba(255,255,255,.72);
      --tmrp-panel-border:rgba(90,90,90,.25);
      --tmrp-control-bg:rgba(255,255,255,.92);
      --tmrp-control-border:rgba(90,90,90,.3);
      --tmrp-control-text:#252525;
      --tmrp-toggle-on-bg:rgba(130,214,150,.55);
      --tmrp-toggle-on-border:rgba(40,120,70,.55);
      --tmrp-target-fill:#8a5ad4;
      --tmrp-target-empty:rgba(120,130,145,.45);
      --tmrp-priority-high:#e55353;
      --tmrp-priority-medium:#c9a21f;
      --tmrp-priority-low:#3b8fdb;
      --tmrp-modal-bg:#ffffff;
      --tmrp-modal-border:rgba(90,90,90,.35);
      --tmrp-modal-overlay:rgba(0,0,0,.55);
      --tmrp-modal-row-bg:rgba(0,0,0,.015);
      --tmrp-modal-row-alt-bg:rgba(0,0,0,.035);
      --tmrp-option-bg:#ffffff;
      --tmrp-option-text:#1f1f1f;
    }
    .tmrp-panel{margin:8px 0;padding:12px 10px;min-height:54px;border:1px solid var(--tmrp-panel-border);background:var(--tmrp-panel-bg);border-radius:6px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;color:inherit}
    .tmrp-panel > label{display:flex;align-items:center;gap:6px}
    .tmrp-panel button,.tmrp-panel select{height:32px;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px}
    .tmrp-panel select,.tmrp-edit-table select{appearance:auto;-webkit-appearance:auto;-moz-appearance:auto}
    .tmrp-panel select option,.tmrp-edit-table select option{background:var(--tmrp-option-bg);color:var(--tmrp-option-text)}
    .tmrp-panel button{padding:0 8px;cursor:pointer}
    .tmrp-panel .tmrp-icon-btn{min-width:32px;height:32px;padding:0 6px;line-height:1;font-size:13px}
    .tmrp-filter{margin-left:8px;font-size:12px}
    .tmrp-table-legend{font-size:11px;opacity:.9;padding:4px 10px 6px;color:var(--tmrp-control-text)}
    .tmrp-table-legend .tmrp-legend-high{color:var(--tmrp-priority-high);font-weight:700}
    .tmrp-table-legend .tmrp-legend-medium{color:var(--tmrp-priority-medium);font-weight:700}
    .tmrp-table-legend .tmrp-legend-low{color:var(--tmrp-priority-low);font-weight:700}
    .tmrp-table-legend .tmrp-legend-target{color:var(--tmrp-target-fill);font-weight:700}
    .tmrp-priority-row.p-high{box-shadow:inset 5px 0 0 var(--tmrp-priority-high)}
    .tmrp-priority-row.p-medium{box-shadow:inset 5px 0 0 var(--tmrp-priority-medium)}
    .tmrp-priority-row.p-low{box-shadow:inset 5px 0 0 var(--tmrp-priority-low)}
    .tmrp-modal-backdrop{position:fixed;inset:0;background:var(--tmrp-modal-overlay);z-index:99999;display:none;align-items:center;justify-content:center;padding:16px}
    .tmrp-modal-backdrop.is-open{display:flex}
    .tmrp-modal{width:min(940px,95vw);max-height:88vh;overflow:auto;background:var(--tmrp-modal-bg);border:1px solid var(--tmrp-modal-border);border-radius:8px;color:var(--tmrp-control-text);padding:10px}
    .tmrp-modal-header h5{margin:0 0 8px 0;font-size:14px}
    .tmrp-modal-role-row{display:flex;align-items:center;gap:8px;margin:0 0 8px 0}
    .tmrp-modal-role-row label{font-size:12px;min-width:70px}
    .tmrp-modal-role-row .tmrp-role-name-input{width:100%;max-width:320px;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:4px 6px}
    .tmrp-modal-body{max-height:65vh;overflow:auto}
    .tmrp-edit-table{width:100%;min-width:0;border-collapse:collapse;font-size:12px}
    .tmrp-edit-table th,.tmrp-edit-table td{padding:6px;border-bottom:1px solid var(--tmrp-control-border);text-align:left;color:var(--tmrp-control-text)}
    .tmrp-edit-table thead th{background:var(--tmrp-modal-row-alt-bg);position:sticky;top:0;z-index:1}
    .tmrp-edit-table tbody tr{background:var(--tmrp-modal-row-bg)}
    .tmrp-edit-table tbody tr:nth-child(even){background:var(--tmrp-modal-row-alt-bg)}
    .tmrp-edit-table input,.tmrp-edit-table select{width:100%;max-width:110px;box-sizing:border-box;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:2px 4px}
    .tmrp-edit-table .tmrp-reason-input{max-width:none;min-width:0}
    .tmrp-modal-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:10px}
    .tmrp-modal-actions button{font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:4px 10px;cursor:pointer}
    .tmrp-data-modal-backdrop{position:fixed;inset:0;background:var(--tmrp-modal-overlay);z-index:100000;display:none;align-items:center;justify-content:center;padding:16px}
    .tmrp-data-modal-backdrop.is-open{display:flex}
    .tmrp-data-modal{width:min(760px,95vw);max-height:88vh;overflow:auto;background:var(--tmrp-modal-bg);border:1px solid var(--tmrp-modal-border);border-radius:8px;color:var(--tmrp-control-text);padding:10px;box-sizing:border-box}
    .tmrp-data-modal-header h5{margin:0 0 8px 0;font-size:14px}
    .tmrp-data-modal-body p{margin:0 0 8px 0;font-size:12px}
    .tmrp-data-role-row{display:flex;align-items:center;gap:8px;margin:0 0 8px 0}
    .tmrp-data-role-row label{font-size:12px;min-width:70px}
    .tmrp-data-role-row .tmrp-import-role-name{width:100%;max-width:320px;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:4px 6px}
    .tmrp-data-textarea{width:100%;min-height:220px;resize:vertical;font-family:Consolas,Monaco,monospace;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:6px;box-sizing:border-box;overflow-x:hidden;white-space:pre-wrap;word-break:break-word}
    .tmrp-data-tools{display:flex;gap:8px;align-items:center;margin-top:8px}
    .tmrp-data-tools .tmrp-import-file{display:none}
    .tmrp-data-tools .tmrp-file-picker{width:100%;display:flex;align-items:center;gap:10px;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-modal-row-bg);border:1px dashed var(--tmrp-control-border);border-radius:6px;padding:10px 12px;cursor:pointer;text-align:left}
    .tmrp-data-tools .tmrp-file-picker:hover{background:var(--tmrp-modal-row-alt-bg)}
    .tmrp-data-tools .tmrp-file-picker.is-dragging{border-style:solid;border-color:var(--tmrp-target-fill);box-shadow:0 0 0 1px var(--tmrp-target-fill) inset}
    .tmrp-data-tools .tmrp-file-picker-icon{font-size:16px;line-height:1}
    .tmrp-data-tools .tmrp-file-picker-label{flex:0 0 auto;opacity:.9}
    .tmrp-data-tools .tmrp-file-name{margin-left:auto;font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:3px 8px;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
    .tmrp-data-modal-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;margin-top:10px}
    .tmrp-data-modal-actions button{font-size:12px;color:var(--tmrp-control-text);background:var(--tmrp-control-bg);border:1px solid var(--tmrp-control-border);border-radius:4px;padding:4px 10px;cursor:pointer}
    .tmrp-hidden{display:none !important}
    @media (max-width: 760px){
      .tmrp-panel{padding:10px 8px;min-height:0;gap:4px}
      .tmrp-panel > label:first-child{display:grid;grid-template-columns:auto 1fr;align-items:center;column-gap:6px;flex:1 1 0;min-width:0}
      .tmrp-panel > label:first-child select{min-width:0;width:100%}
      .tmrp-panel .tmrp-icon-btn{flex:0 0 28px;min-width:28px;height:28px;padding:0}
      .tmrp-filter{order:10;flex:1 0 100%;margin-left:0;font-size:11px}
      .tmrp-modal-backdrop{padding:8px}
      .tmrp-modal{width:96vw;max-height:94vh;padding:8px}
      .tmrp-modal-header h5{font-size:13px}
      .tmrp-modal-role-row label{min-width:62px;font-size:11px}
      .tmrp-modal-role-row .tmrp-role-name-input{max-width:none;font-size:11px}
      .tmrp-modal-body{max-height:70vh}
      .tmrp-edit-table{min-width:0;font-size:11px}
      .tmrp-edit-table th,.tmrp-edit-table td{padding:5px}
      .tmrp-edit-table input,.tmrp-edit-table select{max-width:86px;font-size:11px}
      .tmrp-modal-actions{position:sticky;bottom:0;background:var(--tmrp-modal-bg);padding-top:8px;border-top:1px solid var(--tmrp-control-border)}
      .tmrp-data-modal-backdrop{padding:8px}
      .tmrp-data-modal{width:96vw;max-height:94vh;padding:8px}
      .tmrp-data-role-row label{min-width:62px;font-size:11px}
      .tmrp-data-role-row .tmrp-import-role-name{max-width:none;font-size:11px}
      .tmrp-data-textarea{min-height:180px;font-size:11px}
      .tmrp-data-modal-actions{position:sticky;bottom:0;background:var(--tmrp-modal-bg);padding-top:8px;border-top:1px solid var(--tmrp-control-border)}
      .tmrp-data-tools{flex-wrap:wrap}
      .tmrp-data-tools .tmrp-file-picker{flex-wrap:wrap;gap:6px}
      .tmrp-data-tools .tmrp-file-name{max-width:100%;width:100%;margin-left:0}
    }`;
    document.head.appendChild(style);
  }
  function startObservers() {
    function isPlannerNode(node) {
      if (!(node instanceof Element)) return false;
      if (node.id === "tmrp-style") return true;
      if (node.classList?.contains("tmrp-panel")) return true;
      if (node.classList?.contains("tmrp-modal-backdrop")) return true;
      if (node.classList?.contains("tmrp-data-modal-backdrop")) return true;
      if (node.classList?.contains("tmrp-table-legend")) return true;
      return !!node.closest?.(".tmrp-panel,.tmrp-modal-backdrop,.tmrp-data-modal-backdrop,.tmrp-table-legend");
    }
    function hasExternalMutation(mutations) {
      for (const mutation of mutations) {
        if (!isPlannerNode(mutation.target)) return true;
        for (const node of mutation.addedNodes) {
          if (!isPlannerNode(node)) return true;
        }
        for (const node of mutation.removedNodes) {
          if (!isPlannerNode(node)) return true;
        }
      }
      return false;
    }
    if (mutationObserver) mutationObserver.disconnect();
    mutationObserver = new MutationObserver((mutations) => {
      if (observerPaused || isAnyPlannerModalOpen()) return;
      if (isProcessingPage) return;
      if (!hasExternalMutation(mutations)) return;
      withPerfLabel("mutationObserver callback", () => {
        scheduleProcess();
      });
    });
    mutationObserver.observe(document.body, { childList: true, subtree: true });
    window.setInterval(() => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        scheduleProcess({ force: true });
      }
    }, 700);
  }

  installStyles();
  if (DEBUG_PERF && "PerformanceObserver" in window) {
    try {
      const longTaskObserver = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          debugPerf(`Long task ${entry.duration.toFixed(1)}ms`, entry.name || "unknown");
        }
      });
      longTaskObserver.observe({ type: "longtask", buffered: true });
      debugPerf("debug mode enabled");
    } catch (_) {
      debugPerf("longtask observer not available");
    }
  }
  processPage();
  startObservers();
})();