diff --git a/DESIGN.md b/DESIGN.md
index de6df90..ab51f79 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -188,7 +188,7 @@ The whole world is scoped so it can live inside a shadow root on mangabaka.org:
## Colors
-A bookshop palette: paper and umber grounds, sumi ink, one red band, one teal for completion, kraft for the furniture. Each light token has a dark (umber) counterpart under `prefers-color-scheme: dark`; `obi-ink` is shared by both schemes.
+A bookshop palette: paper and umber grounds, sumi ink, one red band, one teal for completion, kraft for the furniture. Each light token has a dark (umber) counterpart under `prefers-color-scheme: dark`, repeated under `:root[data-theme="dark"]` for the page when Dark Reader is on (`src/web/dark-reader.ts`); `obi-ink` is shared by both schemes.
### Primary
- **Obi Red** (`obi-red`; dark `umber-obi-red`): the band of the main cover, the stamp buttons ("Find covers", "Rehost and file for review"), a failed stage plate, the active plate's blinking pip, the rule above notices, and the main-cover checkbox. Its depth companion **Obi Red Deep** (`obi-red-deep`) is the stamp's hover and the main band's top rule. **Obi Ink** (`obi-ink`) is the only text colour on red.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 6933dd4..9bc4517 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -13,7 +13,7 @@ self-hosted Bun server (primary), a userscript, and later a Bun CLI.
|---|---|
| `src/core/` | Pure pipeline: sources, Wayback history, image inspection, dedupe, seed, MangaBaka client. No DOM, Bun, Node or `GM_*` APIs. |
| `src/core/session.ts` | Review state shared by the page and the userscript: cards, main cover, rehosting, manual text, token flow (preview → confirm → save for review). |
-| `src/web/` | Built. The page ("Obi Shelf" design, see `DESIGN.md`): `template.ts` markup shared with the userscript panel, `app.ts` wiring (`mountApp`), `render.ts` shelf and timeline views, `view-model.ts` pure helpers, `transport.ts` (direct to MangaBaka/litterbox, everything else via `/api/fetch`), `styles.css` scoped for a shadow root. |
+| `src/web/` | Built. The page ("Obi Shelf" design, see `DESIGN.md`): `template.ts` markup shared with the userscript panel, `app.ts` wiring (`mountApp`), `render.ts` shelf and timeline views, `view-model.ts` pure helpers, `transport.ts` (direct to MangaBaka/litterbox, everything else via `/api/fetch`), `styles.css` scoped for a shadow root, `dark-reader.ts` (switches to the page's own dark theme and locks Dark Reader out when that extension is darkening the page). |
| `src/server/` | Built. `app.ts`: web-standard handler (`createApp(env)`) — static assets, `/api/config`, `/api/fetch` allowlisted proxy (redirects re-checked, 20 MB cap), `/api/upload` (store or catbox; takes a source URL, never bytes), `/covers/*`. `storage.ts` (interface, in-memory) and `storage-dir.ts` (folder, size cap, 30-day sweep). `config.ts` (env, client IP), `rate-limit.ts`, `assets-path.ts`. `bun.ts`: thin `Bun.serve` entry. Gate and Workers entry deferred. |
| `src/userscript/` | Built. `gm.ts`: `GM_xmlhttpRequest` transport (no proxy; honest User-Agent for Wayback) and GM storage. `main.ts`: a "Seed covers" launcher on MangaBaka series pages (follows client-side navigation) opening the page's app in a shadow-root panel; optional server URL in its settings. Built by `scripts/build-userscript.ts` into `dist/web/mbseed.user.js`, served at `/mbseed.user.js`. |
| `src/cli/` | Later: Bun CLI. |
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 3e0475b..34708df 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -16,6 +16,21 @@ the repo every session — measurably slower and more tokens).
**Consequences:** Rule changes happen in one file; pointer files must stay thin. Plans are working
artefacts: kept after merge they go stale and contradict the code, so the spec is the record.
+## 2026-10-15 — Use our own dark theme when Dark Reader is on
+**Status:** accepted
+**Context:** With a light system theme the page is light, so the Dark Reader extension restyled it with
+its generated colours instead of the designed umber theme (the user reported Dark Reader did not see it as
+a dark site). Dark Reader marks `` while active and removes its styles
+when `` appears, even after load (read in its source, 2026-10-15).
+**Decision:** `src/web/dark-reader.ts` watches for that mark; on `dark` it sets ``
+(which turns on a copy of the dark tokens in `styles.css`) and adds the lock. One-way for the page's
+lifetime; the `dimmed` (light) scheme is ignored. A test keeps the two dark token blocks identical.
+The userscript panel is untouched: the rule cannot match inside its shadow root.
+**Alternatives:** A static `darkreader-lock` (Dark Reader users on a light system would get the light
+page); a manual theme toggle (not asked for).
+**Consequences:** The dark tokens exist twice in `styles.css`. Turning Dark Reader off keeps the page
+dark until reload. Relies on Dark Reader's attribute name, which is not a documented API.
+
## 2026-10-15 — Docker image built by Gitea CI
**Status:** accepted
**Context:** The user wants a Dockerfile that Gitea builds automatically, plus a docker-compose example,
diff --git a/src/web/dark-reader.ts b/src/web/dark-reader.ts
new file mode 100644
index 0000000..2b6f912
--- /dev/null
+++ b/src/web/dark-reader.ts
@@ -0,0 +1,46 @@
+// Dark Reader marks while it restyles a page. The page has its own
+// dark theme, so when that mark appears we switch to it and add , which makes
+// Dark Reader remove its styles. One-way for the page's lifetime: removing its theme also removes the mark.
+
+export interface ThemeDocument {
+ documentElement: { getAttribute(name: string): string | null; setAttribute(name: string, value: string): void };
+ addLockMeta(): void;
+ /** Calls onChange when 's attributes change; returns a disconnect function. */
+ observeRoot(onChange: () => void): () => void;
+}
+
+export function followDarkReader(doc: ThemeDocument): void {
+ const adopt = (): boolean => {
+ if (doc.documentElement.getAttribute("data-darkreader-scheme") !== "dark") return false;
+ doc.documentElement.setAttribute("data-theme", "dark");
+ doc.addLockMeta();
+ return true;
+ };
+ let adopted = false;
+ const disconnect = doc.observeRoot(() => {
+ if (!adopted && adopt()) {
+ adopted = true;
+ disconnect();
+ }
+ });
+ if (adopt()) {
+ adopted = true;
+ disconnect();
+ }
+}
+
+export function browserThemeDocument(document: Document): ThemeDocument {
+ return {
+ documentElement: document.documentElement,
+ addLockMeta: () => {
+ const meta = document.createElement("meta");
+ meta.name = "darkreader-lock";
+ document.head.append(meta);
+ },
+ observeRoot: (onChange) => {
+ const observer = new MutationObserver(onChange);
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-darkreader-scheme"] });
+ return () => observer.disconnect();
+ },
+ };
+}
diff --git a/src/web/main.ts b/src/web/main.ts
index 628cf16..8899e9d 100644
--- a/src/web/main.ts
+++ b/src/web/main.ts
@@ -1,10 +1,12 @@
import { Session } from "../core/session";
import { withPolicy } from "../core/transport";
import { mountApp } from "./app";
+import { browserThemeDocument, followDarkReader } from "./dark-reader";
import { APP_HTML } from "./template";
import { localStore, pageTransport } from "./transport";
import type { ServerConfig } from "./view-model";
+followDarkReader(browserThemeDocument(document));
const app = document.getElementById("app")!;
app.innerHTML = APP_HTML;
const config = await fetch("/api/config")
diff --git a/src/web/styles.css b/src/web/styles.css
index cf6c306..5df4fb9 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -48,6 +48,29 @@
}
}
+/* The same dark tokens when the page forces dark (dark-reader.ts sets ).
+ Keep identical to the block above; test/dark-reader.test.ts checks it. Never matches inside the
+ userscript's shadow root. */
+:root[data-theme="dark"] .mbseed-page, :root[data-theme="dark"] .mbseed-frame, :root[data-theme="dark"] .mbseed-panel {
+ --mb-page: #2a2520;
+ --mb-panel: #332d27;
+ --mb-slip: #3b342d;
+ --mb-ink: #ece4d6;
+ --mb-ink-2: #bdb2a2;
+ --mb-obi: #c42f28;
+ --mb-obi-deep: #8e1f1a;
+ --mb-obi-text: #f2867b;
+ --mb-teal: #6db3a9;
+ --mb-kraft: #6e5a3f;
+ --mb-rail: #4a3c2a;
+ --mb-line: rgba(236, 228, 214, 0.16);
+ --mb-frame: rgba(236, 228, 214, 0.35);
+ --mb-track: rgba(236, 228, 214, 0.14);
+ --mb-shadow: rgba(0, 0, 0, 0.6);
+ --mb-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23ece4d6' stroke-width='1.6'/%3E%3C/svg%3E");
+ color-scheme: dark;
+}
+
/* ---------- the page around the app ---------- */
.mbseed-page { margin: 0; background: var(--mb-page); color: var(--mb-ink); font: 14px/1.5 var(--mb-sans); }
diff --git a/test/dark-reader.test.ts b/test/dark-reader.test.ts
new file mode 100644
index 0000000..1b9d83c
--- /dev/null
+++ b/test/dark-reader.test.ts
@@ -0,0 +1,72 @@
+import { expect, test } from "bun:test";
+import { followDarkReader, type ThemeDocument } from "../src/web/dark-reader";
+
+type Fake = ThemeDocument & { attrs: Map; metas: string[]; fire(): void; disconnected: boolean };
+
+function fakeDocument(scheme?: string): Fake {
+ const attrs = new Map();
+ if (scheme) attrs.set("data-darkreader-scheme", scheme);
+ const metas: string[] = [];
+ let callback = () => {};
+ const doc: Fake = {
+ attrs,
+ metas,
+ disconnected: false,
+ fire: () => callback(),
+ documentElement: {
+ getAttribute: (name) => attrs.get(name) ?? null,
+ setAttribute: (name, value) => void attrs.set(name, value),
+ },
+ addLockMeta: () => void metas.push("darkreader-lock"),
+ observeRoot: (onChange) => {
+ callback = onChange;
+ return () => {
+ doc.disconnected = true;
+ };
+ },
+ };
+ return doc;
+}
+
+test("Dark Reader already dark at start: our dark theme, and Dark Reader is told to stand down", () => {
+ const doc = fakeDocument("dark");
+ followDarkReader(doc);
+ expect(doc.attrs.get("data-theme")).toBe("dark");
+ expect(doc.metas).toEqual(["darkreader-lock"]);
+ expect(doc.disconnected).toBe(true);
+});
+
+test("Dark Reader turning on after load is followed once", () => {
+ const doc = fakeDocument();
+ followDarkReader(doc);
+ expect(doc.attrs.has("data-theme")).toBe(false);
+ doc.attrs.set("data-darkreader-scheme", "dark");
+ doc.fire();
+ expect(doc.attrs.get("data-theme")).toBe("dark");
+ expect(doc.disconnected).toBe(true);
+ doc.fire();
+ expect(doc.metas).toEqual(["darkreader-lock"]);
+});
+
+test("no Dark Reader, or its dimmed (light) scheme: the page keeps following the system", () => {
+ for (const doc of [fakeDocument(), fakeDocument("dimmed")]) {
+ followDarkReader(doc);
+ doc.fire();
+ expect(doc.attrs.has("data-theme")).toBe(false);
+ expect(doc.metas).toEqual([]);
+ expect(doc.disconnected).toBe(false);
+ }
+});
+
+test("the forced dark theme uses exactly the system dark tokens", async () => {
+ const css = await Bun.file("src/web/styles.css").text();
+ const block = (start: string) => {
+ const at = css.indexOf(start);
+ expect(at).toBeGreaterThan(-1);
+ const open = css.indexOf("{", css.indexOf(".mbseed-page", at));
+ return css.slice(open + 1, css.indexOf("}", open)).replace(/\s+/g, " ").trim();
+ };
+ const system = block("@media (prefers-color-scheme: dark)");
+ expect(system).toContain("--mb-page: #2a2520;");
+ expect(block(':root[data-theme="dark"]')).toBe(system);
+});