Kenmei+

Adds QoL and experience-enhancing features to kenmei.co. Includes mobile and desktop support.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        Kenmei+
// @description Adds QoL and experience-enhancing features to kenmei.co. Includes mobile and desktop support.
// @author      Totem
// @match       *://*.kenmei.co/*
// @run-at      document-end
// @version     2.2.1
// @grant       none
// @license     Creative Commons Attribution 4.0 International Public License; http://creativecommons.org/licenses/by/4.0/
// @namespace   https://greasyfork.org/users/1078112
// @icon        https://www.kenmei.co/favicon-196.png
// ==/UserScript==

/*
 * ┌─────────────────────────────────────────────────────────┐
 * │  Kenmei+ v2.2.1                                         │
 * │  https://greasyfork.org/scripts/466167                  │
 * └─────────────────────────────────────────────────────────┘
 *
 * FEATURES
 *
 *   Dark-Mode Toggle
 *     Toggle for the site's dark mode — unlocked for everyone.
 *
 *   Open Updated (batched)
 *     Opens the next unread chapter for every updated series
 *     in batches (default 10), with a confirm prompt between
 *     batches so 60 tabs don't appear at once.
 *
 *   Set All Latest (API)
 *     Sets every updated series' last-read chapter to the
 *     latest chapter via PUT /api/v2/manga_entries/:id —
 *     no more DOM scraping or mobile/desktop selector dance.
 *
 *   Unread Badge
 *     Live count of unread series in the toolbar. Tooltip
 *     shows last-refresh time.
 *
 *   Auto-Refresh
 *     Periodically re-checks for new chapters in the
 *     background. Interval configurable in settings.
 *
 *   Desktop Notifications
 *     Browser notification when auto-refresh detects a new
 *     chapter drop. Seen-set persists across reloads so you
 *     don't get duplicate notifications.
 *
 *   Series Filter
 *     Exclude specific series from "Open Updated" so only
 *     the ones you care about get opened. Cached so reopening
 *     settings doesn't refetch.
 *
 *   Covers Mode (experimental, opt-in)
 *     Replaces the site's gated list/covers toggle with a
 *     cover grid rendered from the API. Must be enabled in
 *     Settings → Experimental before use. Disabled by default.
 *
 *   Keyboard Shortcuts
 *     Configurable bindings for all major actions plus arrow
 *     keys for page navigation.
 *
 *   Settings Panel
 *     Cog icon next to your avatar. All toggles, the series
 *     filter, refresh interval, batch size, and shortcut
 *     rebinder live here. Settings persist via localStorage.
 *
 * ─────────────────────────────────────────────────────────
 * CHANGELOG
 *   v2.2.1
 *     • Improve upon current 'auto-set latest' implementation.
 *
 *   v2.2.0
 *     • New setting: auto-set latest after open (disabled by default)
 *     • Fixed batch-modal disappearing after the first batch
 *
 *   v2.1.6
 *     • Added app icon
 *
 *   v2.1.5
 *     • Toasts for Open Updated and Set All Latest. Set All
 *       Latest also auto-clicks the site Refresh button.
 *     • Arrow-key page nav now matches the anchor-based pager.
 *     • List view hidden off-screen (kept mounted) so cover
 *       Edit / Delete / Share / Report relay reliably and the
 *       cover checkbox mirrors the row's selection state.
 *     • Cover body no longer navigates — only the title does.
 *
 *   v2.1.4
 *     • Covers Mode is now an opt-in experimental feature
 *       (disabled by default). Enable it under
 *       Settings → Experimental before use. When disabled,
 *       clicking the site's list/covers toggle passes through
 *       to Kenmei's own handler as normal. The coversMode
 *       active-state is reset whenever the override is turned
 *       off so stale grid state cannot linger.
 *
 *   v2.1.3
 *     • Cover-card buttons that depended on Vue's edit/share/
 *       delete/report flows are now wired by clicking the
 *       corresponding button in the list row — Vue's
 *       @click handlers run regardless of CSS visibility, so
 *       the real modals open. Edit, ellipsis menu, decrement,
 *       check-check now match list-mode behaviour.
 *     • Mutation-aware refresh: any non-GET to
 *       /api/v2/manga_entries (add, edit, delete, set-latest)
 *       triggers a re-fetch of the current page so the covers
 *       grid reflects new state without a manual reload.
 *     • Dark mode re-applies on every SPA route change so it
 *       can't be wiped by Vue rehydration on later screens.
 *     • Covers mode persistence already works (localStorage),
 *       and re-enters automatically on dashboard re-init.
 *
 *   v2.1.2
 *     • Cover cards rebuilt to mirror Kenmei's premium DOM
 *       1:1.
 *     • Increment / set-to-latest stepper buttons wired to the
 *       PUT /api/v2/manga_entries endpoint. Decrement, edit,
 *       and ellipsis are stubbed (visual-only) until the
 *       relevant endpoints are captured.
 *
 *   v2.1.1
 *     • Covers mode now hijacks the site's existing list/covers
 *       toggle instead of adding a separate toolbar button —
 *       click goes through capture-phase, premium prompt is
 *       suppressed, the toggle's two icons reflect the active
 *       state. Grid uses Kenmei's native card markup.
 *
 *   v2.1.0
 *     Reliability:
 *       • Set-All-Latest now uses the real PUT endpoint
 *         instead of DOM clicks. Mobile/desktop selectors
 *         are gone.
 *       • Notification seen-set persists in localStorage so
 *         a reload between drops does not eat the alert.
 *
 *     UX:
 *       • Badge shows just the number, with tooltip for the
 *         "X unread • Last checked …" detail.
 *       • Open Updated runs in batches (default 15) with a
 *         confirm prompt between each batch.
 *       • Settings panel caches its series list (no refetch
 *         on every open) with a manual refresh button.
 *       • Auto-refresh interval is editable from the panel.
 *
 *     New:
 *       • Experimental Covers Mode
 *       • Keyboard shortcuts
 *
 *   v2.0.0 — Full rewrite (zero deps, SPA-aware, API-powered).
 *   v1.x — Legacy (jQuery + js-cookie). See git history.
 *
 * ─────────────────────────────────────────────────────────
 */

(function () {
  'use strict';

  /* ================================================================
   *  CONSTANTS & ICONS
   * ================================================================ */
  const STORAGE_KEY   = 'kenmei-plus-v2';
  const SEEN_KEY      = 'kenmei-plus-v2-seen';
  const PREFIX        = 'kp';
  const MIN_REFRESH_S = 5;
  const API_BASE      = 'https://api.kenmei.co';
  const COVERS_PER_PAGE = 30;

  const BTN_PRIMARY = 'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-white transition-colors focus-visible_outline-none focus-visible_ring-2 focus-visible_ring-blue-500 focus-visible_ring-offset-2 disabled_pointer-events-none disabled_opacity-50 dark_ring-offset-0 dark_focus-visible_ring-moon-yellow-400 bg-blue-600 text-white hover_bg-blue-600/90 dark_bg-blue-600/30 dark_text-blue-300 dark_hover_bg-blue-600/40 py-2 h-9 px-2 lg_px-3 shadow rounded-md';
  const BTN_OUTLINE = 'inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-white transition-colors focus-visible_outline-none focus-visible_ring-2 focus-visible_ring-blue-500 focus-visible_ring-offset-2 disabled_pointer-events-none disabled_opacity-50 dark_ring-offset-0 dark_focus-visible_ring-moon-yellow-400 border border-gray-300 bg-white hover_bg-gray-100 hover_text-gray-900 dark_border-gray-800 dark_bg-gray-700 dark_hover_bg-gray-600 dark_hover_text-gray-300 dark_text-white py-2 h-9 px-2 lg_px-3 shadow rounded-md';

  const ICON = {
    openUpdated: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="-ml-1 mr-2 h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg>',
    setLatest: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="-ml-1 mr-2 h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
    cog: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-6 w-6"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>',
    refresh: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-3.5 w-3.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/></svg>',
  };

  /* ================================================================
   *  SETTINGS
   * ================================================================ */
  const DEFAULT_SHORTCUTS = Object.freeze({
    openUpdated:    'o',
    setLatest:      'l',
    toggleSettings: ',',
    toggleCovers:   'c',
    refresh:        'r',
    toggleDark:     'd',
    prevPage:       'ArrowLeft',
    nextPage:       'ArrowRight',
  });

  const DEFAULTS = Object.freeze({
    darkMode:            true,
    autoRefresh:         true,
    autoRefreshInterval: 15,        // seconds
    notifications:       false,
    excludedSeries:      [],
    batchSize:           15,        // tabs per Open Updated batch
    autoSetLatestAfterOpen: false,   // auto-set to latest read after Open Updated
    // ── Experimental ──────────────────────────────────────────────
    // coversOverride gates the entire covers-mode feature. When false
    // the site's own list/covers toggle is left untouched so Kenmei's
    // premium prompt runs as normal. coversMode tracks active state
    // and is only honoured when coversOverride is also true.
    coversOverride:      false,
    coversMode:          false,
    shortcuts:           { ...DEFAULT_SHORTCUTS },
  });

  function loadSettings () {
    try {
      const raw = JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
      // Merge shortcuts so newly added defaults don't disappear for upgraders.
      const shortcuts = { ...DEFAULT_SHORTCUTS, ...(raw.shortcuts || {}) };
      return { ...DEFAULTS, ...raw, shortcuts };
    } catch { return { ...DEFAULTS, shortcuts: { ...DEFAULT_SHORTCUTS } }; }
  }
  function saveSettings (s) { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); }

  let settings = loadSettings();

  /* ================================================================
   *  DARK MODE
   * ================================================================ */
  let darkEnforcer = null;

  function applyDarkMode (isDark) {
    document.documentElement.classList.toggle('dark', isDark);
    settings.darkMode = isDark;
    saveSettings(settings);
    updateCogColor();
    enforceDarkMode(isDark);
  }

  function enforceDarkMode (isDark) {
    if (darkEnforcer) { darkEnforcer.disconnect(); darkEnforcer = null; }
    const html = document.documentElement;
    darkEnforcer = new MutationObserver(() => {
      if (html.classList.contains('dark') !== isDark) {
        html.classList.toggle('dark', isDark);
        updateCogColor();
      }
    });
    darkEnforcer.observe(html, { attributes: true, attributeFilter: ['class'] });
    setTimeout(() => { if (darkEnforcer) { darkEnforcer.disconnect(); darkEnforcer = null; } }, 5000);
  }

  /* ================================================================
   *  AUTH TOKEN INTERCEPTION
   * ================================================================ */
  let authToken     = null;
  let authExpired   = false;   // set true on 401, cleared when fresh token arrives
  const tokenListeners = [];

  function onTokenRestored (cb) { tokenListeners.push(cb); }

  function setAuthToken (t) {
    authToken = t;
    if (authExpired) {
      authExpired = false;
      hideAuthToast();
      tokenListeners.forEach(cb => { try { cb(); } catch (e) { /* swallow */ } });
    }
  }

  const origXHROpen      = XMLHttpRequest.prototype.open;
  const origXHRSetHeader = XMLHttpRequest.prototype.setRequestHeader;

  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this._kpUrl = url;
    return origXHROpen.call(this, method, url, ...rest);
  };

  XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
    if (name.toLowerCase() === 'authorization' && typeof value === 'string'
        && value.startsWith('Bearer ') && this._kpUrl?.includes('kenmei.co')) {
      setAuthToken(value.slice(7));
    }
    return origXHRSetHeader.call(this, name, value);
  };

  const origFetch = window.fetch;
  window.fetch = function (input, init) {
    try {
      let auth = null;
      let url  = null;

      if (input instanceof Request) {
        auth = input.headers.get('Authorization');
        url  = input.url;
      } else if (typeof input === 'string') {
        url = input;
      } else if (input && typeof input.url === 'string') {
        url = input.url;
      }

      if (!auth && init?.headers) {
        const h = init.headers;
        if (h instanceof Headers)         auth = h.get('Authorization');
        else if (Array.isArray(h))        auth = (h.find(([k]) => k.toLowerCase() === 'authorization') || [])[1];
        else if (typeof h === 'object')   auth = h.Authorization || h.authorization;
      }

      if (typeof auth === 'string' && auth.startsWith('Bearer ')
          && typeof url === 'string' && url.includes('kenmei.co')) {
        setAuthToken(auth.slice(7));
      }

      // Watch /api/v2/manga_entries responses so covers mode auto-syncs
      // with the page the site itself just loaded.
      if (typeof url === 'string' && url.includes('api.kenmei.co/api/v2/manga_entries')) {
        const isList = url.includes('/manga_entries?');
        const method = (init?.method || (input instanceof Request ? input.method : 'GET') || 'GET').toUpperCase();
        const promise = origFetch.call(this, input, init);
        return promise.then((res) => {
          try {
            if (isList && method === 'GET') {
              res.clone().json().then(onEntriesPageResponse).catch(() => {});
            } else if (method !== 'GET' && res.ok) {
              // POST / PUT / PATCH / DELETE on a manga_entry — refresh covers grid.
              scheduleCoversRefresh();
            }
          } catch (_) { /* swallow */ }
          return res;
        });
      }
    } catch (e) { /* ignore */ }
    return origFetch.call(this, input, init);
  };

  /* ================================================================
   *  API HELPERS
   * ================================================================ */
  async function apiFetch (path, opts = {}) {
    if (!authToken) throw new Error('No auth token captured yet.');
    const res = await fetch(`${API_BASE}${path}`, {
      method: opts.method || 'GET',
      headers: {
        'Accept':        'application/json',
        'Authorization': `Bearer ${authToken}`,
        ...(opts.body ? { 'Content-Type': 'application/json' } : {}),
        ...(opts.headers || {}),
      },
      body: opts.body ? JSON.stringify(opts.body) : undefined,
    });

    if (res.status === 401) {
      authToken   = null;
      authExpired = true;
      showAuthToast();
      throw new Error('API 401: token expired — refresh the page');
    }
    if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
    if (res.status === 204) return null;

    const ct = res.headers.get('Content-Type') || '';
    return ct.includes('application/json') ? res.json() : res.text();
  }

  async function fetchEntriesPage (page, status = 1) {
    return apiFetch(`/api/v2/manga_entries?page=${page}&status=${status}&search_term=&sort%5BUnread%5D=desc`);
  }

  async function fetchAllEntries (status = 1) {
    const all = [];
    let page = 1, pages = 1;
    do {
      const data = await fetchEntriesPage(page, status);
      all.push(...data.entries);
      pages = data.pagy.pages;
      page++;
    } while (page <= pages);
    return all;
  }

  // For the series filter — pull every status (1..6).
  async function fetchEntriesAllStatuses () {
    const out = [];
    for (let status = 1; status <= 6; status++) {
      try {
        const all = await fetchAllEntries(status);
        out.push(...all);
      } catch (e) { /* status may be empty or restricted; skip */ }
    }
    return out;
  }

  async function fetchUnreadEntries () {
    const all = await fetchAllEntries(1);
    return all.filter(e => e.attributes.unread && e.chapters.next);
  }

  async function setEntryToLatest (entryId, latestChapterId) {
    return apiFetch(`/api/v2/manga_entries/${entryId}`, {
      method: 'PUT',
      body: { manga_entry: { manga_source_chapter_id: latestChapterId } },
    });
  }

  /* ================================================================
   *  DOM HELPERS
   * ================================================================ */
  function h (tag, attrs = {}, children = []) {
    const el = document.createElement(tag);
    for (const [k, v] of Object.entries(attrs)) {
      if (v == null) continue;
      if (k === 'className')        el.className = v;
      else if (k === 'textContent') el.textContent = v;
      else if (k === 'innerHTML')   el.innerHTML = v;
      else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
      else if (k.startsWith('on') && typeof v === 'function')
        el.addEventListener(k.slice(2).toLowerCase(), v);
      else el.setAttribute(k, v);
    }
    for (const c of children) {
      if (c == null || c === false) continue;
      if (typeof c === 'string')    el.append(c);
      else                          el.appendChild(c);
    }
    return el;
  }

  const qs  = (sel, root = document) => root.querySelector(sel);
  const qsa = (sel, root = document) => [...root.querySelectorAll(sel)];

  function waitFor (selector, timeout = 10000) {
    return new Promise((resolve, reject) => {
      const found = qs(selector);
      if (found) return resolve(found);
      const obs = new MutationObserver(() => {
        const el = qs(selector);
        if (el) { obs.disconnect(); clearTimeout(timer); resolve(el); }
      });
      obs.observe(document.body, { childList: true, subtree: true });
      const timer = timeout > 0
        ? setTimeout(() => { obs.disconnect(); reject(new Error(`waitFor timed out`)); }, timeout)
        : null;
    });
  }

  function isTypingTarget (el) {
    if (!el) return false;
    const tag = el.tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
    if (el.isContentEditable) return true;
    return false;
  }

  function relativeTime (ts) {
    if (!ts) return 'never';
    const s = Math.round((Date.now() - ts) / 1000);
    if (s < 5)    return 'just now';
    if (s < 60)   return `${s}s ago`;
    if (s < 3600) return `${Math.floor(s / 60)}m ago`;
    if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
    return `${Math.floor(s / 86400)}d ago`;
  }

  /* ================================================================
   *  SPA NAVIGATION DETECTION
   * ================================================================ */
  function setupNavDetection (callback) {
    let lastPath = location.pathname;
    const check = () => {
      if (location.pathname !== lastPath) {
        const prev = lastPath;
        lastPath = location.pathname;
        callback(lastPath, prev);
      }
    };
    const wrap = (fn) => function (...args) { fn.apply(this, args); requestAnimationFrame(check); };
    history.pushState    = wrap(history.pushState);
    history.replaceState = wrap(history.replaceState);
    window.addEventListener('popstate', () => requestAnimationFrame(check));
    setInterval(check, 500);
    callback(lastPath, null);
  }

  /* ================================================================
   *  MODAL SYSTEM
   * ================================================================ */
  let modalOverlay   = null;
  let modalPanel     = null;
  let modalHideTimer = null;

  function ensureModal () {
    if (modalOverlay) return;
    modalOverlay = h('div', { id: `${PREFIX}-modal-overlay` });
    modalOverlay.addEventListener('click', (e) => {
      if (e.target === modalOverlay) hideModal();
    });
    document.body.appendChild(modalOverlay);
  }

  function showModal (content) {
    if (modalHideTimer) { clearTimeout(modalHideTimer); modalHideTimer = null; }
    ensureModal();
    modalPanel = h('div', { className: `${PREFIX}-modal-panel` });
    modalPanel.appendChild(content);
    modalOverlay.replaceChildren(modalPanel);
    modalOverlay.classList.remove(`${PREFIX}-modal-out`);
    modalOverlay.classList.add(`${PREFIX}-modal-visible`);
    requestAnimationFrame(() => modalOverlay.classList.add(`${PREFIX}-modal-in`));
  }

  function hideModal () {
    if (!modalOverlay) return;
    modalOverlay.classList.remove(`${PREFIX}-modal-in`);
    modalOverlay.classList.add(`${PREFIX}-modal-out`);
    modalHideTimer = setTimeout(() => {
      modalOverlay.classList.remove(`${PREFIX}-modal-visible`, `${PREFIX}-modal-out`);
      modalOverlay.replaceChildren();
      modalPanel = null;
      modalHideTimer = null;
    }, 200);
  }

  function isModalOpen () {
    return !!(modalOverlay && modalOverlay.classList.contains(`${PREFIX}-modal-visible`));
  }

  function buildAlertModal (message, isDark, opts = {}) {
    const buttons = opts.buttons || [{ label: 'OK', primary: true, onClick: hideModal }];
    return h('div', { style: {
      borderRadius: '12px', padding: '28px', textAlign: 'center', minWidth: '280px', maxWidth: '680px',
      boxShadow: '0 20px 60px rgba(0,0,0,0.3)', fontFamily: 'system-ui, sans-serif',
      background: isDark ? '#1f2937' : '#fff', color: isDark ? '#d1d5db' : '#111827',
    }}, [
      opts.title && h('h4', { textContent: opts.title, style: { margin: '0 0 12px', fontSize: '15px', fontWeight: '700' } }),
      h('p', { textContent: message, style: { margin: '0 0 18px', fontSize: '14px', lineHeight: '1.4' } }),
      h('div', { style: { display: 'flex', gap: '8px', justifyContent: 'center' } },
        buttons.map(b => h('button', {
          textContent: b.label,
          className: b.primary ? BTN_PRIMARY : BTN_OUTLINE,
          style: { minWidth: '100px', justifyContent: 'center' },
          onClick: () => { hideModal(); b.onClick && b.onClick(); },
        })),
      ),
    ]);
  }

  /* ================================================================
   *  TOASTS
   *
   *  Generic toast system. Stack of small pills bottom-right.
   *  Auth-expired uses the same system but with `sticky:true` so it
   *  doesn't auto-dismiss until the token comes back.
   * ================================================================ */
  let toastStack  = null;
  let authToastEl = null;

  function ensureToastStack () {
    if (toastStack && document.body.contains(toastStack)) return toastStack;
    toastStack = h('div', { id: `${PREFIX}-toast-stack` });
    document.body.appendChild(toastStack);
    return toastStack;
  }

  function showToast (message, opts = {}) {
    const type     = opts.type || 'info';   // info | success | error
    const duration = opts.duration ?? 3500;
    const sticky   = !!opts.sticky;
    const stack = ensureToastStack();
    const el = h('div', {
      className: `${PREFIX}-toast ${PREFIX}-toast-${type}`,
      textContent: message,
    });
    stack.appendChild(el);
    requestAnimationFrame(() => el.classList.add(`${PREFIX}-toast-in`));
    if (!sticky) {
      setTimeout(() => {
        el.classList.remove(`${PREFIX}-toast-in`);
        setTimeout(() => el.remove(), 250);
      }, duration);
    }
    return el;
  }

  function showAuthToast () {
    if (authToastEl && document.body.contains(authToastEl)) return;
    authToastEl = showToast('Kenmei+ session expired — refresh the page to resume', {
      type: 'error', sticky: true,
    });
  }
  function hideAuthToast () {
    if (!authToastEl) return;
    authToastEl.classList.remove(`${PREFIX}-toast-in`);
    setTimeout(() => { authToastEl?.remove(); authToastEl = null; }, 250);
  }

  /* ---- Site Refresh-button helper ----
     The dashboard has a native Refresh button (the round-arrow icon
     followed by the literal text "Refresh"). Clicking it triggers the
     site's own data refetch, which our /api/v2/manga_entries
     interceptor catches and feeds into both the list view and our
     covers grid. */
  function findRefreshButton () {
    return [...qsa('button')].find(b => b.textContent.trim() === 'Refresh') || null;
  }
  function clickSiteRefresh () {
    const btn = findRefreshButton();
    if (btn) { btn.click(); return true; }
    return false;
  }

  /* ================================================================
   *  DESKTOP NOTIFICATIONS  (persisted seen-set)
   * ================================================================ */
  function loadSeenUnread () {
    try { return new Set(JSON.parse(localStorage.getItem(SEEN_KEY)) || []); }
    catch { return new Set(); }
  }
  function saveSeenUnread (set) {
    localStorage.setItem(SEEN_KEY, JSON.stringify([...set]));
  }
  let previousUnreadIds = loadSeenUnread();
  // First-load grace: if the seen-set is empty (fresh install / cleared),
  // skip notifications on the very first refresh and just seed.
  let notifInitialized = previousUnreadIds.size > 0;

  async function requestNotifPermission () {
    if (!('Notification' in window)) return false;
    if (Notification.permission === 'granted') return true;
    if (Notification.permission === 'denied')  return false;
    const result = await Notification.requestPermission();
    return result === 'granted';
  }

  function fireNotification (title, chapterInfo) {
    if (Notification.permission !== 'granted') return;
    try {
      new Notification('Kenmei+ — New Chapter', {
        body: `${title}\n${chapterInfo}`,
        icon: 'https://www.kenmei.co/assets/default_small-DEwRdcqo.jpeg',
        tag: `kp-${title}`,
      });
    } catch (e) { console.warn('[Kenmei+] Notification error:', e); }
  }

  function checkForNewChapters (unreadEntries) {
    if (!settings.notifications || !notifInitialized) {
      previousUnreadIds = new Set(unreadEntries.map(e => e.id));
      saveSeenUnread(previousUnreadIds);
      notifInitialized = true;
      return;
    }
    for (const entry of unreadEntries) {
      if (!previousUnreadIds.has(entry.id)) {
        const ch = entry.chapters.next;
        const chLabel = ch ? `Ch. ${ch.chapter}` : 'New chapter';
        fireNotification(entry.attributes.title, chLabel);
      }
    }
    previousUnreadIds = new Set(unreadEntries.map(e => e.id));
    saveSeenUnread(previousUnreadIds);
  }

  /* ================================================================
   *  ENTRY CACHE  (used by settings filter + covers mode)
   * ================================================================ */
  let entriesCache    = null;   // all-statuses entries
  let entriesCachedAt = 0;

  async function getEntriesCached (force = false) {
    if (!force && entriesCache && Date.now() - entriesCachedAt < 60_000) return entriesCache;
    entriesCache    = await fetchEntriesAllStatuses();
    entriesCachedAt = Date.now();
    return entriesCache;
  }

  /* ================================================================
   *  SETTINGS PANEL
   * ================================================================ */
  function buildSettingsPanel () {
    const isDark = document.documentElement.classList.contains('dark');
    const bg     = isDark ? '#1f2937' : '#ffffff';
    const fg     = isDark ? '#e5e7eb' : '#111827';
    const muted  = isDark ? '#9ca3af' : '#6b7280';
    const border = isDark ? '#374151' : '#e5e7eb';
    const inputBg = isDark ? '#111827' : '#f9fafb';

    const panel = h('div', { style: {
      borderRadius: '12px', padding: '24px', width: '680px',
      maxWidth: '92vw', maxHeight: '85vh', overflowY: 'auto',
      boxShadow: '0 24px 64px rgba(0,0,0,0.35)',
      fontFamily: 'system-ui, -apple-system, sans-serif',
      background: bg, color: fg,
    }});

    panel.appendChild(h('div', { style: {
      display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px',
    }}, [
      h('h3', { textContent: 'Kenmei+ Settings', style: { margin: '0', fontSize: '16px', fontWeight: '700' } }),
      h('button', {
        innerHTML: '&times;', onClick: hideModal,
        style: { background: 'none', border: 'none', fontSize: '22px', cursor: 'pointer', color: muted, lineHeight: '1' },
      }),
    ]));

    // ── Appearance
    panel.appendChild(sectionLabel('APPEARANCE', muted));
    panel.appendChild(buildToggleRow('Dark Mode', settings.darkMode, (val) => {
      applyDarkMode(val);
      reopenSettings();
    }, isDark));

    panel.appendChild(divider(border));

    // ── Notifications
    panel.appendChild(sectionLabel('NOTIFICATIONS', muted));
    panel.appendChild(buildToggleRow('Chapter drop alerts', settings.notifications, async (val) => {
      if (val) {
        const granted = await requestNotifPermission();
        if (!granted) {
          settings.notifications = false;
          saveSettings(settings);
          reopenSettings();
          return;
        }
      }
      settings.notifications = val;
      saveSettings(settings);
    }, isDark));
    panel.appendChild(h('p', {
      textContent: 'Fires when auto-refresh detects a new unread chapter.',
      style: { margin: '6px 0 0', fontSize: '11px', color: muted },
    }));

    panel.appendChild(divider(border));

    // ── Auto-refresh
    panel.appendChild(sectionLabel('AUTO-REFRESH', muted));
    panel.appendChild(buildToggleRow('Periodically re-count unread', settings.autoRefresh, (val) => {
      settings.autoRefresh = val;
      saveSettings(settings);
      val ? startAutoRefresh() : stopAutoRefresh();
    }, isDark));

    panel.appendChild(buildNumberRow(
      'Interval (seconds)', settings.autoRefreshInterval, MIN_REFRESH_S, 3600,
      (val) => { settings.autoRefreshInterval = val; saveSettings(settings); if (settings.autoRefresh) startAutoRefresh(); },
      isDark, inputBg, border, fg,
    ));

    panel.appendChild(divider(border));

    // ── Open Updated
    panel.appendChild(sectionLabel('OPEN UPDATED', muted));
    panel.appendChild(buildNumberRow(
      'Tabs per batch', settings.batchSize, 1, 100,
      (val) => { settings.batchSize = val; saveSettings(settings); },
      isDark, inputBg, border, fg,
    ));

    panel.appendChild(buildToggleRow('Auto-set latest after Open', settings.autoSetLatestAfterOpen, (val) => {
      settings.autoSetLatestAfterOpen = val;
      saveSettings(settings);
    }, isDark));
    panel.appendChild(h('p', {
      textContent: 'After a chapter is opened via Open Updated, automatically mark it as read.',
      style: { margin: '6px 0 0', fontSize: '11px', color: muted },
    }));

    panel.appendChild(divider(border));

    // ── Series filter
    const filterHeader = h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' } }, [
      h('p', { textContent: 'OPEN UPDATED FILTER', style: { margin: 0, fontSize: '11px', fontWeight: '700', color: muted, letterSpacing: '0.08em' } }),
      h('button', {
        innerHTML: ICON.refresh, title: 'Refresh series list',
        style: { background: 'none', border: 'none', cursor: 'pointer', color: muted, padding: '2px' },
        onClick: () => loadSeriesFilter(seriesList, isDark, fg, muted, border, true),
      }),
    ]);
    panel.appendChild(filterHeader);

    panel.appendChild(h('p', {
      textContent: 'Unchecked series will be skipped by "Open Updated".',
      style: { margin: '0 0 10px', fontSize: '11px', color: muted },
    }));

    const seriesList = h('div', {
      id: `${PREFIX}-series-filter`,
      style: {
        maxHeight: '200px', overflowY: 'auto', borderRadius: '8px',
        border: `1px solid ${border}`, background: inputBg, padding: '4px 0',
      },
    });
    seriesList.appendChild(h('p', {
      textContent: 'Loading series...',
      style: { textAlign: 'center', padding: '12px', fontSize: '12px', color: muted },
    }));
    panel.appendChild(seriesList);

    loadSeriesFilter(seriesList, isDark, fg, muted, border, false);

    panel.appendChild(divider(border));

    // ── Experimental features
    panel.appendChild(buildExperimentalSection(isDark, fg, muted, border, inputBg));

    panel.appendChild(divider(border));

    // ── Keyboard shortcuts
    panel.appendChild(sectionLabel('KEYBOARD SHORTCUTS', muted));
    panel.appendChild(buildShortcutsTable(isDark, fg, muted, border, inputBg));

    panel.appendChild(divider(border));

    // ── API status
    const tokenStatus = authExpired ? '✗ Expired — refresh page' : (authToken ? '✓' : '✗ Waiting for token...');
    const tokenColor  = authToken && !authExpired ? '#22c55e' : '#ef4444';
    panel.appendChild(h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } }, [
      h('span', { textContent: 'API Auth', style: { fontSize: '13px', color: fg } }),
      h('span', { textContent: tokenStatus, style: { fontSize: '12px', color: tokenColor, fontWeight: '600' } }),
    ]));

    panel.appendChild(h('p', {
      textContent: 'Kenmei+ v2.2.1',
      style: { margin: '16px 0 0', fontSize: '11px', color: muted, textAlign: 'center' },
    }));

    return panel;
  }

  /* ── Experimental section ──────────────────────────────────── */
  function buildExperimentalSection (isDark, fg, muted, border, inputBg) {
    const wrap = h('div');

    // Header row: label + "EXPERIMENTAL" badge
    const headerRow = h('div', { style: {
      display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px',
    }});
    headerRow.appendChild(h('p', {
      textContent: 'EXPERIMENTAL',
      style: { margin: 0, fontSize: '11px', fontWeight: '700', color: muted, letterSpacing: '0.08em' },
    }));
    headerRow.appendChild(h('span', {
      textContent: 'may break with site updates',
      style: {
        fontSize: '10px', fontWeight: '600', padding: '2px 6px',
        borderRadius: '4px', letterSpacing: '0.04em',
        background: isDark ? 'rgba(234,179,8,0.15)' : 'rgba(234,179,8,0.12)',
        color: isDark ? '#fde047' : '#a16207',
        border: `1px solid ${isDark ? 'rgba(234,179,8,0.3)' : 'rgba(234,179,8,0.4)'}`,
      },
    }));
    wrap.appendChild(headerRow);

    // Covers override toggle
    wrap.appendChild(buildToggleRow('Covers Mode override', settings.coversOverride, (val) => {
      settings.coversOverride = val;
      if (!val) {
        // Turning the feature off: exit any active covers view and reset state.
        settings.coversMode = false;
        exitCoversMode();
        const toggle = findCoversToggle();
        if (toggle) syncCoversToggleVisual(toggle);
      }
      saveSettings(settings);
      // Re-bind (or unbind) the toggle interception.
      bindCoversToggle();
      if (val) window.location.reload();
    }, isDark));

    wrap.appendChild(h('p', {
      textContent: 'Intercepts the list/covers toggle and renders a cover grid from the API, bypassing the Kenmei premium gate. When disabled, the toggle behaves normally.',
      style: { margin: '8px 0 0', fontSize: '11px', color: muted, lineHeight: '1.5' },
    }));

    // Warning callout
    wrap.appendChild(h('div', {
      style: {
        marginTop: '10px', padding: '8px 10px', borderRadius: '6px',
        fontSize: '11px', lineHeight: '1.5',
        background: isDark ? 'rgba(234,179,8,0.08)' : 'rgba(234,179,8,0.07)',
        border: `1px solid ${isDark ? 'rgba(234,179,8,0.2)' : 'rgba(234,179,8,0.3)'}`,
        color: isDark ? '#fde047' : '#92400e',
      },
      textContent: '⚠ This feature relies on internal CSS class names that Kenmei may change at any time. Cards may display incorrectly after a site update. Many features do not work or are broken.',
    }));

    return wrap;
  }

  function reopenSettings () {
    hideModal();
    setTimeout(() => showModal(buildSettingsPanel()), 250);
  }

  function sectionLabel (text, color) {
    return h('p', {
      textContent: text,
      style: { margin: '0 0 10px', fontSize: '11px', fontWeight: '700', color, letterSpacing: '0.08em' },
    });
  }

  function divider (borderColor) {
    return h('hr', { style: { border: 'none', borderTop: `1px solid ${borderColor}`, margin: '16px 0' } });
  }

  function buildToggleRow (label, isOn, onChange, isDark) {
    const row = h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' } });
    row.appendChild(h('span', { textContent: label, style: { fontSize: '13px' } }));

    const toggle = h('button', { style: {
      width: '44px', height: '24px', borderRadius: '12px', border: 'none',
      cursor: 'pointer', position: 'relative', transition: 'background 0.2s', flexShrink: '0',
      background: isOn ? '#3b82f6' : (isDark ? '#4b5563' : '#d1d5db'),
    }});

    const knob = h('span', { style: {
      display: 'block', width: '20px', height: '20px', borderRadius: '50%',
      background: '#fff', position: 'absolute', top: '2px', left: '2px',
      transition: 'transform 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
      transform: isOn ? 'translateX(20px)' : 'translateX(0)',
    }});
    toggle.appendChild(knob);

    toggle.addEventListener('click', () => {
      isOn = !isOn;
      toggle.style.background = isOn ? '#3b82f6' : (isDark ? '#4b5563' : '#d1d5db');
      knob.style.transform    = isOn ? 'translateX(20px)' : 'translateX(0)';
      onChange(isOn);
    });

    row.appendChild(toggle);
    return row;
  }

  function buildNumberRow (label, value, min, max, onChange, isDark, inputBg, border, fg) {
    const row = h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: '8px' } });
    row.appendChild(h('span', { textContent: label, style: { fontSize: '13px' } }));
    const input = h('input', {
      type: 'textField', min: String(min), max: String(max),
      value: String(value),
      style: {
        width: '80px', padding: '4px 8px', borderRadius: '6px',
        border: `1px solid ${border}`, background: inputBg, color: fg,
        fontSize: '13px', textAlign: 'right',
      },
    });
    input.addEventListener('change', () => {
      const v = Math.max(min, Math.min(max, parseInt(input.value, 10) || min));
      input.value = String(v);
      onChange(v);
    });
    row.appendChild(input);
    return row;
  }

  /* ── Series Filter List (cached) ─────────────────────────────── */
  async function loadSeriesFilter (container, isDark, fg, muted, border, force = false) {
    if (!authToken) {
      container.innerHTML = `<p style="text-align:center;padding:12px;font-size:12px;color:${muted}">Need auth token — refresh page.</p>`;
      return;
    }

    container.replaceChildren(h('p', {
      textContent: 'Loading series...',
      style: { textAlign: 'center', padding: '12px', fontSize: '12px', color: muted },
    }));

    try {
      const entries = await getEntriesCached(force);
      container.replaceChildren();

      entries.sort((a, b) => a.attributes.title.localeCompare(b.attributes.title));

      for (const entry of entries) {
        const excluded = settings.excludedSeries.includes(entry.id);
        const row = h('label', { style: {
          display: 'flex', alignItems: 'center', gap: '8px',
          padding: '6px 10px', cursor: 'pointer', fontSize: '12px',
          color: excluded ? muted : fg,
          transition: 'background 0.1s',
        }});

        row.addEventListener('mouseenter', () => row.style.background = isDark ? '#1f2937' : '#f3f4f6');
        row.addEventListener('mouseleave', () => row.style.background = 'transparent');

        const checkbox = h('input', { type: 'checkbox' });
        checkbox.checked = !excluded;
        checkbox.style.cssText = 'accent-color: #3b82f6; cursor: pointer; flex-shrink: 0;';

        checkbox.addEventListener('change', () => {
          if (checkbox.checked) {
            settings.excludedSeries = settings.excludedSeries.filter(id => id !== entry.id);
          } else if (!settings.excludedSeries.includes(entry.id)) {
            settings.excludedSeries.push(entry.id);
          }
          saveSettings(settings);
          row.style.color = checkbox.checked ? fg : muted;
        });

        const title = h('span', { textContent: entry.attributes.title, style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } });

        if (entry.attributes.unread) {
          const dot = h('span', { style: {
            width: '6px', height: '6px', borderRadius: '50%', flexShrink: '0',
            background: '#3b82f6', marginLeft: 'auto',
          }});
          row.append(checkbox, title, dot);
        } else {
          row.append(checkbox, title);
        }

        container.appendChild(row);
      }
    } catch (err) {
      container.innerHTML = `<p style="text-align:center;padding:12px;font-size:12px;color:#ef4444">Error: ${err.message}</p>`;
    }
  }

  /* ── Shortcut bindings UI ────────────────────────────────────── */
  const SHORTCUT_LABELS = {
    openUpdated:    'Open Updated',
    setLatest:      'Set All Latest',
    toggleSettings: 'Toggle Settings',
    toggleCovers:   'Toggle Covers Mode',
    refresh:        'Refresh Badge',
    toggleDark:     'Toggle Dark Mode',
    prevPage:       'Previous Page',
    nextPage:       'Next Page',
  };

  function prettyKey (k) {
    if (!k) return '—';
    return k
      .replace('Arrow', '')
      .replace(/^./, c => c.toUpperCase());
  }

  function buildShortcutsTable (isDark, fg, muted, border, inputBg) {
    const table = h('div', { style: {
      borderRadius: '8px', border: `1px solid ${border}`, background: inputBg, overflow: 'hidden',
    }});

    Object.keys(SHORTCUT_LABELS).forEach((action, i) => {
      const row = h('div', { style: {
        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        padding: '8px 12px', fontSize: '13px',
        borderTop: i === 0 ? 'none' : `1px solid ${border}`,
      }}, [
        h('span', { textContent: SHORTCUT_LABELS[action], style: { color: fg } }),
      ]);

      const btn = h('button', {
        textContent: prettyKey(settings.shortcuts[action]),
        style: {
          padding: '3px 10px', minWidth: '54px', fontSize: '12px', fontFamily: 'monospace',
          borderRadius: '5px', border: `1px solid ${border}`,
          background: isDark ? '#0b1220' : '#fff', color: fg, cursor: 'pointer',
        },
      });

      let listening = false;
      const onKey = (ev) => {
        if (!listening) return;
        ev.preventDefault();
        ev.stopPropagation();
        if (ev.key === 'Escape') {
          listening = false;
          btn.textContent = prettyKey(settings.shortcuts[action]);
          btn.style.background = isDark ? '#0b1220' : '#fff';
          document.removeEventListener('keydown', onKey, true);
          return;
        }
        // Accept printable single-char keys + arrows
        const key = ev.key;
        if (key.length === 1 || key.startsWith('Arrow')) {
          settings.shortcuts[action] = key;
          saveSettings(settings);
          listening = false;
          btn.textContent = prettyKey(key);
          btn.style.background = isDark ? '#0b1220' : '#fff';
          document.removeEventListener('keydown', onKey, true);
        }
      };

      btn.addEventListener('click', () => {
        if (listening) return;
        listening = true;
        btn.textContent = 'press key…';
        btn.style.background = '#3b82f6';
        document.addEventListener('keydown', onKey, true);
      });

      row.appendChild(btn);
      table.appendChild(row);
    });

    return table;
  }

  /* ================================================================
   *  DASHBOARD – STATE
   * ================================================================ */
  let dashActive  = false;
  let autoTimer   = null;
  let unreadBadge = null;
  let cogBtn      = null;
  let injectedEls = [];
  let lastRefreshAt = 0;

  /* ================================================================
   *  DASHBOARD – SETUP / TEARDOWN
   * ================================================================ */
  function initDashboard () {
    if (dashActive) return;
    waitFor('ul.divide-y.divide-gray-200').then(() => {
      if (!location.pathname.includes('dashboard')) return;
      dashActive = true;
      injectActionButtons();
      injectSettingsCog();
      bindCoversToggle();
      watchCoversToggle();
      refreshBadgeFromAPI();
      if (settings.autoRefresh) startAutoRefresh();
      // Only auto-enter covers mode if the feature override is enabled.
      if (settings.coversOverride && settings.coversMode) enterCoversMode();
    }).catch(() => console.warn('[Kenmei+] Dashboard not found'));
  }

  function teardownDashboard () {
    dashActive = false;
    stopAutoRefresh();
    exitCoversMode();
    unwatchCoversToggle();
    injectedEls.forEach(el => el.remove());
    injectedEls = [];
    unreadBadge = null;
    cogBtn      = null;
  }

  /* ================================================================
   *  INJECT ACTION BUTTONS
   * ================================================================ */
  function injectActionButtons () {
    const toolbar = qs('.flex-none.z-5 .space-x-3');
    if (!toolbar || qs(`#${PREFIX}-btn-open`)) return;

    const btnOpen = h('button', {
      id: `${PREFIX}-btn-open`, className: BTN_PRIMARY,
      innerHTML: ICON.openUpdated + 'Open Updated',
      onClick: openAllUpdated,
    });
    const btnLatest = h('button', {
      id: `${PREFIX}-btn-latest`, className: BTN_OUTLINE,
      innerHTML: ICON.setLatest + 'Set All Latest',
      onClick: setAllLatest,
    });

    unreadBadge = h('span', {
      id: `${PREFIX}-badge`,
      title: 'Unread series',
      style: {
        display: 'none', alignItems: 'center', padding: '2px 10px',
        borderRadius: '9999px', fontSize: '12px', fontWeight: '700',
        background: '#ef4444', color: '#fff', alignSelf: 'center', cursor: 'default',
      },
    });

    toolbar.appendChild(btnOpen);
    toolbar.appendChild(btnLatest);
    toolbar.appendChild(unreadBadge);
    injectedEls.push(btnOpen, btnLatest, unreadBadge);
    qs('.button--link')?.remove();
  }

  /* ================================================================
   *  INJECT SETTINGS COG
   * ================================================================ */
  function injectSettingsCog () {
    const avatar = qs('.btn-avatar');
    if (!avatar || qs(`#${PREFIX}-cog`)) return;

    cogBtn = h('button', {
      id: `${PREFIX}-cog`, className: 'btn--icon', type: 'button',
      innerHTML: `<span class="sr-only">Kenmei+ Settings</span>${ICON.cog}`,
      onClick () { showModal(buildSettingsPanel()); },
    });
    updateCogColor();
    avatar.parentElement.insertBefore(cogBtn, avatar);
    injectedEls.push(cogBtn);
  }

  function updateCogColor () {
    if (!cogBtn) return;
    cogBtn.style.color = document.documentElement.classList.contains('dark') ? '#ffffff' : '#6b7280';
  }

  /* ================================================================
   *  UNREAD BADGE
   * ================================================================ */
  async function refreshBadgeFromAPI () {
    if (!unreadBadge) return;

    if (!authToken) {
      const n = qsa('.unread-indicator').length;
      setBadge(n);
      return;
    }

    try {
      const unread = await fetchUnreadEntries();
      lastRefreshAt = Date.now();
      setBadge(unread.length);
      checkForNewChapters(unread);
    } catch (err) {
      console.warn('[Kenmei+] Badge error:', err.message);
      if (!authExpired) setBadge(qsa('.unread-indicator').length);
    }
  }

  function setBadge (n) {
    if (!unreadBadge) return;
    unreadBadge.textContent = String(n);
    unreadBadge.title = `${n} unread • Last checked ${relativeTime(lastRefreshAt)}`;
    unreadBadge.style.display = n > 0 ? 'inline-flex' : 'none';
  }

  // Periodically refresh tooltip so "5m ago" updates without an API call.
  setInterval(() => {
    if (unreadBadge && unreadBadge.style.display !== 'none' && lastRefreshAt) {
      const n = parseInt(unreadBadge.textContent, 10) || 0;
      unreadBadge.title = `${n} unread • Last checked ${relativeTime(lastRefreshAt)}`;
    }
  }, 30_000);

  /* ================================================================
   *  CORE ACTIONS
   * ================================================================ */
  async function openAllUpdated () {
    const isDark = document.documentElement.classList.contains('dark');
    if (!authToken) {
      showToast('Auth token not captured yet — refresh the page', { type: 'error' });
      return;
    }
    const btn = qs(`#${PREFIX}-btn-open`);
    if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; }
    try {
      showToast('Looking for updated series…');
      const unread = await fetchUnreadEntries();
      const filtered = unread.filter(e => !settings.excludedSeries.includes(e.id));
      if (filtered.length === 0) {
        showToast('No updated series to open', { type: 'info' });
        return;
      }
      const opened = await openInBatches(filtered, settings.batchSize, isDark);
      if (settings.autoSetLatestAfterOpen) {
        const toSet = filtered
          .slice(0, opened)
          .map(e => {
            const latestId = e.attributes?.latestChapter?.id ?? e.chapters?.last?.id;
            return latestId ? setEntryToLatest(e.id, latestId) : null;
          })
          .filter(Boolean);
        if (toSet.length) await Promise.allSettled(toSet);
        clickSiteRefresh();
        setTimeout(refreshBadgeFromAPI, 500);
      }
      if (opened > 0) {
        showToast(`Opened ${opened} updated series`, { type: 'success' });
      }
    } catch (err) {
      console.error('[Kenmei+] Open Updated error:', err);
      showToast(`Open Updated failed: ${err.message}`, { type: 'error' });
    } finally {
      if (btn) { btn.disabled = false; btn.style.opacity = '1'; }
    }
  }

  function openInBatches (entries, batchSize, isDark) {
    return new Promise((resolve) => {
      const total  = entries.length;
      let opened   = 0;
      const openOne = (entry) => {
        const url = entry.chapters?.next?.url;
        if (url) window.open(url, '_blank', 'noopener');
      };
      const nextBatch = () => {
        const remaining = total - opened;
        if (remaining <= 0) { resolve(opened); return; }
        const take = Math.min(batchSize, remaining);
        for (let i = 0; i < take; i++) openOne(entries[opened + i]);
        opened += take;
        if (opened >= total) { resolve(opened); return; }
        const nextN = Math.min(batchSize, total - opened);
        showModal(buildAlertModal(
          `Opened ${opened} of ${total}. Open the next ${nextN}?`,
          isDark,
          {
            title: 'Batch open',
            buttons: [
              { label: 'Stop',          onClick: () => resolve(opened) },
              { label: `Open ${nextN}`, primary: true, onClick: nextBatch },
            ],
          },
        ));
      };
      nextBatch();
    });
  }
  async function setAllLatest () {
    if (!authToken) {
      showToast('Auth token not captured yet — refresh the page', { type: 'error' });
      return;
    }

    const btn = qs(`#${PREFIX}-btn-latest`);
    if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; }

    try {
      const unread = await fetchUnreadEntries();
      if (unread.length === 0) {
        showToast('Nothing to update — no unread series', { type: 'info' });
        clickSiteRefresh();
        setTimeout(refreshBadgeFromAPI, 500);
        return;
      }

      showToast(`Updating ${unread.length} series…`);

      const results = await Promise.allSettled(unread.map(entry => {
        const latestId = entry.attributes?.latestChapter?.id ?? entry.chapters?.last?.id;
        if (!latestId) return Promise.reject(new Error('no latest chapter id'));
        return setEntryToLatest(entry.id, latestId);
      }));

      const ok    = results.filter(r => r.status === 'fulfilled').length;
      const fails = results.length - ok;

      console.log(`[Kenmei+] Set ${ok} series to latest${fails ? ` (${fails} failed)` : ''}`);

      // Click the site's Refresh button so both list view and covers grid
      // pick up the new state via the API response interceptor.
      clickSiteRefresh();
      setTimeout(refreshBadgeFromAPI, 500);

      if (fails) {
        showToast(`Updated ${ok} — ${fails} failed`, { type: 'error' });
      } else {
        showToast(`Marked ${ok} series as up to date`, { type: 'success' });
      }
    } catch (err) {
      console.error('[Kenmei+] Set All Latest error:', err);
      showToast(`Set All Latest failed: ${err.message}`, { type: 'error' });
    } finally {
      if (btn) { btn.disabled = false; btn.style.opacity = '1'; }
    }
  }

  /* ================================================================
   *  AUTO-REFRESH
   * ================================================================ */
  function startAutoRefresh () {
    stopAutoRefresh();
    if (!settings.autoRefresh) return;
    const ms = Math.max(MIN_REFRESH_S, settings.autoRefreshInterval) * 1000;
    autoTimer = setInterval(() => {
      refreshBadgeFromAPI();
      console.log(`[Kenmei+] auto-refresh tick`);
    }, ms);
  }

  function stopAutoRefresh () {
    if (autoTimer) { clearInterval(autoTimer); autoTimer = null; }
  }

  // Resume auto-refresh once a fresh token comes through after expiry.
  onTokenRestored(() => {
    console.log('[Kenmei+] Token restored — resuming.');
    refreshBadgeFromAPI();
    if (settings.autoRefresh) startAutoRefresh();
  });

  /* ================================================================
   *  COVERS MODE
   *
   *  Hijacks the site's `.switch.z-5` toggle (the list/covers
   *  selector) ONLY when settings.coversOverride is true. When the
   *  override is disabled, clicks pass through to Vue's own handler
   *  so Kenmei's premium prompt runs as normal.
   * ================================================================ */
  const coversState = {
    grid:      null,    // <ul> we inject
    list:      null,    // the native list we hide
    entries:   [],
    pagy:      null,
    toggleEl:  null,
    bound:     false,
    observer:  null,
  };

  function findCoversToggle () { return qs('.switch.z-5'); }

  function bindCoversToggle () {
    const toggle = findCoversToggle();

    // Remove any previously attached listener first so we don't double-bind.
    if (coversState.toggleEl) {
      coversState.toggleEl.removeEventListener('click', onCoversToggleClick, true);
    }

    if (!toggle) {
      coversState.toggleEl = null;
      coversState.bound    = false;
      return;
    }

    coversState.toggleEl = toggle;

    if (settings.coversOverride) {
      // Override is on — intercept and suppress Vue's handler.
      toggle.addEventListener('click', onCoversToggleClick, true);
      coversState.bound = true;
    } else {
      // Override is off — do not intercept; leave Vue's handler in place.
      coversState.bound = false;
    }

    syncCoversToggleVisual(toggle);
  }

  function onCoversToggleClick (e) {
    // Only runs when coversOverride is true (we only attach when it is).
    e.stopImmediatePropagation();
    e.preventDefault();
    settings.coversMode = !settings.coversMode;
    saveSettings(settings);
    settings.coversMode ? enterCoversMode() : exitCoversMode();
    syncCoversToggleVisual(coversState.toggleEl);
  }

  function syncCoversToggleVisual (toggle) {
    if (!toggle || !settings.coversOverride) return;
    const opts = qsa('.mode-select-btn', toggle);
    if (opts.length < 2) return;
    // First child = list, second = covers (per site markup).
    opts[0].classList.toggle('mode-select-btn__active', !settings.coversMode);
    opts[1].classList.toggle('mode-select-btn__active',  settings.coversMode);
  }

  function watchCoversToggle () {
    // Re-bind when Vue re-renders (route changes, hot updates).
    if (coversState.observer) return;
    coversState.observer = new MutationObserver(() => {
      const t = findCoversToggle();
      if (t && t !== coversState.toggleEl) {
        coversState.toggleEl = null;
        bindCoversToggle();
      }
      // If grid disappeared (Vue re-rendered the list region), re-attach it.
      if (settings.coversOverride && settings.coversMode
          && coversState.grid && !document.body.contains(coversState.grid)) {
        coversState.grid = null;
        enterCoversMode();
      }
    });
    coversState.observer.observe(document.body, { childList: true, subtree: true });
  }

  function unwatchCoversToggle () {
    if (coversState.observer) { coversState.observer.disconnect(); coversState.observer = null; }
    if (coversState.toggleEl) {
      coversState.toggleEl.removeEventListener('click', onCoversToggleClick, true);
      coversState.toggleEl = null;
    }
    coversState.bound = false;
  }

  function enterCoversMode () {
    const list = qs('ul.divide-y.divide-gray-200');
    if (!list) return;

    coversState.list = list;
    // Hide the list off-screen rather than display:none. Vue keeps the rows
    // mounted and their @click handlers (Edit, Delete, Share, …) keep firing
    // when we relay clicks from cover cards.
    list.classList.add(`${PREFIX}-list-offscreen`);

    if (!coversState.grid || !document.body.contains(coversState.grid)) {
      const grid = hs('ul', SCOPE.LI, {
        id: `${PREFIX}-covers-grid`,
        role: 'list',
        // Matches Kenmei's medium gridSize default (see DashboardGridList).
        className: 'grid-cols-2 md_grid-cols-4 md-grid 5xl_grid-cols-6',
      });
      list.parentElement.insertBefore(grid, list);
      coversState.grid = grid;
    }

    renderCoversGrid();
  }

  function exitCoversMode () {
    if (coversState.grid) {
      coversState.grid.remove();
      coversState.grid = null;
    }
    const list = coversState.list || qs('ul.divide-y.divide-gray-200');
    if (list) list.classList.remove(`${PREFIX}-list-offscreen`);
    coversState.list = null;
  }

  function onEntriesPageResponse (data) {
    if (!data || !Array.isArray(data.entries)) return;
    coversState.entries = data.entries;
    coversState.pagy    = data.pagy;
    if (settings.coversOverride && settings.coversMode) {
      // The list might have been re-attached after the fetch — re-enter to be safe.
      if (!coversState.grid || !document.body.contains(coversState.grid)) enterCoversMode();
      else renderCoversGrid();
    }
  }

  function renderCoversGrid () {
    const grid = coversState.grid;
    if (!grid) return;
    grid.replaceChildren();
    if (coversState.entries.length === 0) {
      grid.appendChild(h('li', {
        textContent: 'Loading…',
        style: { gridColumn: '1 / -1', textAlign: 'center', padding: '24px', color: '#6b7280' },
      }));
      return;
    }
    for (const entry of coversState.entries) grid.appendChild(buildCoverCard(entry));
  }

  /* ---- Vue scoped-attribute hashes (must match the bundle's CSS) ---- */
  const SCOPE = Object.freeze({
    LI:        'data-v-039357b4',
    CARD:      'data-v-f75b6e10',
    OVERLAY:   'data-v-281b8745',
    STEPPER1:  'data-v-7573c614',   // edit + ellipsis
    STEPPER2:  'data-v-b7c83ecc',   // minus + plus + check-check
    PROGRESS:  'data-v-5f9e6021',
    CHECKBOX:  'data-v-51b7852a',
  });

  // h() pass-through that also sets the data-v scope attributes (one or many).
  function hs (tag, scopes, attrs = {}, children = []) {
    const el = h(tag, attrs, children);
    if (typeof scopes === 'string') el.setAttribute(scopes, '');
    else if (Array.isArray(scopes)) scopes.forEach(s => el.setAttribute(s, ''));
    return el;
  }

  /* ---- Lucide icons used by stepper buttons ---- */
  function lucide (paths, extraClass = '') {
    return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide stepper-icon size-4 ${extraClass}" aria-hidden="true">${paths}</svg>`;
  }
  const LUCIDE = {
    squarePen:        lucide('<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"></path>', 'lucide-square-pen-icon lucide-square-pen'),
    ellipsisVertical: lucide('<circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="19" r="1"></circle>', 'lucide-ellipsis-vertical-icon lucide-ellipsis-vertical'),
    minus:            lucide('<path d="M5 12h14"></path>', 'lucide-minus-icon lucide-minus'),
    plus:             lucide('<path d="M5 12h14"></path><path d="M12 5v14"></path>', 'lucide-plus-icon lucide-plus'),
    checkCheck:       lucide('<path d="M18 6 7 17l-5-5"></path><path d="m22 10-7.5 7.5L13 16"></path>', 'lucide-check-check-icon lucide-check-check'),
    check:            lucide('<path d="M20 6 9 17l-5-5"></path>', 'lucide-check-icon lucide-check status-icon size-3'),
  };

  /* ---- Stepper actions (use the same PUT we already built) ---- */
  async function stepperSetChapter (entry, kind) {
    // kind: 'next' (advance by one)
    //       'prev' (rewind by one)
    //       'last' (mark all read / set to latest)
    let id;
    if (kind === 'last') id = entry.chapters?.last?.id || entry.attributes?.latestChapter?.id;
    else if (kind === 'next') id = entry.chapters?.next?.id;
    else if (kind === 'prev') id = entry.chapters?.prev?.id;
    if (!id || !authToken) return;
    try {
      await setEntryToLatest(entry.id, id);
      // Refresh the current page so the grid reflects new chaptersBehind/from.
      const data = await fetchEntriesPage(currentEntriesPage(), 1);
      onEntriesPageResponse(data);
      refreshBadgeFromAPI();
    } catch (e) {
      console.warn('[Kenmei+] stepper failed:', e.message);
    }
  }

  function currentEntriesPage () { return coversState.pagy?.page || 1; }

  /* ---- Refresh-after-mutation (debounced) ---- */
  let coversRefreshTimer = null;
  function scheduleCoversRefresh () {
    if (coversRefreshTimer) return;
    coversRefreshTimer = setTimeout(async () => {
      coversRefreshTimer = null;
      if (!authToken) return;
      try {
        const data = await fetchEntriesPage(currentEntriesPage(), 1);
        onEntriesPageResponse(data);
        refreshBadgeFromAPI();
      } catch (_) { /* swallow */ }
    }, 350);   // small delay so a burst of mutations only triggers one refresh
  }

  /* ---- Delegate to the (hidden) list row ----
     Vue's @click handlers fire whether the row is display:none or not, so
     clicking the corresponding row button gives us the site's real flows
     (edit modal, delete confirm, share clipboard, report dialog, …) for
     free. */
  function findListRow (entry) {
    const list = coversState.list || qs('ul.divide-y.divide-gray-200');
    if (!list) return null;
    const link = list.querySelector(`a[href$="/series/${entry.slug}"]`);
    return link ? link.closest('li') : null;
  }
  function clickRowAction (entry, content) {
    const row = findListRow(entry);
    if (!row) return false;
    const btn = row.querySelector(`button[content="${content}"]`);
    if (!btn) return false;
    btn.click();
    return true;
  }
  function clickRowSeriesLink (entry) {
    const row = findListRow(entry);
    const link = row?.querySelector('a.list-row, a[href*="/series/"]');
    if (link) { link.click(); return true; }
    location.href = `/series/${entry.slug}`;
    return true;
  }

  /* ---- Ellipsis dropdown ---- */
  let openMenuCleanup = null;

  const MENU_ICONS = {
    Info:          '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-info-icon lucide-info" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg>',
    ExternalLink:  '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-external-link-icon lucide-external-link" aria-hidden="true"><path d="M15 3h6v6"></path><path d="M10 14 21 3"></path><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path></svg>',
    Link:          '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-link-icon lucide-link" aria-hidden="true"><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><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>',
    NotebookPen:   '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-notebook-pen-icon lucide-notebook-pen" aria-hidden="true"><path d="M13.4 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7.4"/><path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/><path d="M21.378 5.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/></svg>',
    TriangleAlert: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-triangle-alert-icon lucide-triangle-alert" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>',
    Trash:         '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide size-4 mr-2 lucide-trash-icon lucide-trash" aria-hidden="true"><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path><path d="M3 6h18"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>',
  };

  function openEllipsisMenu (entry, anchorBtn) {
    if (openMenuCleanup) openMenuCleanup();

    const items = [
      { label: 'Series Details', icon: 'Info',          action: () => clickRowSeriesLink(entry) },
      { label: 'Go to Source',   icon: 'ExternalLink',  action: () => clickRowAction(entry, 'Go to Source')   || window.open(entry.links?.series_url, '_blank', 'noopener') },
      { label: 'Share',          icon: 'Link',          action: () => clickRowAction(entry, 'Share') },
      entry.attributes?.notes
        ? { label: 'View Notes',  icon: 'NotebookPen',  action: () => clickRowAction(entry, 'View Notes') }
        : null,
      { label: 'Report',         icon: 'TriangleAlert', action: () => clickRowAction(entry, 'Report') },
      { label: 'Delete',         icon: 'Trash',         action: () => clickRowAction(entry, 'Delete') },
    ].filter(Boolean);

    const wrap = h('div', {
      'data-radix-popper-content-wrapper': '',
      style: { position: 'fixed', left: '0', top: '0', zIndex: '50', minWidth: 'max-content' },
    });
    const menu = h('div', {
      role: 'menu', 'aria-orientation': 'vertical',
      'data-radix-menu-content': '',
      'data-state': 'open', 'data-orientation': 'vertical',
      tabindex: '-1', dir: 'ltr',
      className: 'z-50 min-w-32 overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-950 shadow-md dark_border-gray-800 dark_bg-gray-700 dark_text-gray-50',
    });

    for (const item of items) {
      const it = hs('div', SCOPE.STEPPER1, {
        role: 'menuitem', tabindex: '-1',
        className: 'relative flex cursor-pointer select-none items-center rounded-md px-3 py-1.5 text-sm outline-none transition-colors focus_bg-gray-100 focus_text-gray-900 dark_focus_bg-gray-800 dark_focus_text-gray-50 sm_px-2',
        innerHTML: MENU_ICONS[item.icon] + item.label,
        onClick: (ev) => {
          ev.preventDefault(); ev.stopPropagation();
          try { item.action(); } catch (e) { console.warn('[Kenmei+] menu action failed:', e); }
          if (openMenuCleanup) openMenuCleanup();
        },
      });
      menu.appendChild(it);
    }

    wrap.appendChild(menu);
    document.body.appendChild(wrap);

    // Position below-right of anchor button.
    const rect = anchorBtn.getBoundingClientRect();
    const w = menu.offsetWidth || 160;
    const left = Math.max(8, Math.min(window.innerWidth - w - 8, rect.right - w));
    const top  = Math.min(window.innerHeight - menu.offsetHeight - 8, rect.bottom + 4);
    wrap.style.transform = `translate(${left}px, ${top}px)`;

    const onDocClick = (ev) => { if (!wrap.contains(ev.target)) openMenuCleanup(); };
    const onKey = (ev) => { if (ev.key === 'Escape') openMenuCleanup(); };
    openMenuCleanup = () => {
      wrap.remove();
      document.removeEventListener('mousedown', onDocClick, true);
      document.removeEventListener('keydown',   onKey, true);
      openMenuCleanup = null;
    };
    setTimeout(() => {
      document.addEventListener('mousedown', onDocClick, true);
      document.addEventListener('keydown',   onKey, true);
    }, 0);
  }

  /* ---- Cover card ---- */
  function buildCoverCard (entry) {
    const a    = entry.attributes || {};
    const ch   = entry.chapters   || {};
    const webp = a.cover?.webp?.large;
    const jpeg = a.cover?.jpeg?.large || 'https://www.kenmei.co/assets/default_small-DEwRdcqo.jpeg';
    const behind  = ch.chaptersBehind ?? 0;
    const total   = ch.count ?? 0;
    const upToDate = behind <= 0;
    const progress = upToDate ? 100 : Math.max(0, 100 - (behind / (total || 1)) * 100);
    const offset   = 100 - progress;

    const currentChapter = ch.from?.chapter != null
      ? `Ch. ${ch.from.chapter}`
      : 'Not started';
    const latestChapter  = ch.last?.chapter != null
      ? `Ch. ${ch.last.chapter}`
      : (a.latestChapter?.chapter != null ? `Ch. ${a.latestChapter.chapter}` : '—');
    const continueText   = ch.next?.chapter != null
      ? `Continue to Ch. ${ch.next.chapter}`
      : 'Up to date';
    const continueHref   = ch.next?.url || '#';
    const canContinue    = !!ch.next?.url;

    const behindText = upToDate
      ? 'Up to date'
      : `${behind} chapter${behind === 1 ? '' : 's'} behind`;

    /* ── <li> wrapper ── */
    const li = hs('li', SCOPE.LI, { className: 'relative' });

    /* ── flex column wrapper ── */
    const wrap = hs('div', [SCOPE.CARD, SCOPE.LI], {
      className: 'flex flex-col items-left justify-center relative',
    });

    /* ── card ── */
    const card = hs('div', SCOPE.CARD, { className: 'card group select-none' });
    // Card body itself is non-navigational — only the title text below is a link.

    /* ── card-cover-container ── */
    const cover = hs('div', SCOPE.CARD, { className: 'card-cover-container gradient-visible' });

    /* ── aspect / picture ── */
    const aspect = hs('div', SCOPE.CARD, {
      className: 'aspect-w-2 aspect-h-3 bg-gray-400',
      'data-v-wave-boundary': 'true',
    });
    const pic = hs('picture', SCOPE.CARD);
    if (webp) pic.appendChild(hs('source', SCOPE.CARD, { type: 'image/webp', srcset: webp }));
    pic.appendChild(hs('img', SCOPE.CARD, {
      src: jpeg, loading: 'lazy', alt: a.title || '',
      className: 'object-cover h-full w-full',
    }));
    aspect.appendChild(pic);
    cover.appendChild(aspect);

    /* ── status indicator ── */
    if (upToDate) {
      cover.appendChild(hs('div', SCOPE.CARD, {
        className: 'inline-flex items-center rounded-full border font-semibold transition-colors focus_outline-none focus_ring-2 focus_ring-gray-950 focus_ring-offset-2 dark_border-gray-800 dark_focus_ring-gray-300 border-transparent bg-emerald-100 text-emerald-800 hover_bg-emerald-100/80 dark_bg-emerald-600/30 dark_text-emerald-300 dark_hover_bg-emerald-50/80 status-indicator up-to-date text-[13px] px-[7px] py-[3px]',
        innerHTML: LUCIDE.check,
      }));
    } else {
      cover.appendChild(hs('div', SCOPE.CARD, {
        className: 'inline-flex items-center rounded-full border font-semibold transition-colors focus_outline-none focus_ring-2 focus_ring-gray-950 focus_ring-offset-2 dark_border-gray-800 dark_focus_ring-gray-300 border-transparent bg-blue-100 text-blue-800 hover_bg-blue-100/80 dark_bg-blue-600/30 dark_text-blue-300 dark_hover_bg-blue-50/80 status-indicator behind text-[13px] px-[7px] py-[3px]',
      }, [
        hs('span', SCOPE.CARD, { className: 'status-number h-3 min-w-3', textContent: String(behind) }),
      ]));
    }

    /* ── card-info ── */
    const titleEl = hs('div', SCOPE.CARD, {
      className: 'card-title text-[13px] line-clamp-2 lg_truncate lg_line-clamp-none',
      textContent: a.title || '',
      style: { cursor: 'pointer' },
    });
    titleEl.addEventListener('click', (ev) => {
      ev.preventDefault(); ev.stopPropagation();
      clickRowSeriesLink(entry);
    });
    cover.appendChild(hs('div', SCOPE.CARD, { className: 'card-info' }, [
      titleEl,
      hs('div', SCOPE.CARD, {
        className: 'info-line text-[11px]',
        textContent: a.latestChapter?.releasedAt ? `Updated ${relativeTime(Date.parse(a.latestChapter.releasedAt))}` : '',
      }),
    ]));

    card.appendChild(cover);

    /* ── desktop-only controls (max-lg_hidden) ── */
    const ctrlWrap = hs('div', [SCOPE.OVERLAY, SCOPE.CARD], { className: 'max-lg_hidden' });

    // top-controls: selection checkbox. Mirrors the same series' checkbox
    // in the (off-screen) list row — clicking either toggles both.
    const coverCheckbox = hs('input', SCOPE.CHECKBOX, {
      type: 'checkbox', className: 'dark_bg-gray-700',
    });
    const rowCheckbox = findListRow(entry)?.querySelector('input[type="checkbox"]');
    if (rowCheckbox) coverCheckbox.checked = !!rowCheckbox.checked;
    coverCheckbox.addEventListener('click', (ev) => {
      ev.stopPropagation();
      const r = findListRow(entry)?.querySelector('input[type="checkbox"]');
      if (r) {
        // Programmatically click the real checkbox so Vue's @change fires
        // and the bulk-select state stays in sync.
        r.click();
        coverCheckbox.checked = r.checked;
      }
    });
    ctrlWrap.appendChild(hs('div', SCOPE.OVERLAY, { className: 'top-controls' }, [
      hs('div', [SCOPE.CHECKBOX, SCOPE.OVERLAY], { className: 'relative flex items-start w-4 hidden sm_flex' }, [
        hs('div', SCOPE.CHECKBOX, { className: 'flex h-6 items-center' }, [
          coverCheckbox,
        ]),
      ]),
    ]));

    // hover-overlay
    const hover    = hs('div', SCOPE.OVERLAY, { className: 'hover-overlay text-gray-100' });
    const controls = hs('div', SCOPE.OVERLAY, { className: 'controls-container' });

    // chapter-display-row
    controls.appendChild(hs('div', SCOPE.OVERLAY, { className: 'chapter-display-row text-sm' }, [
      h('span', { className: 'text-blue-500 dark_text-blue-400', textContent: currentChapter }),
      h('span', { className: 'text-gray-500 dark_text-gray-300', textContent: ' / ' }),
      h('span', { className: 'text-white dark_text-gray-300 whitespace-nowrap', textContent: latestChapter }),
    ]));

    // control-buttons-row with two steppers
    const stepper1 = hs('div', [SCOPE.STEPPER1, SCOPE.OVERLAY], { className: 'stepper' });
    stepper1.appendChild(hs('button', SCOPE.STEPPER1, {
      className: 'stepper-btn size-7', content: 'Edit', innerHTML: LUCIDE.squarePen,
      onClick: (e) => {
        e.preventDefault(); e.stopPropagation();
        if (!clickRowAction(entry, 'Edit')) {
          showToast('Could not open the edit modal — try a manual refresh', { type: 'error' });
        }
      },
    }));
    const ellipsisBtn = hs('button', SCOPE.STEPPER1, {
      type: 'button',
      className: 'outline-none stepper-btn size-7', innerHTML: LUCIDE.ellipsisVertical,
    });
    ellipsisBtn.addEventListener('click', (e) => {
      e.preventDefault(); e.stopPropagation();
      openEllipsisMenu(entry, ellipsisBtn);
    });
    stepper1.appendChild(ellipsisBtn);

    const stepper2 = hs('div', [SCOPE.STEPPER2, SCOPE.OVERLAY], { className: 'stepper' });
    const minusBtn = hs('button', SCOPE.STEPPER2, {
      className: 'stepper-btn size-7', innerHTML: LUCIDE.minus,
      onClick: async (e) => {
        e.preventDefault(); e.stopPropagation();
        minusBtn.disabled = true;
        await stepperSetChapter(entry, 'prev');
      },
    });
    if (!ch.prev?.id) minusBtn.disabled = true;
    stepper2.appendChild(minusBtn);

    const plusBtn = hs('button', SCOPE.STEPPER2, {
      className: 'stepper-btn size-7', innerHTML: LUCIDE.plus,
      onClick: async (e) => {
        e.preventDefault(); e.stopPropagation();
        plusBtn.disabled = true;
        await stepperSetChapter(entry, 'next');
      },
    });
    if (!ch.next?.id) plusBtn.disabled = true;
    stepper2.appendChild(plusBtn);

    const checkBtn = hs('button', SCOPE.STEPPER2, {
      className: 'stepper-btn size-7', content: 'Update last read chapter',
      innerHTML: LUCIDE.checkCheck,
      onClick: async (e) => {
        e.preventDefault(); e.stopPropagation();
        checkBtn.disabled = true;
        await stepperSetChapter(entry, 'last');
      },
    });
    if (upToDate) checkBtn.disabled = true;
    stepper2.appendChild(checkBtn);

    controls.appendChild(hs('div', SCOPE.OVERLAY, { className: 'control-buttons-row' }, [stepper1, stepper2]));

    // progress bar + status row
    const progWrap = hs('div', SCOPE.PROGRESS, { className: 'space-y-1.5' });
    const progBar = hs('div', SCOPE.PROGRESS, {
      role: 'progressbar',
      'aria-valuemax': '100', 'aria-valuemin': '0',
      'aria-valuenow': String(progress),
      'aria-valuetext': `${Math.round(progress)}%`,
      'aria-label': `${Math.round(progress)}%`,
      'data-state': 'loading', 'data-value': String(progress), 'data-max': '100',
      className: 'relative w-full overflow-hidden rounded-full dark_bg-gray-700 h-1 bg-white/10',
    });
    progBar.appendChild(h('div', {
      'data-state': 'loading', 'data-value': String(progress), 'data-max': '100',
      className: `h-full w-full flex-1 ${upToDate ? 'bg-green-500 dark_bg-green-400/90' : 'bg-blue-500 dark_bg-blue-400/90'} rounded-full`,
      style: { transform: `translateX(-${offset.toFixed(5)}%)` },
    }));
    progWrap.appendChild(progBar);
    progWrap.appendChild(hs('div', SCOPE.PROGRESS, {
      className: `status-row ${upToDate ? 'text-green-600 dark_text-green-400' : 'text-gray-300 dark_text-gray-400'}`,
    }, [
      upToDate ? h('div', { innerHTML: LUCIDE.check, className: 'inline-flex' }) : null,
      hs('span', SCOPE.PROGRESS, { className: 'whitespace-nowrap flex-shrink-0', textContent: behindText }),
    ]));
    controls.appendChild(progWrap);

    // Continue to Ch. X button
    controls.appendChild(hs('a', SCOPE.OVERLAY, {
      href: continueHref, target: '_blank', rel: 'noreferrer',
      className: `inline-flex items-center justify-center whitespace-nowrap font-medium ring-offset-white transition-colors focus-visible_outline-none focus-visible_ring-2 focus-visible_ring-blue-500 focus-visible_ring-offset-2 disabled_pointer-events-none disabled_opacity-50 dark_ring-offset-0 dark_focus-visible_ring-moon-yellow-400 bg-blue-600 text-white hover_bg-blue-600/90 dark_bg-blue-600/30 dark_text-blue-300 dark_hover_bg-blue-600/40 h-7 rounded px-2 w-full text-xs mt-2 select-none ${canContinue ? '' : 'opacity-50 cursor-not-allowed'}`,
      textContent: continueText,
    }));

    hover.appendChild(controls);
    ctrlWrap.appendChild(hover);
    card.appendChild(ctrlWrap);

    wrap.appendChild(card);
    li.appendChild(wrap);
    return li;
  }

  /* ================================================================
   *  KEYBOARD SHORTCUTS
   * ================================================================ */
  function runShortcut (action) {
    switch (action) {
      case 'openUpdated':    openAllUpdated(); break;
      case 'setLatest':      setAllLatest(); break;
      case 'toggleSettings':
        isModalOpen() ? hideModal() : showModal(buildSettingsPanel());
        break;
      case 'toggleCovers':
        // Only honour the shortcut when the feature override is enabled.
        if (settings.coversOverride) coversState.toggleEl?.click();
        break;
      case 'refresh':        refreshBadgeFromAPI(); break;
      case 'toggleDark':     applyDarkMode(!settings.darkMode); break;
      case 'prevPage': {
        const a = qs('[aria-label="Previous"]');
        if (a && !a.classList.contains('cursor-not-allowed')) a.click();
        break;
      }
      case 'nextPage': {
        const a = qs('[aria-label="Next"]');
        if (a && !a.classList.contains('cursor-not-allowed')) a.click();
        break;
      }
    }
  }

  document.addEventListener('keydown', (e) => {
    if (isTypingTarget(e.target)) return;
    if (e.altKey || e.ctrlKey || e.metaKey) return;
    // Ignore key events while a modal is open and the focus belongs inside it
    // (so typing in shortcut-rebinder isn't double-handled).
    if (isModalOpen() && modalOverlay.contains(e.target)) return;

    for (const [action, binding] of Object.entries(settings.shortcuts)) {
      if (binding === e.key) {
        // Page-nav shortcuts are useful everywhere; others gated on dashboard.
        const dashOnly = !['toggleSettings', 'toggleDark'].includes(action);
        if (dashOnly && !location.pathname.includes('dashboard')) return;
        e.preventDefault();
        runShortcut(action);
        return;
      }
    }
  });

  /* ================================================================
   *  GLOBAL STYLES
   * ================================================================ */
  function injectGlobalStyles () {
    const s = document.createElement('style');
    s.id = `${PREFIX}-global`;
    s.textContent = `
      /* ── Badge ────────────────────────────────────────────── */
      #${PREFIX}-badge {
        animation: ${PREFIX}-pulse 2.5s ease-in-out infinite;
        min-width: 24px; justify-content: center;
      }
      @keyframes ${PREFIX}-pulse { 0%,100%{opacity:1} 50%{opacity:.7} }

      /* ── Modal overlay ────────────────────────────────────── */
      #${PREFIX}-modal-overlay {
        position: fixed; inset: 0; z-index: 10000;
        display: none; align-items: center; justify-content: center;
        background-color: rgba(0,0,0,0);
        backdrop-filter: blur(0px);
        transition: background-color 0.2s ease, backdrop-filter 0.2s ease;
      }
      #${PREFIX}-modal-overlay.${PREFIX}-modal-visible { display: flex; }
      #${PREFIX}-modal-overlay.${PREFIX}-modal-in {
        background-color: rgba(0,0,0,0.55);
        backdrop-filter: blur(3px);
      }
      #${PREFIX}-modal-overlay.${PREFIX}-modal-out {
        background-color: rgba(0,0,0,0);
        backdrop-filter: blur(0px);
      }
      .${PREFIX}-modal-panel {
        transform: scale(0.92) translateY(8px); opacity: 0;
        transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
      }
      .${PREFIX}-modal-in .${PREFIX}-modal-panel  { transform: scale(1)    translateY(0); opacity: 1; }
      .${PREFIX}-modal-out .${PREFIX}-modal-panel { transform: scale(0.95) translateY(4px); opacity: 0; }

      #${PREFIX}-series-filter::-webkit-scrollbar { width: 6px; }
      #${PREFIX}-series-filter::-webkit-scrollbar-thumb {
        background: rgba(128,128,128,0.35); border-radius: 3px;
      }

      /* ── Toasts ───────────────────────────────────────────── */
      #${PREFIX}-toast-stack {
        position: fixed; bottom: 20px; right: 20px; z-index: 9999;
        display: flex; flex-direction: column-reverse; gap: 8px;
        pointer-events: none;
      }
      .${PREFIX}-toast {
        padding: 10px 16px; border-radius: 8px;
        font-size: 13px; font-weight: 600; color: #fff;
        font-family: system-ui, sans-serif;
        box-shadow: 0 6px 20px rgba(0,0,0,0.25);
        opacity: 0; transform: translateY(8px);
        transition: opacity 0.25s, transform 0.25s;
        max-width: 360px; pointer-events: auto;
      }
      .${PREFIX}-toast.${PREFIX}-toast-in { opacity: 1; transform: translateY(0); }
      .${PREFIX}-toast-info    { background: #1f2937; }
      .${PREFIX}-toast-success { background: #059669; }
      .${PREFIX}-toast-error   { background: #dc2626; }

      /* List view banished off-screen while covers are active.
         Stays mounted (Vue keeps its handlers attached) but doesn't
         steal layout space or visual real estate. */
      .${PREFIX}-list-offscreen {
        position: absolute !important;
        left: -10000px !important; top: auto !important;
        width: 1px !important; height: 1px !important;
        overflow: hidden !important; opacity: 0 !important;
        pointer-events: none !important;
      }

      /* ── Covers mode ──────────────────────────────────────────
         The site's own CSS (data-v-scoped rules in index-CmChCRuR.css)
         handles ALL card styling once we apply the matching data-v
         attributes. These are just the bits not in scoped CSS:
         the grid layout (Tailwind utility) and list-style reset. */
      #${PREFIX}-covers-grid {
        list-style: none; padding: 0; margin: 0;
        display: grid; gap: 1rem;
        grid-template-columns: repeat(2, minmax(0, 1fr));
      }
      @media (min-width: 768px) {
        #${PREFIX}-covers-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
      }
      @media (min-width: 1536px) {
        #${PREFIX}-covers-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
      }
      /* Fallback for the 2:3 aspect-ratio box in case the site's
         tailwindcss/aspect-ratio plugin classes are not present. */
      #${PREFIX}-covers-grid .aspect-w-2.aspect-h-3 {
        position: relative; padding-bottom: 150%; overflow: hidden;
      }
      #${PREFIX}-covers-grid .aspect-w-2.aspect-h-3 > picture,
      #${PREFIX}-covers-grid .aspect-w-2.aspect-h-3 > picture > img {
        position: absolute; inset: 0; width: 100%; height: 100%;
      }
    `;
    document.head.appendChild(s);
  }

  /* ================================================================
   *  BOOT
   * ================================================================ */
  applyDarkMode(settings.darkMode);
  injectGlobalStyles();
  ensureModal();

  if (settings.notifications) requestNotifPermission();

  setupNavDetection((path, prev) => {
    // Re-apply dark mode on every SPA navigation — Vue's rehydration on
    // certain routes can otherwise wipe the html.dark class.
    applyDarkMode(settings.darkMode);

    const isDash  = path.includes('dashboard');
    const wasDash = prev && prev.includes('dashboard');

    if (isDash && !wasDash)       setTimeout(initDashboard, 300);
    else if (isDash && wasDash)   refreshBadgeFromAPI();
    else if (!isDash && wasDash)  teardownDashboard();
    else if (isDash && !prev)     setTimeout(initDashboard, 300);
  });
})();