KONE +

base64/점자 디코딩 · DLsite/Steam 링크 카드 · 링크 활성화 체크 · 비번 자동입력. kone.gg 전용.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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
})();