KONE +

Base64/braille decoder, DLsite/Steam product cards, link-health checker, and password auto-fill for kone.gg.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name         KONE +
// @namespace    https://kone.gg/kone-plus-KRUI
// @version      20.0
// @description  Base64/braille decoder, DLsite/Steam product cards, link-health checker, and password auto-fill for kone.gg.
// @description:ko  base64/점자 디코딩 · DLsite/Steam 링크 카드 · 링크 활성화 체크 · 비번 자동입력. kone.gg 전용.
// @author       KRUI
// @match        *://kone.gg/*
// @match        *://*.kone.gg/*
// @match        *://kio.ac/*
// @match        *://kiosk.ac/*
// @match        *://mega.nz/*
// @match        *://transfer.it/*
// @match        *://gofile.io/*
// @match        *://workupload.com/*
// @match        *://mypikpak.com/*
// @match        *://jstrieb.github.io/*
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      dlsite.com
// @connect      store.steampowered.com
// @connect      kio.ac
// @connect      kiosk.ac
// @connect      greasyfork.org
// @connect      update.greasyfork.org
// @connect      *
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const host = location.hostname.replace(/^www\./, '');

  /* ================================================================
     DOM 준비 후 실행
  ================================================================ */
  function domReady(fn) {
    if (document.readyState !== 'loading') fn();
    else document.addEventListener('DOMContentLoaded', fn, { once: true });
  }

  domReady(function () {

  /* ================================================================
     설정
  ================================================================ */
  const CFG = {
    CONTENT_DECODE:    GM_getValue('contentDecode',    true),
    LIST_DECODE:       GM_getValue('listDecode',       true),
    LINK_PANEL:        GM_getValue('linkPanel',        false),
    PANEL_TARGET:      GM_getValue('panelTarget',      'both'),
    LINK_CARD:         GM_getValue('linkCard',         true),
    GENRE_BAR:         GM_getValue('genreBar',         true),     // DLsite 장르/태그 링크를 본문 하단 '별도 바'로 모음 (요구1: 인라인 칩 증식 방지)
    LINK_CHECK:        GM_getValue('linkCheck',        true),
    DRAG_DECODE:       GM_getValue('dragDecode',       true),
    DLSITE_PREVIEW:    GM_getValue('dlsitePreview',    true),
    PREVIEW_META:          GM_getValue('previewMeta',          true),
    PREVIEW_TAGS:          GM_getValue('previewTags',          true),
    PREVIEW_TAGS_EXPANDED: GM_getValue('previewTagsExpanded',  false),
    SCROLL_TO_FIRST:   GM_getValue('scrollToFirst',    false),
    NO_DUPLICATE_CARD: GM_getValue('noDuplicateCard',  false),
    NAV_TARGET:        GM_getValue('navTarget',        'links'),
    PW_AUTO:           GM_getValue('pwAuto',           true),
    LIVE_APPLY:        GM_getValue('liveApply',        true),
    COPY_CODE:         GM_getValue('copyCode',         false),
    PREVIEW_SCALE:     GM_getValue('previewScale',     100),
    SETTINGS_SCALE:    GM_getValue('settingsScale',    100),
    LINK_CHECK_DELAY:  GM_getValue('linkCheckDelay',   0),
    TXT_LIVE:          GM_getValue('txtLive',          false),
    EXPAND_REPLIES:    GM_getValue('expandReplies',    true),
    EXPAND_MORE:       GM_getValue('expandMore',       true),
    NAV_TOAST:         GM_getValue('navToast',         true),
    COMMENT_TOOLS:     GM_getValue('commentTools',     true),    // 댓글 작성창에 점자/base64 변환 툴바 (요구1)
    BRAILLE_PANEL:     GM_getValue('braillePanel',     true),    // 작성 페이지 변환 패널
    BR_MODE:           GM_getValue('brMode',           'braille'), // 'braille' | 'base64'
    BR_COUNT:          GM_getValue('brCount',          1),        // base64 반복 횟수
    BR_LIVE:           GM_getValue('brLive',           true),     // 라이브 변환
    BR_PINNED:         GM_getValue('brPinned',         false),    // 변환 패널 항상 열어두기
    BRAILLE_DECODE:    GM_getValue('brailleDecode',    true),     // 읽기 페이지 점자→한글 자동 변환
    STEG_DECODE:       GM_getValue('stegDecode',       true),     // 읽기 페이지: 숨은 코드(StegCloak) 자동 복원, 실패 시 배지 (요구2)
    // 받침↔문장부호(ㅌ/?·ㅋ/!·ㅍ/.) 동형 보정용 빈출 종결음절. 받침으로 읽은 음절이 이 목록에
    // 있으면 어절 끝에서 받침으로 디코딩(없으면 문장부호). 공백 구분. 설정에서 편집.
    BRAILLE_FINAL:     GM_getValue('brailleFinal',      '끝 밑 밭 팥 솥 곁 볕 뭍 샅 녘 앞 옆 숲 잎 짚 늪 릎'),
    // 외부 변환기(dencode 등)의 UEB(영어) 점자도 디코딩. ON 시 URL/영어로 감지된 토큰만 UEB로 해석.
    BRAILLE_UEB:       GM_getValue('brailleUeb',        false),
    // GreasyFork 자동 업데이트 확인: 접속 시 최신 @version 조회 후 새 버전이면 알림(6시간 간격).
    UPDATE_CHECK:      GM_getValue('updateCheck',       true),
    // W/S(↑/↓)로 카드 이동 시 그 카드의 미리보기도 자동으로 띄움 (미리보기 ON 필요).
    KP_PREVIEW:        GM_getValue('kpPreview',         false),
    // W/S 미리보기 위치: 'card'(기존=카드 옆) | 'center'(정가운데) | 'left'(왼쪽) | 'right'(오른쪽 고정).
    KP_PREVIEW_POS:    GM_getValue('kpPreviewPos',      'card'),

    LIST_SELECTORS: [
      '.title', '.subject', '.list-title', '.gallery-title', '.item-title',
      'td.title', 'td.subject', 'td.title a', 'td.subject a',
      '.board-list td a', '.list-body td a',
      'li.list-item .title', '.post-list .title',
    ],

    DEAD_PATTERNS: {
      'kio.ac':         ['컬렉션을 찾을 수 없음', '만료되었습니다'],
      'kiosk.ac':       ['컬렉션을 찾을 수 없음', '만료되었습니다'],
      // mega.nz: 순수 SPA, 초기 HTML에 에러 없음, HEAD도 항상 200 → 감지 불가하여 제거
      'transfer.it':    ["can't find this transfer", 'oops', 'not found', 'expired'],
      // gofile: 죽으면 "This content does not exist" 문구가 페이지에 표시됨
      'gofile.io':      ['this content does not exist'],
      // workupload: 죽으면 "File not found" 표시, 또는 홈으로 리다이렉트
      'workupload.com': ['file not found', 'not found', 'expired', 'has been deleted', 'does not exist', 'no longer available'],
      // mypikpak: 죽으면 '죄송합니다. 공유 파일을 찾을 수 없습니다' 표시
      'mypikpak.com':   ['죄송합니다', '공유 파일을 찾을 수 없습니다', 'share has expired', 'been deleted', 'been detected', 'link is invalid', 'does not exist'],
    },

    PW_SITES: {
      'kio.ac': {
        inputSel:    '.overflow-auto.max-w-full.grow.p-1 input:nth-of-type(1)',
        btnSel:      '.flex.flex-col-reverse button:nth-of-type(1)',
        successSel:  '.files-list, #download-section',
        errorPat:    [/비밀번호가 일치하지 않/, /incorrect password/i, /invalid password/i],
        mode: 'kio',
      },
      'kiosk.ac': {
        inputSel:    '.input.shadow-xl.flex-grow',
        btnSel:      '.btn.btn-ghost.w-full.mt-2.rounded-md',
        successSel:  '#vexplorer-body',
        errorPat:    [/비밀번호가 일치하지 않/, /incorrect/i],
        mode: 'kio',
      },
      'mega.nz': {
        inputSel:    'input[name="decrypt-link"], #password-decrypt-input',
        btnSel:      'button.decrypt-button, .mega-component.decrypt-button',
        dlBtnSel:    '.mega-button.positive.js-default-download.js-standard-download, .mega-button.large.positive.download.continue-download',
        successSel:  '.mega-button.positive.js-default-download, .fm-item, .file-folder-view:not(.no-content) .item-type-folder',
        errorPat:    [/invalid password/i, /incorrect/i, /wrong password/i],
        mode: 'generic',
        triggerDelay: 2800,
        btnDelay: 500,  // React가 input 이벤트 처리 후 버튼 활성화까지 대기
      },
      'transfer.it': {
        inputSel:    'input[name="msg-dialog-input"]',
        btnSel:      'button.it-button.xl-size.js-positive-btn',
        dlBtnSel:    'section.it-box.lg-shadow.modal.ready-to-download-box button.it-button.xl-size.js-download, .it-button.xl-size[class*="download"]',
        errorPat:    [/incorrect password/i, /wrong password/i, /invalid/i],
        mode: 'transfer',
        triggerDelay: 1500,
      },
      'gofile.io': {
        inputSel:    '#filesErrorPasswordInput',
        btnSel:      '#filesErrorPasswordButton',
        successSel:  '.row.align-items-center.contentRow',
        errorPat:    [/incorrect/i, /wrong/i, /invalid/i],
        mode: 'generic',
        triggerDelay: 1000,
      },
      'workupload.com': {
        inputSel:    '#passwordprotected_file_password',
        btnSel:      '#passwordprotected_file_submit',
        dlBtnSel:    '.btn.btn-prio:not(.fa-unlock)',  // 비번 해제 후 다운로드 버튼
        mode: 'formpost',  // 전통적 form POST: 제출 시 페이지 리로드 → GM_setValue로 인덱스 유지
        triggerDelay: 1000,
      },
      'mypikpak.com': {
        inputSel:    '.el-input__inner[placeholder*="비밀번호"], .el-input__inner[placeholder*="password"], .el-input__inner[placeholder*="Password"], .el-input__inner',
        btnSel:      '.pp-primary-button, .el-button--primary.pp-primary-button',
        successSel:  '.file-list, [class*="file-list"], .drive-main',
        errorPat:    [/incorrect/i, /wrong/i, /invalid/i, /일치하지 않/, /틀렸/],
        mode: 'generic',
        triggerDelay: 1500,
        btnDelay: 500,  // 비번 입력 후 Element UI가 버튼 활성화(is-disabled 해제)할 때까지 대기
      },
      // link-lock (jstrieb.github.io/link-lock/#<암호화 데이터>): 브라우저 AES로 URL을 잠근 링크.
      //   오답 시 .error 박스 표시(+'Try again'=main() 재실행), 정답 시 숨긴 URL로 리다이렉트.
      'jstrieb.github.io': {
        inputSel:  '#password',
        btnSel:    '#unlockbutton',
        errorSel:  '.error.red-border, .error',       // 표시되면(visible) 오답
        retryText: /try again|다시\s*시도/i,           // 폼 복원 버튼(main())
        mode: 'linklock',
        triggerDelay: 800,
      },
    },
  };

  const DONE_ATTR       = 'data-b64d';
  const LTDONE_ATTR     = 'data-b64lt';
  const TITLE_CARD_ATTR = 'data-b64tc';
  const RAW_ATTR        = 'data-b64d-raw';  // processContentNode wrap에 원본 텍스트 저장 (즉시 적용 복원용)
  const ORIG_ATTR       = 'data-b64d-orig'; // convertProductLinks 교체 전 <a> outerHTML 저장 (즉시 적용 복원용)
  const MIN_B64         = 8;
  const KP_FOCUSED_SEL  = '.b64-link.kp-focused, .b64-product-link.kp-focused';
  const IS_MOBILE       = navigator.maxTouchPoints > 0;

  // 조상 중 이미 처리된(DONE_ATTR/LTDONE_ATTR) 노드가 있는지 확인
  // 단, processContentNode·trySiblingMerge·드래그가 만든 '투명 디코딩 래퍼'(display:contents)는
  // 조상으로 쳐서 막지 않는다(통과). 이 래퍼는 시각적으로 투명하고, 코네(SvelteKit)
  // 하이드레이션이 래퍼 안의 우리 카드를 자기 평문 <a>(DONE 없음)로 갈아끼우는 일이 있는데,
  // 그 <a>는 카드화 대상이지만 래퍼의 data-b64d 때문에 convertProductLinks가 통째로 건너뛰어
  // 'NO_DUP ON 상태로 페이지 진입 시 다운로드 카드 미생성'이 발생했다(진단 확증).
  //
  // ★투과 판정은 '계산값(computed) display'로 한다. 과거엔 인라인 프로퍼티 n.style.display로
  //   읽었는데, 래퍼가 'all:unset;display:contents;' cssText로 만들어진 데다 SvelteKit
  //   하이드레이션이 style 속성을 재직렬화하면 n.style.display가 'contents'로 안 읽히는
  //   경우가 있었다(인라인 읽기 불안정 → 페이지마다 카드화 성공/실패가 갈리던 간헐 버그의 원인).
  //   getComputedStyle은 실제 렌더링 display라 진단에서 입증된 'contents'를 안정적으로 반환한다.
  //   display:contents는 우리 투명 래퍼만 쓰므로(숨김블록=none, 카드=block/flex) 오판 없음.
  //   getComputedStyle 호출은 DONE 조상(=우리 래퍼/카드뿐, 드묾)일 때만 일어나 비용도 작다.
  function hasProcessedAncestor(node) {
    let n = node.parentElement;
    while (n) {
      if (n.hasAttribute(DONE_ATTR) || n.hasAttribute(LTDONE_ATTR)) {
        // 인라인이 이미 'contents'면 즉시 통과(빠른 경로), 아니면 계산값으로 재확인.
        let transparent = n.style && n.style.display === 'contents';
        if (!transparent) {
          try { transparent = getComputedStyle(n).display === 'contents'; } catch (e) {}
        }
        if (!transparent) return true;
      }
      n = n.parentElement;
    }
    return false;
  }
  // 최대 반복 디코딩 횟수: 다중 인코딩(6겹 이상)을 처리하기 위해 10으로 설정
  const MAX_DECODE  = 10;

  let HOTKEY     = GM_getValue('hotkey',    'Shift+Q');
  let DUP_HOTKEY = GM_getValue('dupHotkey', 'Shift+D');

  function buildCombo(e) {
    const parts = [];
    if (e.ctrlKey)  parts.push('Ctrl');
    if (e.altKey)   parts.push('Alt');
    if (e.shiftKey) parts.push('Shift');
    if (e.metaKey)  parts.push('Meta');
    const k = e.key;
    if (!['Control', 'Alt', 'Shift', 'Meta'].includes(k)) {
      parts.push(k.length === 1 ? k.toUpperCase() : k);
    }
    return parts.join('+');
  }
  document.addEventListener('keydown', e => {
    const tag = (document.activeElement?.tagName || '').toUpperCase();
    if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;
    if (e.key === 'Escape' && document.getElementById('b64d-settings')) {
      e.preventDefault(); closeSettingsPanel(); return;
    }
    // 단축키는 오버레이 상태와 무관하게 토글 (열었으면 같은 키로 닫을 수 있어야 함)
    if (buildCombo(e) === HOTKEY) { e.preventDefault(); openSettingsPanel(); return; }
    if (buildCombo(e) === DUP_HOTKEY) {
      e.preventDefault();
      const existing = document.getElementById('b64-dup-overlay');
      if (existing) existing.remove(); else showDupModal();
      return;
    }
    // (w/s/a/d 등 나머지 키는 각자의 핸들러가 오버레이 열림 여부를 자체 확인한다)
  });

  /* ================================================================
     비번 저장소
  ================================================================ */
  function getPwList() {
    try {
      const raw = GM_getValue('pwList', null);
      if (raw === null) return null;
      return JSON.parse(raw);
    } catch(e) { return []; }
  }
  function savePwList(arr) {
    GM_setValue('pwList', JSON.stringify(arr.filter(Boolean)));
  }

  /* ── 채널별 비번 (요구3) ──────────────────────────────────────────
     카드 생성 시 '다운로드 URL → 채널코드' 매핑을 GM 저장(채널코드는 카드의 '보이지 않는 값').
     다운로드 사이트(kio.ac 등)의 비번 자동입력이 자기 URL로 채널을 조회해, 그 채널에 정한
     비번만 시도한다(없으면 기존 전체 pwList 폴백). URL은 건드리지 않아 mega #키 등도 무손상. */
  function getChannelCode(href = location.href) {
    try { const m = new URL(href).pathname.match(/^\/s\/([^/?#]+)/); return m ? m[1] : null; } catch (e) { return null; }
  }
  function _kchKey(u) {
    try { const x = new URL(/^https?:\/\//i.test(u) ? u : 'https://' + u); return x.origin + x.pathname; }
    catch (e) { return String(u).split(/[?#]/)[0]; }
  }
  function getChannelUrlMap() {
    try { const r = GM_getValue('channelUrlMap', null); return r ? (JSON.parse(r) || {}) : {}; } catch (e) { return {}; }
  }
  function setChannelForUrl(url, ch) {
    if (!ch || !url) return;
    const map = getChannelUrlMap();
    map[_kchKey(url)] = { ch, ts: Date.now() };
    const keys = Object.keys(map);
    if (keys.length > 300) keys.sort((a, b) => (map[a].ts || 0) - (map[b].ts || 0)).slice(0, keys.length - 300).forEach(k => delete map[k]);
    try { GM_setValue('channelUrlMap', JSON.stringify(map)); } catch (e) {}
  }
  function getChannelForCurrentSite() {
    const rec = getChannelUrlMap()[_kchKey(location.href)];
    return rec ? rec.ch : null;
  }
  function getChannelPwMap() {
    try { const r = GM_getValue('channelPw', null); return r ? (JSON.parse(r) || {}) : {}; } catch (e) { return {}; }
  }
  function saveChannelPwMap(map) { try { GM_setValue('channelPw', JSON.stringify(map)); } catch (e) {} }

  /* ── 채널명(표시 이름) 매핑 ───────────────────────────────────────────
     비번 매칭·저장의 '내부 키'는 항상 채널 코드(/s/<코드>, URL에서만 얻음). 그 위에 코드→사람이 읽는
     채널명 매핑(GM 'channelNames')을 얹어, 팝업·비번 안내·채널 목록 등 '표시'에는 채널명을 쓴다.
     채널명은 채널 페이지 방문 시 자동 추출(captureChannelName) + 비번 관리에서 수동 보정. */
  function getChannelNameMap() {
    try { const r = GM_getValue('channelNames', null); return r ? (JSON.parse(r) || {}) : {}; } catch (e) { return {}; }
  }
  function setChannelName(code, name) {
    if (!code) return;
    name = String(name == null ? '' : name).replace(/\s+/g, ' ').trim();
    const m = getChannelNameMap();
    if (!name || name === code) { if (m[code] === undefined) return; delete m[code]; } // 빈 이름 = 매핑 해제
    else { if (m[code] === name) return; m[code] = name; }
    const keys = Object.keys(m);
    if (keys.length > 500) delete m[keys[0]];
    try { GM_setValue('channelNames', JSON.stringify(m)); } catch (e) {}
  }
  // 코드 → 표시명(이름 없으면 코드 그대로). 팝업·라벨·목록 표시 전용.
  function channelDisplayName(code) { return code ? (getChannelNameMap()[code] || code) : ''; }
  // 사용자 입력(이름 또는 코드) → 실제 채널 코드로 해석. 이름이 매핑에 있으면 그 코드, 아니면 입력을 코드로 간주.
  function resolveChannelInput(input) {
    const s = String(input || '').replace(/^#\s*/, '').trim();
    if (!s) return '';
    const m = getChannelNameMap();
    for (const code in m) if (m[code] === s) return code; // 채널명 정확 일치 → 그 코드
    return s; // 매핑에 없으면 코드로 간주
  }
  // 현재 채널 페이지에서 '채널 홈 링크(<a href="/s/<코드>">채널명</a>)' 텍스트를 채널명으로 자동 추출.
  // 경로가 정확히 /s/<코드>인 링크만(하위 경로 제외) → 브레드크럼/헤더의 채널 이름을 안정적으로 집는다.
  function captureChannelName() {
    const code = getChannelCode();
    if (!code || getChannelNameMap()[code]) return; // 없거나 이미 매핑됨
    for (const a of document.querySelectorAll('a[href*="/s/' + code + '"]')) {
      let p = ''; try { p = new URL(a.href).pathname.replace(/\/$/, ''); } catch (e) { continue; }
      if (p !== '/s/' + code) continue;
      const t = (a.textContent || '').replace(/\s+/g, ' ').trim();
      if (t && t.length <= 60 && t !== code) { setChannelName(code, t); return; }
    }
  }
  // 이 URL이 심어둔 채널의 비번을 '먼저' 시도하고, 실패하면 공동(전역) pwList로 폴백(채널 먼저, 중복 제거).
  // (과거엔 채널 비번만 반환해, 채널 비번이 다 틀리면 공동 비번은 아예 안 넣었다 — StegCloak과 동작 통일.)
  function getEffectivePwList() {
    const out = [];
    const ch = getChannelForCurrentSite();
    if (ch) { const list = getChannelPwMap()[ch]; if (Array.isArray(list)) out.push(...list); }
    const g = getPwList(); if (Array.isArray(g)) out.push(...g);
    return [...new Set(out.filter(Boolean))];
  }

  /* ================================================================
     메뉴
  ================================================================ */
  let menuIds = {};
  function refreshMenu() {
    Object.values(menuIds).forEach(id => { try { GM_unregisterMenuCommand(id); } catch(e){} });
    menuIds = {};
    menuIds['settings'] = GM_registerMenuCommand('⚙ KONE + 설정', openSettingsPanel);
    menuIds['update'] = GM_registerMenuCommand('🔄 업데이트 확인', () => runUpdateCheck(true));
  }

  /* ================================================================
     비번 관리 UI (커스텀 모달)
  ================================================================ */
  function openPwManager() {
    if (document.getElementById('b64d-pw-manager')) return;

    function closePm() {
      document.getElementById('b64d-pw-manager')?.remove();
      document.getElementById('b64d-pw-backdrop')?.remove();
    }

    // 2중 확인 삭제 버튼(3초 내 재클릭 시 실행) — 전역/채널 비번 공용(optimized+ 통합, 과거 2벌 복붙)
    function mkConfirmClearBtn(label, onConfirm) {
      const btn = document.createElement('button');
      btn.className = 'b64dpm-clear';
      btn.textContent = label;
      let confirming = false;
      const reset = () => { confirming = false; btn.textContent = label; btn.style.cssText = ''; };
      btn.addEventListener('click', () => {
        if (!confirming) {
          confirming = true;
          btn.textContent = '정말 삭제하시겠습니까? (다시 클릭)';
          btn.style.cssText = 'background:#ef4444;color:#fff;';
          setTimeout(() => { if (confirming) reset(); }, 3000);
          return;
        }
        reset();
        onConfirm();
      });
      return btn;
    }

    function renderList(listEl) {
      listEl.innerHTML = '';
      const pws = getPwList() || [];
      if (!pws.length) {
        const em = document.createElement('div');
        em.className = 'b64dpm-empty';
        em.textContent = '(등록된 비번 없음)';
        listEl.appendChild(em);
        return;
      }
      pws.forEach((pw, i) => {
        const item = document.createElement('div');
        item.className = 'b64dpm-item';
        const num = document.createElement('span');
        num.className = 'b64dpm-num';
        num.textContent = i + 1;
        const val = document.createElement('span');
        val.className = 'b64dpm-pw';
        val.textContent = pw;
        const del = document.createElement('button');
        del.className = 'b64dpm-del';
        del.textContent = '✕';
        del.title = '삭제';
        del.addEventListener('click', () => {
          const cur = getPwList() || [];
          savePwList(cur.filter((_, j) => j !== i));
          renderList(listEl);
        });
        item.appendChild(num);
        item.appendChild(val);
        item.appendChild(del);
        listEl.appendChild(item);
      });
    }

    const hdr = document.createElement('div');
    hdr.className = 'b64ds-header';
    hdr.innerHTML = '<span>🔑 비밀번호</span>';
    const x = document.createElement('button');
    x.className = 'b64ds-close';
    x.textContent = '✕';
    x.addEventListener('click', closePm);
    hdr.appendChild(x);

    const listEl = document.createElement('div');
    listEl.className = 'b64dpm-list';

    const inp = document.createElement('input');
    inp.className = 'b64dpm-input';
    inp.type = 'text';
    inp.placeholder = '추가할 비번 (쉼표로 구분)';
    inp.setAttribute('autocomplete', 'off');

    const addBtn = document.createElement('button');
    addBtn.className = 'b64dpm-add';
    addBtn.textContent = '추가';
    addBtn.addEventListener('click', () => {
      const val = inp.value.trim();
      if (!val) return;
      const cur = getPwList() || [];
      const newPws = val.split(',').map(s => s.trim()).filter(Boolean);
      savePwList([...new Set([...cur, ...newPws])]);
      inp.value = '';
      renderList(listEl);
    });
    inp.addEventListener('keydown', e => { if (e.key === 'Enter') addBtn.click(); });

    const inputRow = document.createElement('div');
    inputRow.className = 'b64dpm-input-row';
    inputRow.appendChild(inp);
    inputRow.appendChild(addBtn);

    const clearBtn = mkConfirmClearBtn('전체 삭제', () => { savePwList([]); renderList(listEl); });

    // ── 채널별 비번 (요구2/요구3, 버전 A 마스터-디테일) — 왼쪽 채널 목록 + 오른쪽 그 채널의 비번 편집. ──
    const chSide = document.createElement('div'); chSide.className = 'b64dpm-chside';
    const chDetail = document.createElement('div'); chDetail.className = 'b64dpm-chdetail';
    const chGrid = document.createElement('div'); chGrid.className = 'b64dpm-chgrid';
    chGrid.appendChild(chSide); chGrid.appendChild(chDetail);
    let _selCh = null;          // 선택 채널명, '__new__'=새 채널 등록 모드
    let _chFocus = null;        // 재렌더 후 포커스할 입력칸
    const mkInput = (ph) => { const i = document.createElement('input'); i.className = 'b64dpm-input'; i.type = 'text'; i.setAttribute('autocomplete', 'off'); i.placeholder = ph; return i; };
    function renderChannel() {
      const map = getChannelPwMap();
      const chans = Object.keys(map).filter(ch => (map[ch] || []).length);
      if (_selCh !== '__new__' && !chans.includes(_selCh)) _selCh = chans[0] || '__new__';
      _chFocus = null;
      // 사이드바
      chSide.innerHTML = '';
      chans.forEach(ch => {
        const it = document.createElement('button');
        it.className = 'b64dpm-chitem' + (ch === _selCh ? ' on' : '');
        const nm = document.createElement('span'); nm.className = 'b64dpm-chname'; nm.textContent = '# ' + channelDisplayName(ch);
        const ct = document.createElement('span'); ct.className = 'b64dpm-chcount'; ct.textContent = (map[ch] || []).length;
        it.appendChild(nm); it.appendChild(ct);
        it.addEventListener('click', () => { _selCh = ch; renderChannel(); });
        chSide.appendChild(it);
      });
      const addCh = document.createElement('button');
      addCh.className = 'b64dpm-chitem b64dpm-chadd' + (_selCh === '__new__' ? ' on' : '');
      const an = document.createElement('span'); an.className = 'b64dpm-chname'; an.textContent = '+ 새 채널';
      addCh.appendChild(an);
      addCh.addEventListener('click', () => { _selCh = '__new__'; renderChannel(); });
      chSide.appendChild(addCh);
      // 채널 목록 하단(전체폭)에 '채널명 관리' 버튼 — 표시 이름은 별도 모달에서 편집(비번창은 표시만).
      const cnOpen = document.createElement('button');
      cnOpen.className = 'b64dpm-chname-open'; cnOpen.type = 'button'; cnOpen.textContent = '채널명 관리';
      cnOpen.addEventListener('click', openChannelNameManager);
      chSide.appendChild(cnOpen);
      // 디테일
      chDetail.innerHTML = '';
      if (_selCh === '__new__') {
        const t = document.createElement('div'); t.className = 'b64dpm-chtitle'; t.textContent = '새 채널 등록';
        const nameI = mkInput(getChannelCode() ? `채널명 또는 코드 (비우면 현재: ${channelDisplayName(getChannelCode())})` : '채널명 또는 코드');
        const pwI = mkInput('비번 (쉼표로 구분)');
        const btn = document.createElement('button'); btn.className = 'b64dpm-add'; btn.textContent = '등록';
        const doAdd = () => {
          // 입력이 알려진 채널명이면 그 코드로, 아니면 코드로 간주. 비우면 현재 채널.
          const ch = (resolveChannelInput(nameI.value) || getChannelCode() || '');
          const pws = pwI.value.split(',').map(s => s.trim()).filter(Boolean); // 쉼표 구분 + 각 항목 trim
          if (!ch) { nameI.focus(); return; }
          if (!pws.length) { pwI.focus(); return; }
          const m = getChannelPwMap(); m[ch] = [...new Set([...(m[ch] || []), ...pws])]; saveChannelPwMap(m);
          _selCh = ch; renderChannel();
        };
        btn.addEventListener('click', doAdd);
        nameI.addEventListener('keydown', e => { if (e.key === 'Enter') pwI.focus(); });
        pwI.addEventListener('keydown', e => { if (e.key === 'Enter') doAdd(); });
        const nameRow = document.createElement('div'); nameRow.className = 'b64dpm-input-row'; nameRow.appendChild(nameI);
        const pwRow = document.createElement('div'); pwRow.className = 'b64dpm-input-row'; pwRow.appendChild(pwI); pwRow.appendChild(btn);
        chDetail.appendChild(t); chDetail.appendChild(nameRow); chDetail.appendChild(pwRow);
        _chFocus = nameI;
      } else if (_selCh) {
        const t = document.createElement('div'); t.className = 'b64dpm-chtitle';
        const nm = document.createElement('span'); nm.textContent = '# ' + channelDisplayName(_selCh);
        const sub = document.createElement('span'); sub.className = 'b64dpm-chsub';
        sub.textContent = getChannelNameMap()[_selCh] ? ('코드 ' + _selCh) : '채널 비번'; // 이름 있으면 실제 코드도 함께 표기
        t.appendChild(nm); t.appendChild(sub);
        // (표시 이름 편집은 별도 '채널명 관리' 창에서 — openChannelNameManager)
        const list = document.createElement('div'); list.className = 'b64dpm-list';
        (map[_selCh] || []).forEach((pw, i) => {
          const item = document.createElement('div'); item.className = 'b64dpm-item';
          const sp = document.createElement('span'); sp.className = 'b64dpm-pw'; sp.textContent = pw;
          const del = document.createElement('button'); del.className = 'b64dpm-del'; del.textContent = '✕'; del.title = '삭제';
          del.addEventListener('click', () => {
            const m = getChannelPwMap(); m[_selCh] = (m[_selCh] || []).filter((_, j) => j !== i);
            if (!m[_selCh].length) delete m[_selCh]; saveChannelPwMap(m); renderChannel();
          });
          item.appendChild(sp); item.appendChild(del); list.appendChild(item);
        });
        const pwI = mkInput('비번 추가 (쉼표로 구분)');
        const btn = document.createElement('button'); btn.className = 'b64dpm-add'; btn.textContent = '추가';
        const doAdd = () => {
          const pws = pwI.value.split(',').map(s => s.trim()).filter(Boolean); // 쉼표 구분 + 각 항목 trim
          if (!pws.length) { pwI.focus(); return; }
          const m = getChannelPwMap(); m[_selCh] = [...new Set([...(m[_selCh] || []), ...pws])]; saveChannelPwMap(m); renderChannel();
        };
        btn.addEventListener('click', doAdd);
        pwI.addEventListener('keydown', e => { if (e.key === 'Enter') doAdd(); });
        const row = document.createElement('div'); row.className = 'b64dpm-input-row'; row.appendChild(pwI); row.appendChild(btn);
        // 이 채널의 비번만 전체 삭제(확인 2회) — 기본 비번 탭과 동일한 mkConfirmClearBtn, 선택 채널만 대상.
        const clr = mkConfirmClearBtn('이 채널 비번 전체 삭제', () => {
          const m = getChannelPwMap(); delete m[_selCh]; saveChannelPwMap(m); renderChannel();
        });
        chDetail.appendChild(t); chDetail.appendChild(list); chDetail.appendChild(row); chDetail.appendChild(clr);
        _chFocus = pwI;
      }
      // 채널 탭이 실제로 보일 때만 포커스(초기 숨김 상태에서 포커스 훔치지 않게).
      if (_chFocus && paneChannel && !paneChannel.classList.contains('b64dpm-hide')) _chFocus.focus();
    }

    // ── 탭 구성(수정2): [기본 비번] / [채널별 비번]. 자동입력·StegCloak 모두 '채널 비번 선(先) → 없으면
    //    기본 비번' 순으로 시도한다(getEffectivePwList/getStegPwList). 여기선 등록 UI만 탭으로 분리. ──
    const paneGlobal = document.createElement('div');
    paneGlobal.className = 'b64dpm-pane';
    const gNote = document.createElement('div');
    gNote.className = 'b64dpm-note';
    gNote.textContent = '기본(전역) 비번 — 채널별 비번이 없을 때 사용됩니다.';
    paneGlobal.appendChild(gNote);
    paneGlobal.appendChild(listEl);
    paneGlobal.appendChild(inputRow);
    paneGlobal.appendChild(clearBtn);

    const paneChannel = document.createElement('div');
    paneChannel.className = 'b64dpm-pane b64dpm-hide';
    const cNote = document.createElement('div');
    cNote.className = 'b64dpm-note';
    cNote.textContent = '채널별 비번 — 해당 채널에서 기본 비번보다 먼저 시도됩니다.';
    paneChannel.appendChild(cNote);
    paneChannel.appendChild(chGrid);

    const tabs = document.createElement('div');
    tabs.className = 'b64dpm-tabs';
    const tabG = document.createElement('button');
    tabG.className = 'b64dpm-tab on'; tabG.textContent = '기본 비번';
    const tabC = document.createElement('button');
    tabC.className = 'b64dpm-tab'; tabC.textContent = '채널별 비번';
    const showTab = (which) => {
      const g = which === 'global';
      tabG.classList.toggle('on', g); tabC.classList.toggle('on', !g);
      paneGlobal.classList.toggle('b64dpm-hide', !g);
      paneChannel.classList.toggle('b64dpm-hide', g);
      if (!g) renderChannel(); // 채널 탭 진입 시 렌더(+포커스)
    };
    tabG.addEventListener('click', () => showTab('global'));
    tabC.addEventListener('click', () => showTab('channel'));
    tabs.appendChild(tabG); tabs.appendChild(tabC);

    const body = document.createElement('div');
    body.className = 'b64dpm-body';
    body.appendChild(tabs);
    body.appendChild(paneGlobal);
    body.appendChild(paneChannel);
    renderChannel(); // 초기 구성(숨김 상태) — 포커스는 탭 진입 시에만

    const panel = document.createElement('div');
    panel.id = 'b64d-pw-manager';
    panel.dataset.theme = getSystemTheme();
    if (CFG.SETTINGS_SCALE !== 100) panel.style.zoom = CFG.SETTINGS_SCALE / 100; // UI 크기 설정 반영(수정)
    panel.addEventListener('click', e => e.stopPropagation());
    panel.appendChild(hdr);
    panel.appendChild(body);

    const bd = document.createElement('div');
    bd.id = 'b64d-pw-backdrop';
    bd.addEventListener('click', closePm);

    document.body.appendChild(bd);
    document.body.appendChild(panel);
    renderList(listEl);
    inp.focus();
  }

  /* ================================================================
     받침 보정 단어 편집 모달 (점자 변환 하위 옵션)
     비번 모달과 동일 테마 변수를 쓰되, 칩(태그) 기반의 별도 UI.
  ================================================================ */
  const BRFINAL_DEFAULT = '끝 밑 밭 팥 솥 곁 볕 뭍 샅 녘 앞 옆 숲 잎 짚 늪 릎';
  function openBrailleFinalManager() {
    if (document.getElementById('b64d-brfinal')) return;

    function getWords() {
      return String(CFG.BRAILLE_FINAL || '').split(/\s+/).filter(Boolean);
    }
    function saveWords(arr) {
      const uniq = [...new Set(arr)];
      const str = uniq.join(' ');
      CFG.BRAILLE_FINAL = str;
      GM_setValue('brailleFinal', str);
      BR.setFinalSet(str);
      if (CFG.LIVE_APPLY) applyLive(); else schedule();
    }

    function closeBf() {
      document.getElementById('b64d-brfinal')?.remove();
      document.getElementById('b64d-brfinal-backdrop')?.remove();
    }

    const hdr = document.createElement('div');
    hdr.className = 'b64ds-header';
    hdr.innerHTML = '<span>⠿ 받침 보정 단어</span>';
    const x = document.createElement('button');
    x.className = 'b64ds-close'; x.textContent = '✕';
    x.addEventListener('click', closeBf);
    hdr.appendChild(x);

    const note = document.createElement('div');
    note.className = 'b64dbf-note';
    note.innerHTML = '어절 끝에서 <b>ㅌ·ㅋ·ㅍ 받침</b>과 <b>?·!·.</b> 의 점형이 같아 헷갈릴 때, 여기 등록된 단어는 받침으로 읽습니다. 예: <b>끝·앞·옆·무릎</b>';

    const chips = document.createElement('div');
    chips.className = 'b64dbf-chips';

    const countEl = document.createElement('span');
    countEl.className = 'b64dbf-count';

    function renderChips() {
      chips.innerHTML = '';
      const words = getWords();
      if (!words.length) {
        const em = document.createElement('div');
        em.className = 'b64dbf-empty';
        em.textContent = '(등록된 단어 없음)';
        chips.appendChild(em);
      } else {
        words.forEach(w => {
          const chip = document.createElement('span');
          chip.className = 'b64dbf-chip';
          chip.appendChild(document.createTextNode(w));
          const del = document.createElement('button');
          del.className = 'b64dbf-chip-x'; del.type = 'button'; del.textContent = '✕'; del.title = '삭제';
          del.addEventListener('click', () => { saveWords(getWords().filter(x => x !== w)); renderChips(); });
          chip.appendChild(del);
          chips.appendChild(chip);
        });
      }
      countEl.textContent = words.length + '개';
    }

    const inp = document.createElement('input');
    inp.className = 'b64dbf-input'; inp.type = 'text';
    inp.placeholder = '단어 추가 (공백·쉼표로 여러 개)';
    inp.setAttribute('autocomplete', 'off'); inp.spellcheck = false;
    const addBtn = document.createElement('button');
    addBtn.className = 'b64dbf-add'; addBtn.type = 'button'; addBtn.textContent = '추가';
    function addWords() {
      const newWords = inp.value.split(/[\s,]+/).filter(Boolean);
      if (!newWords.length) return;
      saveWords([...getWords(), ...newWords]);
      inp.value = ''; renderChips(); inp.focus();
    }
    addBtn.addEventListener('click', addWords);
    inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); addWords(); } });
    const addRow = document.createElement('div');
    addRow.className = 'b64dbf-add-row';
    addRow.append(inp, addBtn);

    const resetBtn = document.createElement('button');
    resetBtn.className = 'b64dbf-reset'; resetBtn.type = 'button'; resetBtn.textContent = '기본값으로 복원';
    resetBtn.addEventListener('click', () => { saveWords(BRFINAL_DEFAULT.split(/\s+/)); renderChips(); });
    const foot = document.createElement('div');
    foot.className = 'b64dbf-foot';
    foot.append(countEl, resetBtn);

    const body = document.createElement('div');
    body.className = 'b64dbf-body';
    body.append(note, chips, addRow, foot);

    const panel = document.createElement('div');
    panel.id = 'b64d-brfinal';
    panel.dataset.theme = getSystemTheme();
    if (CFG.SETTINGS_SCALE !== 100) panel.style.transform = `translate(-50%,-50%) scale(${CFG.SETTINGS_SCALE / 100})`;
    panel.addEventListener('click', e => e.stopPropagation());
    panel.append(hdr, body);

    const bd = document.createElement('div');
    bd.id = 'b64d-brfinal-backdrop';
    bd.addEventListener('click', closeBf);

    document.body.appendChild(bd);
    document.body.appendChild(panel);
    renderChips();
    inp.focus();
  }

  /* ================================================================
     설정 패널 (버튼 식 UI)
  ================================================================ */
  function getSystemTheme() {
    return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
  }
  function applyThemeToOverlays() {
    const theme = getSystemTheme();
    const s = document.getElementById('b64d-settings');
    if (s) s.dataset.theme = theme;
    const p = document.getElementById('b64d-pw-manager');
    if (p) p.dataset.theme = theme;
    const bf = document.getElementById('b64d-brfinal');
    if (bf) bf.dataset.theme = theme;
    const cn = document.getElementById('b64d-chname');
    if (cn) cn.dataset.theme = theme;
  }

  /* ================================================================
     채널명 관리 (별도 모달) — 코드↔표시 이름 매핑 확인·수정.
     채널 페이지 방문 시 captureChannelName이 자동으로 채우고, 여기서 수동 보정.
     관리 대상 = 이름 있는 채널 + 비번 있는 채널 + 현재 채널(합집합).
  ================================================================ */
  function openChannelNameManager() {
    if (document.getElementById('b64d-chname')) return;
    function close() {
      document.getElementById('b64d-chname')?.remove();
      document.getElementById('b64d-chname-backdrop')?.remove();
    }
    const hdr = document.createElement('div');
    hdr.className = 'b64ds-header';
    hdr.innerHTML = '<span>채널명 관리</span>';
    const x = document.createElement('button');
    x.className = 'b64ds-close'; x.textContent = '✕';
    x.addEventListener('click', close);
    hdr.appendChild(x);

    const note = document.createElement('div');
    note.className = 'b64dpm-note';
    note.textContent = '채널 코드에 표시 이름을 붙입니다. 채널 페이지 방문 시 자동으로 채워지며, 여기서 직접 고칠 수 있습니다(비우면 코드로 표시). 이 이름이 팝업·비번 안내에 표시됩니다.';

    const CN_PER_PAGE = 5;   // 목록 고정 높이에 들어가는 행 수(초과분은 페이지 버튼으로)
    let _page = 0, _adding = false, _flash = null; // _flash = 이동·강조할 코드(중복 추가 시)

    // 현재 관리 대상 채널 코드 목록(이름 있는 채널 + 비번 있는 채널 + 현재 채널, 정렬)
    function allCodes() {
      const names = getChannelNameMap(), pw = getChannelPwMap(), cur = getChannelCode();
      return [...new Set([...Object.keys(names), ...Object.keys(pw), ...(cur ? [cur] : [])])].sort();
    }

    const list = document.createElement('div');
    list.className = 'b64dpm-list';

    // 기존 채널 행: 코드 칩(고정폭) + 이름 입력(flex). 현재 채널은 칩만 accent, _flash면 잠깐 강조.
    function buildRow(code) {
      const names = getChannelNameMap(), cur = getChannelCode();
      const row = document.createElement('div');
      row.className = 'b64dcn-row' + (code === cur ? ' on' : '') + (code === _flash ? ' flash' : '');
      const c = document.createElement('span');
      c.className = 'b64dcn-code'; c.textContent = '# ' + code;
      c.title = code === cur ? (code + ' (현재 채널)') : code;
      const inp = document.createElement('input');
      inp.className = 'b64dpm-input b64dcn-name'; inp.type = 'text';
      inp.setAttribute('autocomplete', 'off');
      inp.placeholder = '표시 이름 (비우면 코드)';
      inp.value = names[code] || '';
      const save = () => setChannelName(code, inp.value); // 빈 값 저장 = 매핑 해제(코드로 폴백)
      inp.addEventListener('change', save);
      inp.addEventListener('keydown', e => { if (e.key === 'Enter') { save(); inp.blur(); } });
      row.appendChild(c); row.appendChild(inp);
      if (code === _flash) { // 강조 1회: 스크롤·포커스 후 애니메이션 클래스 제거
        _flash = null;
        requestAnimationFrame(() => { try { row.scrollIntoView({ block: 'nearest' }); inp.focus(); } catch (e) {} });
        setTimeout(() => row.classList.remove('flash'), 1200);
      }
      return row;
    }

    // 수동 추가 인라인 행(시안 A): 코드 입력 + 이름 입력 + ✓. page 0 최상단에 표시.
    function buildAddRow() {
      const row = document.createElement('div');
      row.className = 'b64dcn-row b64dcn-addrow';
      const ci = document.createElement('input');
      ci.className = 'b64dpm-input b64dcn-codein'; ci.type = 'text';
      ci.setAttribute('autocomplete', 'off'); ci.placeholder = '코드';
      const ni = document.createElement('input');
      ni.className = 'b64dpm-input b64dcn-name'; ni.type = 'text';
      ni.setAttribute('autocomplete', 'off'); ni.placeholder = '표시 이름';
      const ok = document.createElement('button');
      ok.className = 'b64dcn-ok'; ok.type = 'button'; ok.textContent = '✓'; ok.title = '추가';
      const submit = () => doManualAdd(ci.value, ni.value);
      ok.addEventListener('click', submit);
      ci.addEventListener('keydown', e => { if (e.key === 'Enter') ni.focus(); });
      ni.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); });
      row.append(ci, ni, ok);
      requestAnimationFrame(() => { try { ci.focus(); } catch (e) {} });
      return row;
    }

    // 수동 추가 실행: 이미 있으면 그 채널로 이동·강조(중복 생성 안 함), 없으면 이름 매핑 저장.
    function doManualAdd(codeRaw, nameRaw) {
      const code = resolveChannelInput(codeRaw); // 이름을 넣어도 알려진 이름이면 코드로 해석
      const name = String(nameRaw || '').replace(/\s+/g, ' ').trim();
      if (!code) { showNavToast('채널 코드를 입력하세요'); return; }
      const names = getChannelNameMap(), pw = getChannelPwMap(), cur = getChannelCode();
      const exists = names[code] !== undefined || (pw[code] && pw[code].length) || code === cur;
      if (exists) {
        _adding = false; _flash = code;
        _page = Math.floor(allCodes().indexOf(code) / CN_PER_PAGE);
        showNavToast('이미 등록된 채널입니다');
        render(); return;
      }
      if (!name || name === code) { showNavToast('표시 이름을 입력하세요'); return; }
      setChannelName(code, name);
      _adding = false; _flash = code;
      _page = Math.floor(allCodes().indexOf(code) / CN_PER_PAGE);
      render();
    }

    function renderPager(totalPages) {
      pagerWrap.innerHTML = '';
      if (totalPages <= 1) return;
      const mk = (label, page, disabled, active) => {
        const b = document.createElement('button');
        b.type = 'button'; b.className = 'b64dcn-pg' + (active ? ' on' : '');
        b.textContent = label; if (disabled) b.disabled = true;
        if (!disabled && !active) b.addEventListener('click', () => { _page = page; _adding = false; render(); });
        return b;
      };
      pagerWrap.appendChild(mk('‹', _page - 1, _page <= 0, false));
      let s = Math.max(0, Math.min(_page - 2, totalPages - 5)); const e = Math.min(totalPages, s + 5); s = Math.max(0, e - 5);
      for (let p = s; p < e; p++) pagerWrap.appendChild(mk(String(p + 1), p, false, p === _page));
      pagerWrap.appendChild(mk('›', _page + 1, _page >= totalPages - 1, false));
    }

    function render() {
      list.innerHTML = '';
      const codes = allCodes();
      const totalPages = Math.max(1, Math.ceil(codes.length / CN_PER_PAGE));
      if (_adding) _page = 0;
      _page = Math.min(Math.max(_page, 0), totalPages - 1);
      if (_adding && _page === 0) list.appendChild(buildAddRow());
      if (!codes.length && !_adding) {
        const em = document.createElement('div');
        em.className = 'b64dpm-empty';
        em.textContent = '(관리할 채널이 없습니다 — 채널 페이지 방문·채널 비번 등록·수동 추가 시 나타납니다)';
        list.appendChild(em);
      } else {
        codes.slice(_page * CN_PER_PAGE, (_page + 1) * CN_PER_PAGE).forEach(code => list.appendChild(buildRow(code)));
      }
      addBtn.classList.toggle('on', _adding);
      renderPager(totalPages);
    }

    // 하단: 좌측 '+ 수동 추가' + 우측 페이지 버튼
    const footer = document.createElement('div');
    footer.className = 'b64dcn-foot';
    const addBtn = document.createElement('button');
    addBtn.className = 'b64dcn-add'; addBtn.type = 'button'; addBtn.textContent = '+ 수동 추가';
    addBtn.addEventListener('click', () => { _adding = !_adding; if (_adding) _page = 0; render(); });
    const pagerWrap = document.createElement('div');
    pagerWrap.className = 'b64dcn-pager';
    footer.append(addBtn, pagerWrap);

    const body = document.createElement('div');
    body.className = 'b64dpm-body';
    body.appendChild(note);
    body.appendChild(list);
    body.appendChild(footer);

    const panel = document.createElement('div');
    panel.id = 'b64d-chname';
    panel.dataset.theme = getSystemTheme();
    if (CFG.SETTINGS_SCALE !== 100) panel.style.zoom = CFG.SETTINGS_SCALE / 100;
    panel.addEventListener('click', e => e.stopPropagation());
    panel.appendChild(hdr);
    panel.appendChild(body);

    const bd = document.createElement('div');
    bd.id = 'b64d-chname-backdrop';
    bd.addEventListener('click', close);

    document.body.appendChild(bd);
    document.body.appendChild(panel);
    render();
  }
  // kone.gg가 <html class="dark"> 토글 방식을 사용하므로 class 변화 감지
  new MutationObserver(applyThemeToOverlays).observe(
    document.documentElement, { attributes: true, attributeFilter: ['class'] }
  );

  function closeSettingsPanel() {
    if (_settingsCaptureCleanup) { _settingsCaptureCleanup(); _settingsCaptureCleanup = null; }
    document.getElementById('b64d-settings')?.remove();
    document.getElementById('b64d-settings-backdrop')?.remove();
    document.getElementById('b64d-stip')?.remove();
  }

  function openSettingsPanel() {
    if (document.getElementById('b64d-settings')) { closeSettingsPanel(); return; }
    dlpHide();

    const tipEl = document.createElement('div');
    tipEl.id = 'b64d-stip'; tipEl.className = 'b64d-stip'; tipEl.style.display = 'none';
    tipEl.style.fontSize = (11 * CFG.SETTINGS_SCALE / 100) + 'px'; // 설정 화면 크기에 맞춰 글자 확대(좌표 왜곡 없는 방식)
    document.body.appendChild(tipEl);

    function mkToggle(key, stKey, label, tooltip, onChange, opts) {
      const btn = document.createElement('button');
      btn.className = `b64ds-toggle${CFG[key] ? ' active' : ''}${opts && opts.sub ? ' b64ds-sub' : ''}`;
      btn.innerHTML = `<span>${label}</span><span class="b64ds-badge"></span>`;
      if (tooltip) {
        btn.addEventListener('mouseenter', () => {
          tipEl.textContent = tooltip; tipEl.style.display = 'block';
          const r = btn.getBoundingClientRect();
          tipEl.style.left = r.left + 'px'; tipEl.style.top = '0px';
          requestAnimationFrame(() => {
            let top = r.top - tipEl.offsetHeight - 6;
            if (top < 4) top = r.bottom + 6;
            let left = r.left;
            if (left + tipEl.offsetWidth > window.innerWidth - 4) left = window.innerWidth - tipEl.offsetWidth - 4;
            tipEl.style.top = top + 'px'; tipEl.style.left = left + 'px';
          });
        });
        btn.addEventListener('mouseleave', () => { tipEl.style.display = 'none'; });
      }
      btn.addEventListener('click', () => {
        CFG[key] = !CFG[key];
        GM_setValue(stKey, CFG[key]);
        btn.classList.toggle('active', CFG[key]);
        if (key === 'LIVE_APPLY') return;
        if (onChange) onChange(CFG[key]);
        // opts.noApply: DOM/카드와 무관한 순수 동작 토글(예: W/S 미리보기) → 재렌더 생략.
        // (applyLive가 카드를 다시 만들면 W/S 카드 선택이 풀리고 _kpIdx가 초기화되는 문제 방지)
        if (opts && opts.noApply) return;
        if (CFG.LIVE_APPLY) applyLive(); else schedule();
      });
      return btn;
    }
    function mkAction(t, fn) {
      const btn = document.createElement('button');
      btn.className = 'b64ds-action'; btn.textContent = t;
      btn.addEventListener('click', fn); return btn;
    }
    // 설명용 작은 안내문(여러 줄). innerHTML은 고정 문자열만(XSS 무관).
    function mkNote(html) {
      const d = document.createElement('div');
      d.className = 'b64ds-note'; d.innerHTML = html; return d;
    }

    // 단축키 캡처 행 공용 생성기(optimized+ 통합 — 과거 설정/TXT 검사용 ~45줄 블록 2벌 복붙).
    // 반환 { row, cancel }: row=설정 행 요소, cancel=캡처 중이면 리스너 해제(창 닫힘 시 호출).
    function mkHotkeyRow(labelText, initKey, onSet) {
      const row = document.createElement('div');
      row.className = 'b64ds-hotkey-row';
      const lbl = document.createElement('span');
      lbl.className = 'b64ds-hotkey-label';
      lbl.textContent = labelText;
      const r = document.createElement('div');
      r.className = 'b64ds-hotkey-r';
      const keyEl = document.createElement('span');
      keyEl.className = 'b64ds-hotkey-key';
      keyEl.textContent = initKey;
      const btn = document.createElement('button');
      btn.className = 'b64ds-hotkey-btn';
      btn.textContent = '변경';
      let capturing = false, listener = null;
      const cancel = () => {
        if (!capturing) return;
        capturing = false;
        btn.classList.remove('capturing');
        btn.textContent = '변경';
        document.removeEventListener('keydown', listener, true);
      };
      btn.addEventListener('click', () => {
        if (capturing) return;
        capturing = true;
        btn.classList.add('capturing');
        btn.textContent = '입력…';
        listener = e => {
          if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return; // 수식키만 누름: 대기
          if (e.key !== 'Escape' && !(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey)) return; // 평범한 키: 통과
          e.preventDefault(); e.stopPropagation();
          if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) {
            const combo = buildCombo(e);
            keyEl.textContent = combo;
            onSet(combo);
          }
          cancel();
        };
        document.addEventListener('keydown', listener, true);
      });
      r.append(keyEl, btn);
      row.append(lbl, r);
      return { row, cancel };
    }
    const hkMain = mkHotkeyRow('설정 단축키',     HOTKEY,     c => { HOTKEY = c;     GM_setValue('hotkey', c); });
    const hkDup  = mkHotkeyRow('TXT 검사 단축키', DUP_HOTKEY, c => { DUP_HOTKEY = c; GM_setValue('dupHotkey', c); });

    const hdr = document.createElement('div');
    hdr.className = 'b64ds-header';
    hdr.innerHTML = '<span>KONE + 설정</span>';
    const x = document.createElement('button');
    x.className = 'b64ds-close'; x.textContent = '✕';
    x.addEventListener('click', closeSettingsPanel);
    hdr.appendChild(x);

    const body = document.createElement('div');
    body.className = 'b64ds-body';

    function mkTriple(key, stKey, label, opts, onChange) {
      const row = document.createElement('div');
      row.className = 'b64ds-triple-row';
      const lbl = document.createElement('span');
      lbl.className = 'b64ds-triple-label';
      lbl.textContent = label;
      const btns = document.createElement('div');
      btns.className = 'b64ds-triple-btns';
      opts.forEach(({ value, text }) => {
        const b = document.createElement('button');
        b.className = `b64ds-triple-btn${CFG[key] === value ? ' active' : ''}`;
        b.textContent = text;
        b.addEventListener('click', () => {
          CFG[key] = value;
          GM_setValue(stKey, value);
          btns.querySelectorAll('.b64ds-triple-btn').forEach(x => x.classList.toggle('active', x === b));
          if (onChange) { onChange(value); return; }
          document.querySelectorAll('.b64-link.kp-focused, .b64-product-link.kp-focused')
            .forEach(el => el.classList.remove('kp-focused'));
          _kpIdx = -1;
          // 클래스 변경은 MO childList를 트리거하지 않으므로 즉시 재적용
          if (CFG.LIVE_APPLY) applyLive(); else schedule();
        });
        btns.appendChild(b);
      });
      row.appendChild(lbl);
      row.appendChild(btns);
      return row;
    }

    // 그룹 제목 아이콘 (제목 → Feather 스타일 SVG)
    const GROUP_ICONS = {
      '번역':     svg('<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>'),
      '작성 도구': svg('<path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/>'),
      '댓글':     svg('<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>'),
      '카드':     svg('<rect x="1" y="4" width="22" height="16" rx="2" ry="2"/><line x1="1" y1="10" x2="23" y2="10"/>'),
      '모아보기': svg('<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>'),
      '링크 확인': ICO.link,
      '미리보기': svg('<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>'),
      '화면':     svg('<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>'),
      '단축키':   svg('<rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6" y2="10"/><line x1="10" y1="10" x2="10" y2="10"/><line x1="14" y1="10" x2="14" y2="10"/><line x1="18" y1="10" x2="18" y2="10"/><line x1="8" y1="14" x2="16" y2="14"/>'),
      '적용':     svg('<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>'),
      '비번':     svg('<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>'),
      '업데이트': svg('<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/>'),
    };
    // 그룹 카드: 아이콘+제목 헤더 + 항목들을 하나의 섹션 카드로 묶음 (구분성·통일감)
    function mkGroup(title, ...items) {
      const s = document.createElement('div'); s.className = 'b64ds-section';
      if (title) {
        const h = document.createElement('div'); h.className = 'b64ds-group-title';
        if (GROUP_ICONS[title]) h.innerHTML = GROUP_ICONS[title];
        const t = document.createElement('span'); t.textContent = title; h.appendChild(t);
        s.appendChild(h);
      }
      items.forEach(el => el && s.appendChild(el));
      return s;
    }
    function mkSlider(key, stKey, label, min, max, step, unit, defaultVal, onInput, onChange) {
      const row = document.createElement('div');
      row.className = 'b64ds-slider-row';
      const lbl = document.createElement('span');
      lbl.className = 'b64ds-triple-label'; lbl.textContent = label;
      const right = document.createElement('div');
      right.className = 'b64ds-slider-r';
      const valEl = document.createElement('span');
      valEl.className = 'b64ds-slider-val'; valEl.textContent = CFG[key] + unit;
      const inp = document.createElement('input');
      inp.type = 'range'; inp.min = min; inp.max = max; inp.step = step; inp.value = CFG[key];
      inp.className = 'b64ds-slider';
      inp.tabIndex = -1;
      inp.addEventListener('input', () => {
        CFG[key] = +inp.value;
        GM_setValue(stKey, +inp.value);
        valEl.textContent = inp.value + unit;
        if (onInput) onInput(+inp.value);
      });
      inp.addEventListener('pointerup', () => { inp.blur(); if (onChange) onChange(+inp.value); });
      inp.addEventListener('change', () => { if (onChange) onChange(+inp.value); });
      if (defaultVal !== undefined) {
        const rst = document.createElement('button');
        rst.type = 'button'; rst.className = 'b64ds-slider-reset';
        rst.textContent = '↺'; rst.title = `기본값 (${defaultVal}${unit})`;
        rst.addEventListener('click', () => { inp.value = defaultVal; inp.dispatchEvent(new Event('input')); inp.dispatchEvent(new Event('change')); inp.blur(); });
        right.append(valEl, rst, inp);
      } else {
        right.append(valEl, inp);
      }
      row.append(lbl, right);
      return row;
    }
    // 종속 옵션: 부모 토글이 꺼지면 자식 옵션을 흐리고 비활성화
    const setSubEnabled = (el, on) => el.classList.toggle('b64ds-disabled', !on);
    // 링크 확인 딜레이 — '링크 생존 확인'의 하위 옵션
    const delaySlider = mkSlider('LINK_CHECK_DELAY', 'linkCheckDelay', '링크 확인 딜레이', 0, 5000, 250, 'ms', 0);
    delaySlider.classList.add('b64ds-sub');
    // 링크 만료 OFF 시 미리보기 자동 ON을 위해 previewToggleBtn 참조 연결
    let previewToggleBtn = null;
    const linkCheckToggle = mkToggle('LINK_CHECK', 'linkCheck', '링크 생존 확인', null, (isOn) => {
      setSubEnabled(delaySlider, isOn);
      if (!isOn && !CFG.DLSITE_PREVIEW && previewToggleBtn) {
        CFG.DLSITE_PREVIEW = true;
        GM_setValue('dlsitePreview', true);
        previewToggleBtn.classList.add('active');
      }
    });
    setSubEnabled(delaySlider, CFG.LINK_CHECK);
    // W/S 선택 시 미리보기 — '카드 미리보기'의 하위 옵션(미리보기 꺼지면 비활성). noApply: DOM 재렌더 없이 동작만.
    // W/S 미리보기 위치: 기존(카드 옆) | 가운데 | 왼쪽 | 오른쪽 고정. onChange 지정 → applyLive 생략(다음 미리보기부터 반영).
    const kpPosTriple = mkTriple('KP_PREVIEW_POS', 'kpPreviewPos', 'W/S 미리보기 위치', [
      { value: 'card', text: '기존' }, { value: 'center', text: '가운데' }, { value: 'left', text: '왼쪽' }, { value: 'right', text: '오른쪽' },
    ], () => {});
    kpPosTriple.classList.add('b64ds-sub');
    const kpPreviewToggle = mkToggle('KP_PREVIEW', 'kpPreview', 'W/S 선택 시 미리보기', 'W/S(↑/↓)로 카드를 옮길 때 그 카드의 미리보기도 자동으로 띄움', (on) => setSubEnabled(kpPosTriple, on), { sub: true, noApply: true });
    const previewToggle = mkToggle('DLSITE_PREVIEW', 'dlsitePreview', '카드 미리보기', null, (on) => { setSubEnabled(kpPreviewToggle, on); setSubEnabled(kpPosTriple, on && CFG.KP_PREVIEW); });
    setSubEnabled(kpPreviewToggle, CFG.DLSITE_PREVIEW);
    setSubEnabled(kpPosTriple, CFG.DLSITE_PREVIEW && CFG.KP_PREVIEW);
    previewToggleBtn = previewToggle;

    // 탭 구조: 본문 / 링크 / 미리보기 / 시스템
    const tabDefs = ['본문', '링크', '미리보기', '시스템'];
    const tabPanels = tabDefs.map(() => {
      const p = document.createElement('div');
      p.className = 'b64ds-tabpanel';
      return p;
    });
    const [pBonmun, pLink, pPreview, pSystem] = tabPanels;

    // 본문 탭
    // 점자 변환 하위 옵션: UEB 토글 + 받침 보정 단어(별도 모달 버튼). └ 기호 없이 들여쓰기(b64ds-sub)로 계층 표현.
    const brUebToggle = mkToggle('BRAILLE_UEB', 'brailleUeb', 'UEB(영어) 점자 디코딩',
      '외부 변환기(dencode 등)로 만든 영어 UEB 점자도 해독. URL·영어로 감지된 점자만 UEB로 풀고, 한글은 그대로. 영어 점자를 자주 보는 경우만 ON.',
      (on) => { if (CFG.LIVE_APPLY) applyLive(); else schedule(); });
    brUebToggle.classList.add('b64ds-sub');
    // 받침 보정 단어 — 비번 목록 관리처럼 버튼 → 별도 칩 편집 모달. 하위 옵션 최하단에 배치.
    const brFinalBtn = mkAction('⠿ 받침 보정 단어', openBrailleFinalManager);
    brFinalBtn.classList.add('b64ds-sub');
    const brailleDecodeToggle = mkToggle('BRAILLE_DECODE', 'brailleDecode', '본문 점자 변환', '본문 속 한글 점자(⠿)를 한글로 자동 변환', (on) => { setSubEnabled(brUebToggle, on); setSubEnabled(brFinalBtn, on); });
    setSubEnabled(brUebToggle, CFG.BRAILLE_DECODE);
    setSubEnabled(brFinalBtn, CFG.BRAILLE_DECODE);
    // 작성 도구(작성 페이지 변환 패널) — 본문 탭으로 이동. 부모(패널 표시)·자식(항상 열기) 종속.
    const brPinnedToggle = mkToggle('BR_PINNED', 'brPinned', '변환 패널 항상 열기', '작성 페이지에서 변환 패널을 펼친 채로 표시', () => { const e = document.getElementById('b64d-br'); if (e) e.classList.toggle('open', CFG.BR_PINNED); }, { sub: true });
    const braillePanelToggle = mkToggle('BRAILLE_PANEL', 'braillePanel', '작성 변환 패널', '작성 페이지 입력창 옆에 점자/base64 변환 패널 표시', (on) => { syncBraillePanel(); setSubEnabled(brPinnedToggle, on); });
    setSubEnabled(brPinnedToggle, CFG.BRAILLE_PANEL);
    pBonmun.append(
      mkGroup('번역',
        mkToggle('CONTENT_DECODE', 'contentDecode', '본문 번역 (base64)', 'base64 등으로 인코딩된 본문을 자동 해독'),
        brailleDecodeToggle,
        brUebToggle,
        brFinalBtn,
        mkToggle('STEG_DECODE',    'stegDecode',    '숨은 코드 자동 복원', '본문/댓글의 숨은 코드(StegCloak)를 채널 비번으로 자동 복원. 실패하면 🔒 배지 표시'),
        mkToggle('LIST_DECODE',    'listDecode',    '목록 페이지 제목 번역'),
        mkToggle('DRAG_DECODE',    'dragDecode',    '드래그 자동 변환'),
      ),
      mkGroup('작성 도구',
        braillePanelToggle,
        brPinnedToggle,
      ),
      mkGroup('댓글',
        mkToggle('EXPAND_MORE',    'expandMore',    '댓글 더 보기 자동 클릭', '"댓글 더 보기" 버튼을 자동으로 눌러 접힌 댓글을 모두 불러옴'),
        mkToggle('EXPAND_REPLIES', 'expandReplies', '대댓글 자동 펼치기', '"답글 N개" 버튼을 자동으로 눌러 답글(대댓글)을 펼침'),
      ),
    );

    // 링크 탭
    const panelTargetTriple = mkTriple('PANEL_TARGET', 'panelTarget', '모아보기 표시', [
      { value: 'both', text: '모두' }, { value: 'products', text: '작품' }, { value: 'links', text: '다운로드' },
    ]);
    panelTargetTriple.classList.add('b64ds-sub');
    const linkPanelToggle = mkToggle('LINK_PANEL', 'linkPanel', '링크 모아보기', null, (on) => setSubEnabled(panelTargetTriple, on));
    setSubEnabled(panelTargetTriple, CFG.LINK_PANEL);
    pLink.append(
      mkGroup('카드',
        mkToggle('LINK_CARD',       'linkCard',       '링크 카드'),
        mkToggle('GENRE_BAR',       'genreBar',       '장르·태그 별도 바', 'DLsite 장르/태그 검색 링크를 인라인 칩 대신 본문 하단 별도 바에 모아 표시 (코네 재렌더로 칩이 증식하는 문제 방지)'),
        mkToggle('COPY_CODE',       'copyCode',       '작품 카드 복사 시 코드 복사'),
        mkToggle('SCROLL_TO_FIRST', 'scrollToFirst',  '첫 다운로드 링크로 자동 스크롤'),
        mkToggle('NO_DUPLICATE_CARD', 'noDuplicateCard', '중복 카드 생성 방지', 'ON 시 같은 작품·링크 코드가 반복되어도 첫 번째만 카드 생성'),
        mkToggle('TXT_LIVE',        'txtLive',        'TXT 작품 보유 확인', '마지막으로 업로드한 TXT 기준으로 카드 색상 표시'),
        mkTriple('NAV_TARGET', 'navTarget', 'w/s 탐색 범위', [
          { value: 'both', text: '모두' }, { value: 'products', text: '작품' }, { value: 'links', text: '다운로드' },
        ]),
      ),
      mkGroup('모아보기', linkPanelToggle, panelTargetTriple),
      mkGroup('링크 확인', linkCheckToggle, delaySlider),
    );

    // 미리보기 탭
    const tagsExpandedToggle = mkToggle('PREVIEW_TAGS_EXPANDED', 'previewTagsExpanded', '태그 기본 펼침', '미리보기를 열자마자 태그를 펼친 상태로 표시', null, { sub: true });
    const tagsToggle = mkToggle('PREVIEW_TAGS', 'previewTags', '작품 태그', 'DLsite 공식 장르 태그 표시 (T 키 또는 탭으로 펼침)', (on) => setSubEnabled(tagsExpandedToggle, on));
    setSubEnabled(tagsExpandedToggle, CFG.PREVIEW_TAGS);
    pPreview.append(
      mkGroup('미리보기',
        previewToggle,
        kpPreviewToggle,
        kpPosTriple,
        mkToggle('PREVIEW_META', 'previewMeta', '상세정보', '별점 · 판매수 · 출시일 표시 (DLsite/Steam)'),
        tagsToggle,
        tagsExpandedToggle,
        mkSlider('PREVIEW_SCALE', 'previewScale', '미리보기 크기', 50, 200, 10, '%', 100, (v) => {
          document.documentElement.style.setProperty('--b64-dlp-scale', v / 100);
        }),
      ),
    );

    pSystem.append(
      mkGroup('화면',
        mkSlider('SETTINGS_SCALE', 'settingsScale', 'UI 크기', 70, 150, 5, '%', 100, null, (v) => {
          const p = document.getElementById('b64d-settings');
          if (p) p.style.zoom = v / 100;
          const d = document.getElementById('b64-dup-modal');
          if (d) d.style.zoom = v / 100;
          const t = document.getElementById('b64d-stip');
          if (t) t.style.fontSize = (11 * v / 100) + 'px';
          const br = document.getElementById('b64d-br');
          if (br) { br.style.transform = 'scale(' + (v / 100) + ')'; brReposition(); }
        }),
        mkToggle('NAV_TOAST', 'navToast', '페이지 전환 알림', '작성↔읽기 페이지로 전환될 때 화면 상단에 잠깐 알림 표시'),
        mkToggle('LIVE_APPLY', 'liveApply', '설정 변경 즉시 적용', 'OFF 시 다음 페이지 로드에 반영 · 긴 글에서 렉 방지'), // '적용' 그룹 통합 → 화면 최하단
      ),
      mkGroup('비번',
        mkToggle('PW_AUTO', 'pwAuto', '비번 자동입력'),
        mkAction('🔑 비번 목록 관리', openPwManager),
      ),
      mkGroup('단축키', hkMain.row, hkDup.row),
      mkGroup('업데이트',
        mkToggle('UPDATE_CHECK', 'updateCheck', '자동 업데이트 확인', '접속 시 GreasyFork 최신 버전을 확인해 새 버전이 있으면 상단에 알림(6시간 간격). GreasyFork 설치본에서만 동작.'),
        mkAction('🔄 지금 업데이트 확인', () => runUpdateCheck(true)),
        mkNote('완전 무인 업데이트를 원하면 <b>Tampermonkey → 설정 → 업데이트 확인 간격</b>을 켜 두세요(매니저가 백그라운드로 자동 설치).'),
      ),
    );

    // 탭 바 생성
    const tabBar = document.createElement('div');
    tabBar.className = 'b64ds-tabs';
    let activeTabIdx = GM_getValue('settingsTab', 0);
    const tabBtns = tabDefs.map((label, i) => {
      const btn = document.createElement('button');
      btn.className = 'b64ds-tab' + (i === activeTabIdx ? ' active' : '');
      btn.textContent = label;
      btn.addEventListener('click', () => {
        tabBtns.forEach((b, j) => {
          b.classList.toggle('active', j === i);
          tabPanels[j].classList.toggle('active', j === i);
        });
        GM_setValue('settingsTab', i);
      });
      return btn;
    });
    tabBtns.forEach(b => tabBar.appendChild(b));
    tabPanels[activeTabIdx].classList.add('active');

    body.appendChild(tabBar);
    tabPanels.forEach(p => body.appendChild(p));

    const panel = document.createElement('div');
    panel.id = 'b64d-settings';
    panel.dataset.theme = getSystemTheme();
    panel.style.zoom = CFG.SETTINGS_SCALE / 100;
    panel.addEventListener('click', e => e.stopPropagation());
    panel.appendChild(hdr);
    panel.appendChild(body);

    const bd = document.createElement('div');
    bd.id = 'b64d-settings-backdrop';
    bd.addEventListener('click', closeSettingsPanel);

    document.body.appendChild(bd);
    document.body.appendChild(panel);

    // 설정창이 닫힐 때 캡처 모드 리스너 정리
    _settingsCaptureCleanup = () => { hkMain.cancel(); hkDup.cancel(); };

    // 가장 긴 탭에 맞춰 모든 패널 높이 고정 (탭 전환 시 창 크기 변동 방지)
    requestAnimationFrame(() => {
      let maxH = 0;
      tabPanels.forEach(tp => {
        const prev = tp.style.cssText;
        tp.style.cssText = 'display:flex!important;visibility:hidden;position:absolute;';
        maxH = Math.max(maxH, tp.scrollHeight);
        tp.style.cssText = prev;
      });
      if (maxH > 0) tabPanels.forEach(tp => { tp.style.minHeight = maxH + 'px'; });
    });
  }

  function maybeInitPwSetup() {
    if (getPwList() !== null) return;
    setTimeout(() => {
      const ok = window.confirm('🔑 KONE +\n\n비번 자동입력이 처음 실행됩니다.\n지금 비번을 등록하시겠습니까?');
      if (!ok) { savePwList([]); return; }
      const input = window.prompt('비번 입력 (쉼표로 구분):\n예: pass1, 2024국룰');
      if (input) {
        const pws = input.split(',').map(s => s.trim()).filter(Boolean);
        savePwList(pws);
        window.alert(`✅ ${pws.length}개 등록됨.`);
      } else { savePwList([]); }
    }, 2000);
  }

  refreshMenu();
  maybeInitPwSetup();

  // 최초 설치 시 설정 패널 자동 오픈 (kone.gg에서만)
  if (!GM_getValue('_b64d_installed', false)) {
    GM_setValue('_b64d_installed', true);
    if (/kone\.gg/.test(location.hostname)) {
      setTimeout(openSettingsPanel, 2000);
    }
  }

  /* ================================================================
     CSS
  ================================================================ */
  if (!document.getElementById('b64d-style')) {
    const s = document.createElement('style');
    s.id = 'b64d-style';
    s.textContent = `
/* ── 다운로드 링크 카드 ── */
.b64-link{display:inline-flex;align-items:center;gap:8px;padding:6px 10px 6px 0;margin:2px 0;
  background:#f4f4f5;border:0.5px solid #d4d4d8;border-radius:8px;text-decoration:none;color:#18181b;
  font-size:13px;line-height:1.3;max-width:100%;box-sizing:border-box;overflow:hidden;
  transition:background .15s,border-color .15s;cursor:pointer;vertical-align:middle;}
.b64-link:hover{background:#e4e4e7;border-color:#a1a1aa;}
/* 수정1: 댓글 안에서 카드(inline-flex)가 이름·시간과 같은 flex 줄에 끼어 레이아웃이 틀어지던 문제.
   댓글 영역에서만 카드를 별도 줄(flex-basis:100%)로 내리고, 과폭 방지로 max-width 제한.
   ★부모 flex 줄바꿈은 '카드가 든 flex'에만 :has로 한정한다 — 전역으로 주면 우측 액션 바(수정/삭제 버튼)
   flex까지 줄바꿈돼 버튼이 밀리거나 사라지던 부작용이 있었다(사용자 지적). */
#post-comment .b64-link,#post-comment .b64-product-link{flex-basis:100%;max-width:460px;box-sizing:border-box;margin-top:3px;}
#post-comment .flex.gap-2:has(.b64-link,.b64-product-link){flex-wrap:wrap;}
/* 요구1·2: 댓글 변환 툴바 — 코네 '코네콘/작성' 버튼과 같은 줄·유사 모양. mr-auto로 좌측, 자체 버튼은 우측. */
.b64-ctool-bar{display:flex;align-items:center;gap:6px;margin-right:auto;}
.b64-ctool-btn{display:inline-flex;align-items:center;gap:5px;font:500 14px/1 inherit;color:#3f3f46;
  background:#f4f4f5;border:0.5px solid #d4d4d8;border-radius:9999px;padding:9px 16px;cursor:pointer;
  transition:background .15s,border-color .15s;}
.b64-ctool-btn:hover{background:#e4e4e7;border-color:#a1a1aa;}
.b64-ctool-ic{flex-shrink:0;opacity:.8;}
/* 코드(StegCloak) 미니멀 칩 스트립 — 무채색. 툴바 아래(비번 안내 + 한 줄 컨트롤). */
.b64-ctool-code{display:flex;flex-direction:column;gap:5px;width:100%;flex-basis:100%;margin-top:6px;}
.b64-ctool-code-hide{display:none;}
.b64-ctool-row{display:flex;align-items:center;gap:8px;}
.b64-ctool-pwnote{font-size:11px;color:#71717a;padding:0 4px;line-height:1.4;}
.b64-ctool-pwnote-warn{color:#b45309;}
html.dark .b64-ctool-pwnote{color:#a1a1aa;}
html.dark .b64-ctool-pwnote-warn{color:#fbbf24;}
.b64-ctool-regen{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;
  border:0.5px solid #d4d4d8;border-radius:9999px;background:#f4f4f5;color:#3f3f46;cursor:pointer;transition:background .12s;}
.b64-ctool-regen:hover{background:#e4e4e7;}
.b64-ctool-regen .b64-ctool-ic{opacity:.85;}
.b64-ctool-cover{flex:1;min-width:0;height:32px;box-sizing:border-box;border:0.5px solid #d4d4d8;border-radius:9999px;
  padding:0 14px;font:400 12.5px/1 inherit;color:#3f3f46;background:#f4f4f5;outline:none;transition:background .12s,border-color .12s;}
.b64-ctool-cover:focus{border-color:#a1a1aa;background:#fff;}
.b64-ctool-gen{flex-shrink:0;border:0;border-radius:9999px;padding:8px 18px;font:500 13px/1 inherit;background:#18181b;color:#fafafa;cursor:pointer;transition:background .12s;}
.b64-ctool-gen:hover{background:#000;}
.b64-ctool-gen:disabled{opacity:.6;cursor:default;}
html.dark .b64-ctool-regen{background:rgba(255,255,255,.08);color:#e5e7eb;border-color:rgba(209,213,219,.42);}
html.dark .b64-ctool-regen:hover{background:rgba(255,255,255,.13);}
html.dark .b64-ctool-cover{background:rgba(255,255,255,.08);color:#e5e7eb;border-color:rgba(209,213,219,.42);}
html.dark .b64-ctool-cover:focus{background:rgba(255,255,255,.13);border-color:rgba(209,213,219,.6);}
html.dark .b64-ctool-gen{background:#fafafa;color:#18181b;}
html.dark .b64-ctool-gen:hover{background:#fff;}
html.dark .b64-ctool-btn{color:#e5e7eb;background:rgba(255,255,255,.08);border-color:rgba(209,213,219,.42);}
html.dark .b64-ctool-btn:hover{background:rgba(255,255,255,.13);border-color:rgba(209,213,219,.60);}
.b64-link .bl-icon{width:16px;height:16px;flex-shrink:0;opacity:.55;}
.b64-link .bl-text{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1;}
.b64-link .bl-title{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-link .bl-sub{font-size:11px;opacity:.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-link .bl-arrow{flex-shrink:0;opacity:.4;font-size:12px;}
.b64-link.lk-checking .bl-arrow{animation:b64spin .8s linear infinite;display:inline-block;}
@keyframes b64spin{to{transform:rotate(360deg);}}
.b64-link.lk-alive{background:#f0fdf4;border-color:#86efac;color:#14532d;}
.b64-link.lk-dead{background:#fef2f2;border-color:#fca5a5;color:#7f1d1d;}
.b64-link.lk-alive .bl-icon,.b64-link.lk-dead .bl-icon,
.b64-link.lk-alive .bl-arrow,.b64-link.lk-dead .bl-arrow{opacity:.7;}

/* ── 상품 링크 카드 (DLsite) ── */
.b64-product-link{display:inline-flex;align-items:center;gap:8px;padding:6px 10px 6px 0;margin:2px 0;
  background:#f5f3ff;border:0.5px solid #c4b5fd;border-radius:8px;text-decoration:none;color:#4c1d95;
  font-size:13px;line-height:1.3;max-width:100%;box-sizing:border-box;overflow:hidden;
  transition:background .15s,border-color .15s;cursor:pointer;vertical-align:middle;position:relative;}
/* DLsite 장르/태그 검색 칩 (작은 노란 알약) */
.b64-dlsite-tag{display:inline-flex;align-items:center;padding:1px 9px;margin:2px 3px;border-radius:999px;
  background:#fde68a;border:1px solid #f59e0b;color:#78350f;font-size:12px;font-weight:600;line-height:1.5;
  text-decoration:none;vertical-align:middle;cursor:pointer;white-space:nowrap;transition:background .12s,border-color .12s;}
.b64-dlsite-tag:hover{background:#fcd34d;border-color:#d97706;}
html.dark .b64-dlsite-tag{background:rgba(250,204,21,.15);color:#fde68a;border-color:rgba(250,204,21,.45);}
/* 장르·태그 별도 바 (요구1 C안) — 본문 하단에 칩을 한 벌로 모음 */
.b64-genre-bar{margin-top:14px;padding-top:10px;border-top:2px dashed #e4e4e7;display:flex;flex-wrap:wrap;align-items:center;gap:2px;}
.b64-genre-bar-label{font-size:11px;color:#71717a;font-weight:600;letter-spacing:.05em;margin-right:6px;}
html.dark .b64-genre-bar{border-top-color:#3f3f46;}
html.dark .b64-genre-bar-label{color:#a1a1aa;}
/* 숨은 코드(StegCloak) 본문 복원 결과 / 실패 배지 (요구2·수정1) */
.b64-steg-done{background:rgba(34,197,94,.14);border-radius:4px;padding:0 3px;box-decoration-break:clone;-webkit-box-decoration-break:clone;}
.b64-steg-badge{display:inline-flex;align-items:center;gap:4px;margin-left:6px;vertical-align:middle;
  background:#e0e7ff;border:1px solid #a5b4fc;color:#3730a3;border-radius:9999px;padding:1px 10px;
  font-size:12px;font-weight:600;line-height:1.5;cursor:pointer;white-space:nowrap;transition:background .12s;}
.b64-steg-badge:hover{background:#c7d2fe;}
html.dark .b64-steg-done{background:rgba(34,197,94,.2);}
html.dark .b64-steg-badge{background:rgba(99,102,241,.18);color:#c7d2fe;border-color:rgba(129,140,248,.45);}
html.dark .b64-steg-badge:hover{background:rgba(99,102,241,.3);}
html.dark .b64-dlsite-tag:hover{background:rgba(250,204,21,.28);}
.b64-tl-owned::after,.b64-tl-new::after{content:'';position:absolute;right:0;top:0;bottom:0;width:5px;}
.b64-tl-owned::after{background:#ef4444;}
.b64-tl-new::after{background:#22c55e;}
.b64-product-link:hover{background:#ede9fe;border-color:#a78bfa;}
.b64-product-link .bl-icon{width:16px;height:16px;flex-shrink:0;opacity:.6;}
.b64-product-link .bl-text{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1;}
.b64-product-link .bl-title{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-product-link .bl-sub{font-size:11px;opacity:.6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.b64-product-link .bl-arrow{flex-shrink:0;opacity:.4;font-size:12px;}

/* ── Steam 카드 ── */
.b64-product-link.pl-steam{background:#f0f9ff;border-color:#7dd3fc;color:#075985;}
.b64-product-link.pl-steam:hover{background:#e0f2fe;border-color:#38bdf8;}

/* ── Patreon / Getcu / Fanza / Fanbox 카드 — 공통 오렌지 ── */
.b64-product-link.pl-patreon,
.b64-product-link.pl-getcu,
.b64-product-link.pl-fanza,
.b64-product-link.pl-fanbox{background:#fff7ed;border-color:#fdba74;color:#7c2d12;}
.b64-product-link.pl-patreon:hover,
.b64-product-link.pl-getcu:hover,
.b64-product-link.pl-fanza:hover,
.b64-product-link.pl-fanbox:hover{background:#ffedd5;border-color:#fb923c;}


/* ── 설정 패널 / 비번 모달 ── */
/* 세련된 한글 폰트: Pretendard(가능하면 CDN 로드) → 없으면 시스템 한글 폰트로 폴백. CSP 차단 시 폴백만 적용. */
@font-face{font-family:'Pretendard Variable';font-weight:45 920;font-style:normal;font-display:swap;
  src:url('https://cdn.jsdelivr.net/gh/orioncactus/[email protected]/dist/web/variable/woff2/PretendardVariable.woff2') format('woff2-variations');}
#b64d-settings,#b64d-pw-manager,#b64d-brfinal,#b64d-chname{
  --b64-bg:#18181b;--b64-bg2:#27272a;--b64-bg3:#3f3f46;
  --b64-text:#fafafa;--b64-text2:#a1a1aa;--b64-sep-c:#71717a;
  --b64-on-bg:#0c4a6e;--b64-on-hov:#1a6a8f;--b64-on-text:#e0f2fe;
  --b64-input-bg:#27272a;--b64-input-bd:#52525b;--b64-del-c:#ef4444;
  --b64-font:'Pretendard Variable',Pretendard,-apple-system,BlinkMacSystemFont,'Apple SD Gothic Neo','Segoe UI','Malgun Gothic','Noto Sans KR',Roboto,sans-serif;
  --b64-mono:ui-monospace,'SF Mono','JetBrains Mono','Cascadia Code','Roboto Mono',Menlo,Consolas,'Pretendard Variable',monospace;
  position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
  max-width:95vw;max-height:82vh;overflow-y:auto;border-radius:14px;
  background:var(--b64-bg);color:var(--b64-text);
  box-shadow:0 16px 48px rgba(0,0,0,.65);z-index:2147483641;
  font-family:var(--b64-font);font-size:13px;letter-spacing:-0.01em;-webkit-font-smoothing:antialiased;}
#b64d-settings[data-theme="light"],#b64d-pw-manager[data-theme="light"],#b64d-brfinal[data-theme="light"],#b64d-chname[data-theme="light"]{
  --b64-bg:#ffffff;--b64-bg2:#f4f4f5;--b64-bg3:#e4e4e7;
  --b64-text:#18181b;--b64-text2:#52525b;--b64-sep-c:#71717a;
  --b64-on-bg:#0369a1;--b64-on-hov:#0284c7;--b64-on-text:#ffffff;
  --b64-input-bg:#ffffff;--b64-input-bd:#d4d4d8;}
#b64d-settings-backdrop,#b64d-pw-backdrop,#b64d-brfinal-backdrop,#b64d-chname-backdrop{
  position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483640;backdrop-filter:blur(2px);}
#b64d-settings{width:480px;}
#b64d-pw-manager{width:376px;z-index:2147483642;}
#b64d-pw-backdrop{z-index:2147483641;}
#b64d-brfinal{width:380px;z-index:2147483646;}
#b64d-brfinal-backdrop{z-index:2147483645;}
/* 채널명 관리 모달 (시안 C: 코드=칩, 행=카드) — 비번 모달과 동일 테마.
   왼쪽 칩 104px 고정 + 오른쪽 입력 flex → 모든 행 좌우 너비 통일. 현재 채널은 코드 칩만 강조. */
#b64d-chname{width:400px;z-index:2147483646;}
#b64d-chname-backdrop{z-index:2147483645;}
#b64d-chname .b64dpm-body{padding:12px;display:flex;flex-direction:column;gap:8px;}
/* 목록 고정 높이(≈5행) — 초과분은 하단 페이지 버튼으로. 수동 추가 행이 있으면 그만큼만 스크롤. */
#b64d-chname .b64dpm-list{height:266px;overflow-y:auto;gap:0;padding:2px;}
#b64d-chname .b64dcn-row{display:flex;align-items:center;gap:9px;padding:8px;margin-bottom:7px;background:var(--b64-bg2);border-radius:10px;}
#b64d-chname .b64dcn-row:last-child{margin-bottom:0;}
#b64d-chname .b64dcn-code{flex:0 0 104px;box-sizing:border-box;background:var(--b64-bg);border:1px solid var(--b64-bg3);border-radius:7px;
  padding:6px 9px;font-family:var(--b64-mono);font-size:12px;letter-spacing:.01em;color:var(--b64-text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
#b64d-chname .b64dcn-row.on .b64dcn-code{background:var(--b64-on-bg);color:var(--b64-on-text);border-color:transparent;font-weight:600;}
#b64d-chname .b64dcn-name{flex:1;min-width:0;background:var(--b64-bg);font-size:13px;}
/* 수동 추가 인라인 행(시안 A): 점선 accent 카드 + 코드 입력(칩과 동일 폭) + ✓ */
#b64d-chname .b64dcn-addrow{background:var(--b64-input-bg);border:1px dashed var(--b64-on-bg);}
#b64d-chname .b64dcn-codein{flex:0 0 104px;box-sizing:border-box;font-family:var(--b64-mono);font-size:12px;padding:7px 9px;}
#b64d-chname .b64dcn-ok{flex:0 0 auto;width:30px;height:30px;border:0;border-radius:8px;background:var(--b64-on-bg);color:var(--b64-on-text);font-size:14px;cursor:pointer;transition:filter .12s;}
#b64d-chname .b64dcn-ok:hover{filter:brightness(1.15);}
#b64d-chname .b64dcn-row.flash{outline:2px solid #f97316;outline-offset:1px;animation:b64cn-flash 1.2s ease;}
@keyframes b64cn-flash{0%,100%{outline-color:transparent;}20%,80%{outline-color:#f97316;}}
/* 하단: 좌측 수동 추가 + 우측 페이지 버튼 */
#b64d-chname .b64dcn-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;padding-top:10px;border-top:1px solid var(--b64-bg3);}
#b64d-chname .b64dcn-add{border:1px solid var(--b64-input-bd);border-radius:8px;background:var(--b64-bg2);color:var(--b64-text2);
  font-family:inherit;font-size:12px;font-weight:500;padding:8px 13px;cursor:pointer;transition:background .12s,color .12s;white-space:nowrap;}
#b64d-chname .b64dcn-add:hover{background:var(--b64-bg3);color:var(--b64-text);}
#b64d-chname .b64dcn-add.on{background:var(--b64-on-bg);color:var(--b64-on-text);border-color:transparent;}
#b64d-chname .b64dcn-pager{display:flex;gap:3px;}
#b64d-chname .b64dcn-pg{min-width:22px;border:1px solid var(--b64-bg3);border-radius:5px;background:none;color:var(--b64-text2);
  font-size:11px;padding:2px 6px;line-height:18px;cursor:pointer;font-family:inherit;transition:background .12s;}
#b64d-chname .b64dcn-pg:hover:not(.on):not(:disabled){background:var(--b64-bg2);}
#b64d-chname .b64dcn-pg.on{background:var(--b64-on-bg);color:var(--b64-on-text);border-color:transparent;}
#b64d-chname .b64dcn-pg:disabled{opacity:.35;cursor:default;}
/* 비번 관리 채널 목록 하단 '채널명 관리' 전체폭 버튼 (chSide 바닥에 고정) */
.b64dpm-chname-open{width:100%;margin-top:auto;flex-shrink:0;padding:9px 8px;border:1px solid var(--b64-input-bd);border-radius:8px;
  background:var(--b64-bg2);color:var(--b64-text2);font-family:inherit;font-size:12px;font-weight:500;letter-spacing:-.01em;cursor:pointer;transition:background .12s,color .12s;}
.b64dpm-chname-open:hover{background:var(--b64-bg3);color:var(--b64-text);}
/* 받침 보정 단어 편집 모달 — 비번 목록과 구분되는 칩(태그) 기반 UI */
.b64dbf-body{padding:14px 16px 16px;display:flex;flex-direction:column;gap:11px;}
.b64dbf-note{font-size:11.5px;line-height:1.55;color:var(--b64-text2);background:var(--b64-bg2);border-radius:9px;padding:9px 11px;}
.b64dbf-note b{color:var(--b64-text);font-weight:600;}
.b64dbf-chips{display:flex;flex-wrap:wrap;gap:6px;max-height:190px;overflow-y:auto;padding:3px 1px;align-content:flex-start;}
.b64dbf-empty{font-size:12px;color:var(--b64-sep-c);padding:6px 2px;}
.b64dbf-chip{display:inline-flex;align-items:center;gap:5px;background:var(--b64-bg2);border:1px solid var(--b64-bg3);
  border-radius:999px;padding:4px 6px 4px 11px;font-size:13px;font-weight:600;color:var(--b64-text);line-height:1.3;}
.b64dbf-chip-x{background:none;border:none;cursor:pointer;color:var(--b64-sep-c);font-size:13px;line-height:1;
  padding:0;width:16px;height:16px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;transition:.12s;}
.b64dbf-chip-x:hover{background:var(--b64-del-c);color:#fff;}
.b64dbf-add-row{display:flex;gap:6px;}
.b64dbf-input{flex:1;min-width:0;font-size:13px;padding:7px 10px;border:1px solid var(--b64-input-bd);border-radius:8px;
  background:var(--b64-input-bg);color:var(--b64-text);outline:none;}
.b64dbf-input:focus{border-color:var(--b64-on-bg);}
.b64dbf-add{flex-shrink:0;border:none;border-radius:8px;background:var(--b64-on-bg);color:var(--b64-on-text);
  font-size:13px;font-weight:600;padding:0 16px;cursor:pointer;font-family:inherit;}
.b64dbf-add:hover{background:var(--b64-on-hov);}
.b64dbf-foot{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:1px;}
.b64dbf-count{font-size:11px;color:var(--b64-sep-c);}
.b64dbf-reset{border:1px solid var(--b64-bg3);border-radius:8px;background:transparent;color:var(--b64-text2);
  font-size:12px;padding:6px 12px;cursor:pointer;font-family:inherit;}
.b64dbf-reset:hover{background:var(--b64-bg2);color:var(--b64-text);}
.b64ds-header{
  display:flex;align-items:center;justify-content:space-between;
  padding:14px 16px;background:var(--b64-bg2);border-radius:14px 14px 0 0;
  font-weight:700;font-size:15px;letter-spacing:-.01em;position:sticky;top:0;z-index:1;}
.b64ds-close{background:none;border:none;color:var(--b64-sep-c);cursor:pointer;font-size:18px;padding:0 4px;line-height:1;border-radius:4px;transition:color .15s;}
.b64ds-close:hover{color:var(--b64-text);}
.b64ds-slider-row{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:7px 10px;}
.b64ds-slider-r{display:flex;align-items:center;gap:4px;}
.b64ds-slider-val{font-size:11px;color:var(--b64-text2);min-width:34px;text-align:right;flex-shrink:0;}
.b64ds-slider-reset{background:none;border:1px solid var(--b64-bg3,#d4d4d8);border-radius:4px;color:var(--b64-text2);cursor:pointer;font-size:12px;padding:0 4px;line-height:1.5;flex-shrink:0;}
.b64ds-slider-reset:hover{background:var(--b64-bg2);color:var(--b64-text);}
.b64ds-slider{flex:1;min-width:80px;max-width:120px;cursor:pointer;accent-color:var(--b64-on-bg);}
.b64d-stip{position:fixed;z-index:2147483649;background:#18181b;color:#fafafa;font-size:11px;line-height:1.5;padding:0.45em 0.82em;border-radius:6px;max-width:20em;white-space:normal;pointer-events:none;box-shadow:0 2px 10px rgba(0,0,0,.4);}
.b64ds-body{padding:0;}
.b64ds-tabs{display:flex;background:var(--b64-bg2);border-bottom:1px solid var(--b64-bg3);padding:0 12px;overflow:hidden;}
.b64ds-tab{flex:1;padding:10px 0;font-size:13px;font-weight:600;cursor:pointer;border:none;background:none;color:var(--b64-text2);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s,background .12s;font-family:inherit;}
.b64ds-tab:hover{color:var(--b64-text);background:rgba(127,127,127,.1);}
.b64ds-tab.active{color:var(--b64-text);border-bottom-color:#3b82f6;}
.b64ds-tabpanel{display:none;flex-direction:column;gap:10px;padding:16px 16px 26px;}
.b64ds-tabpanel.active{display:flex;}
.b64ds-sep{font-size:11px;font-weight:700;color:var(--b64-sep-c);letter-spacing:.06em;text-transform:uppercase;padding:16px 4px 4px;}
.b64ds-sep:first-child{padding-top:2px;}
.b64ds-section{background:var(--b64-bg2);border-radius:12px;padding:2px 6px;}
.b64ds-group-title{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:700;color:var(--b64-text2);letter-spacing:.05em;text-transform:uppercase;padding:7px 9px;margin:2px 0 5px;border-radius:7px;background:linear-gradient(90deg,rgba(56,189,248,.20),rgba(56,189,248,.02));}
.b64ds-group-title svg{width:14px;height:14px;flex-shrink:0;color:#38bdf8;}
.b64ds-section > :not(.b64ds-group-title) + :not(.b64ds-group-title){position:relative;}
.b64ds-section > :not(.b64ds-group-title) + :not(.b64ds-group-title)::before{content:'';position:absolute;top:0;left:10px;right:10px;border-top:0.5px solid var(--b64-bg3);pointer-events:none;}
.b64ds-toggle{
  display:flex;align-items:center;justify-content:space-between;gap:10px;
  width:100%;padding:10px;border-radius:8px;cursor:pointer;
  border:none;background:transparent;color:var(--b64-text);text-align:left;
  font-size:13px;font-family:inherit;transition:background .12s;}
.b64ds-toggle:hover,.b64ds-toggle.active:hover{background:var(--b64-bg3);}
.b64ds-sub{margin-left:18px;width:calc(100% - 18px);box-sizing:border-box;}
.b64ds-disabled{opacity:.42;pointer-events:none;}
.b64ds-badge{position:relative;width:36px;height:20px;border-radius:999px;flex-shrink:0;background:var(--b64-bg3);transition:background .15s;}
.b64ds-badge::after{content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;transition:left .15s;box-shadow:0 1px 2px rgba(0,0,0,.3);}
.b64ds-toggle.active .b64ds-badge{background:var(--b64-on-bg);}
.b64ds-toggle.active .b64ds-badge::after{left:18px;}
/* 활성 강조: 켜짐=또렷+세미볼드, 꺼짐=글자만 옅게(스위치는 또렷) → 상위 OFF 흐림(.b64ds-disabled)과 구분 */
.b64ds-toggle{color:var(--b64-text2);font-weight:400;}
.b64ds-toggle.active{color:var(--b64-text);font-weight:500;}
.b64ds-action{
  display:flex;align-items:center;gap:8px;
  width:100%;padding:11px 14px;border-radius:9px;cursor:pointer;
  border:none;background:var(--b64-bg2);color:var(--b64-text2);font-size:13px;
  font-family:inherit;text-align:left;transition:background .12s;}
.b64ds-action:hover{background:var(--b64-bg3);}
/* 하위(b64ds-sub) 액션 버튼: .b64ds-action의 width:100%가 .b64ds-sub의 calc를 덮어써 margin-left(18px)만큼
   오른쪽으로 삐져나오던 문제 → 더 구체적인 선택자로 너비를 (100% - 18px)로 고정 */
.b64ds-action.b64ds-sub{width:calc(100% - 18px);}
.b64ds-note{font-size:11px;line-height:1.55;color:var(--b64-text2);padding:6px 12px 2px;}
.b64ds-note b{color:var(--b64-text);font-weight:600;}
.b64ds-hotkey-row{
  display:flex;align-items:center;justify-content:space-between;gap:8px;
  padding:9px 14px;border-radius:9px;background:var(--b64-bg2);}
.b64ds-hotkey-label{font-size:13px;color:var(--b64-text2);}
.b64ds-hotkey-r{display:flex;align-items:center;gap:6px;}
.b64ds-hotkey-key{
  font-size:11px;font-family:monospace;padding:3px 9px;border-radius:6px;
  background:var(--b64-bg3);color:var(--b64-text);border:1px solid var(--b64-input-bd);}
.b64ds-hotkey-btn{
  font-size:11px;padding:3px 9px;border-radius:6px;cursor:pointer;
  border:1px solid var(--b64-input-bd);background:var(--b64-bg2);color:var(--b64-text2);
  font-family:inherit;transition:background .12s,color .12s;}
.b64ds-hotkey-btn:hover{background:var(--b64-bg3);}
.b64ds-hotkey-btn.capturing{color:var(--b64-on-text);background:var(--b64-on-bg);border-color:transparent;}
.b64dpm-body{padding:12px;display:flex;flex-direction:column;gap:8px;}
/* 고정 높이 — 탭을 바꿔도 패널 크기가 흔들리지 않게(설정창처럼). 내부에서만 스크롤. */
.b64dpm-pane{display:flex;flex-direction:column;height:300px;}
.b64dpm-pane > .b64dpm-list{flex:1;min-height:0;max-height:none;}
.b64dpm-pane > .b64dpm-chgrid{flex:1;min-height:0;}
.b64dpm-list{display:flex;flex-direction:column;gap:5px;max-height:200px;overflow-y:auto;padding:2px 0;}
.b64dpm-empty{font-size:12px;color:var(--b64-sep-c);padding:8px 2px;}
.b64dpm-item{
  display:flex;align-items:center;
  padding:9px 10px;border-radius:9px;background:var(--b64-bg2);}
.b64dpm-item span{font-size:13px;color:var(--b64-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* 순서 번호(뮤트 거터) + 비번(세련된 고정폭) — 번호 뒤에만 구분선 */
.b64dpm-num{flex:0 0 20px;text-align:center;font-size:12px;color:var(--b64-sep-c);font-variant-numeric:tabular-nums;}
.b64dpm-pw{flex:1;min-width:0;margin-right:8px;font-family:var(--b64-mono);font-size:13.5px;letter-spacing:.02em;color:var(--b64-text);}
.b64dpm-num + .b64dpm-pw{border-left:1px solid var(--b64-input-bd);padding-left:10px;margin-left:10px;}
.b64dpm-del{
  background:none;border:none;color:var(--b64-sep-c);cursor:pointer;
  font-size:16px;padding:0 2px;flex-shrink:0;line-height:1;transition:color .15s;}
.b64dpm-del:hover{color:var(--b64-del-c);}
.b64dpm-tabs{display:flex;gap:2px;background:var(--b64-bg2);border-radius:9px;padding:3px;margin-bottom:10px;}
.b64dpm-tab{flex:1;font-size:13px;font-weight:500;padding:7px 0;border-radius:7px;border:none;cursor:pointer;background:none;color:var(--b64-sep-c);font-family:inherit;transition:background .15s,color .15s;}
.b64dpm-tab.on{background:var(--b64-bg);color:var(--b64-text);box-shadow:0 1px 3px rgba(0,0,0,.12);}
.b64dpm-note{font-size:11px;color:var(--b64-sep-c);margin-bottom:10px;line-height:1.4;}
.b64dpm-hide{display:none;}
/* 채널별 비번 — 사이드바 마스터-디테일(버전 A) */
.b64dpm-chgrid{display:grid;grid-template-columns:120px 1fr;gap:12px;align-items:stretch;}
.b64dpm-chside{display:flex;flex-direction:column;gap:2px;overflow-y:auto;margin:-2px;padding:2px;min-height:0;}
.b64dpm-chdetail{display:flex;flex-direction:column;min-height:0;}
.b64dpm-chdetail > .b64dpm-list{flex:1;min-height:0;max-height:none;}
.b64dpm-chitem{display:flex;align-items:center;gap:6px;width:100%;text-align:left;font-size:13px;color:var(--b64-text2);
  background:none;border:none;border-radius:8px;padding:8px 10px;cursor:pointer;font-family:inherit;transition:background .12s,color .12s;}
.b64dpm-chitem:hover{background:var(--b64-bg2);color:var(--b64-text);}
.b64dpm-chitem.on{background:var(--b64-on-bg);color:var(--b64-on-text);font-weight:500;}
.b64dpm-chname{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.b64dpm-chcount{font-size:11px;opacity:.65;flex-shrink:0;font-variant-numeric:tabular-nums;}
.b64dpm-chadd{color:var(--b64-on-bg);}
.b64dpm-chadd.on{color:var(--b64-on-text);}
.b64dpm-chdetail{border-left:1px solid var(--b64-input-bd);padding-left:12px;min-width:0;}
.b64dpm-chtitle{font-size:13px;font-weight:600;color:var(--b64-text);margin-bottom:9px;display:flex;align-items:baseline;gap:7px;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.b64dpm-chsub{font-size:11px;font-weight:400;color:var(--b64-sep-c);flex-shrink:0;}
.b64dpm-input-row{display:flex;gap:6px;margin-top:6px;}
.b64dpm-input{
  flex:1;padding:9px 12px;border-radius:8px;font-size:13px;
  border:1px solid var(--b64-input-bd);background:var(--b64-input-bg);
  color:var(--b64-text);font-family:inherit;outline:none;min-width:0;}
.b64dpm-input:focus{border-color:var(--b64-on-bg);}
.b64dpm-add{
  padding:9px 14px;border-radius:8px;cursor:pointer;font-size:13px;
  border:none;background:var(--b64-on-bg);color:var(--b64-on-text);
  font-family:inherit;transition:background .12s;white-space:nowrap;flex-shrink:0;}
.b64dpm-add:hover{background:var(--b64-on-hov);}
.b64dpm-clear{
  padding:9px 12px;border-radius:8px;cursor:pointer;font-size:12px;
  border:1px solid var(--b64-del-c);background:transparent;color:var(--b64-del-c);
  font-family:inherit;transition:background .12s,color .12s;width:100%;margin-top:4px;text-align:center;}
.b64dpm-clear:hover{background:var(--b64-del-c);color:#fff;}

/* ── 공통 유틸 ── */
#b64d-drag-tooltip{
  position:fixed;z-index:2147483647;background:#18181b;color:#fafafa;
  font-size:13px;line-height:1.5;padding:8px 12px;border-radius:8px;
  max-width:480px;word-break:break-all;pointer-events:auto;
  box-shadow:0 4px 16px rgba(0,0,0,.25);
  display:flex;flex-direction:column;gap:6px;}
#b64d-drag-tooltip .b64d-drag-text{white-space:pre-wrap;}
#b64d-drag-tooltip .b64d-drag-cards{display:flex;flex-direction:column;gap:4px;}
/* ── 모바일: 미리보기 뷰포트 중앙 고정, FAB 버튼 ── */
@media (hover:none) and (pointer:coarse){
  #b64d-dlsite-preview{
    left:50%!important;top:50%!important;
    transform:translate(-50%,-50%);
    max-width:calc(100vw - 24px)!important;
    width:min(calc(100vw - 24px), calc(480px * var(--b64-dlp-scale,1)))!important;
  }
}
#b64d-fab{
  position:fixed;bottom:20px;right:20px;z-index:2147483645;display:none;
  align-items:center;justify-content:center;
  width:46px;height:46px;border-radius:50%;padding:0;
  background:#6d28d9;color:#fff;border:none;font-size:22px;
  box-shadow:0 2px 10px rgba(0,0,0,.4);cursor:pointer;touch-action:manipulation;
}
@media (hover:none) and (pointer:coarse){#b64d-fab{display:flex;}}
/* ── 제목 카드 바 ── */
.b64-title-card-bar{margin-top:14px;padding-top:10px;border-top:2px dashed #e4e4e7;display:flex;flex-direction:column;gap:6px;}
.b64-title-card-label{font-size:11px;color:#71717a;font-weight:600;letter-spacing:.05em;margin-bottom:2px;}
@keyframes dlp-spin{to{transform:rotate(360deg);}}
#b64d-dlsite-preview{
  position:fixed;z-index:2147483646;background:rgba(0,0,0,.88);
  border:1px solid rgba(255,255,255,.12);
  width:calc(480px * var(--b64-dlp-scale,1));height:calc(360px * var(--b64-dlp-scale,1));
  overflow:hidden;display:flex;align-items:center;justify-content:center;border-radius:10px;
  pointer-events:auto;box-shadow:0 8px 32px rgba(0,0,0,.6);}
#b64d-dlsite-preview img{width:100%;height:100%;object-fit:contain;display:block;}
#b64d-dlsite-preview .dlp-spinner{
  width:calc(38px * var(--b64-dlp-scale,1));height:calc(38px * var(--b64-dlp-scale,1));border-radius:50%;
  border:3px solid rgba(255,255,255,.18);border-top-color:rgba(255,255,255,.85);
  animation:dlp-spin .7s linear infinite;}
#b64d-dlsite-preview .dlp-err{
  font-size:calc(13px * var(--b64-dlp-scale,1));color:rgba(255,255,255,.55);text-align:center;padding:12px;}
#b64d-dlsite-preview .dlp-panel{
  position:absolute;bottom:0;left:0;right:0;
  padding:0;background:transparent;
  display:flex;flex-direction:column;gap:0;pointer-events:none;}
/* 칩 또는 내비가 생긴 순간 JS가 has-content 클래스를 추가 → 그라디언트 활성화 */
#b64d-dlsite-preview .dlp-panel.has-content{
  padding:calc(44px * var(--b64-dlp-scale,1)) calc(11px * var(--b64-dlp-scale,1)) calc(10px * var(--b64-dlp-scale,1));
  background:linear-gradient(transparent,rgba(0,0,0,.9) 40%);
  gap:calc(5px * var(--b64-dlp-scale,1));}
#b64d-dlsite-preview .dlp-chips{display:flex;flex-wrap:wrap;gap:calc(5px * var(--b64-dlp-scale,1));}
#b64d-dlsite-preview .dlp-nav-row{display:flex;align-items:center;gap:calc(8px * var(--b64-dlp-scale,1));}
#b64d-dlsite-preview .dlp-tags-bar{font-size:calc(11px * var(--b64-dlp-scale,1));color:rgba(255,255,255,.72);cursor:pointer;user-select:none;white-space:nowrap;pointer-events:auto;}
#b64d-dlsite-preview .dlp-tags-list{display:flex;flex-wrap:wrap;gap:calc(4px * var(--b64-dlp-scale,1));max-height:calc(66px * var(--b64-dlp-scale,1));overflow-y:auto;margin-top:calc(4px * var(--b64-dlp-scale,1));pointer-events:auto;}
#b64d-dlsite-preview .dlp-tag{font-size:calc(10px * var(--b64-dlp-scale,1));padding:calc(1px * var(--b64-dlp-scale,1)) calc(7px * var(--b64-dlp-scale,1));border-radius:999px;background:rgba(255,255,255,.14);color:#fafafa;white-space:nowrap;}
#b64d-dlsite-preview .dlp-chip{
  font-size:calc(16px * var(--b64-dlp-scale,1));font-weight:700;line-height:1;
  color:rgba(255,255,255,.92);background:rgba(255,255,255,.13);
  border:1px solid rgba(255,255,255,.18);
  border-radius:calc(4px * var(--b64-dlp-scale,1));
  padding:calc(5px * var(--b64-dlp-scale,1)) calc(11px * var(--b64-dlp-scale,1));
  white-space:nowrap;}
#b64d-dlsite-preview .dlp-chip-star{color:#fde68a;background:rgba(253,230,138,.1);border-color:rgba(253,230,138,.28);}
#b64d-dlsite-preview .dlp-chip-sales{color:#93c5fd;background:rgba(147,197,253,.08);border-color:rgba(147,197,253,.25);}
#b64d-dlsite-preview .dlp-chip-date{color:rgba(255,255,255,.6);}
#b64d-dlsite-preview .dlp-chip-review{color:#86efac;background:rgba(134,239,172,.08);border-color:rgba(134,239,172,.22);}
#b64d-dlsite-preview .dlp-nav{
  margin-left:auto;font-size:calc(12px * var(--b64-dlp-scale,1));color:rgba(255,255,255,.7);text-align:right;line-height:1.3;
  text-shadow:0 1px 4px rgba(0,0,0,.95),0 0 8px rgba(0,0,0,.8);}
/* W/S 미리보기 좌상단 작품 코드 라벨 */
#b64d-dlsite-preview .dlp-kp-code{
  position:absolute;top:calc(7px * var(--b64-dlp-scale,1));left:calc(7px * var(--b64-dlp-scale,1));z-index:2;
  font-size:calc(12px * var(--b64-dlp-scale,1));font-weight:700;line-height:1;letter-spacing:.02em;
  color:#fafafa;background:rgba(0,0,0,.72);border:1px solid rgba(255,255,255,.18);
  border-radius:calc(5px * var(--b64-dlp-scale,1));
  padding:calc(3px * var(--b64-dlp-scale,1)) calc(8px * var(--b64-dlp-scale,1));
  pointer-events:none;text-shadow:0 1px 3px rgba(0,0,0,.9);}

/* ── 키보드 링크 탐색 포커스 ── */
.b64-link.kp-focused,.b64-product-link.kp-focused{outline:2px solid #3b82f6;outline-offset:2px;box-shadow:0 0 0 4px rgba(59,130,246,.15);}
html.dark .b64-link.kp-focused,html.dark .b64-product-link.kp-focused{outline-color:#60a5fa;box-shadow:0 0 0 4px rgba(96,165,250,.2);}
/* ── 설정: 3-way 탐색 범위 선택 ── */
.b64ds-triple-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:9px 10px;background:transparent;}
.b64ds-triple-label{font-size:13px;color:var(--b64-text2);}
.b64ds-triple-btns{display:flex;gap:4px;}
.b64ds-triple-btn{font-size:11px;padding:4px 10px;border-radius:6px;cursor:pointer;border:1px solid var(--b64-input-bd);background:var(--b64-bg3);color:var(--b64-text2);font-family:inherit;transition:background .12s,color .12s;}
.b64ds-triple-btn.active{background:var(--b64-on-bg);color:var(--b64-on-text);border-color:transparent;}
.b64ds-triple-btn:hover:not(.active){background:var(--b64-bg);}
.b64ds-triple-pair{display:flex;gap:6px;}
.b64ds-triple-pair .b64ds-triple-row{flex:1;flex-direction:column;align-items:flex-start;gap:6px;}

/* ── 링크 복사 버튼 — 카드 왼쪽 전체 영역, 세로선 구분 ── */
.bl-copy{align-self:stretch;display:flex;align-items:center;justify-content:center;
  padding:0 9px 0 11px;flex-shrink:0;cursor:pointer;opacity:.28;
  border-right:1px solid rgba(0,0,0,.1);
  transition:opacity .15s,background .15s,color .15s;}
.bl-copy:hover{opacity:.8;background:rgba(0,0,0,.07);}
.bl-copy.bl-copy-ok{opacity:1 !important;color:#16a34a;}
.b64-link .bl-copy .bl-icon,.b64-product-link .bl-copy .bl-icon{width:14px;height:14px;}

/* ── 링크 모아보기 패널 ── */
#b64d-link-panel{
  margin:0 0 16px;padding:12px 14px;
  background:#f4f4f5;border:1px solid #e4e4e7;border-radius:12px;
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
  box-sizing:border-box;}
.b64lp-header{
  display:flex;align-items:center;justify-content:space-between;
  margin-bottom:10px;font-size:12px;font-weight:700;
  color:#71717a;letter-spacing:.04em;text-transform:uppercase;}
.b64lp-close{
  background:none;border:none;cursor:pointer;color:#a1a1aa;
  font-size:14px;padding:0 2px;line-height:1;border-radius:4px;
  transition:color .15s;flex-shrink:0;}
.b64lp-close:hover{color:#18181b;}
.b64lp-section{display:flex;flex-direction:column;gap:6px;}
.b64lp-section+.b64lp-section{margin-top:10px;padding-top:10px;border-top:1px solid #e4e4e7;}
.b64lp-label{font-size:11px;font-weight:700;color:#a1a1aa;letter-spacing:.06em;text-transform:uppercase;}
.b64lp-cards{display:flex;flex-wrap:wrap;gap:4px;}

/* ── kone.gg 자체 다크모드 (html.dark 클래스 방식) ── */
html.dark .bl-copy{border-right-color:rgba(255,255,255,.12);}
html.dark .bl-copy:hover{background:rgba(255,255,255,.09);}
html.dark #b64d-link-panel{background:#27272a;border-color:#3f3f46;}
html.dark .b64lp-section+.b64lp-section{border-top-color:#3f3f46;}
html.dark .b64lp-close:hover{color:#fafafa;}
html.dark .b64-link{background:rgba(255,255,255,.08);border-color:rgba(209,213,219,.42);color:#e5e7eb;}
html.dark .b64-link:hover{background:rgba(255,255,255,.13);border-color:rgba(209,213,219,.60);}
html.dark .b64-link.lk-alive{background:rgba(22,163,74,.14);border-color:rgba(74,222,128,.58);color:#86efac;}
html.dark .b64-link.lk-alive:hover{background:rgba(22,163,74,.22);border-color:rgba(74,222,128,.78);}
html.dark .b64-link.lk-dead{background:rgba(220,38,38,.14);border-color:rgba(248,113,113,.58);color:#fca5a5;}
html.dark .b64-link.lk-dead:hover{background:rgba(220,38,38,.22);border-color:rgba(248,113,113,.78);}
html.dark .b64-product-link{background:rgba(109,40,217,.18);border-color:rgba(167,139,250,.62);color:#c4b5fd;}
html.dark .b64-product-link:hover{background:rgba(109,40,217,.28);border-color:rgba(167,139,250,.82);}
html.dark .b64-product-link.pl-steam{background:rgba(30,58,138,.18);border-color:rgba(96,165,250,.58);color:#93c5fd;}
html.dark .b64-product-link.pl-steam:hover{background:rgba(30,58,138,.28);border-color:rgba(96,165,250,.78);}
html.dark .b64-product-link.pl-patreon,
html.dark .b64-product-link.pl-getcu,
html.dark .b64-product-link.pl-fanza,
html.dark .b64-product-link.pl-fanbox{background:rgba(120,53,15,.18);border-color:rgba(251,146,60,.58);color:#fdba74;}
html.dark .b64-product-link.pl-patreon:hover,
html.dark .b64-product-link.pl-getcu:hover,
html.dark .b64-product-link.pl-fanza:hover,
html.dark .b64-product-link.pl-fanbox:hover{background:rgba(120,53,15,.28);border-color:rgba(251,146,60,.78);}
html.dark .b64-title-card-bar{border-top-color:#3f3f46;}

/* ── 채널 대문 TXT 중복 검사 버튼 ── */
#b64-dup-btn{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;border:1px solid #e4e4e7;background:transparent;cursor:pointer;color:#71717a;padding:0;}
#b64-dup-btn:hover{background:#f4f4f5;color:#18181b;}
#b64-dup-btn .bl-icon{width:16px;height:16px;}
html.dark #b64-dup-btn{border-color:#3f3f46;color:#a1a1aa;}
html.dark #b64-dup-btn:hover{background:#27272a;color:#f4f4f5;}
/* ── TXT 중복 검사 모달 ── */
#b64-dup-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:99990;display:flex;align-items:center;justify-content:center;}
#b64-dup-modal{background:#fff;border-radius:14px;width:700px;max-width:95vw;height:min(710px,90vh);display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(0,0,0,.22);overflow:hidden;}
html.dark #b64-dup-modal{background:#1c1c1e;color:#f4f4f5;}
#b64-dup-modal .dup-hdr{display:flex;align-items:center;justify-content:space-between;padding:13px 16px;border-bottom:1px solid #e4e4e7;font-weight:600;font-size:14px;flex-shrink:0;}
html.dark #b64-dup-modal .dup-hdr{border-bottom-color:#3f3f46;}
#b64-dup-modal .dup-x{background:none;border:none;cursor:pointer;font-size:16px;padding:2px 6px;color:inherit;opacity:.5;border-radius:6px;}
#b64-dup-modal .dup-x:hover{opacity:1;background:rgba(0,0,0,.06);}
html.dark #b64-dup-modal .dup-x:hover{background:rgba(255,255,255,.08);}
#b64-dup-modal .dup-layout{display:flex;flex:1;overflow:hidden;}
#b64-dup-modal .dup-sidebar{width:185px;flex-shrink:0;border-right:1px solid #e4e4e7;overflow:hidden;padding:10px 8px;display:flex;flex-direction:column;gap:4px;}
html.dark #b64-dup-modal .dup-sidebar{border-right-color:#3f3f46;}
#b64-dup-modal .dup-sidebar-title{font-size:11px;font-weight:700;color:#71717a;letter-spacing:.05em;padding:2px 4px 6px;}
#b64-dup-modal .dup-hist-search{width:100%;box-sizing:border-box;padding:5px 8px;border:1px solid #e4e4e7;border-radius:6px;font-size:11px;font-family:inherit;background:transparent;color:inherit;outline:none;margin-bottom:4px;}
#b64-dup-modal .dup-hist-search:focus{border-color:#7dd3fc;}
html.dark #b64-dup-modal .dup-hist-search{border-color:#3f3f46;}
html.dark #b64-dup-modal .dup-hist-search:focus{border-color:#38bdf8;}
#b64-dup-modal .dup-hist-pager{display:flex;align-items:center;justify-content:center;gap:3px;padding:6px 4px 2px;border-top:1px solid #e4e4e7;flex-shrink:0;}
html.dark #b64-dup-modal .dup-hist-pager{border-top-color:#3f3f46;}
#b64-dup-modal .dup-hist-pager-pages{display:flex;gap:2px;}
#b64-dup-modal .dup-hist-pager-btn,#b64-dup-modal .dup-hist-pager-num{background:none;border:1px solid #d4d4d8;border-radius:4px;padding:1px 6px;font-size:11px;cursor:pointer;color:#71717a;font-family:inherit;line-height:18px;min-width:22px;}
#b64-dup-modal .dup-hist-pager-btn:hover,#b64-dup-modal .dup-hist-pager-num:hover{background:#f4f4f5;}
#b64-dup-modal .dup-hist-pager-num.active{background:#0284c7;color:#fff;border-color:transparent;}
#b64-dup-modal .dup-hist-pager-btn:disabled{opacity:.35;cursor:default;}
html.dark #b64-dup-modal .dup-hist-pager-btn,html.dark #b64-dup-modal .dup-hist-pager-num{border-color:#3f3f46;color:#a1a1aa;}
html.dark #b64-dup-modal .dup-hist-pager-btn:hover,html.dark #b64-dup-modal .dup-hist-pager-num:hover{background:#27272a;}
#b64-dup-modal .dup-hist-date-group{font-size:10px;font-weight:700;color:#a1a1aa;letter-spacing:.06em;padding:8px 4px 3px;border-bottom:1px solid #e4e4e7;margin-bottom:2px;}
html.dark #b64-dup-modal .dup-hist-date-group{color:#71717a;border-bottom-color:#3f3f46;}
#b64-dup-modal .dup-body{flex:1;overflow-y:auto;padding:14px 16px;display:flex;flex-direction:column;gap:10px;}
#b64-dup-modal .dup-pick{display:flex;align-items:center;gap:8px;}
#b64-dup-modal .dup-file-btn{padding:6px 14px;background:#0284c7;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;white-space:nowrap;}
#b64-dup-modal .dup-file-btn:hover{background:#0369a1;}
#b64-dup-modal .dup-file-name{font-size:12px;color:#71717a;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;}
#b64-dup-modal .dup-result{font-size:13px;display:flex;flex-direction:column;gap:6px;}
#b64-dup-modal .dup-result-pager{display:flex;align-items:center;justify-content:center;gap:3px;padding:4px 0 2px;margin-top:3px;}
#b64-dup-modal .dup-summary{font-size:12px;color:#71717a;}
#b64-dup-modal .dup-ok{color:#16a34a;font-weight:500;}
html.dark #b64-dup-modal .dup-ok{color:#4ade80;}
#b64-dup-modal .dup-label{font-size:12px;font-weight:600;color:#ef4444;margin-bottom:2px;}
html.dark #b64-dup-modal .dup-label{color:#f87171;}
#b64-dup-modal .dup-page-link{display:inline-flex;align-items:center;gap:4px;padding:5px 12px;border:1px solid #c7d2fe;border-radius:8px;font-size:12px;color:#0284c7;text-decoration:none;background:#eef2ff;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
#b64-dup-modal .dup-page-link:hover{background:#e0e7ff;border-color:#7dd3fc;}
html.dark #b64-dup-modal .dup-page-link{background:#1e1b4b;border-color:#0369a1;color:#7dd3fc;}
html.dark #b64-dup-modal .dup-page-link:hover{background:#312e81;border-color:#38bdf8;}
#b64-dup-modal .dup-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:2px;}
#b64-dup-modal .dup-list li{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;background:#f4f4f5;border-radius:6px;}
html.dark #b64-dup-modal .dup-list li{background:#27272a;}
#b64-dup-modal .dup-code{font-family:monospace;font-weight:600;font-size:13px;}
#b64-dup-modal .dup-badge{font-size:11px;background:#fde68a;color:#92400e;border-radius:999px;padding:1px 7px;font-weight:600;}
html.dark #b64-dup-modal .dup-badge{background:#451a03;color:#fbbf24;}
#b64-dup-modal .dup-empty{color:#71717a;font-size:13px;}
#b64-dup-modal .dup-section-title{font-size:11px;font-weight:700;letter-spacing:.04em;margin:8px 0 3px;color:#52525b;}
html.dark #b64-dup-modal .dup-section-title{color:#a1a1aa;}
#b64-dup-modal .dup-owned-title{color:#ef4444;}
html.dark #b64-dup-modal .dup-owned-title{color:#f87171;}
#b64-dup-modal .dup-new-title{color:#16a34a;}
html.dark #b64-dup-modal .dup-new-title{color:#4ade80;}
#b64-dup-modal .dup-badge-ok{background:#fee2e2;color:#dc2626;border-radius:999px;padding:1px 7px;font-size:11px;font-weight:600;}
html.dark #b64-dup-modal .dup-badge-ok{background:#7f1d1d;color:#fca5a5;}
#b64-dup-modal .dup-list li.dup-goto{cursor:pointer;}
#b64-dup-modal .dup-list li.dup-goto:hover{background:#e4e4e7;outline:1px solid #a1a1aa;}
html.dark #b64-dup-modal .dup-list li.dup-goto:hover{background:#3f3f46;outline-color:#71717a;}
@keyframes b64-card-flash{0%,100%{outline:2px solid transparent;outline-offset:3px}15%{outline:2px solid #f97316;outline-offset:3px}85%{outline:2px solid #f97316;outline-offset:3px}}
.b64-card-flash{animation:b64-card-flash 1.8s ease;}
#b64-dup-modal .dup-hist-list{display:flex;flex-direction:column;gap:4px;flex:1 1 auto;min-height:0;overflow-y:auto;}
#b64-dup-modal .dup-hist-row{display:flex;flex-direction:column;gap:2px;padding:7px 8px;border-radius:6px;cursor:pointer;font-size:12px;font-family:inherit;color:inherit;border:1px solid #e4e4e7;background:#fafafa;width:100%;text-align:left;}
#b64-dup-modal .dup-hist-row:hover{background:#f4f4f5;border-color:#d4d4d8;}
html.dark #b64-dup-modal .dup-hist-row{background:#1c1c1e;border-color:#3f3f46;}
html.dark #b64-dup-modal .dup-hist-row:hover{background:#27272a;border-color:#52525b;}
#b64-dup-modal .dup-hist-file{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;}
#b64-dup-modal .dup-hist-meta{display:flex;justify-content:space-between;gap:4px;}
#b64-dup-modal .dup-hist-date{color:#71717a;font-size:11px;}
#b64-dup-modal .dup-hist-stat{color:#71717a;font-size:11px;}
#b64-dup-modal .dup-hist-row.dup-hist-sel{background:#eff6ff;border-color:#93c5fd;}
html.dark #b64-dup-modal .dup-hist-row.dup-hist-sel{background:#1e3a5f;border-color:#3b82f6;}
#b64-dup-modal .dup-detail-hint{font-size:10px;color:#71717a;margin-top:4px;text-align:center;}
#b64-dup-modal .dup-detail-section{margin-top:8px;padding-top:8px;border-top:1px solid #e4e4e7;}
html.dark #b64-dup-modal .dup-detail-section{border-top-color:#3f3f46;}
#b64-dup-modal .dup-detail-title{font-size:11px;font-weight:600;color:#52525b;margin-bottom:4px;}
html.dark #b64-dup-modal .dup-detail-title{color:#a1a1aa;}

/* ── 작성 페이지 변환 패널 (C안: 좌측 탭 → 슬라이드 서랍) ── */
#b64d-br{position:fixed;right:0;top:130px;z-index:40;display:flex;align-items:stretch;transform-origin:right top;
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
  --brbg:#ffffff;--brbg2:#f4f4f5;--brbg3:#e4e4e7;--brtx:#18181b;--brtx2:#52525b;
  --bron:#0369a1;--bronh:#0284c7;--bronx:#ffffff;--bracc:#0284c7;--brbd:#d4d4d8;}
html.dark #b64d-br{--brbg:#18181b;--brbg2:#27272a;--brbg3:#3f3f46;--brtx:#fafafa;--brtx2:#a1a1aa;
  --bron:#0c4a6e;--bronh:#1a6a8f;--bronx:#e0f2fe;--bracc:#38bdf8;--brbd:#3f3f46;}
#b64d-br-tab{flex-shrink:0;width:30px;background:var(--bron);color:var(--bronx);border:none;cursor:pointer;
  border-radius:10px 0 0 10px;display:flex;align-items:center;justify-content:center;padding:14px 0;
  box-shadow:-2px 0 10px rgba(0,0,0,.35);}
#b64d-br-tab span{writing-mode:vertical-rl;text-orientation:mixed;font-size:12px;letter-spacing:3px;font-weight:500;}
#b64d-br-draw{width:0;overflow:hidden;transition:width .18s ease;background:var(--brbg);color:var(--brtx);
  border-radius:12px 0 0 12px;box-sizing:border-box;}
#b64d-br.open #b64d-br-draw{width:236px;border:.5px solid var(--brbd);border-right:none;box-shadow:-6px 0 24px rgba(0,0,0,.4);}
#b64d-br.open #b64d-br-tab{border-radius:0;}
#b64d-br-inner{width:236px;padding:12px;display:flex;flex-direction:column;gap:9px;box-sizing:border-box;}
#b64d-br .br-h{font-size:12px;font-weight:500;color:var(--brtx);display:flex;align-items:center;gap:6px;}
#b64d-br .br-h .br-x{margin-left:auto;cursor:pointer;color:var(--brtx2);font-size:15px;line-height:1;background:none;border:none;}
#b64d-br .br-seg{display:flex;background:var(--brbg2);border:.5px solid var(--brbd);border-radius:7px;padding:2px;gap:2px;}
#b64d-br .br-seg button{flex:1;font-size:11px;padding:5px 0;border-radius:5px;color:var(--brtx2);background:none;border:none;cursor:pointer;font-family:inherit;}
#b64d-br .br-seg button.on{background:var(--bron);color:var(--bronx);font-weight:500;}
#b64d-br .br-row{display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--brtx);}
#b64d-br .br-step{display:flex;align-items:center;border:.5px solid var(--brbd);border-radius:6px;overflow:hidden;}
#b64d-br .br-step button{width:24px;font-size:14px;padding:2px 0;color:var(--brtx);background:var(--brbg2);border:none;cursor:pointer;}
#b64d-br .br-step b{width:30px;text-align:center;font-size:12px;font-weight:500;border-left:.5px solid var(--brbd);border-right:.5px solid var(--brbd);}
#b64d-br .br-sw{position:relative;width:30px;height:17px;border-radius:99px;background:var(--brbg3);flex-shrink:0;cursor:pointer;transition:background .15s;}
#b64d-br .br-sw::after{content:'';position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:#fff;transition:left .15s;}
#b64d-br .br-sw.on{background:var(--bron);}
#b64d-br .br-sw.on::after{left:15px;}
#b64d-br .br-lab{font-size:10px;color:var(--brtx2);display:flex;justify-content:space-between;margin-bottom:3px;}
#b64d-br textarea{width:100%;height:64px;resize:vertical;background:var(--brbg2);border:.5px solid var(--brbd);border-radius:7px;
  padding:7px 9px;font-size:12px;color:var(--brtx);font-family:inherit;box-sizing:border-box;line-height:1.5;}
#b64d-br .br-out{width:100%;max-height:78px;overflow:auto;background:var(--brbg2);border:.5px solid var(--brbd);border-radius:7px;
  padding:7px 9px;font-size:13px;color:var(--bracc);box-sizing:border-box;word-break:break-all;white-space:pre-wrap;min-height:34px;line-height:1.4;}
#b64d-br .br-btns{display:flex;gap:6px;}
#b64d-br .br-btns button{flex:1;font-size:11px;padding:7px 0;border-radius:6px;cursor:pointer;font-family:inherit;border:.5px solid var(--brbd);background:var(--brbg2);color:var(--brtx2);}
#b64d-br .br-btns button.br-pri{background:var(--bron);color:var(--bronx);border-color:transparent;font-weight:500;}
#b64d-br .br-btns button:hover{filter:brightness(1.15);}
#b64d-br .br-hide{display:none !important;}
/* 코드 모드 겉보기/비번 입력(팝오버 대체 — 패널 내부 삽입) */
#b64d-br .br-code-box{display:flex;flex-direction:column;gap:6px;}
#b64d-br .br-cover-ctl{display:flex;align-items:center;gap:5px;}
#b64d-br .br-cover-sel{background:var(--brbg2);border:.5px solid var(--brbd);border-radius:6px;color:var(--brtx);font-family:inherit;font-size:11px;padding:3px 5px;max-width:120px;}
#b64d-br .br-cover-gen{flex-shrink:0;font-size:12px;line-height:1;padding:4px 7px;border-radius:6px;border:.5px solid var(--brbd);background:var(--brbg2);color:var(--brtx);cursor:pointer;}
#b64d-br .br-cover-gen:hover{filter:brightness(1.15);}
#b64d-br .br-cover-txt{width:100%;height:40px;resize:vertical;background:var(--brbg2);border:.5px solid var(--brbd);border-radius:7px;padding:6px 8px;font-size:12px;color:var(--brtx);font-family:inherit;box-sizing:border-box;line-height:1.45;}
#b64d-br .br-code-pw{background:var(--brbg2);border:.5px solid var(--brbd);border-radius:6px;color:var(--brtx);font-family:inherit;font-size:11px;padding:4px 7px;max-width:150px;}
.b64-braille{cursor:default;} /* 마커 전용(복원/툴팁) — 시각표시 없이 네이티브처럼; 내부 카드/링크 정상 상호작용 */
`;
    (document.head || document.documentElement).appendChild(s);
  }

  /* ================================================================
     SVG 아이콘
  ================================================================ */
  function svg(path) {
    return `<svg class="bl-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${path}</svg>`;
  }
  const ICO = {
    world:   svg('<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>'),
    link:    svg('<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>'),
    linkoff: svg('<path d="m18.84 12.25 1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="m5.17 11.75-1.72 1.71a5 5 0 0 0 7.07 7.07l1.71-1.71"/><line x1="2" y1="2" x2="22" y2="22"/>'),
    check:   svg('<polyline points="20 6 9 17 4 12"/>'),
    alert:   svg('<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>'),
    spin:    `<svg class="bl-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.22-8.56"/></svg>`,
    tag:     svg('<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>'),
    gamepad:  svg('<line x1="6" y1="11" x2="10" y2="11"/><line x1="8" y1="9" x2="8" y2="13"/><line x1="15" y1="12" x2="15.01" y2="12"/><line x1="18" y1="10" x2="18.01" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 16 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.544-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>'),
    patreon:  svg('<circle cx="15" cy="9" r="5.5"/><line x1="3" y1="2" x2="3" y2="22"/>'),
    copy:     svg('<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
    hash:     svg('<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>'),
  };

  /* ================================================================
     카드 유틸
  ================================================================ */
  // 카드 <a> 왼쪽에 복사 버튼 추가 — 모든 make*Card 함수에서 공통 사용
  // code를 넘기면 COPY_CODE 설정에 따라 링크/코드 중 선택 복사 (작품 카드 전용)
  function wrapCard(a, code) {
    if (code) a.dataset.cardCode = String(code);
    const btn = document.createElement('span');
    btn.className = 'bl-copy';
    btn.title = (CFG.COPY_CODE && code) ? `코드 복사 (${code})` : '링크 복사';
    btn.innerHTML = ICO.copy;
    a.insertBefore(btn, a.firstChild);
    return a;
  }

  // 카드 공통 골격(optimized+ 통합) — 모든 make*Card·makeLinkCard가 이 팩토리를 쓴다.
  // 과거엔 '<a> 생성→href/target/rel→DONE_ATTR→className→아이콘/타이틀/서브/화살표 innerHTML
  // →hookDlp→wrapCard' 골격이 9곳에 복붙돼 있었다(bl-arrow 템플릿 9벌).
  // o: { cls, icon, title, sub, url, code?, preview? }  — title/sub는 여기서 esc 처리.
  function makeCardBase(o) {
    const a = document.createElement('a');
    a.href = o.url; a.target = '_blank'; a.rel = 'noopener noreferrer';
    a.setAttribute(DONE_ATTR, '');
    a.className = o.cls;
    a.innerHTML = `<span class="bl-icon-wrap">${o.icon}</span>
<span class="bl-text">
  <span class="bl-title">${esc(o.title)}</span>
  <span class="bl-sub">${esc(o.sub)}</span>
</span><span class="bl-arrow">↗</span>`;
    if (o.preview && CFG.DLSITE_PREVIEW) hookDlp(a);
    return wrapCard(a, o.code || null);
  }

  /* ================================================================
     링크 모아보기 패널 — 페이지 상단에 상품/다운로드 링크 중복 없이 모아 표시
  ================================================================ */
  function buildLinkPanel(contentRoots) {
    if (!CFG.LINK_PANEL || !CFG.LINK_CARD || !contentRoots || !contentRoots.size) {
      document.getElementById('b64d-link-panel')?.remove();
      _panelCardMap.clear();
      return;
    }

    const showProducts  = CFG.PANEL_TARGET !== 'links';
    const showDownloads = CFG.PANEL_TARGET !== 'products';

    // ★수정3: 먼저 '카드 집합 시그니처'를 구해, 기존 패널과 같으면 재빌드하지 않는다. 코네 재렌더로 flush가
    //   자주 도는데, 매번 패널을 부수고 다시 만들면 그 안(본문 밖) 카드에 둔 W/S 선택·호버가 계속 풀렸다.
    const sigKeys = [], sigSeen = new Set();
    contentRoots.forEach(root => {
      const sel = [showProducts && '.b64-product-link[href]', showDownloads && '.b64-link[href]'].filter(Boolean).join(',');
      if (!sel) return;
      root.querySelectorAll(sel).forEach(a => {
        if (a.closest('#b64d-link-panel')) return;
        const k = a.dataset.cardCode ? 'c:' + a.dataset.cardCode : 'h:' + a.href;
        if (!sigSeen.has(k)) { sigSeen.add(k); sigKeys.push(k); }
      });
    });
    const sig = sigKeys.join('|');
    const existingPanel = document.getElementById('b64d-link-panel');
    if (existingPanel && existingPanel.dataset.sig === sig) return; // 집합 동일 → 유지(선택/호버 보존)
    // 재빌드 확정 → 기존 패널 제거 + 맵 초기화
    existingPanel?.remove();
    _panelCardMap.clear();

    const seenHrefs = new Set();
    const products  = [];
    const downloads = [];

    function cloneCard(a) {
      const href = a.href;
      if (!href) return null;
      // 상품 카드는 작품 코드 기준, 그 외(다운로드 링크)는 href 기준으로 중복 제거
      // → 같은 작품이 코드·URL 등 다른 형태로 여러 번 카드화돼도 모아보기엔 1개만
      const dedupKey = a.dataset.cardCode ? 'c:' + a.dataset.cardCode : 'h:' + href;
      if (seenHrefs.has(dedupKey)) return null;
      seenHrefs.add(dedupKey);
      const clone = a.cloneNode(true);
      clone.removeAttribute(DONE_ATTR);
      clone.removeAttribute(ORIG_ATTR);
      clone.removeAttribute('data-lkch');
      // cloneNode은 이벤트 리스너를 복사하지 않으므로 미리보기 훅 재연결
      if (CFG.DLSITE_PREVIEW && clone.dataset._dlpHooked) {
        clone.addEventListener('mouseenter', dlpShow);
        if (IS_MOBILE) _addMobileDlpTouch(clone);
      }
      if (_linkCheckCache.has(href)) {
        const { alive, msg } = _linkCheckCache.get(href);
        _applyLinkState(clone, alive, msg);
      }
      _panelCardMap.set(href, clone);
      return clone;
    }

    contentRoots.forEach(root => {
      if (showProducts)  root.querySelectorAll('.b64-product-link[href]').forEach(a => {
        const c = cloneCard(a); if (c) products.push(c);
      });
      if (showDownloads) root.querySelectorAll('.b64-link[href]').forEach(a => {
        const c = cloneCard(a); if (c) downloads.push(c);
      });
    });

    if (!products.length && !downloads.length) return;

    const panel = document.createElement('div');
    panel.id = 'b64d-link-panel';
    panel.dataset.sig = sig; // 수정3: 다음 flush에서 집합 동일 판정 → 불필요한 재빌드 방지
    // DONE_ATTR: MO 재트리거 방지 + 내부 링크를 convertProductLinks가 재처리하지 않도록
    panel.setAttribute(DONE_ATTR, '');

    const hdr = document.createElement('div');
    hdr.className = 'b64lp-header';
    const title = document.createElement('span');
    title.textContent = '링크 모아보기';
    const closeBtn = document.createElement('button');
    closeBtn.className = 'b64lp-close';
    closeBtn.textContent = '✕';
    closeBtn.title = '닫기';
    closeBtn.addEventListener('click', () => panel.remove());
    hdr.appendChild(title);
    hdr.appendChild(closeBtn);
    panel.appendChild(hdr);

    function makeSection(label, cards) {
      const sec = document.createElement('div');
      sec.className = 'b64lp-section';
      const lbl = document.createElement('div');
      lbl.className = 'b64lp-label';
      lbl.textContent = label;
      const wrap = document.createElement('div');
      wrap.className = 'b64lp-cards';
      cards.forEach(c => wrap.appendChild(c));
      sec.appendChild(lbl);
      sec.appendChild(wrap);
      return sec;
    }

    if (products.length)  panel.appendChild(makeSection('작품',     products));
    if (downloads.length) panel.appendChild(makeSection('다운로드', downloads));

    // #post-article 최상단에 삽입 (없으면 첫 contentRoot 앞)
    const articleEl = document.getElementById('post-article');
    if (articleEl) {
      articleEl.insertBefore(panel, articleEl.firstChild);
    } else {
      const first = [...contentRoots][0];
      first.parentNode?.insertBefore(panel, first);
    }
  }

  // 복사 버튼 클릭 이벤트 (캡처 단계 — <a> 기본 네비게이션 차단)
  document.addEventListener('click', e => {
    const btn = e.target.closest && e.target.closest('.bl-copy');
    if (!btn) return;
    e.preventDefault();
    e.stopPropagation();
    const card = btn.closest('a[href]');
    if (!card) return;
    const code = card.dataset.cardCode;
    const textToCopy = (CFG.COPY_CODE && code) ? code : card.href;
    const onOk = () => {
      btn.innerHTML = ICO.check;
      btn.classList.add('bl-copy-ok');
      setTimeout(() => { btn.innerHTML = ICO.copy; btn.classList.remove('bl-copy-ok'); }, 1500);
    };
    if (navigator.clipboard?.writeText) {
      navigator.clipboard.writeText(textToCopy).then(onOk).catch(() => {});
    } else {
      try {
        const ta = document.createElement('textarea');
        ta.value = textToCopy; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0;pointer-events:none;';
        document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); onOk();
      } catch(_) {}
    }
  }, true);

  /* ================================================================
     base64 디코딩
  ================================================================ */
  const DEC_STRICT  = new TextDecoder('utf-8', { fatal: true  });
  const DEC_LENIENT = new TextDecoder('utf-8', { fatal: false });

  // URL-safe base64('-'→'+', '_'→'/')도 허용. 정규화 후 표준 base64로 체크.
  function looksLikeB64(str) {
    const norm = str.replace(/-/g, '+').replace(/_/g, '/');
    return norm.length >= MIN_B64 && norm.length % 4 !== 1 &&
           /^[A-Za-z0-9+/]+={0,2}$/.test(norm);
  }

  // 맥락상(kone.gg=한국어) 거의 안 나오는 문자군 — base64 오탐(랜덤 바이트)의 강한 신호
  function isSuspiciousScript(c) {
    return (c >= 0x0300 && c <= 0x036f) ||  // 결합 분음 기호
           (c >= 0x0400 && c <= 0x07ff) ||  // 키릴·아르메니아·히브리·아랍·시리아
           (c >= 0x0900 && c <= 0x0fff) ||  // 데바나가리 등 인도계·타이·티베트
           (c >= 0x1ab0 && c <= 0x1aff) || (c >= 0x1dc0 && c <= 0x1dff); // 결합 기호 확장
  }
  function isProbablyText(str) {
    if (!str) return false;
    let bad = 0, repl = 0;
    for (const ch of str) {
      const c = ch.codePointAt(0);
      // C0 제어문자 + DEL + C1 제어문자(0x80-0x9F) — 일반 텍스트에 없음
      if (c < 0x09 || (c > 0x0d && c < 0x20) || c === 0x7f || (c >= 0x80 && c <= 0x9f)) bad++;
      else if (isSuspiciousScript(c)) bad++; // 예: 'Vtuber'→'Vۛz' 의 ۛ(U+06DB) 같은 오탐 차단
      if (c === 0xfffd) repl++;
    }
    // 대체문자(U+FFFD)는 0개여야 통과 — 깨진 디코딩(부분만 유효한 가비지)이 '정상'으로 통과해
    // 트래쉬 카드/누적을 만들던 근본 문제 차단. 깨끗한 base64는 strict 디코딩으로 U+FFFD가 0개다.
    return bad / str.length < 0.1 && repl === 0;
  }

  // 공통 디코딩 코어: 표준 base64 문자열(URL-safe 변환 완료)을 패딩 보정 → atob → UTF-8 디코딩.
  // strict 먼저 시도, 실패 시 lenient(대체 문자 허용)로 폴백. atob 실패(비정상 입력)는 호출부 try가 흡수.
  // (decodeB64Once·decodeShortB64가 동일 로직을 2벌 갖고 있던 중복 제거 — optimized+)
  function b64DecodeCore(norm) {
    let s = norm;
    const rem = s.length % 4;
    if (rem === 2) s += '==';
    else if (rem === 3) s += '=';
    const bytes = Uint8Array.from(atob(s), c => c.charCodeAt(0));
    try       { return DEC_STRICT.decode(bytes); }
    catch (e) { return DEC_LENIENT.decode(bytes); }
  }

  // URL-safe base64('-', '_')를 표준으로 변환한 뒤 디코딩
  function decodeB64Once(str) {
    // 다단계 인코딩의 중간 결과가 줄바꿈/공백을 포함할 수 있으므로 제거 후 판정·디코딩.
    // (findAllMatches가 넘기는 토큰엔 공백이 없어 1단계 동작엔 영향 없음 — 다단계 재디코딩만 개선)
    const compact = str.replace(/\s+/g, '');
    if (!looksLikeB64(compact)) return null;
    try { return b64DecodeCore(compact.replace(/-/g, '+').replace(/_/g, '/')); }
    catch (e) { return null; }
  }

  function fullyDecode(str) {
    let cur = str, best = null;
    for (let i = 0; i < MAX_DECODE; i++) {
      const d = decodeB64Once(cur);
      if (!d || !isProbablyText(d)) break;
      best = d; cur = d;
    }
    return best;
  }

  // 짧은 base64(값 4~7자) 디코딩 — MIN_B64 임계 무시. '토큰/문단 전체가 base64'일 때만 findAllMatches
  // 특례에서 쓰며, 오탐은 isProbablyText(repl===0)가 방어한다(수정사항2). 예 'bmFqYXI'→'najar'.
  function decodeShortB64(s) {
    const norm = s.replace(/-/g, '+').replace(/_/g, '/');
    if (norm.length < 4 || norm.length % 4 === 1 || !/^[A-Za-z0-9+/]+={0,2}$/.test(norm)) return null;
    try { return b64DecodeCore(norm); }
    catch (e) { return null; }
  }

  /* ================================================================
     매칭 패턴 정의

     DLsite:
       - 라틴: RJ/BJ/VJ/RE/BE/VE + 4~8자리, 대소문자 무관 (i 플래그)
       - 한글 IME: 한글 모드에서 rj 를 타이핑하면 r→ㄱ, j→ㅓ → '거'
                   Rj(shift+r)→ㄲ, j→ㅓ → '꺼'
                   두 글자 모두 RJ 코드로 정규화

     Steam:
       - 스팀 (한글)
       - Steam / steam (영문 대소문자)
       - st / St (약칭, 단어 경계 필요)
  ================================================================ */
  // DLsite 라틴 코드 – 접두어·숫자 사이 공백 허용, 대소문자 무관
  // 후행은 \b 대신 (?![0-9]) — 코드 뒤에 '_'(단어문자)가 붙어도(예 'RJ01645247_빈틈투성이!')
  // \b가 숫자↔'_' 경계를 못 만들어 매칭 실패하던 문제(수정사항7). 숫자로만 끝나면 되므로 안전.
  const DLSITE_LATIN_RE = /\b((?:RJ|BJ|VJ|RE|BE|VE))\s?(\d{6,8})(?![0-9])/gi;
  // DLsite 한글 IME – 꺼(Rj)/거(rj) 또는 분리 자모 ㄲㅓ/ㄱㅓ + 선택적 공백
  const DLSITE_KR_RE    = /(꺼|거|ㄲㅓ|ㄱㅓ)\s?(\d{6,8})/g;
  // Steam – 스팀, Steam, steam, st/St/ST (단어 경계), 구분자 공백·대시 허용
  const STEAM_CODE_RE   = /(?:스팀|[Ss]team|\b[Ss][Tt])[\s-]*(\d{4,10})/g;
  // 크리에이터 플랫폼 코드 – Getcu(GC) / Fanza(FZ) / Fanbox(FB)
  const CREATOR_CODE_RE = /\b((?:GC|FZ|FB))\s?(\d{4,8})(?![0-9])/gi;
  // base64 매칭(URL-safe '-_' 포함, g 플래그) — findAllMatches 핫패스 재사용용.
  //   g 정규식은 lastIndex 상태를 가지므로 사용 전 반드시 0으로 리셋한다.
  const B64_MATCH_RE = new RegExp(`[A-Za-z0-9+/\\-_]{${MIN_B64},}={0,2}`, 'g');
  // base64 존재 여부 테스트(표준 문자만, 비전역) — flushListTitles 후보 필터용. test()는 무상태라 리셋 불필요.
  const B64_TEST_RE  = new RegExp(`[A-Za-z0-9+/]{${MIN_B64},}={0,2}`);
  // URL 매칭(g, 대소문자 무관). ★문자클래스에서 한글/일문/한자/전각을 '제외'한다 — 디코딩된 URL 뒤에
  //   공백 없이 붙은 한글이 URL의 일부로 흡수되던 문제(수정사항2·예시1: mypikpak URL 뒤 'ㄱㄹ7일…'가
  //   %E3%84%B1… 로 흡수)를 차단. URL은 ASCII(퍼센트 인코딩)이므로 CJK를 만나면 URL 종료로 본다.
  //   ★또한 룩어헤드 (?!https?:\/\/)로 '다음 URL 시작' 전에 멈춘다 — 디코딩 결과에서 공백 없이 두 URL이
  //   붙어(https://a…https://b…) 한 URL로 삼켜져 href·dedup키가 깨지던 문제(다운로드 카드 중복·손상) 차단.
  //   g 정규식이라 사용 전 lastIndex=0 리셋 필수.
  const URL_RE = /((?:https?:\/\/|www\.)(?:(?!https?:\/\/)[^\s<>"'()ᄀ-ᇿ -〿぀-ヿ㄰-㆏㐀-䶿一-鿿ꥠ-꥿가-퟿豈-﫿＀-￯])+)/gi;

  /* ── URL → 작품코드 / 사이트 판별 공통 헬퍼 (optimized+ 단일화) ──────────
     같은 정규식·호스트 목록이 makeLinkCard·buildNodes·convertProductLinks(본문/OG)에
     제각기 복붙돼 있어, 한 곳만 고치면 dedup 키가 어긋나는 drift 위험이 있었다 → 여기로 통합.
     ※ dlpShow의 dlsiteCodeMatch(product_id 전용)는 의도적으로 미통합 — CDN fast-path는
       www 상품 페이지 형식에만 유효해, [/=]코드 보조 패턴까지 넓히면 동작이 달라진다. */
  // DLsite 상품 URL → 코드 (www의 product_id/RJ… + ch.dlsite.com 등 '=코드'·'/코드' 형식)
  const DLSITE_URL_ID_RE1 = /product_id\/((?:RJ|BJ|VJ|RE|BE|VE)\w+)/i;
  const DLSITE_URL_ID_RE2 = /[/=]((?:RJ|BJ|VJ|RE|BE|VE)\d{6,8})\b/i;
  function dlsiteCodeOf(url) {
    const m = url.match(DLSITE_URL_ID_RE1) || url.match(DLSITE_URL_ID_RE2);
    return m ? m[1].toUpperCase() : null;
  }
  // Steam 상품 URL → appId (호스트 확인은 호출부 책임)
  function steamAppOf(url) { const m = url.match(/\/app\/(\d+)/); return m ? m[1] : null; }
  // Getchu 상품 URL → 숫자 ID (item/{digits} 또는 ?id={digits}).
  // ※ buildNodes lkKey용 '비숫자 item 코드'([a-z0-9_-]) 매칭은 의도적으로 별도 유지.
  function getchuIdOf(url) { const m = url.match(/\/item\/(\d+)|[?&]id=(\d+)/); return m ? (m[1] || m[2]) : null; }
  // 다운로드/파일공유 사이트(비번 자동입력·생존확인 대상) 여부 — PW_SITES/DEAD_PATTERNS 기준
  function isDownloadHost(h) {
    const hb = h.replace(/^www\./, '');
    return !!(CFG.PW_SITES[h] || CFG.PW_SITES[hb] || CFG.DEAD_PATTERNS[h] || CFG.DEAD_PATTERNS[hb]);
  }
  // 카드화 지원 호스트 목록(단일 소스). 과거 buildNodes(isKnownHost)·convertProductLinks(OG known)에
  // 표현이 미묘하게 다른 목록 2벌이 있었다(=== vs includes vs endsWith → drift 시작 상태였음).
  // kone.gg는 호출부에서 별도 처리(내부 링크는 카드화 금지 / 평문 링크 유지).
  function isKnownCardHost(h) {
    const hb = h.replace(/^www\./, '');
    return hb === 'dlsite.com' || h.endsWith('.dlsite.com')
      || h === 'store.steampowered.com'
      || hb === 'patreon.com'
      || hb === 'getcu.com' || hb === 'getchu.com'
      || hb === 'dmm.co.jp' || h.endsWith('.dmm.co.jp')
      || hb === 'fanza.jp'
      || hb === 'fanbox.cc' || h.endsWith('.fanbox.cc')
      || isDownloadHost(h);
  }

  /* ================================================================
     findAllMatches (base64 + 점자 + DLsite 코드 + Steam 코드)

     결과 캐시(영구 + 개수 상한): findAllMatches는 입력 문자열만으로 결과가
     정해지는 순수 함수이고(CFG/DOM 미참조), 반환 배열은 어느 호출처에서도
     변형되지 않으므로 공유 캐시가 안전하다. Svelte 재렌더로 동일 텍스트가
     매 flush마다 재분석되던 비용을 제거한다.
  ================================================================ */
  const _famCache = new Map();
  const FAM_CACHE_MAX = 2000;
  function findAllMatches(raw) {
    const cached = _famCache.get(raw);
    if (cached !== undefined) return cached; // []도 유효한 캐시값 (매칭 없음)

    // ── 짧은 base64 특례 (수정사항2) ──
    // 정규 base64 매칭은 MIN_B64=8 이상만 잡아 'bmFqYXI'(값 7자) 같은 짧은 인코딩을 놓쳤다.
    // '토큰/문단 전체'가 순수 base64이고 패딩 제외 값이 8자 미만이면 개별 디코딩한다. 문장 속이 아니라
    // 통째가 base64일 때만이라 오탐 위험이 낮고, 깨진 결과는 isProbablyText(repl===0)가 거른다.
    // (여러 짧은 base64가 각 문단으로 나열돼도 processContentNode 경로에서 개별 카드화된다.)
    {
      const w = raw.trim();
      const core = w.replace(/=+$/, '');
      if (w.length >= 4 && core.length < MIN_B64 && /^[A-Za-z0-9+/_-]+={0,2}$/.test(w)) {
        const dec = decodeShortB64(w);
        if (dec !== null && dec !== w && isProbablyText(dec)) {
          const one = [{ index: raw.indexOf(w), length: w.length, decoded: dec, type: 'base64' }];
          if (_famCache.size >= FAM_CACHE_MAX) _famCache.delete(_famCache.keys().next().value);
          _famCache.set(raw, one);
          return one;
        }
      }
    }

    const hits = [];

    // ── base64 (URL-safe '-_' 포함) ──
    // URL-safe base64는 '+' 대신 '-', '/' 대신 '_' 를 사용하므로 함께 매칭
    // 1) 원시 base64 토큰 위치 수집
    B64_MATCH_RE.lastIndex = 0; // 공유 g 정규식 — 사용 전 위치 초기화
    const _toks = []; let m;
    while ((m = B64_MATCH_RE.exec(raw)) !== null) _toks.push({ index: m.index, end: m.index + m[0].length, text: m[0] });
    // 2) '줄바꿈/공백으로 쪼개진 한 덩어리' base64 병합 디코딩 (수정사항3 ③).
    //    긴 base64가 줄바꿈으로 쪼개져 한 텍스트노드에 들어오면(코네 본문) 각 줄이 따로 매칭돼
    //    부분 디코딩(엉뚱한 결과)이 됐다. decodeB64Once는 compact(공백제거) 후 디코딩하므로,
    //    조각을 이어붙여 통째로 디코딩하면 정상이 된다. 단, 병합은 '미완결 조각'에만 적용:
    //    앞 조각이 '=' 패딩으로 끝나면 그건 '완결 단위'(예 ex2: 공백구분 별개 base64)이므로 병합 안 함.
    //    토큰 사이가 공백뿐(\s+)일 때만 같은 base64로 보고 확장. 병합본 디코딩 실패 시 단일 토큰으로 폴백.
    let last = 0, i = 0;
    while (i < _toks.length) {
      if (_toks[i].index < last) { i++; continue; }
      let j = i;
      while (j + 1 < _toks.length
             && !/=$/.test(_toks[j].text)
             && /^\s+$/.test(raw.slice(_toks[j].end, _toks[j + 1].index))) j++;
      let consumed = false;
      if (j > i) {
        const seg = raw.slice(_toks[i].index, _toks[j].end);
        const decoded = fullyDecode(seg);
        if (decoded) {
          hits.push({ index: _toks[i].index, length: _toks[j].end - _toks[i].index, decoded, type: 'base64' });
          last = _toks[j].end; i = j + 1; consumed = true;
        }
      }
      if (!consumed) {
        const decoded = fullyDecode(_toks[i].text);
        if (decoded) { hits.push({ index: _toks[i].index, length: _toks[i].text.length, decoded, type: 'base64' }); last = _toks[i].end; }
        i++;
      }
    }

    // ── DLsite 코드 (라틴, 대소문자 무관) ──
    DLSITE_LATIN_RE.lastIndex = 0;
    while ((m = DLSITE_LATIN_RE.exec(raw)) !== null) {
      const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
      if (!ov) {
        const code = (m[1] + m[2]).toUpperCase();
        hits.push({ index: m.index, length: m[0].length, decoded: code, type: 'dlsite', code });
      }
    }

    // ── DLsite 코드 (한글 IME: 거=rj, 꺼=Rj → RJ 정규화) ──
    DLSITE_KR_RE.lastIndex = 0;
    while ((m = DLSITE_KR_RE.exec(raw)) !== null) {
      const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
      if (!ov) {
        const code = 'RJ' + m[2];
        hits.push({ index: m.index, length: m[0].length, decoded: code, type: 'dlsite', code });
      }
    }

    // ── Steam 코드 ──
    STEAM_CODE_RE.lastIndex = 0;
    while ((m = STEAM_CODE_RE.exec(raw)) !== null) {
      const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
      if (!ov) hits.push({ index: m.index, length: m[0].length, decoded: `Steam ${m[1]}`, type: 'steam', appId: m[1] });
    }

    // ── 크리에이터 플랫폼 코드 (GC/FZ/FB) ──
    CREATOR_CODE_RE.lastIndex = 0;
    while ((m = CREATOR_CODE_RE.exec(raw)) !== null) {
      const ov = hits.some(h => m.index < h.index + h.length && m.index + m[0].length > h.index);
      if (!ov) {
        const code = (m[1] + m[2]).toUpperCase();
        hits.push({ index: m.index, length: m[0].length, decoded: code, type: 'creator', code });
      }
    }

    hits.sort((a, b) => a.index - b.index);
    const clean = []; let end = 0;
    for (const h of hits) {
      if (h.index >= end) { clean.push(h); end = h.index + h.length; }
    }
    // 상한 초과 시 가장 오래된 항목부터 제거 (Map 삽입 순서 = FIFO)
    if (_famCache.size >= FAM_CACHE_MAX) _famCache.delete(_famCache.keys().next().value);
    _famCache.set(raw, clean);
    return clean;
  }

  /* ================================================================
     링크 생존 확인
  ================================================================ */
  // url → {alive, msg}: 동일 URL 재확인 생략용 캐시 (세션 유지)
  const _linkCheckCache = new Map();
  // url → 패널 클론 카드: 원본 체크 결과를 패널에 동기화
  const _panelCardMap   = new Map();
  // url → Set<cardEl>: 진행 중인 URL의 대기 카드 목록 (중복 요청 제거)
  const _lcPending      = new Map();

  // 링크 체크 동시 실행 제한 (GM 풀 포화 방지 → 미리보기 GM 요청 우선 확보)
  const _lcQueue = [];
  let   _lcActive = 0;
  const LC_MAX = 2;
  function _lcRelease() {
    _lcActive--;
    if (_lcQueue.length) { _lcActive++; _lcQueue.shift()(); }
  }
  function _lcEnqueue(fn) {
    if (_lcActive < LC_MAX) { _lcActive++; fn(); }
    else _lcQueue.push(fn);
  }

  function _applyLinkState(el, alive, msg) {
    el.classList.remove('lk-checking', 'lk-alive', 'lk-dead');
    if (alive === true)       el.classList.add('lk-alive');
    else if (alive === false) el.classList.add('lk-dead');
    const sub  = el.querySelector('.bl-sub');
    const wrap = el.querySelector('.bl-icon-wrap');
    const arr  = el.querySelector('.bl-arrow');
    if (sub)  sub.textContent = msg;
    if (wrap) wrap.innerHTML  = alive === true ? ICO.link : alive === false ? ICO.linkoff : ICO.world;
    if (arr)  arr.innerHTML   = alive === true ? ICO.check : alive === false ? ICO.alert : '↗';
  }

  function setLinkState(cardEl, alive, msg) {
    _applyLinkState(cardEl, alive, msg);
    const url = cardEl.href;
    if (url) {
      _linkCheckCache.set(url, { alive, msg });
      // 같은 URL을 기다리던 중복 카드에 결과 일괄 적용
      const waiting = _lcPending.get(url);
      if (waiting) {
        waiting.forEach(el => { if (el !== cardEl) _applyLinkState(el, alive, msg); });
        _lcPending.delete(url);
      }
      const pc = _panelCardMap.get(url);
      if (pc && pc !== cardEl) _applyLinkState(pc, alive, msg);
    }
    _lcRelease();
  }

  function beginCheck(cardEl) {
    cardEl.classList.add('lk-checking');
    const arr = cardEl.querySelector('.bl-arrow');
    if (arr) arr.innerHTML = ICO.spin;
  }

  function checkLinkByHead(url, cardEl) {
    beginCheck(cardEl);
    GM_xmlhttpRequest({
      method: 'HEAD', url, timeout: 3000,
      onload(res) {
        const alive = res.status >= 200 && res.status < 400;
        let h = '';
        try { h = new URL(url).hostname; } catch(e) {}
        setLinkState(cardEl, alive, alive ? `${h} · 링크 정상` : `${h} · 링크 없음 (${res.status})`);
      },
      onerror()   { setLinkState(cardEl, false, '연결 실패'); },
      ontimeout() { setLinkState(cardEl, false, '응답 없음'); },
    });
  }

  // kio.ac/kiosk.ac: SPA라서 dead 텍스트가 JS 렌더링됨 → HTTP 상태 + JSON 패턴 병행 체크
  function checkKioLink(url, cardEl, siteName) {
    beginCheck(cardEl);
    GM_xmlhttpRequest({
      method: 'GET', url, timeout: 3000,
      onload(res) {
        if (res.status >= 400) {
          setLinkState(cardEl, false, `${siteName} · 링크 만료 (${res.status})`);
          return;
        }
        const body = (res.responseText || '').toLowerCase();
        const dead = [
          '컬렉션을 찾을 수 없음', '만료되었습니다', '찾을 수 없습니다', '존재하지 않',
          '"notfound"', '"not_found"', '"not found"', '"status":404',
          '"status":"error"', '"error":true', 'collection not found',
        ].some(p => body.includes(p));
        setLinkState(cardEl, !dead, dead ? `${siteName} · 링크 만료` : `${siteName} · 링크 정상`);
      },
      onerror()   { setLinkState(cardEl, false, `${siteName} · 연결 실패`); },
      ontimeout() { setLinkState(cardEl, false, `${siteName} · 응답 없음`); },
    });
  }

  // mega.nz: CS API — folder/file 링크 구분하여 각각 적절한 커맨드 사용
  function checkMegaLink(url, cardEl) {
    const folderM = url.match(/mega\.nz\/folder\/([A-Za-z0-9_-]+)/);
    const fileM   = url.match(/mega\.nz\/file\/([A-Za-z0-9_-]+)/);
    const m = folderM || fileM;
    if (!m) { checkLinkByHead(url, cardEl); return; }
    const handle   = m[1];
    const isFolder = !!folderM;
    // folder: ?n=HANDLE + {"a":"f","c":1} / file: body에 핸들 포함 {"a":"g","p":HANDLE}
    const apiUrl = isFolder
      ? `https://g.api.mega.co.nz/cs?id=0&app=webclient&n=${handle}`
      : 'https://g.api.mega.co.nz/cs?id=0&app=webclient';
    const data = isFolder
      ? JSON.stringify([{ a: 'f', c: 1 }])
      : JSON.stringify([{ a: 'g', p: handle, ssl: 0 }]);
    beginCheck(cardEl);
    GM_xmlhttpRequest({
      method: 'POST', url: apiUrl, data,
      headers: {
        'Content-Type': 'application/json',
        'Origin': 'https://mega.nz',
        'Referer': 'https://mega.nz/',
      },
      timeout: 3000,
      onload(res) {
        try {
          const d = JSON.parse(res.responseText);
          // 에러: [-9], [-2] 등 음수 배열 또는 단독 음수, 또는 {e: -N}
          const code = Array.isArray(d) ? d[0] : (typeof d === 'number' ? d : d?.e);
          const dead = typeof code === 'number' && code < 0;
          setLinkState(cardEl, !dead, dead ? 'mega.nz · 링크 만료' : 'mega.nz · 링크 정상');
        } catch(e) { setLinkState(cardEl, true, 'mega.nz · 링크 정상'); }
      },
      onerror()   { setLinkState(cardEl, false, 'mega.nz · 연결 실패'); },
      ontimeout() { setLinkState(cardEl, false, 'mega.nz · 응답 없음'); },
    });
  }

  // mypikpak.com: device_id + X-Client-Id 헤더 포함
  // 진단: 실제 API 응답을 콘솔에 출력해 어떤 필드가 오는지 확인
  function checkPikpakLink(url, cardEl) {
    const m = url.match(/mypikpak\.com\/s\/([^/?#]+)/);
    if (!m) { checkLinkByHead(url, cardEl); return; }
    const shareId = m[1];
    const deviceId = (crypto && crypto.randomUUID)
      ? crypto.randomUUID()
      : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
          const r = Math.random() * 16 | 0;
          return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
        });
    beginCheck(cardEl);
    GM_xmlhttpRequest({
      method: 'GET',
      url: `https://api-drive.mypikpak.com/drive/v1/share?share_id=${shareId}&thumbnail_size=SIZE_MEDIUM&pass_code=&device_id=${deviceId}`,
      headers: {
        // mypikpak 웹 클라이언트 식별 헤더 (공개 공유 API 접근용)
        'X-Client-Id':      'YNxT9w7GMdWvEOKa',
        'X-Client-Version': '1.0.0',
        'X-Device-Name':    'Chrome Browser',
        'Accept':           'application/json, text/plain, */*',
      },
      timeout: 3000,
      onload(res) {
        if (res.status === 404) { setLinkState(cardEl, false, 'mypikpak · 링크 만료'); return; }
        if (res.status >= 500) { setLinkState(cardEl, null, `mypikpak · 확인 불가 (${res.status})`); return; }
        try {
          const d = JSON.parse(res.responseText);
          // share_status: "OK" / "EXPIRED" / "CANCELLED" 등
          if (d.share_status) {
            // 비번 보호 공유: 링크는 정상이고 비번만 필요 → '만료' 아님
            if (/pass/i.test(d.share_status)) { setLinkState(cardEl, true, 'mypikpak · 정상 (비번 필요)'); return; }
            const alive = ['OK', 'ACTIVE', 'NORMAL'].includes(d.share_status);
            setLinkState(cardEl, alive, alive ? 'mypikpak · 링크 정상' : `mypikpak · 링크 만료 (${d.share_status})`); return;
          }
          // 에러 응답: {error:'INVALID', message:'...'} or {code:-N}
          if (d.error || (typeof d.code === 'number' && d.code !== 0)) {
            // error가 'NOT_FOUND' 계열이면 만료, 그 외(인증 에러 등)는 불명
            const errStr = String(d.error || d.message || '').toLowerCase();
            if (/pass/i.test(errStr)) { setLinkState(cardEl, true, 'mypikpak · 정상 (비번 필요)'); return; }
            const dead = errStr.includes('not_found') || errStr.includes('not found') ||
                         errStr.includes('expired') || errStr.includes('invalid');
            setLinkState(cardEl, dead ? false : null,
              dead ? 'mypikpak · 링크 만료' : `mypikpak · 확인 불가 (${d.error || d.code})`); return;
          }
        } catch(e) {}
        setLinkState(cardEl, null, 'mypikpak · 확인 불가');
      },
      onerror()   { setLinkState(cardEl, null, 'mypikpak · 확인 불가'); },
      ontimeout() { setLinkState(cardEl, null, 'mypikpak · 응답 없음'); },
    });
  }

  function checkLink(url, cardEl) {
    // 캐시 조회: 이미 체크한 URL이면 즉시 결과 적용, 네트워크 요청 생략
    if (_linkCheckCache.has(url)) {
      const { alive, msg } = _linkCheckCache.get(url);
      _applyLinkState(cardEl, alive, msg);
      return;
    }
    // 동일 URL이 이미 진행 중이면 대기 목록에만 추가 (네트워크 요청 중복 방지)
    if (_lcPending.has(url)) {
      _lcPending.get(url).add(cardEl);
      beginCheck(cardEl);
      return;
    }
    _lcPending.set(url, new Set([cardEl]));
    // 동시 실행 제한 큐: GM 풀 포화 방지 → 미리보기 요청에 슬롯 확보
    _lcEnqueue(() => {
      let h = '';
      try { h = new URL(url).hostname; } catch(e) { return checkLinkByHead(url, cardEl); }
      if (h === 'kio.ac' || h.endsWith('.kio.ac') || h === 'kiosk.ac')
        return checkKioLink(url, cardEl, h);
      if (h === 'mega.nz')         return checkMegaLink(url, cardEl);
      if (h === 'mypikpak.com')    return checkPikpakLink(url, cardEl);
      // 검증 불가 사이트(사유 고정) — 네트워크 요청 없이 즉시 회색 안내.
      //   gofile.io: /contents API 유료 전용 + SPA라 페이지 HTML에도 생사 정보 없음
      //   workupload.com: Cloudflare 봇 감지로 자동 요청 차단
      //   transfer.it: SPA + MEGA API 헤더 제약으로 만료 감지 불가
      if (h === 'gofile.io')       return setLinkState(cardEl, null, 'gofile.io · 만료 검증 불가');
      if (h === 'workupload.com')  return setLinkState(cardEl, null, 'workupload.com · Cloudflare 봇 감지로 검증 불가');
      if (h === 'transfer.it')     return setLinkState(cardEl, null, 'transfer.it · 만료 검증 불가');
      // kio·mega·mypikpak 외 사이트는 자동 검증 불가 → 즉시 안내
      setLinkState(cardEl, null, h + ' · 만료 검증 불가');
    });
  }

  /* ================================================================
     유틸
  ================================================================ */
  function esc(s) {
    return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }

  function hookDlp(el) {
    el.dataset._dlpHooked = '1';
    el.addEventListener('mouseenter', dlpShow);
    if (IS_MOBILE) _addMobileDlpTouch(el);
  }

  function _addMobileDlpTouch(el) {
    let _tsY = 0;
    el.addEventListener('touchstart', e => { _tsY = e.touches[0].clientY; }, { passive: true });
    el.addEventListener('touchend',   e => {
      if (Math.abs(e.changedTouches[0].clientY - _tsY) > 10) return; // 스크롤 제스처 무시
      dlpShowTouch(e);
    }, { passive: false });
  }

  /* ================================================================
     DLsite 상품 카드
     - 직접 RJ 코드 또는 DLsite URL에서 추출된 코드 모두 사용
  ================================================================ */
  function makeDlsiteCard(code) {
    const prefix = code.slice(0, 2).toUpperCase();
    const sectionMap = { RJ: 'maniax', BJ: 'bl', VJ: 'soft', RE: 'maniax', BE: 'bl', VE: 'soft' };
    const section = sectionMap[prefix] || 'maniax';
    return makeCardBase({
      cls: 'b64-product-link', icon: ICO.tag,
      title: `DLsite · ${code}`, sub: 'dlsite.com · 호버 시 미리보기',
      url: `https://www.dlsite.com/${section}/work/=/product_id/${code}.html`,
      code, preview: true,
    });
  }

  /* ================================================================
     Steam 상품 카드
  ================================================================ */
  function makeSteamCard(appId) {
    const a = makeCardBase({
      cls: 'b64-product-link pl-steam', icon: ICO.gamepad,
      title: `Steam · ${appId}`, sub: 'store.steampowered.com · 호버 시 미리보기',
      url: `https://store.steampowered.com/app/${appId}/`,
      code: 'ST' + appId, preview: true,
    });

    // 게임 이름 비동기 취득
    GM_xmlhttpRequest({
      method: 'GET',
      url: `https://store.steampowered.com/api/appdetails?appids=${appId}&filters=basic&l=korean`,
      timeout: 6000,
      onload(res) {
        try {
          const data = JSON.parse(res.responseText);
          const info = data[appId];
          if (info?.success && info.data?.name) {
            const titleEl = a.querySelector('.bl-title');
            const subEl   = a.querySelector('.bl-sub');
            if (titleEl) titleEl.textContent = `Steam · ${info.data.name}`;
            if (subEl)   subEl.textContent   = `App #${appId} · 호버 시 미리보기`;
          }
        } catch(e) {}
      },
    });

    return a;
  }

  /* ================================================================
     Patreon 상품 카드
  ================================================================ */
  function makePatreonCard(url) {
    const m = url.match(/patreon\.com\/(?:c\/)?([^/?#]+)/i);
    const creator = m ? m[1] : '';
    return makeCardBase({
      cls: 'b64-product-link pl-patreon', icon: ICO.patreon,
      title: 'Patreon' + (creator ? ' · ' + creator : ''), sub: 'patreon.com',
      url, code: creator || null,
    });
  }

  /* ================================================================
     크리에이터 플랫폼 카드 (Getchu / Fanza / Fanbox)
  ================================================================ */
  // href: URL에서 진입할 때 원본 URL 보존용 (없으면 getchu.com/item/{digits} 생성)
  function makeGetcuCard(code, href) {
    const digits = code.replace(/^GC/i, '');
    return makeCardBase({
      cls: 'b64-product-link pl-getcu', icon: ICO.tag,
      title: `Getchu · ${digits}`, sub: 'getchu.com',
      url: href || `https://getchu.com/item/${digits}`,
      code, preview: true,
    });
  }

  function makeFanzaCard(code) {
    const digits = code.replace(/^FZ/i, '');
    return makeCardBase({
      cls: 'b64-product-link pl-fanza', icon: ICO.tag,
      title: `Fanza · ${code}`, sub: 'dmm.co.jp',
      url: `https://www.dmm.co.jp/dc/doujin/-/detail/=/cid=d_${digits}/`,
      code,
    });
  }

  // href: URL에서 진입할 때 원본 URL 보존용 (없으면 www.fanbox.cc/posts/{digits} 생성)
  // subLabel: 카드 하단 보조 텍스트 (기본 'fanbox.cc')
  function makeFanboxCard(code, href, subLabel) {
    const digits = code.replace(/^FB/i, '');
    // code가 순수 숫자가 아니면 작가명으로 전달된 것 → "FB" 접두어 없이 표시
    const displayCode = /^\d+$/.test(digits) ? ('FB' + digits) : code;
    return makeCardBase({
      cls: 'b64-product-link pl-fanbox', icon: ICO.patreon,
      title: `Fanbox · ${displayCode}`, sub: subLabel || 'fanbox.cc',
      url: href || `https://www.fanbox.cc/posts/${digits}`,
      code: displayCode,
    });
  }

  // Fanbox 크리에이터 페이지 카드 (포스트 ID 없이 서브도메인만 있는 경우)
  // 예: https://mangamaterials.fanbox.cc/posts → 크리에이터명 'mangamaterials' 표시
  function makeFanboxCreatorCard(creator, url) {
    return makeCardBase({
      cls: 'b64-product-link pl-fanbox', icon: ICO.patreon,
      title: `Fanbox · ${creator}`, sub: 'fanbox.cc · 크리에이터',
      url, code: creator,
    });
  }

  function makeCreatorCard(code) {
    const prefix = code.slice(0, 2).toUpperCase();
    if (prefix === 'GC') return makeGetcuCard(code);
    if (prefix === 'FZ') return makeFanzaCard(code);
    if (prefix === 'FB') return makeFanboxCard(code);
    return document.createTextNode(code);
  }

  /* ================================================================
     DLsite 검색/태그·장르 카드 (상품이 아닌 /fsr/ 검색·목록 링크)
  ================================================================ */
  // DLsite 장르 ID→이름 영구 캐시. 한 번 받으면 GM 저장소에 보관 → 다시는 네트워크 안 씀.
  let _dlGenre = null;
  function _dlGenreMap() {
    if (!_dlGenre) { try { _dlGenre = JSON.parse(GM_getValue('dlGenreNames', '{}')); } catch (e) { _dlGenre = {}; } }
    return _dlGenre;
  }
  const _dlGenreFetching = new Set();
  function fetchDlGenreName(id, url) {
    if (_dlGenreFetching.has(id)) return;
    _dlGenreFetching.add(id);
    GM_xmlhttpRequest({
      method: 'GET', url,
      headers: { 'Referer': 'https://www.dlsite.com/' },
      timeout: 8000,
      onload(res) {
        _dlGenreFetching.delete(id);
        // <title>의 「장르명」만 정규식으로 추출 — DOMParser 없이 처리해 가볍고 빠름
        const t = (res.responseText.match(/<title>([^<]*)<\/title>/i) || ['', ''])[1];
        const m = t.match(/[「『]([^」』]+)[」』]/);
        const name = m && m[1].trim();
        if (!name) return;
        _dlGenreMap()[id] = name;
        try { GM_setValue('dlGenreNames', JSON.stringify(_dlGenre)); } catch (e) {}
        document.querySelectorAll('.b64-dlsite-tag[data-genre="' + id + '"]').forEach(el => { el.textContent = '# ' + name; });
      },
      onerror()   { _dlGenreFetching.delete(id); },
      ontimeout() { _dlGenreFetching.delete(id); },
    });
  }

  function makeDlsiteSearchCard(url) {
    const kw = url.match(/\/keyword\/([^/]+)/i);
    const genre = url.match(/\/genre\/(\d+)/i);
    let label, genreId = null;
    if (kw)        { try { label = decodeURIComponent(kw[1]); } catch (e) { label = kw[1]; } }
    else if (genre){ genreId = genre[1]; label = _dlGenreMap()[genreId] || ('장르 ' + genreId); }
    else           { label = '검색'; }
    const a = document.createElement('a');
    a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
    a.setAttribute(DONE_ATTR, '');
    a.dataset._dlpHooked = '1';  // 미리보기 훅 제외 (상품 아님)
    a.className = 'b64-dlsite-tag';
    if (genreId) a.dataset.genre = genreId;
    a.textContent = '# ' + label;
    // 캐시에 없을 때만 1회 비동기 취득 → 이후 영구 캐시
    if (genreId && !_dlGenreMap()[genreId]) fetchDlGenreName(genreId, url);
    return a;
  }

  // 장르/키워드 dedup 키: 같은 장르가 여러 URL 형태(정렬·페이지 파라미터 차이 등)로 와도 1개로 묶는다.
  function _genreKey(href) {
    const g = href.match(/\/genre\/(\d+)/i);
    if (g) return 'g:' + g[1];
    const k = href.match(/\/keyword\/([^/]+)/i);
    if (k) { try { return 'k:' + decodeURIComponent(k[1]); } catch (e) { return 'k:' + k[1]; } }
    return 'u:' + href.split(/[?#]/)[0];
  }

  /* ================================================================
     장르·태그 별도 바 (요구1 — C안)
     DLsite /fsr/ 장르·키워드·태그 검색 링크를 인라인 칩으로 만들지 않고, 본문 하단에 '한 벌'로 모은다.
     핵심: 코네(SvelteKit)가 work.genre 태그를 주기적으로 여러 벌 재렌더 → 인라인 칩화하면 flush·토글
     마다 누적 증식했다. 이 바는 flush마다 통째로 제거 후 dedup해 재생성하므로 개수가 '현재 유니크
     장르 수'로 항상 수렴(누적 불가). 원본 <a>는 숨겨 인라인 중복 표시를 막고, data-b64genre로 표시해
     applyLive(토글 OFF 등)가 원상 복구할 수 있게 한다.
  ================================================================ */
  function buildGenreBar(contentRoots) {
    if (!CFG.LINK_CARD || !CFG.GENRE_BAR || !contentRoots || !contentRoots.size) {
      document.querySelectorAll('.b64-genre-bar').forEach(el => el.remove());
      return;
    }
    // 1) 원본 코네 장르 <a>는 '항상' 수집하며 새 것은 숨긴다(코네가 매 flush 새 <a>로 재렌더해도 인라인
    //    중복 표시 방지). 동시에 장르 키를 모아 시그니처를 만든다.
    const seen = new Set();
    const found = []; // [key, href]
    contentRoots.forEach(root => {
      root.querySelectorAll('a[href]').forEach(a => {
        if (a.classList.contains('b64-dlsite-tag')) return;      // 우리가 만든 칩 제외
        if (a.closest('#b64d-link-panel') || a.closest('.b64-genre-bar')) return;
        const href = a.href || '';
        let host = ''; try { host = new URL(href).hostname; } catch (e) { return; }
        if (!host.includes('dlsite.com') || !/\/fsr\//i.test(href)) return;
        if (/product_id\/(?:RJ|BJ|VJ|RE|BE|VE)/i.test(href)) return; // 상품 링크는 장르 아님
        if (!a.hasAttribute(DONE_ATTR)) { // 원본 숨김(한 번만) + applyLive 복구용 표식
          a.setAttribute(DONE_ATTR, '');
          a.dataset.b64genre = '1';
          a.style.setProperty('display', 'none', 'important');
        }
        const key = _genreKey(href);
        if (seen.has(key)) return;
        seen.add(key);
        found.push([key, href]);
      });
    });
    // 2) ★수정3: 장르 칩 집합이 그대로면 바를 재빌드하지 않는다(재빌드 시 그 안 카드의 호버가 풀리고 깜빡임).
    //    (원본 숨김은 위에서 항상 처리하므로 새 <a>도 안전.)
    const sig = found.map(f => f[0]).join('|');
    const existing = document.querySelector('.b64-genre-bar');
    if (!found.length) { existing?.remove(); return; }
    if (existing && existing.dataset.sig === sig) return; // 집합 동일 → 유지
    existing?.remove();
    const bar = document.createElement('div');
    bar.className = 'b64-genre-bar';
    bar.dataset.sig = sig;
    bar.setAttribute(DONE_ATTR, ''); // MO 재트리거·재처리 방지
    const label = document.createElement('span');
    label.className = 'b64-genre-bar-label';
    label.textContent = '장르·태그';
    bar.appendChild(label);
    found.forEach(([, href]) => bar.appendChild(makeDlsiteSearchCard(href)));
    // 본문 하단에 삽입(본문에서 벗어나지 않게). #post-article 있으면 그 끝, 없으면 첫 contentRoot 끝.
    const articleEl = document.getElementById('post-article');
    const host = articleEl || [...contentRoots][0];
    if (host) host.appendChild(bar);
  }

  // 리디렉션 URL 해석: google.com/url?url=<진짜주소> 같은 래퍼는 '진짜 주소'로 치환.
  // (수정사항8) 이런 링크는 hostname이 google.com이지만 쿼리에 dmm.co.jp 등 지원 사이트 주소가
  // 들어 있어, href 문자열 검색으로 사이트를 판정하던 곳에서 엉뚱한 작품 카드로 오인됐다.
  // 지원하는 리디렉터의 타깃만 뽑아 실제 대상으로 카드화하고, 그 외 구글 리디렉션은 원본 그대로 둔다.
  function resolveRedirect(u) {
    try {
      const p = new URL(/^https?:\/\//i.test(u) ? u : 'https://' + u);
      const host = p.hostname.replace(/^www\./, '');
      // 구글 리디렉터(/url) — url= 또는 q= 파라미터가 진짜 목적지
      if (host === 'google.com' && (p.pathname === '/url' || p.pathname === '/urlq')) {
        const t = p.searchParams.get('url') || p.searchParams.get('q');
        if (t && /^https?:\/\//i.test(t)) return t;
      }
    } catch (e) {}
    return u;
  }

  /* ================================================================
     다운로드 링크 카드
     - DLsite/Steam/Patreon URL은 LINK_CARD 설정 시 상품 카드로 라우팅
  ================================================================ */
  function makeLinkCard(rawUrl) {
    let url = /^https?:\/\//i.test(rawUrl) ? rawUrl : 'https://' + rawUrl;
    url = resolveRedirect(url);
    let h = '';
    try { h = new URL(url).hostname; } catch(e) { h = url; }

    // kone.gg 내부 링크 → 카드 없이 일반 밑줄 링크
    if (/kone\.gg$/.test(h)) {
      const a = document.createElement('a');
      a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
      a.setAttribute(DONE_ATTR, '');
      a.textContent = rawUrl;
      a.style.cssText = 'color:inherit;text-decoration:underline;word-break:break-all;';
      return a;
    }

    if (CFG.LINK_CARD) {
      // DLsite URL → 상품 카드
      if (h.includes('dlsite.com')) {
        // product_id/RJxxx (www) + ch.dlsite.com/.../VJxxx 등 '=코드'·'/코드' 형식 모두 인식
        // (convertProductLinks·buildNodes dedup 키와 동일 헬퍼 → 세 경로 카드/키 일치)
        const dlCode = dlsiteCodeOf(url);
        if (dlCode) return makeDlsiteCard(dlCode);
        // 장르/태그/키워드 검색·목록 링크(/fsr/=/...)는 상품이 아니므로 별도 검색 카드로
        if (/\/fsr\//i.test(url)) return makeDlsiteSearchCard(url);
        // ci-en.dlsite.com(작가 페이지)만 기본 카드로 유지 — 전용 미리보기 백엔드(cienMatch)가 있어
        // 카드가 의미 있게 동작한다(사용자 결정: ci-en만 유지).
        if (h === 'ci-en.dlsite.com') {
          return makeCardBase({
            cls: 'b64-product-link', icon: ICO.tag,
            title: h, sub: 'dlsite.com',
            url, preview: true,
          });
        }
        // 그 외 DLsite URL(홈·카테고리·검색 등 '작품이 아닌 단순 페이지')은 카드화하지 않고 평문 링크로 둔다.
        const a = document.createElement('a');
        a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
        a.setAttribute(DONE_ATTR, '');
        a.textContent = rawUrl;
        a.style.cssText = 'color:inherit;text-decoration:underline;word-break:break-all;';
        return a;
      }

      // Steam URL → 상품 카드
      if (h === 'store.steampowered.com') {
        const appId = steamAppOf(url);
        if (appId) return makeSteamCard(appId);
      }

      // Patreon URL → 상품 카드 (경로 있는 경우만)
      if (h === 'patreon.com' || h === 'www.patreon.com') {
        try { if (new URL(url).pathname.length > 1) return makePatreonCard(url); } catch(e) {}
      }

      // Getcu URL → 크리에이터 카드 (원본 URL 보존)
      if (h === 'getcu.com' || h === 'www.getcu.com') {
        const m = url.match(/\/item\/(\d+)/i);
        const code = m ? 'GC' + m[1] : 'Getcu';
        return makeGetcuCard(code, url);
      }

      // Fanza/DMM URL → 크리에이터 카드
      // dlsoft.dmm.co.jp/detail/코드/ 형식도 지원
      if (h.includes('dmm.co.jp') || h === 'fanza.jp' || h === 'www.fanza.jp') {
        // 쿼리 파라미터 cid= 방식 (www.dmm.co.jp)
        let m = url.match(/cid=d_?([a-z0-9_]+)/i);
        // 경로 방식 (dlsoft.dmm.co.jp/detail/코드/)
        if (!m) m = url.match(/\/detail\/([a-z0-9][a-z0-9_-]*[a-z0-9])\/?(?:[?#]|$)/i);
        const code = m ? 'FZ' + m[1] : 'Fanza';
        return makeFanzaCard(code);
      }

      // Fanbox URL → 크리에이터/포스트 카드 (원본 URL 보존)
      // 작가코드.fanbox.cc/posts          → 크리에이터 카드
      // 작가코드.fanbox.cc/posts/작품코드 → 포스트 카드 (작가코드 서브도메인 유지)
      if (h === 'www.fanbox.cc' || h.endsWith('.fanbox.cc')) {
        const creatorM = h.match(/^([^.]+)\.fanbox\.cc$/);
        const creator = (creatorM && creatorM[1] !== 'www') ? creatorM[1] : null;
        const postM = url.match(/\/posts\/(\d+)/i);
        if (postM) {
          // 작가 서브도메인이 있으면 작가명 표시 + 원본 URL 유지
          if (creator) return makeFanboxCard(creator, url, 'fanbox.cc · 포스트');
          return makeFanboxCard('FB' + postM[1]);
        }
        if (creator) return makeFanboxCreatorCard(creator, url);
        return makeFanboxCard('Fanbox');
      }

      // Getchu URL → 크리에이터 카드 (getchu.com/item/코드/, 원본 URL 보존)
      if (h === 'getchu.com' || h === 'www.getchu.com') {
        const m = url.match(/\/item\/([a-z0-9_-]+)\/?/i) || url.match(/[?&]id=(\d+)/i);
        const code = m ? 'GC' + m[1] : 'Getchu';
        return makeGetcuCard(code, url);
      }
    }

    // 일반 카드
    const isKio = h === 'kio.ac' || h.endsWith('.kio.ac') || h === 'kiosk.ac';
    // link-lock: URL 조각(#암호화 데이터)이 매우 길어 제목에 그대로 두면 지저분 → 깔끔한 라벨로 표기.
    const isLinkLock = h === 'jstrieb.github.io' && /\/link-lock\//i.test(url);

    // 채널별 비번(요구3): 다운로드 사이트 카드면 '이 URL → 현재 채널' 매핑을 저장해 둔다.
    // (다운로드 사이트에서 비번 자동입력이 자기 URL로 채널을 조회해 그 채널 비번만 시도)
    const isDlHost = isKio || isDownloadHost(h);
    if (isDlHost) {
      const _ch = getChannelCode();
      if (_ch) setChannelForUrl(url, _ch);
    }

    let a;
    if (CFG.LINK_CARD) {
      a = makeCardBase({
        cls: 'b64-link',
        icon: (isKio || isLinkLock) ? ICO.link : ICO.world,
        title: isLinkLock ? 'Link Lock · 잠긴 링크' : rawUrl,
        sub: isLinkLock ? 'jstrieb.github.io · 비밀번호 필요' : h,
        url,
      });
    } else {
      a = document.createElement('a');
      a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
      a.setAttribute(DONE_ATTR, '');
      a.textContent = rawUrl;
      a.style.cssText = 'color:#1a73e8;text-decoration:underline;word-break:break-all;';
    }
    if (isDlHost) {
      const _ch2 = getChannelForCurrentSite() || getChannelCode();
      if (_ch2) a.dataset.channel = _ch2; // 보이지 않는 채널코드(디버그·모아보기 dedup 참고용)
    }

    if (CFG.LINK_CHECK) setTimeout(() => checkLink(url, a), CFG.LINK_CHECK_DELAY);
    return a;
  }

  /* ================================================================
     디코딩 텍스트 → DOM 노드
     URL / DLsite 코드 / Steam 코드를 별도 regex로 처리 (i 플래그 분리)
  ================================================================ */
  let _bnBrDepth = 0; // buildNodes 점자 재귀 깊이 가드(중첩 디코딩 폭주 방지)
  function buildNodes(text) {
    // URL 범위 먼저 파악 — URL 안에 박힌 base64(예: link-lock 'https://…/#eyJ…')는 디코딩하면 안 됨
    // (URL의 일부다). 과거엔 findAllMatches가 URL 속 base64를 풀어 URL을 JSON으로 깨뜨렸다(수정사항4 점자).
    const _urlRanges = [];
    URL_RE.lastIndex = 0; let _um;
    while ((_um = URL_RE.exec(text)) !== null) _urlRanges.push([_um.index, _um.index + _um[0].length]);
    const _inUrl = (idx, len) => _urlRanges.some(([s, e]) => idx < e && idx + len > s);
    // 디코딩 결과 내 embedded base64 재처리 (복합 인코딩 지원)
    // base64만 필터 (DLsite/Steam/Creator 코드는 아래 기존 로직이 처리). URL 내부 base64는 제외.
    const b64Hits = findAllMatches(text).filter(h => h.type === 'base64' && !_inUrl(h.index, h.length));
    if (b64Hits.length) {
      const out = [];
      let pos = 0;
      for (const h of b64Hits) {
        if (h.index > pos) out.push(...buildNodes(text.slice(pos, h.index)));
        out.push(...buildNodes(h.decoded)); // 재귀: 디코딩 결과도 동일하게 처리
        pos = h.index + h.length;
      }
      if (pos < text.length) out.push(...buildNodes(text.slice(pos)));
      return out;
    }
    // 점자 런 재귀 디코딩 (복합/중첩 인코딩): base64를 푼 결과가 다시 점자인 경우(예: test→점자→base64→점자)
    // 안쪽 점자가 그대로 남던 문제 — buildNodes가 base64만 재귀하고 점자는 안 풀었던 게 원인.
    // 공백 토큰화는 decodeBrailleIn과 동일(URL 보존). 디코딩 결과를 재귀 처리해 점자↔base64 다중 중첩까지 해결.
    if (CFG.BRAILLE_DECODE && _bnBrDepth < 8 && BR_RUN_RE.test(text)) {
      const parts = text.split(/(\s+)/);
      if (parts.some(p => p && BR_RUN_RE.test(p))) {
        _bnBrDepth++;
        const out = [];
        for (const part of parts) {
          if (!part) continue;
          if (BR_RUN_RE.test(part)) {
            let dec;
            if (CFG.BRAILLE_UEB && BR.isLikelyUEB(part)) dec = BR.decodeUEB(part);
            else { dec = BR.decode(part); if (/(?:https?:\/\/|www\.)/i.test(dec)) dec = dec.replace(/“/g, '?'); }
            out.push(...buildNodes(dec));
          } else out.push(document.createTextNode(part));
        }
        _bnBrDepth--;
        return out;
      }
    }
    // 기존 로직: URL / DLsite 코드 / Steam 코드 / 크리에이터 코드
    const matches = [];
    let m;

    // URL (대소문자 무관, CJK 제외 — URL_RE 정의 참조: 디코딩 URL 뒤 한글 흡수 방지)
    URL_RE.lastIndex = 0;
    while ((m = URL_RE.exec(text)) !== null)
      matches.push({ index: m.index, end: m.index + m[0].length, type: 'url', raw: m[0] });

    // DLsite 코드 (라틴, 대소문자 무관)
    DLSITE_LATIN_RE.lastIndex = 0;
    while ((m = DLSITE_LATIN_RE.exec(text)) !== null)
      matches.push({ index: m.index, end: m.index + m[0].length, type: 'dlsite', code: (m[1] + m[2]).toUpperCase() });

    // DLsite 코드 (한글 IME)
    DLSITE_KR_RE.lastIndex = 0;
    while ((m = DLSITE_KR_RE.exec(text)) !== null)
      matches.push({ index: m.index, end: m.index + m[0].length, type: 'dlsite', code: 'RJ' + m[2] });

    // Steam 코드
    STEAM_CODE_RE.lastIndex = 0;
    while ((m = STEAM_CODE_RE.exec(text)) !== null)
      matches.push({ index: m.index, end: m.index + m[0].length, type: 'steam', appId: m[1] });

    // 크리에이터 플랫폼 코드 (GC/FZ/FB)
    CREATOR_CODE_RE.lastIndex = 0;
    while ((m = CREATOR_CODE_RE.exec(text)) !== null)
      matches.push({ index: m.index, end: m.index + m[0].length, type: 'creator', code: (m[1] + m[2]).toUpperCase() });

    // 위치순 정렬 + 중첩 제거
    matches.sort((a, b) => a.index - b.index);
    const clean = []; let end = 0;
    for (const mt of matches) {
      if (mt.index >= end) { clean.push(mt); end = mt.end; }
    }

    const nodes = []; let last = 0;
    for (const mt of clean) {
      if (mt.index > last) nodes.push(document.createTextNode(text.slice(last, mt.index)));

      if (mt.type === 'dlsite') {
        const dKey = 'dl:' + mt.code;
        const skip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(dKey);
        if (!skip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(dKey);
        if (CFG.LINK_CARD && !skip) { const el = makeDlsiteCard(mt.code); el.dataset.b64key = dKey; _liveCards.set(dKey, el); nodes.push(el); }
        else nodes.push(document.createTextNode(mt.code));
      } else if (mt.type === 'steam') {
        const sKey = 'st:' + mt.appId;
        const skip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(sKey);
        if (!skip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(sKey);
        if (CFG.LINK_CARD && !skip) { const el = makeSteamCard(mt.appId); el.dataset.b64key = sKey; _liveCards.set(sKey, el); nodes.push(el); }
        else nodes.push(document.createTextNode(`Steam ${mt.appId}`));
      } else if (mt.type === 'creator') {
        const cKey = 'cr:' + mt.code;
        const skip = CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(cKey);
        if (!skip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(cKey);
        const gcD = mt.code.startsWith('GC') ? mt.code.slice(2) : null;
        const gcSkip = CFG.NO_DUPLICATE_CARD && gcD !== null && _gcSeenInFlush.has(gcD);
        const crEl = CFG.LINK_CARD && !skip && !gcSkip ? makeCreatorCard(mt.code) : null;
        if (crEl && gcD !== null) _gcSeenInFlush.add(gcD);
        if (crEl) { crEl.dataset.b64key = cKey; _liveCards.set(cKey, crEl); }
        nodes.push(crEl || document.createTextNode(mt.code));
      } else {
        // URL: 후행 구두점 제거
        let url = mt.raw;
        const trail = (url.match(/[.,;:!?)\]}'"]+$/) || [''])[0];
        if (trail) url = url.slice(0, -trail.length);
        const fullUrl = /^https?:\/\//i.test(url) ? url : 'https://' + url;
        let urlHost = '';
        try { urlHost = new URL(fullUrl).hostname; } catch(e) {}
        // 알려진 지원 사이트만 링크 카드 생성; 미지원 사이트는 일반 밑줄 링크.
        // 호스트 목록은 isKnownCardHost(단일 소스) — kone.gg만 여기서 별도 허용(내부 링크는
        // makeLinkCard가 평문 링크로 처리).
        const isKnownHost = /kone\.gg$/.test(urlHost) || isKnownCardHost(urlHost);
        if (isKnownHost) {
          // 중복 카드 방지: 같은 flush 내 동일 작품은 두 번째부터 일반 링크로.
          // 상품 URL은 코드 기준 키로 정규화 → 같은 작품이 코드·URL·디코딩(점자/base64)
          // 형태로 섞여도 dedup 키가 일치(텍스트 코드 dl:코드, convertProductLinks와 동일).
          //   DLsite=dl:코드 / Steam=st:appId / Getchu=cr:GC숫자(+_gcSeenInFlush 교차)
          // 일반(lk:) URL은 query/hash를 떼어 convertProductLinks의 키와 동일하게 정규화
          // (같은 링크가 평문<a>·디코딩으로 섞여도 ?쿼리 유무로 키가 갈리지 않게).
          let lkKey = 'lk:' + fullUrl.split(/[?#]/)[0];
          let gcLkDigits = null;
          if (urlHost.includes('dlsite.com')) {
            const dCode = dlsiteCodeOf(fullUrl);
            if (dCode) lkKey = 'dl:' + dCode;
          } else if (urlHost === 'store.steampowered.com') {
            const aId = steamAppOf(fullUrl);
            if (aId) lkKey = 'st:' + aId;
          } else if (urlHost === 'getchu.com' || urlHost === 'www.getchu.com') {
            // 비숫자 item 코드(문자 포함 슬러그)도 키에 반영해야 해 getchuIdOf(숫자 전용)보다 넓게 매칭
            const gcM = fullUrl.match(/\/item\/([a-z0-9_-]+)/i) || fullUrl.match(/[?&]id=(\d+)/i);
            if (gcM) { lkKey = 'cr:GC' + gcM[1]; gcLkDigits = gcM[1]; }
          }
          const gcLkDup = CFG.NO_DUPLICATE_CARD && gcLkDigits !== null && _gcSeenInFlush.has(gcLkDigits);
          const lkSkip = gcLkDup || (CFG.NO_DUPLICATE_CARD && _seenProductCodes.has(lkKey));
          if (!lkSkip && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(lkKey);
          if (!lkSkip) {
            const lkEl = makeLinkCard(url);
            if (lkEl && lkEl.dataset) { lkEl.dataset.b64key = lkKey; _liveCards.set(lkKey, lkEl); }
            nodes.push(lkEl);
            if (gcLkDigits !== null) _gcSeenInFlush.add(gcLkDigits);
          } else {
            // NO_DUP: 같은 작품/링크의 2번째+ 등장 → 카드 대신 평문 링크.
            // ★반드시 b64key를 박는다. 과거엔 키 없는 평문 DONE 링크였는데, 이러면
            //   convertProductLinks의 부활 로직(DONE+b64key+카드사멸 → 재카드화)이 이 링크를
            //   못 살린다. 코네 하이드레이션이 '첫 카드'(유일한 카드)를 떼어내면 되살릴 씨앗이
            //   하나도 없어 'NO_DUP ON 진입 시 다운로드 카드 0개'가 됐다(라이브 진단 확증).
            //   키를 부여하면 카드가 살아있을 땐 그대로 평문(중복)으로 두고, 카드가 죽으면
            //   다음 flush에서 이 링크가 부활해 카드가 된다(보이는 카드 항상 1개 보장).
            const a = document.createElement('a');
            a.href = fullUrl; a.target = '_blank'; a.rel = 'noopener noreferrer';
            a.setAttribute(DONE_ATTR, ''); a.textContent = url;
            a.dataset.b64key = lkKey;
            a.style.cssText = 'color:inherit;text-decoration:underline;word-break:break-all;';
            nodes.push(a);
          }
        } else {
          const a = document.createElement('a');
          a.href = fullUrl; a.target = '_blank'; a.rel = 'noopener noreferrer';
          a.setAttribute(DONE_ATTR, '');
          a.textContent = url;
          a.style.cssText = 'color:inherit;text-decoration:underline;word-break:break-all;';
          nodes.push(a);
        }
        if (trail) nodes.push(document.createTextNode(trail));
      }

      last = mt.end;
    }
    if (last < text.length) nodes.push(document.createTextNode(text.slice(last)));
    return nodes;
  }

  /* ================================================================
     본문 텍스트 노드 처리

     중복 감지:
     - processedRaws: Map<key, WeakRef<wrap>>
     - 같은 내용이 또 들어올 때, 기존 wrap이 살아있고 같은 부모이면
       Svelte 재삽입으로 판단 → 숨김.
     - 기존 wrap이 없거나 다른 부모이면 정상 중복 → 재디코딩.
  ================================================================ */
  const processedRaws     = new Map(); // key → WeakRef<wrap>
  const processedListRaws = new Map(); // key → WeakRef<decodedSpan> (목록 노드)
  const _cardMap          = new WeakMap(); // 원본 <a> → 생성된 카드 (Svelte 재렌더링 후 카드 재생성 판단용)
  const _seenProductCodes = new Set(); // flush 내 상품 코드 중복 방지 (NO_DUPLICATE_CARD)
  const _gcSeenInFlush    = new Set(); // GC 코드·URL 교차 중복 방지 (설정 무관, 항상 적용)
  // 키(dl:/st:/lk:…) → 그 키로 만든 마지막 카드. flush 간 유지(영구). 코네가 <a> 노드를 통째 갈아치워
  // _cardMap(노드 신원)이 끊겨도, 이 키 기준 생존 확인으로 '카드가 죽었으면 재생성'을 판단한다.
  const _liveCards        = new Map();
  const _cardKeyAlive = (key) => { const c = key && _liveCards.get(key); return !!(c && document.contains(c)); };

  function processContentNode(node) {
    if (!node.parentNode) return;
    const raw = node.nodeValue;
    if (!raw || !raw.trim()) return;

    let anc = node.parentNode;
    while (anc) {
      if (anc.nodeType === Node.ELEMENT_NODE) {
        if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return;
        if (anc.nodeName === 'A') return;
        const tn = anc.nodeName;
        if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return;
      }
      anc = anc.parentNode;
    }

    const key = raw.trim();

    if (processedRaws.has(key)) {
      const existingWrap = processedRaws.get(key)?.deref();
      // 같은 부모 + wrap이 살아있음 = Svelte/React 재삽입 → 숨김
      // 다른 부모(다른 섹션/독립 단락) = 별도 위치 → 재디코딩 허용
      const isSvelte = existingWrap &&
                       document.contains(existingWrap) &&
                       existingWrap.parentNode === node.parentNode;
      if (isSvelte) {
        const hide = document.createElement('span');
        hide.setAttribute(DONE_ATTR, '');
        hide.style.cssText = 'display:none!important;';
        hide.textContent = raw;
        node.parentNode.replaceChild(hide, node);
        return;
      }
      // wrap이 없거나 다른 부모(다른 섹션) → 재디코딩
      processedRaws.delete(key);
    }

    const hits = findAllMatches(raw);
    // 평문 URL(예: 선두 공백/탭으로 코네가 자동 링크하지 않은 dlsite URL)도 카드화 대상.
    // findAllMatches는 URL을 잡지 않으므로(코드만), URL이 있으면 buildNodes로 통째 처리한다.
    // (자동 링크된 URL은 <A> 안이라 위에서 이미 스킵됨 → 중복 처리 없음)
    const hasUrl = /(?:https?:\/\/|www\.)\S/i.test(raw);
    if (!hits.length && !hasUrl) return;

    // buildNodes가 base64 재귀·URL·DLsite/Steam/크리에이터 코드·dedup(_seenProductCodes)을 모두 처리.
    // (평문 URL 속 RJ 코드만 따로 카드화돼 URL이 조각나던 문제 → URL 전체를 한 카드로)
    const frag = document.createDocumentFragment();
    buildNodes(raw).forEach(n => frag.appendChild(n));

    const wrap = document.createElement('span');
    wrap.setAttribute(DONE_ATTR, '');
    wrap.setAttribute(RAW_ATTR, raw);
    wrap.style.cssText = 'all:unset;display:contents;';
    wrap.appendChild(frag);

    processedRaws.set(key, new WeakRef(wrap));
    node.parentNode.replaceChild(wrap, node);
  }

  /* ================================================================
     목록 텍스트 노드 처리
  ================================================================ */
  function processListNode(node) {
    const raw = node.nodeValue;
    if (!raw || !raw.trim()) return;
    const p = node.parentNode;
    if (!p) return;
    if (p.nodeType === Node.ELEMENT_NODE && p.hasAttribute(LTDONE_ATTR)) return;
    let anc = p;
    while (anc) {
      if (anc.nodeType === Node.ELEMENT_NODE) {
        if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return;
        const tn = anc.nodeName;
        if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return;
      }
      anc = anc.parentNode;
    }

    const key = raw.trim();

    // processContentNode가 같은 내용을 이미 본문에 디코딩했으면 목록 디코딩 스킵
    if (processedRaws.has(key)) {
      const contentWrap = processedRaws.get(key)?.deref();
      if (contentWrap && document.contains(contentWrap)) return;
    }

    if (processedListRaws.has(key)) {
      const existingSpan = processedListRaws.get(key)?.deref();
      const isSvelte = existingSpan &&
                       document.contains(existingSpan) &&
                       existingSpan.parentNode === p;
      if (isSvelte) {
        const hide = document.createElement('span');
        hide.setAttribute(LTDONE_ATTR, '');
        hide.style.cssText = 'display:none!important;';
        hide.textContent = raw;
        p.replaceChild(hide, node);
        return;
      }
      processedListRaws.delete(key);
    }

    const hits = findAllMatches(raw);
    if (!hits.length) return;

    // 목록 페이지: RJ/Steam 코드는 인라인 카드로 만들지 않음 (썸네일이 대신 표시)
    // 텍스트만 변환 (base64 / 점자)
    let result = '', last = 0;
    for (const { index, length, decoded } of hits) {
      result += raw.slice(last, index) + decoded;
      last = index + length;
    }
    result += raw.slice(last);
    if (result === raw) return;

    const hideWrap = document.createElement('span');
    hideWrap.setAttribute(LTDONE_ATTR, 'src');
    hideWrap.style.cssText = 'display:none!important;';
    hideWrap.textContent = raw;

    const decodedSpan = document.createElement('span');
    decodedSpan.setAttribute(LTDONE_ATTR, '');
    decodedSpan.style.cssText = 'all:unset;';
    decodedSpan.textContent = result;

    processedListRaws.set(key, new WeakRef(decodedSpan));
    p.insertBefore(hideWrap, node);
    p.insertBefore(decodedSpan, node);
    p.removeChild(node);
  }

  /* ================================================================
     공통 walker
  ================================================================ */
  function walkAndProcess(root, handler) {
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
      acceptNode(node) {
        const p = node.parentNode;
        if (!p) return NodeFilter.FILTER_REJECT;
        const tag = p.nodeName;
        if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA') return NodeFilter.FILTER_REJECT;
        if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
        let anc = p;
        while (anc && anc !== root) {
          if (anc.nodeType === Node.ELEMENT_NODE) {
            // contenteditable="true" 영역(Froala 에디터 등) 내부는 절대 처리하지 않음
            if (anc.contentEditable === 'true') return NodeFilter.FILTER_REJECT;
            if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return NodeFilter.FILTER_REJECT;
            const tn = anc.nodeName;
            if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return NodeFilter.FILTER_REJECT;
          }
          anc = anc.parentNode;
        }
        return NodeFilter.FILTER_ACCEPT;
      },
    });
    const nodes = []; let n;
    while ((n = walker.nextNode())) nodes.push(n);
    nodes.forEach(handler);
  }

  /* ================================================================
     인접 블록 합산 디코딩
     walkAndProcess 보다 먼저 실행.
     이유: walkAndProcess가 먼저 실행되면 개별 디코딩된 요소에 DONE_ATTR가 붙어
           원본 base64 조각 정보가 사라지므로, 합산 시도 자체가 불가능해짐.
     전략: 개별 디코딩이 불가능한(분할된) base64 줄만 그룹에 포함,
           개별 디코딩 가능한 줄은 제외해 walkAndProcess에 위임.
  ================================================================ */
  function trySiblingMerge(root) {
    const BLOCK = new Set(['P','DIV','LI','BLOCKQUOTE','DD','DT']);

    function isUnprocessed(el) {
      return !el.querySelector('[' + DONE_ATTR + '], [' + LTDONE_ATTR + ']');
    }

    // 텍스트가 60%+ base64 문자로 구성인지 확인 (순수 한국어 단락 오합산 방지)
    // 한국어 접두사 + base64 혼합이나 공백 포함 base64도 통과할 수 있도록 여유 있게 설정
    function looksB64ish(txt) {
      const c = txt.replace(/\s/g, '');
      if (c.length < MIN_B64) return false;
      let n = 0;
      for (let i = 0; i < c.length; i++) {
        const ch = c[i];
        if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')
            || ch === '+' || ch === '/' || ch === '-' || ch === '_' || ch === '=') n++;
      }
      return n / c.length >= 0.6;
    }

    // 한글/한자/가나(CJK) 포함 여부. base64는 ASCII뿐이라 CJK가 있으면 base64 조각이 아니라
    // 제목/문장이다(형제 합산에서 제외해 과합산 방지). 한글 자모/완성형 + CJK통합한자 + 히라/가타.
    function hasCJK(txt) {
      return /[ᄀ-ᇿ぀-ヿ㄰-㆏㐀-䶿一-鿿가-힣]/.test(txt);
    }

    // 합산 성공 시 true, 아니면 false(호출부가 개별 처리로 폴백). 잘못 묶인 경우(한 base64 아님)는
    // 합산본 디코딩에 U+FFFD가 생기는데 isProbablyText(repl===0)가 거부해 base64 hit이 없으므로 false.
    function applyMerge(els) {
      if (els.length < 2) return false;
      const parts = els.map(e => (e.textContent || '').trim()).filter(Boolean);
      const combined = parts.join('\n'); // \n: 개별 라인 구분 유지 → 각 라인 독립 매칭 보장
      const stripped = combined.replace(/\s+/g, ''); // 줄바꿈 제거 → 분할 base64 합산
      let hits = findAllMatches(combined); let src = combined;
      if (!hits.some(h => h.type === 'base64') && stripped.length >= MIN_B64) {
        const h2 = findAllMatches(stripped);
        if (h2.some(h => h.type === 'base64')) { hits = h2; src = stripped; }
      }
      if (!hits.some(h => h.type === 'base64')) return false;
      let res = '', p = 0;
      for (const { index, length, decoded } of hits) { res += src.slice(p, index) + decoded; p = index + length; }
      res += src.slice(p);
      const wrap = document.createElement('span');
      wrap.setAttribute(DONE_ATTR, ''); wrap.setAttribute('data-b64merge', 'block'); wrap.style.cssText = 'all:unset;display:contents;';
      wrap.setAttribute('data-b64merge-html', els[0].innerHTML); // SvelteKit 프로퍼티 소실 대비
      buildNodes(res).forEach(nd => wrap.appendChild(nd));
      // 원본 조각 보존(즉시 토글 복원용): els[0] 원래 자식 + 합쳐져 사라질 형제 블록 참조.
      const orig = document.createDocumentFragment();
      while (els[0].firstChild) orig.appendChild(els[0].firstChild);
      wrap._b64mergeOrig = orig;
      wrap._b64mergeSibs = els.slice(1);
      els[0].appendChild(wrap);
      for (let i = 1; i < els.length; i++) els[i].remove();
      return true;
    }

    // 같은 블록 안에서 글자색 등 인라인 스타일로 base64가 여러 <span>(=여러 텍스트 노드)으로 쪼개진 것을
    // 합쳐 디코딩한다. 진단(콘솔)으로 확인된 두 실패 형태를 모두 처리:
    //   · 한 줄 base64가 색상 span 2개로 분할 (<br> 없음)
    //   · <br>/개행으로 나뉜 '여러 줄' base64가 각 줄마다 또 색상 분할 (이전엔 줄을 통째 뭉개 ◇? 깨짐)
    // 전략: 인라인 내용을 <br>/개행 기준 '줄'로 나눠, '줄 단위'로만 합산·디코딩한다(줄 간 합치기 절대 금지).
    // 안전장치:
    //   ⓐ 자식 블록(p/div/li/…)이 있으면 위임(잎 블록만). <br>는 줄 경계로 처리(스킵 아님).
    //   ⓑ 줄 디코딩 결과에 U+FFFD(깨짐)가 있으면 그 줄은 디코딩하지 않고 원문 텍스트로 유지.
    //   ⓒ 실제로 '한 줄이 2개 이상 텍스트 노드로 쪼개진' 경우가 있을 때만 교체(단일 노드는 walkAndProcess에 위임).
    function inlineSplitB64(el) {
      // 자식 블록 있으면 위임(잎 블록만). ★button 포함 블록도 제외 — 코네 댓글은 본문과 액션 버튼(수정/삭제)이
      // 같은 컨테이너에 있어, 이 재구성이 버튼을 orig로 옮겨 DOM에서 없애던 문제(요구0). 버튼 블록은 하위 재귀에 위임.
      if (el.querySelector('p,div,li,blockquote,dd,dt,button')) return false;
      const REPL = String.fromCharCode(0xFFFD);
      // 인라인 내용을 줄 배열로 수집. 각 줄: { text, n=비어있지 않은 텍스트노드 수 }
      const lines = [{ text: '', n: 0 }];
      let ok = true;
      (function walk(node) {
        for (const ch of node.childNodes) {
          if (!ok) return;
          if (ch.nodeType === Node.TEXT_NODE) {
            const parts = ch.nodeValue.split('\n');
            for (let k = 0; k < parts.length; k++) {
              if (k > 0) lines.push({ text: '', n: 0 });
              const last = lines[lines.length - 1];
              last.text += parts[k];
              if (parts[k].trim()) last.n++;
            }
          } else if (ch.nodeType === Node.ELEMENT_NODE) {
            const tag = ch.nodeName;
            if (tag === 'BR') { lines.push({ text: '', n: 0 }); }
            else if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA') { ok = false; return; }
            else if (ch.hasAttribute(DONE_ATTR) || ch.hasAttribute(LTDONE_ATTR) || ch.isContentEditable) { ok = false; return; }
            else walk(ch);
          }
        }
      })(el);
      if (!ok) return false;
      // 줄별 디코딩. 비어있지 않은 줄만. 색상 분할(한 줄에 노드 2+)된 줄이 하나라도 실제 디코딩될 때만 교체.
      const built = [];      // 각 항목 = 그 줄을 이룰 노드 배열
      let splitDecoded = false;
      for (const line of lines) {
        if (!line.text.trim()) continue;
        const hits = findAllMatches(line.text);
        const b64 = hits.filter(h => h.type === 'base64');
        const valid = b64.length && b64.every(h => h.decoded.indexOf(REPL) === -1); // ⓑ
        if (valid) {
          // 디코딩 결과와 주변 텍스트를 분리해서 buildNodes 호출 — 하나로 합치면 URL regex가
          // 디코딩된 URL 뒤에 붙은 한글 등을 URL 일부로 삼키는 문제 방지.
          const segNodes = [];
          let p = 0;
          for (const { index, length, decoded } of hits) {
            if (index > p) segNodes.push(...buildNodes(line.text.slice(p, index)));
            segNodes.push(...buildNodes(decoded));
            p = index + length;
          }
          if (p < line.text.length) segNodes.push(...buildNodes(line.text.slice(p)));
          built.push(segNodes);
          if (line.n >= 2) splitDecoded = true; // ⓒ 이 줄이 여러 노드로 쪼개져 있었음(분할 복구 대상)
        } else {
          built.push([document.createTextNode(line.text)]);
        }
      }
      if (!splitDecoded || !built.length) return false;
      const wrap = document.createElement('span');
      wrap.setAttribute(DONE_ATTR, ''); wrap.setAttribute('data-b64merge', 'inline'); wrap.style.cssText = 'all:unset;display:contents;';
      wrap.setAttribute('data-b64merge-html', el.innerHTML); // SvelteKit이 JS 프로퍼티를 잃어도 복원 가능
      built.forEach((nodes, i) => {
        if (i > 0) wrap.appendChild(document.createElement('br')); // 줄 사이 줄바꿈 복원
        nodes.forEach(nd => wrap.appendChild(nd));
      });
      const orig = document.createDocumentFragment();
      while (el.firstChild) orig.appendChild(el.firstChild); // 원본 보존(즉시 토글 복원용)
      wrap._b64mergeOrig = orig;
      el.appendChild(wrap);
      return true;
    }

    // 미처리 단일 블록 개별 처리: 색상 span으로 쪼개진 한 줄이면 inlineSplitB64, 아니면 하위 재귀.
    // (sibling-merge에서 빠지거나 합산 실패한 블록의 폴백 — 색상인코딩(수정1)·자기완결 base64가
    //  여기로 와서 제대로 처리된다.)
    function processSingle(ch) {
      if (!isUnprocessed(ch)) return;
      const txt = (ch.textContent || '').trim();
      if (BLOCK.has(ch.nodeName) && looksB64ish(txt) && inlineSplitB64(ch)) return;
      scan(ch);
    }

    function scan(parent) {
      let run = [];
      // run을 비울 때: 2개+면 형제 합산 시도, 실패하면(또는 1개면) 각 블록을 개별 처리로 폴백.
      // → '연속 base64 조각'은 합산되고, 잘못 묶였거나 단일인 블록은 개별 디코딩/색상처리로 정확히 간다.
      //   합산이 멋대로 다른 콘텐츠를 삼키지 않게(과합산 방지) 하면서, 무엇도 미처리로 누락되지 않게 보장.
      const flushRun = () => {
        if (run.length >= 2 && applyMerge(run)) { run = []; return; }
        run.forEach(processSingle);
        run = [];
      };
      for (const ch of Array.from(parent.children)) {
        const txt = (ch.textContent || '').trim();
        // 형제 합산 후보 — 단일 판정으로 통합(과거 hasLongB64Run·_selfComplete·!개별디코딩 패치 폐기):
        //   블록 + 미처리 + base64ish(0.6) + CJK없음 + 이미지없음.
        // · CJK없음: 제목/파일명/문장('서양/[OnlyFans]…','오늘 영상…')은 한글/한자/가나가 섞여 제외 →
        //   과합산(사진 사라짐·UI 평문화: 수정2·3) 방지. 순수 base64 조각은 CJK가 없어 통과.
        // · 줄 길이 무관: 짧은 줄 base64도 통과(hasLongB64Run이 이를 제외해 '평문 그대로'이던 회귀 제거).
        // ★button 포함 블록은 후보에서 제외(요구0): 코네 댓글의 '본문+액션 버튼' 혼재 컨테이너를 합산/재구성
        //   대상으로 삼으면 버튼이 사라진다. 그 안의 base64는 하위 재귀·walkAndProcess가 개별 처리해 카드가 된다.
        const candidate = BLOCK.has(ch.nodeName) && isUnprocessed(ch)
          && looksB64ish(txt) && !hasCJK(txt) && !ch.querySelector('img, button');
        if (candidate) {
          run.push(ch);
          if (run.length >= 8) flushRun(); // 과도한 합산 방지
        } else {
          flushRun();
          processSingle(ch);
        }
      }
      flushRun();
    }

    scan(root);
  }

  /* ================================================================
     드래그 자동 변환
  ================================================================ */
  let dragTooltip = null;
  // 드래그 툴팁 제거 시 내부 카드에 열려 있던 미리보기도 같이 닫음
  function removeDragTooltip() {
    if (dragTooltip) {
      // 툴팁 안의 카드에서 미리보기가 열려 있으면 함께 닫기
      if (dlp.showing) dlpHide();
      dragTooltip.remove();
      dragTooltip = null;
    }
  }

  document.addEventListener('mouseup', (e) => {
    if (!CFG.DRAG_DECODE) return;
    if (isWritePage()) return;
    // 툴팁 안을 클릭한 경우 닫지 않음 (카드 링크 클릭 보호)
    if (dragTooltip && dragTooltip.contains(e.target)) return;
    removeDragTooltip();
    const sel = window.getSelection();
    if (!sel || sel.isCollapsed) return;
    const text = sel.toString().trim();
    if (!text) return;

    // ── 본문 인-플레이스 교체 (팝업과 동일한 디코딩, DOM 직접 교체) ──
    // 단일 텍스트 노드 내 선택일 때만 in-place. 선택이 요소(색상 박스/칸 등) 경계를 넘으면
    // deleteContents가 박스를 쪼개 '오른쪽에 빈 칸이 하나 더' 생기므로, 그 경우는 비파괴적 팝업으로 폴백.
    const rIp = sel.rangeCount ? sel.getRangeAt(0) : null;
    if (rIp) {
      const r = rIp;
      // text = sel.toString().trim() 이므로 팝업과 완전히 동일한 소스 사용
      let ipHits = findAllMatches(text);
      let ipSrc = text;
      if (!ipHits.length) {
        const stripped = text.replace(/[\r\n\s]+/g, '');
        if (stripped.length >= MIN_B64 && stripped !== text) {
          const h2 = findAllMatches(stripped);
          if (h2.length) { ipHits = h2; ipSrc = stripped; }
        }
      }
      // 디코딩/카드 대상(base64·DLsite/Steam/크리에이터 코드·URL)이 있으면 드래그한 그 자리에
      // 카드를 직접 삽입한다(팝업 X). buildNodes가 base64 재귀·URL·코드·dedup을 모두 처리.
      const ipHasUrl = /(?:https?:\/\/|www\.)\S/i.test(ipSrc);
      if (ipHits.length || ipHasUrl) {
        const rNodes = buildNodes(ipSrc);
        // 실제로 카드/링크 등 변환이 생겼을 때만 교체 (순수 텍스트면 그대로 둠)
        const changed = rNodes.some(nd => nd.nodeType === Node.ELEMENT_NODE)
          || (rNodes.length === 1 && rNodes[0].nodeType === Node.TEXT_NODE && rNodes[0].nodeValue !== ipSrc);
        if (changed) {
          const wrap = document.createElement('span');
          wrap.setAttribute(DONE_ATTR, '');
          wrap.style.cssText = 'all:unset;display:contents;';
          rNodes.forEach(nd => wrap.appendChild(nd));
          // ★ 단일 텍스트노드뿐 아니라 '여러 노드/색상 칸을 가로지르는' 선택도 본문을 직접 대체(팝업 X).
          //   deleteContents가 색상 span을 쪼개 남기는 '빈 껍데기'는 삽입 후 형제 기준으로 정리.
          try {
            r.deleteContents();
            r.insertNode(wrap);
            const parent = wrap.parentNode;
            if (parent) Array.from(parent.children).forEach(x => {
              if (x === wrap || x.contains(wrap) || x.hasAttribute(DONE_ATTR)) return;
              if (!x.textContent.trim() && !x.querySelector('img,br,svg')) x.remove();
            });
            sel.removeAllRanges();
            return;
          } catch(_) {}
        }
      }
    }

    // ── 팝업 표시 (인-플레이스 불가 시) ──
    // 줄바꿈 제거 후 통합 base64 시도 (여러 줄로 분리된 경우 대응)
    let hits = findAllMatches(text);
    let effectiveText = text;
    if (!hits.length) {
      const stripped = text.replace(/[\r\n\s]+/g, '');
      if (stripped !== text && stripped.length >= MIN_B64) {
        const h2 = findAllMatches(stripped);
        if (h2.length) { hits = h2; effectiveText = stripped; }
      }
    }
    if (!hits.length) return;

    // 디코딩 결과 텍스트 재조립
    let result = '', last = 0;
    for (const { index, length, decoded } of hits) { result += effectiveText.slice(last, index) + decoded; last = index + length; }
    result += effectiveText.slice(last);

    // 상품 코드(DLsite/Steam) 추출
    const productHits = hits.filter(h => h.type === 'dlsite' || h.type === 'steam');
    const hasNewText  = result !== effectiveText;
    if (!hasNewText && !productHits.length) return;

    const range = sel.getRangeAt(0);
    const rect  = range.getBoundingClientRect();
    dragTooltip = document.createElement('div');
    dragTooltip.id = 'b64d-drag-tooltip';

    // 텍스트 변환 결과
    if (hasNewText) {
      const textEl = document.createElement('div');
      textEl.className = 'b64d-drag-text';
      textEl.textContent = result;
      dragTooltip.appendChild(textEl);
    }

    // 상품 카드
    if (productHits.length) {
      const seen = new Set();
      const cardsWrap = document.createElement('div');
      cardsWrap.className = 'b64d-drag-cards';
      for (const hit of productHits) {
        const id = hit.type === 'dlsite' ? hit.code : hit.appId;
        if (seen.has(id)) continue;
        seen.add(id);
        cardsWrap.appendChild(
          hit.type === 'dlsite' ? makeDlsiteCard(hit.code) : makeSteamCard(hit.appId)
        );
      }
      dragTooltip.appendChild(cardsWrap);
    }

    dragTooltip.style.top  = `${rect.bottom + window.scrollY + 6}px`;
    dragTooltip.style.left = `${rect.left + window.scrollX}px`;
    document.body.appendChild(dragTooltip);
  });
  document.addEventListener('mousedown', (e) => {
    if (dragTooltip && dragTooltip.contains(e.target)) return;
    removeDragTooltip();
  });
  // 모바일: 터치로 텍스트 선택 후 손 뗄 때 드래그 디코딩 실행
  document.addEventListener('touchend', () => {
    if (!CFG.DRAG_DECODE || isWritePage()) return;
    setTimeout(() => {
      const sel = window.getSelection();
      if (sel && !sel.isCollapsed && sel.toString().trim()) {
        document.dispatchEvent(new MouseEvent('mouseup', { bubbles: false, cancelable: false }));
      }
    }, 80);
  }, { passive: true });
  // 모바일: 다른 곳 탭 시 툴팁 닫기
  document.addEventListener('touchstart', (e) => {
    if (dragTooltip && dragTooltip.contains(e.target)) return;
    removeDragTooltip();
  }, { passive: true });
  document.addEventListener('keydown', removeDragTooltip);

  /* ================================================================
     미리보기 팝업 (DLsite 및 Steam 공통)
     키보드 a/d 또는 ← → 로 이미지 전환, 휠 스크롤 가능
  ================================================================ */
  let _dlpRestore = null, _dlpRestoreTimer = null;
  // Getchu 페이지 파싱 결과(이미지 URL 목록) 캐시 — 재호버 시 페이지 fetch 1회 절약.
  // 값은 짧은 URL 문자열 배열뿐(블롭 아님)이라 메모리 부담 미미. 개수 상한(FIFO) 적용.
  const _gcPageCache = new Map();
  const GC_CACHE_MAX = 60;
  const dlp = { el: null, images: [], idx: 0, showing: false, srcWatcher: null, chipsEl: null, panelEl: null, navEl: null, site: null, blobUrls: null, _pending: null, href: null, _touchHide: null, tags: null, tagsBarEl: null, tagsListEl: null, tagsExpanded: false, kpCodeEl: null, _kpClickHide: null, _kp: false };

  // 미리보기 팝업에 에러 메시지 표시: 내용 비우고 에러 div + (칩 있으면) 패널 재부착
  function renderDlpError(msg) {
    if (!dlp.el) return;
    dlp.el.innerHTML = '';
    const d = document.createElement('div');
    d.className = 'dlp-err';
    d.textContent = msg;
    dlp.el.appendChild(d);
    if (dlp.panelEl && dlp.chipsEl?.children.length) dlp.el.appendChild(dlp.panelEl);
  }
  // 네비 텍스트("N/총  ←→ a·d") 갱신 — 이미지 1장 이하면 비움
  function dlpUpdateNav() {
    if (dlp.navEl) dlp.navEl.textContent = dlp.images.length > 1 ? `${dlp.idx+1}/${dlp.images.length}  ←→ a·d` : '';
  }
  // 작품 태그 영역 렌더 (기본 접힘 → T키/탭으로 펼침). 공식 장르만.
  function renderTags() {
    if (!dlp.tagsBarEl || !dlp.tagsListEl) return;
    const tags = dlp.tags || [];
    if (!CFG.PREVIEW_TAGS || !tags.length) {
      dlp.tagsBarEl.style.display = 'none';
      dlp.tagsListEl.style.display = 'none';
      dlp.tagsListEl.innerHTML = '';
      return;
    }
    if (dlp.panelEl) dlp.panelEl.classList.add('has-content');
    const hint = IS_MOBILE ? '탭' : 'T';
    dlp.tagsBarEl.style.display = '';
    dlp.tagsBarEl.textContent = dlp.tagsExpanded ? `🏷 태그 ${tags.length}개 · ${hint} 접기` : `🏷 태그 ${tags.length}개 · ${hint} 펼치기`;
    if (dlp.tagsExpanded) {
      dlp.tagsListEl.style.display = '';
      dlp.tagsListEl.innerHTML = '';
      for (const t of tags) {
        const chip = document.createElement('span');
        chip.className = 'dlp-tag';
        chip.textContent = t;
        dlp.tagsListEl.appendChild(chip);
      }
    } else {
      dlp.tagsListEl.style.display = 'none';
      dlp.tagsListEl.innerHTML = '';
    }
  }

  function dlpUpdateImage() {
    if (!dlp.el || !dlp.images.length) return;
    const newSrc = dlp.images[dlp.idx];

    // 진행 중인 교체 프리로드 취소
    if (dlp._pending) { dlp._pending.onload = dlp._pending.onerror = null; dlp._pending = null; }

    function hookErr(el) {
      el.onerror = () => {
        if (!dlp.el) return;
        dlp.images.splice(dlp.idx, 1);
        if (dlp.idx >= dlp.images.length) dlp.idx = Math.max(0, dlp.images.length - 1);
        if (dlp.images.length) { dlpUpdateImage(); return; }
        renderDlpError('이미지를 불러올 수 없습니다.');
      };
    }
    function updatePanel() {
      // W/S 코드 라벨: dlpUpdateImage가 첫 이미지에서 el.innerHTML을 비우므로 매 갱신마다 재부착(좌상단 고정).
      if (dlp.kpCodeEl && dlp.el && !dlp.el.contains(dlp.kpCodeEl)) dlp.el.appendChild(dlp.kpCodeEl);
      if (!dlp.panelEl) return;
      dlpUpdateNav();
      dlp.el.appendChild(dlp.panelEl);
    }

    let img = dlp.el.querySelector('img');
    if (!img) {
      // 최초 호출: 스피너 제거 후 img 생성
      dlp.el.innerHTML = '';
      img = document.createElement('img');
      if (dlp.site !== 'getchu') img.referrerPolicy = 'no-referrer';
      dlp.el.appendChild(img);
      hookErr(img);
      img.src = newSrc;
      updatePanel();
      return;
    }

    if (img.src === newSrc) { updatePanel(); return; }

    // URL이 다를 때: DOM 밖에서 미리 로드 후 원자적 교체 → 로딩 화면 없음
    const next = document.createElement('img');
    if (dlp.site !== 'getchu') next.referrerPolicy = 'no-referrer';
    dlp._pending = next;
    next.onload = () => {
      if (!dlp.el || dlp._pending !== next) return;
      dlp._pending = null;
      if (dlp.el.contains(img)) dlp.el.replaceChild(next, img); else dlp.el.appendChild(next);
      hookErr(next);
      updatePanel();
    };
    next.onerror = () => {
      if (!dlp.el || dlp._pending !== next) return;
      dlp._pending = null;
      dlp.images.splice(dlp.idx, 1);
      if (dlp.idx >= dlp.images.length) dlp.idx = Math.max(0, dlp.images.length - 1);
      if (dlp.images.length) { dlpUpdateImage(); return; }
      renderDlpError('이미지를 불러올 수 없습니다.');
    };
    next.src = newSrc;
  }

  function dlpMove(e) {
    if (!dlp.el) return;
    const pw = dlp.el.offsetWidth  || 50;
    const ph = dlp.el.offsetHeight || 50;
    const x = (e.clientX + 16 + pw > window.innerWidth)  ? Math.max(0, e.clientX - pw - 16) : e.clientX + 16;
    const y = (e.clientY + 16 + ph > window.innerHeight) ? Math.max(0, e.clientY - ph - 16) : e.clientY + 16;
    dlp.el.style.left = `${x}px`;
    dlp.el.style.top  = `${y}px`;
  }

  // W/S 미리보기 위치 고정(가운데/왼쪽/오른쪽). 커서추적(dlpMove) 대신 화면을 좌/우 반으로 나눠 '빈공간의
  // 정중앙'(좌=25%, 가운데=50%, 우=75%)에 비율로 핀 → 화면 폭이 달라져도 양쪽 여백 한가운데 유지.
  function applyKpFixedPos(el, pos, cardEl) {
    const vw = window.innerWidth;
    // 본문 '글 칼럼'의 좌우 경계를 찾아, 그 바깥 여백의 정중앙에 둔다(칼럼 침범 방지). 폭 휴리스틱(walk-up)은
    // 너무 넓은 조상을 잡아 여백이 0에 수렴→침범했음. 코네 새 본문은 .tiptap.ProseMirror(=실제 글 칼럼)이므로
    // 카드 기준 closest로 칼럼을 직접 특정한다.
    const colEl = (cardEl && (cardEl.closest('.tiptap.ProseMirror') || cardEl.closest('#post-article') || cardEl.closest('article')))
               || document.querySelector('.tiptap.ProseMirror') || document.getElementById('post-article');
    let colL = vw * 0.25, colR = vw * 0.75; // 폴백
    if (colEl) { const r = colEl.getBoundingClientRect(); if (r.width > 40) { colL = r.left; colR = r.right; } }
    const half = (el.offsetWidth || Math.round(480 * (CFG.PREVIEW_SCALE / 100))) / 2;
    const gap = 12;
    let cx;
    // 여백 중앙을 원칙으로 하되, 여백이 좁으면 '칼럼을 침범하지 않는 선'(칼럼 모서리에 붙임)으로 보정.
    if (pos === 'left')       cx = Math.min(colL / 2, colL - gap - half);
    else if (pos === 'right') cx = Math.max((colR + vw) / 2, colR + gap + half);
    else                      cx = vw / 2; // center
    // 칼럼 경계 우선 보호 → 뷰포트 밖으로 나가더라도 칼럼 침범 안 함.
    // 뷰포트 클램프는 그 다음에 적용하되, 칼럼 경계는 재침범하지 않도록 한 번 더 보정.
    if (pos === 'left')       cx = Math.min(cx, colL - gap - half);
    else if (pos === 'right') cx = Math.max(cx, colR + gap + half);
    const vpMin = half + 4, vpMax = vw - half - 4;
    cx = Math.min(Math.max(cx, vpMin), vpMax);
    if (pos === 'left' && cx + half > colL - gap) cx = colL - gap - half;
    else if (pos === 'right' && cx - half < colR + gap) cx = colR + gap + half;
    el.style.right = 'auto'; el.style.bottom = 'auto'; el.style.top = '50%';
    el.style.left = Math.round(cx) + 'px';
    el.style.transform = 'translate(-50%,-50%)';
  }

  function dlpHide() {
    // 카드 DOM 교체 시(Svelte 재렌더 등) 빠른 재표시를 위해 미리보기 상태 임시 보존 (600ms)
    if (dlp.showing && dlp.href && dlp.images.length > 0) {
      clearTimeout(_dlpRestoreTimer);
      // Getchu blob URL은 revoke하지 않고 _dlpRestore에 이관 (타이머 만료 시 revoke)
      const savedBlobs = (dlp.site === 'getchu' && dlp.blobUrls) ? dlp.blobUrls : null;
      if (savedBlobs) dlp.blobUrls = null;
      const chips = dlp.chipsEl ? [...dlp.chipsEl.children].map(c => ({ text: c.textContent, cls: c.className })) : [];
      _dlpRestore = { images: dlp.images.slice(), idx: dlp.idx, href: dlp.href, site: dlp.site, chips, blobUrls: savedBlobs, tags: dlp.tags, tagsExpanded: dlp.tagsExpanded };
      _dlpRestoreTimer = setTimeout(() => {
        if (_dlpRestore?.blobUrls) _dlpRestore.blobUrls.forEach(u => URL.revokeObjectURL(u));
        _dlpRestore = null;
      }, 600);
    }
    if (dlp._pending) { dlp._pending.onload = dlp._pending.onerror = null; dlp._pending = null; }
    if (dlp.srcWatcher) { dlp.srcWatcher.disconnect(); dlp.srcWatcher = null; }
    if (dlp.el) { dlp.el.remove(); dlp.el = null; }
    if (dlp.blobUrls) { dlp.blobUrls.forEach(u => URL.revokeObjectURL(u)); dlp.blobUrls = null; }
    dlp.showing = false; dlp.images = []; dlp.idx = 0; dlp.href = null;
    dlp.chipsEl = null; dlp.panelEl = null; dlp.navEl = null; dlp.site = null;
    dlp.tags = null; dlp.tagsBarEl = null; dlp.tagsListEl = null; dlp.tagsExpanded = false;
    dlp.kpCodeEl = null;
    document.removeEventListener('mousemove', dlpMove);
    document.removeEventListener('keydown', dlpKey);
    if (dlp._touchHide) { document.removeEventListener('touchstart', dlp._touchHide, true); dlp._touchHide = null; }
    // W/S 미리보기 바깥 클릭 시 해제용 리스너 정리
    if (dlp._kpClickHide) { document.removeEventListener('mousedown', dlp._kpClickHide, true); dlp._kpClickHide = null; }
  }

  function dlpKey(e) {
    if (!dlp.showing) return;
    if (document.getElementById('b64d-settings') || document.getElementById('b64-dup-overlay')) return;
    if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return; // Shift+D 등 수식키 조합은 단축키로 처리
    // T: 작품 태그 펼치기/접기 (이미지 1장이어도 동작)
    if (e.code === 'KeyT' && CFG.PREVIEW_TAGS && dlp.tags && dlp.tags.length) {
      e.preventDefault(); dlp.tagsExpanded = !dlp.tagsExpanded; renderTags(); return;
    }
    if (dlp.images.length <= 1) return;
    if (e.key === 'ArrowLeft'  || e.code === 'KeyA') { e.preventDefault(); dlp.idx = (dlp.idx - 1 + dlp.images.length) % dlp.images.length; dlpUpdateImage(); }
    if (e.key === 'ArrowRight' || e.code === 'KeyD') { e.preventDefault(); dlp.idx = (dlp.idx + 1) % dlp.images.length; dlpUpdateImage(); }
  }

  function dlpShowTouch(e) {
    if (!CFG.DLSITE_PREVIEW) return;
    const card = e.currentTarget;
    // 같은 카드 두 번째 탭: 미리보기 닫고 링크 열기 허용 (preventDefault 안 함 → click 발생)
    if (dlp.showing && dlp.href === card.href) { dlpHide(); return; }
    // 첫 번째 탭: 미리보기 표시, click(링크 이동) 차단
    // touchend에서 preventDefault → 스크롤에는 영향 없음
    e.preventDefault();
    const rect = card.getBoundingClientRect();
    dlpShow({ clientX: rect.left + 4, clientY: rect.bottom, currentTarget: card });
  }

  function dlpShow(e) {
    if (!CFG.DLSITE_PREVIEW) return;
    const _isKp = !!(e && e._kp);
    // 고정 모드(좌/우/가운데) W/S 미리보기 간 이동은 박스를 부수지 않고 '제자리 갱신' → 매번 껐다 켜며 로딩하는
    // 깜빡임 제거. (기존='card' 모드는 카드마다 위치가 달라 종전대로 재생성.)
    const reuse = !!(e && e._kpReuse && dlp.el && dlp.showing && dlp._kp);
    if (!reuse) {
      if (dlp.showing) dlpHide();
      dlp.showing = true;
      dlp.el = document.createElement('div');
      dlp.el.id = 'b64d-dlsite-preview';
      dlp.el.setAttribute('data-b64d', '');  // MO schedule() 재트리거 방지
      dlp.el.innerHTML = '<div class="dlp-spinner"></div>';
      // 패널 구조 (persistent: innerHTML 초기화 후 재부착)
      dlp.panelEl = document.createElement('div'); dlp.panelEl.className = 'dlp-panel';
      dlp.chipsEl = document.createElement('div'); dlp.chipsEl.className = 'dlp-chips';
      dlp.navEl   = document.createElement('div'); dlp.navEl.className   = 'dlp-nav';
      dlp.tagsBarEl  = document.createElement('div'); dlp.tagsBarEl.className  = 'dlp-tags-bar'; dlp.tagsBarEl.style.display = 'none';
      dlp.tagsBarEl.addEventListener('click', () => { dlp.tagsExpanded = !dlp.tagsExpanded; renderTags(); });
      dlp.tagsListEl = document.createElement('div'); dlp.tagsListEl.className = 'dlp-tags-list'; dlp.tagsListEl.style.display = 'none';
      dlp.tags = null; dlp.tagsExpanded = !!CFG.PREVIEW_TAGS_EXPANDED;  // '태그 기본 펼침' 설정 시 펼친 상태로 시작
      // 태그바(좌) + 페이지표기(우)를 한 줄에 배치
      const navRow = document.createElement('div'); navRow.className = 'dlp-nav-row';
      navRow.appendChild(dlp.tagsBarEl);
      navRow.appendChild(dlp.navEl);
      dlp.panelEl.appendChild(dlp.chipsEl);
      dlp.panelEl.appendChild(navRow);
      dlp.panelEl.appendChild(dlp.tagsListEl);
      document.body.appendChild(dlp.el);
      // 위치: 일반·'카드 옆(기존)'은 커서/카드 좌표 기준(dlpMove). 고정 옵션이면 화면 비율 위치에 핀.
      const _kpPin = _isKp && CFG.KP_PREVIEW_POS && CFG.KP_PREVIEW_POS !== 'card';
      if (_kpPin) applyKpFixedPos(dlp.el, CFG.KP_PREVIEW_POS, e && e.currentTarget);
      else dlpMove(e);
      // W/S로 띄운 미리보기는 마우스를 따라다니지 않도록 mousemove 추적 생략(카드 위치/고정 유지).
      if (!IS_MOBILE && !_isKp) document.addEventListener('mousemove', dlpMove);
      document.addEventListener('keydown', dlpKey);
      if (_isKp) {
        // 미리보기 '바깥' 클릭 시 해제. 현재 키 입력과 분리해 다음 틱부터 청취.
        dlp._kpClickHide = (ev) => { if (dlp.el && dlp.el.contains(ev.target)) return; dlpHide(); _clearKpNav(); };
        setTimeout(() => { if (dlp._kpClickHide) document.addEventListener('mousedown', dlp._kpClickHide, true); }, 0);
      }
      dlp.el.addEventListener('wheel', we => {
        we.preventDefault();
        if (dlp.images.length <= 1) return;
        dlp.idx = we.deltaY > 0 ? (dlp.idx+1)%dlp.images.length : (dlp.idx-1+dlp.images.length)%dlp.images.length;
        dlpUpdateImage();
      }, { passive: false });
      // 모바일: 좌우 스와이프로 이미지 전환
      if (IS_MOBILE) {
        let _swX = 0;
        dlp.el.addEventListener('touchstart', te => { _swX = te.touches[0].clientX; }, { passive: true });
        dlp.el.addEventListener('touchend', te => {
          const dx = te.changedTouches[0].clientX - _swX;
          if (Math.abs(dx) < 40 || dlp.images.length <= 1) return;
          dlp.idx = dx < 0
            ? (dlp.idx + 1) % dlp.images.length
            : (dlp.idx - 1 + dlp.images.length) % dlp.images.length;
          dlpUpdateImage();
        }, { passive: true });
      }
    } else {
      // ── 제자리 갱신: 박스·리스너·위치 유지, 새 카드의 메타/이미지만 교체. 기존 <img>는 새 이미지 로드 전까지
      //    그대로 둠(dlpUpdateImage가 오프DOM 프리로드 후 원자적 replaceChild) → 스피너/빈 화면/깜빡임 없음.
      if (dlp._pending) { dlp._pending.onload = dlp._pending.onerror = null; dlp._pending = null; }
      if (dlp.srcWatcher) { dlp.srcWatcher.disconnect(); dlp.srcWatcher = null; }
      if (dlp.blobUrls) { dlp.blobUrls.forEach(u => URL.revokeObjectURL(u)); dlp.blobUrls = null; }
      dlp.images = []; dlp.idx = 0; dlp.site = null;
      dlp.tags = null; dlp.tagsExpanded = !!CFG.PREVIEW_TAGS_EXPANDED;
      if (dlp.chipsEl) dlp.chipsEl.innerHTML = '';
      if (dlp.tagsListEl) { dlp.tagsListEl.innerHTML = ''; dlp.tagsListEl.style.display = 'none'; }
      if (dlp.tagsBarEl) dlp.tagsBarEl.style.display = 'none';
      if (dlp.panelEl) dlp.panelEl.classList.remove('has-content');
    }
    dlp._kp = _isKp;
    // 코드 라벨(생성·재사용 공통): 좌상단 작품 코드(updatePanel이 이미지 갱신마다 재부착).
    if (_isKp) {
      const _kpCode = (e.currentTarget && e.currentTarget.dataset && e.currentTarget.dataset.cardCode) || '';
      if (_kpCode) {
        if (!dlp.kpCodeEl || !dlp.el.contains(dlp.kpCodeEl)) { dlp.kpCodeEl = document.createElement('div'); dlp.kpCodeEl.className = 'dlp-kp-code'; dlp.el.appendChild(dlp.kpCodeEl); }
        dlp.kpCodeEl.textContent = _kpCode; dlp.kpCodeEl.style.display = '';
      } else if (dlp.kpCodeEl) { dlp.kpCodeEl.style.display = 'none'; }
    }

    const thisEl   = dlp.el;
    const href     = e.currentTarget.href;
    dlp.href = href; // dlpHide 시 상태 보존용
    const cardAnchor = e.currentTarget; // work→announce 폴백 시 href 업데이트용
    const steamMatch = href && href.match(/store\.steampowered\.com\/app\/(\d+)/);
    const getchuMatch = href && href.includes('getchu.com/');
    const cienMatch   = href && href.includes('ci-en.dlsite.com/');

    // 카드 DOM 교체 시 이전 위치·메타 즉시 복원 (Svelte 재렌더 등 600ms 이내 재호출 대비)
    let skipAsync = false;
    if (_dlpRestore) {
      if (_dlpRestore.href === href) {
        clearTimeout(_dlpRestoreTimer);
        dlp.images = _dlpRestore.images;
        dlp.idx    = _dlpRestore.idx;
        dlp.site   = _dlpRestore.site;
        if (_dlpRestore.blobUrls) dlp.blobUrls = _dlpRestore.blobUrls; // Getchu blob 재활용
        // 칩(별점·판매수·날짜) 즉시 복원 — 상세정보 깜빡임 방지
        for (const { text, cls } of _dlpRestore.chips) {
          const c = document.createElement('span'); c.className = cls; c.textContent = text;
          dlp.chipsEl.appendChild(c);
        }
        if (_dlpRestore.chips.length) dlp.panelEl?.classList.add('has-content');
        dlp.tags = _dlpRestore.tags || null; dlp.tagsExpanded = !!_dlpRestore.tagsExpanded; renderTags(); // 태그·펼침상태 즉시 복원
        dlpUpdateImage(); // 캐시에서 즉시 이전 위치 표시
        skipAsync = true; // 비동기 재요청 불필요 (이미지·메타 모두 유효)
      } else {
        clearTimeout(_dlpRestoreTimer);
        if (_dlpRestore.blobUrls) _dlpRestore.blobUrls.forEach(u => URL.revokeObjectURL(u));
      }
      _dlpRestore = null;
    }

    // 공통 에러 표시 헬퍼
    const showErr = msg => {
      if (thisEl !== dlp.el) return;
      renderDlpError(msg);
    };
    // 메타 칩 추가 헬퍼
    const addChip = (text, cls) => {
      if (!dlp.chipsEl || !text) return;
      const c = document.createElement('span');
      c.className = 'dlp-chip' + (cls ? ' ' + cls : '');
      c.textContent = text;
      dlp.chipsEl.appendChild(c);
      dlp.panelEl?.classList.add('has-content');
    };

    if (skipAsync) {
      // 복원 완료 — 비동기 재로딩 건너뜀 (Steam/DLsite/Getchu 공통)
    } else if (steamMatch) {
      // ── Steam: 스크린샷 + 출시일 + 리뷰 ──
      dlp.site = 'steam';
      const setMeta = (key, val) => {
        if (!dlp.chipsEl) return;
        dlp.chipsEl.dataset[key] = val;
        dlp.chipsEl.innerHTML = '';
        if (dlp.chipsEl.dataset.review) addChip(dlp.chipsEl.dataset.review, 'dlp-chip-review');
        if (dlp.chipsEl.dataset.date)   addChip(dlp.chipsEl.dataset.date, 'dlp-chip-date');
      };
      const steamFilters = CFG.PREVIEW_META ? 'screenshots,release_date' : 'screenshots';
      GM_xmlhttpRequest({
        method: 'GET',
        url: `https://store.steampowered.com/api/appdetails?appids=${steamMatch[1]}&filters=${steamFilters}`,
        timeout: 5000,
        onload(res) {
          if (thisEl !== dlp.el) return;
          try {
            const data = JSON.parse(res.responseText);
            const info = data[steamMatch[1]];
            if (info?.success && info.data?.screenshots?.length) {
              dlp.images = info.data.screenshots.map(s => s.path_thumbnail || s.path_full);
              dlp.idx = 0;
              dlpUpdateImage();
            } else {
              showErr('스크린샷 없음');
            }
            if (CFG.PREVIEW_META && info?.data?.release_date?.date) setMeta('date', info.data.release_date.date);
          } catch { showErr('미리보기 로드 실패'); }
        },
        onerror()   { showErr('연결 실패'); },
        ontimeout() { showErr('응답 없음'); },
      });
      if (CFG.PREVIEW_META) GM_xmlhttpRequest({
        method: 'GET',
        url: `https://store.steampowered.com/appreviews/${steamMatch[1]}?json=1&language=all`,
        timeout: 5000,
        onload(res) {
          if (thisEl !== dlp.el) return;
          try {
            const s = JSON.parse(res.responseText)?.query_summary;
            if (s?.review_score_desc) {
              setMeta('review', `${s.review_score_desc} (${(s.total_positive||0).toLocaleString()}/${(s.total_reviews||0).toLocaleString()})`);
            }
          } catch {}
        },
      });
    } else if (getchuMatch) {
      // ── Getchu: 페이지 파싱 → 이미지 GM_xmlhttpRequest 프록시(Referer 우회) ──
      dlp.site = 'getchu';
      dlp.blobUrls = [];
      const gcIdMatch = href.match(/\/item\/(\d+)|[?&]id=(\d+)/);
      const gcId = gcIdMatch?.[1] || gcIdMatch?.[2];

      // pairs(이미지 URL 목록) → 실제 다운로드·표시. 캐시 적중/페이지 파싱이 공유하는 경로.
      const loadGetchuPairs = (pairs) => {
        if (thisEl !== dlp.el) return;
        const rawUrls = pairs.map(p => p.full);
        const blobSlots = new Array(rawUrls.length).fill(null); // null=대기, false=실패, string=blob URL
        let doneCount = 0;
        let thumbShown = false;

        const maybeUpdate = () => {
          if (thisEl !== dlp.el) return;
          const avail = blobSlots.filter(u => typeof u === 'string');
          // 풀사이즈가 생겼거나, 썸네일만 표시 중이었다면 교체
          if (avail.length > 0 && (avail.length > dlp.images.length || thumbShown)) {
            dlp.images = avail; dlp.idx = Math.min(dlp.idx, Math.max(0, avail.length - 1));
            thumbShown = false;
            dlpUpdateImage();
          }
          if (doneCount === rawUrls.length && !dlp.images.length) showErr('이미지를 불러올 수 없습니다.');
        };

        // 첫 번째 이미지 썸네일 빠른 표시 (소용량, Referer 우회)
        const firstThumb = pairs[0].thumb;
        const firstFull  = pairs[0].full;
        if (firstThumb !== firstFull) {
          GM_xmlhttpRequest({
            method: 'GET', url: firstThumb,
            responseType: 'arraybuffer',
            headers: { 'Referer': 'https://www.getchu.com/' },
            timeout: 8000,
            onload(r) {
              // 풀사이즈가 이미 도착했으면 스킵
              if (thisEl !== dlp.el || blobSlots[0] !== null) return;
              const ct = (r.responseHeaders||'').match(/content-type:\s*([^\r\n;]+)/i)?.[1]?.trim() || 'image/jpeg';
              const burl = URL.createObjectURL(new Blob([r.response], { type: ct }));
              if (dlp.blobUrls) dlp.blobUrls.push(burl);
              if (!dlp.images.length) {
                dlp.images = [burl]; dlp.idx = 0;
                thumbShown = true;
                dlpUpdateImage();
              }
            },
          });
        }

        // 풀사이즈 전체 동시 다운로드
        rawUrls.forEach((url, i) => {
          GM_xmlhttpRequest({
            method: 'GET', url,
            responseType: 'arraybuffer',
            headers: { 'Referer': 'https://www.getchu.com/' },
            timeout: 12000,
            onload(r) {
              if (thisEl !== dlp.el) { blobSlots[i] = false; doneCount++; return; }
              const ct = (r.responseHeaders||'').match(/content-type:\s*([^\r\n;]+)/i)?.[1]?.trim() || 'image/jpeg';
              const burl = URL.createObjectURL(new Blob([r.response], { type: ct }));
              blobSlots[i] = burl;
              if (dlp.blobUrls) dlp.blobUrls.push(burl);
              doneCount++; maybeUpdate();
            },
            onerror()  { blobSlots[i] = false; doneCount++; maybeUpdate(); },
            ontimeout(){ blobSlots[i] = false; doneCount++; maybeUpdate(); },
          });
        });
      };

      // 캐시 적중: 페이지 fetch 생략 (재호버 시 왕복 1회 절약). 이미지는 캐시하지 않고 매번 새로 받음.
      const cachedPairs = gcId ? _gcPageCache.get(gcId) : null;
      if (cachedPairs) {
        loadGetchuPairs(cachedPairs);
      } else {
        const fetchUrl = gcId ? `https://www.getchu.com/soft.phtml?id=${gcId}` : href;
        GM_xmlhttpRequest({
          method: 'GET', url: fetchUrl,
          headers: { 'Referer': 'https://www.getchu.com/' },
          timeout: 10000,
          onload(res) {
            if (thisEl !== dlp.el) return;
            const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
            const pairs = []; // [{full, thumb}]
            doc.querySelectorAll('a.highslide').forEach(a => {
              const im = a.querySelector('img');
              let full = a.getAttribute('href') || '';
              if (!full || !/\.(jpe?g|png|gif|webp)/i.test(full)) {
                full = im ? (im.getAttribute('src') || '').replace(/_s(\.(jpe?g|png|gif|webp))$/i, '$1') : '';
              }
              if (!full) return;
              if (full.startsWith('/')) full = 'https://www.getchu.com' + full;
              if (!/^https?:/.test(full)) return;
              let thumb = im ? (im.getAttribute('src') || '') : full;
              if (thumb.startsWith('/')) thumb = 'https://www.getchu.com' + thumb;
              if (!pairs.some(p => p.full === full)) pairs.push({ full, thumb });
            });
            pairs.sort((a, b) => (b.full.includes('package') ? 1 : 0) - (a.full.includes('package') ? 1 : 0));
            if (!pairs.length) { showErr('이미지를 찾을 수 없습니다.'); return; }
            // 파싱 결과 캐시 (URL 문자열만, 상한 초과 시 FIFO 제거)
            if (gcId) {
              if (_gcPageCache.size >= GC_CACHE_MAX) _gcPageCache.delete(_gcPageCache.keys().next().value);
              _gcPageCache.set(gcId, pairs);
            }
            loadGetchuPairs(pairs);
          },
          onerror()   { showErr('미리보기 로드 실패'); },
          ontimeout() { showErr('응답 없음'); },
        });
      }
    } else if (cienMatch) {
      // ── Ci-en (ci-en.dlsite.com) ──
      dlp.site = 'cien';
      GM_xmlhttpRequest({
        method: 'GET', url: href,
        headers: { 'Referer': 'https://ci-en.dlsite.com/' },
        timeout: 10000,
        onload(res) {
          if (thisEl !== dlp.el) return;
          const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
          const seen = new Set();
          const imgs = [];
          const push = u => { if (u && u.startsWith('http') && !seen.has(u)) { seen.add(u); imgs.push(u); } };

          // 1. SSR JSON 스크립트 내 URL 추출 (Nuxt __NUXT_DATA__ 등)
          for (const s of doc.querySelectorAll('script')) {
            const txt = s.textContent || '';
            if (!txt.includes('media.ci-en.jp')) continue;
            for (const m of txt.matchAll(/https:\\?\/\\?\/media\.ci-en\.jp\\?\/[^"'\s\\]+\.(?:jpg|jpeg|png|gif|webp)[^"'\s\\]*/gi)) {
              push(m[0].replace(/\\\/|\\u002[Ff]/g, '/').split('"')[0]);
            }
          }

          // 2. img src / data-src
          doc.querySelectorAll('img[src*="media.ci-en.jp"], img[data-src*="media.ci-en.jp"], [data-src*="media.ci-en.jp"]').forEach(el => {
            push(el.getAttribute('src') || '');
            push(el.dataset?.src || '');
          });

          // private attachment 우선, 없으면 article_cover
          const attachments = imgs.filter(u => u.includes('/attachment/'));
          const covers      = imgs.filter(u => u.includes('/article_cover/'));
          const final = attachments.length ? attachments : covers;

          if (final.length) {
            dlp.images = final; dlp.idx = 0; dlpUpdateImage();
          } else {
            showErr('이미지 없음 (비공개 게시물이거나 로그인 필요)');
          }
        },
        onerror()   { showErr('미리보기 로드 실패'); },
        ontimeout() { showErr('응답 없음'); },
      });
    } else {
      // ── DLsite ──
      dlp.site = 'dlsite';
      let smpActive = true;
      let smpStarted = false;

      const dlsiteCodeMatch = href.match(/product_id\/((?:RJ|BJ|VJ|RE|BE|VE)\w+)/i);
      const sectionMatch    = href.match(/dlsite\.com\/(\w+)\//);
      const code    = dlsiteCodeMatch ? dlsiteCodeMatch[1].toUpperCase() : null;
      const section = sectionMatch    ? sectionMatch[1]                  : null;

      // smp probe — 호출측(guess 또는 info API)에 관계없이 최초 1회만 실행
      const startSmpProbes = (workImgUrl) => {
        if (smpStarted) return;
        smpStarted = true;
        if (!smpActive || !workImgUrl || thisEl !== dlp.el) return;
        const smpStem = workImgUrl.replace(/_img_main(\.\w+)$/, '_img_smp');
        const smpExt  = (workImgUrl.match(/_img_main(\.\w+)$/) || ['', '.jpg'])[1];
        if (smpStem === workImgUrl) return;
        const SMP_MAX = 12;
        const smpSlots = new Array(SMP_MAX).fill(null);
        const addSmpOrdered = () => {
          if (!smpActive || thisEl !== dlp.el) return;
          for (let i = 0; i < SMP_MAX; i++) {
            if (smpSlots[i] === null) break;
            if (smpSlots[i] === true) {
              const u = `${smpStem}${i + 1}${smpExt}`;
              if (!dlp.images.includes(u)) dlp.images.push(u);
            }
          }
          dlpUpdateNav();
        };
        for (let i = 0; i < SMP_MAX; i++) {
          const probe = new Image();
          probe.referrerPolicy = 'no-referrer';
          probe.onload  = () => { smpSlots[i] = true;  addSmpOrdered(); };
          probe.onerror = () => { smpSlots[i] = false; addSmpOrdered(); };
          probe.src = `${smpStem}${i + 1}${smpExt}`;
        }
      };

      // ── FAST PATH: CDN URL 즉시 예측 (API 응답 없이 ~0.5s) ──
      // img.dlsite.jp/modpub/images2/work/{cat}/{prefix}{floor/1000*1000}/{code}_img_main.jpg
      if (code) {
        const codeNum    = parseInt(code.replace(/^[A-Z]+/, ''), 10);
        const codePrefix = code.replace(/\d+$/, '');
        const codePad    = String(codeNum).padStart(8, '0');
        const dirPad     = codePrefix + String(Math.floor(codeNum / 1000) * 1000).padStart(8, '0');
        let guessShown   = false;
        for (const cat of ['doujin', 'professional', 'translation', 'books']) {
          const guessUrl = `https://img.dlsite.jp/modpub/images2/work/${cat}/${dirPad}/${codePrefix}${codePad}_img_main.jpg`;
          const p = new Image();
          p.referrerPolicy = 'no-referrer';
          p.onload = () => {
            if (guessShown || thisEl !== dlp.el) return;
            guessShown = true;
            if (!dlp.images.length) { dlp.images = [guessUrl]; dlp.idx = 0; dlpUpdateImage(); }
            startSmpProbes(guessUrl);
          };
          p.src = guessUrl;
        }
      }

      // ── HTML 페이지 (확정 이미지 리스트, work→announce 자동 폴백) ──
      const isWorkUrl = href.includes('/work/=/');
      const fetchDlsiteHtml = (url, isRetry) => {
        GM_xmlhttpRequest({
          method: 'GET', url,
          headers: { 'User-Agent': navigator.userAgent, 'Referer': 'https://www.dlsite.com/' },
          timeout: 15000,
          onload(res) {
            if (thisEl !== dlp.el) return;
            // work URL 4xx → announce 재시도 (smpActive 유지)
            if (!isRetry && isWorkUrl && res.status >= 400) {
              fetchDlsiteHtml(href.replace('/work/=/', '/announce/=/'), true);
              return;
            }
            smpActive = false;
            const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
            // 공식 장르 태그 추출 (href에 work.genre 포함) — 추가 요청 없이 작품 HTML에서 그대로
            if (CFG.PREVIEW_TAGS && (!dlp.tags || !dlp.tags.length)) {
              const tg = [...new Set([...doc.querySelectorAll('a[href*="work.genre"]')].map(el => (el.textContent || '').trim()).filter(Boolean))];
              if (tg.length) { dlp.tags = tg; renderTags(); }
            }
            const imgs = [];
            for (const sel of ['.product-slider-data > div[data-src]', '.main-image-slider img', '.product-slider img', 'img[src*="img.dlsite.jp"]:not([src*="/resize/"])', 'img[src*="img.dlsite.com"]']) {
              doc.querySelectorAll(sel).forEach(el => {
                const rawSrc = el.dataset.src || el.getAttribute('src') || '';
                const src = rawSrc.startsWith('//') ? 'https:' + rawSrc : rawSrc;
                if (src && /^https?:\/\//.test(src) && !src.includes('/resize/') && !imgs.includes(src)) imgs.push(src);
              });
              if (imgs.length) break;
            }
            if (imgs.length) {
              // announce 폴백 성공 → 카드 링크도 announce URL로 갱신
              if (isRetry) try { cardAnchor.href = url; } catch(_) {}
              const prevSrc = dlp.images[dlp.idx] || '';
              const prevIdx = dlp.idx;
              // 파일명 기반 매칭: CDN 도메인(img.dlsite.jp vs .com)·쿼리스트링 차이 허용
              const fnOf = u => { const p = u.split('/').pop(); return p ? p.split('?')[0] : ''; };
              const prevFn = fnOf(prevSrc);
              const keepIdx = prevFn ? imgs.findIndex(u => fnOf(u) === prevFn) : -1;
              if (keepIdx !== -1) {
                // 파일명 매칭 성공 → HTML 목록으로 교체
                // 이미 로드된 URL은 그대로 유지해 불필요한 재로드 방지
                const fnMap = new Map(dlp.images.map(u => [fnOf(u), u]));
                dlp.images = imgs.map(u => fnMap.get(fnOf(u)) || u);
                dlp.idx = keepIdx;
                dlpUpdateImage();
              } else if (imgs.length < dlp.images.length) {
                // HTML이 SMP보다 적게 반환(JS 로딩 페이지 등) → 기존 목록 유지
                // 진짜 새 파일만 뒤에 추가
                let added = false;
                for (const u of imgs) {
                  if (!dlp.images.some(e => fnOf(e) === fnOf(u))) { dlp.images.push(u); added = true; }
                }
                if (added) dlpUpdateNav();
              } else {
                // 완전히 다른 이미지 목록이고 HTML이 더 크거나 같음 → 교체 + 인덱스 클램프
                // (imgs.length >= dlp.images.length > prevIdx이므로 실제로 클램프 발생 안 함)
                dlp.images = imgs;
                dlp.idx = Math.min(prevIdx, imgs.length - 1);
                dlpUpdateImage();
              }
            } else if (!isRetry && isWorkUrl) {
              // 200이지만 이미지 없음(출시 전 work 페이지 등) → announce 재시도
              smpActive = true; // 재시도 동안 smp 유지
              fetchDlsiteHtml(href.replace('/work/=/', '/announce/=/'), true);
            } else if (!dlp.images.length) {
              showErr('이미지를 찾을 수 없습니다.');
            }
          },
          onerror() {
            if (!isRetry && isWorkUrl) {
              fetchDlsiteHtml(href.replace('/work/=/', '/announce/=/'), true);
            } else {
              smpActive = false;
              if (!dlp.images.length) showErr('미리보기 로드 실패');
            }
          },
          ontimeout() { smpActive = false; if (!dlp.images.length) showErr('응답 없음 (15s)'); },
        });
      };
      fetchDlsiteHtml(href, false);

      // ── Info API (메타 정보 + fast path 실패 시 fallback) ──
      if (code && section) {
        GM_xmlhttpRequest({
          method: 'GET',
          url: `https://www.dlsite.com/${section}/product/info/ajax?product_id=${code}`,
          timeout: 8000,
          onload(res) {
            if (thisEl !== dlp.el) return;
            try {
              const info = JSON.parse(res.responseText)?.[code];
              if (!info) return;
              if (info.work_image && !dlp.images.length) {
                const workImg = info.work_image.startsWith('//') ? 'https:' + info.work_image : info.work_image;
                dlp.images = [workImg]; dlp.idx = 0; dlpUpdateImage();
              }
              const wi = info.work_image ? (info.work_image.startsWith('//') ? 'https:' + info.work_image : info.work_image) : null;
              startSmpProbes(wi);
              if (CFG.PREVIEW_META && dlp.chipsEl) {
                if (info.rate_average_2dp != null) addChip(`★${Number(info.rate_average_2dp).toFixed(2)}${info.rate_count ? ` (${info.rate_count})` : ''}`, 'dlp-chip-star');
                if (info.dl_count        != null) addChip(`판매 ${Number(info.dl_count).toLocaleString()}건`, 'dlp-chip-sales');
                if (info.regist_date)              addChip(info.regist_date.slice(0, 10), 'dlp-chip-date');
              }
            } catch {}
          },
        });
      }
    }

    const srcEl = e.currentTarget;
    if (IS_MOBILE) {
      const th = te => { if (dlp.el && dlp.el.contains(te.target)) return; if (srcEl.contains(te.target)) return; dlpHide(); };
      dlp._touchHide = th;
      document.addEventListener('touchstart', th, true);
    } else if (!_isKp) {
      // W/S 미리보기는 호버 기반이 아니므로 카드 mouseleave로 닫지 않음(바깥 클릭·이동으로 해제).
      // 제자리 갱신(reuse) 시 죽은 {once} 리스너가 카드마다 쌓이는 것도 방지.
      srcEl.addEventListener('mouseleave', dlpHide, { once: true });
    }

    // srcEl이 DOM에서 제거될 경우(OG 프리뷰 → 카드 교체 등) 즉시 숨김
    const watchParent = srcEl.parentNode || document.body;
    dlp.srcWatcher = new MutationObserver(() => {
      if (!document.contains(srcEl)) dlpHide();
    });
    dlp.srcWatcher.observe(watchParent, { childList: true, subtree: true });
  }

  /* ================================================================
     하이퍼링크 → 상품 카드 변환
     href 또는 링크 텍스트에 DLsite/Steam 정보가 있는 <a>를 카드로 교체.
     교체 후 preview 훅도 자동으로 달린다(makeDlsiteCard/makeSteamCard 내부 처리).
  ================================================================ */
  function convertProductLinks(contentRoots, _pass = 0) {
    if (!CFG.LINK_CARD) return;
    if (!contentRoots.size) return; // 본문 루트 미매칭 = kone.gg 본문 페이지 아님

    // contentRoots 내 링크만 처리 (OG 프리뷰 등 외부 중복 방지, 동일 ID 중복 카드 방지)
    const seen = new Set();
    contentRoots.forEach(root => {
      root.querySelectorAll('a').forEach(a => {
        if (a.hasAttribute(LTDONE_ATTR)) return;
        if (a.hasAttribute(DONE_ATTR)) {
          // 이 <a>로 만든 카드가 아직 살아있나? 두 가지로 확인:
          //  · _cardMap(노드 신원): 같은 <a> 노드가 유지될 때.
          //  · _liveCards(키 기준): 코네가 <a>를 '새 노드'로 갈아치워 _cardMap이 끊겨도, <a>에 박아둔
          //    dataset.b64key로 그 키의 카드가 DOM에 살아있는지 확인(중복으로 숨긴 <a>도 같은 키 공유).
          // 둘 다 죽었으면 → DONE 해제 후 재생성(다운로드 카드가 코네 재렌더로 사라지던 문제의 핵심 보강).
          const prevCard = _cardMap.get(a);
          const ownAlive = prevCard && document.contains(prevCard);
          // '과거 카드였던 <a>'(b64key 보유 또는 _cardMap 등록)만 부활 대상. buildNodes가 만든
          // 평문 DONE 앵커(중복 lkSkip 링크·미지원 호스트 링크)는 카드였던 적이 없어 b64key가 없다.
          // 이들의 DONE을 벗기면 hasProcessedAncestor 완화 이후 재카드화·숨김되어 텍스트가 증발하므로,
          // 카드 이력이 없으면 손대지 않고 그대로 둔다(현 위치 텍스트 링크 유지).
          const hadCard = !!prevCard || !!a.dataset.b64key;
          if (hadCard && !ownAlive && !_cardKeyAlive(a.dataset.b64key)) {
            a.removeAttribute(DONE_ATTR);
            a.style.removeProperty('display');
            _cardMap.delete(a);
          } else {
            return;
          }
        }
        if (hasProcessedAncestor(a)) return;
        if (!a.parentNode) return;
        // 자식 요소가 있는 <a>: kone.gg OG 프리뷰 카드(not-prose + h-24)
        // → dlsite/steam 등 지원 상품이면 우리 카드로 '대체'(평문/base64/점자와 동일하게 카드화),
        //   그 외 지원 사이트면 링크 카드로, 미지원은 종전대로 숨김만. (그래야 OG 임베드 URL도 카드가 됨)
        if (a.firstElementChild) {
          if (a.classList.contains('not-prose') && a.classList.contains('h-24')) {
            const ogHref = a.href || '';
            let ogCard = null, ogKey = null;
            try {
              const h = new URL(ogHref).hostname;
              if (CFG.LINK_CARD && !/kone\.gg$/.test(h)) {
                if (h.includes('dlsite.com')) {
                  const c = dlsiteCodeOf(ogHref);
                  if (c) ogKey = 'dl:' + c;
                } else if (h === 'store.steampowered.com') {
                  const aId = steamAppOf(ogHref); if (aId) ogKey = 'st:' + aId;
                } else {
                  // fanbox/dmm 등: 평문 카드 키(convertProductLinks의 'cr:'+href)와 통일 → 같은 작품의
                  // '평문 링크 + OG 프리뷰'가 카드 2개로 중복 생성되던 문제 방지(수정: fanbox 중복).
                  ogKey = 'cr:' + ogHref.split(/[?#]/)[0];
                }
                const known = isKnownCardHost(h); // 단일 소스 (kone.gg는 상위 조건에서 이미 배제)
                if (known) {
                  // OG 프리뷰는 평문 링크의 '부가 표시'이므로 NO_DUP과 무관하게 항상 중복 제거한다
                  // (평문 카드가 이미 있으면 OG는 카드화하지 않고 숨김만 — 코네 OG는 CSS로 어차피 숨겨짐).
                  const dup = ogKey && (seen.has(ogKey) || _seenProductCodes.has(ogKey));
                  if (!dup) {
                    ogCard = makeLinkCard(ogHref);
                    if (ogKey) { seen.add(ogKey); if (CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(ogKey); }
                  }
                }
              }
            } catch (e) {}
            a.setAttribute(DONE_ATTR, '');
            // 키↔카드 등록: 하이드레이션이 OG <a>(또는 카드)를 떼어내도 키 기준으로 생존확인·재생성.
            // 숨긴 중복 OG <a>도 같은 키를 박아 둬야 아래 NO_DUP 안전망이 되살릴 수 있다.
            if (ogKey) a.dataset.b64key = ogKey;
            a.style.setProperty('display', 'none', 'important');
            if (ogCard) {
              ogCard.setAttribute(ORIG_ATTR, a.outerHTML);
              a.parentNode.insertBefore(ogCard, a);
              _cardMap.set(a, ogCard);
              if (ogKey) { ogCard.dataset.b64key = ogKey; _liveCards.set(ogKey, ogCard); }
            }
            return;
          }
          // OG(not-prose h-24)가 아닌데 자식 요소가 있는 <a>: 코네가 URL에 인라인 서식(<u>/<b>/<i>/
          // <mark> 등)을 입히면 <a> 안에 그 태그가 들어가 firstElementChild가 생긴다. 과거엔 여기서
          // 무조건 return → '밑줄 친 transfer.it URL' 등이 카드화되지 않았다(수정3: 드래그하면 되던 이유는
          // 드래그가 convertProductLinks가 아닌 processContentNode 경로라서). 미디어 임베드(img/video 등)만
          // 아니면 일반 링크로 보고 아래 href 기반 카드화를 계속한다(미디어 포함은 특수 임베드라 스킵).
          if (a.querySelector('img, picture, video, iframe, svg')) return;
        }

        let href = a.href || '';
        // 리디렉션 래퍼(google.com/url?url=…)는 진짜 목적지로 해석 (수정사항8)
        href = resolveRedirect(href);
        // ★사이트 판정은 href 문자열 검색이 아니라 hostname으로 한다. 과거엔 href.includes('dmm.co.jp')
        //   식이라, google.com/url?…&url=https%3A%2F%2Fwww.dmm.co.jp%2F… 같은 리디렉션 링크의 '쿼리에
        //   든' 도메인까지 걸려 엉뚱한 작품 카드가 생성됐다(수정사항8). hostname 기준이면 쿼리 무관.
        let hHost = '';
        try { hHost = new URL(href).hostname; } catch(e) {}
        const hBase = hHost.replace(/^www\./, '');
        // kone.gg 내부 링크 → 절대 카드로 변환하지 않음
        if (/kone\.gg$/.test(hHost)) return;
        let card = null;
        let seenKey = null;

        // ── hostname 기준 ──
        if (hHost.includes('dlsite.com')) {
          // www.dlsite.com: /product_id/RJxxx, ch.dlsite.com: /VJxxx 또는 /product/VJxxx
          const dlCode = dlsiteCodeOf(href);
          if (dlCode) {
            seenKey = 'dl:' + dlCode;
            card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeDlsiteCard(dlCode);
            if (card && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(seenKey);
          } else if (CFG.LINK_CARD) {
            // 작품 코드가 없는 DLsite URL 중 '/fsr/ 검색·장르 링크'(칩)와 'ci-en.dlsite.com'(작가 페이지
            // 카드)만 처리. 그 외(홈·카테고리·검색 등 '작품이 아닌 단순 페이지')는 카드화하지 않고 원래
            // 링크를 그대로 둔다(사용자 결정: ci-en만 유지).
            const isFsr = /\/fsr\//i.test(href);
            if (!isFsr && hHost !== 'ci-en.dlsite.com') return;
            // ★fsr 칩은 GENRE_BAR ON이면 인라인으로 안 만들고 건너뜀 → buildGenreBar가 본문 하단 바로 모음
            //   (인라인 칩은 코네 work.genre 재렌더마다 배수 증식했음, 요구1). ci-en 카드는 영향 없음.
            if (isFsr && CFG.GENRE_BAR) return;
            // fsr(칩)·ci-en(카드) → makeLinkCard가 각각 반환. _cardKeyAlive로 교차 flush 중복 차단.
            seenKey = 'dl:' + href.split(/[?#]/)[0];
            // ★수정4 근본: NO_DUP ON일 때, 이 키의 칩/카드가 '이미 DOM에 살아있으면'(코네가 genre <a>를
            //   flush마다 새 노드로 재렌더해도) 새로 만들지 않고 <a>만 숨긴다. _seenProductCodes는 flush마다
            //   비워지므로 그것만으로는 재렌더 누적을 못 막았다 — _cardKeyAlive(_liveCards, flush 넘어 유지)로
            //   교차 flush 중복을 차단. (GENRE_BAR OFF 상태 장르 칩 증식의 실제 원인.)
            if (CFG.NO_DUPLICATE_CARD && !seen.has(seenKey) && _cardKeyAlive(seenKey)) {
              a.setAttribute(DONE_ATTR, '');
              a.dataset.b64key = seenKey;
              a.style.setProperty('display', 'none', 'important');
              return;
            }
            const dupTag = seen.has(seenKey) || _seenProductCodes.has(seenKey);
            card = dupTag ? null : makeLinkCard(href);
            if (card) { seen.add(seenKey); _seenProductCodes.add(seenKey); }
          }
        } else if (hHost === 'store.steampowered.com' && href.includes('/app/')) {
          const appId = steamAppOf(href);
          if (appId) {
            seenKey = 'st:' + appId;
            card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeSteamCard(appId);
            if (card && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(seenKey);
          }
        } else if (hBase === 'patreon.com') {
          try {
            const u = new URL(href);
            if (u.pathname.length > 1) {
              seenKey = 'pt:' + href.split(/[?#]/)[0];
              card = (CFG.NO_DUPLICATE_CARD && seen.has(seenKey)) ? null : makePatreonCard(href);
            }
          } catch(e) {}
        } else if (hHost.includes('getchu.com') || hHost.includes('getcu.com')) {
          const gcDigits = getchuIdOf(href);
          if (gcDigits) {
            seenKey = 'cr:GC' + gcDigits;
            if (CFG.NO_DUPLICATE_CARD && _gcSeenInFlush.has(gcDigits)) {
              // 텍스트 GC 코드로 이미 카드 생성됨 → <a> 숨김만
              a.setAttribute(DONE_ATTR, '');
              a.style.setProperty('display', 'none', 'important');
              return;
            }
            card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeGetcuCard('GC' + gcDigits, href);
            if (card) _gcSeenInFlush.add(gcDigits);
          } else {
            seenKey = 'lk:' + href.split(/[?#]/)[0];
            card = (CFG.NO_DUPLICATE_CARD && seen.has(seenKey)) ? null : makeLinkCard(href);
          }
        } else if (hHost.includes('dmm.co.jp') || hBase === 'fanza.jp' || hHost.endsWith('fanbox.cc')) {
          seenKey = 'cr:' + href.split(/[?#]/)[0];
          card = (CFG.NO_DUPLICATE_CARD && seen.has(seenKey)) ? null : makeLinkCard(href);
        }

        // ── href가 알려진 다운로드/파일 공유 사이트 → 일반 링크 카드 (★ 텍스트 코드 검색보다 먼저!) ──
        // 다운로드 URL 경로에 우연히 상품 코드꼴(예: gofile.io/d/Ve123456 → 'VE123456',
        // workupload.com/file/BE12345678, mypikpak.com/s/VJ…, transfer.it/t/ST…)이 들어가면
        // 아래 텍스트 기반 검색이 DLsite/Steam 상품으로 '오분류' → NO_DUP에서 그 가짜 코드가 중복 판정되면
        // card=null이 되며 다운로드 카드조차 안 생기던 문제. href가 다운로드 사이트면 여기서 먼저 확정.
        if (!card && !seenKey && href) {
          try {
            const u = new URL(href);
            // 경로가 없는 사이트 루트 URL (예: https://transfer.it/) → 카드 생성 안 함
            if (u.pathname.length <= 1) return;
            if (isDownloadHost(u.hostname)) {
              // 다운로드 카드는 'flush 내 중복(seen)'만으로 제거. _seenProductCodes(교차/페이지) 억제는
              // 쓰지 않는다 — 그게 첫 다운로드 카드까지 취소시켜 '카드 자체가 안 생기던' 원인이었음.
              seenKey = 'lk:' + href.split(/[?#]/)[0];
              card = (CFG.NO_DUPLICATE_CARD && seen.has(seenKey)) ? null : makeLinkCard(href);
            }
          } catch(e) {}
        }

        // ── href로 분류 안 된 경우만 링크 텍스트에서 코드 탐색 (다운로드 href는 위에서 처리됨) ──
        if (!card && !seenKey) {
          const t = a.textContent.trim();
          DLSITE_LATIN_RE.lastIndex = 0; const dlLatin = DLSITE_LATIN_RE.exec(t);
          DLSITE_KR_RE.lastIndex   = 0; const dlKr    = DLSITE_KR_RE.exec(t);
          STEAM_CODE_RE.lastIndex  = 0; const st      = STEAM_CODE_RE.exec(t);
          CREATOR_CODE_RE.lastIndex = 0; const cr     = CREATOR_CODE_RE.exec(t);
          if (dlLatin)   { seenKey = 'dl:' + (dlLatin[1]+dlLatin[2]).toUpperCase(); card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeDlsiteCard((dlLatin[1]+dlLatin[2]).toUpperCase()); }
          else if (dlKr) { seenKey = 'dl:RJ' + dlKr[2]; card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeDlsiteCard('RJ' + dlKr[2]); }
          else if (st)   { seenKey = 'st:' + st[1]; card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeSteamCard(st[1]); }
          else if (cr) {
            const crCode = (cr[1]+cr[2]).toUpperCase();
            seenKey = 'cr:' + crCode;
            const crGcD = crCode.startsWith('GC') ? crCode.slice(2) : null;
            const crGcDup = CFG.NO_DUPLICATE_CARD && crGcD !== null && _gcSeenInFlush.has(crGcD);
            if (crGcDup) {
              a.setAttribute(DONE_ATTR, ''); a.style.setProperty('display', 'none', 'important'); return;
            }
            card = (CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) ? null : makeCreatorCard(crCode);
            if (card && crGcD !== null) _gcSeenInFlush.add(crGcD);
          }
        }

        if (card) {
          if (seenKey && CFG.NO_DUPLICATE_CARD) _seenProductCodes.add(seenKey);
          if (seenKey) seen.add(seenKey);
          card.setAttribute(ORIG_ATTR, a.outerHTML);
          a.setAttribute(DONE_ATTR, '');
          if (seenKey) { a.dataset.b64key = seenKey; card.dataset.b64key = seenKey; _liveCards.set(seenKey, card); } // 키↔카드 등록(재생성·중복정리 판단)
          a.style.setProperty('display', 'none', 'important');
          a.parentNode.insertBefore(card, a);
          _cardMap.set(a, card); // 카드 제거 감지용 (Svelte 재렌더링 대응)
        } else if (seenKey && CFG.NO_DUPLICATE_CARD && (seen.has(seenKey) || _seenProductCodes.has(seenKey))) {
          // NO_DUP ON: 같은 작품/링크의 2번째+ 등장(또는 processContentNode 선행 카드와 중복).
          // ★과거엔 display:none으로 숨겼는데, 표 셀/단독 단락이 그 링크 하나뿐이면 빈칸(깨진 표)으로
          //   남았다(⑥). 사용자 의도: 숨기지 말고 '보이는 평문 링크'로 둔다(buildNodes dup·다운로드
          //   링크와 일관). b64key는 유지 → 카드가 소멸하면 위 DONE-부활 로직이 이 링크를 카드화한다.
          //   (NO_DUP OFF면 위 card 분기에서 매 등장 카드화되어 여기 안 옴 = 표/평문도 카드 생성.)
          a.setAttribute(DONE_ATTR, '');
          a.dataset.b64key = seenKey;
        }
      });
    });

    // ── NO_DUP 하이드레이션 안전망(★ '카드 통째로 빔' 근본 대응) ──
    // 핵심 원인: 코네(SvelteKit) 하이드레이션이 이전 렌더 트리를 'display:none'으로 남겨둔다(=DOM에는
    // 있지만 안 보임). 그 안의 카드가 document.contains()로는 '살아있다'고 잡혀 NO_DUP이 '보이는' 새 <a>를
    // 억제 → 결국 보이는 카드가 0개. (OFF면 형태별로 다 만들어 보이는 트리에도 카드가 남아 정상.)
    // 그래서 '문서 연결'이 아니라 '실제 가시성(offsetParent)' 기준으로 키별 '보이는 카드 정확히 1개'를 강제.
    if (CFG.NO_DUPLICATE_CARD && _pass < 2) {
      const isVis = el => el.isConnected && el.offsetParent !== null; // display:none 서브트리/분리 카드 = 안 보임
      const cardsByKey = new Map();   // key → 우리 카드[] (보임/숨김 혼재)
      const anchorsByKey = new Map(); // key → 숨긴 원본 <a>[] (카드로 교체돼 display:none)
      const push = (m, k, v) => { let a = m.get(k); if (!a) m.set(k, a = []); a.push(v); };
      contentRoots.forEach(root => {
        // ★.b64-dlsite-tag(장르 칩)도 '카드'로 계수한다(수정4). 빠져 있으면 안전망이 '이 키엔 보이는
        //   카드 없음'으로 오판해 숨긴 원본 <a>를 되살리고, 그게 재칩화되어 flush마다 장르 칩이 증식했다.
        root.querySelectorAll('.b64-product-link[data-b64key], .b64-link[data-b64key], .b64-dlsite-tag[data-b64key]').forEach(c => {
          if (c.closest('#b64d-link-panel')) return;   // 모아보기 패널 복제본 제외
          if (c.dataset.b64key) push(cardsByKey, c.dataset.b64key, c);
        });
        root.querySelectorAll('a[data-b64key]:not(.b64-product-link):not(.b64-link)').forEach(a => {
          if (a.style.display !== 'none' || a.closest('#b64d-link-panel')) return;
          if (a.dataset.b64key) push(anchorsByKey, a.dataset.b64key, a);
        });
      });
      // (1) 보이는 중복 카드 정리: 키별 '보이는 카드'가 2개+면 첫 개만 남기고 제거(짧은 틈에 둘 생긴 경우).
      cardsByKey.forEach(cards => { const vis = cards.filter(isVis); vis.slice(1).forEach(c => c.remove()); });
      // (2) 보이는 카드 0개인데 숨긴 원본 <a>가 있으면 '보이는 트리'의 <a> 하나를 되살려 재카드화.
      let revived = false;
      anchorsByKey.forEach((anchors, key) => {
        if ((cardsByKey.get(key) || []).some(isVis)) return; // 이미 보이는 카드 있음
        const a = anchors.find(x => x.parentElement && x.parentElement.offsetParent !== null) // 보이는 트리 우선
               || anchors.find(x => x.parentNode);
        if (!a) return;
        a.removeAttribute(DONE_ATTR);
        a.style.removeProperty('display');
        delete a.dataset.b64key;
        _cardMap.delete(a);
        _seenProductCodes.delete(key); // 억제기록 해제(안 풀면 재패스에서 또 null)
        if (key.startsWith('cr:GC')) _gcSeenInFlush.delete(key.slice(5));
        revived = true;
      });
      if (revived) { convertProductLinks(contentRoots, _pass + 1); return; } // 되살린 <a> 재카드화(1회)
    }

    // preview 훅: 카드로 교체되지 않은 DLsite/Steam 링크에도 hover 달기
    if (CFG.DLSITE_PREVIEW) {
      const previewSel = 'a[href*="dlsite.com"], a[href*="ch.dlsite.com"], a[href*="store.steampowered.com/app/"], a[href*="getchu.com/"]';
      contentRoots.forEach(root => {
        root.querySelectorAll(previewSel).forEach(a => {
          if (a.dataset._dlpHooked) return;
          if (a.firstElementChild) return; // OG 프리뷰 등 블록형 링크 제외
          hookDlp(a);
        });
      });
    }
  }

  /* ================================================================
     비번 자동입력 공통 유틸
  ================================================================ */
  function normText(t) { return String(t||'').replace(/\s+/g,' ').trim(); }

  function isVisible(el) {
    if (!el) return false;
    const s = window.getComputedStyle(el);
    const r = el.getBoundingClientRect();
    return s.display !== 'none' && s.visibility !== 'hidden' && r.width > 0 && r.height > 0;
  }

  function setNativeValue(el, value) {
    el.focus();
    const proto = Object.getPrototypeOf(el);
    const desc = Object.getOwnPropertyDescriptor(proto, 'value') ||
                 Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
    if (desc?.set) desc.set.call(el, value); else el.value = value;
    if (el._valueTracker) el._valueTracker.setValue('');
    el.dispatchEvent(new InputEvent('input',  { bubbles: true, data: value, inputType: 'insertText' }));
    el.dispatchEvent(new Event('change', { bubbles: true }));
  }

  // 버튼이 없거나 비활성일 때 Enter 키 이벤트로 폼 제출 (generic/formpost/transfer/linklock 공용 —
  // optimized+ 통합, 과거 동일 2줄 디스패치가 4벌 복붙돼 있었다)
  function submitViaEnter(input) {
    input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true }));
    input.dispatchEvent(new KeyboardEvent('keyup',   { key: 'Enter', keyCode: 13, bubbles: true }));
  }

  function getInputEl(sel) {
    return [...document.querySelectorAll(sel)].find(isVisible) || null;
  }

  function getScope(input) {
    if (!input) return document.body;
    return input.closest('[role="dialog"]') || input.closest('form') ||
           input.closest('[class*="modal"]') || input.parentElement || document.body;
  }

  function findPwError(input, patterns) {
    const scope = getScope(input);
    for (const el of scope.querySelectorAll('div,p,span,strong,em,small,li')) {
      if (!isVisible(el)) continue;
      const t = normText(el.textContent);
      if (t && t.length < 120 && patterns.some(p => p.test(t))) return t;
    }
    return '';
  }

  /* ================================================================
     비번 자동입력
  ================================================================ */
  const siteCfg = CFG.PW_SITES[host];

  if (siteCfg && CFG.PW_AUTO) {

    if (siteCfg.mode === 'kio') {
      const KIO = {
        phase: 'idle', lastInputEl: null,
        lastSubmitAt: 0, lastAttemptAt: 0,
        queue: [], idx: 0, tried: new Set(), active: null,
        lastAdvKey: '',
        GRACE: 3000, FALLBACK: 2000, RETRY_MS: 50, RETRY_MAX: 8,
      };

      function kioGetBtn(scope) {
        return [...(scope||document).querySelectorAll('button')]
          .find(b => isVisible(b) && /^(확인|ok|confirm|submit|enter)$/i.test(normText(b.textContent))) || null;
      }

      function kioResetForNewModal(input) {
        if (KIO.lastInputEl === input) return;
        const lastAt = Math.max(KIO.lastAttemptAt, KIO.lastSubmitAt);
        if (KIO.active && ['attempting','submitted'].includes(KIO.phase) &&
            lastAt > 0 && Date.now() - lastAt <= KIO.GRACE) {
          KIO.lastInputEl = input; return;
        }
        KIO.lastInputEl = input; KIO.phase = 'idle';
        KIO.lastSubmitAt = 0; KIO.lastAttemptAt = 0;
        KIO.queue = getEffectivePwList().filter(Boolean).map(v => ({ value: v }));
        KIO.idx = 0; KIO.tried = new Set(); KIO.active = null;
        KIO.lastAdvKey = '';
      }

      function kioClickWhenReady(expected, onSuccess) {
        let retries = 0;
        const attempt = () => {
          const input = getInputEl(siteCfg.inputSel); if (!input) return;
          const btn = kioGetBtn(getScope(input)); if (!btn) return;
          if (normText(input.value) !== normText(expected)) return;
          if (!btn.disabled) { btn.click(); KIO.lastSubmitAt = Date.now(); onSuccess(); return; }
          if (retries < KIO.RETRY_MAX) { retries++; setTimeout(attempt, KIO.RETRY_MS); }
          else { KIO.phase = 'submitted'; KIO.lastSubmitAt = Date.now(); }
        };
        queueMicrotask(attempt);
      }

      async function kioTryNext(input) {
        while (KIO.idx < KIO.queue.length) {
          const { value } = KIO.queue[KIO.idx];
          if (!value || KIO.tried.has(value)) { KIO.idx++; continue; }
          KIO.tried.add(value);
          KIO.active = { value };
          KIO.lastAdvKey = '';
          KIO.lastAttemptAt = Date.now();
          KIO.phase = 'attempting';
          setNativeValue(input, value);
          kioClickWhenReady(value, () => { KIO.phase = 'submitted'; });
          return;
        }
        KIO.phase = 'done'; KIO.active = null;
      }

      async function kioTick() {
        const input = getInputEl(siteCfg.inputSel); if (!input) return;
        if (!kioGetBtn(getScope(input))) return;
        kioResetForNewModal(input);

        if (['attempting','submitted'].includes(KIO.phase) && KIO.active && !normText(input.value)) {
          setNativeValue(input, KIO.active.value);
          KIO.phase = 'attempting'; KIO.lastAttemptAt = Date.now();
          kioClickWhenReady(KIO.active.value, () => { KIO.phase = 'submitted'; KIO.lastSubmitAt = Date.now(); });
          return;
        }

        const err = findPwError(input, siteCfg.errorPat);
        const key = KIO.active ? `${KIO.idx}:${err}` : '';

        if (err && KIO.phase === 'submitted' && KIO.active &&
            normText(input.value) === normText(KIO.active.value) &&
            key !== KIO.lastAdvKey) {
          KIO.lastAdvKey = key; KIO.idx++;
          await kioTryNext(input); return;
        }
        if (KIO.phase === 'idle') { await kioTryNext(input); return; }
        if (KIO.phase === 'attempting' && Date.now() - KIO.lastAttemptAt >= KIO.FALLBACK) { KIO.idx++; await kioTryNext(input); return; }
        if (KIO.phase === 'submitted' && Date.now() - KIO.lastSubmitAt >= KIO.FALLBACK)   { KIO.idx++; await kioTryNext(input); }
      }

      let kioTickBusy = false;
      window.setInterval(async () => {
        if (kioTickBusy) return;
        kioTickBusy = true;
        try { await kioTick(); } finally { kioTickBusy = false; }
      }, 100);
      window.addEventListener('pageshow', e => {
        if (!e.persisted) return;
        Object.assign(KIO, { phase:'idle', lastInputEl:null, lastSubmitAt:0, lastAttemptAt:0,
          queue:[], idx:0, tried:new Set(), active:null, lastAdvKey:'' });
        kioTickBusy = false;
      });
      document.addEventListener('visibilitychange', () => { if (!document.hidden) kioTick().catch(()=>{}); });
      setTimeout(() => kioTick().catch(()=>{}), 0);
    }

    if (siteCfg.mode === 'generic') {
      async function genericTryPw() {
        const pws = getEffectivePwList();
        for (const pw of pws) {
          if (!pw) continue;
          const input = getInputEl(siteCfg.inputSel);
          if (!input) break;
          setNativeValue(input, pw);
          // React가 input 이벤트를 처리해 버튼을 활성화할 때까지 대기 (최대 btnDelay ms)
          const btnWait = siteCfg.btnDelay || 200;
          const btnDeadline = Date.now() + btnWait;
          let btn = null;
          while (Date.now() < btnDeadline) {
            btn = document.querySelector(siteCfg.btnSel) ||
                  [...document.querySelectorAll('button')].find(
                    b => isVisible(b) && /^(확인|ok|confirm|submit|enter|unlock|access)$/i.test(normText(b.textContent))
                  );
            if (btn && !btn.disabled) break;
            await new Promise(r => setTimeout(r, 50));
          }
          if (btn && !btn.disabled) {
            btn.click();
          } else {
            submitViaEnter(input); // 버튼이 disabled이거나 없으면 Enter 키로 폼 제출 시도
          }
          await new Promise(r => setTimeout(r, 1000));
          const postInput = getInputEl(siteCfg.inputSel);
          // 비번 입력창이 사라졌으면 성공 (페이지 이동, AJAX 폼 제거 등)
          if (!postInput) break;
          // 에러 패턴 먼저 확인 (successSel보다 우선 — premature match 방지)
          if (findPwError(postInput, siteCfg.errorPat)) continue;
          // 성공 요소 체크: 입력창이 남아있지만 성공 콘텐츠가 나타난 경우 (AJAX)
          if (siteCfg.successSel && document.querySelector(siteCfg.successSel)) break;
        }
      }
      const gObs = new MutationObserver(() => {
        const input = getInputEl(siteCfg.inputSel);
        if (input && !input.dataset._gTried) { input.dataset._gTried = '1'; setTimeout(genericTryPw, 300); }
      });
      gObs.observe(document.documentElement, { childList: true, subtree: true });
      setTimeout(() => {
        const i = getInputEl(siteCfg.inputSel);
        if (i && !i.dataset._gTried) { i.dataset._gTried = '1'; genericTryPw(); }
      }, siteCfg.triggerDelay || 1500);
    }

    if (siteCfg.mode === 'formpost') {
      // 전통적 form POST: 비번 제출 시 페이지 전체 리로드됨
      // → DOM 플래그 방식으로는 인덱스 유지 불가 → GM_setValue로 영속화
      // PERSIST_KEY는 파일 URL별로 분리 (다른 파일과 인덱스 혼용 방지)
      const PERSIST_KEY = `_fpIdx_${location.hostname}${location.pathname}`;
      const pws = getEffectivePwList();

      const input = getInputEl(siteCfg.inputSel);
      if (!input) {
        // 비번 폼 없음 → 성공 페이지(다운로드 버튼 있음)인지 확인
        if (siteCfg.dlBtnSel) {
          const dlBtn = document.querySelector(siteCfg.dlBtnSel);
          if (dlBtn && isVisible(dlBtn)) {
            GM_setValue(PERSIST_KEY, 0); // 성공: 인덱스 초기화
            setTimeout(() => {
              const b = document.querySelector(siteCfg.dlBtnSel);
              if (b && isVisible(b)) b.click();
            }, siteCfg.triggerDelay || 1000);
          }
        }
        return; // 관련 없는 페이지 → 아무 동작 안 함
      }

      // 비번 폼 존재 → 저장된 인덱스로 다음 비번 시도
      const idx = GM_getValue(PERSIST_KEY, 0);
      if (!pws.length || idx >= pws.length) {
        GM_setValue(PERSIST_KEY, 0); return; // 비번 목록 없음 or 전부 실패 → 초기화 후 중단
      }

      setTimeout(() => {
        const inp = getInputEl(siteCfg.inputSel);
        if (!inp) return;
        const pw = pws[idx];
        if (!pw) { GM_setValue(PERSIST_KEY, 0); return; }

        // 제출 전에 다음 인덱스 저장: 페이지 리로드 후 이 값을 읽어 다음 비번으로 진행
        GM_setValue(PERSIST_KEY, idx + 1);
        setNativeValue(inp, pw);

        setTimeout(() => {
          const btn = document.querySelector(siteCfg.btnSel);
          if (btn && !btn.disabled) btn.click();
          else submitViaEnter(inp);
        }, siteCfg.btnDelay || 300);
      }, siteCfg.triggerDelay || 1500);
    }

    if (siteCfg.mode === 'transfer') {
      // ★transfer 실제 흐름(사용자 확인): 비번 제출 → 입력창 사라지고 로딩 → (오답)다이얼로그 재등장 /
      //   (정답)다운로드 준비 화면. 게다가 재시도할수록 로딩이 점점 길어진다(레이트리밋). while 루프 폴링은 이
      //   재마운트 + 길어지는 로딩 타이밍에 취약해, 재등장 후 다음 비번을 못 넣는 문제가 있었다(사용자 증상).
      //   → KIO처럼 인터벌 상태머신으로 재작성: '다이얼로그가 present이고 처리 중이 아닐 때' 다음 비번을 넣고,
      //   사라졌다 재등장(재마운트)/오류문구면 다음 인덱스로. 성공은 오직 '다운로드 준비 UI'로만 판정(로딩 시간 무관).
      let _tIdx = 0, _tDone = false, _tItv = null;
      let _tState = 'idle';        // idle(입력 대기) | submitted(결과 대기)
      let _tSubmitAt = 0, _tGoneSeen = false;
      // ★transfer: 'Download all'(.js-download) 버튼은 잠금(비번 프롬프트) 화면에도 이미 떠 있고 pw 입력창과 '공존'한다
      //   (사용자 콘솔 확인). 그래서 다운로드 버튼은 성공 신호가 될 수 없다(그걸로 판정하면 첫 비번 로딩 중 오판→멈춤).
      //   유일하게 확실한 구분: 오답이면 pw 다이얼로그가 '되돌아오고', 정답이면 '되돌아오지 않는다'(닫힌 채 다운로드).
      //   ⇒ 성공을 능동 판정하지 않는다. '다이얼로그가 되돌아올 때(재마운트)만 다음 비번'을 넣고, 안 되돌아오면(정답)
      //   그대로 대기 = 사실상 성공(더 할 일 없음). 시간 임계값을 안 쓰므로 로딩이 아무리 길어져도 안전.
      function tTick() {
        if (_tDone) return;
        const input = getInputEl(siteCfg.inputSel);

        if (_tState === 'submitted') {
          if (!input) { _tGoneSeen = true; return; }                 // 닫힘(로딩 or 성공) — 되돌아올 때까지 계속 대기
          if (_tGoneSeen || findPwError(input, siteCfg.errorPat)) {  // 되돌아옴(재마운트) or 오류문구 = 오답
            _tIdx++; _tGoneSeen = false;
            if (_tIdx >= getEffectivePwList().length) { _tDone = true; if (_tItv) clearInterval(_tItv); return; } // 소진
            _tState = 'idle'; return;                                // 다음 tick에서 다음 비번(재등장 안정화 버퍼 ~틱간격)
          }
          if (Date.now() - _tSubmitAt > 120000) { _tIdx++; _tState = 'idle'; _tGoneSeen = false; return; } // 안전장치(클릭 미반영 등)
          return;                                                    // 제출 직후 아직 그대로(사라지지도 오류도 아님) — 대기
        }

        // idle: 비번 프롬프트가 있으면 다음 비번 입력. 없으면(정답으로 닫힘/미오픈) 아무것도 안 함.
        if (!input) return;
        const pws = getEffectivePwList();
        if (_tIdx >= pws.length) { _tDone = true; if (_tItv) clearInterval(_tItv); return; } // 비번 소진 → 중단
        const pw = pws[_tIdx];
        if (!pw) { _tIdx++; return; }
        setNativeValue(input, pw);
        _tState = 'submitted'; _tSubmitAt = Date.now(); _tGoneSeen = false;
        setTimeout(() => {
          const inp2 = getInputEl(siteCfg.inputSel);
          if (!inp2) return;
          const btn = document.querySelector(siteCfg.btnSel);
          if (btn && !btn.disabled) btn.click();
          else submitViaEnter(inp2);
        }, siteCfg.btnDelay || 250);
      }
      _tItv = setInterval(tTick, 350);

      // 페이지 로드 시 다운로드 버튼이 있으면 클릭해 비번 다이얼로그 열기(이미 열려 있으면 스킵).
      if (siteCfg.dlBtnSel) {
        setTimeout(() => {
          if (getInputEl(siteCfg.inputSel)) return;
          const dlBtn = document.querySelector(siteCfg.dlBtnSel);
          if (dlBtn && isVisible(dlBtn)) dlBtn.click();
        }, siteCfg.triggerDelay || 1500);
      }
    }

    if (siteCfg.mode === 'linklock') {
      // link-lock: 오답 시 인라인 .error 박스 표시(+'Try again'으로 폼 복원), 정답 시 리다이렉트.
      const _wait = ms => new Promise(r => setTimeout(r, ms));
      const _errVisible = () => { const b = document.querySelector(siteCfg.errorSel || '.error'); return !!(b && isVisible(b)); };
      const _clickRetry = () => {
        const re = siteCfg.retryText || /try again/i;
        const b = [...document.querySelectorAll('button')].find(x => isVisible(x) && re.test(normText(x.textContent)));
        if (b) { b.click(); return true; } return false;
      };
      let _llRunning = false, _llDone = false;
      async function linklockTry() {
        if (_llRunning || _llDone) return;
        _llRunning = true;
        try {
          const pws = getEffectivePwList();
          for (const pw of pws) {
            if (!pw) continue;
            if (_errVisible()) { _clickRetry(); await _wait(400); }  // 오류 상태면 폼 복원 후 재시도
            let input = getInputEl(siteCfg.inputSel);
            if (!input) break;
            setNativeValue(input, pw);
            await _wait(120);
            const btn = document.querySelector(siteCfg.btnSel);
            if (btn && !btn.disabled) btn.click();
            else submitViaEnter(input);
            // 결과 폴링: 오류박스 표시=오답, 폼(input) 사라짐=성공(리다이렉트). 최대 ~8초.
            const deadline = Date.now() + 8000;
            let verdict = 'timeout';
            await _wait(300);
            while (Date.now() < deadline) {
              if (_errVisible()) { verdict = 'error'; break; }        // 오류박스 표시 = 오답
              if (!getInputEl(siteCfg.inputSel)) {
                // 폼 사라짐 — 리다이렉트(성공)인지, 에러박스로 교체된 건지 유예 후 재확인
                await _wait(300);
                if (_errVisible()) { verdict = 'error'; break; }
                if (!getInputEl(siteCfg.inputSel)) { verdict = 'success'; break; }
              }
              await _wait(250);
            }
            if (verdict === 'success') { _llDone = true; return; }
            // error/timeout → 다음 비번(loop 처음에서 Try again으로 폼 복원)
          }
        } finally { _llRunning = false; }
      }
      const llObs = new MutationObserver(() => {
        if (_llDone || _llRunning) return;
        if (getInputEl(siteCfg.inputSel)) linklockTry();
      });
      llObs.observe(document.documentElement, { childList: true, subtree: true });
      setTimeout(() => { if (!_llRunning && !_llDone && getInputEl(siteCfg.inputSel)) linklockTry(); }, siteCfg.triggerDelay || 800);
    }

  }

  /* ================================================================
     flush / observe
  ================================================================ */
  let pending = false, mo = null, _scrolledOnce = false, _kpIdx = -1, _wasWritePage = false, _urlFlushTimer = null;
  let _settingsCaptureCleanup = null;
  let _cardRmFlushes = 0, _cardRmWindow = 0; // 카드 제거 감지 재flush 폭주 방지(코네가 계속 떼어낼 때)
  let _hoveredCard = null;

  // 카드 호버 추적 (이벤트 위임). 본문·모아보기 패널 클론 모두 개별 대상 — 호버한 그 카드 기준으로
  // w/s가 위/아래로 이동한다(요구4: 클론도 개별 인식).
  document.addEventListener('mouseover', e => {
    const card = e.target.closest && e.target.closest('.b64-link, .b64-product-link');
    _hoveredCard = card || null;
  }, { passive: true, capture: false });

  function isOurNode(node) {
    let n = node;
    while (n) {
      if (n.nodeType === Node.ELEMENT_NODE &&
          (n.hasAttribute(DONE_ATTR) || n.hasAttribute(LTDONE_ATTR))) return true;
      n = n.parentNode;
    }
    return false;
  }

  function getContentRoots() {
    // 글 작성 페이지면 아무것도 처리 안 함
    if (isWritePage()) return { roots: new Set(), allRoots: new Set() };

    // 우선순위 그룹: 상위 그룹에서 1개라도 매칭되면 하위 그룹은 건너뜀.
    // kone.gg 가 #post-article 과 .article-body 를 별개 요소로 렌더링할 때
    // 글 내용이 2배로 보이는 현상을 방지한다.
    const PRIORITY_GROUPS = [
      ['#post-article', '#post-comment'],
      ['.article-body', 'div.fr-view.article-content', 'body div.article-body > div.fr-view'],
      ['p.text-sm.whitespace-pre-wrap', 'div.text-sm.whitespace-pre-wrap'],
    ];

    // allRoots: 우선순위 불문 매칭된 모든 요소 → flushListTitles 전역 스캔 배제에 사용
    const allRoots = new Set();
    let candidates = new Set();
    let activeFound = false;

    for (const group of PRIORITY_GROUPS) {
      const groupSet = new Set();
      for (const sel of group) {
        document.querySelectorAll(sel).forEach(el => {
          // contenteditable="true" 조상 → 에디터 내부 → 제외
          let anc = el;
          while (anc) {
            if (anc.contentEditable === 'true') return;
            anc = anc.parentElement;
          }
          // 요소 내부에 Froala 에디터 잔존 → write→read 전환 중 → 제외
          if (el.querySelector('.fr-element, .fr-wrapper, .fr-toolbar')) return;
          groupSet.add(el);
          allRoots.add(el); // 우선순위 무관하게 전체 수집
        });
      }
      if (!activeFound && groupSet.size > 0) {
        candidates = groupSet;
        activeFound = true;
        // break 제거: 낮은 우선순위 그룹도 allRoots 에 수집해야 하므로 계속 순회
      }
    }

    // 중첩 제거: 다른 루트의 자손인 요소는 제외
    const roots = new Set();
    for (const el of candidates) {
      let dominated = false;
      for (const other of candidates) {
        if (other !== el && other.contains(el)) { dominated = true; break; }
      }
      if (!dominated) roots.add(el);
    }
    return { roots, allRoots };
  }

  function flushListTitles(contentRoots, allContentRoots = contentRoots) {
    if (!CFG.LIST_DECODE || !document.body) return;

    CFG.LIST_SELECTORS.forEach(sel => {
      document.querySelectorAll(sel).forEach(el => {
        let anc = el;
        while (anc) { if (allContentRoots.has(anc)) return; anc = anc.parentElement; }
        walkAndProcess(el, processListNode);
      });
    });

    const gw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
      acceptNode(node) {
        const p = node.parentNode;
        if (!p) return NodeFilter.FILTER_REJECT;
        const tag = p.nodeName;
        if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'INPUT') return NodeFilter.FILTER_REJECT;
        if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
        let anc = p;
        while (anc) {
          if (anc.nodeType === Node.ELEMENT_NODE) {
            if (anc.contentEditable === 'true') return NodeFilter.FILTER_REJECT; // Froala 에디터 내부 제외
            if (anc.hasAttribute(DONE_ATTR) || anc.hasAttribute(LTDONE_ATTR)) return NodeFilter.FILTER_REJECT;
            if (allContentRoots.has(anc)) return NodeFilter.FILTER_REJECT;
            const tn = anc.nodeName;
            if (tn === 'S' || tn === 'STRIKE' || tn === 'DEL') return NodeFilter.FILTER_REJECT;
          }
          anc = anc.parentNode;
        }
        return NodeFilter.FILTER_ACCEPT;
      },
    });

    const listNodes = []; let ln;
    while ((ln = gw.nextNode())) {
      const v = ln.nodeValue;
      if (B64_TEST_RE.test(v) ||
          /\b(?:RJ|BJ|VJ|RE|BE|VE)\s?\d{6,8}(?![0-9])/i.test(v) ||
          /(꺼|거|ㄲㅓ|ㄱㅓ)\s?\d{6,8}/.test(v) ||
          /(?:스팀|[Ss]team|\b[Ss][Tt])[\s-]*\d{4,10}/.test(v) ||
          /\b(?:GC|FZ|FB)\s?\d{4,8}(?![0-9])/i.test(v))
        listNodes.push(ln);
    }
    listNodes.forEach(processListNode);
  }

  /* ================================================================
     제목 카드 삽입
     CONTENT_SELECTORS 본문 영역이 감지될 때, 같은 컨테이너 내
     h1/h2/h3 에서 RJ·Steam 코드를 찾아 본문 하단에 카드 바 추가
  ================================================================ */
  function injectTitleCards(contentRoots) {
    if (!CFG.LINK_CARD) return;

    // CONTENT_SELECTORS가 매칭된 경우에만 실행 (목록 페이지 제외)
    const roots = [...contentRoots].filter(r => !r.hasAttribute(TITLE_CARD_ATTR));
    if (!roots.length) return;

    // content root 밖의 heading 요소 수집
    const allHeadings = [...document.querySelectorAll('h1, h2, h3')].filter(h => {
      for (const r of contentRoots) if (r.contains(h)) return false;
      return true;
    });

    for (const root of roots) {
      root.setAttribute(TITLE_CARD_ATTR, '1');
      if (!allHeadings.length) continue;

      // root의 조상을 거슬러 올라가며 heading을 포함하는 가장 가까운 컨테이너 탐색
      let scoped = allHeadings;
      let anc = root.parentElement;
      while (anc && anc !== document.body) {
        const found = allHeadings.filter(h => anc.contains(h));
        if (found.length) { scoped = found; break; }
        anc = anc.parentElement;
      }

      // heading 텍스트에서 DLsite/Steam/크리에이터 코드 추출
      const seen = new Set();
      const cards = [];
      for (const h of scoped) {
        const hits = findAllMatches(h.textContent)
          .filter(hit => hit.type === 'dlsite' || hit.type === 'steam' || hit.type === 'creator');
        for (const hit of hits) {
          const id = hit.type === 'steam' ? hit.appId : hit.code;
          if (seen.has(id)) continue;
          seen.add(id);
          if (hit.type === 'dlsite') cards.push(makeDlsiteCard(hit.code));
          else if (hit.type === 'steam') cards.push(makeSteamCard(hit.appId));
          else cards.push(makeCreatorCard(hit.code));
        }
      }

      if (!cards.length) continue;

      // 카드 바 생성 후 본문 하단에 삽입
      const bar = document.createElement('div');
      bar.setAttribute(DONE_ATTR, '');
      bar.className = 'b64-title-card-bar';
      const label = document.createElement('div');
      label.className = 'b64-title-card-label';
      label.textContent = '📌 제목에서 발견된 작품';
      bar.appendChild(label);
      cards.forEach(c => bar.appendChild(c));
      root.appendChild(bar);
    }
  }

  /* ── 카드 키보드 탐색 (w/s, ↑/↓) ── */
  function kpGetLinks() {
    const sel = CFG.NAV_TARGET === 'products' ? '.b64-product-link'
              : CFG.NAV_TARGET === 'both'     ? '.b64-link, .b64-product-link'
              : '.b64-link';
    // ★모아보기 패널(#b64d-link-panel) 클론도 '개별 카드'로 순회 대상에 포함한다(요구4). 본문 카드와
    //   모아보기 클론은 서로 독립된 카드로 인식돼야 하며(동시 선택/해제 금지), w/s는 문서 순서대로
    //   본문·모아보기 카드를 하나씩 이동한다. (과거엔 클론을 제외+동일작품 동시 하이라이트로 묶었으나,
    //   본문 카드 선택 시 모아보기 선택이 풀리는 문제로 복원.)
    return Array.from(document.querySelectorAll(sel));
  }
  // 포커스 적용: 기존 포커스 해제 후 '그 카드 하나만' 하이라이트(요구4 — 개별 인식).
  function _setKpFocus(cardEl) {
    document.querySelectorAll(KP_FOCUSED_SEL).forEach(el => el.classList.remove('kp-focused'));
    if (cardEl) cardEl.classList.add('kp-focused');
  }
  // W/S 선택 상태를 완전히 해제(선택 인덱스 + 하이라이트). 미리보기를 바깥클릭으로 끌 때 호출해
  // '선택이 안 풀린 채 남아 다음 w/s가 안 먹던' 문제를 없앤다(요구4).
  function _clearKpNav() {
    _kpIdx = -1;
    document.querySelectorAll(KP_FOCUSED_SEL).forEach(el => el.classList.remove('kp-focused'));
  }

  // W/S 선택 시 그 카드의 미리보기를 자동으로 띄움(옵션). 미리보기 훅이 달린 카드만 대상.
  let _kpPreviewTimer = null;
  function kpPreviewPos(cardEl) {
    // 좌표를 화면 안으로 클램프 → 카드가 화면 밖/가장자리여도 미리보기가 안 보이거나 끝에 걸치지 않게.
    const r = cardEl.getBoundingClientRect();
    const vw = window.innerWidth, vh = window.innerHeight;
    return {
      clientX: Math.min(Math.max(r.right + 4, 8), vw - 8),
      clientY: Math.min(Math.max(r.top + 4, 8), vh - 8),
    };
  }
  function kpMaybePreview(cardEl) {
    clearTimeout(_kpPreviewTimer);
    if (!CFG.KP_PREVIEW || !CFG.DLSITE_PREVIEW) return;
    const fixed = CFG.KP_PREVIEW_POS && CFG.KP_PREVIEW_POS !== 'card'; // 좌/우/가운데 고정 모드
    const hooked = cardEl && cardEl.dataset && cardEl.dataset._dlpHooked;
    // 고정 모드에서 '훅 카드 → 훅 카드' 이동은 박스를 닫지 않고 제자리 갱신(reuse) → 깜빡임 제거.
    // 그 외('card' 모드/훅 없는 카드)는 종전대로 즉시 닫고 디바운스 후 다시.
    const reuse = fixed && hooked && dlp.showing && dlp._kp;
    if (!reuse && dlp.showing) dlpHide();
    if (!hooked) return;
    _kpPreviewTimer = setTimeout(() => {
      if (!CFG.KP_PREVIEW || !CFG.DLSITE_PREVIEW) return;
      if (!document.contains(cardEl) || !cardEl.classList.contains('kp-focused')) return; // 여전히 이 카드가 포커스일 때만
      const p = kpPreviewPos(cardEl);
      dlpShow({ clientX: p.clientX, clientY: p.clientY, currentTarget: cardEl, _kp: true, _kpReuse: fixed && dlp.showing && dlp._kp });
    }, 130); // 짧게 → 마우스 호버에 가깝게 반응(연타 중엔 안 뜨고, 멈추면 즉시)
  }

  function kpNavigate(dir) {
    const links = kpGetLinks();
    if (!links.length) return false;
    let next;
    if (_kpIdx < 0) {
      next = dir > 0 ? 0 : links.length - 1;
    } else {
      next = _kpIdx + dir;
      if (next < 0 || next >= links.length) return false; // 경계에서 정지 (순환 없음)
    }
    _kpIdx = next;
    _setKpFocus(links[_kpIdx]);
    links[_kpIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
    kpMaybePreview(links[_kpIdx]);
    return true;
  }
  // capture:true — 버블링 단계에서 사이트가 이벤트를 막아도 동작하도록
  window.addEventListener('keydown', e => {
    const tg = e.target;
    if (tg.tagName === 'INPUT' || tg.tagName === 'TEXTAREA' || tg.isContentEditable) return;
    if (e.ctrlKey || e.altKey || e.metaKey) return;
    if (document.getElementById('b64d-settings') || document.getElementById('b64-dup-overlay')) return;
    const isDown = e.code === 'KeyS' || e.key === 'ArrowDown';
    const isUp   = e.code === 'KeyW' || e.key === 'ArrowUp';
    if (isDown || isUp) {
      // 마우스가 카드 위에 있으면 그 카드 기준으로 바로 위/아래 작품으로 이동
      if (_hoveredCard && document.contains(_hoveredCard)) {
        const allCards = kpGetLinks();
        const hovIdx = allCards.indexOf(_hoveredCard);
        if (hovIdx >= 0) {
          if (_kpIdx !== hovIdx) {
            // 첫 번째 키: 포커스가 다른 곳에 있으면 먼저 호버 카드로 이동만
            _kpIdx = hovIdx;
          } else {
            // 두 번째 키: 이미 호버 카드에 포커스 → 방향대로 한 칸 이동
            _kpIdx = isDown
              ? Math.min(hovIdx + 1, allCards.length - 1)
              : Math.max(hovIdx - 1, 0);
          }
          _setKpFocus(allCards[_kpIdx]);
          allCards[_kpIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
          kpMaybePreview(allCards[_kpIdx]);
          e.preventDefault();
        }
        return;
      }
      if (isDown) { if (kpNavigate(1))  e.preventDefault(); }
      else        { if (kpNavigate(-1)) e.preventDefault(); }
    } else if (e.key === 'Enter' && _kpIdx >= 0) {
      const links = kpGetLinks();
      if (links[_kpIdx]) { links[_kpIdx].click(); e.preventDefault(); }
    }
  }, { capture: true, passive: false });

  const WRITE_PAGE_RE = /kone\.gg\/s\/[^/]+(?:\/[^/]+)?\/write(?:\/|[?#]|$)/;
  function isWritePage(href = location.href) {
    return WRITE_PAGE_RE.test(href);
  }

  // kone.gg OG 프리뷰 카드(not-prose h-24)를 CSS로 숨김
  /* ================================================================
     채널 대문 TXT 중복 검사
  ================================================================ */
  const DUP_HIST_KEY     = 'b64d_dup_hist';
  const DUP_HIST_MAX     = 20;

  function getPageCardCodes() {
    const codes = new Set();
    document.querySelectorAll('a.b64-product-link').forEach(a => {
      const code = a.dataset.cardCode;
      if (code) codes.add(code.toUpperCase());
    });
    return codes;
  }

  function getDupHist() {
    try { return JSON.parse(GM_getValue(DUP_HIST_KEY, '[]')); } catch { return []; }
  }
  function saveDupHist(entry) {
    const h = getDupHist();
    h.unshift(entry);
    if (h.length > DUP_HIST_MAX) h.length = DUP_HIST_MAX;
    GM_setValue(DUP_HIST_KEY, JSON.stringify(h));
  }

  function extractCodesFromTxt(text) {
    // 쉼표·줄바꿈으로 항목 분리 후 각각에서 정규 식별자 추출
    const items = text.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
    const codes = [];
    for (const raw of items) {
      let m, id = null;
      if ((m = raw.match(/\b(RJ|BJ|VJ|RE|BE|VE)\s?(\d{6,8})\b/i)))
        id = m[1].toUpperCase() + m[2];
      else if ((m = raw.match(/store\.steampowered\.com\/app\/(\d{4,10})/)))
        id = 'ST' + m[1];
      else if ((m = raw.match(/\bST(\d{4,10})\b/i)))
        id = 'ST' + m[1];
      else if ((m = raw.match(/\bGC(\d+)\b/i)))
        id = 'GC' + m[1];
      else if ((m = raw.match(/getchu\.com\/(?:soft\.phtml.*?id=|item\/)(\d+)/)))
        id = 'GC' + m[1];
      else if ((m = raw.match(/\bFZ(\w+)\b/i)))
        id = 'FZ' + m[1].toUpperCase();
      else if ((m = raw.match(/dmm\.co\.jp.*?\/cid=(?:d_)?(\w+)/)))
        id = 'FZ' + m[1].toUpperCase();
      else if ((m = raw.match(/\bFB(\d+)\b/i)))
        id = 'FB' + m[1];
      else if ((m = raw.match(/fanbox\.cc\/posts\/(\d+)/)))
        id = 'FB' + m[1];
      else if ((m = raw.match(/([a-z0-9_-]+)\.fanbox\.cc/i)) && m[1].toLowerCase() !== 'www')
        id = 'FANBOX:' + m[1].toLowerCase();
      else if ((m = raw.match(/patreon\.com\/(?:c\/)?([^/?#\s]+)/i)))
        id = 'PATREON:' + m[1].toLowerCase();
      else if (/^https?:\/\//.test(raw)) {
        try {
          const u = new URL(raw.trim());
          id = 'URL:' + u.hostname.replace(/^www\./, '') + u.pathname.replace(/\/$/, '');
        } catch { id = null; }
      } else if (raw.length >= 4 && !/\s/.test(raw))
        id = raw.toUpperCase();
      if (id) codes.push(id);
    }
    return codes;
  }

  function findDupCodes(codes) {
    const cnt = {};
    for (const c of codes) cnt[c] = (cnt[c] || 0) + 1;
    return Object.entries(cnt).filter(([, n]) => n > 1).map(([code, count]) => ({ code, count }));
  }

  function showDupModal() {
    if (document.getElementById('b64-dup-overlay')) return;
    dlpHide();

    const overlay = document.createElement('div');
    overlay.id = 'b64-dup-overlay';

    const modal = document.createElement('div');
    modal.id = 'b64-dup-modal';
    if (CFG.SETTINGS_SCALE !== 100) modal.style.zoom = CFG.SETTINGS_SCALE / 100;

    // Header
    const hdr = document.createElement('div');
    hdr.className = 'dup-hdr';
    const hdrTitle = document.createElement('span');
    hdrTitle.textContent = '📋 TXT 중복 검사';
    const closeBtn = document.createElement('button');
    closeBtn.className = 'dup-x'; closeBtn.type = 'button'; closeBtn.textContent = '✕';
    closeBtn.addEventListener('click', () => overlay.remove());
    hdr.append(hdrTitle, closeBtn);

    // Body
    const body = document.createElement('div');
    body.className = 'dup-body';

    // File picker
    const pickRow = document.createElement('div');
    pickRow.className = 'dup-pick';
    const fileInput = document.createElement('input');
    fileInput.type = 'file'; fileInput.accept = '.txt,text/plain'; fileInput.style.display = 'none';
    const pickBtn = document.createElement('button');
    pickBtn.className = 'dup-file-btn'; pickBtn.type = 'button'; pickBtn.textContent = 'TXT 파일 선택';
    const fileLabel = document.createElement('span');
    fileLabel.className = 'dup-file-name'; fileLabel.textContent = '파일 미선택';
    pickRow.append(fileInput, pickBtn, fileLabel);
    pickBtn.addEventListener('click', () => fileInput.click());

    // Result area
    const resultEl = document.createElement('div');
    resultEl.className = 'dup-result';
    resultEl.innerHTML = '<span class="dup-empty">파일을 선택하면 결과가 표시됩니다.</span>';

    function scrollToCard(code) {
      const sel = `a.b64-product-link[data-card-code="${code.replace(/"/g, '\\"')}"]`;
      const el = document.querySelector(sel);
      if (!el) return;
      el.scrollIntoView({ behavior: 'smooth', block: 'center' });
      el.classList.remove('b64-card-flash');
      void el.offsetWidth;  // reflow to restart animation
      el.classList.add('b64-card-flash');
      el.addEventListener('animationend', () => el.classList.remove('b64-card-flash'), { once: true });
    }

    // 결과 목록 페이저: 모달 높이를 일정하게 유지하기 위해 긴 목록은 페이지로 나눔
    const SEC_PAGE = 15; // 섹션당 페이지 항목 수
    function buildPager(totalPages, getCur, onGo) {
      const el = document.createElement('div');
      el.className = 'dup-result-pager';
      function refresh() {
        el.innerHTML = '';
        const cur = getCur();
        const prev = document.createElement('button');
        prev.type = 'button'; prev.className = 'dup-hist-pager-btn'; prev.textContent = '←';
        prev.disabled = cur <= 0; prev.addEventListener('click', () => onGo(cur - 1));
        el.appendChild(prev);
        const nums = document.createElement('div'); nums.className = 'dup-hist-pager-pages';
        const win = 5;
        let e = Math.min(totalPages, Math.max(cur + 3, win));
        let s = Math.max(0, e - win); e = Math.min(totalPages, s + win);
        for (let p = s; p < e; p++) {
          const b = document.createElement('button');
          b.type = 'button'; b.className = 'dup-hist-pager-num' + (p === cur ? ' active' : '');
          b.textContent = (p + 1); b.addEventListener('click', () => onGo(p));
          nums.appendChild(b);
        }
        el.appendChild(nums);
        const next = document.createElement('button');
        next.type = 'button'; next.className = 'dup-hist-pager-btn'; next.textContent = '→';
        next.disabled = cur >= totalPages - 1; next.addEventListener('click', () => onGo(cur + 1));
        el.appendChild(next);
      }
      refresh();
      return { el, refresh };
    }

    function makeSection(titleText, titleClass, items, badgeFn, clickFn) {
      const wrap = document.createElement('div');
      const t = document.createElement('div');
      t.className = 'dup-section-title' + (titleClass ? ' ' + titleClass : '');
      t.textContent = titleText;
      wrap.appendChild(t);
      if (!items.length) {
        const e = document.createElement('span');
        e.className = 'dup-empty'; e.textContent = '없음';
        wrap.appendChild(e);
        return wrap;
      }
      const ul = document.createElement('ul');
      ul.className = 'dup-list';
      wrap.appendChild(ul);
      const totalPages = Math.ceil(items.length / SEC_PAGE);
      let cur = 0;
      const renderPage = () => {
        ul.innerHTML = '';
        items.slice(cur * SEC_PAGE, (cur + 1) * SEC_PAGE).forEach(item => {
          const li = document.createElement('li');
          li.innerHTML = badgeFn(item);
          if (clickFn) {
            li.classList.add('dup-goto');
            li.title = '클릭하면 해당 카드로 이동';
            li.addEventListener('click', () => clickFn(item));
          }
          ul.appendChild(li);
        });
      };
      renderPage();
      if (totalPages > 1) {
        const pager = buildPager(totalPages, () => cur, (p) => {
          cur = Math.max(0, Math.min(totalPages - 1, p));
          renderPage(); pager.refresh();
        });
        wrap.appendChild(pager.el);
      }
      return wrap;
    }

    function renderEntry(entry) {
      resultEl.innerHTML = '';
      if (!entry) { resultEl.innerHTML = '<span class="dup-empty">파일을 선택하면 결과가 표시됩니다.</span>'; return; }

      const hasPage = Array.isArray(entry.pageCodes) && entry.pageCodes.length > 0;
      const sumEl = document.createElement('div');
      sumEl.className = 'dup-summary';
      sumEl.textContent = hasPage
        ? `TXT ${entry.total}개 · 페이지 카드 ${entry.pageCodes.length}개`
        : `TXT ${entry.total}개 (${entry.unique}개 고유)`;
      resultEl.appendChild(sumEl);

      if (hasPage) {
        const owned    = entry.owned    || [];
        const notOwned = entry.notOwned || [];
        if (entry.url) {
          let path = '';
          try { path = new URL(entry.url).pathname.replace(/\/$/, '') || '/'; } catch {}
          const linkBtn = document.createElement('a');
          linkBtn.className = 'dup-page-link';
          linkBtn.href = entry.url;
          linkBtn.target = '_blank';
          linkBtn.rel = 'noopener noreferrer';
          linkBtn.textContent = '↗ ' + (path || entry.url);
          resultEl.appendChild(linkBtn);
        }
        resultEl.appendChild(makeSection(
          `미보유 / 새 항목 (${notOwned.length}개)`, 'dup-new-title', notOwned,
          c => `<span class="dup-code">${esc(c)}</span>`,
          c => scrollToCard(c)
        ));
        resultEl.appendChild(makeSection(
          `이미 보유 (${owned.length}개)`, 'dup-owned-title', owned,
          c => `<span class="dup-code">${esc(c)}</span><span class="dup-badge-ok">✓</span>`,
          c => scrollToCard(c)
        ));
        if (entry.duplicates && entry.duplicates.length) {
          resultEl.appendChild(makeSection(
            `TXT 내 중복 (${entry.duplicates.length}건)`, '', entry.duplicates,
            ({ code, count }) => `<span class="dup-code">${esc(code)}</span><span class="dup-badge">×${count}</span>`
          ));
        }
      } else {
        const noteEl = document.createElement('span');
        noteEl.className = 'dup-empty';
        noteEl.textContent = '현재 페이지에 작품 카드 없음 — TXT 내 중복만 검사';
        resultEl.appendChild(noteEl);
        if (!entry.duplicates.length) {
          const okEl = document.createElement('div');
          okEl.className = 'dup-ok'; okEl.textContent = '✅ 중복 없음';
          resultEl.appendChild(okEl);
        } else {
          resultEl.appendChild(makeSection(
            `중복 ${entry.duplicates.length}건`, 'dup-label', entry.duplicates,
            ({ code, count }) => `<span class="dup-code">${esc(code)}</span><span class="dup-badge">×${count}</span>`
          ));
        }
      }
    }

    fileInput.addEventListener('change', () => {
      const file = fileInput.files[0];
      if (!file) return;
      fileLabel.textContent = file.name;
      const reader = new FileReader();
      reader.onload = ev => {
        const codes   = extractCodesFromTxt(ev.target.result);
        const dups    = findDupCodes(codes);
        const txtSet  = new Set(codes.map(c => c.toUpperCase()));
        const pageSet = getPageCardCodes();
        const owned    = [...pageSet].filter(c => txtSet.has(c));
        const notOwned = [...pageSet].filter(c => !txtSet.has(c));
        const entry = {
          file: file.name,
          date: new Date().toISOString(),
          url: location.href,
          total: codes.length,
          unique: new Set(codes).size,
          duplicates: dups,
          allCodes: codes,
          pageCodes: [...pageSet],
          owned,
          notOwned,
        };
        GM_setValue('libCodes', JSON.stringify([...new Set(codes.map(c => c.toUpperCase()))]));
        saveDupHist(entry);
        renderEntry(entry);
        renderHist();
        if (CFG.TXT_LIVE) applyTxtLiveColors();
      };
      reader.readAsText(file, 'utf-8');
    });

    // History sidebar
    const histList = document.createElement('div');
    histList.className = 'dup-hist-list';
    function renderDetail(entry) {
      let sec = resultEl.querySelector('.dup-detail-section');
      if (sec) { sec.remove(); return; }
      sec = document.createElement('div');
      sec.className = 'dup-detail-section';
      const title = document.createElement('div');
      title.className = 'dup-detail-title';
      title.textContent = `전체 항목 빈도 (${(entry.allCodes || []).length}개)`;
      sec.appendChild(title);
      if (!entry.allCodes || !entry.allCodes.length) {
        const empty = document.createElement('span');
        empty.className = 'dup-empty';
        empty.textContent = '상세 데이터 없음 (이전 버전 기록)';
        sec.appendChild(empty);
      } else {
        const cnt = {};
        for (const c of entry.allCodes) cnt[c] = (cnt[c] || 0) + 1;
        const sorted = Object.entries(cnt).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
        const ul = document.createElement('ul');
        ul.className = 'dup-list';
        sorted.forEach(([code, count]) => {
          const li = document.createElement('li');
          if (count < 2) li.style.opacity = '0.5';
          const badge = count > 1 ? `<span class="dup-badge">${count}</span>` : '';
          li.innerHTML = `<span class="dup-code">${esc(code)}</span>${badge}`;
          ul.appendChild(li);
        });
        sec.appendChild(ul);
      }
      resultEl.appendChild(sec);
    }

    let _selRow = null;
    const HIST_PAGE_SIZE = 9; // 페이지당 '항목(기록)' 수. 날짜 헤더는 별도이므로 고정 높이에 여유 있게.
    const pagerEl = document.createElement('div');
    pagerEl.className = 'dup-hist-pager';

    function renderHist(q, page) {
      page = page ?? 0;
      histList.innerHTML = ''; _selRow = null;
      const allEntries = getDupHist();
      if (!allEntries.length) { histList.innerHTML = '<span class="dup-empty">기록 없음</span>'; pagerEl.innerHTML = ''; return; }

      const now = new Date();
      const todayKey = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
      const yest = new Date(now); yest.setDate(now.getDate() - 1);
      const yestKey = `${yest.getFullYear()}-${yest.getMonth()}-${yest.getDate()}`;

      function makeGroup(dt) {
        const yy = String(dt.getFullYear()).slice(-2);
        const mm = String(dt.getMonth()+1).padStart(2,'0');
        const dd = String(dt.getDate()).padStart(2,'0');
        const m  = dt.getMonth()+1, d = dt.getDate();
        const dateCode = `${yy}.${mm}.${dd}`;
        const k = `${dt.getFullYear()}-${dt.getMonth()}-${dt.getDate()}`;
        let label;
        if (k === todayKey) label = `오늘 · ${dateCode}`;
        else if (k === yestKey) label = `어제 · ${dateCode}`;
        else label = dateCode;
        const searchKey = `${label} ${dateCode} ${m}월 ${d}일 ${dt.getFullYear()}년 ${m}월 ${d}일`.toLowerCase();
        return { k, label, searchKey };
      }

      const groupMap = new Map();
      allEntries.forEach(entry => {
        const dt = new Date(entry.date);
        const g  = makeGroup(dt);
        if (!groupMap.has(g.k)) groupMap.set(g.k, { label: g.label, searchKey: g.searchKey, rows: [] });
        groupMap.get(g.k).rows.push({ entry, dt });
      });

      // 항목(기록)만 평탄화 — 각 항목에 소속 날짜그룹(k/label)을 달아둔다.
      // 페이지네이션을 '항목 수' 기준으로 하면, 페이지가 날짜그룹 중간에서 시작해도
      // 렌더 시 그룹 변화마다(+ 페이지 첫 항목 앞) 날짜 헤더를 찍어 항상 날짜가 보인다.
      const filter = (q || '').trim().toLowerCase();
      const entryItems = [];
      groupMap.forEach(({ label, searchKey, rows }, k) => {
        if (filter && !searchKey.includes(filter)) return;
        rows.forEach(({ entry, dt }) => entryItems.push({ entry, dt, label, k }));
      });

      if (!entryItems.length) { histList.innerHTML = '<span class="dup-empty">검색 결과 없음</span>'; pagerEl.innerHTML = ''; return; }

      // 날짜헤더/행 생성 헬퍼
      const makeHeader = (label) => {
        const g = document.createElement('div');
        g.className = 'dup-hist-date-group'; g.textContent = label; return g;
      };
      const makeRow = (entry, dt) => {
        const row = document.createElement('button');
        row.type = 'button'; row.className = 'dup-hist-row';
        const timeStr = `${String(dt.getHours()).padStart(2,'0')}:${String(dt.getMinutes()).padStart(2,'0')}`;
        const statText = (entry.pageCodes && entry.pageCodes.length)
          ? `보유 ${(entry.owned||[]).length}·신규 ${(entry.notOwned||[]).length}`
          : `중복 ${(entry.duplicates||[]).length}건`;
        if (entry.url) row.title = entry.url;
        row.innerHTML = `<span class="dup-hist-file">${esc(entry.file)}</span><span class="dup-hist-meta"><span class="dup-hist-date">${esc(timeStr)}</span><span class="dup-hist-stat">${esc(statText)}</span></span>`;
        row.addEventListener('click', () => {
          if (_selRow === row) { renderDetail(entry); }
          else {
            if (_selRow) _selRow.classList.remove('dup-hist-sel');
            _selRow = row; row.classList.add('dup-hist-sel');
            renderEntry(entry); fileLabel.textContent = entry.file;
          }
        });
        return row;
      };

      // 페이지 경계를 '실제 렌더 높이'로 계산(추정 X) → 날짜 헤더·gap·margin까지 정확 반영, 스크롤 0.
      // histList에 항목을 직접 붙여보며 scrollHeight가 clientHeight를 넘는 순간을 페이지 경계로 삼는다.
      // 모달 append 전 첫 렌더는 clientHeight=0 → 폴백(고정 개수). append 후 rAF 재렌더에서 정확히 다시 나눔.
      const avail = histList.clientHeight || 0;
      const pages = []; // 각 페이지 = [start, end)
      if (avail >= 60) {
        histList.innerHTML = '';
        let start = 0, lastKp = null;
        for (let i = 0; i < entryItems.length; i++) {
          const it = entryItems[i];
          const hdr = it.k !== lastKp ? makeHeader(it.label) : null;
          if (hdr) histList.appendChild(hdr);
          const row = makeRow(it.entry, it.dt);
          histList.appendChild(row);
          if (i > start && histList.scrollHeight > avail) {
            // 넘침 → 이 항목은 다음 페이지로. 방금 붙인 요소 제거 후 페이지 확정.
            histList.removeChild(row);
            if (hdr) histList.removeChild(hdr);
            pages.push([start, i]);
            histList.innerHTML = '';
            start = i; lastKp = null; i--; // 이 항목을 새 페이지에서 다시
          } else {
            lastKp = it.k;
          }
        }
        pages.push([start, entryItems.length]);
        histList.innerHTML = '';
      } else {
        for (let s = 0; s < entryItems.length; s += HIST_PAGE_SIZE) pages.push([s, Math.min(s + HIST_PAGE_SIZE, entryItems.length)]);
      }
      const totalPages = pages.length || 1;
      const cur = Math.min(page, totalPages - 1);

      let lastK = null;
      const [ps, pe] = pages[cur] || [0, 0];
      for (let i = ps; i < pe; i++) {
        const it = entryItems[i];
        if (it.k !== lastK) { histList.appendChild(makeHeader(it.label)); lastK = it.k; }
        histList.appendChild(makeRow(it.entry, it.dt));
      }

      pagerEl.innerHTML = '';
      if (totalPages <= 1) return;
      const prev = document.createElement('button');
      prev.type = 'button'; prev.className = 'dup-hist-pager-btn'; prev.textContent = '←';
      prev.disabled = cur === 0;
      prev.addEventListener('click', () => renderHist(q, cur - 1));
      const pageNums = document.createElement('div');
      pageNums.className = 'dup-hist-pager-pages';
      let lo = Math.max(0, cur - 2), hi = Math.min(totalPages - 1, lo + 4);
      lo = Math.max(0, hi - 4);
      for (let p = lo; p <= hi; p++) {
        const nb = document.createElement('button');
        nb.type = 'button'; nb.className = `dup-hist-pager-num${p === cur ? ' active' : ''}`;
        nb.textContent = p + 1;
        const _p = p; nb.addEventListener('click', () => renderHist(q, _p));
        pageNums.appendChild(nb);
      }
      const next = document.createElement('button');
      next.type = 'button'; next.className = 'dup-hist-pager-btn'; next.textContent = '→';
      next.disabled = cur === totalPages - 1;
      next.addEventListener('click', () => renderHist(q, cur + 1));
      pagerEl.append(prev, pageNums, next);
    }

    renderHist();

    const sidebar = document.createElement('div');
    sidebar.className = 'dup-sidebar';
    const sidebarTitle = document.createElement('div');
    sidebarTitle.className = 'dup-sidebar-title';
    sidebarTitle.textContent = '이전 기록';
    const histSearch = document.createElement('input');
    histSearch.type = 'text';
    histSearch.className = 'dup-hist-search';
    histSearch.placeholder = '날짜 검색 (26.05, 오늘…)';
    histSearch.setAttribute('autocomplete', 'off');
    histSearch.addEventListener('input', () => renderHist(histSearch.value));
    sidebar.append(sidebarTitle, histSearch, histList, pagerEl);

    const layout = document.createElement('div');
    layout.className = 'dup-layout';
    body.append(pickRow, resultEl);
    layout.append(sidebar, body);
    modal.append(hdr, layout);
    overlay.appendChild(modal);
    document.body.appendChild(overlay);
    // append 후 histList가 실제 높이를 가질 때까지 기다렸다가 측정값으로 페이지를 다시 나눈다
    // (clientHeight가 0인 동안 폴백 고정개수로 그리면 넘쳐서 스크롤이 생기므로, 측정 가능해질 때 1회 재렌더).
    let _histSettle = 0;
    (function settle() {
      if (!document.getElementById('b64-dup-overlay')) return;
      if (histList.clientHeight >= 60 || _histSettle >= 12) { renderHist(histSearch.value); return; }
      _histSettle++; requestAnimationFrame(settle);
    })();
    window.addEventListener('resize', () => { if (document.getElementById('b64-dup-overlay')) renderHist(histSearch.value); });
    overlay.addEventListener('click', ev => { if (ev.target === overlay) overlay.remove(); });
    document.addEventListener('keydown', function escFn(ev) {
      if (ev.key === 'Escape' && document.getElementById('b64-dup-overlay')) {
        overlay.remove(); document.removeEventListener('keydown', escFn);
      }
    });
  }

  function injectDupCheckBtn() {
    if (document.getElementById('b64-dup-btn')) return;
    // 구독·서브 정보 버튼 그룹 우선, fallback: h1 기준 탐색
    const anchor = document.querySelector(
      'button[aria-label="서브 정보"], button[aria-label*="구독"]'
    );
    let btnArea = anchor?.parentElement;
    if (!btnArea) {
      const h1 = document.querySelector('main h1');
      btnArea = h1?.parentElement?.parentElement?.children?.[1];
    }
    if (!btnArea) return;
    const btn = document.createElement('button');
    btn.id = 'b64-dup-btn'; btn.type = 'button'; btn.title = 'TXT 중복 검사';
    btn.innerHTML = svg('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>');
    btn.addEventListener('click', showDupModal);
    btnArea.prepend(btn);
  }

  // inline style은 Svelte 재렌더링에 사라지지만 <head> CSS 규칙은 살아남음
  function injectOGHideStyle() {
    if (!document.head || document.getElementById('b64d-og-hide')) return;
    const s = document.createElement('style');
    s.id = 'b64d-og-hide';
    s.textContent = 'a.not-prose.h-24.overflow-hidden{display:none!important}';
    document.head.appendChild(s);
  }

  function applyTxtLiveColors() {
    const cards = document.querySelectorAll('a.b64-product-link');
    if (!CFG.TXT_LIVE) {
      cards.forEach(a => a.classList.remove('b64-tl-owned', 'b64-tl-new'));
      return;
    }
    let lib;
    try { lib = new Set(JSON.parse(GM_getValue('libCodes', '[]'))); } catch { lib = new Set(); }
    cards.forEach(a => {
      const code = a.dataset.cardCode?.toUpperCase();
      a.classList.remove('b64-tl-owned', 'b64-tl-new');
      if (code) a.classList.add(lib.has(code) ? 'b64-tl-owned' : 'b64-tl-new');
    });
  }

  /* ================================================================
     댓글 자동 펼치기 (설정: EXPAND_REPLIES / EXPAND_MORE)
     - "답글 N개" 클릭 → "답글 숨기기"로 글자가 바뀌어 재클릭 안 됨 (마킹 병행)
     - "댓글 더 보기"는 같은 버튼이 남을 수 있어 타이머로 1회씩 페이싱
     - 클릭 → DOM 변화 → MO → flush → 재호출되며 중첩 답글까지 이어서 펼침
     - 버튼은 class가 아니라 글자로 매칭 (Svelte 재렌더에 강함)
  ================================================================ */
  let _expandClicks = 0;     // 페이지당 누적 클릭 (폭주 방지용 상한)
  let _moreTimer    = null;  // "댓글 더 보기" 페이싱 타이머
  const EXPAND_CAP  = 200;   // 안전 상한

  function autoExpandComments() {
    if (!CFG.EXPAND_REPLIES && !CFG.EXPAND_MORE) return;
    const root = document.getElementById('post-comment');
    if (!root) return;
    if (_expandClicks > EXPAND_CAP) return;

    let moreBtn = null, replyClicks = 0;
    for (const b of root.querySelectorAll('button, a, [role="button"]')) {
      const t = (b.textContent || '').replace(/\s+/g, ' ').trim();
      if (!t) continue;
      // 실제 이동 링크(href 있는 <a>)는 클릭 시 페이지 이탈 위험 → 제외
      if (b.tagName === 'A') {
        const href = b.getAttribute('href') || '';
        if (href && href !== '#' && !/^javascript:/i.test(href)) continue;
      }
      // 대댓글 펼치기: "답글 N개" — "답글 숨기기"(이미 펼침)는 제외
      if (CFG.EXPAND_REPLIES && !b.dataset._kpExpand
          && /답글/.test(t) && /\d/.test(t) && !/숨기기/.test(t)) {
        b.dataset._kpExpand = '1';
        b.click();
        replyClicks++; _expandClicks++;
        if (replyClicks >= 30) break;  // 한 패스 제한 → 다음 flush에서 이어감
      } else if (CFG.EXPAND_MORE && !moreBtn && t.replace(/\s/g, '') === '댓글더보기') {
        moreBtn = b;
      }
    }
    // "댓글 더 보기": 타이머 없을 때만 1회 클릭 후 로드 대기 → 과도한 연타·중복 로드 방지
    if (moreBtn && !_moreTimer) {
      moreBtn.click();
      _expandClicks++;
      _moreTimer = setTimeout(() => { _moreTimer = null; autoExpandComments(); }, 700);
    }
  }

  /* ================================================================
     StegCloak 호환 엔진 (요구2) — 비번(채널 비번)으로 비밀 메시지를 제로폭 문자에 숨겨 cover 문장에 심고,
     읽을 때 역으로 복원한다. SubtleCrypto로 StegCloak(1.x) 포맷과 상호운용:
       · 압축 lzutf8 → 보수(compliment, ~byte) → PBKDF2(salt8,10k,SHA-512,48B→IV16+Key32) →
         AES-256-CTR → (무결성 시 HMAC-SHA256) → salt+hmac?+ct → byte→2bit→zwc(0..3), flag=zwc[0/1/2].
       · zwc 6종=[200c,200d,2061,2062,2063,2064]. shrink(반복쌍 압축)은 복호(expand)만 완전 지원,
         부호화는 무압축(no-op) — 실제 StegCloak가 그대로 복원 가능(상호운용 검증 완료).
       · 부호화 압축은 리터럴 전용(유효 lzutf8) — 실제 StegCloak.decompress가 그대로 읽음.
     ※ 실제 도구 샘플('best of best'/somisoft)로 역산 검증 통과. */
  const SC = (() => {
    const ZWC = ['‌','‍','⁡','⁢','⁣','⁤'];
    const _has = c => ZWC.indexOf(c) !== -1;
    const _tableMap = () => [ZWC[0]+ZWC[1],ZWC[0]+ZWC[2],ZWC[0]+ZWC[3],ZWC[1]+ZWC[2],ZWC[1]+ZWC[3],ZWC[2]+ZWC[3]];
    function _recReplace(data, patterns, repls) { for (let i = patterns.length-1; i >= 0; i--) data = data.split(patterns[i]).join(repls[i]); return data; }
    function _expand(zwcStr) {
      const chars = [...zwcStr], flag = chars[0], stream = chars.slice(1).join('');
      const pair = _tableMap()[ZWC.indexOf(flag)].split('');
      return _recReplace(stream, [ZWC[4],ZWC[5]], [pair[0]+pair[0], pair[1]+pair[1]]);
    }
    function _concealToData(zwcStr) {
      const chars = [...zwcStr], fi = ZWC.indexOf(chars[0]);
      const encrypt = fi === 0 || fi === 1, integrity = fi === 0;
      let bin = '';
      for (let i = 1; i < chars.length; i++) bin += ZWC.indexOf(chars[i]).toString(2).padStart(2,'0');
      const bytes = [];
      for (let i = 0; i + 8 <= bin.length; i += 8) bytes.push(parseInt(bin.slice(i, i+8), 2));
      return { encrypt, integrity, data: new Uint8Array(bytes) };
    }
    function _dataToZWC(integrity, crypt, bytes) {
      let bin = ''; for (const b of bytes) bin += b.toString(2).padStart(8,'0');
      let out = (integrity && crypt) ? ZWC[0] : crypt ? ZWC[1] : ZWC[2];
      for (let i = 0; i < bin.length; i += 2) out += ZWC[parseInt(bin.substr(i,2), 2)];
      return out;
    }
    // lzutf8: 부호화는 리터럴 전용(UTF-8 바이트 그대로 = 유효 lzutf8). 복호는 매치 포인터까지 완전 지원.
    function _lzCompress(str) { return new TextEncoder().encode(str); }
    function _lzDecompress(input) {
      const out = []; let op = 0; const push = v => { out[op++] = v; };
      for (let rp = 0; rp < input.length; rp++) {
        const v = input[rp];
        if ((v >>> 6) !== 3) { push(v); continue; }
        const seqId = v >>> 5;
        if (rp === input.length-1 || (rp === input.length-2 && seqId === 7)) { push(v); continue; }
        if ((input[rp+1] >>> 7) === 1) { push(v); }
        else {
          const mlen = v & 31; let md;
          if (seqId === 6) { md = input[rp+1]; rp += 1; } else { md = (input[rp+1] << 8) | input[rp+2]; rp += 2; }
          const mp = op - md;
          for (let o = 0; o < mlen; o++) push(out[mp+o]);
        }
      }
      return Uint8Array.from(out);
    }
    const _compliment = b => Uint8Array.from(b, x => (~x) & 0xff);
    function _concat(arrs) { let n = 0; for (const a of arrs) n += a.length; const o = new Uint8Array(n); let p = 0; for (const a of arrs) { o.set(a, p); p += a.length; } return o; }
    async function _pbkdf2(password, salt) {
      const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
      return new Uint8Array(await crypto.subtle.deriveBits({ name:'PBKDF2', salt, iterations:10000, hash:'SHA-512' }, base, 48*8));
    }
    async function _aesCtr(keyBytes, iv, data, mode) {
      const key = await crypto.subtle.importKey('raw', keyBytes, { name:'AES-CTR' }, false, ['encrypt','decrypt']);
      const p = { name:'AES-CTR', counter:iv, length:128 };
      return new Uint8Array(mode === 'encrypt' ? await crypto.subtle.encrypt(p, key, data) : await crypto.subtle.decrypt(p, key, data));
    }
    async function _hmac(keyBytes, data) {
      const key = await crypto.subtle.importKey('raw', keyBytes, { name:'HMAC', hash:'SHA-256' }, false, ['sign']);
      return new Uint8Array(await crypto.subtle.sign('HMAC', key, data));
    }
    async function _decrypt(dataBytes, password, integrity) {
      const salt = dataBytes.slice(0,8);
      let hmacData = null, secret;
      if (integrity) { hmacData = dataBytes.slice(8,40); secret = dataBytes.slice(40); } else { secret = dataBytes.slice(8); }
      const ivKey = await _pbkdf2(password, salt);
      const decrypted = await _aesCtr(ivKey.slice(16,48), ivKey.slice(0,16), secret, 'decrypt');
      if (integrity) { const v = await _hmac(ivKey.slice(16,48), decrypted); if (hmacData.length !== v.length || !v.every((b,i) => b === hmacData[i])) throw new Error('HMAC 무결성 실패 (비번 또는 데이터 오류)'); }
      return decrypted;
    }
    async function _encrypt(secret, password, integrity) {
      const salt = crypto.getRandomValues(new Uint8Array(8));
      const ivKey = await _pbkdf2(password, salt);
      const ct = await _aesCtr(ivKey.slice(16,48), ivKey.slice(0,16), secret, 'encrypt');
      if (integrity) return _concat([salt, await _hmac(ivKey.slice(16,48), secret), ct]);
      return _concat([salt, ct]);
    }
    // 텍스트에서 zwc를 모두 순서대로 수집해 복원(도구별 분산 방식 무관). 반환: 비밀 문자열.
    async function reveal(text, password) {
      const stream = [...text].filter(_has).join('');
      if (!stream) throw new Error('숨겨진 스트림이 없습니다');
      const { encrypt, integrity, data } = _concealToData(_expand(stream));
      const dec = encrypt ? await _decrypt(data, password, integrity) : data;
      return new TextDecoder().decode(_lzDecompress(_compliment(dec)));
    }
    // 비밀(message)을 cover 문장에 숨긴다(2단어 이상). integrity=HMAC 무결성.
    async function hide(message, password, cover, integrity = true) {
      const arr = String(cover).split(' ');
      if (arr.length < 2) throw new Error('cover 문장은 최소 2단어가 필요합니다');
      const payload = await _encrypt(_compliment(_lzCompress(message)), password, integrity);
      const zwcStream = ZWC[0] + _dataToZWC(integrity, true, payload); // no-op shrink(flag zwc0)+conceal
      return arr.slice(0,1).concat([zwcStream + arr[1]]).concat(arr.slice(2)).join(' ');
    }
    const hasHidden = text => [...String(text)].some(_has);
    return { reveal, hide, hasHidden, ZWC };
  })();

  /* ── 코드(StegCloak) 인코딩 팝오버 (요구2) ── 겉보기 문장(cover)에 비밀을 숨겨 반환. 채널 비번 사용. */
  // cover 문장 풀(다양화) — 팝오버 열 때마다 무작위 1개를 기본 선택해 매번 겉보기가 달라지게 한다.
  const STEG_COVERS = [
    { label: '일상 · 날씨',   text: '오늘 날씨가 꽤 괜찮아서 잠깐 산책이라도 다녀올까 고민 중이에요.' },
    { label: '일상 · 음식',   text: '방금 간단하게 요기했는데 생각보다 훨씬 맛있어서 좀 놀랐네요.' },
    { label: '일상 · 주말',   text: '이번 주말엔 별다른 계획 없이 그냥 집에서 푹 쉬려고 합니다.' },
    { label: '인사 · 안부',   text: '안녕하세요 오늘도 무리하지 마시고 좋은 하루 보내시길 바랍니다.' },
    { label: '인사 · 감사',   text: '늘 챙겨주셔서 정말 감사드리고 다음에 꼭 다시 인사드리겠습니다.' },
    { label: '공지 · 안내',   text: '문의해 주신 내용은 확인하는 대로 순차적으로 안내해 드리겠습니다.' },
    { label: 'EN · casual',  text: 'Just finished my coffee and the whole morning already feels a bit brighter than before.' },
    { label: 'EN · weather', text: 'The sky looks clear today so maybe a short quiet walk would be a nice idea.' },
    { label: 'EN · greeting',text: 'Hello there, I really hope you are having a calm and pleasant day so far.' },
    { label: 'EN · quote',   text: 'Every long journey always begins with a single small and quiet step forward.' },
    { label: 'Lorem ipsum',  text: 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore.' },
  ];
  // 로컬 cover 문장 생성기(템플릿+단어뱅크) — API/네트워크 없이 자연스러운 겉보기 문장을 즉석 생성('AI 생성' 대체).
  function stegGenerateCover() {
    const p = a => a[Math.floor(Math.random() * a.length)];
    const KO = [
      () => `${p(['오늘','요즘','방금','아까'])} ${p(['간단하게','오랜만에','대충'])} ${p(['밥을','커피를','간식을','라면을'])} ${p(['먹었는데','마셨는데'])} ${p(['생각보다','정말','의외로'])} ${p(['괜찮았어요','맛있더라고요','좋았어요'])}.`,
      () => `${p(['오늘','내일','주말에'])} ${p(['날씨가','바람이','하늘이'])} ${p(['맑아서','선선해서','포근해서'])} ${p(['산책하기','나가기','쉬기'])} ${p(['딱 좋은','참 좋은','괜찮은'])} 하루네요.`,
      () => `${p(['안녕하세요','반갑습니다'])} 오늘도 ${p(['좋은','편안한','즐거운'])} 하루 ${p(['보내세요','되세요','보내시길 바랍니다'])}.`,
      () => `${p(['이번 주는','요새는','한동안은'])} ${p(['좀 바빴는데','정신없었는데','여유로웠는데'])} 그래도 ${p(['버틸 만해요','괜찮네요','즐겁습니다'])}.`,
    ];
    const EN = [
      () => `The ${p(['sky','weather','morning','air'])} looks ${p(['clear','calm','bright','nice'])} today so maybe a short ${p(['walk','break','coffee'])} would be ${p(['nice','great','good'])}.`,
      () => `Just ${p(['finished','made','had'])} my ${p(['coffee','lunch','tea'])} and the day already feels a bit ${p(['brighter','calmer','better'])}.`,
      () => `I really hope you are having a ${p(['calm','pleasant','wonderful','good'])} and ${p(['quiet','relaxing','easy'])} day so far.`,
    ];
    return p(Math.random() < 0.6 ? KO : EN)();
  }

  /* ================================================================
     댓글 변환 툴바 (요구1) — 댓글 작성창(textarea)에 점자/base64 변환 버튼 추가.
     선택 텍스트가 있으면 그 부분만, 없으면 전체를 변환해 그 자리에 되쓴다(코네 UI와 어울리는 소형 바).
  ================================================================ */
  function ctoolConvert(ta, mode) {
    const s = ta.selectionStart || 0, e = ta.selectionEnd || 0;
    const hasSel = e > s;
    const src = hasSel ? ta.value.slice(s, e) : ta.value;
    if (!src) return;
    // 코드(StegCloak)는 mountCommentTools의 인라인 칩 스트립에서 처리(ctoolConvert는 점자/base64만).
    let out;
    try {
      if (mode === 'braille')      out = BR.encode(src);
      else if (mode === 'base64')  out = btoa(unescape(encodeURIComponent(src)));
      else return;
    } catch (err) { return; }
    const val = hasSel ? ta.value.slice(0, s) + out + ta.value.slice(e) : out;
    setNativeValue(ta, val); // React/코네 textarea에 값+input 이벤트 반영
    ta.focus();
    const np = hasSel ? s + out.length : out.length;
    try { ta.setSelectionRange(np, np); } catch (err) {}
  }
  function mountCommentTools() {
    if (!CFG.COMMENT_TOOLS) return;
    const root = document.getElementById('post-comment');
    if (!root) return;
    root.querySelectorAll('textarea').forEach(ta => {
      if (ta.dataset._ctool) return;
      const ph = ta.getAttribute('placeholder') || '';
      if (!/댓글|답글|comment/i.test(ph)) return; // 댓글 작성창만
      ta.dataset._ctool = '1';
      const bar = document.createElement('div');
      bar.className = 'b64-ctool-bar';
      // 이모지 대신 인라인 SVG 아이콘(세련화 + 점자 이모지가 폰트에 따라 안 보이던 문제 해결).
      const _icBraille = '<svg class="b64-ctool-ic" viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><circle cx="8.5" cy="6" r="2.1"/><circle cx="15.5" cy="6" r="2.1"/><circle cx="8.5" cy="12" r="2.1"/><circle cx="15.5" cy="12" r="2.1"/><circle cx="8.5" cy="18" r="2.1"/><circle cx="15.5" cy="18" r="2.1"/></svg>';
      const _icCode64 = '<svg class="b64-ctool-ic" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>';
      const _icLock = '<svg class="b64-ctool-ic" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="10.5" width="16" height="10" rx="2"/><path d="M8 10.5V7a4 4 0 0 1 8 0v3.5"/></svg>';
      const mk = (icon, label, mode, title) => {
        const b = document.createElement('button');
        b.type = 'button'; b.className = 'b64-ctool-btn'; b.title = title;
        b.innerHTML = icon + '<span>' + label + '</span>';
        b.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); ctoolConvert(ta, mode); });
        return b;
      };
      bar.appendChild(mk(_icBraille, '점자', 'braille', '선택 또는 전체를 점자로 변환'));
      bar.appendChild(mk(_icCode64, 'base64', 'base64', '선택 또는 전체를 base64로 변환'));
      // 코드 버튼: 팝오버 대신 툴바 아래 '미니멀 칩' 스트립을 토글(버전 C). 칩=현재 겉보기 문장(클릭→새 문장),
      //   무채색 '코드 생성' 버튼. 별도 비번칸 없음 — 채널 비번 사용, 없으면 토스트 안내.
      const _icGen = '<svg class="b64-ctool-ic" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><polyline points="21 3 21 8 16 8"/></svg>';
      const codeBtn = document.createElement('button');
      codeBtn.type = 'button'; codeBtn.className = 'b64-ctool-btn'; codeBtn.title = '숨은 코드(StegCloak) — 채널 비번 사용';
      codeBtn.innerHTML = _icLock + '<span>코드</span>';
      bar.appendChild(codeBtn);
      const codeStrip = document.createElement('div');
      codeStrip.className = 'b64-ctool-code b64-ctool-code-hide';
      // 🔄 = 새 겉보기 문장 자동 생성 / 입력칸 = 직접 타이핑 가능(사용자 요청). 입력칸 값이 실제 cover.
      // 비번 출처 안내(어떤 비번으로 코드화될지) — 채널 전용 비번이 없으면 '공동 비번' 경고 문구 표시.
      const pwNote = document.createElement('div');
      pwNote.className = 'b64-ctool-pwnote';
      const updatePwNote = () => {
        const info = getStegEncodePw();
        pwNote.textContent = info ? ('비번: ' + stegPwLabel(info)) : '등록된 비번이 없습니다 — 비번 관리에서 채널/공동 비번을 등록하세요';
        pwNote.classList.toggle('b64-ctool-pwnote-warn', !info || info.src === 'global');
      };
      const row = document.createElement('div');
      row.className = 'b64-ctool-row';
      const regen = document.createElement('button');
      regen.type = 'button'; regen.className = 'b64-ctool-regen'; regen.title = '새 겉보기 문장 생성';
      regen.innerHTML = _icGen;
      const coverIn = document.createElement('input');
      coverIn.type = 'text'; coverIn.className = 'b64-ctool-cover'; coverIn.setAttribute('autocomplete', 'off');
      coverIn.placeholder = '겉보기 문장 (직접 입력 가능, 2단어 이상)';
      coverIn.value = stegGenerateCover();
      regen.addEventListener('click', ev => { ev.preventDefault(); ev.stopPropagation(); coverIn.value = stegGenerateCover(); coverIn.focus(); });
      const gen = document.createElement('button');
      gen.type = 'button'; gen.className = 'b64-ctool-gen'; gen.textContent = '코드 생성';
      gen.addEventListener('click', async ev => {
        ev.preventDefault(); ev.stopPropagation();
        const s = ta.selectionStart || 0, e = ta.selectionEnd || 0; const hasSel = e > s;
        const secret = hasSel ? ta.value.slice(s, e) : ta.value;
        if (!secret || !secret.trim()) { showNavToast('숨길 내용을 입력하세요'); return; }
        const cover = coverIn.value.trim();
        if (!cover || cover.split(/\s+/).length < 2) { showNavToast('겉보기 문장은 2단어 이상이어야 합니다'); coverIn.focus(); return; }
        const info = getStegEncodePw();
        if (!info) { showNavToast('등록된 비번이 없습니다 — 비번 관리에서 채널 또는 공동 비번을 등록하세요'); return; }
        gen.disabled = true; const lab = gen.textContent; gen.textContent = '생성 중…';
        try {
          const out = await SC.hide(secret, info.pw, cover, true);
          const val = hasSel ? ta.value.slice(0, s) + out + ta.value.slice(e) : out;
          setNativeValue(ta, val); ta.focus();
          const np = hasSel ? s + out.length : out.length; try { ta.setSelectionRange(np, np); } catch (err) {}
          codeStrip.classList.add('b64-ctool-code-hide');
          // 어떤 비번으로 코드화됐는지 표시(공동 비번을 쓴 경우 추가 문구 포함).
          showNavToast(`코드 생성됨 · ${stegPwLabel(info)} 「${info.pw}」`);
        } catch (err) { showNavToast('코드 생성 실패: ' + (err.message || err)); }
        finally { gen.disabled = false; gen.textContent = lab; }
      });
      coverIn.addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); gen.click(); } });
      row.appendChild(regen); row.appendChild(coverIn); row.appendChild(gen);
      codeStrip.appendChild(pwNote); codeStrip.appendChild(row);
      codeBtn.addEventListener('click', ev => {
        ev.preventDefault(); ev.stopPropagation();
        // 열 때 입력칸 값은 보존(직접 쓴 문장 유지) — 자동 생성은 🔄로만. 비번 안내는 열 때마다 갱신.
        if (codeStrip.classList.contains('b64-ctool-code-hide')) { updatePwNote(); codeStrip.classList.remove('b64-ctool-code-hide'); coverIn.focus(); }
        else codeStrip.classList.add('b64-ctool-code-hide');
      });
      // 요구2: '코네콘/작성' 버튼과 같은 줄에 배치(그 justify-end flex를 space-between으로). 코드 칩 스트립은 그 줄 아래.
      let host = ta.parentElement, btnBar = null;
      for (let i = 0; i < 5 && host; i++) {
        const cand = host.querySelector('.justify-end');
        if (cand && cand.querySelector('button')) { btnBar = cand; break; }
        host = host.parentElement;
      }
      if (btnBar) { btnBar.style.justifyContent = 'space-between'; btnBar.insertBefore(bar, btnBar.firstChild); btnBar.insertAdjacentElement('afterend', codeStrip); }
      else { ta.insertAdjacentElement('afterend', bar); bar.insertAdjacentElement('afterend', codeStrip); }
    });
  }

  function flush() {
    pending = false;
    if (mo) mo.disconnect();
    if (!document.body) { observe(); return; }
    injectOGHideStyle();
    captureChannelName(); // 채널 페이지의 채널명 자동 추출(코드→이름 매핑, 1회면 채움). 작성 페이지 포함.
    // 글 작성 페이지에서는 모든 처리 비활성화 (복호화 무한 증식 방지)
    if (isWritePage()) { _wasWritePage = true; return; }

    const { roots: contentRoots, allRoots: allContentRoots } = getContentRoots();
    // write 페이지에서 돌아온 직후: Froala 에디터 DOM이 아직 남아있으면 재대기
    // contenteditable 속성이 먼저 제거되는 경우 대비 → Froala 전용 클래스로 체크
    if (_wasWritePage) {
      if (document.querySelector('.fr-element, .fr-wrapper, .fr-toolbar')) {
        pending = true;
        setTimeout(flush, 300);
        return;
      }
      _wasWritePage = false;
    }
    _seenProductCodes.clear();
    _gcSeenInFlush.clear();
    if (CFG.CONTENT_DECODE) {
      // 1) 분할 base64 먼저: walkAndProcess 전에 실행해야 원본 조각 정보 보존.
      //    (요구0: trySiblingMerge가 '버튼 담은 블록'을 재구성/제거하지 않도록 scan/processSingle에서
      //     button 포함 블록을 후보에서 제외 — 댓글 우측 액션 버튼 보존 + 다운로드 카드 생성 둘 다 만족.)
      contentRoots.forEach(el => trySiblingMerge(el));
      // 2) 개별 노드 처리: 합산에서 제외된 요소들 (개별 디코딩 가능한 것들)
      contentRoots.forEach(el => walkAndProcess(el, processContentNode));
    }
    if (CFG.BRAILLE_DECODE) contentRoots.forEach(el => decodeBrailleIn(el));
    if (CFG.STEG_DECODE) contentRoots.forEach(el => decodeStegIn(el));
    flushListTitles(contentRoots, allContentRoots);
    injectTitleCards(contentRoots);
    convertProductLinks(contentRoots);
    buildGenreBar(contentRoots);
    buildLinkPanel(contentRoots);
    injectDupCheckBtn();
    observe();
    applyTxtLiveColors();
    autoExpandComments();
    mountCommentTools();

    // 첫 번째 링크 카드로 스크롤 (페이지당 1회, 최초 복호화 시)
    if (CFG.SCROLL_TO_FIRST && !_scrolledOnce) {
      const firstLink = document.querySelector('.b64-link');
      if (firstLink) {
        _scrolledOnce = true;
        setTimeout(() => firstLink.scrollIntoView({ behavior: 'smooth', block: 'center' }), 150);
      }
    }
  }

  function applyLive() {
    // 1. convertProductLinks 교체 카드(a[data-b64d-orig]) → 원본 <a> 복원.
    //    복원한 카드의 키를 기록해 둔다(아래 1.5에서 '카드가 숨긴 원본 <a>'를 정확히 정리하기 위함).
    const _restoredKeys = new Set();
    document.querySelectorAll(`[${ORIG_ATTR}]`).forEach(card => {
      if (!card.parentNode) return;
      if (card.dataset.b64key) _restoredKeys.add(card.dataset.b64key);
      const origHtml = card.getAttribute(ORIG_ATTR);
      const tmp = document.createElement('div');
      tmp.innerHTML = origHtml;
      const origEl = tmp.firstChild;
      if (origEl) card.parentNode.replaceChild(origEl, card);
    });

    // 1.5 카드로 대체됐던 <a>(DONE+b64key, 카드 클래스 아님) 처리. 두 갈래를 구분한다:
    //   (a) convertProductLinks가 카드로 바꾸며 '숨긴 원본 <a>'(display:none). 1단계가 그 카드를
    //       origHtml로 이미 복원했으므로(같은 키가 _restoredKeys에 있음) 이 숨긴 원본은 잉여 → 제거.
    //       ★과거엔 이걸 '복원(display 켜기)'해서, 복원본(1단계)+원본(여기) 2벌이 됐고 다음 flush가
    //         둘 다 카드화 → 설정 토글마다 카드가 배수로 폭증(fanbox 등에서 수천 개)하던 근본 원인.
    //   (b) NO_DUP 중복으로 남긴 '보이는 평문 링크'(카드 짝 아님) → DONE/키만 해제해 다음 flush가 재평가.
    document.querySelectorAll(`a[data-b64key][${DONE_ATTR}]:not(.b64-link):not(.b64-product-link)`).forEach(a => {
      if (a.closest('#b64d-link-panel')) return;
      if (a.style.display === 'none' && _restoredKeys.has(a.dataset.b64key)) { a.remove(); return; }
      a.removeAttribute(DONE_ATTR);
      a.style.removeProperty('display');
      delete a.dataset.b64key;
    });

    // 2. processContentNode wrap(span[data-b64d-raw]) → 원본 텍스트 복원
    document.querySelectorAll(`span[${RAW_ATTR}]`).forEach(el => {
      const raw = el.getAttribute(RAW_ATTR);
      if (el.parentNode) el.parentNode.replaceChild(document.createTextNode(raw), el);
    });

    // 2.4 분할/색상 base64 합산 wrap(span[data-b64merge]) → 원본 조각 복원 (CONTENT_DECODE 즉시 토글 반영).
    //     기존엔 RAW_ATTR가 없어 applyLive가 못 되돌려 '새로고침해야 적용'되던 문제(색깔 여러 개 인코딩).
    document.querySelectorAll('span[data-b64merge]').forEach(el => {
      const host = el.parentNode; if (!host) return;
      let orig = el._b64mergeOrig;
      const sibs = el._b64mergeSibs;
      // SvelteKit이 DOM을 교체하면 JS 프로퍼티(_b64mergeOrig)가 소실 → data 속성에서 복원
      if (!orig) {
        const html = el.getAttribute('data-b64merge-html');
        if (html) { const tmp = document.createElement('span'); tmp.innerHTML = html; orig = document.createDocumentFragment(); while (tmp.firstChild) orig.appendChild(tmp.firstChild); }
      }
      if (orig) host.replaceChild(orig, el); else host.removeChild(el); // wrap → 원본 조각
      if (sibs && host.parentNode) { // applyMerge: 합쳐졌던 형제 블록 재삽입
        let after = host;
        sibs.forEach(s => { if (s) { host.parentNode.insertBefore(s, after.nextSibling); after = s; } });
      }
    });

    // 2.5 점자 디코딩 span → 원본 점자 복원 (점자 변환 옵션 즉시 반영용)
    document.querySelectorAll('span.b64-braille[data-b64br]').forEach(el => {
      const orig = el.getAttribute('data-b64br');
      if (el.parentNode) el.parentNode.replaceChild(document.createTextNode(orig), el);
    });

    // 3. processListNode 결과 복원: span[data-b64lt="src"] + 바로 뒤 decoded span 제거
    document.querySelectorAll(`span[${LTDONE_ATTR}="src"]`).forEach(hideWrap => {
      const parent = hideWrap.parentNode;
      if (!parent) return;
      const next = hideWrap.nextSibling;
      parent.insertBefore(document.createTextNode(hideWrap.textContent), hideWrap);
      parent.removeChild(hideWrap);
      if (next && next.nodeType === Node.ELEMENT_NODE &&
          next.hasAttribute(LTDONE_ATTR) && next.getAttribute(LTDONE_ATTR) !== 'src') {
        parent.removeChild(next);
      }
    });

    // 4. 제목 카드 바 제거 + TITLE_CARD_ATTR 초기화
    document.querySelectorAll('.b64-title-card-bar').forEach(el => el.remove());
    document.querySelectorAll(`[${TITLE_CARD_ATTR}]`).forEach(el => el.removeAttribute(TITLE_CARD_ATTR));

    // 4.5 장르·태그 별도 바 제거 + 숨겨둔 원본 장르 <a> 원상복구(요구1). 다음 flush가 GENRE_BAR 상태에
    //     맞춰 다시 그린다(ON=바 재생성+재숨김, OFF=코네 원본 장르 링크 그대로 노출).
    document.querySelectorAll('.b64-genre-bar').forEach(el => el.remove());
    document.querySelectorAll('a[data-b64genre]').forEach(a => {
      a.removeAttribute(DONE_ATTR);
      a.style.removeProperty('display');
      delete a.dataset.b64genre;
    });

    // 4.6 숨은 코드(StegCloak) host를 원본 겉보기(cover) 텍스트로 원상복구(요구2/수정1). 성공 시엔 복호
    //     결과가 들어있으므로 자식 이동이 아니라 data-b64steg의 원본 문자열로 되돌린다. 다음 flush가 재복원.
    document.querySelectorAll('span.b64-steg-host[data-b64steg]').forEach(host => {
      const parent = host.parentNode; if (!parent) return;
      parent.replaceChild(document.createTextNode(host.getAttribute('data-b64steg') || ''), host);
    });

    // 중복 코드 Set도 초기화 → NO_DUPLICATE_CARD 켜고/끌 때 즉시 반영
    _seenProductCodes.clear();
    _gcSeenInFlush.clear();
    processedRaws.clear();
    processedListRaws.clear();
    _kpIdx = -1;
    schedule();
  }

  function schedule(delayMs) {
    if (pending) return;
    pending = true;
    if (delayMs > 0) setTimeout(flush, delayMs);
    else queueMicrotask(flush); // 일반 mutation: 페인트 전 즉시 실행 → 깜빡임 제거
  }

  function observe() {
    if (!mo) {
      mo = new MutationObserver(mutations => {
        for (const mu of mutations) {
          for (const added of mu.addedNodes) {
            if (isOurNode(added)) continue;
            if (added.nodeType === Node.ELEMENT_NODE &&
                (added.hasAttribute(DONE_ATTR) || added.hasAttribute(LTDONE_ATTR))) continue;
            schedule(); return;
          }
          // 우리가 본문에 끼운 카드가 코네 하이드레이션/재렌더로 떨어져 나가면 원본 <a>는 DONE으로
          // 숨은 채 남아 '카드만' 사라진다. MO는 addedNodes만 보던 탓에 이 제거를 놓쳐 재생성이 안 됨
          // (다운로드 링크 카드가 사라지던 근본 원인). 제거도 감지해 재flush → convertProductLinks가
          // _cardMap/b64key로 재카드화.  ★감지 대상: convertProductLinks 카드(ORIG_ATTR) +
          // buildNodes가 본문에 직접 끼운 카드(.b64-link/.b64-product-link[data-b64key], ORIG_ATTR 없음).
          // 후자가 빠져 있어, NO_DUP ON 진입 시 buildNodes 카드가 떨어져도 재flush가 안 떠 '카드 0개'가
          // 유지됐다(라이브 진단). 이제 둘 다 감지. (모아보기 패널 클론은 b64key/ORIG_ATTR 없어 무시)
          const CARD_RM_SEL = '[' + ORIG_ATTR + '], .b64-link[data-b64key], .b64-product-link[data-b64key]';
          for (const removed of mu.removedNodes) {
            if (removed.nodeType !== Node.ELEMENT_NODE) continue;
            if ((removed.matches && removed.matches(CARD_RM_SEL)) || (removed.querySelector && removed.querySelector(CARD_RM_SEL))) {
              const now = Date.now();
              if (now - _cardRmWindow > 3000) { _cardRmWindow = now; _cardRmFlushes = 0; }
              if (++_cardRmFlushes <= 8) { schedule(); return; } // 3초 내 8회까지만(코네가 계속 떼어낼 때 폭주 방지)
              return;
            }
          }
        }
      });
    }
    const root = document.body || document.documentElement;
    if (root) mo.observe(root, { childList: true, subtree: true });
  }

  window.addEventListener('pageshow', e => {
    if (e.persisted) { processedRaws.clear(); processedListRaws.clear(); schedule(); }
  });

  // 작성↔읽기 페이지 전환 알림 토스트 (화면 상단 중앙, 1.8초)
  let _navToastEl = null, _navToastTimer = null;
  function showNavToast(msg) {
    if (!document.body) return;
    if (!_navToastEl) {
      _navToastEl = document.createElement('div');
      _navToastEl.id = 'b64d-nav-toast';
      _navToastEl.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;background:#18181b;color:#fafafa;font:600 13px/1.4 system-ui,sans-serif;padding:8px 14px;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.5);pointer-events:none;max-width:90vw;white-space:nowrap;opacity:0;transition:opacity .3s;';
      document.body.appendChild(_navToastEl);
    }
    _navToastEl.textContent = msg;
    _navToastEl.style.opacity = '1';
    clearTimeout(_navToastTimer);
    _navToastTimer = setTimeout(() => { if (_navToastEl) _navToastEl.style.opacity = '0'; }, 1800);
  }

  /* ================================================================
     GreasyFork 업데이트 확인 (CFG.UPDATE_CHECK)
     - 설치본의 @updateURL(.meta.js)을 조회해 @version 비교 → 새 버전이면 알림 배너.
     - 유저스크립트 매니저 정책상 '무음 설치'는 불가 → 원클릭 업데이트 링크(.user.js) 제공.
     - GreasyFork에서 설치하면 매니저가 @updateURL/@downloadURL을 주입하므로 자동 동작.
       수동 설치판(실험판 등)은 정보가 없어 조용히 무시(수동 확인 시 안내만).
  ================================================================ */
  function cmpVer(a, b) {
    const A = String(a).split('.').map(s => parseInt(s, 10) || 0);
    const B = String(b).split('.').map(s => parseInt(s, 10) || 0);
    for (let i = 0; i < Math.max(A.length, B.length); i++) { const x = A[i] || 0, y = B[i] || 0; if (x !== y) return x - y; }
    return 0;
  }
  function notifyUpdate(latest, dlUrl) {
    if (!document.body || document.getElementById('b64d-update-banner')) return;
    const dark = document.documentElement.classList.contains('dark');
    const bar = document.createElement('div');
    bar.id = 'b64d-update-banner';
    bar.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;'
      + `background:${dark ? '#0c4a6e' : '#0369a1'};color:#e0f2fe;font:600 13px/1.4 system-ui,sans-serif;`
      + 'padding:9px 10px 9px 14px;border-radius:10px;box-shadow:0 6px 22px rgba(0,0,0,.5);display:flex;align-items:center;gap:10px;max-width:92vw;';
    // 좌측 텍스트(2줄): 안내 + '설치창이 열린다'는 점을 명확히
    const txtWrap = document.createElement('div');
    txtWrap.style.cssText = 'display:flex;flex-direction:column;gap:1px;min-width:0;';
    const txt = document.createElement('span');
    txt.innerHTML = `KONE + 새 버전 <b>v${esc(latest)}</b> 사용 가능`;
    const sub = document.createElement('span');
    sub.textContent = '‘업데이트’ → 매니저 설치창이 열립니다(거기서 설치 1회 클릭)';
    sub.style.cssText = 'font-weight:400;font-size:11px;opacity:.82;';
    txtWrap.append(txt, sub);
    const go = document.createElement('a');
    go.textContent = '업데이트'; go.href = dlUrl; go.target = '_blank'; go.rel = 'noopener noreferrer';
    go.title = '클릭하면 매니저(Tampermonkey 등) 설치/업데이트 창이 열립니다';
    go.style.cssText = 'background:#38bdf8;color:#04283b;text-decoration:none;font-weight:700;padding:4px 12px;border-radius:7px;white-space:nowrap;flex-shrink:0;';
    const x = document.createElement('button');
    x.textContent = '✕'; x.style.cssText = 'background:none;border:none;color:inherit;cursor:pointer;font-size:14px;opacity:.7;padding:0 2px;flex-shrink:0;';
    x.addEventListener('click', () => bar.remove());
    bar.append(txtWrap, go, x);
    document.body.appendChild(bar);
  }
  function runUpdateCheck(manual) {
    const info = (typeof GM_info !== 'undefined' && GM_info) ? GM_info : {};
    const sc = info.script || {};
    const cur = sc.version || info.version || '0';
    const metaUrl = sc.updateURL || info.scriptUpdateURL || sc.downloadURL || '';
    const dlUrl = sc.downloadURL || (metaUrl ? metaUrl.replace(/\.meta\.js(\?|$)/i, '.user.js$1') : '');
    if (!metaUrl) { if (manual) showNavToast('업데이트 정보 없음 (수동 설치판)'); return; }
    if (!manual) {
      const last = +GM_getValue('lastUpdateCheck', 0);
      if (Date.now() - last < 6 * 60 * 60 * 1000) return; // 6시간 throttle
    }
    GM_setValue('lastUpdateCheck', Date.now());
    if (manual) showNavToast('업데이트 확인 중…');
    GM_xmlhttpRequest({
      method: 'GET',
      url: metaUrl + (metaUrl.includes('?') ? '&' : '?') + '_=' + Date.now(),
      timeout: 10000,
      onload(r) {
        const m = /@version\s+(\S+)/.exec(r.responseText || '');
        if (!m) { if (manual) showNavToast('버전 정보를 읽지 못했습니다'); return; }
        const latest = m[1];
        if (cmpVer(latest, cur) > 0) notifyUpdate(latest, dlUrl || metaUrl);
        else if (manual) showNavToast('최신 버전입니다 (v' + cur + ')');
      },
      onerror() { if (manual) showNavToast('업데이트 확인 실패'); },
      ontimeout() { if (manual) showNavToast('업데이트 확인 시간 초과'); },
    });
  }

  /* ================================================================
     한글 점자 엔진 (인코딩/디코딩)
     점자 매핑 출처: 국립국어원 「한국 점자 규정」(2024) — 문화체육관광부 고시,
       저작권법 제7조에 따라 비보호(자유 이용). 영문 점자: Unified English Braille(UEB).
     규정 PDF를 격자(윤곽+채움) 방식으로 추출·검증. dot번호 ↔ U+2800.
  ================================================================ */
  const BR = (function () {
    function d2c(d) { if (!d) return '⠀'; let m = 0; for (const c of d) m |= 1 << (+c - 1); return String.fromCharCode(0x2800 + m); }
    function c2d(ch) { const m = ch.charCodeAt(0) - 0x2800; let s = ''; for (let i = 0; i < 6; i++) if (m & (1 << i)) s += (i + 1); return s; }
    function vc(v) { return Array.isArray(v) ? v.map(d2c).join('') : d2c(v); }

    const CHO = { 'ㄱ': '4', 'ㄴ': '14', 'ㄷ': '24', 'ㄹ': '5', 'ㅁ': '15', 'ㅂ': '45', 'ㅅ': '6', 'ㅇ': '', 'ㅈ': '46', 'ㅊ': '56', 'ㅋ': '124', 'ㅌ': '125', 'ㅍ': '145', 'ㅎ': '245' };
    const JONG = { 'ㄱ': '1', 'ㄴ': '25', 'ㄷ': '35', 'ㄹ': '2', 'ㅁ': '26', 'ㅂ': '12', 'ㅅ': '3', 'ㅇ': '2356', 'ㅈ': '13', 'ㅊ': '23', 'ㅋ': '235', 'ㅌ': '236', 'ㅍ': '256', 'ㅎ': '356' };
    const VOWEL = { 'ㅏ': '126', 'ㅑ': '345', 'ㅓ': '234', 'ㅕ': '156', 'ㅗ': '136', 'ㅛ': '346', 'ㅜ': '134', 'ㅠ': '146', 'ㅡ': '246', 'ㅣ': '135', 'ㅐ': '1235', 'ㅔ': '1345', 'ㅖ': '34', 'ㅘ': '1236', 'ㅚ': '13456', 'ㅢ': '2456', 'ㅒ': ['345', '1235'], 'ㅙ': ['1236', '1235'], 'ㅝ': '1234', 'ㅞ': ['1234', '1235'], 'ㅟ': ['134', '1235'] };
    const DOEN = '6', ON = '123456', GUBUN = '36';   // 된소리표, 온표(단독자모), 구분표(모음연쇄)
    const NUM_COLLIDE = new Set(['ㄴ', 'ㄷ', 'ㅁ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']);  // 숫자와 혼동되는 초성(제44항 다만)
    const CONS_ABBR = { 'ㄱ': '1246', 'ㄴ': '14', 'ㄷ': '24', 'ㅁ': '15', 'ㅂ': '45', 'ㅅ': '123', 'ㅈ': '46', 'ㅋ': '124', 'ㅌ': '125', 'ㅍ': '145', 'ㅎ': '245' };
    // 초성과 점형이 같은 약자(나·다·마·바·자·카·타·파·하). 뒤에 모음 음절이 오면 초성으로 오독 →
    // 제18항: 약자 뒤에 구분표(36)를 적어 구분. (가=1246·사=123은 충돌 없음 → 제외)
    const COLLIDE_ABBR = new Set(['ㄴ', 'ㄷ', 'ㅁ', 'ㅂ', 'ㅈ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']);
    const COLLIDE_ABBR_CELL = new Set(['14', '24', '15', '45', '46', '124', '125', '145', '245']);
    const RIME_ABBR = { 'ㅓㄱ': '1456', 'ㅓㄴ': '23456', 'ㅓㄹ': '2345', 'ㅕㄴ': '16', 'ㅕㄹ': '1256', 'ㅕㅇ': '12456', 'ㅗㄱ': '1346', 'ㅗㄴ': '12356', 'ㅗㅇ': '123456', 'ㅜㄴ': '1245', 'ㅜㄹ': '12346', 'ㅡㄴ': '1356', 'ㅡㄹ': '2346', 'ㅣㄴ': '12345' };
    const GEOT = ['456', '234'];
    const ABBR_WORD = { '그래서': ['1', '234'], '그러나': ['1', '14'], '그러면': ['1', '25'], '그러므로': ['1', '26'], '그런데': ['1', '1345'], '그리고': ['1', '136'], '그리하여': ['1', '156'] };
    const NUMMARK = '3456';
    const DIGIT = { '0': '245', '1': '1', '2': '12', '3': '14', '4': '145', '5': '15', '6': '124', '7': '1245', '8': '125', '9': '24' };
    const ROMAN = { a: '1', b: '12', c: '14', d: '145', e: '15', f: '124', g: '1245', h: '125', i: '24', j: '245', k: '13', l: '123', m: '134', n: '1345', o: '135', p: '1234', q: '12345', r: '1235', s: '234', t: '2345', u: '136', v: '1236', w: '2456', x: '1346', y: '13456', z: '1356' };
    const ROM_START = '356', ROM_END = '256', CAP = '6';
    // 문장부호 (제49항, 격자추출). 다중 칸은 배열.
    const OPEN_DQ = '236', CLOSE_DQ = '356', OPEN_SQ = ['6', '236'], CLOSE_SQ = ['356', '3'];
    const PUNCT = {
      '.': '256', '?': '236', '!': '235', ',': '5',
      '·': ['5', '23'], ':': ['5', '2'], '/': ['456', '34'], '…': ['6', '6', '6'],
      '“': OPEN_DQ, '”': CLOSE_DQ, '‘': OPEN_SQ, '’': CLOSE_SQ,
      '(': ['236', '3'], ')': ['6', '356'], '{': ['236', '2'], '}': ['5', '356'],
      '[': ['236', '23'], ']': ['56', '356'],
      '『': ['56', '236'], '』': ['356', '23'], '「': ['5', '236'], '」': ['356', '2'],
      '《': ['56', '2356'], '》': ['2356', '23'], '〈': ['5', '2356'], '〉': ['2356', '2'],
      '—': ['36', '36'], '–': '36', '-': '36', '~': ['4', '35'],
      '○': ['456', '356', '123'], '×': ['456', '1346', '123'], '△': ['456', '346', '123'], '□': ['456', '2356', '123'],
      // ASCII 기호(2024 규정). 받침/로마자표와 충돌하는 단일칸 수학기호(+26·등호25,25 등)와
      // %(356,1234=로마자시작표+p 충돌)는 제외. URL에 흔한 다중칸 기호만 안전하게 추가.
      '_': ['456', '36'], '@': ['4', '1'], '^': ['4', '26'], '#': ['456', '1456'],
      '&': ['4', '12346'], '\\': ['456', '16'], '|': ['456', '1256'], ';': ['56', '23'],
    };
    const CHO_L = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
    const JUNG_L = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'];
    const JONG_L = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
    const DOEN_MAP = { 'ㄲ': 'ㄱ', 'ㄸ': 'ㄷ', 'ㅃ': 'ㅂ', 'ㅆ': 'ㅅ', 'ㅉ': 'ㅈ' };
    const DOEN_INV = { 'ㄱ': 'ㄲ', 'ㄷ': 'ㄸ', 'ㅂ': 'ㅃ', 'ㅅ': 'ㅆ', 'ㅈ': 'ㅉ' };
    const JONG_SPLIT = { 'ㄳ': ['ㄱ', 'ㅅ'], 'ㄵ': ['ㄴ', 'ㅈ'], 'ㄶ': ['ㄴ', 'ㅎ'], 'ㄺ': ['ㄹ', 'ㄱ'], 'ㄻ': ['ㄹ', 'ㅁ'], 'ㄼ': ['ㄹ', 'ㅂ'], 'ㄽ': ['ㄹ', 'ㅅ'], 'ㄾ': ['ㄹ', 'ㅌ'], 'ㄿ': ['ㄹ', 'ㅍ'], 'ㅀ': ['ㄹ', 'ㅎ'], 'ㅄ': ['ㅂ', 'ㅅ'] };

    function decomp(ch) { const c = ch.charCodeAt(0) - 0xAC00; return [CHO_L[Math.floor(c / 588)], JUNG_L[Math.floor((c % 588) / 28)], JONG_L[c % 28]]; }
    function encJong(j) {
      if (j === 'ㄲ') return d2c('1') + d2c('1');
      if (j === 'ㅆ') return d2c('34');
      if (JONG_SPLIT[j]) { const [a, b] = JONG_SPLIT[j]; return encJong(a) + encJong(b); }
      return d2c(JONG[j]);
    }
    function encSyllable(ch) {
      if (ch === '것') return vc(GEOT);
      const [cho0, jung, jong] = decomp(ch);
      let cho = cho0, doen = false;
      if (DOEN_MAP[cho]) { doen = true; cho = DOEN_MAP[cho]; }
      const pre = doen ? d2c(DOEN) : '';
      if (jong === 'ㅇ' && jung === 'ㅓ' && (cho === 'ㅅ' || cho === 'ㅈ' || cho === 'ㅊ'))
        return pre + d2c(CHO[cho]) + d2c(RIME_ABBR['ㅕㅇ']);
      const rk = jung + jong;
      if (jong && RIME_ABBR[rk]) return pre + (cho !== 'ㅇ' ? d2c(CHO[cho]) : '') + vc(RIME_ABBR[rk]);
      if (jung === 'ㅏ' && CONS_ABBR[cho]) return pre + d2c(CONS_ABBR[cho]) + (jong ? encJong(jong) : '');
      return pre + (cho !== 'ㅇ' ? d2c(CHO[cho]) : '') + vc(VOWEL[jung]) + (jong ? encJong(jong) : '');
    }
    function encJamo(ch) {  // 단독 자모(제8항): 온표 + (자음=받침형 / 모음=모음)
      if (VOWEL[ch]) return d2c(ON) + vc(VOWEL[ch]);
      if (DOEN_MAP[ch]) return d2c(ON) + encJong(ch);
      if (JONG[ch] !== undefined) return d2c(ON) + d2c(JONG[ch]);
      return ch;
    }
    function encRoman(run) {
      let s = d2c(ROM_START);
      for (const ch of run) {
        if (ch === ' ') { s += d2c(''); continue; }
        const low = ch.toLowerCase();
        if (ROMAN[low]) { if (ch !== low) s += d2c(CAP); s += d2c(ROMAN[low]); } else s += ch;
      }
      return s + d2c(ROM_END);
    }
    function isRoman(c) { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'; }
    function encode(text) {
      let out = '', i = 0, inNum = false, prev = null, dqOpen = false, sqOpen = false;
      while (i < text.length) {
        let w = null;
        for (const k in ABBR_WORD) if (text.startsWith(k, i) && (!w || k.length > w.length)) w = k;
        if (w) { out += vc(ABBR_WORD[w]); i += w.length; inNum = false; prev = null; continue; }
        const ch = text[i];
        if (ch >= '0' && ch <= '9') { if (!inNum) { out += d2c(NUMMARK); inNum = true; } out += d2c(DIGIT[ch]); i++; prev = null; continue; }
        const wasNum = inNum; inNum = false;
        if (isRoman(ch)) {
          let j = i, run = '';
          while (j < text.length) {
            if (isRoman(text[j])) { run += text[j]; j++; }
            else if (text[j] === ' ' && j + 1 < text.length && isRoman(text[j + 1])) { run += ' '; j++; }
            else break;
          }
          out += encRoman(run); i = j; prev = null; continue;
        }
        if (ch >= '가' && ch <= '힣') {
          const [cho, jung, jong] = decomp(ch);
          // 제44항 [다만]: 숫자 뒤 ㄴ/ㄷ/ㅁ/ㅋ/ㅌ/ㅍ/ㅎ 초성 또는 '운'이 붙으면 띄어 씀
          if (wasNum && (NUM_COLLIDE.has(cho) || (cho === 'ㅇ' && jung === 'ㅜ' && jong === 'ㄴ'))) out += ' ';
          if (prev && prev.jong === '') {  // 모음 연쇄 구분표(제11·12항)
            if (cho === 'ㅇ' && jung === 'ㅖ') out += d2c(GUBUN);
            else if (cho === 'ㅇ' && jung === 'ㅐ' && (prev.jung === 'ㅑ' || prev.jung === 'ㅘ' || prev.jung === 'ㅜ' || prev.jung === 'ㅝ')) out += d2c(GUBUN);
            else if (cho === 'ㅇ' && prev.collideAbbr) out += d2c(GUBUN); // 초성형 약자 + 모음 음절 구분(제18항)
          }
          out += encSyllable(ch);
          // 초성형 약자(받침 없는 ㄴ/ㄷ/…/하 + ㅏ, 된소리 아님)로 적혔는지 기록 → 다음 모음과 구분
          prev = { jung, jong, collideAbbr: jung === 'ㅏ' && jong === '' && !DOEN_MAP[cho] && COLLIDE_ABBR.has(cho) };
          i++; continue;
        }
        if (ch >= 'ㄱ' && ch <= 'ㅣ') { out += encJamo(ch); i++; prev = null; continue; }
        if (ch === '"') { out += vc(dqOpen ? CLOSE_DQ : OPEN_DQ); dqOpen = !dqOpen; i++; prev = null; continue; }
        if (ch === "'") { out += vc(sqOpen ? CLOSE_SQ : OPEN_SQ); sqOpen = !sqOpen; i++; prev = null; continue; }
        if (PUNCT[ch] !== undefined) { out += vc(PUNCT[ch]); i++; prev = null; continue; }
        out += ch; i++; prev = null;
      }
      return out;
    }

    // ── 디코딩 ──
    const CHO_R = {}; for (const k in CHO) if (CHO[k]) CHO_R[CHO[k]] = k;
    const JONG_R = {}; for (const k in JONG) JONG_R[JONG[k]] = k;
    const VOW1 = {}; const VOW2 = []; for (const k in VOWEL) { const v = VOWEL[k]; if (Array.isArray(v)) VOW2.push([v[0], v[1], k]); else VOW1[v] = k; }
    const SYL_R = {}; for (const k in CONS_ABBR) SYL_R[CONS_ABBR[k]] = k;
    const RIME_R = {}; for (const k in RIME_ABBR) RIME_R[RIME_ABBR[k]] = k;
    const DIGIT_R = {}; for (const k in DIGIT) DIGIT_R[DIGIT[k]] = k;
    const WORD2_R = {}; for (const k in ABBR_WORD) WORD2_R[ABBR_WORD[k][1]] = k;
    const ROMAN_R = {}; for (const k in ROMAN) ROMAN_R[ROMAN[k]] = k;
    const PUNCT1_R = {}, PUNCT2_R = {}, PUNCT3_R = {};
    for (const k in PUNCT) { const v = PUNCT[k]; if (!Array.isArray(v)) { if (!PUNCT1_R[v]) PUNCT1_R[v] = k; } else if (v.length === 2) { if (!PUNCT2_R[v.join(',')]) PUNCT2_R[v.join(',')] = k; } else if (v.length === 3) { if (!PUNCT3_R[v.join(',')]) PUNCT3_R[v.join(',')] = k; } }
    const PLAIN_CHO = ['4', '24', '45', '6', '46'];
    const SIOT_GROUP = new Set(['ㅅ', 'ㅆ', 'ㅈ', 'ㅉ', 'ㅊ']);

    // 받침↔문장부호 충돌 셀: 236=ㅌ받침/물음표/여는따옴, 235=ㅋ받침/느낌표, 256=ㅍ받침/마침표.
    // 어절 끝에서 둘 중 무엇인지 점자만으론 모호 → 받침으로 읽은 음절이 빈출 종결음절(FINAL_SET)에
    // 있으면 받침, 아니면 문장부호로 분기(제3항 받침 vs 제5장 문장부호 동형 보정).
    const COLLIDE = new Set(['236', '235', '256']);
    let FINAL_SET = new Set();
    function setFinalSet(words) { FINAL_SET = new Set((Array.isArray(words) ? words : String(words).split(/\s+/)).filter(Boolean)); }

    function compose(cho, jung, jong) {
      const ci = CHO_L.indexOf(cho), ji = JUNG_L.indexOf(jung), ki = JONG_L.indexOf(jong || '');
      if (ci < 0 || ji < 0 || ki < 0) return null;
      return String.fromCharCode(0xAC00 + (ci * 21 + ji) * 28 + ki);
    }
    function isVowelAt(ds, i) {
      if (i >= ds.length || ds[i] == null) return null;
      if (i + 1 < ds.length) for (const [a, b, nm] of VOW2) if (ds[i] === a && ds[i + 1] === b) return [nm, 2];
      if (VOW1[ds[i]]) return [VOW1[ds[i]], 1];
      return null;
    }
    function isChoAt(ds, i) {
      if (i >= ds.length || ds[i] == null || CHO_R[ds[i]] === undefined) return null;
      if (isVowelAt(ds, i + 1) || (ds[i + 1] != null && RIME_R[ds[i + 1]])) return CHO_R[ds[i]];
      return null;
    }
    function jongAt(ds, i, n) {
      if (i >= n || ds[i] == null) return null;
      if (ds[i] === '34') return ['ㅆ', 1];
      if (ds[i] === '1' && i + 1 < n && ds[i + 1] === '1') return ['ㄲ', 2];
      if (JONG_R[ds[i]] !== undefined) return [JONG_R[ds[i]], 1];
      return null;
    }
    function atBoundary(ds, i) { return i < 0 || i >= ds.length || ds[i] == null || ds[i] === ''; }
    function finishJong(ds, j, n, a) {
      let jong = a[0], nj = j + a[1];
      if (!isChoAt(ds, nj)) {
        const b = jongAt(ds, nj, n);
        if (b) { const dbl = Object.keys(JONG_SPLIT).find(k => JONG_SPLIT[k][0] === jong && JONG_SPLIT[k][1] === b[0]); if (dbl) { jong = dbl; nj += b[1]; } }
      }
      return [jong, nj];
    }
    function readJong(ds, j, n, cho, jung) {
      if (isChoAt(ds, j)) return ['', j];
      if (ds[j] != null && PUNCT1_R[ds[j]] && (atBoundary(ds, j + 1) || (ds[j + 1] != null && PUNCT1_R[ds[j + 1]] !== undefined))) {
        // 충돌 셀이면 빈출 종결음절 보정: 받침으로 읽은 음절이 FINAL_SET에 있으면 받침 채택
        if (COLLIDE.has(ds[j]) && cho) {
          const a = jongAt(ds, j, n);
          if (a) { const syl = compose(cho, jung, a[0]); if (syl && FINAL_SET.has(syl)) return finishJong(ds, j, n, a); }
        }
        return ['', j]; // 그 외(쉼표 등 비충돌 부호 포함) = 문장부호
      }
      if (ds[j] != null && ds[j + 1] != null && PUNCT2_R[ds[j] + ',' + ds[j + 1]]) return ['', j]; // 2칸 문장부호 시작이면 종성 아님
      const a = jongAt(ds, j, n); if (!a) return ['', j];
      return finishJong(ds, j, n, a);
    }
    function parseSyllable(ds, i, n) {
      let j = i, cho = 'ㅇ', doen = false;
      if (ds[j] === DOEN && PLAIN_CHO.includes(ds[j + 1]) && (isVowelAt(ds, j + 2) || (ds[j + 2] != null && RIME_R[ds[j + 2]]))) { doen = true; j++; }
      const c = isChoAt(ds, j);
      if (c) { cho = c; j++; }
      if (doen && DOEN_INV[cho]) cho = DOEN_INV[cho];
      if (ds[j] != null && RIME_R[ds[j]]) {
        let ju = RIME_R[ds[j]][0], jo = RIME_R[ds[j]].slice(1);
        if (ju === 'ㅕ' && jo === 'ㅇ' && SIOT_GROUP.has(cho)) ju = 'ㅓ';
        return [compose(cho, ju, jo), j + 1];
      }
      const v = isVowelAt(ds, j); if (!v) return null;
      const [jong, nj] = readJong(ds, j + v[1], n, cho, v[0]);
      const s = compose(cho, v[0], jong);
      return s ? [s, nj] : null;
    }
    function decode(str) {
      const ds = [], raw = [];
      for (const ch of str) { const cc = ch.charCodeAt(0); if (cc >= 0x2800 && cc <= 0x28ff) { ds.push(c2d(ch)); raw.push(null); } else { ds.push(null); raw.push(ch); } }
      let out = '', i = 0; const n = ds.length;
      while (i < n) {
        if (ds[i] == null) { out += raw[i]; i++; continue; }
        if (ds[i] === '') { out += ' '; i++; continue; }
        const d = ds[i];
        if (d === ROM_START && i + 1 < n && (ROMAN_R[ds[i + 1]] || ds[i + 1] === CAP)) { // 356=시작표/닫는따옴 충돌 → 뒤가 로마자일 때만
          i++; let cap = 0;
          while (i < n && ds[i] != null) {
            if (ds[i] === ROM_END) { i++; break; }
            if (ds[i] === '') { out += ' '; i++; continue; }
            if (ds[i] === CAP) { cap = cap ? 2 : 1; i++; continue; }
            if (ROMAN_R[ds[i]]) { const L = ROMAN_R[ds[i]]; out += cap ? L.toUpperCase() : L; if (cap === 1) cap = 0; i++; continue; }
            break;
          }
          continue;
        }
        if (d === NUMMARK) { i++; while (i < n && ds[i] != null && DIGIT_R[ds[i]]) { out += DIGIT_R[ds[i]]; i++; } continue; }  // 규정 제44항대로 충돌 단위는 인코딩 시 띄어쓰므로 단순 처리
        if (d === ON && i + 1 < n) {  // 온표 → 단독 자모 (자음=받침형, 모음=모음)
          if (JONG_R[ds[i + 1]] !== undefined) { out += JONG_R[ds[i + 1]]; i += 2; continue; }
          const vv = isVowelAt(ds, i + 1); if (vv) { out += VOW1[ds[i + 1]] || vv[0]; i += 1 + vv[1]; continue; }
        }
        if (d === GUBUN && (ds[i + 1] === '34' || ds[i + 1] === '1235')) { i++; continue; } // 구분표 → 건너뜀
        if (d === '1' && i + 1 < n && WORD2_R[ds[i + 1]] && atBoundary(ds, i - 1) && atBoundary(ds, i + 2)) { out += WORD2_R[ds[i + 1]]; i += 2; continue; }
        if (i + 2 < n && PUNCT3_R[d + ',' + ds[i + 1] + ',' + ds[i + 2]]) { out += PUNCT3_R[d + ',' + ds[i + 1] + ',' + ds[i + 2]]; i += 3; continue; }
        if (i + 1 < n && ds[i + 1] != null && PUNCT2_R[d + ',' + ds[i + 1]]) { out += PUNCT2_R[d + ',' + ds[i + 1]]; i += 2; continue; }
        if (d === '456' && i + 1 < n && ds[i + 1] === '234') { out += '것'; i += 2; continue; }
        const p = parseSyllable(ds, i, n);
        if (p) { out += p[0]; i = p[1]; continue; }
        let k = i, dn = false;
        if (ds[k] === DOEN && (SYL_R[ds[k + 1]] || ds[k + 1] === '1246' || ds[k + 1] === '123')) { dn = true; k++; }
        if (ds[k] != null && SYL_R[ds[k]]) {
          const abbrCell = ds[k];
          let cho2 = SYL_R[ds[k]]; if (dn && DOEN_INV[cho2]) cho2 = DOEN_INV[cho2]; k++;
          let [jong, nj] = readJong(ds, k, n, cho2, 'ㅏ');
          // 초성형 약자(나·다·…·하) + 구분표(36) + 모음 → 구분표 건너뜀(제18항: 약자/초성 모호성 복원)
          if (jong === '' && !dn && COLLIDE_ABBR_CELL.has(abbrCell) && ds[nj] === GUBUN && isVowelAt(ds, nj + 1)) nj++;
          out += compose(cho2, 'ㅏ', jong); i = nj; continue;
        }
        if (PUNCT1_R[d]) { out += (d === '236' && !atBoundary(ds, i + 1)) ? '“' : PUNCT1_R[d]; i++; continue; }
        out += d2c(d); i++;
      }
      return out;
    }

    /* ── UEB(영어) 디코더 — 디코딩 전용 ──
       외부 변환기(dencode 등)의 순수 UEB 점자엔 한글 로마자 시작/종료표가 없어 우리 decode()가
       글자(125=h)를 한글(ㅌ)로 오독한다. 이 디코더는 점자를 UEB 영어로 해석. 기호 점형=UEB 규정. */
    let UEB_SYM = {
      '256': '.', '2': ',', '236': '?', '235': '!', '25': ':', '23': ';', '3': "'",
      '36': '-', '356': '”',
      '256,256,256': '…', '6,36': '—', '6,236': '‘', '6,356': '’',
      '5,126': '(', '5,345': ')', '46,126': '[', '46,345': ']',
      '5,235': '+', '5,36': '-', '5,236': '×', '5,34': '÷', '5,2356': '=',
      '456,34': '/', '4,12346': '&', '456,1456': '#', '46,356': '%', '4,1': '@',
      '46,36': '_',
    };
    function setUebSym(map) { UEB_SYM = map || {}; }
    function cellsOf(token) {
      const cells = [];
      for (const ch of token) { const cc = ch.charCodeAt(0); cells.push((cc >= 0x2800 && cc <= 0x28ff) ? c2d(ch) : { raw: ch }); }
      return cells;
    }
    function decodeUEB(token) {
      const cs = cellsOf(token);
      let out = '', i = 0, cap = 0, num = false; const n = cs.length;
      while (i < n) {
        const d = cs[i];
        if (typeof d === 'object') { out += d.raw; cap = 0; num = false; i++; continue; }
        if (d === '') { out += ' '; cap = 0; num = false; i++; continue; }
        if (i + 2 < n && typeof cs[i + 1] === 'string' && typeof cs[i + 2] === 'string'
            && UEB_SYM[d + ',' + cs[i + 1] + ',' + cs[i + 2]] !== undefined) { out += UEB_SYM[d + ',' + cs[i + 1] + ',' + cs[i + 2]]; i += 3; cap = 0; num = false; continue; }
        if (i + 1 < n && typeof cs[i + 1] === 'string'
            && UEB_SYM[d + ',' + cs[i + 1]] !== undefined) { out += UEB_SYM[d + ',' + cs[i + 1]]; i += 2; cap = 0; num = false; continue; }
        if (d === '56') { num = false; i++; continue; }                  // grade-1 = 숫자모드 종료
        if (d === CAP) {                                                  // 점6 = 대문자 관련 표
          num = false;
          if (cs[i + 1] === CAP) { cap = 2; i += 2; continue; }           // ⠠⠠ = 대문자 단어/구절
          if (cs[i + 1] === '3') { cap = 0; i += 2; continue; }           // ⠠⠄ = 대문자 종료표(출력 없음)
          cap = 1; i++; continue;                                          // ⠠ = 단일 대문자
        }
        if (d === NUMMARK) { num = true; cap = 0; i++; continue; }         // 3456 = 수표(대문자 지시자 효력 종료)
        if (num && DIGIT_R[d]) { out += DIGIT_R[d]; i++; continue; }      // 수표 뒤 a~j = 1~0
        // UEB: 숫자 모드 내 마침표(256)·쉼표(2)는 소수점/자릿수 구분으로 모드 유지
        if (num && (d === '256' || d === '2')) { out += (d === '256' ? '.' : ','); i++; continue; }
        num = false;
        if (UEB_SYM[d] !== undefined) { out += UEB_SYM[d]; cap = 0; i++; continue; }
        if (ROMAN_R[d]) { const L = ROMAN_R[d]; out += cap ? L.toUpperCase() : L; if (cap === 1) cap = 0; i++; continue; }
        out += d2c(d); i++; // 미지 셀 = 점자 그대로(잔여 = 한글 판정 신호)
      }
      return out;
    }
    // 토글 ON 시: 이 토큰을 UEB(영어)로 봐야 하는가? URL이면 확실, 그 외엔 잔여 점자 없고 글자 있으면 영어.
    function isLikelyUEB(token) {
      const cs = cellsOf(token); // 우리 한글 로마자 시작표(356+로마자/대문자) 패턴이면 우리 포맷 → 한글
      for (let i = 0; i < cs.length - 1; i++)
        if (cs[i] === '356' && typeof cs[i + 1] === 'string' && (ROMAN_R[cs[i + 1]] || cs[i + 1] === CAP)) return false;
      const u = decodeUEB(token);
      if (/(?:https?:\/\/|www\.)/i.test(u)) return true;
      if (/[⠀-⣿]/.test(u)) return false; // 미해석 셀 잔여 = 한글
      return /[A-Za-z]/.test(u);
    }
    return { encode, decode, setFinalSet, decodeUEB, setUebSym, isLikelyUEB };
  })();
  BR.setFinalSet(CFG.BRAILLE_FINAL); // 빈출 종결음절 목록 주입 (설정 변경 시 재주입)

  // 읽기 페이지: 본문 속 점자 런을 한글로 변환 (CFG.BRAILLE_DECODE)
  const BR_RUN_RE = /[⠀-⣿]/;
  function decodeBrailleIn(root) {
    if (!root) return;
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
      acceptNode(n) {
        if (!n.nodeValue || !BR_RUN_RE.test(n.nodeValue)) return NodeFilter.FILTER_REJECT;
        const p = n.parentElement;
        if (!p || p.closest('#b64d-br,#b64d-settings,textarea,[contenteditable],script,style')) return NodeFilter.FILTER_REJECT;
        if (p.closest('[data-b64br]')) return NodeFilter.FILTER_REJECT;
        return NodeFilter.FILTER_ACCEPT;
      }
    });
    const nodes = []; let nn; while ((nn = walker.nextNode())) nodes.push(nn);
    for (const node of nodes) {
      // 공백 기준 토큰화(런 기준 X): 점자 사이에 낀 비점자 ASCII(= _ 등 PUNCT에 없는 기호)도
      // 같은 토큰으로 묶어 통째로 디코딩한다. URL은 공백이 없어 한 토큰이 되므로,
      // 'https://…/work/=/product_id/RJ…'가 '='·'_'에서 끊겨 조각조각 카드화되던 문제 방지.
      // (로마자 런 내부 공백은 인코딩 시 빈점자 U+2800로 들어가 일반 공백 분할의 영향을 받지 않음)
      const parts = node.nodeValue.split(/(\s+)/);
      if (!parts.some(p => p && BR_RUN_RE.test(p))) continue;
      const frag = document.createDocumentFragment();
      for (const part of parts) {
        if (!part) continue;
        if (BR_RUN_RE.test(part)) {
          const span = document.createElement('span');
          span.className = 'b64-braille'; span.setAttribute('data-b64br', part); // 원본 점자 저장(즉시 복원용)
          span.setAttribute(DONE_ATTR, ''); // 후속 패스(convertProductLinks 등)가 내부를 재카드화하지 않도록
          span.title = '점자 디코딩';
          // base64 결과와 동일하게 URL→링크카드·상품코드→카드·중첩base64까지 처리
          let dec;
          // UEB 옵션 ON + 이 토큰이 UEB(영어/URL)로 감지되면 UEB로 디코딩(외부 변환기 점자 대응)
          if (CFG.BRAILLE_UEB && BR.isLikelyUEB(part)) {
            dec = BR.decodeUEB(part);
            span.title = 'UEB 점자 디코딩';
          } else {
            dec = BR.decode(part);
            // '?'와 여는따옴표 '“'는 규정상 둘 다 점 2-3-6 → decode가 비경계 '?'를 '“'로 오판.
            // URL꼴 토큰엔 곡선따옴표가 정상 출현할 수 없으므로 '“'는 전부 '?'의 오디코딩 → 복원.
            if (/(?:https?:\/\/|www\.)/i.test(dec)) dec = dec.replace(/“/g, '?');
          }
          buildNodes(dec).forEach(n => span.appendChild(n));
          frag.appendChild(span);
        } else frag.appendChild(document.createTextNode(part));
      }
      node.parentNode.replaceChild(frag, node);
    }
  }

  /* ================================================================
     숨은 코드(StegCloak) 자동 복원 (요구2) — 읽기 페이지 본문/댓글에서 제로폭 문자가 든 텍스트를
     찾아 채널 비번으로 자동 복원한다. 성공하면 겉보기 문장 옆에 복원 결과를, 실패(비번 없음/오류)하면
     '🔒 숨은 코드' 배지를 붙인다(클릭 시 비번 입력). 원본 겉보기 텍스트는 건드리지 않는다.
  ================================================================ */
  // 이 kone 채널(/s/<채널>)에 등록된 비번 + 전역 pwList 순으로 시도.
  function getStegPwList() {
    const out = [];
    const ch = getChannelCode();
    const cm = getChannelPwMap();
    if (ch && Array.isArray(cm[ch])) out.push(...cm[ch]);
    const g = getPwList(); if (Array.isArray(g)) out.push(...g);
    return [...new Set(out.filter(Boolean))];
  }
  // 코드화(인코딩)에 쓸 비번 결정: 현재 채널 비번 우선 → 없으면 공동(전역) 비번 1번. 무엇을 썼는지 함께 반환.
  function getStegEncodePw() {
    const ch = getChannelCode();
    const cm = getChannelPwMap();
    const chList = (ch && Array.isArray(cm[ch])) ? cm[ch].filter(Boolean) : [];
    if (chList.length) return { pw: chList[0], src: 'channel', channel: ch };
    const g = (getPwList() || []).filter(Boolean);
    if (g.length) return { pw: g[0], src: 'global', channel: ch };
    return null; // 채널·공동 어느 비번도 없음
  }
  // 비번 출처를 사람이 읽는 라벨로 (해당 채널 전용 비번이 없어 공동 비번을 쓰는 경우 추가 문구 포함).
  function stegPwLabel(info) {
    if (!info) return '등록된 비번 없음';
    if (info.src === 'channel') return `채널 [${channelDisplayName(info.channel)}] 비번`;
    return info.channel ? `공동 비번 1번 · 이 채널 전용 비번 없음` : '공동 비번 1번';
  }
  async function _stegTry(text, pws) {
    for (const pw of pws) { try { const r = await SC.reveal(text, pw); if (r != null && r !== '') return r; } catch (e) {} }
    return null;
  }
  // 성공 시 host 내용을 복호 결과로 교체(본문 삽입) — buildNodes로 URL/코드까지 카드화. 자동·배지클릭 공용.
  function _stegFillHost(host, secret) {
    host.textContent = '';
    host.classList.add('b64-steg-done');
    host.title = '숨은 코드 복원됨';
    buildNodes(secret).forEach(n => host.appendChild(n));
  }
  function _stegMakeBadge(host, text) {
    const badge = document.createElement('span');
    badge.className = 'b64-steg-badge';
    badge.setAttribute(DONE_ATTR, '');
    badge.title = '숨은 코드가 있으나 복원 실패 — 클릭해 비번 입력';
    badge.textContent = '🔒 숨은 코드';
    badge.addEventListener('click', async (ev) => {
      ev.preventDefault(); ev.stopPropagation();
      const pw = window.prompt('숨은 코드 복원 비밀번호:');
      if (!pw) return;
      const r = await _stegTry(text, [pw]);
      if (r != null) { if (host.isConnected) _stegFillHost(host, r); }
      else { badge.textContent = '🔒 복원 실패'; setTimeout(() => { if (badge.isConnected) badge.textContent = '🔒 숨은 코드'; }, 1500); }
    });
    return badge;
  }
  function decodeStegIn(root) {
    if (!CFG.STEG_DECODE || !root) return;
    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
      acceptNode(n) {
        if (!n.nodeValue || !SC.hasHidden(n.nodeValue)) return NodeFilter.FILTER_REJECT;
        const p = n.parentElement;
        if (!p || p.closest('#b64d-br,#b64d-settings,#b64d-pw-manager,textarea,[contenteditable],script,style')) return NodeFilter.FILTER_REJECT;
        if (p.closest('.b64-steg-host,[data-b64steg]')) return NodeFilter.FILTER_REJECT; // 이미 처리됨
        return NodeFilter.FILTER_ACCEPT;
      }
    });
    const nodes = []; let nn; while ((nn = walker.nextNode())) nodes.push(nn);
    for (const node of nodes) {
      const text = node.nodeValue;
      if (!node.parentNode) continue;
      // 텍스트 노드를 span으로 감싸 'claim'(비동기 완료 전 중복 처리 방지). data-b64steg에 원본 겉보기(cover)를
      // 저장 → applyLive가 원상복구. 성공 시 base64/점자 디코딩처럼 '본문에 삽입'(cover를 복호 결과로 교체).
      const hostEl = document.createElement('span');
      hostEl.className = 'b64-steg-host';
      hostEl.setAttribute('data-b64steg', text);
      hostEl.setAttribute(DONE_ATTR, '');
      node.parentNode.replaceChild(hostEl, node);
      hostEl.appendChild(node); // 복호 완료 전까지는 원본 겉보기 텍스트 유지
      (async () => {
        const secret = await _stegTry(text, getStegPwList());
        if (!hostEl.isConnected) return;
        if (secret != null) _stegFillHost(hostEl, secret); // 본문 삽입(cover→복호 결과)
        else hostEl.appendChild(_stegMakeBadge(hostEl, text)); // 실패 → 겉보기 유지 + 배지
      })();
    }
  }

  /* ================================================================
     작성 페이지 변환 패널 (C안: 좌측 탭 → 서랍)
     · 모드: 점자 | base64(N회) · 라이브 토글 · 복사/삽입(Tiptap)
  ================================================================ */
  let _brEl = null, _brConvTimer = null, _brLastSel = null;
  const BR_EDITOR_SEL = 'div.tiptap.ProseMirror, [contenteditable="true"][role="textbox"]';
  function brEditor() { return document.querySelector(BR_EDITOR_SEL); }
  // 에디터(글 입력창) 바로 옆에 붙도록 위치 계산
  function brReposition() {
    if (!_brEl) return;
    const ed = document.querySelector('#edra-editor') || brEditor();
    _brEl.style.left = 'auto';
    if (!ed) { _brEl.style.right = (window.innerWidth - 60) + 'px'; _brEl.style.top = '130px'; return; }
    const r = ed.getBoundingClientRect();
    // 패널 우측 가장자리 = 에디터 왼쪽 가장자리 → 탭이 입력창 바로 왼쪽에, 서랍은 왼쪽으로 열림
    _brEl.style.right = Math.max(0, Math.round(window.innerWidth - r.left)) + 'px';
    _brEl.style.top = Math.max(8, Math.round(r.top)) + 'px';
  }
  // 사용자가 에디터에서 마지막으로 둔 커서(선택)를 기억 → 삽입 시 그 위치에
  function brTrackSel() {
    const ed = brEditor(); if (!ed) return;
    const sel = window.getSelection();
    if (sel && sel.rangeCount && ed.contains(sel.anchorNode)) _brLastSel = sel.getRangeAt(0).cloneRange();
  }
  function brB64Once(str) {
    const bytes = new TextEncoder().encode(str); let bin = '';
    for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
    return btoa(bin);
  }
  function brBase64(str, n) { let s = str; for (let i = 0; i < n; i++) s = brB64Once(s); return s; }
  function brailleEncode(str) { return str ? BR.encode(str) : ''; }

  function brConvert() {
    if (!_brEl) return;
    if (CFG.BR_MODE === 'code') return; // 코드 모드는 아래 brGenerateCode(패널 내부 UI)로만 생성
    const txt = _brEl.querySelector('.br-in').value;
    const res = (CFG.BR_MODE === 'base64') ? (txt ? brBase64(txt, CFG.BR_COUNT) : '') : brailleEncode(txt);
    _brEl.dataset.full = res;
    const out = _brEl.querySelector('.br-out');
    out.textContent = res.length > 4000 ? res.slice(0, 4000) + ' …' : res;
    const lenEl = _brEl.querySelector('.br-len');
    if (lenEl) lenEl.textContent = res ? res.length.toLocaleString() + '자' : '';
  }
  // 코드(StegCloak) 생성: 패널 내부 UI(겉보기/비번)로 입력을 숨겨 결과 영역+dataset.full에 → 복사/삽입 재사용.
  // (팝오버 없이 기존 변환기 UI 안에서 처리 — 사용자 요청.)
  function brGenerateCode() {
    if (!_brEl) return;
    const secret = _brEl.querySelector('.br-in').value;
    if (!secret || !secret.trim()) { showNavToast('숨길 내용을 입력하세요'); return; }
    const cover = _brEl.querySelector('.br-cover-txt').value.trim();
    if (!cover || cover.split(/\s+/).length < 2) { showNavToast('겉보기 문장은 2단어 이상이어야 합니다'); return; }
    // 비번: 채널 우선 → 없으면 공동 1번 → 그것도 없으면 아래 입력칸. 무엇을 썼는지 토스트로 표시.
    const info = getStegEncodePw();
    const pw = info ? info.pw : _brEl.querySelector('.br-code-pw').value.trim();
    const srcLabel = info ? stegPwLabel(info) : '입력한 비번';
    if (!pw) { showNavToast('비밀번호를 입력하세요 (또는 채널/공동 비번 등록)'); return; }
    const btn = _brEl.querySelector('.br-conv'); btn.disabled = true; const label = btn.textContent; btn.textContent = '생성 중…';
    SC.hide(secret, pw, cover, true).then(out => {
      _brEl.dataset.full = out;
      _brEl.querySelector('.br-out').textContent = out;
      const lenEl = _brEl.querySelector('.br-len'); if (lenEl) lenEl.textContent = out.length.toLocaleString() + '자';
      showNavToast(`코드 생성됨 · ${srcLabel} 「${pw}」`);
    }).catch(e => showNavToast('코드 생성 실패: ' + (e.message || e))).finally(() => { btn.disabled = false; btn.textContent = label; });
  }
  function brScheduleConvert() { if (CFG.BR_LIVE) { clearTimeout(_brConvTimer); _brConvTimer = setTimeout(brConvert, 150); } }
  function brInsert() {
    const full = _brEl && _brEl.dataset.full || ''; if (!full) return;
    const ed = brEditor();
    if (!ed) { showNavToast('에디터를 찾지 못했습니다'); return; }
    ed.focus();
    // 에디터에 마지막으로 둔 커서 위치 복원 (없으면 현재 위치). 최상단 강제 삽입 방지.
    if (_brLastSel && ed.contains(_brLastSel.startContainer)) {
      const sel = window.getSelection();
      sel.removeAllRanges(); sel.addRange(_brLastSel);
    }
    try { document.execCommand('insertText', false, full); }
    catch (e) { showNavToast('삽입 실패 — 복사 후 붙여넣어 주세요'); return; }
    brTrackSel(); // 삽입 후 커서 위치 갱신(연속 삽입 대비)
  }
  function brSyncModeUI() {
    if (!_brEl) return;
    const isCode = CFG.BR_MODE === 'code';
    _brEl.querySelectorAll('.br-seg button').forEach(b => b.classList.toggle('on', b.dataset.mode === CFG.BR_MODE));
    _brEl.querySelector('.br-count-row').classList.toggle('br-hide', CFG.BR_MODE !== 'base64');
    _brEl.querySelector('.br-live-row').classList.toggle('br-hide', isCode); // 코드엔 라이브 변환 없음
    const conv = _brEl.querySelector('.br-conv');
    conv.classList.toggle('br-hide', isCode ? false : CFG.BR_LIVE); // 코드는 '코드 생성' 항상 표시
    conv.textContent = isCode ? '코드 생성' : '변환';
    _brEl.querySelector('.br-outlab').textContent = isCode ? '코드' : (CFG.BR_MODE === 'base64' ? '결과' : '점자');
    const ta = _brEl.querySelector('.br-in');
    if (ta) ta.setAttribute('placeholder', isCode ? '숨길 내용 입력' : '한글/텍스트 입력');
    // 코드 모드: 겉보기/비번 입력 UI를 패널 내부에 표시(팝오버 대체). 채널 비번이 있으면 비번 입력행 숨김.
    const codeBox = _brEl.querySelector('.br-code-box');
    if (codeBox) codeBox.classList.toggle('br-hide', !isCode);
    if (isCode) {
      const pwRow = _brEl.querySelector('.br-code-pw-row');
      if (pwRow) pwRow.classList.toggle('br-hide', getStegPwList().length > 0);
    }
  }
  function mountBraillePanel() {
    if (!CFG.BRAILLE_PANEL || document.getElementById('b64d-br') || !document.body) return;
    const wrap = document.createElement('div'); wrap.id = 'b64d-br';
    wrap.innerHTML =
      '<div id="b64d-br-draw"><div id="b64d-br-inner">' +
        '<div class="br-h">변환기<button class="br-x" title="닫기">✕</button></div>' +
        '<div class="br-seg"><button data-mode="braille">점자</button><button data-mode="base64">base64</button><button data-mode="code">코드</button></div>' +
        '<div class="br-row br-count-row"><span>반복 횟수</span><span class="br-step"><button class="br-dec">−</button><b class="br-cnt">1</b><button class="br-inc">+</button></span></div>' +
        '<div class="br-row br-live-row"><span>라이브 변환</span><span class="br-sw br-live"></span></div>' +
        '<div class="br-code-box br-hide">' +
          '<div class="br-row"><span>겉보기</span><span class="br-cover-ctl"><select class="br-cover-sel"></select><button class="br-cover-gen" type="button" title="새 문장 생성">🎲</button></span></div>' +
          '<textarea class="br-cover-txt" placeholder="겉보기 문장 (2단어 이상)"></textarea>' +
          '<div class="br-row br-code-pw-row"><span>비번</span><input class="br-code-pw" type="text" autocomplete="off" placeholder="채널 비번 없을 때"></div>' +
        '</div>' +
        '<div><div class="br-lab"><span>입력</span></div><textarea class="br-in" placeholder="한글/텍스트 입력"></textarea></div>' +
        '<div><div class="br-lab"><span class="br-outlab">결과</span><span class="br-len"></span></div><div class="br-out"></div></div>' +
        '<div class="br-btns"><button class="br-conv">변환</button><button class="br-copy">복사</button><button class="br-pri br-ins">삽입</button></div>' +
      '</div></div>' +
      '<button id="b64d-br-tab" title="변환기 열기/닫기"><span>⠿ 변환</span></button>';
    document.body.appendChild(wrap);
    _brEl = wrap;
    wrap.querySelector('#b64d-br-tab').addEventListener('click', () => wrap.classList.toggle('open'));
    wrap.querySelector('.br-x').addEventListener('click', () => wrap.classList.remove('open'));
    wrap.querySelectorAll('.br-seg button').forEach(b => b.addEventListener('click', () => {
      CFG.BR_MODE = b.dataset.mode; GM_setValue('brMode', CFG.BR_MODE); brSyncModeUI();
      if (CFG.BR_MODE === 'code') { // 코드는 [코드 생성]으로 생성 → 모드 전환 시 이전 결과 비움
        wrap.dataset.full = ''; wrap.querySelector('.br-out').textContent = '';
        const l = wrap.querySelector('.br-len'); if (l) l.textContent = '';
      } else brConvert();
    }));
    const cntEl = wrap.querySelector('.br-cnt'); cntEl.textContent = CFG.BR_COUNT;
    wrap.querySelector('.br-dec').addEventListener('click', () => { CFG.BR_COUNT = Math.max(1, CFG.BR_COUNT - 1); GM_setValue('brCount', CFG.BR_COUNT); cntEl.textContent = CFG.BR_COUNT; brConvert(); });
    wrap.querySelector('.br-inc').addEventListener('click', () => { CFG.BR_COUNT = Math.min(20, CFG.BR_COUNT + 1); GM_setValue('brCount', CFG.BR_COUNT); cntEl.textContent = CFG.BR_COUNT; brConvert(); });
    const liveSw = wrap.querySelector('.br-live'); liveSw.classList.toggle('on', CFG.BR_LIVE);
    liveSw.addEventListener('click', () => { CFG.BR_LIVE = !CFG.BR_LIVE; GM_setValue('brLive', CFG.BR_LIVE); liveSw.classList.toggle('on', CFG.BR_LIVE); brSyncModeUI(); brConvert(); });
    wrap.querySelector('.br-in').addEventListener('input', brScheduleConvert);
    // 변환 버튼: 코드 모드면 패널 내부 UI로 코드 생성(brGenerateCode), 그 외엔 일반 변환.
    wrap.querySelector('.br-conv').addEventListener('click', () => { if (CFG.BR_MODE === 'code') brGenerateCode(); else brConvert(); });
    wrap.querySelector('.br-copy').addEventListener('click', () => { const f = wrap.dataset.full || ''; if (f && navigator.clipboard) { navigator.clipboard.writeText(f); showNavToast('복사됨'); } });
    wrap.querySelector('.br-ins').addEventListener('click', brInsert);
    // 코드 모드 겉보기 문장 UI: 드롭다운=프리셋 불러오기, 🎲=자동 생성, 텍스트영역=실제 사용할 겉보기(편집 가능).
    const coverSel = wrap.querySelector('.br-cover-sel'), coverTxt = wrap.querySelector('.br-cover-txt');
    const optDef = document.createElement('option'); optDef.value = ''; optDef.textContent = '불러오기…'; coverSel.appendChild(optDef);
    STEG_COVERS.forEach((c, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = c.label; coverSel.appendChild(o); });
    coverSel.addEventListener('change', () => { if (coverSel.value !== '') { coverTxt.value = STEG_COVERS[+coverSel.value].text; coverSel.value = ''; } });
    wrap.querySelector('.br-cover-gen').addEventListener('click', () => { coverTxt.value = stegGenerateCover(); });
    coverTxt.value = stegGenerateCover(); // 초기 겉보기 문장(자동 생성 → 매번 다양)
    brSyncModeUI();
    if (CFG.BR_PINNED) wrap.classList.add('open'); // 항상 열어두기 옵션
    // UI 크기(SETTINGS_SCALE) 반영 + 에디터(글 입력창) 옆에 위치
    wrap.style.transform = 'scale(' + (CFG.SETTINGS_SCALE / 100) + ')';
    brReposition();
    [60, 250, 700, 1500].forEach(t => setTimeout(brReposition, t)); // 에디터가 늦게 뜨는 SPA 대비
    window.addEventListener('scroll', brReposition, true);
    window.addEventListener('resize', brReposition);
    document.addEventListener('selectionchange', brTrackSel);
  }
  function unmountBraillePanel() {
    window.removeEventListener('scroll', brReposition, true);
    window.removeEventListener('resize', brReposition);
    document.removeEventListener('selectionchange', brTrackSel);
    _brEl = null; _brLastSel = null;
    const e = document.getElementById('b64d-br'); if (e) e.remove();
  }
  function syncBraillePanel() {
    if (CFG.BRAILLE_PANEL && isWritePage()) mountBraillePanel();
    else unmountBraillePanel();
  }

  let _lastUrl = location.href;
  function onUrlChange() {
    const cur = location.href;
    if (cur !== _lastUrl) {
      const prev = _lastUrl;
      _lastUrl = cur;
      // 작성↔읽기 페이지 전환 시 알림 (설정 NAV_TOAST)
      if (CFG.NAV_TOAST) {
        const toW = isWritePage(cur), fromW = isWritePage(prev);
        if (toW && !fromW)      showNavToast('✏️ 작성 모드');
        else if (!toW && fromW) showNavToast('📄 읽기 모드');
      }
      // write 페이지에서 떠나는 경우 표시 (flush() 실행 여부와 무관하게 URL로 직접 판단)
      if (isWritePage(prev)) _wasWritePage = true;
      syncBraillePanel(); // 작성↔읽기 전환에 따라 변환 패널 장착/해제
      // 이전 URL 변경 타이머 취소 후 반드시 재시작 (pending=true 상태에서도 동작해야 함)
      if (_urlFlushTimer) { clearTimeout(_urlFlushTimer); _urlFlushTimer = null; }
      pending = false;
      if (mo) mo.disconnect();
      processedRaws.clear();
      processedListRaws.clear();
      _liveCards.clear(); // 페이지 이동 시 키↔카드 등록 비움(메모리 상한)
      _scrolledOnce = false;
      _kpIdx = -1;
      _expandClicks = 0;
      if (_moreTimer) { clearTimeout(_moreTimer); _moreTimer = null; }
      document.querySelectorAll(KP_FOCUSED_SEL).forEach(el => el.classList.remove('kp-focused'));
      dlpHide();
      // SPA로 write 페이지 진입 시 즉시 DOM 복원 (에디터 간섭 방지)
      if (!isWritePage(prev) && isWritePage(cur)) {
        document.getElementById('b64d-link-panel')?.remove();
        _panelCardMap.clear();
        applyLive(); // DOM 복원 → schedule() → flush()에서 isWritePage()로 조기 종료
        return;
      }
      // write 페이지 이탈 시: flush() early-return이 observe()를 생략해 MO가 끊겨있음.
      // pending=false 상태 그대로 MO 즉시 재연결 → Svelte가 읽기 콘텐츠 추가 시 schedule() 즉시 동작.
      // (pending=true를 먼저 세우면 MO → schedule()이 바로 return해버려 효과 없음)
      if (isWritePage(prev)) {
        observe(); // pending=false 유지 → MO 발화 시 schedule() 즉시 처리
        _urlFlushTimer = setTimeout(() => { _urlFlushTimer = null; flush(); }, 500); // 폴백
        return;
      }
      pending = true;
      _urlFlushTimer = setTimeout(() => { _urlFlushTimer = null; pending = false; flush(); }, 500);
    }
  }
  window.addEventListener('popstate', onUrlChange);
  ['pushState', 'replaceState'].forEach(method => {
    const orig = history[method];
    history[method] = function (...args) { const r = orig.apply(this, args); onUrlChange(); return r; };
  });
  // ── SPA 내비게이션 감지 보강 (코네=SvelteKit 대응) ──
  // SvelteKit은 history.pushState 원본 참조를 선점해 위 패치를 우회하고, 일부는 Navigation API로만
  // 라우팅한다. 그래서 popstate/pushState 훅이 write↔일반 전환을 놓친다.
  // → Navigation API(즉시) + 0.3초 폴링(범용 폴백, 문자열 비교 1회라 비용 미미)으로 모든 URL 변경 포착.
  if (window.navigation && typeof window.navigation.addEventListener === 'function') {
    window.navigation.addEventListener('navigatesuccess', onUrlChange); // URL 확정 후 발화 → location.href 최신
  }
  setInterval(onUrlChange, 300);
  syncBraillePanel(); // 초기 로드 시 작성 페이지면 변환 패널 장착

  // 탭 포커스 이탈(다른 탭 클릭·새 탭 열기) 또는 새로고침 시 미리보기 강제 숨김
  window.addEventListener('blur', dlpHide);
  window.addEventListener('beforeunload', dlpHide);

  document.documentElement.style.setProperty('--b64-dlp-scale', CFG.PREVIEW_SCALE / 100);

  // 모바일 전용 FAB (설정 패널 진입점 — 단축키 대체)
  if (IS_MOBILE && !document.getElementById('b64d-fab')) {
    const fab = document.createElement('button');
    fab.id = 'b64d-fab';
    fab.title = 'KONE+ 설정';
    fab.innerHTML = '⚙';
    fab.addEventListener('touchstart', e => { e.preventDefault(); openSettingsPanel(); }, { passive: false });
    document.body.appendChild(fab);
  }

  flush();
  observe();

  // ── 초기 로딩 하이드레이션 안전망 ──
  // 코네(SvelteKit)가 페이지 진입 직후 본문을 재렌더하며 우리가 끼운 카드를 떼어내는 일이 있다.
  // 특히 NO_DUP에선 '첫 카드'가 사라지면 나머지는 숨김 상태라 다운로드 카드가 통째로 비어 보였다.
  // (토글로 켜면 이미 안착된 DOM이라 정상) → 하이드레이션이 끝날 시점들에 재flush해 _liveCards(키 기준)
  // 생존확인으로 사라진 카드를 다시 만든다. flush는 멱등(이미 있는 카드는 건드리지 않음)이라 안전.
  if (/kone\.gg$/.test(host)) [400, 1200, 2500, 4500].forEach(t => setTimeout(() => { if (!isWritePage()) schedule(); }, t));

  // GreasyFork 자동 업데이트 확인 (kone.gg 접속 시 1회, 내부 6시간 throttle)
  if (CFG.UPDATE_CHECK && /kone\.gg$/.test(host)) setTimeout(() => runUpdateCheck(false), 4000);

  }); // domReady end
})();