Kenmei+

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

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

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

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

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

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