c597bb5d5b
init() ran at line 514 (document.body exists at @run-at document-idle), but buildUI() reads the TEMPLATE/CSS consts declared lower in the IIFE. Accessing them before initialization threw ReferenceError: Cannot access 'CSS' before initialization, so the script died before mounting the FAB and no UI appeared in Cromite. Move the boot invocation to the end of the IIFE, after both consts are initialized. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
608 lines
21 KiB
JavaScript
608 lines
21 KiB
JavaScript
// ==UserScript==
|
|
// @name Manga Bookmark Sync
|
|
// @namespace mangabm
|
|
// @version 1.0.0
|
|
// @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs).
|
|
// @author you
|
|
// @match https://asuracomic.net/*
|
|
// @match https://asurascans.com/*
|
|
// @match https://demonicscans.org/*
|
|
// @run-at document-idle
|
|
// @noframes
|
|
// ==/UserScript==
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
// ============================================================
|
|
// CONFIG — fill these in before installing.
|
|
// ============================================================
|
|
const API_BASE = "https://manga-api.violetcrown.my.id"; // your backend origin, no trailing slash
|
|
const API_TOKEN = "40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df"; // must equal backend API_TOKEN
|
|
|
|
// Safe in Bromite's isolated world: the page's own JS cannot read these.
|
|
const CACHE_KEY = "mangabm:cache";
|
|
|
|
// ============================================================
|
|
// Site adapters
|
|
//
|
|
// Page type + IDs come from URL regex (most stable); title/cover come from
|
|
// og: meta tags. Verified live 2026-07-24 against asurascans.com and
|
|
// demonicscans.org — see README "Adapter reference".
|
|
// ============================================================
|
|
|
|
function meta(prop) {
|
|
const el = document.querySelector('meta[property="' + prop + '"]');
|
|
return el ? el.getAttribute("content") : null;
|
|
}
|
|
|
|
const asura = {
|
|
site: "asura",
|
|
// asuracomic.net currently 301s to asurascans.com; match both.
|
|
matches: (loc) => /(^|\.)asurascans\.com$|(^|\.)asuracomic\.net$/.test(loc.hostname),
|
|
detect(loc) {
|
|
const path = loc.pathname;
|
|
// /comics/<slug-hash>/chapter/<n>
|
|
let m = path.match(/^\/comics\/([^/]+)\/chapter\/([^/?#]+)/);
|
|
if (m) {
|
|
const num = parseFloat(m[2]);
|
|
return {
|
|
type: "chapter",
|
|
site: this.site,
|
|
seriesId: m[1],
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/comics/" + m[1],
|
|
chapterLabel: "Chapter " + m[2],
|
|
chapterNum: isNaN(num) ? null : num,
|
|
chapterUrl: loc.href,
|
|
};
|
|
}
|
|
// /comics/<slug-hash>
|
|
m = path.match(/^\/comics\/([^/?#]+)\/?$/);
|
|
if (m) {
|
|
return {
|
|
type: "series",
|
|
site: this.site,
|
|
seriesId: m[1],
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/comics/" + m[1],
|
|
chapterLabel: null,
|
|
chapterNum: null,
|
|
chapterUrl: null,
|
|
};
|
|
}
|
|
return { type: "other" };
|
|
|
|
// Strips "Chapter N - Read Online" and the " | Asura Scans" suffix.
|
|
function cleanTitle(t) {
|
|
if (!t) return "";
|
|
return t
|
|
.replace(/\s*Chapter\s+[\d.]+\s*-\s*Read Online\s*/i, " ")
|
|
.replace(/\s*\|\s*Asura Scans\s*$/i, "")
|
|
.trim();
|
|
}
|
|
},
|
|
};
|
|
|
|
const demonic = {
|
|
site: "demonic",
|
|
matches: (loc) => /(^|\.)demonicscans\.org$/.test(loc.hostname),
|
|
detect(loc) {
|
|
const path = loc.pathname;
|
|
// Canonical reader: /title/<slug>/chapter/<n>/<page>
|
|
// (chaptered.php?manga=<id>&chapter=<n> 301s to this before we run.)
|
|
let m = path.match(/^\/title\/([^/]+)\/chapter\/([^/?#]+)/);
|
|
if (m) {
|
|
const num = parseFloat(m[2]);
|
|
return {
|
|
type: "chapter",
|
|
site: this.site,
|
|
seriesId: decodeURIComponent(m[1]),
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/manga/" + m[1],
|
|
chapterLabel: "Chapter " + m[2],
|
|
chapterNum: isNaN(num) ? null : num,
|
|
chapterUrl: loc.href,
|
|
};
|
|
}
|
|
// Series: /manga/<slug>
|
|
m = path.match(/^\/manga\/([^/?#]+)/);
|
|
if (m) {
|
|
return {
|
|
type: "series",
|
|
site: this.site,
|
|
seriesId: decodeURIComponent(m[1]),
|
|
title: cleanTitle(meta("og:title")),
|
|
cover: meta("og:image") || "",
|
|
seriesUrl: loc.origin + "/manga/" + m[1],
|
|
chapterLabel: null,
|
|
chapterNum: null,
|
|
chapterUrl: null,
|
|
};
|
|
}
|
|
return { type: "other" };
|
|
|
|
// Demonic chapter og:title is "<Title> Chapter N"; series is clean.
|
|
function cleanTitle(t) {
|
|
if (!t) return "";
|
|
return t.replace(/\s*Chapter\s+[\d.]+\s*$/i, "").trim();
|
|
}
|
|
},
|
|
};
|
|
|
|
const ADAPTERS = [asura, demonic];
|
|
|
|
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;
|
|
}
|
|
|
|
// ============================================================
|
|
// 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 authHeaders(extra) {
|
|
return Object.assign({ Authorization: "Bearer " + API_TOKEN }, extra || {});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
async function apiPut(key, obj) {
|
|
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
|
method: "PUT",
|
|
headers: authHeaders({ "Content-Type": "application/json" }),
|
|
body: JSON.stringify(obj),
|
|
});
|
|
if (!res.ok) throw new Error("PUT /bookmarks " + res.status);
|
|
return res.json();
|
|
}
|
|
|
|
async function apiDelete(key) {
|
|
const res = await fetch(API_BASE + "/bookmarks/" + encodeURIComponent(key), {
|
|
method: "DELETE",
|
|
headers: authHeaders(),
|
|
});
|
|
if (!res.ok) throw new Error("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) {
|
|
state.list = Array.isArray(list) ? list : [];
|
|
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);
|
|
}
|
|
|
|
// ============================================================
|
|
// Mutations (optimistic: update UI/cache first, then sync)
|
|
// ============================================================
|
|
|
|
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,
|
|
series_id: p.seriesId,
|
|
title: p.title || (existing && existing.title) || p.seriesId,
|
|
series_url: p.seriesUrl || (existing && existing.series_url) || "",
|
|
cover: p.cover || (existing && existing.cover) || "",
|
|
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() {
|
|
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,
|
|
series_id: p.seriesId,
|
|
title: existing.title || p.title || p.seriesId,
|
|
series_url: existing.series_url || p.seriesUrl || "",
|
|
cover: existing.cover || p.cover || "",
|
|
last_chapter: p.chapterLabel || "",
|
|
last_chapter_num: p.chapterNum,
|
|
last_chapter_url: p.chapterUrl || "",
|
|
updated_at: Date.now(),
|
|
});
|
|
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();
|
|
try {
|
|
const saved = await apiPut(bm.key, bm);
|
|
upsertLocal(saved); // adopt server updated_at
|
|
render();
|
|
toast(okMsg);
|
|
} catch (e) {
|
|
toast("Offline — saved locally, will retry", true);
|
|
}
|
|
}
|
|
|
|
async function removeBookmark(key) {
|
|
removeLocal(key); // optimistic
|
|
render();
|
|
try {
|
|
await apiDelete(key);
|
|
toast("Removed");
|
|
} catch (e) {
|
|
toast("Offline — remove will retry", true);
|
|
}
|
|
}
|
|
|
|
// Auto-record on opening a chapter of a bookmarked series.
|
|
async function maybeAutoUpdate() {
|
|
const p = state.page;
|
|
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
|
|
}
|
|
await updateToCurrentChapter();
|
|
}
|
|
|
|
// ============================================================
|
|
// UI — Shadow DOM (isolated from site CSS)
|
|
// ============================================================
|
|
|
|
let root, panelOpen = false;
|
|
|
|
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);
|
|
|
|
root.getElementById("fab").addEventListener("click", togglePanel);
|
|
root.getElementById("backdrop").addEventListener("click", togglePanel);
|
|
root.getElementById("closeBtn").addEventListener("click", togglePanel);
|
|
}
|
|
|
|
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);
|
|
|
|
// 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." }));
|
|
}
|
|
|
|
// Bookmark list.
|
|
const listEl = root.getElementById("list");
|
|
listEl.innerHTML = "";
|
|
if (state.list.length === 0) {
|
|
listEl.appendChild(el("div", { class: "empty", text: "No bookmarks yet." }));
|
|
}
|
|
for (const b of state.list) {
|
|
listEl.appendChild(renderItem(b));
|
|
}
|
|
}
|
|
|
|
function renderItem(b) {
|
|
const cont = b.last_chapter_url || b.series_url || "#";
|
|
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", text: (b.last_chapter || "—") + " · " + b.site }),
|
|
el("div", { class: "actions" }, [
|
|
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) }),
|
|
]),
|
|
]),
|
|
]);
|
|
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);
|
|
}
|
|
|
|
// 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() {
|
|
try {
|
|
const list = await apiGet();
|
|
setList(list);
|
|
render();
|
|
} catch (e) {
|
|
render(); // fall back to cache
|
|
}
|
|
}
|
|
|
|
let lastUrl = location.href;
|
|
function onNavigate() {
|
|
state.page = detect();
|
|
render();
|
|
maybeAutoUpdate();
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ============================================================
|
|
// Boot
|
|
// ============================================================
|
|
|
|
function init() {
|
|
buildUI();
|
|
state.page = detect();
|
|
render();
|
|
installNavWatcher();
|
|
refresh().then(() => maybeAutoUpdate()); // sync, then auto-record this page
|
|
}
|
|
|
|
// ============================================================
|
|
// Markup + styles (kept at bottom for readability)
|
|
// ============================================================
|
|
|
|
const TEMPLATE = `
|
|
<button id="fab" aria-label="Manga bookmarks">📑</button>
|
|
<div id="backdrop"></div>
|
|
<aside id="panel" role="dialog" aria-label="Manga bookmarks">
|
|
<header>
|
|
<span>Manga Bookmarks</span>
|
|
<button id="closeBtn" aria-label="Close">✕</button>
|
|
</header>
|
|
<section id="context"></section>
|
|
<div id="list"></div>
|
|
</aside>
|
|
<div id="toasts"></div>
|
|
`;
|
|
|
|
const CSS = `
|
|
:host { all: initial; }
|
|
* { box-sizing: border-box; font-family: system-ui, -apple-system, sans-serif; }
|
|
#fab {
|
|
position: fixed; right: 16px;
|
|
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
|
width: 52px; height: 52px; border-radius: 50%; border: none;
|
|
background: #6d28d9; color: #fff; font-size: 22px; line-height: 52px;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,.4); z-index: 2147483646; cursor: pointer;
|
|
}
|
|
#fab:active { transform: scale(.94); }
|
|
#backdrop {
|
|
position: fixed; inset: 0; background: rgba(0,0,0,.5);
|
|
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: #1b1b22; color: #eee;
|
|
transform: translateX(105%); transition: transform .25s ease;
|
|
z-index: 2147483647; display: flex; flex-direction: column;
|
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
box-shadow: -4px 0 20px rgba(0,0,0,.5);
|
|
}
|
|
#panel.open { transform: translateX(0); }
|
|
header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 14px 16px; border-bottom: 1px solid #33333d; font-weight: 600; font-size: 16px;
|
|
}
|
|
#closeBtn { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; }
|
|
#context {
|
|
padding: 12px 16px; border-bottom: 1px solid #33333d;
|
|
display: flex; flex-direction: column; gap: 8px;
|
|
}
|
|
.ctx-title { font-weight: 600; font-size: 15px; }
|
|
.ctx-sub { color: #9a9aa5; font-size: 13px; }
|
|
#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; }
|
|
.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; }
|
|
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
.btn {
|
|
background: #33333d; color: #eee; border: none; border-radius: 6px;
|
|
padding: 8px 12px; font-size: 13px; cursor: pointer; text-decoration: none; display: inline-block;
|
|
}
|
|
.btn:active { opacity: .8; }
|
|
.btn.primary { background: #6d28d9; color: #fff; }
|
|
.btn.small { padding: 6px 10px; font-size: 12px; }
|
|
.btn.danger { background: #7f1d1d; }
|
|
#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: #2f2f3a; color: #fff; padding: 10px 16px; border-radius: 8px;
|
|
font-size: 13px; opacity: 0; transform: translateY(10px); transition: all .3s;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,.4); max-width: 80vw;
|
|
}
|
|
.toast.show { opacity: 1; transform: translateY(0); }
|
|
.toast.err { background: #7f1d1d; }
|
|
`;
|
|
|
|
// ============================================================
|
|
// 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);
|
|
})();
|