Threads Plus

Threads ウェブ版向けに開発された軽量スクリプト。ワンクリックでスレッドのリンクをコピー(トラッキングコードなし)、ワンクリックでスレッドの内容をコピーするボタン、絶対時刻表示、画像スライドアニメーションなどの機能を提供し、ブラウジング体験をよりスムーズにします!全ての設定は右上のフローティングボタンに統合されました。 (洗練されたカルーセルアニメーション、スライドとフェードの違いが鮮明)

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name             Threads Plus
// @name:en          Threads Plus
// @name:zh-TW       Threads Plus
// @name:ja          Threads Plus
// @name:ko          Threads Plus
// @namespace        https://greasyfork.org/zh-TW/users/1620480-shana2023
// @version          1.3.4
// @description      A lightweight script developed specifically for the web version of Threads, offering features such as one-click copying of thread links (with no tracking code), a one-click thread content copy button, absolute time display, image carousel animation, and more, making the browsing experience smoother! All settings are now integrated into a floating button. (Ultra-refined carousel animations with distinct slide/fade modes)
// @description:en   A lightweight script developed specifically for the web version of Threads, offering features such as one-click copying of thread links (with no tracking code), a one-click thread content copy button, absolute time display, image carousel animation, and more, making the browsing experience smoother! All settings are now integrated into a floating button. (Ultra-refined carousel animations with distinct slide/fade modes)
// @description:zh-TW 專為 Threads 網頁版開發的輕量型腳本,提供一鍵複製串文連結(無追蹤碼)、一鍵複製串文內容按鈕、絕對時間顯示、圖片輪播動畫等功能,讓瀏覽體驗更流暢!所有設定現已整合至懸浮按鈕(位於右上角,對齊發表按鈕)。 (極致細膩的圖片輪播動畫,平滑與淡入模式差異鮮明)
// @description:ja   Threads ウェブ版向けに開発された軽量スクリプト。ワンクリックでスレッドのリンクをコピー(トラッキングコードなし)、ワンクリックでスレッドの内容をコピーするボタン、絶対時刻表示、画像スライドアニメーションなどの機能を提供し、ブラウジング体験をよりスムーズにします!全ての設定は右上のフローティングボタンに統合されました。 (洗練されたカルーセルアニメーション、スライドとフェードの違いが鮮明)
// @description:ko   Threads 웹 버전을 위해 개발된 경량 스크립트로, 원클릭 스레드 링크 복사(추적 코드 제외), 원클릭 스레드 내용 복사 버튼, 절대 시간 표시, 이미지 캐러셀 애니메이션 등의 기능을 제공하여 더 부드러운 브라우징 경험을 선사합니다! 모든 설정이 오른쪽 상단의 플로팅 버튼에 통합되었습니다. (극도로 세련된 캐러셀 애니메이션, 슬라이드/페이드 모드 차별화)
// @author           Shana2023
// @match            https://www.threads.com/*
// @match            https://threads.com/*
// @match            https://www.threads.net/*
// @match            https://threads.net/*
// @grant            GM_addStyle
// @grant            GM_setClipboard
// @grant            GM_getValue
// @grant            GM_setValue
// @license          GPL-3.0-or-later
// @run-at           document-start
// @icon             data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+PHJlY3Qgd2lkdGg9IjEyOCIgaGVpZ2h0PSIxMjgiIHJ4PSIyNCIgZmlsbD0iIzAwMDAwMCIvPjx0ZXh0IHg9IjY0IiB5PSI4OCIgZm9udC1mYW1pbHk9IkFyaWFsLCBIZWx2ZXRpY2EsIHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iODAiIGZvbnQtd2VpZ2h0PSJib2xkIiBmaWxsPSIjZmZmZmZmIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIj5APC90ZXh0Pjwvc3ZnPg==
// ==/UserScript==

(function () {
  'use strict';

  // ════════════════════════ 核心層 ════════════════════════

  class Logger {
    static #PREFIX = '[TP]';
    static info(msg, ...args)  { console.log(Logger.#PREFIX, msg, ...args); }
    static warn(msg, ...args)  { console.warn(Logger.#PREFIX, msg, ...args); }
    static error(msg, ...args) { console.error(Logger.#PREFIX, msg, ...args); }
  }

  class EventBus {
    #handlers = {};

    on(event, fn) {
      (this.#handlers[event] ??= []).push(fn);
    }

    off(event, fn) {
      const list = this.#handlers[event];
      if (!list) return;
      this.#handlers[event] = list.filter(f => f !== fn);
    }

    emit(event, ...args) {
      (this.#handlers[event] || []).forEach(fn => fn(...args));
    }
  }

  class I18n {
    static #DICT = {
      copyLinkTitle:       { zh: '複製這則串文連結(無追蹤碼)',        en: 'Copy thread link (tracking-free)',        ja: 'スレッドのリンクをコピー(追跡コードなし)',        ko: '스레드 링크 복사 (추적 코드 없음)' },
      copyTextTitle:       { zh: '複製這則串文內容',                    en: 'Copy thread text',                       ja: 'スレッドの内容をコピー',                          ko: '스레드 내용 복사' },
      cleanLinkMenu:       { zh: '複製連結(無追蹤碼)',                en: 'Copy link (tracking-free)',              ja: 'リンクをコピー(追跡コードなし)',                ko: '링크 복사 (추적 코드 없음)' },
      settingsEnableLink:  { zh: '複製串文連結(無追蹤碼)',            en: 'Copy link (tracking-free)',              ja: 'スレッドのリンクをコピー(追跡コードなし)',      ko: '스레드 링크 복사 (추적 코드 없음)' },
      settingsEnableCopy:  { zh: '複製串文內容',                        en: 'Copy thread text',                       ja: 'スレッドの内容をコピー',                          ko: '스레드 내용 복사' },
      settingsEnableTime:  { zh: '顯示絕對時間',                        en: 'Show absolute time',                     ja: '絶対時刻を表示',                                ko: '절대 시간 표시' },
      settingsCarouselAnim:{ zh: '圖片輪播動畫',                        en: 'Image carousel animation',               ja: '画像カルーセルアニメーション',                    ko: '이미지 캐러셀 애니메이션' },
      settingsReset:       { zh: '還原預設設定',                        en: 'Reset default settings',                 ja: 'デフォルト設定に戻す',                            ko: '기본 설정으로 초기화' },
      animSlide:           { zh: '平滑',                                en: 'Slide',                                  ja: 'スライド',                                      ko: '슬라이드' },
      animFade:            { zh: '淡入',                                en: 'Fade',                                   ja: 'フェード',                                      ko: '페이드' },
      prev:                { zh: '上一張',                              en: 'Previous',                               ja: '前へ',                                          ko: '이전' },
      next:                { zh: '下一張',                              en: 'Next',                                   ja: '次へ',                                          ko: '다음' },
    };

    static #LANG = (() => {
      const raw = (navigator.language || 'en').toLowerCase();
      if (raw.startsWith('zh')) return 'zh';
      if (raw.startsWith('ja')) return 'ja';
      if (raw.startsWith('ko')) return 'ko';
      return 'en';
    })();

    static getLanguage() {
      return I18n.#LANG;
    }

    static t(key) {
      const entry = I18n.#DICT[key];
      if (!entry) return key;
      return entry[I18n.#LANG] || entry.en || key;
    }
  }

  class Config {
    static #KEY = 'threads-plus-settings';
    static #DEFAULT = {
      enableTextCopy: true,
      enableLinkCopy: true,
      carouselEffect: 'slide',
      enableAbsoluteTime: true,
    };
    static #data = { ...Config.#DEFAULT };
    static _eventBus = null;

    static load() {
      try {
        const raw = typeof GM_getValue === 'function' ? GM_getValue(Config.#KEY, null) : null;
        if (raw) Object.assign(Config.#data, JSON.parse(raw));
      } catch (e) {
        Logger.warn('Settings load failed', e);
      }
      Config.#data = { ...Config.#DEFAULT, ...Config.#data };
    }

    static save() {
      try {
        if (typeof GM_setValue === 'function') GM_setValue(Config.#KEY, JSON.stringify(Config.#data));
      } catch (e) {
        Logger.warn('Settings save failed', e);
      }
    }

    static get(key) {
      return Config.#data[key];
    }

    static set(key, value) {
      if (Config.#data[key] === value) return;
      Config.#data[key] = value;
      Config.save();
      Config._eventBus?.emit('setting-changed', { key, value });
    }

    static reset() {
      Config.#data = { ...Config.#DEFAULT };
      Config.save();
      Config._eventBus?.emit('settings-reset');
    }
  }

  class Utils {
    static SVG_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';

    static copyToClipboard(text) {
      try {
        if (typeof GM_setClipboard === 'function') GM_setClipboard(text);
        else navigator.clipboard?.writeText(text).catch(() => {});
      } catch {
        /* ignore */
      }
    }

    static blockEvent(e) {
      e.preventDefault();
      e.stopPropagation();
      e.stopImmediatePropagation?.();
    }

    static formatAbsoluteTime(isoString) {
      try {
        const d = new Date(isoString);
        if (isNaN(d.getTime())) return '';
        const pad = n => String(n).padStart(2, '0');
        return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
      } catch {
        return '';
      }
    }
  }

  class DomService {
    static isVisibleRect(r) {
      return r.width > 0 && r.height > 0;
    }

    static rectDistanceScore(src, cand) {
      if (!cand || cand.width === 0 || cand.height === 0) return Number.MAX_SAFE_INTEGER;
      return Math.abs((src.top + src.height / 2) - (cand.top + cand.height / 2)) * 3 +
             Math.abs((src.left + src.width / 2) - (cand.left + cand.width / 2));
    }

    static isShareSvg(svg) {
      const label = svg.getAttribute('aria-label') || svg.querySelector?.('title')?.textContent || '';
      return /分享|share|共有|공유/i.test(label);
    }

    static isCompactIconRect(r) {
      return r && r.width >= 18 && r.height >= 18 && r.width <= 92 && r.height <= 58;
    }

    static countShareIconsInNode(node) {
      return Array.from(node.querySelectorAll?.('svg[aria-label]') || [])
        .filter(DomService.isShareSvg).length;
    }

    static isInsideNestedPostBlock(el, root) {
      const press = el?.closest?.('[data-pressable-container]');
      return !!(press && press !== root && root?.contains?.(press) && DomService.countShareIconsInNode(press) > 0);
    }

    static findClickableAncestor(node, boundary) {
      let cur = node;
      for (let d = 0; cur && cur !== boundary && d < 8; d++) {
        if (cur.matches?.('[role="button"],button,a,[tabindex="0"]')) return cur;
        if (getComputedStyle(cur).cursor === 'pointer') return cur;
        cur = cur.parentElement;
      }
      return node.parentElement;
    }

    static findShareIconSlot(svg, boundary) {
      let cur = svg,
          best = DomService.findClickableAncestor(svg, boundary) || svg.parentElement;
      for (let d = 0; cur && cur !== boundary && d < 8; d++) {
        const p = cur.parentElement;
        if (!p) break;
        if (!DomService.isCompactIconRect(p.getBoundingClientRect())) break;
        best = p;
        cur = p;
      }
      return best;
    }

    static findPostBlockRoot(shareButton) {
      const article = shareButton.closest?.('article,[role="article"]');
      if (article && DomService.countShareIconsInNode(article) === 1) return article;

      let press = shareButton.parentElement;
      for (let d = 0; press && d < 16; d++, press = press.parentElement) {
        if (!press.hasAttribute?.('data-pressable-container')) continue;
        const r = press.getBoundingClientRect();
        const hasId = DomService.getPostIdsInNode(press).size > 0 || !!press.querySelector('time[datetime]');
        if (r.width >= 220 && r.height >= 48 && r.width <= Math.min(innerWidth, 1100) &&
            DomService.countShareIconsInNode(press) >= 1 && hasId) {
          return press;
        }
      }

      let node = shareButton.parentElement, best = null;
      for (let d = 0; node && d < 16; d++, node = node.parentElement) {
        if (node === document.body || node === document.documentElement) break;
        const r = node.getBoundingClientRect();
        if (r.width < 220 || r.height < 48 || r.width > Math.min(innerWidth, 1100)) continue;
        const sc = DomService.countShareIconsInNode(node);
        if (sc > 1) break;
        if (sc !== 1) continue;
        const ids = DomService.getPostIdsInNode(node);
        if (ids.size !== 1 && !node.querySelector('time[datetime]')) continue;
        best = node;
      }
      return best;
    }

    static sanitizeFilename(v) {
      const c = String(v || 'unknown')
        .replace(/^@/, '')
        .replace(/[\\/:*?"<>|]+/g, '_')
        .replace(/\s+/g, '_')
        .replace(/_+/g, '_')
        .replace(/^_+|_+$/g, '');
      return c || 'unknown';
    }

    static parsePostInfoFromUrl(url) {
      try {
        const u = new URL(url, location.href);
        const m = u.pathname.match(/\/@([^/]+)\/post\/([^/?#]+)/);
        if (!m) return null;
        return {
          author: DomService.sanitizeFilename(decodeURIComponent(m[1])),
          postId: DomService.sanitizeFilename(decodeURIComponent(m[2])),
          postUrl: u.href
        };
      } catch {
        return null;
      }
    }

    static buildCleanUrl(info) {
      if (!info?.author || !info?.postId) return '';
      return `https://www.threads.com/@${info.author}/post/${info.postId}`;
    }

    static getCurrentDetailPostInfo() {
      return DomService.parsePostInfoFromUrl(location.href);
    }

    static getPostIdsInNode(node) {
      const ids = new Set();
      const links = [
        ...(node.matches?.('a[href*="/post/"]') ? [node] : []),
        ...Array.from(node.querySelectorAll?.('a[href*="/post/"]') || [])
      ];
      links.forEach(l => {
        const info = DomService.parsePostInfoFromUrl(l.href || l.getAttribute?.('href'));
        if (info?.postId) ids.add(info.postId);
      });
      return ids;
    }

    static findActionBarForPost(post) {
      const svgs = post.querySelectorAll('svg[aria-label]');
      for (const svg of svgs) {
        if (DomService.isShareSvg(svg)) {
          const slot = DomService.findShareIconSlot(svg, post);
          if (slot) return slot.parentElement;
        }
      }
      return null;
    }
  }

  class TextService {
    static #MAX_CACHE = 200;
    static #cache = new Map();

    static #cacheGet(key) {
      if (!key.isConnected) {
        TextService.#cache.delete(key);
        return undefined;
      }
      if (!TextService.#cache.has(key)) return undefined;
      const val = TextService.#cache.get(key);
      TextService.#cache.delete(key);
      TextService.#cache.set(key, val);
      return val;
    }

    static #cacheSet(key, value) {
      for (const k of TextService.#cache.keys()) {
        if (!k.isConnected) TextService.#cache.delete(k);
      }
      if (TextService.#cache.size >= TextService.#MAX_CACHE) {
        const oldest = TextService.#cache.keys().next().value;
        if (oldest) TextService.#cache.delete(oldest);
      }
      TextService.#cache.set(key, value);
    }

    static precomputeVisiblePosts() {
      const posts = document.querySelectorAll('article, [role="article"]');
      for (const post of posts) {
        const rect = post.getBoundingClientRect();
        if (rect.bottom < 0 || rect.top > window.innerHeight) continue;
        try {
          const actionBar = DomService.findActionBarForPost(post);
          if (!actionBar) continue;
          TextService.extract(post, actionBar);
        } catch (e) {
          /* ignore */
        }
      }
    }

    static #getRenderedText(el) {
      return el ? String(el.innerText || '').replace(/\r\n?/g, '\n').replace(/^\n+|\n+$/g, '') : '';
    }

    static #stripCarouselCounter(text) {
      let t = String(text || '');
      t = t.replace(/\n[ \t\u00a0]*\d+[ \t\u00a0]*\n[ \t\u00a0]*\/[ \t\u00a0]*\n[ \t\u00a0]*\d+[ \t\u00a0]*$/, '');
      t = t.replace(/\n[ \t\u00a0]*\d+[ \t\u00a0]*\/[ \t\u00a0]*\d+[ \t\u00a0]*$/, '');
      return t.replace(/[ \t\u00a0]+$/g, '').replace(/\n+$/g, '');
    }

    static cleanFragment(t) {
      return String(t || '')
        .replace(/\r\n?/g, '\n')
        .replace(/[ \t\u00a0]*(?:\n[ \t\u00a0]*)?(?:翻譯|查看翻譯|翻訳|翻訳を見る|번역|번역 보기)[ \t\u00a0]*$/i, '')
        .replace(/[ \t\u00a0]+$/g, '')
        .replace(/^\n+|\n+$/g, '');
    }

    static findBestPostInfo(node, element, excludeNested = false) {
      if (node.matches?.('a[href*="/post/"]')) {
        const own = DomService.parsePostInfoFromUrl(node.href);
        if (own) return own;
      }
      const links = Array.from(node.querySelectorAll?.('a[href*="/post/"]') || [])
        .filter(l => !excludeNested || !DomService.isInsideNestedPostBlock(l, node));
      const srcRect = element.getBoundingClientRect();
      const candidates = links
        .map(l => ({ link: l, info: DomService.parsePostInfoFromUrl(l.href) }))
        .filter(i => i.info)
        .map(i => ({
          ...i,
          score: i.link.contains(element) ? -1 : DomService.rectDistanceScore(srcRect, i.link.getBoundingClientRect())
        }))
        .sort((a, b) => a.score - b.score);
      return candidates[0]?.info || null;
    }

    static findPostInfo(node) {
      if (node.matches?.('a[href*="/post/"]')) {
        return DomService.parsePostInfoFromUrl(node.href);
      }
      return Array.from(node.querySelectorAll?.('a[href*="/post/"]') || [])
        .map(l => DomService.parsePostInfoFromUrl(l.href))
        .find(Boolean) || null;
    }

    static #computeBoundaryTop(root, actionBar) {
      const rr = root.getBoundingClientRect();
      const at = actionBar?.getBoundingClientRect?.().top;
      const validAt = (at != null && at > rr.top + 4) ? at : undefined;
      const mt = Array.from(root.querySelectorAll('img, video'))
        .map(e => e.getBoundingClientRect())
        .filter(r => r.width >= 96 && r.height >= 96)
        .map(r => r.top)
        .filter(t => t >= rr.top)
        .sort((a, b) => a - b)[0];
      return Math.min(
        Number.isFinite(mt) ? mt : Infinity,
        validAt !== undefined ? validAt : Infinity,
        rr.bottom
      );
    }

    static #makeExclusionChecker(root, boundaryTop, postInfo) {
      return function (el) {
        if (!el || !root.contains(el)) return true;
        if (el.closest('.tp-copy-tool-button, .tp-link-tool-button, #tp-toast, #tp-lightbox')) return true;
        const ia = el.closest('button, [role="button"], nav');
        if (ia && ia !== root && root.contains(ia)) return true;
        const link = el.closest('a[href]');
        if (link && (link === el || link.contains(el))) {
          const href = link.getAttribute('href') || '';
          if (/\/post\//i.test(href)) {
            const li = DomService.parsePostInfoFromUrl(href);
            if (!(postInfo?.postId && li?.postId === postInfo.postId)) return true;
          } else if (/\/@[^/]+\/?$|\/search(?:\?|$)/i.test(href)) {
            return true;
          }
        }
        if (el.querySelector('time, img, video')) return true;
        const r = el.getBoundingClientRect();
        if (!DomService.isVisibleRect(r) || r.top >= boundaryTop || r.bottom <= root.getBoundingClientRect().top) return true;
        const text = TextService.#getRenderedText(el);
        if (!text) return true;
        if (postInfo?.author && text.replace(/^@/, '') === postInfo.author.replace(/^@/, '')) return true;
        if (/^\d[\d,.]*\s*$/.test(text)) return true;
        if (/^\d+\s*(秒|分鐘?|分|小時|天|週|周|個月|月|年|秒|分|時間|日|週間|ヶ月|年|초|분|시간|일|주|개월|년)\s*$/.test(text)) return true;
        return false;
      };
    }

    static #scoreElement(el, root, boundaryTop) {
      const text = TextService.#getRenderedText(el);
      const r = el.getBoundingClientRect(),
            rr = root.getBoundingClientRect();
      const lines = text.split('\n').length;
      const ws = getComputedStyle(el).whiteSpace || '';
      const hasNested = !!el.querySelector('button, [role="button"], img, video, time');
      let score = text.length * 8 + Math.min(lines, 20) * 30;
      score += el.matches('[dir="auto"]') ? 420 : 0;
      score += /pre|break-spaces/.test(ws) ? 220 : 0;
      score += r.width >= 180 ? 80 : 0;
      score += Math.min(160, Math.max(0, r.top - rr.top) * 0.45);
      if (hasNested) score -= 900;
      if (r.bottom > boundaryTop + 4) score -= 500;
      if (text.length <= 2) score -= 80;
      return score;
    }

    static extract(root, actionBar) {
      if (!root) return '';
      const cached = TextService.#cacheGet(root);
      if (cached !== undefined) return cached;

      const boundaryTop = TextService.#computeBoundaryTop(root, actionBar);
      const postInfo = TextService.findBestPostInfo(root, actionBar || root, true) ||
                       TextService.findPostInfo(root);
      const isExcluded = TextService.#makeExclusionChecker(root, boundaryTop, postInfo);
      const MAX = 500;

      const collect = (els) => {
        if (els.length > MAX) els = els.slice(0, MAX);
        return els
          .filter(el => !isExcluded(el))
          .map(el => ({
            el,
            text: TextService.cleanFragment(TextService.#getRenderedText(el)),
            rect: el.getBoundingClientRect(),
            score: TextService.#scoreElement(el, root, boundaryTop)
          }))
          .filter(i => i.text)
          .sort((a, b) => b.score - a.score);
      };

      let candidates = collect(Array.from(root.querySelectorAll('[dir="auto"]')));
      if (!candidates.length) candidates = collect(Array.from(root.querySelectorAll('p, div, span')));

      const ordered = candidates
        .filter(i => !candidates.some(o => o !== i && i.el.contains(o.el) && o.text === i.text))
        .sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);

      const fragments = [];
      ordered.forEach(i => {
        if (!i.text) return;
        if (fragments.some(f => f === i.text || f.includes(i.text))) return;
        for (let idx = fragments.length - 1; idx >= 0; idx--) {
          if (i.text.includes(fragments[idx])) fragments.splice(idx, 1);
        }
        fragments.push(i.text);
      });
      const result = TextService.#stripCarouselCounter(fragments.join('\n'));
      TextService.#cacheSet(root, result);
      return result;
    }
  }

  class StyleManager {
    static #INJECTED = false;

    static inject() {
      if (StyleManager.#INJECTED) return;
      StyleManager.#INJECTED = true;
      const css = StyleManager.#buildCSS();
      if (typeof GM_addStyle === 'function') {
        GM_addStyle(css);
      } else {
        const style = document.createElement('style');
        style.id = 'tp-styles';
        style.textContent = css;
        document.documentElement.appendChild(style);
      }
    }

    static #buildCSS() {
      const btn = `.tp-button-wrapper{display:inline-flex!important;align-items:center!important;gap:2px!important;vertical-align:middle!important}.tp-copy-tool-button,.tp-link-tool-button{width:clamp(32px,8vw,40px)!important;height:clamp(28px,7vw,36px)!important;min-width:clamp(32px,8vw,40px)!important;flex:0 0 auto!important;border:0!important;border-radius:999px!important;padding:0!important;color:rgb(228,230,235)!important;background:transparent!important;display:inline-flex!important;align-items:center!important;justify-content:center!important;cursor:pointer!important;pointer-events:auto!important;margin:0!important;line-height:1!important;transition:background .15s!important}.tp-copy-tool-button:hover,.tp-link-tool-button:hover{background:rgba(255,255,255,0.08)!important}.tp-btn-active{background:rgba(0,122,255,0.25)!important}.tp-copy-tool-button svg,.tp-link-tool-button svg{width:clamp(16px,4vw,20px)!important;height:clamp(16px,4vw,20px)!important;display:block!important;fill:none!important;stroke:currentColor!important;stroke-width:1.75!important;stroke-linecap:round!important;stroke-linejoin:round!important;transform:translateY(0.5px)!important;pointer-events:none!important}`;

      const lightbox = `
        #tp-lightbox{position:fixed!important;inset:0!important;z-index:2147483646!important;background:rgba(0,0,0,0.92)!important;display:flex!important;align-items:center!important;justify-content:center!important;user-select:none!important;-webkit-user-select:none!important;transition:opacity .35s cubic-bezier(0.4,0,0.2,1)!important}
        #tp-lightbox[hidden]{display:none!important;opacity:0!important}
        #tp-lightbox:not([hidden]){opacity:1!important}

        /* 平滑模式使用 translateX,淡入模式不使用 */
        #tp-lightbox .tp-lb-track{display:flex!important;height:100%!important;align-items:center!important;will-change:transform!important;transition:transform .72s cubic-bezier(0.2,0.9,0.3,1)!important}
        #tp-lightbox .tp-lb-track.tp-lb-fade{display:block!important;transition:none!important;will-change:auto!important}

        /* 圖片輪播動畫:共用基礎 */
        #tp-lightbox .tp-lb-slide{flex:0 0 100%!important;display:flex!important;align-items:center!important;justify-content:center!important;height:100%!important;position:relative!important;overflow:hidden!important}

        /* 圖片輪播動畫:平滑模式 */
        #tp-lightbox .tp-lb-track:not(.tp-lb-fade) .tp-lb-slide{
          transition:transform .72s cubic-bezier(0.2,0.9,0.3,1),opacity .72s cubic-bezier(0.2,0.9,0.3,1),filter .72s cubic-bezier(0.2,0.9,0.3,1)!important;
          transform:scale(.88)!important; opacity:.25!important; filter:blur(8px)!important;
          will-change:transform,opacity,filter!important
        }
        #tp-lightbox .tp-lb-track:not(.tp-lb-fade) .tp-lb-slide.tp-active{
          transform:scale(1)!important; opacity:1!important; filter:blur(0)!important
        }

        /* 圖片輪播動畫:淡入模式 */
        #tp-lightbox .tp-lb-track.tp-lb-fade .tp-lb-slide{
          position:absolute!important; inset:0!important;
          transition:opacity .85s cubic-bezier(0.4,0,0.2,1),transform .85s cubic-bezier(0.4,0,0.2,1)!important;
          opacity:0!important; transform:scale(.95)!important;
          z-index:0!important; pointer-events:none!important;
          will-change:opacity,transform!important
        }
        #tp-lightbox .tp-lb-track.tp-lb-fade .tp-lb-slide.tp-active{
          opacity:1!important; transform:scale(1)!important;
          z-index:1!important; pointer-events:auto!important
        }

        /* 圖片載入動畫 */
        #tp-lightbox .tp-lb-slide img{
          max-width:92vw!important; max-height:85vh!important; object-fit:contain!important; border-radius:4px!important;
          opacity:0; transform:scale(1.03); filter:blur(3px);
          transition:opacity .6s cubic-bezier(0.4,0,0.2,1),transform .6s cubic-bezier(0.4,0,0.2,1),filter .6s cubic-bezier(0.4,0,0.2,1)!important;
          will-change:opacity,transform,filter!important
        }
        #tp-lightbox .tp-lb-slide img.tp-loaded{opacity:1; transform:scale(1); filter:blur(0px)}

        /* 旋轉動畫與導航按鈕 */
        #tp-lightbox .tp-lb-spinner{position:absolute!important;width:28px!important;height:28px!important;border:2.5px solid rgba(255,255,255,0.25)!important;border-top-color:#fff!important;border-radius:50%!important;animation:tp-spin .7s linear infinite!important;transition:opacity .4s cubic-bezier(0.4,0,0.2,1)!important;opacity:1}
        #tp-lightbox .tp-lb-spinner.tp-spinner-fade{opacity:0!important}
        @keyframes tp-spin{to{transform:rotate(360deg)}}
        #tp-lightbox .tp-lb-nav{position:absolute!important;top:50%!important;transform:translateY(-50%)!important;width:40px!important;height:40px!important;border:0!important;background:rgba(128,128,128,0.45)!important;backdrop-filter:blur(12px)!important;-webkit-backdrop-filter:blur(12px)!important;border-radius:50%!important;color:#fff!important;cursor:pointer!important;display:flex!important;align-items:center!important;justify-content:center!important;z-index:10!important;padding:0!important;transition:background .25s cubic-bezier(0.4,0,0.2,1)!important}
        #tp-lightbox .tp-lb-nav:hover{background:rgba(160,160,160,0.55)!important}
        #tp-lightbox .tp-lb-nav svg{width:20px!important;height:20px!important;stroke:white!important;stroke-width:2.5!important;stroke-linecap:round!important;stroke-linejoin:round!important;fill:none!important}
        #tp-lightbox .tp-lb-prev{left:16px!important}
        #tp-lightbox .tp-lb-next{right:16px!important}
        #tp-lightbox .tp-lb-counter{position:absolute!important;bottom:16px!important;left:50%!important;transform:translateX(-50%)!important;color:#aaa!important;font-size:13px!important;background:rgba(0,0,0,0.45)!important;backdrop-filter:blur(8px)!important;-webkit-backdrop-filter:blur(8px)!important;padding:4px 12px!important;border-radius:20px!important}
      `;

      return btn + lightbox;
    }
  }

  // ════════════════════════ 應用層 ════════════════════════

  class BaseModule {
    name = 'unnamed';
    bus;
    constructor(name, bus) {
      this.name = name;
      this.bus = bus;
    }
    init() {}
    refresh() {}
    destroy() {}
  }

  class AppKernel {
    #modules = [];
    #bus;
    #observer = null;
    #observerTarget = null;
    #hasForcedRefresh = false;
    #lastRefreshTime = 0;
    #refreshTimer = null;
    #scrollHandler = null;
    #resizeHandler = null;
    #precomputeTimer = null;
    static #THROTTLE_MS = 200;

    constructor(bus) {
      this.#bus = bus;
    }

    register(ModuleClass) {
      const instance = new ModuleClass(this.#bus);
      if (typeof instance.init !== 'function') {
        Logger.warn('模組缺少 init 方法,已跳過', instance.name);
        return;
      }
      this.#modules.push(instance);
    }

    start() {
      Config._eventBus = this.#bus;
      Config.load();
      this.#bus.on('refresh-all', () => this.refreshAll());
      this.#modules.forEach(mod => {
        try { mod.init(); } catch (e) { Logger.warn(`模組初始化失敗:${mod.name}`, e); }
      });
      this.#updateObserverTarget();
      this.#bindGlobalEvents();
      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => this.#forceRefreshAll());
      } else {
        this.#forceRefreshAll();
      }
      Logger.info('已啟動');
    }

    stop() {
      if (this.#refreshTimer !== null) {
        clearTimeout(this.#refreshTimer);
        this.#refreshTimer = null;
      }
      if (this.#observer) {
        this.#observer.disconnect();
        this.#observer = null;
      }
      this.#observerTarget = null;
      if (this.#scrollHandler) {
        document.removeEventListener('scroll', this.#scrollHandler, { capture: true });
        this.#scrollHandler = null;
      }
      if (this.#resizeHandler) {
        window.removeEventListener('resize', this.#resizeHandler, { capture: true });
        this.#resizeHandler = null;
      }
      if (this.#precomputeTimer) {
        clearTimeout(this.#precomputeTimer);
        this.#precomputeTimer = null;
      }
      this.#modules.forEach(mod => {
        try { if (typeof mod.destroy === 'function') mod.destroy(); } catch (e) { Logger.warn(`模組 destroy 失敗:${mod.name}`, e); }
      });
      this.#hasForcedRefresh = false;
      this.#lastRefreshTime = 0;
      Logger.info('已停止');
    }

    refreshAll() {
      this.#doRefresh();
    }

    #doRefresh() {
      this.#lastRefreshTime = Date.now();
      this.#modules.forEach(mod => {
        try { if (typeof mod.refresh === 'function') mod.refresh(); } catch (e) { Logger.warn(`refresh 失敗:${mod.name}`, e); }
      });
      this.#scheduleIdlePrecompute();
    }

    #scheduleRefresh() {
      this.#updateObserverTarget();
      if (this.#refreshTimer !== null) {
        clearTimeout(this.#refreshTimer);
        this.#refreshTimer = null;
      }
      const elapsed = Date.now() - this.#lastRefreshTime;
      if (elapsed >= AppKernel.#THROTTLE_MS) {
        this.#doRefresh();
      } else {
        this.#refreshTimer = setTimeout(() => {
          this.#refreshTimer = null;
          this.#doRefresh();
        }, AppKernel.#THROTTLE_MS - elapsed);
      }
    }

    #resolveBestTarget() {
      return document.querySelector('[role="main"]') ||
             document.querySelector('[data-testid="primaryColumn"]') ||
             document.querySelector('main') ||
             document.body;
    }

    #updateObserverTarget() {
      const newTarget = this.#resolveBestTarget();
      if (newTarget === this.#observerTarget) return;
      if (this.#observer) {
        this.#observer.disconnect();
        this.#observer = null;
      }
      this.#observerTarget = newTarget;
      if (!this.#observerTarget) return;
      this.#observer = new MutationObserver(mutations => {
        const needs = mutations.some(m => {
          if (m.type === 'attributes') return m.attributeName === 'src' || m.attributeName === 'srcset';
          const changed = [...Array.from(m.addedNodes || []), ...Array.from(m.removedNodes || [])];
          return changed.some(n => {
            const el = n.nodeType === Node.ELEMENT_NODE ? n : n.parentElement;
            if (!el) return false;
            return el.matches?.('article, [role="article"]') || el.querySelector?.('svg');
          });
        });
        if (needs) this.#scheduleRefresh();
      });
      this.#observer.observe(this.#observerTarget, {
        childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset']
      });
    }

    #bindGlobalEvents() {
      this.#scrollHandler = () => this.#scheduleRefresh();
      this.#resizeHandler = () => this.#scheduleRefresh();
      document.addEventListener('scroll', this.#scrollHandler, { passive: true, capture: true });
      window.addEventListener('resize', this.#resizeHandler, { passive: true, capture: true });
    }

    #forceRefreshAll() {
      if (this.#hasForcedRefresh) return;
      this.#hasForcedRefresh = true;
      const schedule = fn => requestIdleCallback ? requestIdleCallback(fn, { timeout: 100 }) : setTimeout(fn, 50);
      schedule(() => this.#doRefresh());
    }

    #scheduleIdlePrecompute() {
      if (this.#precomputeTimer) clearTimeout(this.#precomputeTimer);
      this.#precomputeTimer = setTimeout(() => {
        this.#precomputeTimer = null;
        if (typeof requestIdleCallback !== 'undefined') {
          requestIdleCallback(() => TextService.precomputeVisiblePosts(), { timeout: 200 });
        } else {
          TextService.precomputeVisiblePosts();
        }
      }, 500);
    }
  }

  // ════════════════════════ 模組層 ════════════════════════

  class CopyButtonsModule extends BaseModule {
    #copyBtnByRoot = new WeakMap();
    #linkBtnByRoot = new WeakMap();
    #copyBtnByShare = new WeakMap();
    #linkBtnByShare = new WeakMap();
    #copyCtx = new WeakMap();
    #linkCtx = new WeakMap();
    #liveCopyBtns = new Set();
    #liveLinkBtns = new Set();
    #liveWrappers = new Set();
    #lastLinkCopy = null;
    #lastTextCopy = null;

    constructor(bus) {
      super('copy-buttons', bus);
    }

    init() {
      this.bus.on('setting-changed', ({ key }) => {
        if (key === 'enableLinkCopy' || key === 'enableTextCopy') this.refresh();
      });
      this.bus.on('settings-reset', () => this.refresh());
    }

    refresh() {
      if (!document.body) return;
      const linkEnabled = Config.get('enableLinkCopy'),
            copyEnabled = Config.get('enableTextCopy');

      if (this.#lastLinkCopy !== linkEnabled) {
        if (!linkEnabled) {
          this.#liveLinkBtns.forEach(b => b.remove());
          this.#liveLinkBtns.clear();
          this.#linkBtnByRoot = new WeakMap();
          this.#linkBtnByShare = new WeakMap();
          this.#linkCtx = new WeakMap();
        }
        this.#lastLinkCopy = linkEnabled;
      }
      if (this.#lastTextCopy !== copyEnabled) {
        if (!copyEnabled) {
          this.#liveCopyBtns.forEach(b => b.remove());
          this.#liveCopyBtns.clear();
          this.#copyBtnByRoot = new WeakMap();
          this.#copyBtnByShare = new WeakMap();
          this.#copyCtx = new WeakMap();
        }
        this.#lastTextCopy = copyEnabled;
      }

      this.#scanAndInsert();
      this.#handleDetailPage();
    }

    #scanAndInsert() {
      const activeCopy = new Set(),
            activeLink = new Set(),
            activeWrappers = new Set(),
            seenRoots = new Set(),
            seenSlots = new Set();

      document.querySelectorAll('svg[aria-label]').forEach(svg => {
        if (!DomService.isShareSvg(svg)) return;
        const shareBtn = DomService.findShareIconSlot(svg, document.body);
        if (!shareBtn || seenSlots.has(shareBtn)) return;
        seenSlots.add(shareBtn);
        if (!DomService.isCompactIconRect(shareBtn.getBoundingClientRect())) return;
        const root = this.#resolveRoot(shareBtn);
        if (!root || seenRoots.has(root)) return;
        seenRoots.add(root);

        let wrapper = shareBtn.parentElement?.querySelector?.(':scope > .tp-button-wrapper');
        if (!wrapper) {
          wrapper = document.createElement('span');
          wrapper.className = 'tp-button-wrapper';
          shareBtn.after(wrapper);
        }
        activeWrappers.add(wrapper);

        if (Config.get('enableLinkCopy')) {
          const b = this.#getOrCreateLinkBtn(root, shareBtn);
          if (!wrapper.contains(b)) wrapper.appendChild(b);
          activeLink.add(b);
        }
        if (Config.get('enableTextCopy')) {
          const b = this.#getOrCreateCopyBtn(root, shareBtn);
          if (!wrapper.contains(b)) wrapper.appendChild(b);
          activeCopy.add(b);
        }

        const first = wrapper.querySelector('.tp-link-tool-button'),
              second = wrapper.querySelector('.tp-copy-tool-button');
        if (first && second && first.nextElementSibling !== second) {
          wrapper.insertBefore(first, second);
        }
      });

      this.#liveCopyBtns.forEach(b => { if (!activeCopy.has(b)) { b.remove(); this.#liveCopyBtns.delete(b); } });
      this.#liveLinkBtns.forEach(b => { if (!activeLink.has(b)) { b.remove(); this.#liveLinkBtns.delete(b); } });
      this.#liveWrappers.forEach(w => { if (!activeWrappers.has(w)) { w.remove(); this.#liveWrappers.delete(w); } });
      activeWrappers.forEach(w => this.#liveWrappers.add(w));
    }

    #resolveRoot(shareBtn) {
      const copyBtn = this.#copyBtnByShare.get(shareBtn);
      if (copyBtn) {
        const ctx = this.#copyCtx.get(copyBtn);
        if (ctx?.root?.isConnected) return ctx.root;
      }
      return DomService.findPostBlockRoot(shareBtn);
    }

    #feedback(btn, html) {
      if (btn.dataset.tpFeedback) return;
      btn.dataset.tpFeedback = '1';
      btn.innerHTML = Utils.SVG_CHECK;
      btn.classList.add('tp-btn-active');
      setTimeout(() => {
        btn.innerHTML = html;
        btn.classList.remove('tp-btn-active');
        delete btn.dataset.tpFeedback;
      }, 600);
    }

    #getOrCreateLinkBtn(root, shareBtn) {
      let btn = this.#linkBtnByShare.get(shareBtn) || this.#linkBtnByRoot.get(root);
      const html = '<svg aria-hidden="true" viewBox="0 0 24 24"><rect x="8" y="8" width="13" height="13" rx="2"></rect><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"></path></svg>';

      if (!btn || !btn.isConnected) {
        btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'tp-link-tool-button';
        btn.title = I18n.t('copyLinkTitle');
        btn.setAttribute('aria-label', I18n.t('copyLinkTitle'));
        btn.innerHTML = html;
        btn.addEventListener('click', e => {
          Utils.blockEvent(e);
          this.#feedback(btn, html);
          const ctx = this.#linkCtx.get(btn);
          const info = ctx?.root
            ? (TextService.findBestPostInfo(ctx.root, ctx.shareButton, true) || TextService.findPostInfo(ctx.root))
            : DomService.parsePostInfoFromUrl(location.href);
          const url = DomService.buildCleanUrl(info || DomService.parsePostInfoFromUrl(location.href));
          if (!url) return;
          Utils.copyToClipboard(url);
        }, true);
        this.#linkBtnByRoot.set(root, btn);
        this.#linkBtnByShare.set(shareBtn, btn);
        this.#linkCtx.set(btn, { root, shareButton: shareBtn });
        this.#liveLinkBtns.add(btn);
      } else {
        this.#linkBtnByRoot.set(root, btn);
        this.#linkBtnByShare.set(shareBtn, btn);
        this.#linkCtx.set(btn, { root, shareButton: shareBtn });
      }
      return btn;
    }

    #getOrCreateCopyBtn(root, shareBtn) {
      let btn = this.#copyBtnByShare.get(shareBtn) || this.#copyBtnByRoot.get(root);
      const html = '<svg aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="3" width="16" height="18" rx="2"></rect><path d="M8 8h8M8 12h8M8 16h8"></path></svg>';

      if (!btn || !btn.isConnected) {
        btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'tp-copy-tool-button';
        btn.title = I18n.t('copyTextTitle');
        btn.setAttribute('aria-label', I18n.t('copyTextTitle'));
        btn.innerHTML = html;
        btn.addEventListener('click', e => {
          Utils.blockEvent(e);
          this.#feedback(btn, html);
          const ctx = this.#copyCtx.get(btn);
          const text = TextService.extract(ctx.root, ctx.actionBar);
          if (!text) return;
          Utils.copyToClipboard(text);
        }, true);
        this.#copyBtnByRoot.set(root, btn);
        this.#copyBtnByShare.set(shareBtn, btn);
        this.#copyCtx.set(btn, { root, shareButton: shareBtn, actionBar: shareBtn.parentElement });
        this.#liveCopyBtns.add(btn);
      } else {
        this.#copyBtnByRoot.set(root, btn);
        this.#copyBtnByShare.set(shareBtn, btn);
        this.#copyCtx.set(btn, { root, shareButton: shareBtn, actionBar: shareBtn.parentElement });
      }
      return btn;
    }

    #handleDetailPage() {
      if (!DomService.getCurrentDetailPostInfo()) return;
      const root = this.#findDetailRoot();
      if (!root) return;
      const shareBtn = this.#findDetailShareBtn(root);
      if (!shareBtn) return;

      let wrapper = shareBtn.parentElement?.querySelector?.(':scope > .tp-button-wrapper');
      if (!wrapper) {
        wrapper = document.createElement('span');
        wrapper.className = 'tp-button-wrapper';
        shareBtn.after(wrapper);
      }

      if (Config.get('enableLinkCopy')) {
        const b = this.#linkBtnByRoot.get(root) || this.#linkBtnByShare.get(shareBtn);
        if (b && !wrapper.contains(b)) wrapper.appendChild(b);
      }
      if (Config.get('enableTextCopy')) {
        const b = this.#copyBtnByRoot.get(root) || this.#copyBtnByShare.get(shareBtn);
        if (b && !wrapper.contains(b)) wrapper.appendChild(b);
      }

      const first = wrapper.querySelector('.tp-link-tool-button'),
            second = wrapper.querySelector('.tp-copy-tool-button');
      if (first && second && first.nextElementSibling !== second) {
        wrapper.insertBefore(first, second);
      }
    }

    #findDetailRoot() {
      const articles = document.querySelectorAll('article, [role="article"]');
      return Array.from(articles)
        .filter(n => {
          const r = n.getBoundingClientRect();
          return r.width > 260 && r.height > 140 && r.bottom > 0 && r.top < innerHeight;
        })
        .sort((a, b) => {
          const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
          return (Math.abs(ra.top - 80) + Math.max(0, ra.left)) -
                 (Math.abs(rb.top - 80) + Math.max(0, rb.left));
        })[0] || null;
    }

    #findDetailShareBtn(root) {
      const detailInfo = DomService.getCurrentDetailPostInfo();
      const searchRoots = [{ node: root, rp: true }, { node: document.body, rp: false }];
      const seen = new Set(), candidates = [];

      searchRoots.forEach(({ node: sr, rp }) => {
        sr.querySelectorAll('svg[aria-label], svg').forEach(svg => {
          if (seen.has(svg)) return;
          seen.add(svg);
          if (!DomService.isShareSvg(svg)) return;
          const slot = DomService.findShareIconSlot(svg, sr);
          const r = slot?.getBoundingClientRect?.();
          if (!slot || !r || r.width < 18 || r.height < 18) return;
          if (r.bottom < 0 || r.top > innerHeight) return;
          const blockRoot = DomService.findPostBlockRoot(slot);
          const blockInfo = blockRoot ? TextService.findBestPostInfo(blockRoot, slot, true) : null;
          let score = this.#detailShareScore(root, { slot }, rp);
          score += (blockInfo && detailInfo?.postId && blockInfo.postId === detailInfo.postId) ? -50000 : 50000;
          candidates.push({ svg, slot, rect: r, score });
        });
      });
      candidates.sort((a, b) => a.score - b.score);
      return candidates[0]?.slot || null;
    }

    #detailShareScore(root, item, rp) {
      const rr = root?.getBoundingClientRect?.(), r = item.slot.getBoundingClientRect();
      let score = 0;
      if (rp) score -= 20000;
      if (rr && r.top >= rr.top && r.bottom <= rr.bottom + 120) score -= 10000;
      score += r.top + Math.abs(r.left - 360) * 0.15 + Math.max(0, r.top - innerHeight) * 10 + Math.max(0, -r.top) * 10;
      return score;
    }
  }

  class ShareMenuModule extends BaseModule {
    #pendingContext = null;
    #suppressUntil = 0;
    #menuObserver = null;
    #retryCount = 0;
    #retryDelays = [100, 150, 250];
    #pointerDownHandler;

    constructor(bus) {
      super('share-menu', bus);
    }

    init() {
      this.#pointerDownHandler = e => this.#captureContext(e);
      document.addEventListener('pointerdown', this.#pointerDownHandler, true);
      this.#menuObserver = new MutationObserver(mutations => {
        if (!this.#pendingContext) return;
        for (const m of mutations) {
          for (const node of m.addedNodes) {
            if (node.nodeType === Node.ELEMENT_NODE && (
              node.matches?.('[role="menu"], [role="menuitem"], [role="listbox"]') ||
              node.querySelector?.('[role="menu"], [role="menuitem"], [role="listbox"]')
            )) {
              requestAnimationFrame(() => this.#tryInject());
              return;
            }
          }
        }
      });
      this.#menuObserver.observe(document.documentElement, { childList: true, subtree: true });
      this.bus.on('setting-changed', ({ key }) => { if (key === 'enableLinkCopy') this.refresh(); });
    }

    refresh() {
      if (!Config.get('enableLinkCopy')) document.querySelectorAll('.tp-clean-link-menu-item').forEach(el => el.remove());
    }

    destroy() {
      if (this.#pointerDownHandler) {
        document.removeEventListener('pointerdown', this.#pointerDownHandler, true);
        this.#pointerDownHandler = null;
      }
      if (this.#menuObserver) {
        this.#menuObserver.disconnect();
        this.#menuObserver = null;
      }
    }

    #captureContext(e) {
      if (!Config.get('enableLinkCopy') || Date.now() < this.#suppressUntil) return;
      const shareSvg = (() => {
        const path = e.composedPath?.() || [];
        return path.find(n => n?.tagName?.toLowerCase?.() === 'svg' && DomService.isShareSvg(n))
               || e.target?.closest?.('svg[aria-label]');
      })();
      if (!shareSvg) return;
      const shareBtn = DomService.findShareIconSlot(shareSvg, document.body) ||
                       DomService.findClickableAncestor(shareSvg, document.body);
      if (!shareBtn) return;
      const root = DomService.findPostBlockRoot(shareBtn) || shareBtn.closest?.('article,[role="article"]');
      const info = root
        ? (TextService.findBestPostInfo(root, shareBtn, true) || TextService.findPostInfo(root))
        : DomService.parsePostInfoFromUrl(location.href);
      const cleanUrl = DomService.buildCleanUrl(info || DomService.parsePostInfoFromUrl(location.href));
      if (!cleanUrl) return;
      this.#pendingContext = { cleanUrl, shareButton: shareBtn, createdAt: Date.now() };
      this.#retryCount = 0;
    }

    #tryInject() {
      const ctx = this.#pendingContext;
      if (!ctx?.cleanUrl || Date.now() - ctx.createdAt > 8000) {
        this.#pendingContext = null; this.#retryCount = 0; return false;
      }
      if (document.querySelector('.tp-clean-link-menu-item')) return true;
      const native = this.#findNativeItem();
      if (!native) {
        if (this.#retryCount < this.#retryDelays.length) {
          setTimeout(() => this.#tryInject(), this.#retryDelays[this.#retryCount++]);
          return false;
        }
        this.#pendingContext = null; this.#retryCount = 0; return false;
      }
      const cleanItem = native.cloneNode(true);
      cleanItem.classList.add('tp-clean-link-menu-item');
      cleanItem.removeAttribute('id');
      cleanItem.querySelectorAll('[id]').forEach(e => e.removeAttribute('id'));
      this.#replaceLabel(cleanItem);
      cleanItem.addEventListener('pointerdown', Utils.blockEvent, true);
      cleanItem.addEventListener('mousedown', Utils.blockEvent, true);
      cleanItem.addEventListener('click', e => {
        Utils.blockEvent(e);
        Utils.copyToClipboard(ctx.cleanUrl);
        this.#pendingContext = null; this.#retryCount = 0;
        this.#closeMenu(ctx, cleanItem);
      }, true);
      native.before(cleanItem);
      this.#pendingContext = null; this.#retryCount = 0;
      return true;
    }

    #findNativeItem() {
      const items = document.querySelectorAll('[role="menuitem"], [role="button"], button, [tabindex="0"]');
      return Array.from(items)
        .filter(el => !el.classList.contains('tp-clean-link-menu-item'))
        .filter(el => /^(複製連結|Copy link|リンクをコピー|링크 복사)$/i.test(
          (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim()
        ))
        .filter(el => {
          const r = el.getBoundingClientRect();
          return r.width > 80 && r.height > 24 && r.bottom > 0 && r.right > 0 &&
                 r.top < innerHeight && r.left < innerWidth;
        })
        .sort((a, b) => a.getBoundingClientRect().width * a.getBoundingClientRect().height -
                         b.getBoundingClientRect().width * b.getBoundingClientRect().height)[0] || null;
    }

    #replaceLabel(item) {
      const walker = document.createTreeWalker(item, NodeFilter.SHOW_TEXT);
      const nodes = [];
      let n = walker.nextNode();
      while (n) { nodes.push(n); n = walker.nextNode(); }
      const target = nodes.find(tn => /^(複製連結|Copy link|リンクをコピー|링크 복사)$/i.test(
        (tn.nodeValue || '').replace(/\s+/g, ' ').trim()
      ));
      if (target) target.nodeValue = I18n.t('cleanLinkMenu');
      item.setAttribute('aria-label', I18n.t('cleanLinkMenu'));
      item.title = I18n.t('cleanLinkMenu');
    }

    #closeMenu(ctx, cleanItem) {
      const t = document.activeElement instanceof Element ? document.activeElement : document.body;
      t.dispatchEvent(new KeyboardEvent('keydown', {
        key: 'Escape', code: 'Escape', keyCode: 27, which: 27,
        bubbles: true, cancelable: true, composed: true
      }));
      t.dispatchEvent(new KeyboardEvent('keyup', {
        key: 'Escape', code: 'Escape', keyCode: 27, which: 27,
        bubbles: true, cancelable: true, composed: true
      }));
      setTimeout(() => {
        const isVis = el => {
          if (!el?.isConnected) return false;
          const r = el.getBoundingClientRect();
          const s = getComputedStyle(el);
          return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 0 && r.height > 0;
        };
        if (!isVis(cleanItem) && !isVis(this.#findNativeItem())) return;
        if (!ctx?.shareButton?.isConnected) return;
        this.#suppressUntil = Date.now() + 500;
        ctx.shareButton.click();
      }, 120);
    }
  }

  class CarouselModule extends BaseModule {
    #lightbox = null;
    #track = null;
    #slides = [];
    #currentIndex = 0;
    #slideElements = [];
    #preloadImages = new Map();
    #preloadedIdx = new Set();
    #preloadOrder = [];
    #onImageClickBound = null;
    #onKeyDownBound = null;

    constructor(bus) {
      super('carousel', bus);
    }

    init() {
      this.#onImageClickBound = e => this.#onImageClick(e);
      this.#onKeyDownBound = e => this.#onKeyDown(e);
      document.addEventListener('click', this.#onImageClickBound, true);
      document.addEventListener('keydown', this.#onKeyDownBound);
    }

    destroy() {
      if (this.#onImageClickBound) {
        document.removeEventListener('click', this.#onImageClickBound, true);
        this.#onImageClickBound = null;
      }
      if (this.#onKeyDownBound) {
        document.removeEventListener('keydown', this.#onKeyDownBound);
        this.#onKeyDownBound = null;
      }
      this.#close();
    }

    refresh() {}

    #onImageClick(e) {
      const img = e.target.closest?.('img');
      if (!img) return;
      if (img.closest('.tp-copy-tool-button, .tp-link-tool-button, #tp-lightbox')) return;
      const postRoot = img.closest?.('article,[role="article"]') || DomService.findPostBlockRoot(img);
      if (!postRoot) return;
      const allImages = Array.from(postRoot.querySelectorAll('img')).filter(im => {
        const r = im.getBoundingClientRect();
        const s = im.src || im.currentSrc || '';
        return r.width >= 60 && r.height >= 60 && !/profile_pic|avatar|emoji|sprite|static|favicon/i.test(s);
      });
      if (!allImages.length) return;
      const urls = allImages.map(im => im.src || im.currentSrc).filter(Boolean);
      if (!urls.length) return;
      const startIndex = allImages.indexOf(img);
      this.#openLightbox(urls, startIndex >= 0 ? startIndex : 0);
      Utils.blockEvent(e);
    }

    #openLightbox(urls, index) {
      if (this.#lightbox && !this.#lightbox.hidden) this.#close();
      if (!this.#lightbox) this.#createLightbox();
      this.#slides = urls;
      this.#currentIndex = Math.max(0, Math.min(index, urls.length - 1));
      this.#buildSlides();
      this.#preloadAround(this.#currentIndex);
      this.#loadImagesAround(this.#currentIndex, true);
      const isFade = Config.get('carouselEffect') === 'fade';
      this.#track.classList.toggle('tp-lb-fade', isFade);
      this.#syncActive();
      if (!isFade) this.#track.style.transform = `translateX(-${this.#currentIndex * 100}%)`;
      this.#lightbox.hidden = false;
      requestAnimationFrame(() => { this.#lightbox.style.opacity = ''; });
      this.#updateCounter();
    }

    #createLightbox() {
      this.#lightbox = document.createElement('div');
      this.#lightbox.id = 'tp-lightbox';
      this.#lightbox.hidden = true;
      this.#lightbox.style.opacity = '0';
      this.#lightbox.innerHTML = `
        <button class="tp-lb-nav tp-lb-prev" aria-label="${I18n.t('prev')}">
          <svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"></polyline></svg>
        </button>
        <button class="tp-lb-nav tp-lb-next" aria-label="${I18n.t('next')}">
          <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
        </button>
        <div class="tp-lb-track"></div>
        <div class="tp-lb-counter"></div>
      `;
      document.body.appendChild(this.#lightbox);
      this.#track = this.#lightbox.querySelector('.tp-lb-track');
      this.#lightbox.addEventListener('click', e => { if (e.target === this.#lightbox) this.#close(); });
      this.#lightbox.querySelector('.tp-lb-prev').addEventListener('click', e => { Utils.blockEvent(e); this.#navigate(-1); });
      this.#lightbox.querySelector('.tp-lb-next').addEventListener('click', e => { Utils.blockEvent(e); this.#navigate(1); });
    }

    #buildSlides() {
      this.#track.innerHTML = '';
      this.#slideElements = [];
      for (let i = 0; i < this.#slides.length; i++) {
        const slide = document.createElement('div');
        slide.className = 'tp-lb-slide';
        slide.dataset.index = i;
        const spinner = document.createElement('div');
        spinner.className = 'tp-lb-spinner';
        slide.appendChild(spinner);
        this.#track.appendChild(slide);
        this.#slideElements.push(slide);
      }
    }

    #onImageReady(slide, img) {
      const removeSpinner = () => {
        const spinner = slide.querySelector('.tp-lb-spinner');
        if (spinner) {
          spinner.classList.add('tp-spinner-fade');
          spinner.addEventListener('transitionend', () => spinner.remove(), { once: true });
        }
      };
      const showImage = () => {
        requestAnimationFrame(() => {
          img.classList.add('tp-loaded');
          removeSpinner();
        });
      };
      const onError = () => { img.remove(); removeSpinner(); };

      if (img.complete) {
        if (img.naturalWidth === 0 && img.naturalHeight === 0) {
          onError();
        } else {
          requestAnimationFrame(() => requestAnimationFrame(showImage));
        }
      } else {
        img.addEventListener('load', () => requestAnimationFrame(showImage), { once: true });
        img.addEventListener('error', onError, { once: true });
      }
    }

    #loadImagesAround(centerIdx, isInitial = false) {
      const total = this.#slides.length;
      if (!total) return;
      const loadRange = new Set();
      for (let off = -1; off <= 1; off++) {
        const idx = centerIdx + off;
        if (idx >= 0 && idx < total) loadRange.add(idx);
      }
      this.#slideElements.forEach((slide, idx) => {
        if (loadRange.has(idx)) {
          if (!slide.querySelector('img')) {
            const spinner = slide.querySelector('.tp-lb-spinner');
            if (spinner) spinner.classList.remove('tp-spinner-fade');
            const preloadedImg = this.#preloadImages.get(idx);
            if (preloadedImg && preloadedImg.complete && preloadedImg.naturalWidth > 0) {
              preloadedImg.classList.remove('tp-loaded');
              slide.appendChild(preloadedImg);
              this.#onImageReady(slide, preloadedImg);
            } else {
              const img = document.createElement('img');
              img.src = this.#slides[idx];
              img.alt = '';
              img.loading = (isInitial && idx === centerIdx) ? 'eager' : 'lazy';
              if (idx === centerIdx) img.setAttribute('fetchpriority', 'high');
              slide.appendChild(img);
              this.#onImageReady(slide, img);
            }
          }
        } else {
          const img = slide.querySelector('img');
          if (img) img.classList.remove('tp-loaded');
          if (!slide.querySelector('.tp-lb-spinner')) {
            const s = document.createElement('div');
            s.className = 'tp-lb-spinner';
            slide.appendChild(s);
          }
        }
      });
    }

    #preloadAround(centerIdx) {
      const total = this.#slides.length;
      const maxPreload = 6;
      for (let off = -1; off <= 1; off++) {
        const idx = centerIdx + off;
        if (idx >= 0 && idx < total && !this.#preloadedIdx.has(idx)) {
          const img = new Image();
          img.src = this.#slides[idx];
          this.#preloadImages.set(idx, img);
          this.#preloadedIdx.add(idx);
          this.#preloadOrder.push(idx);
          if (this.#preloadOrder.length > maxPreload) {
            const oldestIdx = this.#preloadOrder.shift();
            this.#preloadImages.delete(oldestIdx);
            this.#preloadedIdx.delete(oldestIdx);
          }
        }
      }
    }

    #syncActive() {
      this.#slideElements.forEach((s, i) => s.classList.toggle('tp-active', i === this.#currentIndex));
    }

    #navigate(delta) {
      const ni = this.#currentIndex + delta;
      if (ni < 0 || ni >= this.#slides.length) return;
      this.#currentIndex = ni;
      this.#preloadAround(this.#currentIndex);
      this.#loadImagesAround(this.#currentIndex);
      if (Config.get('carouselEffect') === 'fade') {
        this.#syncActive();
      } else {
        this.#track.style.transform = `translateX(-${this.#currentIndex * 100}%)`;
        this.#syncActive();
      }
      this.#updateCounter();
    }

    #goTo(idx) {
      if (idx < 0 || idx >= this.#slides.length) return;
      this.#currentIndex = idx;
      this.#preloadAround(this.#currentIndex);
      this.#loadImagesAround(this.#currentIndex);
      if (Config.get('carouselEffect') === 'fade') {
        this.#syncActive();
      } else {
        this.#track.style.transform = `translateX(-${this.#currentIndex * 100}%)`;
        this.#syncActive();
      }
      this.#updateCounter();
    }

    #updateCounter() {
      const c = this.#lightbox?.querySelector('.tp-lb-counter');
      if (c) c.textContent = `${this.#currentIndex + 1} / ${this.#slides.length}`;
    }

    #close() {
      if (!this.#lightbox) return;
      this.#lightbox.hidden = true;
      this.#lightbox.style.opacity = '0';
      this.#slides = [];
      this.#slideElements = [];
      this.#currentIndex = 0;
      for (const link of this.#preloadImages.values()) link.remove();
      this.#preloadImages.clear();
      this.#preloadedIdx.clear();
      this.#preloadOrder = [];
      this.#track.replaceChildren();
      this.#track.classList.remove('tp-lb-fade');
      this.#track.style.transform = '';
    }

    #onKeyDown(e) {
      const lb = document.getElementById('tp-lightbox');
      if (!lb || lb.hidden) return;
      if (e.key === 'Escape') this.#close();
      else if (e.key === 'ArrowLeft') this.#navigate(-1);
      else if (e.key === 'ArrowRight') this.#navigate(1);
      else if (e.key === 'Home') this.#goTo(0);
      else if (e.key === 'End') this.#goTo(this.#slides.length - 1);
    }
  }

  class TimeDisplayModule extends BaseModule {
    #timeAttr = 'data-tp-time';
    #origAttr = 'data-tp-original-time';

    constructor(bus) {
      super('time-display', bus);
    }

    init() {
      this.bus.on('setting-changed', ({ key }) => {
        if (key === 'enableAbsoluteTime') this.refresh();
      });
      this.bus.on('settings-reset', () => this.refresh());
    }

    refresh() {
      const enabled = Config.get('enableAbsoluteTime');
      document.querySelectorAll('time[datetime]').forEach(el => this.#process(el, enabled));
    }

    #process(el, enabled) {
      const iso = el.getAttribute('datetime');
      if (!iso) return;
      if (enabled) {
        if (!el.hasAttribute(this.#origAttr)) el.setAttribute(this.#origAttr, el.textContent || '');
        const formatted = Utils.formatAbsoluteTime(iso);
        if (formatted) {
          el.textContent = formatted;
          el.setAttribute(this.#timeAttr, '');
        }
        const absSpan = el.parentElement?.querySelector('.tp-abs-time');
        if (absSpan) absSpan.remove();
      } else {
        if (el.hasAttribute(this.#origAttr)) {
          el.textContent = el.getAttribute(this.#origAttr);
          el.removeAttribute(this.#origAttr);
        }
        el.removeAttribute(this.#timeAttr);
        const absSpan = el.parentElement?.querySelector('.tp-abs-time');
        if (absSpan) absSpan.remove();
      }
    }
  }

  /* 懸浮設定按鈕模組 */
  class FloatingMenuModule extends BaseModule {
    #btn = null;
    #menu = null;
    #isOpen = false;
    #onClickOutside = null;
    #styleId = 'tp-floating-menu-style';

    constructor(bus) {
      super('floating-menu', bus);
    }

    init() {
      this.#injectStyles();
      this.#createBtn();
      this.bus.on('setting-changed', () => { if (this.#isOpen) this.#renderMenuItems(); });
      this.bus.on('settings-reset', () => { if (this.#isOpen) this.#renderMenuItems(); });
    }

    destroy() {
      this.#btn?.remove();
      this.#closeMenu();
      this.#btn = null;
    }

    refresh() {
      if (!document.body.contains(this.#btn)) document.body.appendChild(this.#btn);
    }

    #createBtn() {
      if (this.#btn) return;
      this.#btn = document.createElement('button');
      this.#btn.id = 'tp-floating-btn';
      this.#btn.innerHTML = '⚙';
      this.#btn.title = 'Threads Plus 設定';
      this.#btn.addEventListener('pointerdown', (e) => {
        Utils.blockEvent(e);
        this.#toggleMenu();
      }, true);
      document.body.appendChild(this.#btn);
    }

    #toggleMenu() {
      if (this.#isOpen) this.#closeMenu();
      else this.#openMenu();
    }

    #openMenu() {
      this.#menu = document.createElement('div');
      this.#menu.id = 'tp-floating-menu';
      this.#renderMenuItems();
      document.body.appendChild(this.#menu);
      this.#btn.classList.add('tp-floating-btn-open');
      requestAnimationFrame(() => {
        void this.#menu.offsetWidth;
        requestAnimationFrame(() => this.#menu.classList.add('tp-floating-menu-visible'));
      });
      this.#isOpen = true;
      this.#onClickOutside = (e) => {
        if (!this.#btn?.contains(e.target) && !this.#menu?.contains(e.target)) this.#closeMenu();
      };
      document.addEventListener('click', this.#onClickOutside, true);
    }

    #closeMenu() {
      if (this.#menu) {
        this.#menu.classList.remove('tp-floating-menu-visible');
        this.#btn?.classList.remove('tp-floating-btn-open');
        const menu = this.#menu;
        this.#menu = null;
        this.#isOpen = false;
        setTimeout(() => { if (menu.parentNode) menu.remove(); }, 260);
      } else {
        this.#isOpen = false;
      }
      if (this.#onClickOutside) {
        document.removeEventListener('click', this.#onClickOutside, true);
        this.#onClickOutside = null;
      }
    }

    #renderMenuItems() {
      if (!this.#menu) return;
      const items = [
        { key: 'enableLinkCopy', labelKey: 'settingsEnableLink' },
        { key: 'enableTextCopy', labelKey: 'settingsEnableCopy' },
        { key: 'enableAbsoluteTime', labelKey: 'settingsEnableTime' },
      ];
      this.#menu.innerHTML = '';

      items.forEach(item => {
        const row = document.createElement('div');
        row.className = 'tp-menu-item';
        const isOn = Config.get(item.key);
        row.innerHTML = `<span class="tp-check">${isOn ? '✔' : '✘'}</span> <span class="tp-label">${I18n.t(item.labelKey)}</span>`;
        row.addEventListener('pointerdown', (e) => {
          Utils.blockEvent(e);
          const newValue = !Config.get(item.key);
          Config.set(item.key, newValue);
          const checkEl = row.querySelector('.tp-check');
          if (checkEl) this.#animateToggle(checkEl, newValue ? '✔' : '✘');
        });
        this.#menu.appendChild(row);
      });

      // 輪播動畫模式
      const animRow = document.createElement('div');
      animRow.className = 'tp-menu-item';
      const effect = Config.get('carouselEffect');
      animRow.innerHTML = `<span class="tp-check">⚙</span> <span class="tp-label">${I18n.t('settingsCarouselAnim')}:</span> <span class="tp-mode-text">${effect === 'slide' ? I18n.t('animSlide') : I18n.t('animFade')}</span>`;
      animRow.addEventListener('pointerdown', (e) => {
        Utils.blockEvent(e);
        const next = Config.get('carouselEffect') === 'slide' ? 'fade' : 'slide';
        Config.set('carouselEffect', next);
        const modeEl = animRow.querySelector('.tp-mode-text');
        if (modeEl) this.#animateToggle(modeEl, next === 'slide' ? I18n.t('animSlide') : I18n.t('animFade'));
      });
      this.#menu.appendChild(animRow);

      // 重置按鈕
      const resetRow = document.createElement('div');
      resetRow.className = 'tp-menu-item';
      resetRow.innerHTML = `<span class="tp-check">⟳</span> <span class="tp-label">${I18n.t('settingsReset')}</span>`;
      resetRow.addEventListener('pointerdown', (e) => {
        Utils.blockEvent(e);
        Config.reset();
        this.#renderMenuItems();
      });
      this.#menu.appendChild(resetRow);

      // 彈入動畫
      const targets = this.#menu.querySelectorAll('.tp-check, .tp-mode-text');
      targets.forEach(el => {
        el.style.opacity = '0';
        el.style.transform = 'scale(0.5)';
      });
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          targets.forEach(el => {
            el.style.opacity = '';
            el.style.transform = '';
          });
        });
      });
    }

    #animateToggle(el, newContent) {
      el.style.transition = 'transform 0.15s ease, opacity 0.15s ease';
      el.style.transform = 'scale(0)';
      el.style.opacity = '0';
      const finish = () => {
        el.removeEventListener('transitionend', finish);
        el.textContent = newContent;
        requestAnimationFrame(() => {
          el.style.transform = 'scale(1)';
          el.style.opacity = '1';
        });
      };
      el.addEventListener('transitionend', finish, { once: true });
      setTimeout(() => { if (el.style.transform === 'scale(0)') finish(); }, 200);
    }

    #injectStyles() {
      if (document.getElementById(this.#styleId)) return;
      const style = document.createElement('style');
      style.id = this.#styleId;
      style.textContent = `
        #tp-floating-btn {
          position:fixed;
          top: max(24px, env(safe-area-inset-top, 0px));
          right:24px;
          z-index:2147483640;
          width:48px; height:48px;
          border-radius:50%;
          background:rgba(32,32,34,0.85);
          backdrop-filter:blur(12px);
          -webkit-backdrop-filter:blur(12px);
          border:1px solid rgba(255,255,255,0.1);
          color:#e4e6eb;
          cursor:pointer;
          display:flex;
          align-items:center;
          justify-content:center;
          box-shadow:0 4px 12px rgba(0,0,0,0.4);
          font-size:20px;
          line-height:1;
          user-select:none;
          transition:transform 0.3s cubic-bezier(0.4,0,0.2,1);
        }
        #tp-floating-btn:hover { background:rgba(50,50,52,0.9); }
        #tp-floating-btn.tp-floating-btn-open { transform:rotate(90deg); }
        #tp-floating-menu {
          position:fixed;
          top: calc(max(24px, env(safe-area-inset-top, 0px)) + 56px);
          right:24px;
          z-index:2147483639;
          background:rgba(32,32,34,0.95);
          backdrop-filter:blur(16px);
          -webkit-backdrop-filter:blur(16px);
          border:1px solid rgba(255,255,255,0.12);
          border-radius:14px;
          padding:8px 0;
          color:#e4e6eb;
          font-family:system-ui,sans-serif;
          font-size:14px;
          min-width:220px;
          box-shadow:0 8px 24px rgba(0,0,0,0.5);
          opacity:0;
          transform:scale(0.9);
          transition:opacity 0.25s ease, transform 0.25s cubic-bezier(0.4,0,0.2,1);
          will-change:opacity,transform;
        }
        #tp-floating-menu.tp-floating-menu-visible { opacity:1; transform:scale(1); }
        .tp-menu-item { padding:10px 20px; cursor:pointer; display:flex; align-items:center; gap:8px; transition:background 0.15s; }
        .tp-menu-item:hover { background:rgba(255,255,255,0.08); }
        .tp-check, .tp-mode-text { display:inline-block; will-change:transform,opacity; }
        .tp-check { width:20px; text-align:center; }
      `;
      document.head.appendChild(style);
    }
  }

  // ════════════════════════ 啟動 ════════════════════════

  StyleManager.inject();
  const bus = new EventBus();
  const app = new AppKernel(bus);
  app.register(CopyButtonsModule);
  app.register(ShareMenuModule);
  app.register(CarouselModule);
  app.register(TimeDisplayModule);
  app.register(FloatingMenuModule);
  app.start();

})();