Files
mangaBookmark/AGENTS.md
T
sulthan 0725b11275 docs: sync AGENTS.md to current architecture (internal/, Cinder, confirm-gated, edge-tab)
Captures what shipped on the branch:
- backend split into internal/ packages; composition root = main.go
- web UI go:embed now lives under internal/web/; Dockerfile must copy tree
- impeccable detector caveat (root-absolute /static/ paths) and false-clean
- confirm-row pattern for archive/finish/remove; --ember reserved
- edge-tab hitbox design (7x44 visible, 28x72 hit, touch-action + arm hold)
- Cinder design system section + ember-law reference
2026-08-03 19:52:40 +07:00

16 KiB
Raw Blame History

AGENTS.md

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.

Hard constraints (drive design — do not 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.

Architecture

Bromite 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 <site>:<series_id> (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> (slug carries a trailing site-wide build-hash suffix, e.g. -059befe1, that rotates on every redeploy), chapter /comics/<slug>/chapter/<n>. 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> (slug may URL-encode punctuation, e.g. %2527 for '), chapter /title/<slug>/chapter/<n>/<page> (older chaptered.php?manga=<id>&chapter=<n> 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.

Commands

Backend (cd backend):

  • Test all: go test ./...
  • Single test: go test -run TestName ./...
  • Build static binary: CGO_ENABLED=0 go build

Local stack: docker compose up (named volume mounted at /data, restart: unless-stopped).

Smoke test: curl endpoints w/ Authorization: Bearer <token>; confirm OPTIONS preflight returns CORS headers and /healthz returns 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:

  • Open PR: tea pr create --head <branch> --base main --title "..." --description "..."
  • List / view / check out: tea pr list, tea pr <n>, tea pr checkout <n>
  • 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.

Design system

Web UI + userscript panel follow Cinder, rules in docs/design-system.md — source of truth Claude Design project mangaBookmark Web UI (969ac210-fe02-4c01-ae1b-9a271dcc779a). Read it before touching backend/internal/web/static/style.css, backend/internal/web/templates/*, or userscript TEMPLATE/CSS. Core law: ember means new chapter only — no other state (busy, error, destruction) may use --ember; destruction gets --danger. No cards/corners/shadows, one --measure: 760px column, tokens only (never hardcode hex outside :root), both colour branches touched together. Any move that pulls series out of list (archive/finish/remove) must be confirm-gated via its own .confirm-row; only restore fires instantly.

Security invariants

  • Auth on /bookmarks*: require Authorization: Bearer <API_TOKEN>, 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.

Comments

Comment only if code alone can't carry info. Cost per read — must earn spot.

Write for:

  • Why not what. Tradeoffs, non-obvious decisions.
  • Load-bearing detail looking incidental — say so if "simplify" breaks it.
  • Non-local consequence, invisible from function alone.
  • Wire format / encoding / interface contract — save callers re-deriving.
  • Gotcha/workaround, with ref if exists.
  • Domain/business rule not derivable from code.

Skip:

  • Restating code (no // increment i above i++).
  • Trivial getter/setter/pass-through.
  • Banners, dividers, // helpers.
  • Change narration (// fix bug, // as requested, // new impl) — git's job.
  • Commented-out code — delete.
  • TODO without concrete action.

Style: one dense comment over function beats one per line inside. Tight, no worked example unless bug subtle. Wrong comment worse than none — update/delete on change. Default fewer — sparse+high-signal beats comprehensive.

Test: "competent reader get this from code in few sec?" Yes → skip. Needs detour through another file/spec/git-blame → write it.

Relevant skills

multi-stage-dockerfile and docker-compose-orchestration for container work (referenced in plan).

golang-code-style, golang-error-handling, golang-performance, golang-testing for backend Go work.

graphify

Project has knowledge graph at graphify-out/ w/ god nodes, community structure, cross-file relationships.

Rules:

  • For codebase questions, first run graphify query "<question>" when graphify-out/graph.json exists. Use graphify path "<A>" "<B>" for relationships, graphify explain "<concept>" 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

  • 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.