// ==UserScript== // @name Novel Bookmark Sync // @namespace bmgr // @version 1.0.0 // @description Track read progress on NovelFull & LightNovelWorld and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs). // @author you // @downloadURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/novel-bookmark.user.js // @updateURL https://bookmark-api.violetcrown.my.id/u/__API_TOKEN__/novel-bookmark.user.js // @match https://novelfull.com/* // @match https://lightnovelworld.net/* // @run-at document-idle // @noframes // ==/UserScript== (function () { "use strict"; // ============================================================ // CONFIG — fill these in before installing. // ============================================================ const API_BASE = "https://bookmark-api.violetcrown.my.id"; // your backend origin, no trailing slash const API_TOKEN = "__API_TOKEN__"; // substituted by the backend at serve time (issue #24) const WEB_BASE = "https://bookmark.violetcrown.my.id"; // the browser UI, for the panel's nav chips // This script owns the novel library; the manga script is a separate install // with its own prefix, so the two never share a cache, a queue or a panel. const STORE_PREFIX = "bmgr:novel:"; const CACHE_KEY = STORE_PREFIX + "cache"; // Per-device record of when each series was last checked for new chapters. // Deliberately not synced: each device does its own checking. const LASTCHECKED_KEY = STORE_PREFIX + "lastchecked"; const LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000; const LATEST_CHECK_BATCH = 1; // series fetched per navigation // series fetched per navigation // Which library this script's rows belong to. The manga script is a separate // install that declares "manga"; the backend keeps whichever it is told. const LIBRARY = "novel"; // ============================================================ // Site adapters // // Page type + IDs come from URL regex (most stable); the title comes from // og: meta tags or the page's own heading. Covers are never read here: the // backend acquires and serves them itself (ADR-0007). // ============================================================ // Chapter lists are read from two places: the page we are standing on, and // series pages fetched in the background. Both are reduced to {href, text} // pairs so each adapter needs only one rule for picking the latest chapter. function anchorsFromDocument(doc) { return Array.from(doc.querySelectorAll("a[href]")).map((a) => ({ href: a.getAttribute("href") || "", text: a.textContent || "", })); } // A fetched page is raw text; DOMParser would run against markup we do not // control, so anchors are pulled out directly instead. function anchorsFromHTML(html) { const re = /]*\shref\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi; const out = []; let m; while ((m = re.exec(html))) { out.push({ href: m[1], text: m[2].replace(/<[^>]*>/g, " ") }); } return out; } function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // Neither site lists chapters in a dependable order — novelfull puts a // newest-first widget above an oldest-first page, lightnovelworld lists all // of them ascending — so the newest is the maximum, never the first or last. function maxChapter(anchors, re) { let best = null; for (const a of anchors) { const m = (a.href || "").match(re); if (!m) continue; const num = parseFloat(m[1]); if (isNaN(num)) continue; if (!best || num > best.num) best = { num: num, label: "Chapter " + m[1] }; } return best; } const novelfull = { site: "novelfull", matches: (loc) => /(^|\.)novelfull\.com$/.test(loc.hostname), detect(loc) { const path = loc.pathname; // //chapter-[-].html let m = path.match(/^\/([^/]+)\/chapter-([0-9]+(?:\.[0-9]+)?)/); if (m) { const num = parseFloat(m[2]); const back = document.querySelector("a.truyen-title"); return { type: "chapter", site: this.site, seriesId: m[1], // h3.title on a chapter page is the *chapter's* title; the breadcrumb // link back to the series page carries the series name. title: back ? (back.textContent || "").trim() : "", seriesUrl: loc.origin + "/" + m[1] + ".html", chapterLabel: "Chapter " + m[2], chapterNum: isNaN(num) ? null : num, chapterUrl: loc.href, }; } // /.html m = path.match(/^\/([^/]+)\.html$/); if (m) { const h3 = document.querySelector("h3.title"); return { type: "series", site: this.site, seriesId: m[1], title: h3 ? (h3.textContent || "").trim() : "", seriesUrl: loc.origin + "/" + m[1] + ".html", chapterLabel: null, chapterNum: null, chapterUrl: null, }; } return { type: "other" }; }, latestChapterFromAnchors(anchors, seriesId) { return maxChapter(anchors, new RegExp("/" + escapeRe(seriesId) + "/chapter-([0-9.]+)")); }, }; // The host shape this adapter owns. One definition so the pointer validator // and `matches` cannot drift apart (a leading subdomain is allowed). const lnwHostRe = /(^|\.)lightnovelworld\.net$/; // A chapter page's pointer is its own link back to its Series. The href is // page markup, so validate before trusting: resolve it against the page // address, require the host above and the /novel// Series path. // Anything else is not a pointer. function seriesIdFromLnwPointer(href, base) { if (!href) return null; let u; try { u = new URL(href, base); } catch (e) { return null; } if (!lnwHostRe.test(u.hostname)) return null; const m = u.pathname.match(/^\/novel\/([^/]+)\/?$/); return m ? m[1] : null; } const lightnovelworld = { site: "lightnovelworld", matches: (loc) => lnwHostRe.test(loc.hostname), detect(loc) { const path = loc.pathname; // /-chapter-/ — flat, at the site root, not under /novel/. The // greedy group takes the LAST "-chapter-", so a slug containing those // words still resolves to the right series. let m = path.match(/^\/(.+)-chapter-([0-9]+(?:\.[0-9]+)?)\/?$/); if (m) { // The address is not the identity on this Site: the slug in the path // is a Chapter Slug, which can differ from the Series slug and is // never computable from it. The Series address is read from the // page's own pointer — a silent fallback to derivation is the defect // this replaced, not a safety net. const chapterSlug = m[1]; const pointer = document.querySelector("a[aria-label='All Chapter']"); let seriesId = pointer ? seriesIdFromLnwPointer(pointer.getAttribute("href"), loc.href) : null; if (!seriesId) { // Fallback: the microdata breadcrumb's second crumb is the Series. // Scoped to the BreadcrumbList because itemprop="item" is not // unique to it (the header nav uses microdata too). const crumb = document.querySelector( '[itemtype="http://schema.org/BreadcrumbList"] a[itemprop="item"][href*="/novel/"]' ); seriesId = crumb ? seriesIdFromLnwPointer(crumb.getAttribute("href"), loc.href) : null; } // Neither pointer present, nor either pointing at a /novel// // address on this host: not a page the script understands, so no // Bookmark under an invented identity. if (!seriesId) return { type: "other" }; const num = parseFloat(m[2]); const h1 = document.querySelector("h1.entry-title"); const heading = h1 ? h1.textContent || "" : ""; return { type: "chapter", site: this.site, seriesId: seriesId, chapterSlug: chapterSlug, // The heading is " Chapter "; drop the suffix. title: heading.replace(/\s*Chapter\s+[0-9.]+\s*$/i, "").trim(), seriesUrl: "https://lightnovelworld.net/novel/" + seriesId + "/", chapterLabel: "Chapter " + m[2], chapterNum: isNaN(num) ? null : num, chapterUrl: loc.href, }; } // /novel// m = path.match(/^\/novel\/([^/]+)\/?$/); if (m) { const h1 = document.querySelector("h1.entry-title"); return { type: "series", site: this.site, seriesId: m[1], chapterSlug: null, title: h1 ? (h1.textContent || "").trim() : "", seriesUrl: "https://lightnovelworld.net/novel/" + m[1] + "/", chapterLabel: null, chapterNum: null, chapterUrl: null, }; } return { type: "other" }; }, }; const ADAPTERS = [novelfull, lightnovelworld]; function detect() { const loc = window.location; for (const a of ADAPTERS) { if (a.matches(loc)) return a.detect(loc); } return { type: "other" }; } function keyOf(page) { return page.site + ":" + page.seriesId; } function adapterFor(site) { return ADAPTERS.find((a) => a.site === site) || null; } // A stale lightnovelworld row is one keyed under the Chapter Slug its // address was built from, while the page's pointer names a different // Series. All three storage sites must move together: the cache row alone // leaves a queued write replaying under a key whose row no longer exists // (that write is silently lost), and a last-checked timestamp left behind // re-fetches the repaired row on the next visit. A repair must never cost a // Reader the chapter they were tracking, so when a row already holds the // repaired key the two rows merge and the farther-ahead progress wins. // Pure: no storage access, no module state, no clock. function repairLnwStaleRow(list, queue, lastChecked, page) { if ( !page || page.site !== "lightnovelworld" || !page.chapterSlug || !page.seriesId || page.chapterSlug === page.seriesId ) { return { list, queue, lastChecked }; } const oldKey = "lightnovelworld:" + page.chapterSlug; const newKey = "lightnovelworld:" + page.seriesId; const stale = list.find((b) => b.key === oldKey); if (!stale) return { list, queue, lastChecked }; let listOut; const existing = list.find((b) => b.key === newKey); if (existing) { // Duplicate case (spec user story 2): one row must survive, and it is // the one already under the repaired key — with the stale row's // progress carried across when it is ahead, favourite OR'd, and the // stronger lifecycle bucket kept (archived > reading, so a merge can // never silently un-archive a row). finished is not a bucket the // script can see (issue #157). const merged = Object.assign({}, existing); if ( existing.last_chapter_num == null || (stale.last_chapter_num != null && stale.last_chapter_num > existing.last_chapter_num) ) { merged.last_chapter = stale.last_chapter; merged.last_chapter_num = stale.last_chapter_num; merged.last_chapter_url = stale.last_chapter_url; } merged.favorite = !!(existing.favorite || stale.favorite); const rank = (s) => ({ archived: 1, reading: 0 }[s || "reading"] || 0); merged.status = rank(stale.status) > rank(existing.status) ? stale.status : existing.status; merged.updated_at = Math.max(existing.updated_at || 0, stale.updated_at || 0); listOut = list.filter((b) => b.key !== oldKey).map((b) => (b.key === newKey ? merged : b)); } else { listOut = list.map((b) => b.key === oldKey ? Object.assign({}, b, { key: newKey, series_id: page.seriesId, series_url: page.seriesUrl, }) : b ); } let queueOut = queue; const oldEntry = queue.find((e) => e.key === oldKey); if (oldEntry) { const survivor = queue.find((x) => x.key === newKey); if (!survivor) { queueOut = queue.map((e) => (e.key === oldKey ? Object.assign({}, e, { key: newKey }) : e)); } else { // Both keys hold a marker and one row survives, so the two collapse // into one entry under the repaired key (the queue's one-entry-per-key // invariant). sendStatus is sticky — an archive intent from either // marker survives, the queue's own rule — and the worse attempts // count wins. The stale-key marker's op is dropped: the row it // described is retired by the merge itself. queueOut = queue .filter((e) => e.key !== oldKey && e.key !== newKey) .concat([ { key: newKey, op: survivor.op, sendStatus: survivor.sendStatus || oldEntry.sendStatus, attempts: Math.max(survivor.attempts || 0, oldEntry.attempts || 0), }, ]); } } let lastCheckedOut = lastChecked; if (oldKey in lastChecked) { lastCheckedOut = Object.assign({}, lastChecked); // Max, not overwrite: a timestamp already under the repaired key must // not be rolled back to an older one. lastCheckedOut[newKey] = Math.max(lastCheckedOut[newKey] || 0, lastCheckedOut[oldKey]); delete lastCheckedOut[oldKey]; } return { list: listOut, queue: queueOut, lastChecked: lastCheckedOut }; } // Highest chapter the site lists, or null when the markup yields nothing. // seriesId is only consulted by adapters whose pages carry other series' // chapter links. An adapter without a scanner (lightnovelworld — see the // AGENTS.md entry) yields null, not an error. function computeLatestChapter(site, anchors, seriesId) { const a = adapterFor(site); return a && a.latestChapterFromAnchors ? a.latestChapterFromAnchors(anchors, seriesId) : null; } function currentSite() { for (const a of ADAPTERS) if (a.matches(window.location)) return a.site; return null; } // ============================================================ // API client + local cache // ============================================================ function loadCache() { try { const raw = localStorage.getItem(CACHE_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } function saveCache(list) { try { localStorage.setItem(CACHE_KEY, JSON.stringify(list)); } catch (e) { /* quota / private mode — ignore */ } } function loadLastChecked() { try { const raw = localStorage.getItem(LASTCHECKED_KEY); return raw ? JSON.parse(raw) : {}; } catch (e) { return {}; } } function saveLastChecked(map) { try { localStorage.setItem(LASTCHECKED_KEY, JSON.stringify(map)); } catch (e) { /* quota / private mode — ignore */ } } function authHeaders(extra) { return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {}); } // The retry queue treats a 400 differently from a dropped connection, so the // status has to survive the throw. A network failure leaves it undefined. function httpError(what, status) { const e = new Error(what + " " + status); e.status = status; return e; } async function apiGet() { const res = await fetch(API_BASE + "/bookmarks", { headers: authHeaders() }); if (!res.ok) throw new Error("GET /bookmarks " + res.status); return res.json(); } // Only an explicit archive/restore has an opinion about the bucket. Every // other write omits `status`, so the server keeps the stored one — // otherwise a cached value would resend a stale status (the API rejects // unknown values with 400) or silently un-archive a series archived on // another device. async function apiPut(key, obj, { sendStatus = false } = {}) { const body = Object.assign({}, obj); if (!sendStatus) delete body.status; // Covers belong to the backend, which acquires and serves them itself // (ADR-0007) and ignores an incoming one; a third-party address must never // go back on the wire. delete body.cover; const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { method: "PUT", headers: authHeaders({ "Content-Type": "application/json" }), body: JSON.stringify(body), }); if (!res.ok) throw httpError("PUT /bookmarks", res.status); return res.json(); } async function apiDelete(key) { const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), { method: "DELETE", headers: authHeaders(), }); // 404 means the row is already gone, which is what the caller wanted. if (!res.ok && res.status !== 404) throw httpError("DELETE /bookmarks", res.status); } // ============================================================ // State // ============================================================ const state = { list: loadCache(), // last-known bookmarks (array) byKey: {}, page: { type: "other" }, }; reindex(); function reindex() { state.byKey = {}; for (const b of state.list) state.byKey[b.key] = b; state.list.sort((a, b) => (b.updated_at || 0) - (a.updated_at || 0)); } function setList(list) { // GET /bookmarks answers with every library; this panel owns one of them, // and the cache must not hold rows it can never show. state.list = (Array.isArray(list) ? list : []).filter((b) => kindOf(b) === LIBRARY); reindex(); saveCache(state.list); } function upsertLocal(bm) { const i = state.list.findIndex((b) => b.key === bm.key); if (i >= 0) state.list[i] = bm; else state.list.push(bm); reindex(); saveCache(state.list); } function removeLocal(key) { state.list = state.list.filter((b) => b.key !== key); reindex(); saveCache(state.list); } // A list cached by an older version has no status field, and a row the // server defaulted has "reading" — both mean the same thing here. function statusOf(b) { return b.status || "reading"; } // A list cached by an older version has no kind field, and a row the server // defaulted has "manga" — both mean the same thing here. function kindOf(b) { return b.kind || "manga"; } // finished is a fact about the Series, decided from the owner's side and // derived on the wire (issue #157); the panel only labels it. function finishedLabel(b) { return b && b.finished ? "Finished" : ""; } // ============================================================ // Retry queue // // A failed write is not rolled back and not lost: the key is parked here and // replayed when the backend is next reachable. Entries carry no payload — the // body is read from state.byKey at send time, because state.list *is* the // desired state and is already persisted under CACHE_KEY. One entry per key, // so two writes to the same series cannot replay out of order, an // archive-then-unarchive collapses to whatever the cache now says, and a // queued DELETE replaces a queued PUT rather than racing it. // ============================================================ const QUEUE_KEY = STORE_PREFIX + "queue"; const QUEUE_MAX = 200; // ~12 KB; realistically bounded by the bookmark count const QUEUE_MAX_ATTEMPTS = 10; let authFailed = false; // a 401 was seen; retrying cannot help until the token changes function loadQueue() { try { const raw = localStorage.getItem(QUEUE_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } function saveQueue(q) { try { localStorage.setItem(QUEUE_KEY, JSON.stringify(q)); } catch (e) { /* quota / private mode — ignore */ } } const queue = loadQueue(); function queueCount() { return queue.length; } function queueGet(key) { return queue.find((e) => e.key === key) || null; } function titleFor(key) { const b = state.byKey[key]; return (b && (b.title || b.series_id)) || key; } // Oldest goes first when the cap is hit: the newest entry is the action the // user just took, and dropping that is the bug this queue exists to fix. function queuePush(entry) { if (queue.length >= QUEUE_MAX) { const evicted = queue.shift(); toast("Sync queue full — dropped " + titleFor(evicted.key), true); } queue.push(entry); return entry; } function queueDrop(key) { const i = queue.findIndex((e) => e.key === key); if (i >= 0) { queue.splice(i, 1); saveQueue(queue); } } // Parks a write without counting it as a failure. Used when the key is already // on the wire: the write is owed, but nothing went wrong, so it must not spend // one of the ten attempts. function queueDefer(key, op, sendStatus) { const e = queueGet(key) || queuePush({ key: key, op: op, sendStatus: false, attempts: 0 }); e.op = op; // a delete replaces a put, and a put replaces a delete e.sendStatus = e.sendStatus || sendStatus; // sticky: an archive intent is never dropped saveQueue(queue); return e; } // Records a failed write and classifies why it failed. A 400 is a payload the // server will never accept, so it is dropped now instead of being retried ten // times; a 401 is the wrong token, so the entry is kept untouched and the // drain gives up until the token changes. function queueEnqueue(key, op, sendStatus, err) { const status = err && err.status; if (status === 400) { queueDrop(key); toast("Couldn't sync " + titleFor(key) + " — change lost", true); return; } const e = queueDefer(key, op, sendStatus); if (status === 401) { authFailed = true; } else if (++e.attempts >= QUEUE_MAX_ATTEMPTS) { queueDrop(key); toast("Gave up syncing " + titleFor(key), true); return; } saveQueue(queue); } // Keys with a request on the wire right now, mapped to "another write arrived // while this one was flying". A second write to the same key is never sent // concurrently — the two responses would race to own the row — it is parked in // the queue instead and a later drain replays it. The flight already in the // air must then leave that entry alone and not adopt its own now-stale // response, or it would undo the write the user just made. const inFlight = new Map(); let draining = null; // the pass in progress, so a second caller awaits it // Replays everything owed. Cheap in the normal case — it returns on the first // line when the queue is empty, which is why it can hang off navigation. A // 401 stops the whole pass: the token is wrong, so the next entry would fail // the same way, and the queue is left intact so fixing the token fixes it. // // Returns the in-flight pass when one is already running, so refresh()'s // `await drain()` really does wait for what we owe instead of racing a drain // that onNavigate or the online listener started unawaited. function drain() { if (queue.length === 0) return Promise.resolve(); if (draining) return draining; const pass = (async () => { authFailed = false; try { for (const e of queue.slice()) { if (e.op === "delete") await pushDelete(e.key); else await pushBookmark(e.key, e.sendStatus); if (authFailed) { toast("Sync auth failed — check the token", true); break; } } } finally { render(); } })(); // Swallowed, not surfaced: drain is called unawaited from onNavigate and the // online listener, and an uncaught rejection on a page we do not control is // a console error nobody can act on. Every real sync failure is already // toasted and queued by pushBookmark/pushDelete. draining = pass .catch(() => {}) .finally(() => { draining = null; }); return draining; } // Keys the server has not heard about yet must survive a fetched list, or the // card the user just changed silently flaps back — the exact bug this queue // exists to fix. Reads state.byKey, so it must run *before* setList replaces // it. function overlayPending(list) { if (queue.length === 0) return list; const out = list.filter((b) => { const e = queueGet(b.key); return !(e && e.op === "delete"); }); for (const e of queue) { if (e.op !== "put") continue; const local = state.byKey[e.key]; if (!local) continue; const i = out.findIndex((b) => b.key === e.key); if (i >= 0) out[i] = local; else out.push(local); } return out; } // ============================================================ // Mutations (optimistic: update UI/cache first, then sync) // ============================================================ // Every write goes through here, queued or not, so there is one place that // talks to the API and one place that decides `sendStatus`. That flag is // sticky: while an archive is pending for a key, a later progress write to the // same key still carries the bucket — otherwise the server would answer with // the old status and the adopted row would silently un-archive the series. async function pushBookmark(key, sendStatus) { const pending = queueGet(key); const withStatus = sendStatus || (pending ? pending.sendStatus : false); const bm = state.byKey[key]; if (!bm) { queueDrop(key); // removed locally in the meantime — nothing left to send return true; } if (inFlight.has(key)) { queueDefer(key, "put", withStatus); // see inFlight — parked, not sent inFlight.set(key, true); // supersedes the flight already in the air return false; } inFlight.set(key, false); let ok = false; try { // ponytail: last-write-wins, so a replay can overwrite a newer server // value (a poller-written latest_chapter, or progress from another // device). Single user, self-healing on the next poll — revisit only if // this ever runs multi-user. const saved = await apiPut(key, bm, { sendStatus: withStatus }); if (!inFlight.get(key)) { upsertLocal(saved); queueDrop(key); } ok = true; } catch (e) { // Same guard as the success path: a newer write is parked for this key, so // classifying *this* failure would clobber its op or, on a 400, drop it // outright. Its own drain reports its own outcome. The one thing that must // still carry across is sendStatus — this flight may have been the archive // replay, and losing its flag here would narrow the parked write into a // silent un-archive. const parked = inFlight.get(key) ? queueGet(key) : null; if (parked) queueDefer(key, parked.op, withStatus); else queueEnqueue(key, "put", withStatus, e); } finally { inFlight.delete(key); } // Outside the try: a throw in render() is a UI bug, not a write failure, and // must not re-queue a write that already landed. if (ok) render(); return ok; } async function pushDelete(key) { if (inFlight.has(key)) { queueDefer(key, "delete", false); // see inFlight — parked, not sent inFlight.set(key, true); // supersedes the flight already in the air return false; } inFlight.set(key, false); try { await apiDelete(key); if (!inFlight.get(key)) queueDrop(key); return true; } catch (e) { if (!inFlight.get(key)) queueEnqueue(key, "delete", false, e); // see pushBookmark return false; } finally { inFlight.delete(key); } } async function bookmarkCurrent() { const p = state.page; if (p.type !== "series" && p.type !== "chapter") return; const key = keyOf(p); const existing = state.byKey[key]; const bm = { key: key, site: p.site, kind: LIBRARY, series_id: p.seriesId, title: p.title || (existing && existing.title) || p.seriesId, series_url: p.seriesUrl || (existing && existing.series_url) || "", last_chapter: p.chapterLabel || (existing && existing.last_chapter) || "", last_chapter_num: p.chapterNum != null ? p.chapterNum : existing ? existing.last_chapter_num : null, last_chapter_url: p.chapterUrl || (existing && existing.last_chapter_url) || "", updated_at: Date.now(), }; await syncUpsert(bm, "Bookmarked"); } async function updateToCurrentChapter() { cancelDwell(); // manual button + timer-fire both clear any pending dwell const p = state.page; if (p.type !== "chapter") return; const key = keyOf(p); const existing = state.byKey[key] || {}; const bm = Object.assign({}, existing, { key: key, site: p.site, kind: LIBRARY, series_id: p.seriesId, title: existing.title || p.title || p.seriesId, series_url: existing.series_url || p.seriesUrl || "", last_chapter: p.chapterLabel || existing.last_chapter || "", last_chapter_num: p.chapterNum != null ? p.chapterNum : existing.last_chapter_num, last_chapter_url: p.chapterUrl || "", updated_at: Date.now(), }); await syncUpsert(bm, "Progress updated"); } async function syncUpsert(bm, okMsg) { upsertLocal(bm); // optimistic render(); if (await pushBookmark(bm.key, false)) toast(okMsg); else toast("Saved — will sync when online", true); } async function toggleFavorite(key) { const existing = state.byKey[key]; if (!existing) return; const bm = Object.assign({}, existing, { favorite: !existing.favorite, updated_at: Date.now(), // candidate only; the server keeps the stored one }); upsertLocal(bm); // optimistic render(); if (!(await pushBookmark(bm.key, false))) toast("Saved — will sync when online", true); } // Archive parks a series: it leaves All and Favourites but the server keeps // polling it for new chapters. finished is not a bucket the script can // reach — it is a fact about the Series now (issue #157). async function toggleArchive(key) { const existing = state.byKey[key]; if (!existing) return; const next = statusOf(existing) === "archived" ? "reading" : "archived"; upsertLocal(Object.assign({}, existing, { status: next })); render(); // The only write with an opinion about the bucket, so the only one that // sends `status` at all. if (await pushBookmark(key, true)) { toast(next === "archived" ? "Archived" : "Back in your list"); } else { toast((next === "archived" ? "Archived" : "Restored") + " — will sync when online", true); } } // Reports the newest chapter a site has published. Silent: this fires from // page visits and background checks the user did not ask for, and it never // reorders the list — updated_at is a candidate the server discards unless // reading progress moved. // // An unchanged number is still sent. It is the read that lets the backend // skip its own poll of this series (a Sighting, issue #103), so the common // case — visiting a series with nothing new — is exactly the one worth // reporting. Only the local write and the re-render are skipped. async function reportLatestChapter(existing, latest) { if (!existing || !latest) return; const changed = existing.latest_chapter_num !== latest.num; let bm = existing; if (changed) { bm = Object.assign({}, existing, { latest_chapter: latest.label, latest_chapter_num: latest.num, updated_at: Date.now(), }); upsertLocal(bm); render(); } // A queued write owns this row; the drain sends latest_chapter // with it, carrying the correct bucket. if (queueGet(bm.key)) return; try { const saved = await apiPut(bm.key, bm); if (changed) { upsertLocal(saved); render(); } } catch (e) { /* offline — the local cache still shows it, retried on a later visit */ } } // Standing on a bookmarked series page: its chapter list is already here. function maybeCaptureLatestOnSeriesPage() { const p = state.page; if (p.type !== "series") return; const existing = state.byKey[keyOf(p)]; if (!existing) return; reportLatestChapter( existing, computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId) ); } // Everything else is only learned by fetching a series page — or, where the // site offers one, its JSON API. Same-origin only: these requests carry the // session that gets us past the site's bot checks, which a request to the // other site (or from a server) would not. A few series per navigation keeps // it indistinguishable from browsing. async function backgroundRefreshLatest() { const site = currentSite(); if (!site) return; // A Site with no client scanner (lightnovelworld) is refreshed by the Poll // on a cooldown shorter than the client throttle; fetching its pages here // would be a megabyte-scale request whose result is discarded. Skipping // before the due filter records no freshness timestamp and consumes no // per-navigation batch slot. const adapter = adapterFor(site); if (!adapter || !adapter.latestChapterFromAnchors) return; const checked = loadLastChecked(); const now = Date.now(); const due = state.list .filter((b) => b.site === site && b.series_url) .filter((b) => now - (checked[b.key] || 0) >= LATEST_CHECK_THROTTLE_MS) .sort((a, b) => (checked[a.key] || 0) - (checked[b.key] || 0)) .slice(0, LATEST_CHECK_BATCH); if (due.length === 0) return; for (const bm of due) { // Recorded even when the fetch fails, so a broken series is retried on // the next throttle window rather than on every single page load. checked[bm.key] = Date.now(); try { const res = await fetch(bm.series_url, { credentials: "same-origin" }); if (!res.ok) continue; const html = await res.text(); const latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id); await reportLatestChapter(state.byKey[bm.key] || bm, latest); } catch (e) { /* offline or blocked — try again after the throttle window */ } } saveLastChecked(checked); } async function removeBookmark(key) { removeLocal(key); // optimistic render(); if (await pushDelete(key)) toast("Removed"); else toast("Removed — will sync when online", true); } // Dwell timer: don't advance progress the instant a newer chapter opens (guards // against a misclick on "latest chapter"). Arm a 25s timer instead; the tab fills // bottom-up to show it counting down. The manual "Update to X" button still fires // immediately. Timer is keyed to the chapter, not the URL, so turning pages within // the same chapter (Demonic /chapter/N/) keeps it counting rather than resetting. const DWELL_MS = 25000; let dwell = null; // { id, timerId } function chapterIdentity(p) { return p && p.type === "chapter" ? p.site + ":" + p.seriesId + "@" + p.chapterNum : null; } function armDwell(id) { dwell = { id, timerId: setTimeout(() => { dwell = null; stopRing(); updateToCurrentChapter(); }, DWELL_MS), }; startRing(DWELL_MS); } function cancelDwell() { if (dwell) clearTimeout(dwell.timerId); dwell = null; stopRing(); } // Auto-record on opening a chapter of a bookmarked series (after the dwell delay). function maybeAutoUpdate() { const p = state.page; const id = chapterIdentity(p); if (dwell && id === dwell.id) return; // same chapter, page turn — keep timer running cancelDwell(); // navigated elsewhere — drop any stale dwell if (p.type !== "chapter") return; const existing = state.byKey[keyOf(p)]; if (!existing) return; // only track series the user bookmarked const cur = p.chapterNum; const prev = existing.last_chapter_num; // Advance when current >= stored, or when either is unparseable (set current). const advance = cur == null || prev == null || cur >= prev; if (!advance) return; if (existing.last_chapter_url === p.chapterUrl && existing.last_chapter === p.chapterLabel) { return; // already recorded } armDwell(id); } // ============================================================ // UI — Shadow DOM (isolated from site CSS) // ============================================================ let root, panelOpen = false; // True while refresh() is waiting on the backend. Only changes what the list // draws when there is nothing cached to draw instead. let loading = false; let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design function buildUI() { const host = document.createElement("div"); host.id = "mangabm-host"; host.style.cssText = "all: initial;"; (document.body || document.documentElement).appendChild(host); root = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); style.textContent = CSS; root.appendChild(style); const wrap = document.createElement("div"); wrap.id = "wrap"; wrap.innerHTML = TEMPLATE; root.appendChild(wrap); const fab = root.getElementById("fab"); fab.addEventListener("click", () => { if (fab.dataset.dragged) { delete fab.dataset.dragged; // this click is the tail of a drag — swallow it return; } togglePanel(); }); root.getElementById("backdrop").addEventListener("click", togglePanel); root.getElementById("closeBtn").addEventListener("click", togglePanel); root.getElementById("pending").addEventListener("click", () => drain()); for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) { root.getElementById(id).addEventListener("click", () => { activeTab = tab; render(); }); } placeFab(fab); makeDraggable(fab); window.addEventListener("resize", () => placeFab(fab)); // re-clamp on rotation } // ============================================================ // FAB placement + dragging (snaps to nearest left/right edge) // ============================================================ const FAB_KEY = STORE_PREFIX + "fabpos"; const FAB_MARGIN = 12; // vertical breathing room at the top and bottom const FAB_EDGE = 0; // horizontal: an edge tab sits flush against the side const ARM_MS = 400; // hold this long on the invisible strip to arm a drag function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } function applyFabPos(fab, side, y) { fab.style.top = y + "px"; fab.style.bottom = "auto"; fab.classList.toggle("left", side === "left"); if (side === "left") { fab.style.left = FAB_EDGE + "px"; fab.style.right = "auto"; } else { fab.style.right = FAB_EDGE + "px"; fab.style.left = "auto"; } } function loadFabPos() { try { return JSON.parse(localStorage.getItem(FAB_KEY)); } catch (e) { return null; } } function saveFabPos(side, yRatio) { try { localStorage.setItem(FAB_KEY, JSON.stringify({ side, yRatio })); } catch (e) { /* quota / private mode — ignore */ } } function placeFab(fab) { const h = fab.offsetHeight || 44; const p = loadFabPos(); const maxY = window.innerHeight - h - FAB_MARGIN; const side = p && p.side === "left" ? "left" : "right"; const y = p ? clamp(p.yRatio * window.innerHeight, FAB_MARGIN, maxY) : maxY - 8; applyFabPos(fab, side, y); } // A gesture that starts on the visible sliver drags immediately, as it always // has. A gesture that starts on the invisible #hit strip scrolls the page // instead — unless it is held for ARM_MS first, which arms a reposition drag. // ponytail: the scroll is hand-rolled (no momentum/fling) and assumes the // document is the scroller. That is the price of touch-action:none, which we // need so a hold can claim pixels a swipe would otherwise scroll. If the // stiffness annoys, give #hit touch-action:pan-y and accept that vertical // repositioning must start on the 7px sliver. function makeDraggable(fab) { let down = false, moved = false, armed = false, scrolling = false, fromHit = false; let sx = 0, sy = 0, offX = 0, offY = 0, lastY = 0, armTimer = 0; function reset() { down = moved = armed = scrolling = fromHit = false; clearTimeout(armTimer); fab.classList.remove("armed"); fab.style.transition = ""; } function scrollPage(e) { e.preventDefault(); window.scrollBy(0, lastY - e.clientY); lastY = e.clientY; } fab.addEventListener("pointerdown", (e) => { delete fab.dataset.dragged; // stale flag would swallow this gesture's click down = true; moved = false; armed = false; scrolling = false; fromHit = e.target.id === "hit"; sx = e.clientX; sy = e.clientY; lastY = e.clientY; const r = fab.getBoundingClientRect(); offX = e.clientX - r.left; offY = e.clientY - r.top; fab.setPointerCapture(e.pointerId); fab.style.transition = "none"; clearTimeout(armTimer); armTimer = setTimeout(() => { armed = true; fab.classList.add("armed"); // visible confirmation the hold took }, ARM_MS); }); fab.addEventListener("pointermove", (e) => { if (!down) return; if (scrolling) { scrollPage(e); return; } if (!moved) { if (Math.abs(e.clientX - sx) <= 6 && Math.abs(e.clientY - sy) <= 6) return; clearTimeout(armTimer); if (fromHit && !armed) { scrolling = true; scrollPage(e); return; } moved = true; } e.preventDefault(); const w = fab.offsetWidth, h = fab.offsetHeight; fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - w) + "px"; fab.style.right = "auto"; fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - h) + "px"; fab.style.bottom = "auto"; }); function snapToEdge() { const w = fab.offsetWidth, h = fab.offsetHeight; const r = fab.getBoundingClientRect(); const side = r.left + w / 2 < window.innerWidth / 2 ? "left" : "right"; const y = clamp(r.top, FAB_MARGIN, window.innerHeight - h - FAB_MARGIN); fab.style.transition = "left .2s ease, right .2s ease, top .2s ease"; applyFabPos(fab, side, y); saveFabPos(side, y / window.innerHeight); } fab.addEventListener("pointerup", (e) => { if (!down) return; const didDrag = moved, didScroll = scrolling; reset(); if (didScroll) { fab.dataset.dragged = "1"; // suppress any click trailing the scroll return; } if (!didDrag) return; // tap — let the click handler toggle the panel fab.dataset.dragged = "1"; // suppress the click that trails this drag snapToEdge(); }); fab.addEventListener("pointercancel", () => { if (!down) return; const didDrag = moved; reset(); if (didDrag) snapToEdge(); // OS claimed the gesture mid-drag — don't strand the FAB }); // touch-action: none stops panning and zooming but NOT Chromium's long-press // gesture, which fires at ~500ms — just after ARM_MS — raises the context // menu, and cancels the pointer stream. The hold armed the drag and the OS // stole it back a frame later. Suppressing the menu keeps the pointer alive. fab.addEventListener("contextmenu", (e) => e.preventDefault()); } // ============================================================ // Dwell-timer progress: the tab fills bottom-up. A 7px sliver has no room // for the ring this replaced. // ============================================================ function startRing(ms) { if (!root) return; const fill = root.getElementById("fill"); fill.style.transition = "none"; fill.style.height = "0"; fill.getBoundingClientRect(); // reflow so the next assignment animates fill.style.transition = "height " + ms + "ms linear"; fill.style.height = "100%"; } function stopRing() { if (!root) return; const fill = root.getElementById("fill"); if (!fill) return; fill.style.transition = "none"; fill.style.height = "0"; } function togglePanel() { panelOpen = !panelOpen; render(); if (panelOpen) refresh(); // pull latest from server on open } function el(tag, props, children) { const e = document.createElement(tag); if (props) for (const k in props) { if (k === "text") e.textContent = props[k]; else if (k === "html") e.innerHTML = props[k]; else if (k.slice(0, 2) === "on") e.addEventListener(k.slice(2).toLowerCase(), props[k]); else e.setAttribute(k, props[k]); } if (children) for (const c of children) if (c) e.appendChild(c); return e; } function render() { if (!root) return; root.getElementById("panel").classList.toggle("open", panelOpen); root.getElementById("backdrop").classList.toggle("open", panelOpen); // Silent while everything is synced; honest the moment something is stuck. const pending = root.getElementById("pending"); pending.hidden = queueCount() === 0; pending.textContent = "⟳ " + queueCount() + " pending"; // Context header for the current page. const ctx = root.getElementById("context"); ctx.innerHTML = ""; const p = state.page; if (p.type === "series" || p.type === "chapter") { const key = keyOf(p); const existing = state.byKey[key]; ctx.appendChild(el("div", { class: "ctx-title", text: p.title || p.seriesId })); if (!existing) { ctx.appendChild(el("button", { class: "btn primary", text: "+ Bookmark this", onclick: () => bookmarkCurrent() })); } else { const cur = existing.last_chapter || "—"; ctx.appendChild(el("div", { class: "ctx-sub", text: "Tracked · last read: " + cur })); if (p.type === "chapter" && p.chapterLabel && p.chapterLabel !== existing.last_chapter) { ctx.appendChild(el("button", { class: "btn primary", text: "Update to " + p.chapterLabel, onclick: () => updateToCurrentChapter() })); } } } else { ctx.appendChild(el("div", { class: "ctx-sub", text: "Open a series or chapter to bookmark it." })); } // Tabs narrow what is drawn; state.list always holds every bookmark. // Archived rows are hidden from All and Favourites; a Series that is // finished is the web UI's business and arrives archived (issue #157). for (const [id, tab] of [["tabAll", "all"], ["tabFav", "favorites"], ["tabArc", "archived"]]) { root.getElementById(id).classList.toggle("active", activeTab === tab); } const listEl = root.getElementById("list"); listEl.innerHTML = ""; const items = state.list.filter((b) => { const s = statusOf(b); if (activeTab === "archived") return s === "archived"; if (s !== "reading") return false; return activeTab !== "favorites" || b.favorite; }); if (items.length === 0 && loading) { // Nothing cached and the fetch is still out — without this the panel // looks frozen on the first open after a cold start. listEl.appendChild(el("div", { class: "empty" }, [el("div", { class: "spinner" })])); } else if (items.length === 0) { const empty = { favorites: "No favourites yet.", archived: "Nothing archived.", all: "No bookmarks yet.", }[activeTab]; listEl.appendChild(el("div", { class: "empty", text: empty })); } for (const b of items) { listEl.appendChild(renderItem(b)); } } function renderItem(b) { const cont = b.last_chapter_url || b.series_url || "#"; // Only worth naming the latest chapter while it is ahead of the read one. const behind = b.latest_chapter && b.latest_chapter_num != null && (b.last_chapter_num == null || b.latest_chapter_num > b.last_chapter_num); const sub = behind ? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter : (b.last_chapter || "—") + " · " + b.site; // Cover and title are the only continue links. The subtitle and the action // row live in the same text column but are not navigation targets — a thumb // that misses ★ must not land on one, and Remove must never be one. // hot = a chapter is out that has not been read (crimson title, ember rule); // dim = archived (italic grey). Same two states the web UI paints. const heat = (behind ? " hot" : "") + (statusOf(b) === "archived" ? " dim" : ""); return el("div", { class: "item" + heat }, [ el("a", { class: "go", href: cont }, [ b.cover ? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "", // A Cover that will not load shows the designed placeholder // rather than the browser's broken-image glyph (#47). onerror: (e) => e.target.replaceWith(el("div", { class: "cover ph" })), }) : el("div", { class: "cover ph" }), ]), el("div", { class: "meta" }, [ el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }), el("div", { class: "c" + (behind ? " behind" : ""), text: sub }), b.finished && el("span", { class: "finished", text: finishedLabel(b) }), el("div", { class: "actions" }, [ el("button", { class: "btn small star" + (b.favorite ? " on" : ""), text: b.favorite ? "★" : "☆", title: b.favorite ? "Remove from favourites" : "Add to favourites", onclick: () => toggleFavorite(b.key), }), el("button", { class: "btn small", text: statusOf(b) === "archived" ? "Unarchive" : "Archive", onclick: () => toggleArchive(b.key), }), el("button", { class: "btn small danger", text: "Remove", onclick: () => confirmRemove(b), }), ]), ]), ]); } // Removal syncs to every device, so it asks first. confirm() is the page's // own dialog, not a shadow-root one — modal, and the site cannot style it. function confirmRemove(b) { if (confirm('Remove "' + (b.title || b.series_id) + '" from your bookmarks?')) { removeBookmark(b.key); } } // Toast inside the shadow root. function toast(msg, isErr) { if (!root) return; const t = el("div", { class: "toast" + (isErr ? " err" : ""), text: msg }); root.getElementById("toasts").appendChild(t); setTimeout(() => t.classList.add("show"), 10); setTimeout(() => { t.classList.remove("show"); setTimeout(() => t.remove(), 300); }, 2600); } // ============================================================ // Refresh + navigation // ============================================================ async function refresh(awaitFirst) { if (awaitFirst) await awaitFirst; // repair sync lands before we adopt the server's view of it await drain(); // push what we owe before adopting the server's view of it loading = true; render(); try { const list = await apiGet(); setList(overlayPending(list)); } catch (e) { // Offline or backend down: keep whatever the cache holds. } finally { loading = false; render(); } } // Runs the repair on every navigation, silently: rewrites a stale // lightnovelworld row to the page's discovered identity, persists all three // sites, and syncs through the queue-backed path so an offline repair parks // and replays later. No toast — the Reader never asked for this. Returns // the sync promise (or null when nothing changed) so init can hold the // server view until the repair has landed. When the transform returns its // inputs unchanged there is nothing to do. function applyLnwStaleRowRepair() { const lastChecked = loadLastChecked(); const out = repairLnwStaleRow(state.list, queue, lastChecked, state.page); if (out.list === state.list && out.queue === queue && out.lastChecked === lastChecked) { return null; } state.list = out.list; reindex(); // byKey answers under the old key until rebuilt saveCache(state.list); queue.splice(0, queue.length, ...out.queue); // closures hold this array instance saveQueue(queue); saveLastChecked(out.lastChecked); // Queue-backed sync, outcome swallowed: the repaired row is PUT (with its // bucket when archived — the only status a userscript write may send) and // the old server bookmark is deleted so no duplicate survives on the // wire. The delete parks under the retired key while offline — the // teardown of the old identity, not a new record of the Chapter Slug. const key = keyOf(state.page); return Promise.all([ pushBookmark(key, statusOf(state.byKey[key]) === "archived"), pushDelete("lightnovelworld:" + state.page.chapterSlug), ]).catch(() => {}); } let lastUrl = location.href; function onNavigate() { state.page = detect(); applyLnwStaleRowRepair(); render(); maybeAutoUpdate(); maybeCaptureLatestOnSeriesPage(); // Asura is client-routed, so full loads are rare — checking here too is // what keeps its bookmarks current. The throttle still caps the rate. backgroundRefreshLatest(); // Drain only, not a full refresh: Asura may go a long time without a // reload, and an extra GET per client-side route change is not wanted. drain(); } // Framework-agnostic URL-change watcher: patch history + poll as a fallback, // so it works whether the site is client-routed (Asura) or reloads (Demonic). function installNavWatcher() { const fire = () => { if (location.href !== lastUrl) { lastUrl = location.href; // Meta tags update slightly after the URL on client routes. setTimeout(onNavigate, 300); } }; const wrap = (name) => { const orig = history[name]; history[name] = function () { const r = orig.apply(this, arguments); fire(); return r; }; }; wrap("pushState"); wrap("replaceState"); window.addEventListener("popstate", fire); setInterval(fire, 1500); // catch routes that bypass history } // ============================================================ // Two-finger long-press — a way in that does not depend on finding a 7px tab // // Deliberately two fingers, not one: Chromium claims the one-finger long // press for image context menus, text selection, and link previews, and on a // manga reader the whole viewport is images. It assigns no two-finger long // press, so these listeners stay passive and never preventDefault. // ============================================================ const LONGPRESS_MS = 500; const LONGPRESS_SLOP = 15; // px either finger may drift before it counts as a drag function installLongPress() { let timer = null; let start = null; // [{x, y}, {x, y}] — where each finger landed const cancel = () => { if (timer) clearTimeout(timer); timer = null; start = null; }; const opts = { passive: true, capture: true }; // Fires once per finger, so the first touch cancels and the second arms. // A third finger cancels again. document.addEventListener( "touchstart", (e) => { cancel(); if (e.touches.length !== 2) return; start = [ { x: e.touches[0].clientX, y: e.touches[0].clientY }, { x: e.touches[1].clientX, y: e.touches[1].clientY }, ]; timer = setTimeout(() => { cancel(); togglePanel(); }, LONGPRESS_MS); }, opts ); // A pinch-zoom is two fingers held down, so the slop check is what keeps a // normal-speed one from opening the panel mid-gesture. Verified 2026-07-28: // a pinch that reaches 240px in ~240ms cancels well before the timer. A // deliberately slow pinch that stays inside the slop for the full 500ms // does open the panel — accepted, since an absolute per-finger drift // threshold cannot tell that apart from two fingers resting, which is the // gesture this is for. document.addEventListener( "touchmove", (e) => { if (!start || e.touches.length !== 2) return cancel(); for (let i = 0; i < 2; i++) { if ( Math.abs(e.touches[i].clientX - start[i].x) > LONGPRESS_SLOP || Math.abs(e.touches[i].clientY - start[i].y) > LONGPRESS_SLOP ) { return cancel(); } } }, opts ); document.addEventListener("touchend", cancel, opts); document.addEventListener("touchcancel", cancel, opts); } // ============================================================ // Boot // ============================================================ function init() { buildUI(); state.page = detect(); const repair = applyLnwStaleRowRepair(); render(); installNavWatcher(); installLongPress(); window.addEventListener("online", drain); // signal returned while the page stayed open // Sync first: both auto-record and the latest-chapter checks below need to // know which series are bookmarked and how fresh they are. refresh(repair).then(() => { maybeAutoUpdate(); maybeCaptureLatestOnSeriesPage(); backgroundRefreshLatest(); }); } // ============================================================ // Markup + styles (kept at bottom for readability) // ============================================================ const TEMPLATE = `
`; const CSS = ` /* Cinder, ported to the panel: same tokens and the same rule that heat is typographic. Fonts are system stacks, not the web UI's Google fonts — an @import here would be at the mercy of the host page's CSP. See docs/design-system.md. */ :host { all: initial; --ink: #100f0e; --ash: #161413; --dim: #0d0c0b; --rule: #221f1d; --field-line: #2c2926; --hover: #1a1816; --paper: #f2ece5; --paper-hot: #f0d3cb; --paper-dim: #ddd5cb; --mute: #8d857c; --mute-2: #5a5450; --faint: #3a3733; --faint-2: #57504b; --ember: #e0452c; --ember-wash: #1a1211; --ember-ink: #150907; --ember-soft: #eda798; --brass: #b8912f; --trash: #6b5450; --moss: #7fae86; --font-display: Georgia, "Times New Roman", serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; --font-body: system-ui, -apple-system, sans-serif; } * { box-sizing: border-box; font-family: var(--font-body); } #fab { position: fixed; width: 7px; height: 44px; border: none; padding: 0; background: var(--ember); opacity: .5; box-shadow: 0 2px 8px rgba(0,0,0,.5); z-index: 2147483646; cursor: pointer; touch-action: none; user-select: none; -webkit-touch-callout: none; } #fab:active, #fab.armed { opacity: 1; } #fab.armed { box-shadow: 0 0 0 2px var(--ember-soft); } #fill { position: absolute; left: 0; right: 0; bottom: 0; height: 0; background: var(--paper); pointer-events: none; } /* Invisible tap extension: the visible sliver is 7x44, this makes it 28x72. It grows inward (toward the screen centre) because the tab is docked flush against an edge, so outward is off-screen. */ #hit { position: absolute; top: -14px; bottom: -14px; left: -21px; right: 0; } #fab.left #hit { left: 0; right: -21px; } #backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.6); opacity: 0; pointer-events: none; transition: opacity .2s; z-index: 2147483646; } #backdrop.open { opacity: 1; pointer-events: auto; } #panel { position: fixed; top: 0; right: 0; height: 100%; width: min(400px, 92vw); background: var(--ink); color: var(--paper-dim); transform: translateX(105%); transition: transform .25s ease; z-index: 2147483647; display: flex; flex-direction: column; padding-bottom: env(safe-area-inset-bottom, 0px); border-left: 1px solid var(--rule); box-shadow: -4px 0 20px rgba(0,0,0,.6); } #panel.open { transform: translateX(0); } header { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid var(--rule); font: 400 22px/1 var(--font-display); color: var(--paper); } header em { color: var(--ember); font-style: italic; } .brand { display: flex; align-items: center; gap: 9px; } /* 5px stroke on a 200-unit grid thins out at this size: the markup carries 6.5 instead. overflow visible keeps the topmost blade pip from clipping. */ .mark { width: 26px; height: 22px; flex: none; overflow: visible; } #closeBtn { background: none; border: none; color: var(--mute); cursor: pointer; font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase; border-bottom: 1px solid var(--field-line); padding: 0 0 2px; } #closeBtn:active { color: var(--paper); } #nav { display: flex; gap: 14px; padding: 11px 16px; border-bottom: 1px solid var(--rule); } /* Chips are set as small caps rules, not pills — the sheet has no corners. */ .chip { background: none; border: none; padding: 0 0 2px; border-bottom: 1px solid var(--field-line); color: var(--mute); text-decoration: none; cursor: pointer; font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase; white-space: nowrap; } .chip:active { color: var(--paper); border-bottom-color: var(--paper); } .chip.pending { color: var(--ember); border-bottom-color: var(--ember); } .chip.pending[hidden] { display: none; } #context { padding: 14px 16px; border-bottom: 1px solid var(--rule); display: flex; flex-direction: column; gap: 9px; align-items: flex-start; } .ctx-title { font: 400 19px/1.2 var(--font-display); color: var(--paper); } .ctx-sub { font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--mute); } #tabs { display: flex; gap: 16px; padding: 2px 16px 0; border-bottom: 1px solid var(--rule); } .tab { background: none; border: none; cursor: pointer; color: var(--mute); font: 400 16px var(--font-display); padding: 8px 0 12px; border-bottom: 2px solid transparent; margin-bottom: -1px; } .tab.active { color: var(--paper); border-bottom-color: var(--paper); } #list { overflow-y: auto; flex: 1; } .empty { color: var(--mute); padding: 34px 16px; font: 400 15px/1.6 var(--font-body); } /* Loading is the web UI's burning hairline, laid flat across the list. */ .spinner { width: 100%; height: 1px; background: var(--rule); overflow: hidden; position: relative; } .spinner::after { content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 30%; background: var(--ember); animation: barSlide 1.15s linear infinite; } @keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } } .item { display: flex; flex-direction: row; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--rule); } .item.dim { background: var(--dim); } .go { display: block; flex: none; } .go:active, .go-t:active { opacity: .7; } .go-t { display: block; text-decoration: none; color: inherit; } .cover { width: 52px; height: 70px; object-fit: cover; flex: none; background: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px); } .cover.ph { display: block; } .item.dim .cover { filter: grayscale(1); opacity: .85; } .meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 7px; align-items: flex-start; } .t { font: 400 17px/1.2 var(--font-display); color: var(--paper-dim); max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .item.hot .t { color: var(--paper-hot); border-bottom: 1px solid var(--ember); padding-bottom: 3px; } .item.dim .t { font-style: italic; color: var(--mute); } .c { margin: 0; color: var(--mute); font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; } .c.behind { color: var(--ember); } /* finished: a Series fact, not a state class (issue #157) — same meta typography as .c, moss instead of mute. */ .finished { margin: 0; color: var(--moss); font: 500 10px/1.4 var(--font-mono); letter-spacing: .12em; text-transform: uppercase; } .actions { display: flex; gap: 0; flex-wrap: wrap; margin-top: 2px; } .btn { background: none; color: var(--mute); border: 1px solid var(--rule); margin: 0 -1px -1px 0; padding: 0 12px; height: 38px; line-height: 36px; cursor: pointer; text-decoration: none; display: inline-block; font: 500 10px var(--font-mono); letter-spacing: .12em; text-transform: uppercase; } .btn:active { color: var(--paper); background: var(--hover); } .btn.primary { border-color: var(--paper); background: var(--paper); color: var(--ink); font: 400 16px var(--font-display); letter-spacing: 0; text-transform: none; height: 44px; line-height: 42px; padding: 0 18px; } .btn.small { height: 34px; line-height: 32px; padding: 0 10px; } .btn.danger { color: var(--trash); } .btn.danger:active { color: var(--ember); background: var(--ember-wash); } .btn.star { font-size: 13px; letter-spacing: 0; } .btn.star.on { color: var(--brass); border-color: #332b14; } #toasts { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(80px + env(safe-area-inset-bottom, 0px)); z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; align-items: center; } .toast { background: var(--ash); color: var(--paper); padding: 12px 16px; border-left: 2px solid var(--paper); font: 400 16px var(--font-display); opacity: 0; transform: translateY(10px); transition: all .3s; box-shadow: 0 4px 14px rgba(0,0,0,.5); max-width: 80vw; } .toast.show { opacity: 1; transform: translateY(0); } .toast.err { background: var(--ember-wash); border-left-color: var(--ember); color: var(--ember-soft); } `; // ============================================================ // Test hook // // Fires only in Node, where window is undefined. The typeof window check keeps it // inert on pages whose own scripts define a UMD module shim — under @grant none // the userscript shares page globals, so we must not clobber the page's exports. // Exposes pure logic only — see userscript/test/novel-logic.test.js. // ============================================================ if (typeof window === "undefined" && typeof module === "object" && module.exports) { module.exports = { novelfull, lightnovelworld, anchorsFromHTML, statusOf, kindOf, maxChapter, escapeRe, computeLatestChapter, repairLnwStaleRow, finishedLabel }; } // ============================================================ // Boot — MUST run last: init() -> buildUI() reads TEMPLATE/CSS, // which are const (TDZ) and only initialized above. At // @run-at document-idle document.body already exists, so init() // fires synchronously here, after the consts are set. // ============================================================ if (document.body) init(); else document.addEventListener("DOMContentLoaded", init); })();