From 383dbaa7eb27a33962d3d1011e74014343ca1846 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 15:26:50 +0700 Subject: [PATCH 1/7] test: zero-dependency harness for userscript adapter logic Loads the IIFE in bare Node behind a minimal localStorage/document stub, with document.body left undefined so the boot block defers and the UI never builds. Adds a module.exports hook that is inert in every real userscript engine. Co-Authored-By: Claude Opus 5 --- userscript/manga-bookmark.user.js | 12 ++ userscript/test/logic.test.js | 186 ++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 userscript/test/logic.test.js diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 625ac0c..544b391 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -1357,6 +1357,18 @@ .toast.err { background: #7f1d1d; } `; + // ============================================================ + // Test hook + // + // Node's require() defines `module`; Bromite's native engine, Tampermonkey, + // and Violentmonkey do not, so this is inert everywhere the script actually + // ships. Exposes pure logic only — see userscript/test/logic.test.js, which + // loads this file with document.body undefined so the UI never boots. + // ============================================================ + if (typeof module === "object" && module.exports) { + module.exports = { stripBuildHash, asura, demonic, anchorsFromHTML, statusOf }; + } + // ============================================================ // Boot — MUST run last: init() -> buildUI() reads TEMPLATE/CSS, // which are const (TDZ) and only initialized above. At diff --git a/userscript/test/logic.test.js b/userscript/test/logic.test.js new file mode 100644 index 0000000..00990cf --- /dev/null +++ b/userscript/test/logic.test.js @@ -0,0 +1,186 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert"); + +// ============================================================ +// Minimal browser stub +// +// manga-bookmark.user.js is a browser IIFE. At module scope it touches +// localStorage (loadCache, loadQueue, the key-migration IIFE) and, on its very +// last lines, document. Nothing else runs under test: document.body is left +// UNDEFINED, so the boot block takes its `else` branch and waits for a +// DOMContentLoaded that never fires. buildUI, init, and every network call stay +// dormant with no further stubbing. +// ============================================================ + +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; + +// location stub. The IIFE reads location.href during boot; set it to a dummy value. +globalThis.location = { + href: "about:blank", + hostname: "", + pathname: "/", + origin: "", +}; + +// og: meta tags the adapters read through meta(). Reassigned per test. +let metaTags = {}; +globalThis.document = { + querySelector(sel) { + const m = sel.match(/^meta\[property="([^"]+)"\]$/); + if (!m) return null; + const v = metaTags[m[1]]; + return v == null ? null : { getAttribute: () => v }; + }, + addEventListener() {}, + body: undefined, +}; + +const { + stripBuildHash, + asura, + demonic, + anchorsFromHTML, + statusOf, +} = require("../manga-bookmark.user.js"); + +// detect() reads only these four properties off location. +function loc(href) { + const u = new URL(href); + return { pathname: u.pathname, origin: u.origin, href: u.href, hostname: u.hostname }; +} + +// ============================================================ +// stripBuildHash — Asura's slug suffix rotates on every redeploy, so it must +// not be part of the series identity. Must stay in sync with asuraBuildHash in +// backend/store.go. +// ============================================================ + +test("stripBuildHash removes a trailing 8-hex suffix", () => { + assert.equal(stripBuildHash("solo-leveling-059befe1"), "solo-leveling"); +}); + +test("stripBuildHash leaves a slug with no suffix alone", () => { + assert.equal(stripBuildHash("solo-leveling"), "solo-leveling"); +}); + +test("stripBuildHash ignores suffixes that are not exactly 8 hex chars", () => { + assert.equal(stripBuildHash("solo-leveling-abc1234"), "solo-leveling-abc1234"); + assert.equal(stripBuildHash("solo-leveling-059befe1a"), "solo-leveling-059befe1a"); + assert.equal(stripBuildHash("solo-leveling-059befeg1"), "solo-leveling-059befeg1"); +}); + +// ============================================================ +// Asura adapter +// ============================================================ + +test("asura.detect reads a series page, stripping the hash from the id only", () => { + metaTags = { "og:title": "Solo Leveling | Asura Scans", "og:image": "https://cdn.example/x.jpg" }; + const p = asura.detect(loc("https://asurascans.com/comics/solo-leveling-059befe1")); + assert.equal(p.type, "series"); + assert.equal(p.site, "asura"); + assert.equal(p.seriesId, "solo-leveling"); + // seriesUrl keeps the hash: navigation needs the current one (stale ones 302). + assert.equal(p.seriesUrl, "https://asurascans.com/comics/solo-leveling-059befe1"); + assert.equal(p.title, "Solo Leveling"); + assert.equal(p.cover, "https://cdn.example/x.jpg"); + assert.equal(p.chapterNum, null); +}); + +test("asura.detect reads a chapter page including a decimal number", () => { + metaTags = { "og:title": "Solo Leveling Chapter 12.5 - Read Online | Asura Scans", "og:image": "" }; + const url = "https://asurascans.com/comics/solo-leveling-059befe1/chapter/12.5"; + const p = asura.detect(loc(url)); + assert.equal(p.type, "chapter"); + assert.equal(p.seriesId, "solo-leveling"); + assert.equal(p.chapterNum, 12.5); + assert.equal(p.chapterLabel, "Chapter 12.5"); + assert.equal(p.chapterUrl, url); + assert.equal(p.title, "Solo Leveling"); +}); + +test("asura.detect returns other for non-series paths", () => { + metaTags = {}; + assert.equal(asura.detect(loc("https://asurascans.com/")).type, "other"); + assert.equal(asura.detect(loc("https://asurascans.com/bookmarks")).type, "other"); +}); + +test("asura.latestChapterFromAnchors takes the highest and skips the First Chapter shortcut", () => { + const best = asura.latestChapterFromAnchors([ + { href: "/comics/solo-leveling-059befe1/chapter/1", text: "Chapter 1" }, + { href: "/comics/solo-leveling-059befe1/chapter/10", text: "Chapter 10" }, + { href: "/comics/solo-leveling-059befe1/chapter/2", text: "Chapter 2" }, + // Points at a chapter but is not a list entry — the text check excludes it. + { href: "/comics/solo-leveling-059befe1/chapter/1", text: "First Chapter" }, + ]); + assert.deepEqual(best, { num: 10, label: "Chapter 10" }); +}); + +test("asura.latestChapterFromAnchors returns null when nothing matches", () => { + assert.equal(asura.latestChapterFromAnchors([{ href: "/about", text: "About" }]), null); +}); + +// ============================================================ +// Demonic adapter +// ============================================================ + +test("demonic.detect reads a series page", () => { + metaTags = { "og:title": "The World After The Fall", "og:image": "https://cdn.example/y.jpg" }; + const p = demonic.detect(loc("https://demonicscans.org/manga/the-world-after-the-fall")); + assert.equal(p.type, "series"); + assert.equal(p.site, "demonic"); + assert.equal(p.seriesId, "the-world-after-the-fall"); + assert.equal(p.title, "The World After The Fall"); +}); + +test("demonic.detect reads a chapter page and strips the suffix from the title", () => { + metaTags = { "og:title": "The World After The Fall Chapter 3", "og:image": "" }; + const p = demonic.detect(loc("https://demonicscans.org/title/the-world-after-the-fall/chapter/3/1")); + assert.equal(p.type, "chapter"); + assert.equal(p.chapterNum, 3); + assert.equal(p.chapterLabel, "Chapter 3"); + assert.equal(p.seriesUrl, "https://demonicscans.org/manga/the-world-after-the-fall"); + assert.equal(p.title, "The World After The Fall"); +}); + +test("demonic percent-encoded slugs decode to the same id on /manga/ and /title/", () => { + metaTags = {}; + const series = demonic.detect(loc("https://demonicscans.org/manga/i%2527m-the-max-level-newbie")); + const chapter = demonic.detect(loc("https://demonicscans.org/title/i%2527m-the-max-level-newbie/chapter/3/1")); + assert.equal(series.seriesId, chapter.seriesId); + assert.equal(series.seriesId, "i%27m-the-max-level-newbie"); +}); + +test("demonic.latestChapterFromAnchors parses chaptered.php links, including &", () => { + const best = demonic.latestChapterFromAnchors([ + { href: "chaptered.php?manga=5&chapter=3", text: "Chapter 3" }, + { href: "chaptered.php?manga=5&chapter=12", text: "Chapter 12" }, + { href: "/manga/something", text: "Series" }, + ]); + assert.deepEqual(best, { num: 12, label: "Chapter 12" }); +}); + +// ============================================================ +// Shared helpers +// ============================================================ + +test("anchorsFromHTML pulls href/text pairs and flattens nested tags", () => { + const out = anchorsFromHTML('OneTwo more'); + assert.equal(out.length, 2); + assert.deepEqual(out[0], { href: "/a", text: "One" }); + assert.equal(out[1].href, "/b"); + assert.equal(out[1].text.replace(/\s+/g, " ").trim(), "Two more"); +}); + +test("statusOf defaults a missing status to reading", () => { + assert.equal(statusOf({}), "reading"); + assert.equal(statusOf({ status: "" }), "reading"); + assert.equal(statusOf({ status: "archived" }), "archived"); + assert.equal(statusOf({ status: "finished" }), "finished"); +}); -- 2.52.0 From d93a94f1aaf3cf64825d353df170927ba96ca290 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 15:32:15 +0700 Subject: [PATCH 2/7] feat: card body is the continue target; drop Continue and Edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover, title, and subtitle become one link to the last-read chapter. The action row is a sibling of that link, so the destructive button is never also a navigation target, and Remove now confirms. Manual chapter entry is dropped entirely — the web UI has a real form, and in the field the existing 'Update to Chapter N' button covers it. Co-Authored-By: Claude Opus 5 --- userscript/manga-bookmark.user.js | 87 +++++++++++++++---------------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 544b391..24b9866 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -677,19 +677,6 @@ await syncUpsert(bm, "Progress updated"); } - // Manual override from the panel input — forces any value. - async function setChapterManual(key, label, num, url) { - const existing = state.byKey[key]; - if (!existing) return; - const bm = Object.assign({}, existing, { - last_chapter: label, - last_chapter_num: isNaN(num) ? existing.last_chapter_num : num, - last_chapter_url: url || existing.last_chapter_url, - updated_at: Date.now(), - }); - await syncUpsert(bm, "Progress set"); - } - async function syncUpsert(bm, okMsg) { upsertLocal(bm); // optimistic render(); @@ -1108,40 +1095,47 @@ const sub = behind ? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter : (b.last_chapter || "—") + " · " + b.site; - const row = el("div", { class: "item" }, [ - b.cover - ? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" }) - : el("div", { class: "cover ph" }), - el("div", { class: "meta" }, [ - el("div", { class: "t", text: b.title || b.series_id }), - el("div", { class: "c" + (behind ? " behind" : ""), text: sub }), - 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("a", { class: "btn small", href: cont, text: "Continue" }), - el("button", { class: "btn small", text: "Edit", onclick: () => editChapter(b) }), - el("button", { class: "btn small danger", text: "Remove", onclick: () => removeBookmark(b.key) }), + // Cover and text are one link: the card body *is* the continue button, so + // there is no separate one. The action row is a sibling of that link, never + // a child — a thumb that misses ★ must not land on a navigation target, and + // Remove must never be one. + return el("div", { class: "item" }, [ + el("a", { class: "go", href: cont }, [ + b.cover + ? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" }) + : el("div", { class: "cover ph" }), + el("div", { class: "meta" }, [ + el("div", { class: "t", text: b.title || b.series_id }), + el("div", { class: "c" + (behind ? " behind" : ""), text: sub }), ]), ]), + 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), + }), + ]), ]); - return row; } - function editChapter(b) { - const raw = prompt("Set last-read chapter for \"" + (b.title || b.series_id) + "\":", b.last_chapter || ""); - if (raw == null) return; - const label = raw.trim(); - const num = parseFloat((label.match(/[\d.]+/) || [])[0]); - setChapterManual(b.key, label, num, b.last_chapter_url); + // 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. @@ -1325,12 +1319,17 @@ .tab.active { color: #eee; border-bottom-color: #6d28d9; } #list { overflow-y: auto; flex: 1; padding: 8px 0; } .empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; } - .item { display: flex; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #2a2a33; } + .item { + display: flex; flex-direction: column; gap: 8px; + padding: 10px 16px; border-bottom: 1px solid #2a2a33; + } + .go { display: flex; gap: 10px; text-decoration: none; color: inherit; } + .go:active { opacity: .7; } .cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; } .cover.ph { display: block; } .meta { min-width: 0; flex: 1; } .t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .c { color: #9a9aa5; font-size: 12px; margin: 2px 0 8px; } + .c { color: #9a9aa5; font-size: 12px; margin: 2px 0 0; } .c.behind { color: #c4b5fd; } .actions { display: flex; gap: 6px; flex-wrap: wrap; } .btn { -- 2.52.0 From 04359ea03412f8b9f760d6e69c801f17744e5f2b Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 15:36:31 +0700 Subject: [PATCH 3/7] feat: replace the FAB with a 7x44 edge tab The circular button covered artwork exactly while reading. The tab keeps the drag-and-snap behaviour and the mangabm:fabpos key; only its box changes. The dwell countdown becomes a bottom-up fill of the tab, since a sliver has no room for a ring. Also fixes a latent bug the new shape exposes: placeFab and makeDraggable used offsetWidth for the vertical clamp too. Invisible on a 52px circle, off by 37px on a 7x44 tab. Co-Authored-By: Claude Opus 5 --- userscript/manga-bookmark.user.js | 83 ++++++++++++++----------------- 1 file changed, 36 insertions(+), 47 deletions(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 24b9866..b518396 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -897,7 +897,8 @@ // ============================================================ const FAB_KEY = "mangabm:fabpos"; - const FAB_MARGIN = 12; + 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 function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); @@ -906,11 +907,12 @@ 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_MARGIN + "px"; + fab.style.left = FAB_EDGE + "px"; fab.style.right = "auto"; } else { - fab.style.right = FAB_MARGIN + "px"; + fab.style.right = FAB_EDGE + "px"; fab.style.left = "auto"; } } @@ -932,9 +934,9 @@ } function placeFab(fab) { - const s = fab.offsetWidth || 52; + const h = fab.offsetHeight || 44; const p = loadFabPos(); - const maxY = window.innerHeight - s - FAB_MARGIN; + 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); @@ -958,10 +960,10 @@ if (!moved && (Math.abs(e.clientX - sx) > 6 || Math.abs(e.clientY - sy) > 6)) moved = true; if (!moved) return; e.preventDefault(); - const s = fab.offsetWidth; - fab.style.left = clamp(e.clientX - offX, 0, window.innerWidth - s) + "px"; + 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 - s) + "px"; + fab.style.top = clamp(e.clientY - offY, 0, window.innerHeight - h) + "px"; fab.style.bottom = "auto"; }); fab.addEventListener("pointerup", (e) => { @@ -970,10 +972,10 @@ fab.style.transition = ""; if (!moved) return; // tap — let the click handler toggle the panel fab.dataset.dragged = "1"; // suppress the click that trails this drag - const s = fab.offsetWidth; + const w = fab.offsetWidth, h = fab.offsetHeight; const r = fab.getBoundingClientRect(); - const side = r.left + s / 2 < window.innerWidth / 2 ? "left" : "right"; - const y = clamp(r.top, FAB_MARGIN, window.innerHeight - s - FAB_MARGIN); + 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); @@ -981,31 +983,26 @@ } // ============================================================ - // Countdown ring around the FAB (dwell-timer progress) + // Dwell-timer progress: the tab fills bottom-up. A 7px sliver has no room + // for the ring this replaced. // ============================================================ - const RING_C = 144.5; // circumference: 2π·23 - function startRing(ms) { if (!root) return; - const ring = root.getElementById("ring"); - const prog = ring.firstElementChild; - ring.classList.add("active"); - prog.style.transition = "none"; - prog.style.strokeDashoffset = RING_C; // empty - prog.getBoundingClientRect(); // reflow so the next assignment animates - prog.style.transition = "stroke-dashoffset " + ms + "ms linear"; - prog.style.strokeDashoffset = "0"; // fills over ms + 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 ring = root.getElementById("ring"); - if (!ring) return; - const prog = ring.firstElementChild; - ring.classList.remove("active"); - prog.style.transition = "none"; - prog.style.strokeDashoffset = RING_C; + const fill = root.getElementById("fill"); + if (!fill) return; + fill.style.transition = "none"; + fill.style.height = "0"; } function togglePanel() { @@ -1227,10 +1224,7 @@ // ============================================================ const TEMPLATE = ` - +