diff --git a/.gitignore b/.gitignore index 1762a52..dead3b4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ backend/backend graphify-out/ plans/ docs/superpowers/ +.superpowers/ +go.work +go.work.sum # impeccable-ignore-start # Ephemeral output, runtime state, and per-dev overrides. diff --git a/AGENTS.md b/AGENTS.md index f03f720..c7dcc7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,149 +2,27 @@ Guidance for OpenCode (and Claude Code) working in this repo. -## Status - -Active. Backend (`backend/`) and userscript (`userscript/manga-bookmark.user.js`) built. Plan `plans/mangaBookmark.md` = original spec, may drift; trust code + design docs in `docs/superpowers/specs/` over plan. - ## What this is -Manga read-progress tracker for user reading on **asurascans.com** (current domain; asuracomic.net 301s here) and **demonicscans.org** from **Bromite** (mobile Chromium). Userscript injects on-page UI (floating button + slide-in panel), syncs progress to self-hosted Go backend so bookmarks unify across both sites and devices. +Manga read-progress tracker, user read on **asurascans.com** (current domain; asuracomic.net 301s here) and **demonicscans.org** via **Violentmonkey**. Userscript inject on-page UI (floating button + slide-in panel), sync progress to self-hosted Go backend so bookmarks unify across both sites and devices. -## Hard constraints (drive design — do not violate) +## Hard constraints (drive design — don't violate) -Bromite uses Chromium's **native** userscript engine, not Tampermonkey: -- **No `GM_*` APIs anywhere.** No `GM_setValue`/`GM_getValue` (use page `localStorage`), no `GM_registerMenuCommand` (inject on-page UI), no `GM_xmlhttpRequest` for cross-origin (use plain `fetch()`). GM-free script also runs in desktop Tampermonkey/Violentmonkey for faster iteration. -- Cross-origin `fetch()` works **only** against CORS-enabled backend. Manga sites `https://`, so backend **must be HTTPS** (else mixed-content block). -- Asura and Demonic = **separate origins, separate `localStorage`** — shared remote store only way to unify bookmarks. Cloud sync required, not optional. -- Userscript runs in **isolated world**, so embedded API token safe from site's JS. -- Cloudflare's block on manga sites is **IP-reputation-based, not universal — not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s w/ real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. Contradicts earlier, untested assumption CGNAT dev IP would be blocked; wasn't, at least this date. Treat "does curl work now" as live, time-varying fact to re-check, not fixed property of machine — Cloudflare bot scoring can flip clean IP without notice. Any backend fetcher still needs graceful-degrade path for when challenged; adapters should be **verified against live pages** (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test. +Userscript targets **Violentmonkey**, so `GM_*` APIs available, but stay GM-free where plain web APIs suffice — keeps portability across engines: +- **Avoid `GM_*` unless needed.** Prefer page `localStorage` over `GM_setValue`/`GM_getValue`, on-page UI over `GM_registerMenuCommand`, plain `fetch()` over `GM_xmlhttpRequest` for cross-origin. +- Cross-origin `fetch()` work **only** against CORS-enabled backend. Manga sites `https://`, so backend **must be HTTPS** (else mixed-content block). +- Asura and Demonic are **separate origins with separate `localStorage`** — shared remote store only way to unify bookmarks. Cloud sync required, not optional. +- Userscript run in **isolated world**, so embedded API token safe from site's JS. +- Cloudflare's block on manga sites **IP-reputation-based, not universal — and not reliably reproducible.** Verified 2026-07-26: plain `curl` from both CGNAT dev machine *and* deployed VPS got clean 200s with real HTML on both asurascans.com and demonicscans.org (homepage, series, chapter pages) — no interactive Turnstile challenge from either IP at test time. Contradicts earlier untested assumption CGNAT dev IP blocked; wasn't, at least this date. Treat "does curl work right now" as live, time-varying fact to re-check, not fixed property of machine — Cloudflare's bot scoring can flip previously-clean IP without notice. Backend fetcher still needs graceful-degrade path for when challenged, and adapters should be **verified against live pages** (Playwright MCP, on-device devtools, direct probe) before finalizing, not assumed from single earlier test. ## Architecture ``` -Bromite userscript (isolated world, per-site adapters, localStorage cache) +Violentmonkey userscript (isolated world, per-site adapters, localStorage cache) -- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume) ``` -- **Backend** (`backend/`): stdlib `net/http` (handful of routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`. - Single binary, split into packages under `backend/internal/`: `store` - (Bookmark type, SQLite persistence, migrations), `latest` (background - poller, site parsers, TLS fetcher), `session` (cookie signing, login - rate limiter), `httpmw` (Auth/Gzip/CORS middleware), `api` (JSON - bookmark handlers), `userscript` (userscript-serving handler), `web` - (browser UI handler + `templates/` + `static/`, `go:embed`-ed). - `backend/main.go` is the composition root — the only place that wires - packages together into `newRouter`. Root-level `*_test.go` hold - integration tests that exercise the full router; unit tests for a - package live beside it under `internal/`. -- **Single-user store.** One `bookmarks` table keyed `:` (`asura`|`demonic`). Sync **last-write-wins**. Schema + endpoint list in plan. -- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth). -- **Web UI:** same binary serves password-gated browser UI on second - hostname — `GET /` (list, or login page when no session), - `POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints - under `/ui/*`. Templates + assets `go:embed`-ed under - `backend/internal/web/`, so `backend/Dockerfile` must copy the whole - `internal/` tree, not just `*.go`. Sessions = stateless - HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, when empty - web routes not registered at all. UI mutations read-modify-write - through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one - place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`. - **Design-tool caveat:** templates link `/static/style.css` root-absolutely - (correct — served from `/`), but impeccable detector resolves - stylesheet href with `path.resolve(fileDir, href)`, drops directory - on leading `/` and silently skips file. Relative hrefs don't help - either: template's directory isn't its served path. So - `detect.mjs backend/internal/web/templates` reports **false clean** — - always pass `backend/internal/web/static` too. One finding there, - `overused-font` on "Instrument Serif", deliberate identity choice, not debt. -- **Every action that moves series out of list is confirm-gated.** - Archive, finish, remove each open own `.confirm-row` disclosure - (`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈ - `archive|finish|remove`); restore fires instantly since it's the reversal. - Remove's row wears ember wash, two reversible ones wear `.calm` grey. - `--ember` stays reserved for new-chapter signal: busy bar and inline - error use `--mute`. -- **Latest-chapter poller:** ticker goroutine in same binary re-checks - each bookmarked series' newest published chapter from backend's own - network access, so `latest_chapter` stays fresh when user not - browsing. Second, parallel signal — userscript keeps own - `maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged. - Two independent clocks: per-bookmark cooldown (`latest_checked_at` column, - enforced by `Store.DueForLatestCheck`'s WHERE clause) and wake interval. - Row stamped *before* fetch so broken series waits full - cooldown instead of retrying every tick; writes go through - `Store.Get` + `Store.Upsert` so new chapter never reorders list. - Fetches use `bogdanfinn/tls-client` w/ Chrome profile as defence in depth - against fingerprint-based blocking; any failure logs and skips. See - `docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`. - Poller's `Store.Get` + `Store.Upsert` not wrapped in transaction, so - userscript `PUT` committing between the two can be overwritten by - poller's stale re-read — reverting read progress and, since stored - value now differs, moving `updated_at` and reordering list. Known, - accepted limitation for single-user deployment, not bug to fix. -- **`updated_at` drives list order, moves only on real reading progress:** server applies timestamp when row new or `last_chapter_num` changes, else keeps stored value — favouriting series or recording newly published chapter must not reorder list. `PUT` therefore returns row **as stored**; clients must adopt that response over own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4. -- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` | - `finished`, orthogonal to `favorite`. Archived and finished appear only in - own tab — not All, Updated, Favourites, or recent strip. Poller keeps - checking archived series, skips finished ones. `finished` settable only - from web UI; `PUT /bookmarks/{key}` rejects it w/ 400. - **Empty incoming status means "keep stored one"** — resolved on - `VALUES` side of `Store.Upsert`, not conflict clause, since - `excluded.*` = post-evaluation row and default applied there'd - wipe bucket on every PUT from client predating column. See - `docs/superpowers/specs/2026-07-27-status-buckets-design.md`. -- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH` - (default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD` - (gates browser UI; unset disables it), - `LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER` - (background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`). - `USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`, - default `/userscript/manga-bookmark.user.js`, supplied by a bindmount). - -### Userscript structure (single IIFE, `manga-bookmark.user.js`) - -1. **Site adapters** — one per host, `detect(location, document)` returns page `type` + IDs. ID type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes. -2. **API client** — `apiGet/apiPut/apiDelete` w/ bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback. -3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value. -4. **Retry queue** — every write goes through `pushBookmark`/`pushDelete`, so - failed mutation parked in `localStorage` (`mangabm:queue`) and replayed on - next navigation, reconnect, or `refresh()`. Entries are markers - (`{key, op, sendStatus, attempts}`), never payloads — body read from - cache at send time, so one entry per key gives ordering + coalescing for - free. `sendStatus` **sticky**: while archive pending, later writes to - that key keep carrying bucket, stops successful - in-between write from silently un-archiving series. `refresh()` drains - before fetching, overlays anything still pending, so list never - flaps. 400 drops entry, 401 aborts pass and keeps queue, - transient failures retry to cap of 10. Latest-chapter writes deliberately - stay out of queue. See - `docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`. -5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS - (critical on mobile). Three tabs (All / Favourites / Archived) + row of - link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG - block next to `API_BASE`. FAB is `7 × 44` edge tab whose *hit* area - widened to `28 × 72` by invisible `#hit` child; `#fab` must keep - `touch-action: none` and must **not** regain `overflow: hidden`. Since - `touch-action` resolved at gesture start, strip can't be both - browser-scrolled and script-dragged, so `makeDraggable` splits by intent: swipe - from `#hit` scrolls via `window.scrollBy`, hold of `ARM_MS` arms - reposition drag, visible sliver drags with no hold. See - `docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`. -6. **SPA navigation** — Asura is Astro, client-routed on comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fires w/o reload. Demonic uses classic reloads (initial `document-idle` run suffices). - -### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trusting) - -- **asurascans.com**: series `/comics/` (slug carries a trailing - site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every - redeploy**), chapter `/comics//chapter/`. `seriesId` must strip - the hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in the userscript, - `asuraBuildHash` in the backend); URLs keep the full slug — stale-hash - URLs 302 to current ones. Astro-rendered; chapter links present in raw - server HTML. -- **demonicscans.org**: series `/manga/` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title//chapter//` (older `chaptered.php?manga=&chapter=` form still exists as redirect, what series-page chapter-list anchors link through). - Encodings (incl. triple-encoded punctuation like `%25252D`) are identical - on /manga/ and /title/ pages, so decode-once seriesIds match — verified - 2026-07-28. +Backend-specific architecture (packages, endpoints, poller, config env vars) lives in `backend/AGENTS.md`. Userscript-specific structure (adapters, retry queue, UI, live URL shapes) lives in `userscript/AGENTS.md`. ## Commands @@ -155,18 +33,18 @@ Backend (`cd backend`): Local stack: `docker compose up` (named volume mounted at `/data`, `restart: unless-stopped`). -Smoke test: `curl` endpoints w/ `Authorization: Bearer `; confirm `OPTIONS` preflight returns CORS headers and `/healthz` returns 200. +Smoke test: `curl` endpoints with `Authorization: Bearer `; confirm `OPTIONS` preflight return CORS headers and `/healthz` return 200. ## Forge: Gitea, not GitHub -`origin` = self-hosted Gitea instance (`gitea.violetcrown.my.id`), so **`gh` doesn't work here — use `tea` (Gitea CLI) for anything past plain git.** Common ones: +`origin` is self-hosted Gitea instance (`gitea.violetcrown.my.id`), so **`gh` don't work here — use `tea` (Gitea CLI) for anything past plain git.** Common ones: - Open PR: `tea pr create --head --base main --title "..." --description "..."` - List / view / check out: `tea pr list`, `tea pr `, `tea pr checkout ` - Issues: `tea issue create`, `tea issue list` - Auth lives in `tea login`, not `GH_TOKEN` env var. -`tea` prints output as rendered boxes not plain text; PR URL lands on last line. +`tea` print output as rendered boxes rather than plain text; PR URL lands on last line. ## Design system @@ -185,7 +63,7 @@ instantly. ## Security invariants - Auth on `/bookmarks*`: require `Authorization: Bearer `, **constant-time compare**, 401 otherwise. -- CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` w/ `204`. +- CORS: reflect `Origin` only when in `ALLOWED_ORIGINS`; allow `GET,PUT,DELETE,OPTIONS` + headers `Authorization,Content-Type`; answer preflight `OPTIONS` with `204`. ## Comments @@ -219,15 +97,14 @@ Test: "competent reader get this from code in few sec?" Yes → skip. Needs deto ## graphify -Project has knowledge graph at graphify-out/ w/ god nodes, community structure, cross-file relationships. +Project has knowledge graph at graphify-out/ with god nodes, community structure, cross-file relationships. Rules: -- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships, `graphify explain ""` for focused concepts. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. Return scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. - If graphify-out/wiki/index.md exists, use for broad navigation instead of raw source browsing. - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain don't surface enough context. - After modifying code, run `graphify update .` to keep graph current (AST-only, no API cost). -## OpenCode-specific +## Notes -- Caveman mode active by default (`/home/tan/.config/opencode/AGENTS.md`). Keep comms terse — drop articles, fluff, pleasantries. Code/commits/security written normal. -- `.superpowers/` and `.agents/` dirs hold skill definitions. Gitea at `gitea.violetcrown.my.id`. \ No newline at end of file +- Keep comms terse — drop articles, fluff, pleasantries. Code/commits/security written normally. diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..1530929 --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,81 @@ +Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGENTS.md` for the project-wide architecture diagram, hard constraints, and design system. + +- **Backend** (`backend/`): stdlib `net/http` (handful routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`. + Single binary, split into packages under `backend/internal/`: `store` + (Bookmark type, SQLite persistence, migrations), `latest` (background + poller, site parsers, TLS fetcher), `session` (cookie signing, login + rate limiter), `httpmw` (Auth/Gzip/CORS middleware), `api` (JSON + bookmark handlers), `userscript` (userscript-serving handler), `web` + (browser UI handler + `templates/` + `static/`, `go:embed`-ed). + `backend/main.go` is the composition root — the only place that wires + packages together into `newRouter`. Root-level `*_test.go` hold + integration tests that exercise the full router; unit tests for a + package live beside it under `internal/`. +- **Single-user store.** One `bookmarks` table keyed `:` (`asura`|`demonic`|`comix`|`kagane`). Sync **last-write-wins**. Schema and endpoint list in plan. +- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth). +- **Web UI:** same binary serve password-gated browser UI on second + hostname — `GET /` (list, or login page when no session), + `POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints + under `/ui/*`. Templates + assets `go:embed`-ed under + `backend/internal/web/`, so `backend/Dockerfile` must copy the whole + `internal/` tree, not just `*.go`. Sessions stateless + HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, and when empty, + web routes not registered at all. UI mutations read-modify-write + through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one + place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`. + **Design-tool caveat:** templates link `/static/style.css` root-absolutely + (correct — served from `/`), but impeccable detector resolves + stylesheet href with `path.resolve(fileDir, href)`, drops directory + on leading `/` and silently skip file. Relative href don't help + either: template's directory isn't its served path. So + `detect.mjs backend/internal/web/templates` reports **false clean** — + always pass `backend/internal/web/static` too. One finding there, + `overused-font` on "Instrument Serif", deliberate identity choice, not debt. +- **Every action that moves series out of list is confirm-gated.** + Archive, finish, remove each open own `.confirm-row` disclosure + (`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈ + `archive|finish|remove`); restore fire instantly since it's the reversal. + Remove's row wear ember wash, two reversible ones wear `.calm` grey. + `--ember` stay reserved for new-chapter signal: busy bar and inline + error use `--mute`. +- **Latest-chapter poller:** ticker goroutine in same binary re-check + each bookmarked series' newest published chapter from backend's own + network access, so `latest_chapter` stay fresh when user not + browsing. Second, parallel signal — userscript keep own + `maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged. + Two independent clocks: per-bookmark cooldown (`latest_checked_at` column, + enforced by `Store.DueForLatestCheck`'s WHERE clause) and wake interval. + Row stamped *before* fetch so broken series wait out full + cooldown instead of retrying every tick, and writes go through + `Store.Get` + `Store.Upsert` so new chapter never reorders list. + Fetches use `bogdanfinn/tls-client` with Chrome profile as defence in depth + against fingerprint-based blocking; any failure log and skip. kagane sits + behind a Cloudflare JavaScript challenge the TLS client can't clear, so it is + browser-only: fetched over CDP via `BROWSER_WS_URL`, and simply not polled + when that's unset. See + `docs/superpowers/specs/2026-07-26-server-latest-chapter-polling-design.md`. + Poller's `Store.Get` + `Store.Upsert` not wrapped in transaction, so + userscript `PUT` that commits between the two can get overwritten by + poller's stale re-read — reverting that read progress and, since stored + value now differs, moving `updated_at` and reordering list. Known, + accepted limitation for single-user deployment, not bug to fix. +- **`updated_at` drives list order, so moves only on real reading progress:** server apply its timestamp when row new or `last_chapter_num` changes, else keep stored value — favouriting series or recording newly published chapter must not reorder list. `PUT` therefore returns row **as stored**, clients must adopt that response rather than own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4. +- **Lifecycle buckets:** `status` on each bookmark is `reading` | `archived` | + `finished`, orthogonal to `favorite`. Archived and finished appear only in + own tab — not in All, Updated, Favourites, or recent strip. Poller keeps + checking archived series and skip finished ones. `finished` settable + only from web UI; `PUT /bookmarks/{key}` reject it with 400. + **Empty incoming status means "keep stored one"** — resolved on the + `VALUES` side of `Store.Upsert`, not conflict clause, since + `excluded.*` is post-evaluation row and default applied there would + wipe bucket on every PUT from client that predates column. See + `docs/superpowers/specs/2026-07-27-status-buckets-design.md`. +- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH` + (default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD` + (gates browser UI; unset disable it), + `LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER` + (background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`). + `USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`, + default `/userscript/manga-bookmark.user.js`, supplied by bindmount). + `BROWSER_WS_URL` (headless-shell CDP endpoint for kagane; unset disables + browser polling and leaves that site to the userscript alone). diff --git a/backend/internal/web/static/login-art.png b/backend/internal/web/static/login-art.png new file mode 100644 index 0000000..d957714 Binary files /dev/null and b/backend/internal/web/static/login-art.png differ diff --git a/docs/design-system.md b/docs/design-system.md index 9aed761..468b5b0 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -10,7 +10,7 @@ Implemented in: | Surface | Files | | --- | --- | -| Web UI (login, list, card, empty, errors) | `backend/static/style.css`, `backend/templates/{app,card,list,login,chrome,icons}.html`, `backend/static/filter.js` | +| Web UI (login, list, card, empty, errors) | `backend/internal/web/static/style.css`, `backend/internal/web/templates/{app,card,list,login,chrome,icons}.html`, `backend/internal/web/static/filter.js` | | Userscript panel (Shadow DOM) | `userscript/manga-bookmark.user.js` — `TEMPLATE` and `CSS` at the bottom of the IIFE | ## 1. The one idea @@ -41,7 +41,7 @@ Corollaries: ## 2. Tokens -Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's +Defined once in `backend/internal/web/static/style.css` `:root`, mirrored in the userscript's `:host`. **Never hardcode a hex outside those two blocks.** | Token | Dark | Light | Use | @@ -77,6 +77,8 @@ Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's | `--fav-line` | `#332b14` | `#e3d3a4` | desktop cell border, favourite when on | | `--asura` | `#7d93a5` | `#4f6b80` | site tag | | `--demonic` | `#a98a78` | `#8a6a55` | site tag | +| `--comix` | `#8a9a7d` | `#5f7250` | site tag | +| `--kagane` | `#9a8aa5` | `#6f5f7d` | site tag | | `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot | `--slate`/`--moss`/`--clay`/`--brass` are held at the same weight deliberately: @@ -95,7 +97,7 @@ dark, the hues are re-tuned. | Sans | `DM Sans` → system UI | system UI | The web UI **self-hosts** all three: five latin-subset woff2 files in -`backend/static/fonts/` (~120 KB total), declared by the `@font-face` block at +`backend/internal/web/static/fonts/` (~120 KB total), declared by the `@font-face` block at the top of `style.css` and embedded in the binary by the existing `//go:embed static`. There is no request to Google — this UI needs to survive on a LAN with no internet route. `staticHandler()` in `web.go` registers the @@ -141,18 +143,19 @@ Recurring specs (copy these rather than inventing sizes): **Brand mark**: an inline `` (`viewBox="0 0 200 172"`), defined once in `chrome.html`'s `mark` template and reused by `app.html` and `login.html` so it takes the page's `--ink`/`currentColor`/`--ember` rather -than shipping as a static asset. The blade at its centre strokes -`var(--logo-blade, var(--ember))` — override that custom property, don't -duplicate the SVG, if a surface ever needs a different blade colour. Drawn at -a 5px stroke on a 200-unit grid; at brand size that thins out, so `.brand .mark -g` nudges `stroke-width` up to `6.5` rather than scaling the artwork down. +than shipping as a static asset. The blade at its centre strokes `var(--ember)`, +so a surface that needs a different blade colour re-points that token rather +than duplicating the SVG. Drawn at a 5px stroke on a 200-unit grid; at brand +size that thins out, so `.brand .mark g` nudges `stroke-width` up to `6.5` +rather than scaling the artwork down. **Action key** (`.keyrow`): one permanent line under the tabs naming what every icon in `.actions` does — Read / Fav / Chapter / Archive / Done / -Delete — so the icon strip on a card is never a guess. On a phone each pair -stacks icon-over-word (`flex-direction: column`) so the word gets the full -cell width and can stay in long form; ≥720px it lays out icon-beside-word and -switches the `.short`/`.full` label pair. `.pair.brass` and `.pair.trash` +Delete — so the icon strip on a card is never a guess. The key follows the tab, +not the row: Archive becomes Restore under Archived and Finished, and Finished +drops Done. On a phone each pair stacks icon-over-word +(`flex-direction: column`) so the word gets the full cell width; ≥720px it lays +out icon-beside-word at the same wording. `.pair.brass` and `.pair.trash` carry their icon's resting accent so the key itself teaches the colour vocabulary in §1/§2. diff --git a/userscript/AGENTS.md b/userscript/AGENTS.md new file mode 100644 index 0000000..98292f6 --- /dev/null +++ b/userscript/AGENTS.md @@ -0,0 +1,46 @@ +Guidance for OpenCode (and Claude Code) working under `userscript/`. See root `AGENTS.md` for the project-wide architecture diagram, hard constraints, and design system. + +### Userscript structure (single IIFE, `manga-bookmark.user.js`) + +1. **Site adapters** — one per host, `detect(location, document)` return page `type` + IDs. Identify type/IDs from **URL regex** (most stable); pull `title`/`cover` from **`og:title`/`og:image` meta tags**, not CSS classes. +2. **API client** — `apiGet/apiPut/apiDelete` with bearer header; `localStorage` key `mangabm:cache` for instant render + offline fallback. +3. **Progress logic** — auto-upsert `last_chapter` only when `chapterNum >= stored last_chapter_num` (re-reading old chapters must not regress progress; unparseable -> set current). Manual panel override forces any value. +4. **Retry queue** — every write go through `pushBookmark`/`pushDelete`, so + failed mutation park in `localStorage` (`mangabm:queue`) and replayed on + next navigation, reconnect, or `refresh()`. Entries are markers + (`{key, op, sendStatus, attempts}`), never payloads — body read from + cache at send time, so one entry per key give ordering and coalescing for + free. `sendStatus` is **sticky**: while archive pending, later writes to + that key keep carrying bucket, which stop successful + in-between write from silently un-archiving series. `refresh()` drains + before it fetches and overlays anything still pending, so list never + flaps. 400 drops entry, 401 abort pass and keep queue, and + transient failures retry to cap of 10. Latest-chapter writes deliberately + stay out of queue. See + `docs/superpowers/specs/2026-07-27-offline-retry-queue-design.md`. +5. **UI** — rendered inside **Shadow DOM** root to isolate from site CSS + (critical on mobile). Three tabs (All / Favourites / Archived) and row of + link chips to web UI and both manga sites; `WEB_BASE` sits in CONFIG + block next to `API_BASE`. FAB is `7 × 44` edge tab whose *hit* area + widened to `28 × 72` by invisible `#hit` child; `#fab` must keep + `touch-action: none` and must **not** regain `overflow: hidden`. Since + `touch-action` resolved at gesture start, strip can't be both + browser-scrolled and script-dragged, so `makeDraggable` splits by intent: swipe + from `#hit` scrolls via `window.scrollBy`, hold of `ARM_MS` arms + reposition drag, visible sliver drags with no hold. See + `docs/superpowers/specs/2026-07-28-edge-tab-hitbox-design.md`. +6. **SPA navigation** — Asura is Astro, client-routed on comic/chapter pages: patch `history.pushState`/`replaceState` + listen `popstate`, re-run `detect()` on URL change so auto-update fire without reload. Demonic uses classic reloads (initial `document-idle` run suffice). + +### Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust) + +- **asurascans.com**: series `/comics/` (slug carries trailing + site-wide build-hash suffix, e.g. `-059befe1`, that **rotates on every + redeploy**), chapter `/comics//chapter/`. `seriesId` must strip + hash (`/-[0-9a-f]{8}$/`, `stripBuildHash` in userscript, + `asuraBuildHash` in backend); URLs keep full slug — stale-hash + URLs 302 to current ones. Astro-rendered; chapter links present in raw + server HTML. +- **demonicscans.org**: series `/manga/` (slug may URL-encode punctuation, e.g. `%2527` for `'`), chapter `/title//chapter//` (older `chaptered.php?manga=&chapter=` form still exists as redirect, what series-page chapter-list anchors link through). + Encodings (incl. triple-encoded punctuation like `%25252D`) identical + on /manga/ and /title/ pages, so decode-once seriesIds match — verified + 2026-07-28.