Background for c.ai

Custom backgrounds for Character.AI: URL / Upload (IndexedDB) / Unsplash (search+browse+select), overlay, modes, sticky scroll, import/export, optional per-chat backgrounds.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Background for c.ai
// @namespace    c.ai Background Image Customizer
// @match        https://character.ai/*
// @run-at       document-idle
// @grant        none
// @license      MIT
// @version      2.2.0
// @description  Custom backgrounds for Character.AI: URL / Upload (IndexedDB) / Unsplash (search+browse+select), overlay, modes, sticky scroll, import/export, optional per-chat backgrounds.
// @icon         https://i.imgur.com/ynjBqKW.png
// ==/UserScript==

(() => {
  "use strict";

  const VERSION = "2.2.0";

  // Backward compatible with v2.0/v2.1 global prefs
  const LS_KEY_GLOBAL = "cai_bg_customizer_v2";
  // New: per-chat overrides live here
  const LS_KEY_CHAT_PREFIX = "cai_bg_customizer_v2_chat_";

  // Key storage (encrypted-at-rest if possible)
  const LS_SECRET_UNSPLASH = "cai_bg_customizer_v2_unsplash_secret_v1";
  const LS_SECRET_FALLBACK = "cai_bg_customizer_v2_unsplash_key_plain_fallback";

  // IndexedDB for uploaded images + crypto key
  const DB_NAME = "caiBgCustomizer";
  const DB_VERSION = 1;
  const STORE_IMAGES = "images";
  const STORE_KEYS = "keys";

  // Global-only keys (never per chat)
  const GLOBAL_ONLY_KEYS = new Set(["enabled", "blurBehindUI", "dimUI", "perChatEnabled"]);

  // Per-chat keys (overrides apply only when perChatEnabled && chatId)
  const CHAT_KEYS = new Set([
    "imageSource",
    "imageUrl",
    "uploadImageId",
    "uploadImageName",
    "unsplashSelected",
    "overlayOpacity",
    "attachment",
    "position",
    "imageType",
  ]);

  const DEFAULTS = {
    // Global-only
    enabled: true,
    blurBehindUI: false,
    dimUI: false,
    perChatEnabled: false,

    // Image source selection (can be per chat)
    imageSource: "url", // "url" | "upload" | "unsplash"

    // URL source
    imageUrl: "",

    // Upload source
    uploadImageId: null,
    uploadImageName: "",

    // Unsplash
    unsplashSelected: null, // { id, regularUrl, fullUrl, thumbUrl, userName, userProfile, attributionText, downloadLocation } | null

    // Background behavior
    overlayOpacity: 0.35, // 0..1
    attachment: "fixed",  // "fixed" | "scroll"
    position: "center center",
    imageType: "Stretch", // "Stretch" | "Distort" | "ContainSingle" | "ContainRepeat"
  };

  /** ---------------------------
   *  Utilities
   *  --------------------------- */
  const clamp = (n, min, max) => Math.min(max, Math.max(min, n));

  function safeQS(sel) {
    try { return document.querySelector(sel); } catch { return null; }
  }

  function looksLikeUrl(s) {
    if (!s) return true;
    const v = String(s).trim();
    if (!v) return true;
    if (v.startsWith("data:image/")) return true;
    try {
      const u = new URL(v, location.href);
      return u.protocol === "http:" || u.protocol === "https:";
    } catch {
      return false;
    }
  }

  function uid(prefix = "id") {
    return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
  }

  function bufToB64(buf) {
    const bytes = new Uint8Array(buf);
    let binary = "";
    const chunk = 0x8000;
    for (let i = 0; i < bytes.length; i += chunk) {
      binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
    }
    return btoa(binary);
  }

  function b64ToBuf(b64) {
    const bin = atob(b64);
    const bytes = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
    return bytes.buffer;
  }

  async function blobToDataUrl(blob) {
    return await new Promise((resolve, reject) => {
      const r = new FileReader();
      r.onload = () => resolve(String(r.result));
      r.onerror = () => reject(r.error || new Error("FileReader failed"));
      r.readAsDataURL(blob);
    });
  }

  async function dataUrlToBlob(dataUrl) {
    const res = await fetch(dataUrl);
    return await res.blob();
  }

  /** ---------------------------
   *  Chat ID from URL
   *  --------------------------- */
  function getChatIdFromUrl() {
    const m = location.pathname.match(/^\/chat\/([^\/?#]+)/);
    return m ? m[1] : null;
  }

  let currentChatId = getChatIdFromUrl();

  /** ---------------------------
   *  Global + per-chat storage
   *  --------------------------- */
  function migrateSettings(s) {
    const merged = { ...DEFAULTS, ...(s || {}) };

    // sanity
    if (!["url", "upload", "unsplash"].includes(merged.imageSource)) merged.imageSource = "url";
    if (!["fixed", "scroll"].includes(merged.attachment)) merged.attachment = "fixed";
    if (!["Stretch", "Distort", "ContainSingle", "ContainRepeat"].includes(merged.imageType)) merged.imageType = "Stretch";

    merged.overlayOpacity = clamp(Number(merged.overlayOpacity ?? DEFAULTS.overlayOpacity), 0, 1);
    merged.position = (merged.position || DEFAULTS.position).trim() || DEFAULTS.position;

    merged.enabled = !!merged.enabled;
    merged.blurBehindUI = !!merged.blurBehindUI;
    merged.dimUI = !!merged.dimUI;
    merged.perChatEnabled = !!merged.perChatEnabled;

    return merged;
  }

  function loadGlobalSettings() {
    try {
      const raw = localStorage.getItem(LS_KEY_GLOBAL);
      if (!raw) return { ...DEFAULTS };
      return migrateSettings(JSON.parse(raw));
    } catch {
      return { ...DEFAULTS };
    }
  }

  function saveGlobalSettings(next) {
    localStorage.setItem(LS_KEY_GLOBAL, JSON.stringify(next));
  }

  function loadChatOverrides(chatId) {
    if (!chatId) return {};
    try {
      const raw = localStorage.getItem(LS_KEY_CHAT_PREFIX + chatId);
      if (!raw) return {};
      const parsed = JSON.parse(raw);
      // only allow known keys
      const clean = {};
      for (const k of Object.keys(parsed || {})) {
        if (CHAT_KEYS.has(k)) clean[k] = parsed[k];
      }
      return clean;
    } catch {
      return {};
    }
  }

  function saveChatOverrides(chatId, overrides) {
    if (!chatId) return;
    const clean = {};
    for (const k of Object.keys(overrides || {})) {
      if (CHAT_KEYS.has(k)) clean[k] = overrides[k];
    }
    localStorage.setItem(LS_KEY_CHAT_PREFIX + chatId, JSON.stringify(clean));
  }

  function clearChatOverrides(chatId) {
    if (!chatId) return;
    localStorage.removeItem(LS_KEY_CHAT_PREFIX + chatId);
  }

  function clearAllChatOverrides() {
    const keys = [];
    for (let i = 0; i < localStorage.length; i++) {
      const k = localStorage.key(i);
      if (k && k.startsWith(LS_KEY_CHAT_PREFIX)) keys.push(k);
    }
    keys.forEach((k) => localStorage.removeItem(k));
  }

  function getAllChatOverridesMap() {
    const out = {};
    for (let i = 0; i < localStorage.length; i++) {
      const k = localStorage.key(i);
      if (!k || !k.startsWith(LS_KEY_CHAT_PREFIX)) continue;
      const chatId = k.slice(LS_KEY_CHAT_PREFIX.length);
      try {
        out[chatId] = JSON.parse(localStorage.getItem(k) || "{}") || {};
      } catch {
        out[chatId] = {};
      }
    }
    return out;
  }

  let globalSettings = loadGlobalSettings();

  function isChatScoped() {
    return !!(globalSettings.perChatEnabled && currentChatId);
  }

  function getEffectiveSettings() {
    if (!isChatScoped()) return globalSettings;
    const overrides = loadChatOverrides(currentChatId);
    return migrateSettings({ ...globalSettings, ...overrides });
  }

  /** ---------------------------
   *  IndexedDB (uploads + crypto key)
   *  --------------------------- */
  let dbPromise = null;

  function openDB() {
    if (dbPromise) return dbPromise;
    dbPromise = new Promise((resolve, reject) => {
      const req = indexedDB.open(DB_NAME, DB_VERSION);
      req.onupgradeneeded = () => {
        const db = req.result;
        if (!db.objectStoreNames.contains(STORE_IMAGES)) db.createObjectStore(STORE_IMAGES, { keyPath: "id" });
        if (!db.objectStoreNames.contains(STORE_KEYS)) db.createObjectStore(STORE_KEYS, { keyPath: "name" });
      };
      req.onsuccess = () => resolve(req.result);
      req.onerror = () => reject(req.error || new Error("IndexedDB open failed"));
    });
    return dbPromise;
  }

  async function idbPut(store, value) {
    const db = await openDB();
    return await new Promise((resolve, reject) => {
      const tx = db.transaction(store, "readwrite");
      tx.oncomplete = () => resolve(true);
      tx.onerror = () => reject(tx.error || new Error("IDB transaction failed"));
      tx.objectStore(store).put(value);
    });
  }

  async function idbGet(store, key) {
    const db = await openDB();
    return await new Promise((resolve, reject) => {
      const tx = db.transaction(store, "readonly");
      const req = tx.objectStore(store).get(key);
      req.onsuccess = () => resolve(req.result || null);
      req.onerror = () => reject(req.error || new Error("IDB get failed"));
    });
  }

  async function idbDelete(store, key) {
    const db = await openDB();
    return await new Promise((resolve, reject) => {
      const tx = db.transaction(store, "readwrite");
      tx.oncomplete = () => resolve(true);
      tx.onerror = () => reject(tx.error || new Error("IDB delete failed"));
      tx.objectStore(store).delete(key);
    });
  }

  async function storeUploadedBlob({ id, name, type, blob }) {
    const rec = {
      id: id || uid("img"),
      name: name || "",
      type: type || blob.type || "image/*",
      size: blob.size || 0,
      updatedAt: Date.now(),
      blob
    };
    await idbPut(STORE_IMAGES, rec);
    return rec;
  }

  async function storeUploadedFile(file, forcedId = null) {
    return await storeUploadedBlob({ id: forcedId, name: file.name || "", type: file.type || "image/*", blob: file });
  }

  async function getUploadedImageBlob(id) {
    if (!id) return null;
    const rec = await idbGet(STORE_IMAGES, id);
    return rec?.blob || null;
  }

  /** ---------------------------
   *  Unsplash key: encrypted-at-rest if possible
   *  --------------------------- */
  async function getAesKey() {
    if (!crypto?.subtle) return null;

    const existing = await idbGet(STORE_KEYS, "aes_gcm_v1").catch(() => null);
    if (existing?.key) return existing.key;

    const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
    await idbPut(STORE_KEYS, { name: "aes_gcm_v1", key }).catch(() => {});
    return key;
  }

  async function encryptSecret(plaintext) {
    const key = await getAesKey();
    if (!key) return null;

    const iv = crypto.getRandomValues(new Uint8Array(12));
    const enc = new TextEncoder().encode(plaintext);
    const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc);
    return { iv: bufToB64(iv.buffer), ct: bufToB64(ct) };
  }

  async function decryptSecret(payload) {
    const key = await getAesKey();
    if (!key) return null;
    const iv = new Uint8Array(b64ToBuf(payload.iv));
    const ct = b64ToBuf(payload.ct);
    const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
    return new TextDecoder().decode(pt);
  }

  let unsplashAccessKeyCache = null;

  async function loadUnsplashKey() {
    if (unsplashAccessKeyCache !== null) return unsplashAccessKeyCache;

    try {
      const raw = localStorage.getItem(LS_SECRET_UNSPLASH);
      if (raw) {
        const payload = JSON.parse(raw);
        const key = await decryptSecret(payload);
        if (key) {
          unsplashAccessKeyCache = key;
          return key;
        }
      }
    } catch {}

    const fallback = localStorage.getItem(LS_SECRET_FALLBACK);
    unsplashAccessKeyCache = fallback ? String(fallback) : "";
    return unsplashAccessKeyCache;
  }

  async function saveUnsplashKey(nextKey) {
    unsplashAccessKeyCache = String(nextKey || "").trim();
    localStorage.removeItem(LS_SECRET_UNSPLASH);
    localStorage.removeItem(LS_SECRET_FALLBACK);

    if (!unsplashAccessKeyCache) return;

    const enc = await encryptSecret(unsplashAccessKeyCache);
    if (enc) localStorage.setItem(LS_SECRET_UNSPLASH, JSON.stringify(enc));
    else localStorage.setItem(LS_SECRET_FALLBACK, unsplashAccessKeyCache);
  }

  /** ---------------------------
   *  Unsplash API
   *  --------------------------- */
  async function unsplashFetch(path, params = {}) {
    const key = await loadUnsplashKey();
    if (!key) throw new Error("Missing Unsplash Access Key");

    const url = new URL(`https://api.unsplash.com${path}`);
    Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, String(v)));

    const res = await fetch(url.toString(), { headers: { Authorization: `Client-ID ${key}` } });
    if (!res.ok) {
      const text = await res.text().catch(() => "");
      throw new Error(`Unsplash error ${res.status}: ${text.slice(0, 200)}`);
    }
    return await res.json();
  }

  async function unsplashSearch(query) {
    return await unsplashFetch("/search/photos", { query, per_page: 24, orientation: "landscape" });
  }

  async function unsplashBrowse() {
    return await unsplashFetch("/photos", { per_page: 24, order_by: "popular" });
  }

  async function unsplashTrackDownload(downloadLocation) {
    const key = await loadUnsplashKey();
    if (!key || !downloadLocation) return;
    try {
      await fetch(downloadLocation, { headers: { Authorization: `Client-ID ${key}` } });
    } catch {}
  }

  function normalizeUnsplashItem(item) {
    const userName = item?.user?.name || item?.user?.username || "Unknown";
    const userProfile = item?.user?.links?.html || item?.user?.portfolio_url || "";
    const attributionText = `Photo by ${userName} on Unsplash`;

    return {
      id: item.id,
      regularUrl: item?.urls?.regular || "",
      fullUrl: item?.urls?.full || "",
      thumbUrl: item?.urls?.thumb || item?.urls?.small || "",
      userName,
      userProfile,
      attributionText,
      downloadLocation: item?.links?.download_location || ""
    };
  }

  /** ---------------------------
   *  DOM target selection (userstyle-inspired)
   *  --------------------------- */
  function findAppContainer() {
    const primary = safeQS("body > div > div:has(main)");
    if (primary) return primary;

    const byMain = safeQS("main")?.closest("div");
    if (byMain) return byMain;

    return safeQS("#__next") || safeQS("[id*='root']") || document.body;
  }

  /** ---------------------------
   *  Effective image URL resolution
   *  --------------------------- */
  let uploadObjectUrl = null;
  let uploadObjectUrlId = null;

  async function getEffectiveImageUrl(eff) {
    if (!eff.enabled) return "";

    if (eff.imageSource === "url") {
      const url = (eff.imageUrl || "").trim();
      return looksLikeUrl(url) ? url : "";
    }

    if (eff.imageSource === "upload") {
      const id = eff.uploadImageId;
      if (!id) return "";
      if (uploadObjectUrl && uploadObjectUrlId === id) return uploadObjectUrl;

      if (uploadObjectUrl) {
        try { URL.revokeObjectURL(uploadObjectUrl); } catch {}
        uploadObjectUrl = null;
        uploadObjectUrlId = null;
      }

      const blob = await getUploadedImageBlob(id);
      if (!blob) return "";
      uploadObjectUrl = URL.createObjectURL(blob);
      uploadObjectUrlId = id;
      return uploadObjectUrl;
    }

    if (eff.imageSource === "unsplash") {
      const sel = eff.unsplashSelected;
      if (!sel) return "";
      return (sel.regularUrl || sel.fullUrl || "").trim();
    }

    return "";
  }

  /** ---------------------------
   *  CSS injection
   *  --------------------------- */
  const STYLE_ID = "cai-bg-customizer-style-v22";

  function computeBackgroundCss(eff, imageUrl) {
    if (!eff.enabled || !imageUrl) {
      return `
        :root { --cai-bg-enabled: 0; }
        .cai-bg-target { background: none !important; }
      `;
    }

    const overlay = clamp(Number(eff.overlayOpacity ?? 0.35), 0, 1);
    const attach = eff.attachment === "scroll" ? "scroll" : "fixed";
    const pos = (eff.position || "center center").trim() || "center center";

    let size = "cover";
    let repeat = "no-repeat";

    switch (eff.imageType) {
      case "Distort":
        size = "100vw 100vh";
        repeat = "no-repeat";
        break;
      case "ContainSingle":
        size = "contain";
        repeat = "no-repeat";
        break;
      case "ContainRepeat":
        size = "contain";
        repeat = "repeat";
        break;
      case "Stretch":
      default:
        size = "cover";
        repeat = "no-repeat";
        break;
    }

    const safeUrl = String(imageUrl).replace(/"/g, '\\"');

    return `
      :root {
        --cai-bg-enabled: 1;
        --cai-bg-overlay: ${overlay};
        --cai-bg-attachment: ${attach};
        --cai-bg-position: ${pos};
        --cai-bg-size: ${size};
        --cai-bg-repeat: ${repeat};
      }

      .cai-bg-target {
        background-image:
          linear-gradient(
            rgba(0,0,0,var(--cai-bg-overlay)),
            rgba(0,0,0,var(--cai-bg-overlay))
          ),
          url("${safeUrl}") !important;
        background-attachment: var(--cai-bg-attachment) !important;
        background-position: var(--cai-bg-position) !important;
        background-size: var(--cai-bg-size) !important;
        background-repeat: var(--cai-bg-repeat) !important;
      }
    `;
  }

  function computeUiOverrideCss(eff) {
    const disableBlur = !eff.blurBehindUI;
    const dimUI = !!eff.dimUI;

    return `
      #cai-bg-ui-host { position: fixed; inset: 0; pointer-events: none; z-index: 2147483647; }
      #cai-bg-ui { pointer-events: auto; }

      ${disableBlur ? `
      .max-w-2xl:has(textarea) > * { backdrop-filter: none !important; }
      ` : ""}

      .max-w-2xl:has(textarea) > * {
        background: transparent !important;
      }

      main > div:has(title) > div > div.pb-4 {
        background-color: transparent !important;
      }

      ${dimUI ? `
      main [class*="bg-"], header [class*="bg-"] {
        background-color: rgba(0,0,0,0.25) !important;
      }
      ` : ""}
    `;
  }

  let applySeq = 0;
  let applyTimer = null;

  function requestApply() {
    clearTimeout(applyTimer);
    applyTimer = setTimeout(() => applyStyles(), 30);
  }

  async function applyStyles() {
    const seq = ++applySeq;

    const eff = getEffectiveSettings();
    const target = findAppContainer();
    target.classList.add("cai-bg-target");

    const imageUrl = await getEffectiveImageUrl(eff);
    if (seq !== applySeq) return;

    let styleEl = document.getElementById(STYLE_ID);
    if (!styleEl) {
      styleEl = document.createElement("style");
      styleEl.id = STYLE_ID;
      document.head.appendChild(styleEl);
    }

    styleEl.textContent = `
      ${computeBackgroundCss(eff, imageUrl)}
      ${computeUiOverrideCss(eff)}
    `;
  }

  /** ---------------------------
   *  Import / Export
   *  --------------------------- */
  async function buildExportObject() {
    const key = await loadUnsplashKey();
    const chats = getAllChatOverridesMap();

    // gather referenced upload IDs (global + all chat overrides)
    const ids = new Set();
    if (globalSettings.uploadImageId) ids.add(globalSettings.uploadImageId);
    Object.values(chats).forEach((o) => {
      if (o?.uploadImageId) ids.add(o.uploadImageId);
    });

    const uploads = [];
    for (const id of ids) {
      const blob = await getUploadedImageBlob(id);
      if (!blob) continue;
      uploads.push({
        id,
        name: "", // name is stored in settings, not IDB (but we don't need it here)
        type: blob.type || "image/*",
        dataUrl: await blobToDataUrl(blob)
      });
    }

    return {
      format: "cai-bg-customizer",
      version: VERSION,
      exportedAt: new Date().toISOString(),
      global: { ...globalSettings },
      chats,
      secrets: { unsplashAccessKey: key || "" },
      assets: { uploads }
    };
  }

  async function importFromObject(obj) {
    if (!obj || obj.format !== "cai-bg-customizer") {
      throw new Error("Not a cai-bg-customizer export.");
    }

    // overwrite global
    globalSettings = migrateSettings(obj.global || {});
    saveGlobalSettings(globalSettings);

    // overwrite all chat overrides
    clearAllChatOverrides();
    const chats = obj.chats || {};
    for (const [chatId, overrides] of Object.entries(chats)) {
      saveChatOverrides(chatId, overrides || {});
    }

    // restore key
    if (obj?.secrets?.unsplashAccessKey) {
      await saveUnsplashKey(obj.secrets.unsplashAccessKey);
    }

    // restore uploads (keep same IDs so references stay valid)
    const uploads = obj?.assets?.uploads || [];
    for (const up of uploads) {
      if (!up?.id || !up?.dataUrl) continue;
      const blob = await dataUrlToBlob(up.dataUrl);
      await storeUploadedBlob({ id: up.id, name: up.name || "", type: up.type || blob.type, blob });
    }
  }

  /** ---------------------------
   *  UI (Shadow DOM)
   *  --------------------------- */
  const UI_HOST_ID = "cai-bg-ui-host";
  const UI_ID = "cai-bg-ui";
  const PANEL_ID = "cai-bg-panel";
  const BTN_ID = "cai-bg-btn";
  const TOAST_ID = "cai-bg-toast";

  function ensureUI() {
    let host = document.getElementById(UI_HOST_ID);
    if (host) return host;

    host = document.createElement("div");
    host.id = UI_HOST_ID;

    const ui = document.createElement("div");
    ui.id = UI_ID;

    const shadow = ui.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <style>
        :host { all: initial; }

        .wrap {
          position: fixed;
          top: 12px;
          right: 12px;
          display: flex;
          flex-direction: column;
          gap: 10px;
          align-items: flex-end;
          font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Montserrat", sans-serif;
        }

        button, input, select { font-family: inherit; }

        button {
          all: unset;
          box-sizing: border-box;
          display: inline-flex;
          align-items: center;
          justify-content: center;
          cursor: pointer;
          user-select: none;
        }

        .btn {
          width: 36px;
          height: 36px;
          border-radius: 10px;
          background: rgba(20,20,20,0.75);
          border: 1px solid rgba(255,255,255,0.14);
          backdrop-filter: blur(10px);
          box-shadow: 0 10px 30px rgba(0,0,0,0.25);
          color: white;
          font-size: 18px;
        }
        .btn:hover { transform: translateY(-1px); }
        .btn:active { transform: translateY(0px); }

        .panel {
          width: min(560px, calc(100vw - 24px));
          border-radius: 18px;
          background: rgba(20,20,20,0.88);
          border: 1px solid rgba(255,255,255,0.12);
          backdrop-filter: blur(14px);
          box-shadow: 0 18px 60px rgba(0,0,0,0.45);
          color: white;
          padding: 14px;
          display: none;

          /* FIX: scroll when content grows */
          max-height: calc(100vh - 90px);
          overflow: auto;
          overscroll-behavior: contain;
        }
        .panel.open { display: block; }

        .row {
          display: grid;
          grid-template-columns: 1fr auto;
          gap: 10px;
          align-items: center;
        }

        .title {
          font-size: 14px;
          opacity: 0.95;
          display: flex;
          align-items: center;
          gap: 10px;
        }

        .badge {
          font-size: 11px;
          padding: 2px 8px;
          border-radius: 999px;
          border: 1px solid rgba(255,255,255,0.14);
          opacity: 0.85;
        }

        .tabs { display: flex; gap: 8px; margin-top: 12px; }
        .tab {
          padding: 8px 10px;
          border-radius: 999px;
          background: rgba(255,255,255,0.06);
          border: 1px solid rgba(255,255,255,0.12);
          font-size: 12px;
          color: white;
          opacity: 0.9;
        }
        .tab.active {
          background: rgba(255,255,255,0.14);
          border-color: rgba(255,255,255,0.22);
          opacity: 1;
        }

        .field { display: grid; gap: 6px; margin-top: 12px; }
        label { font-size: 12px; opacity: 0.85; }

        input[type="text"], input[type="password"], select {
          width: 100%;
          box-sizing: border-box;
          background: rgba(255,255,255,0.07);
          color: white;
          border: 1px solid rgba(255,255,255,0.14);
          border-radius: 12px;
          padding: 10px 12px;
          outline: none;
          font-size: 13px;
        }
        input[type="range"] { width: 100%; accent-color: white; }
        .hint { font-size: 11px; opacity: 0.65; line-height: 1.35; }

        .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
        @media (max-width: 480px) { .grid2 { grid-template-columns: 1fr; } }

        .toggle {
          display: flex;
          align-items: center;
          justify-content: space-between;
          gap: 10px;
          padding: 10px 12px;
          border-radius: 12px;
          background: rgba(255,255,255,0.06);
          border: 1px solid rgba(255,255,255,0.12);
        }

        .pill {
          padding: 9px 12px;
          border-radius: 999px;
          background: rgba(255,255,255,0.10);
          border: 1px solid rgba(255,255,255,0.14);
          font-size: 12px;
          color: white;
        }
        .pill:hover { background: rgba(255,255,255,0.14); }
        .pill.danger { background: rgba(255,70,70,0.18); border-color: rgba(255,70,70,0.35); }
        .pill.danger:hover { background: rgba(255,70,70,0.25); }

        .actions {
          display: flex;
          gap: 10px;
          justify-content: space-between;
          flex-wrap: wrap;
          margin-top: 14px;
        }
        .actions .left, .actions .right { display: flex; gap: 10px; flex-wrap: wrap; }

        .toast {
          position: fixed;
          bottom: 14px;
          right: 14px;
          padding: 10px 12px;
          border-radius: 12px;
          background: rgba(20,20,20,0.88);
          border: 1px solid rgba(255,255,255,0.12);
          backdrop-filter: blur(14px);
          color: white;
          font-size: 12px;
          opacity: 0;
          transform: translateY(8px);
          transition: opacity 140ms ease, transform 140ms ease;
          pointer-events: none;
        }
        .toast.show { opacity: 1; transform: translateY(0); }

        .tabContent { display: none; }
        .tabContent.active { display: block; }

        .unsplashBar {
          display: grid;
          grid-template-columns: 1fr auto auto;
          gap: 10px;
          align-items: center;
        }
        @media (max-width: 520px) { .unsplashBar { grid-template-columns: 1fr; } }

        .results {
          margin-top: 10px;
          display: grid;
          grid-template-columns: repeat(4, 1fr);
          gap: 8px;
        }
        @media (max-width: 560px) { .results { grid-template-columns: repeat(3, 1fr); } }
        @media (max-width: 420px) { .results { grid-template-columns: repeat(2, 1fr); } }

        .card {
          position: relative;
          border-radius: 12px;
          overflow: hidden;
          border: 1px solid rgba(255,255,255,0.12);
          background: rgba(255,255,255,0.04);
          aspect-ratio: 1 / 1;
        }
        .card img { width: 100%; height: 100%; object-fit: cover; display: block; }
        .card .selectGlow {
          position: absolute;
          inset: 0;
          border: 2px solid rgba(255,255,255,0.65);
          border-radius: 12px;
          pointer-events: none;
          opacity: 0;
        }
        .card.selected .selectGlow { opacity: 1; }

        .footer {
          margin-top: 12px;
          font-size: 11px;
          opacity: 0.7;
          display: flex;
          justify-content: space-between;
          align-items: center;
          gap: 10px;
          flex-wrap: wrap;
        }
        .footer a {
          color: white;
          text-decoration: underline;
          text-underline-offset: 2px;
        }
      </style>

      <div class="wrap">
        <button class="btn" id="${BTN_ID}" title="Background settings">🖼️</button>

        <div class="panel" id="${PANEL_ID}" role="dialog" aria-label="Background settings">
          <div class="row">
            <div class="title">
              <strong>Background</strong>
              <span class="badge">v${VERSION}</span>
            </div>
            <button class="pill" id="closeBtn" title="Close (Esc)">Close</button>
          </div>

          <div class="tabs" role="tablist" aria-label="Image source">
            <button class="tab" id="tabUrlBtn" data-tab="url">URL</button>
            <button class="tab" id="tabUploadBtn" data-tab="upload">Upload</button>
            <button class="tab" id="tabUnsplashBtn" data-tab="unsplash">Unsplash</button>
          </div>

          <div class="tabContent" id="tab_url">
            <div class="field">
              <label>Image URL</label>
              <input id="imgUrl" type="text" placeholder="https://... or data:image/..." />
              <div class="hint">Direct image URL required. If it breaks randomly, your host is hotlink-blocking you.</div>
            </div>
          </div>

          <div class="tabContent" id="tab_upload">
            <div class="field">
              <label>Upload local image (stored in browser via IndexedDB)</label>
              <div class="grid2">
                <button class="pill" id="pickUploadBtn">Choose image</button>
                <button class="pill danger" id="deleteUploadBtn" title="Delete stored upload">Delete stored</button>
              </div>
              <div class="hint" id="uploadStatus">No image uploaded.</div>
              <input id="uploadInput" type="file" accept="image/*" style="display:none" />
              <div class="hint">Yes, it stays in your browser. No, it’s not magically synced. Also: don’t upload a 40MB jpeg and cry.</div>
            </div>
          </div>

          <div class="tabContent" id="tab_unsplash">
            <div class="field">
              <label>Unsplash Access Key</label>
              <div class="grid2">
                <input id="unsplashKey" type="password" placeholder="Paste your Unsplash access key" />
                <button class="pill" id="saveUnsplashKeyBtn">Save key</button>
              </div>
              <div class="hint">Stored encrypted-at-rest if supported. Still: anything running on this page could potentially use it.</div>
            </div>

            <div class="field">
              <label>Search / Browse</label>
              <div class="unsplashBar">
                <input id="unsplashQuery" type="text" placeholder="Search (e.g. neon city, noir, retro)" />
                <button class="pill" id="unsplashSearchBtn">Search</button>
                <button class="pill" id="unsplashBrowseBtn">Browse</button>
              </div>
              <div class="hint" id="unsplashStatus">Enter a key to search.</div>
              <div class="results" id="unsplashResults"></div>
              <div class="hint" id="unsplashCredit" style="margin-top:10px;"></div>
            </div>
          </div>

          <div class="grid2">
            <div class="field">
              <label>Overlay darkness</label>
              <input id="overlay" type="range" min="0" max="1" step="0.01" />
              <div class="hint"><span id="overlayVal"></span></div>
            </div>

            <div class="field">
              <label>Attachment</label>
              <select id="attach">
                <option value="fixed">Sticky (fixed)</option>
                <option value="scroll">Scroll</option>
              </select>
            </div>
          </div>

          <div class="grid2">
            <div class="field">
              <label>Image mode</label>
              <select id="type">
                <option value="Stretch">Stretch (cover)</option>
                <option value="Distort">Distort (100vw 100vh)</option>
                <option value="ContainSingle">Contain (single)</option>
                <option value="ContainRepeat">Contain (repeat)</option>
              </select>
            </div>

            <div class="field">
              <label>Position</label>
              <input id="pos" type="text" placeholder="center center / top center / 20% 30% ..." />
              <div class="hint">Any valid CSS background-position string.</div>
            </div>
          </div>

          <div class="field">
            <div class="toggle">
              <div>
                <div style="font-size: 12px; opacity: 0.9;">Enabled</div>
                <div class="hint">Toggle background on/off (global)</div>
              </div>
              <input id="enabled" type="checkbox" />
            </div>
          </div>

          <div class="field">
            <div class="toggle">
              <div>
                <div style="font-size: 12px; opacity: 0.9;">Per-chat backgrounds</div>
                <div class="hint" id="perChatHint"></div>
              </div>
              <input id="perChat" type="checkbox" />
            </div>
          </div>

          <div class="grid2">
            <div class="field">
              <div class="toggle">
                <div>
                  <div style="font-size: 12px; opacity: 0.9;">Keep CAI blur</div>
                  <div class="hint">Global. If off, disables blur near input</div>
                </div>
                <input id="blurUI" type="checkbox" />
              </div>
            </div>

            <div class="field">
              <div class="toggle">
                <div>
                  <div style="font-size: 12px; opacity: 0.9;">Dim panels</div>
                  <div class="hint">Global. Extra contrast help</div>
                </div>
                <input id="dimUI" type="checkbox" />
              </div>
            </div>
          </div>

          <div class="actions">
            <div class="left">
              <button class="pill" id="importBtn" title="Import from file">Import</button>
              <button class="pill" id="exportBtn" title="Export to file">Export</button>
              <input id="importInput" type="file" accept="application/json" style="display:none" />
            </div>
            <div class="right">
              <button class="pill" id="resetBtn" title="Reset">Reset</button>
              <button class="pill danger" id="clearBtn" title="Clear current image">Clear image</button>
            </div>
          </div>

          <div class="footer">
            <div>Made by <a href="https://greasyfork.org/en/users/1226710-mr005k" target="_blank" rel="noopener noreferrer">Mr005K</a></div>
            <div style="opacity:0.7;">
              Scope: <span id="scopeBadge"></span> · Source: <span id="sourceBadge"></span>
            </div>
          </div>
        </div>

        <div class="toast" id="${TOAST_ID}"></div>
      </div>
    `;

    host.appendChild(ui);
    document.body.appendChild(host);
    wireUI(shadow);
    return host;
  }

  function wireUI(shadow) {
    const btn = shadow.getElementById(BTN_ID);
    const panel = shadow.getElementById(PANEL_ID);
    const toast = shadow.getElementById(TOAST_ID);

    // Tabs
    const tabUrlBtn = shadow.getElementById("tabUrlBtn");
    const tabUploadBtn = shadow.getElementById("tabUploadBtn");
    const tabUnsplashBtn = shadow.getElementById("tabUnsplashBtn");
    const tabUrl = shadow.getElementById("tab_url");
    const tabUpload = shadow.getElementById("tab_upload");
    const tabUnsplash = shadow.getElementById("tab_unsplash");

    // Badges
    const sourceBadge = shadow.getElementById("sourceBadge");
    const scopeBadge = shadow.getElementById("scopeBadge");

    // URL tab
    const imgUrl = shadow.getElementById("imgUrl");

    // Upload tab
    const pickUploadBtn = shadow.getElementById("pickUploadBtn");
    const deleteUploadBtn = shadow.getElementById("deleteUploadBtn");
    const uploadInput = shadow.getElementById("uploadInput");
    const uploadStatus = shadow.getElementById("uploadStatus");

    // Unsplash tab
    const unsplashKey = shadow.getElementById("unsplashKey");
    const saveUnsplashKeyBtn = shadow.getElementById("saveUnsplashKeyBtn");
    const unsplashQuery = shadow.getElementById("unsplashQuery");
    const unsplashSearchBtn = shadow.getElementById("unsplashSearchBtn");
    const unsplashBrowseBtn = shadow.getElementById("unsplashBrowseBtn");
    const unsplashResults = shadow.getElementById("unsplashResults");
    const unsplashStatus = shadow.getElementById("unsplashStatus");
    const unsplashCredit = shadow.getElementById("unsplashCredit");

    // Common controls
    const overlay = shadow.getElementById("overlay");
    const overlayVal = shadow.getElementById("overlayVal");
    const attach = shadow.getElementById("attach");
    const type = shadow.getElementById("type");
    const pos = shadow.getElementById("pos");
    const enabled = shadow.getElementById("enabled");
    const perChat = shadow.getElementById("perChat");
    const perChatHint = shadow.getElementById("perChatHint");
    const blurUI = shadow.getElementById("blurUI");
    const dimUI = shadow.getElementById("dimUI");

    // Actions
    const closeBtn = shadow.getElementById("closeBtn");
    const resetBtn = shadow.getElementById("resetBtn");
    const clearBtn = shadow.getElementById("clearBtn");
    const importBtn = shadow.getElementById("importBtn");
    const exportBtn = shadow.getElementById("exportBtn");
    const importInput = shadow.getElementById("importInput");

    function showToast(msg) {
      toast.textContent = msg;
      toast.classList.add("show");
      setTimeout(() => toast.classList.remove("show"), 1400);
    }

    function pickTab(id) {
      const map = {
        url: { btn: tabUrlBtn, el: tabUrl },
        upload: { btn: tabUploadBtn, el: tabUpload },
        unsplash: { btn: tabUnsplashBtn, el: tabUnsplash }
      };
      Object.entries(map).forEach(([k, v]) => {
        v.btn.classList.toggle("active", k === id);
        v.el.classList.toggle("active", k === id);
      });
    }

    function syncBadges(eff) {
      sourceBadge.textContent = eff.imageSource === "url" ? "URL" : eff.imageSource === "upload" ? "Upload" : "Unsplash";
      scopeBadge.textContent = isChatScoped() ? `This chat (${currentChatId.slice(0, 8)}…)` : "Global";
      perChatHint.textContent = globalSettings.perChatEnabled
        ? (currentChatId ? "ON: image settings are saved per chat." : "ON: (you’re not in a chat page right now)")
        : "OFF: same background everywhere.";
    }

    function splitPatch(patch) {
      const g = {};
      const c = {};
      for (const [k, v] of Object.entries(patch)) {
        if (GLOBAL_ONLY_KEYS.has(k)) g[k] = v;
        else if (CHAT_KEYS.has(k)) c[k] = v;
        else g[k] = v;
      }
      return { globalPatch: g, chatPatch: c };
    }

    function commit(patch, toastMsg) {
      const { globalPatch, chatPatch } = splitPatch(patch);

      // Always apply global patch
      if (Object.keys(globalPatch).length) {
        globalSettings = migrateSettings({ ...globalSettings, ...globalPatch });
        saveGlobalSettings(globalSettings);
      }

      // Apply chat patch only if we're in chat scope
      if (Object.keys(chatPatch).length) {
        if (isChatScoped()) {
          const prev = loadChatOverrides(currentChatId);
          saveChatOverrides(currentChatId, { ...prev, ...chatPatch });
        } else {
          // If not chat scoped, treat chat keys as global edits (so UI doesn't feel broken)
          globalSettings = migrateSettings({ ...globalSettings, ...chatPatch });
          saveGlobalSettings(globalSettings);
        }
      }

      if (toastMsg) showToast(toastMsg);
      requestApply();
      syncInputsFromState();
    }

    function openPanel() {
      panel.classList.add("open");
      syncInputsFromState();
    }
    function closePanel() {
      panel.classList.remove("open");
    }

    async function syncInputsFromState() {
      // refresh route context (in case SPA nav happened)
      currentChatId = getChatIdFromUrl();

      const eff = getEffectiveSettings();

      pickTab(eff.imageSource);
      syncBadges(eff);

      // URL
      imgUrl.value = eff.imageUrl || "";

      // Upload status
      if (eff.uploadImageId) uploadStatus.textContent = `Selected: ${eff.uploadImageName || eff.uploadImageId}`;
      else uploadStatus.textContent = "No image uploaded/selected.";

      // Unsplash key UI: don't reveal
      const key = await loadUnsplashKey();
      unsplashKey.value = key ? "••••••••••••••••" : "";
      unsplashStatus.textContent = key ? "Ready. Search or browse." : "Enter a key to search.";
      renderUnsplashCredit(eff);

      // Common (NOTE: overlay/attachment/position/type are per-chat overridable)
      overlay.value = String(clamp(Number(eff.overlayOpacity ?? 0.35), 0, 1));
      overlayVal.textContent = `Overlay: ${Math.round(Number(overlay.value) * 100)}%`;
      attach.value = eff.attachment === "scroll" ? "scroll" : "fixed";
      type.value = eff.imageType || "Stretch";
      pos.value = eff.position || "center center";

      // Global-only
      enabled.checked = !!globalSettings.enabled;
      perChat.checked = !!globalSettings.perChatEnabled;
      blurUI.checked = !!globalSettings.blurBehindUI;
      dimUI.checked = !!globalSettings.dimUI;
    }

    function renderUnsplashCredit(eff) {
      const sel = eff.unsplashSelected;
      if (!sel) { unsplashCredit.textContent = ""; return; }
      const profile = sel.userProfile ? ` — ${sel.userProfile}` : "";
      unsplashCredit.textContent = `${sel.attributionText}${profile}`;
    }

    // Open/close
    btn.addEventListener("click", () => panel.classList.contains("open") ? closePanel() : openPanel());
    closeBtn.addEventListener("click", closePanel);
    window.addEventListener("keydown", (e) => {
      if (e.key === "Escape" && panel.classList.contains("open")) closePanel();
    });

    // Tabs set imageSource (chat-scoped if enabled)
    tabUrlBtn.addEventListener("click", () => commit({ imageSource: "url" }, "Source: URL"));
    tabUploadBtn.addEventListener("click", () => commit({ imageSource: "upload" }, "Source: Upload"));
    tabUnsplashBtn.addEventListener("click", () => commit({ imageSource: "unsplash" }, "Source: Unsplash"));

    // URL input
    imgUrl.addEventListener("input", () => {
      const v = imgUrl.value.trim();
      if (!looksLikeUrl(v)) return showToast("That doesn’t look like a usable URL.");
      commit({ imageUrl: v, imageSource: "url" }, "Updated URL");
    });

    // Upload
    pickUploadBtn.addEventListener("click", () => uploadInput.click());
    uploadInput.addEventListener("change", async () => {
      const file = uploadInput.files?.[0];
      uploadInput.value = "";
      if (!file) return;

      try {
        const meta = await storeUploadedFile(file);
        commit({ uploadImageId: meta.id, uploadImageName: meta.name || meta.id, imageSource: "upload" }, "Uploaded & selected");
      } catch (e) {
        showToast(`Upload failed: ${String(e?.message || e)}`);
      }
    });

    deleteUploadBtn.addEventListener("click", async () => {
      const eff = getEffectiveSettings();
      const id = eff.uploadImageId;
      if (!id) return showToast("Nothing to delete.");

      try { await idbDelete(STORE_IMAGES, id); } catch {}

      if (uploadObjectUrl) {
        try { URL.revokeObjectURL(uploadObjectUrl); } catch {}
        uploadObjectUrl = null;
        uploadObjectUrlId = null;
      }

      // delete stored upload + unselect
      commit({ uploadImageId: null, uploadImageName: "" }, "Deleted upload");
    });

    // Unsplash key
    saveUnsplashKeyBtn.addEventListener("click", async () => {
      const raw = unsplashKey.value.trim();
      if (!raw || raw.startsWith("•")) {
        const existing = await loadUnsplashKey();
        if (existing) return showToast("Key already saved.");
        return showToast("Paste the actual key first.");
      }
      await saveUnsplashKey(raw);
      unsplashKey.value = "••••••••••••••••";
      showToast("Unsplash key saved");
      unsplashStatus.textContent = "Saved. Search or browse.";
    });

    async function runUnsplash(query, mode) {
      unsplashStatus.textContent = "Loading…";
      unsplashResults.innerHTML = "";

      try {
        const k = await loadUnsplashKey();
        if (!k) throw new Error("No access key saved.");

        const data = mode === "browse" ? await unsplashBrowse() : await unsplashSearch(query);
        const items = Array.isArray(data) ? data : (data?.results || []);
        if (!items.length) { unsplashStatus.textContent = "No results."; return; }

        unsplashStatus.textContent = `${items.length} results. Click to select.`;
        renderUnsplashResults(items.map(normalizeUnsplashItem));
      } catch (e) {
        unsplashStatus.textContent = "Failed.";
        showToast(String(e?.message || e));
      }
    }

    function renderUnsplashResults(items) {
      const eff = getEffectiveSettings();
      const selectedId = eff.unsplashSelected?.id || null;

      unsplashResults.innerHTML = "";
      items.forEach((it) => {
        const div = document.createElement("div");
        div.className = "card" + (it.id === selectedId ? " selected" : "");
        div.innerHTML = `
          <img alt="" src="${it.thumbUrl.replace(/"/g, "&quot;")}" />
          <div class="selectGlow"></div>
        `;
        div.addEventListener("click", async () => {
          commit({ unsplashSelected: it, imageSource: "unsplash" }, "Selected Unsplash image");
          if (it.downloadLocation) await unsplashTrackDownload(it.downloadLocation);
          // highlight
          [...unsplashResults.querySelectorAll(".card")].forEach((c) => c.classList.remove("selected"));
          div.classList.add("selected");
        });
        unsplashResults.appendChild(div);
      });
    }

    unsplashSearchBtn.addEventListener("click", () => runUnsplash(unsplashQuery.value.trim(), "search"));
    unsplashBrowseBtn.addEventListener("click", () => runUnsplash("", "browse"));
    unsplashQuery.addEventListener("keydown", (e) => {
      if (e.key === "Enter") runUnsplash(unsplashQuery.value.trim(), "search");
    });

    // Common controls
    overlay.addEventListener("input", () => {
      overlayVal.textContent = `Overlay: ${Math.round(Number(overlay.value) * 100)}%`;
      commit({ overlayOpacity: clamp(Number(overlay.value), 0, 1) });
    });
    attach.addEventListener("change", () => commit({ attachment: attach.value }, "Attachment updated"));
    type.addEventListener("change", () => commit({ imageType: type.value }, "Mode updated"));
    pos.addEventListener("input", () => commit({ position: pos.value.trim() || "center center" }));

    // Global-only toggles
    enabled.addEventListener("change", () => commit({ enabled: enabled.checked }, enabled.checked ? "Enabled" : "Disabled"));

    perChat.addEventListener("change", () => {
      commit({ perChatEnabled: perChat.checked }, perChat.checked ? "Per-chat enabled" : "Per-chat disabled");
      // When flipping, resync and reapply immediately
      syncInputsFromState();
      requestApply();
    });

    blurUI.addEventListener("change", () => commit({ blurBehindUI: blurUI.checked }, "UI blur updated"));
    dimUI.addEventListener("change", () => commit({ dimUI: dimUI.checked }, "Panel dim updated"));

    // Clear image (scope-aware via commit split)
    clearBtn.addEventListener("click", () => {
      const eff = getEffectiveSettings();
      if (eff.imageSource === "url") return commit({ imageUrl: "" }, "Cleared URL");
      if (eff.imageSource === "unsplash") return commit({ unsplashSelected: null }, "Cleared Unsplash selection");
      if (eff.imageSource === "upload") return commit({ uploadImageId: null, uploadImageName: "" }, "Unselected upload");
    });

    // Reset:
    // - If per-chat enabled and in chat => reset THIS chat overrides (back to global)
    // - Else reset global settings (keep Unsplash key)
    resetBtn.addEventListener("click", () => {
      if (isChatScoped()) {
        clearChatOverrides(currentChatId);
        showToast("Reset this chat to global defaults");
        syncInputsFromState();
        requestApply();
      } else {
        const keepKey = globalSettings.perChatEnabled; // not a secret, just preference
        globalSettings = migrateSettings({ ...DEFAULTS, perChatEnabled: keepKey });
        saveGlobalSettings(globalSettings);
        showToast("Reset global settings");
        syncInputsFromState();
        requestApply();
      }
    });

    // Export / Import
    exportBtn.addEventListener("click", async () => {
      try {
        const exportObj = await buildExportObject();
        const blob = new Blob([JSON.stringify(exportObj, null, 2)], { type: "application/json" });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = `cai-bg-customizer-export-v${VERSION}.json`;
        a.click();
        setTimeout(() => URL.revokeObjectURL(url), 1500);
        showToast("Exported (global + per-chat + uploads + key)");
      } catch (e) {
        showToast(`Export failed: ${String(e?.message || e)}`);
      }
    });

    importBtn.addEventListener("click", () => importInput.click());
    importInput.addEventListener("change", async () => {
      const file = importInput.files?.[0];
      importInput.value = "";
      if (!file) return;

      try {
        const txt = await file.text();
        await importFromObject(JSON.parse(txt));
        showToast("Imported settings");
        syncInputsFromState();
        requestApply();
      } catch (e) {
        showToast(`Import failed: ${String(e?.message || e)}`);
      }
    });

    // initial sync
    syncInputsFromState();
  }

  /** ---------------------------
   *  Resilience: observer + SPA nav
   *  --------------------------- */
  let observer = null;

  function startObserver() {
    if (observer) observer.disconnect();

    observer = new MutationObserver(() => {
      if (!document.getElementById(UI_HOST_ID)) ensureUI();

      // Refresh chat id (SPA can change it)
      const nextChatId = getChatIdFromUrl();
      if (nextChatId !== currentChatId) {
        currentChatId = nextChatId;
        requestApply();
      }

      const target = findAppContainer();
      if (target && !target.classList.contains("cai-bg-target")) target.classList.add("cai-bg-target");

      if (!document.getElementById(STYLE_ID)) requestApply();
    });

    observer.observe(document.documentElement, { childList: true, subtree: true });
  }

  function hookHistory() {
    const _pushState = history.pushState;
    const _replaceState = history.replaceState;

    function onNav() {
      setTimeout(() => {
        globalSettings = loadGlobalSettings(); // pick up any changes
        const nextChatId = getChatIdFromUrl();
        currentChatId = nextChatId;
        ensureUI();
        requestApply();
      }, 60);
    }

    history.pushState = function (...args) {
      const out = _pushState.apply(this, args);
      onNav();
      return out;
    };
    history.replaceState = function (...args) {
      const out = _replaceState.apply(this, args);
      onNav();
      return out;
    };
    window.addEventListener("popstate", onNav);
  }

  /** ---------------------------
   *  Boot
   *  --------------------------- */
  function boot() {
    ensureUI();
    requestApply();
    startObserver();
    hookHistory();
  }

  boot();
})();