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); +});