Add comix.to and kagane.to support (#13)

Tracks read progress on comix.to and kagane.to alongside asura and demonic, in both the userscript and the backend.

Implements `docs/superpowers/plans/2026-08-03-comix-kagane-support.md`.

## Userscript

- `comix` adapter — `/title/<id>-<slug>`; only the id prefix is identity (the slug follows the title). No `og:image`, so the cover is matched by `alt`.
- `kagane` adapter — reader URLs are uuids with no chapter number, so it comes out of `og:title`; anchor scanning is structurally impossible, replaced by `latestChapterFromApi` against kagane's same-origin JSON API.
- `seriesId` threaded through `latestChapterFromAnchors` so comix can scope its scan to its own series and a recommendation strip cannot win the maximum.
- `@match` for both hosts, panel chips, v1.6.0.

## Backend

- `latestChapterFrom` cases: comix parses the SSR JSON state blob (`latestChapterUrl`, scoped to the series id); kagane parses API JSON (`chapter_no`).
- Poller allowlist extended; `Poller.BrowserFetch` with `fetcherFor(site)` routes kagane to a browser fetcher. Nil means kagane is not polled at all — never a fallback to the TLS fetcher, which would only ever retrieve a challenge page.
- `BrowserFetcher`: chromedp against a `headless-shell` sidecar. kagane sits behind a Cloudflare JS challenge that no TLS fingerprint clears, and the request is made inside the page rather than by replaying `cf_clearance`.
- `BROWSER_WS_URL` wiring, sidecar in both compose files (no `ports:`, dedicated non-external network), Dockerfile on `golang:1.26-alpine` — chromedp requires go 1.26.
- Web UI `--comix` / `--kagane` tokens in both colour branches.

## Notes for review

- `series_url` is client-supplied and a headless browser is a strong SSRF primitive, so kagane's host is pinned twice: in `fetchableSeriesURL` and again in `kaganeAPIURL`.
- Three chained defects found during verification made the browser path dead under Compose (sidecar flag collision, Chrome's Host-header DNS-rebinding check, the wrong chromedp option). Fixed; the compose comments record the wrong configurations too, so they don't get "simplified" back.
- `ALLOWED_ORIGINS` now includes both new origins. Without it every write from comix/kagane silently fails CORS preflight, parks in the retry queue, and drops at the cap.

## Verification

221 backend tests, 32 userscript tests, static `CGO_ENABLED=0` build, both compose configs.

Two gaps, both real:

1. The userscript on live pages via Violentmonkey needs a human browser profile — not run. Check: comix series page (title/cover, no chapter), comix chapter page (records the number; an *older* chapter must not regress it), comix SPA navigation without reload, kagane series page (og:image cover), kagane reader (number from `og:title`), both chips opening the right sites.
2. The kagane browser path has not completed end-to-end anywhere. Dial/navigate/fetch is confirmed, but Cloudflare 403'd headless-shell's Chrome on every attempt from the dev sandbox, and comix's poll-through-Docker was blocked by that environment's TLS interception. Both environment-dependent rather than branch defects — the first real deploy is the actual verification.

Reviewed-on: #13
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #13.
This commit is contained in:
2026-08-03 19:53:45 +07:00
committed by sulthan
parent 3c935ba7c3
commit 180ee78b1f
49 changed files with 2233 additions and 1027 deletions
+121 -52
View File
@@ -1,16 +1,16 @@
# Cinder — mangaBookmark design system
Source of truth: the Claude Design doc **Cinder Sheet**
(`cfa39183-8874-4f76-987c-afef14dceebb`, files `Cinder Sheet.dc.html` for the
static spec and `Cinder Sheet App.dc.html` for the interactive one). This file
records the rules that got implemented so a future agent can extend the UI
without re-reading the design.
Source of truth: the Claude Design project **mangaBookmark Web UI**
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`, `index.html` + siblings
`archived.html`/`fav.html`/`finished.html`/`new.html`/`login.html`/`mobile.html`,
`style.css`, `filter.js`). This file records the rules that got implemented so
a future agent can extend the UI without re-reading the design.
Implemented in:
| Surface | Files |
| --- | --- |
| Web UI (login, list, card, empty, errors) | `backend/static/style.css`, `backend/templates/{app,card,list,login,icons}.html`, `backend/static/filter.js` |
| Web UI (login, list, card, empty, errors) | `backend/static/style.css`, `backend/templates/{app,card,list,login,chrome,icons}.html`, `backend/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
@@ -19,9 +19,12 @@ Implemented in:
allowed to be crimson: its title turns `--paper-hot` and sits on a 1px ember
underline sized to the text, its cover gains a 3px ember rule at the foot, and
its `Ch N out` meta and play icon go ember. Everything else — favourites,
status, chrome — stays cool. If a new feature wants to be noticed, it does *not*
get to borrow the ember; find a typographic answer (weight, italic, a rule) or
use brass, which is already spoken for by favourites.
status, chrome, destruction — stays off that one colour. Destruction gets its
own token (`--danger`, a duller oxblood) precisely so a remove confirm is
never mistaken across the room for an unread chapter. If a new feature wants
to be noticed, it does *not* get to borrow the ember; find a typographic
answer (weight, italic, a rule) or reach for one of the named action accents
(§2).
Corollaries:
@@ -34,7 +37,7 @@ Corollaries:
- **Three type roles, never mixed.** Display serif for anything a human reads as
a name (brand, titles, tabs, primary buttons, empty-state headings). Mono
small-caps for machine facts (site, chapter numbers, labels, status, badges,
ghost buttons). Sans for prose only (empty-state body, hints).
ghost buttons, the action key). Sans for prose only (empty-state body, hints).
## 2. Tokens
@@ -44,7 +47,7 @@ Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's
| Token | Dark | Light | Use |
| --- | --- | --- | --- |
| `--ink` | `#100f0e` | `#f7f4ef` | page |
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form, toast) |
| `--ash` | `#161413` | `#efeae3` | recessed panel (chapter form) |
| `--dim` | `#0d0c0b` | `#f1ede7` | archived / finished row background |
| `--rule` | `#221f1d` | `#e0dad2` | hairline between sheets, button borders |
| `--rule-soft` | `#1a1817` | `#e8e3dc` | the measure's own side edges |
@@ -54,23 +57,34 @@ Defined once in `backend/static/style.css` `:root`, mirrored in the userscript's
| `--paper-hot` | `#f0d3cb` | `#a33018` | title of a series with a new chapter |
| `--paper-dim` | `#ddd5cb` | `#191715` | resting title |
| `--mute` | `#8d857c` | `#6b645d` | secondary text, idle icons |
| `--mute-2` | `#5a5450` | `#857d75` | eyebrow labels, hints |
| `--mute-2` | `#877f76` | `#6c655e` | eyebrow labels, hints (must clear 4.5:1 on both `--ink` and `--ash`) |
| `--faint` | `#3a3733` | `#c9c2ba` | the `/` separators in a meta line |
| `--faint-2` | `#57504b` | `#a8a098` | cover monogram |
| `--ember` | `#e0452c` | `#c23a22` | heat — see §1 |
| `--ember-wash` | `#1a1211` | `#fbeee9` | ember-tinted surface (confirm, error) |
| `--ember-wash` | `#1a1211` | `#fbeee9` | ember-tinted surface |
| `--ember-ink` | `#150907` | `#fff` | text on solid ember |
| `--ember-soft` | `#eda798` | `#8d2c17` | text on ember wash |
| `--brass` | `#b8912f` | `#8a681c` | favourites, and only favourites |
| `--trash` | `#6b5450` | `#a98276` | remove, at rest |
| `--danger` | `#cf5c4d` | `#97362a` | destruction — remove confirm, never the same as `--ember` |
| `--danger-wash` | `#211311` | `#fbe9e5` | remove-confirm surface |
| `--danger-ink` | `#150808` | `#fff` | text on solid danger |
| `--danger-soft` | `#e2aaa1` | `#7c2c22` | text on danger wash |
| `--brass` | `#b8912f` | `#8a681c` | favourite — a cooler second metal |
| `--slate` | `#7fa0c0` | `#3f6689` | archive accent |
| `--moss` | `#7fae86` | `#3d6c46` | finished accent |
| `--clay` | `#b5906f` | `#7c5533` | set-chapter accent |
| `--trash` | `#977671` | `#8c6558` | remove, at rest — icons need 3:1, not 4.5:1 |
| `--play-hot-line` | `#3a1d18` | `#f0cfc6` | desktop cell border, play when `.is-new` |
| `--fav-line` | `#332b14` | `#e3d3a4` | desktop cell border, favourite when on |
| `--asura` | `#7d93a5` | `#4f6b80` | site tag |
| `--demonic` | `#a98a78` | `#8a6a55` | site tag |
| `--hatch` / `--hatch-dim` | 135° 5px stripe | paper stripe | missing-cover slot |
Dark is the default (`color-scheme: dark light`); light is a
`@media (prefers-color-scheme: light)` override of the same names. **Any new
colour must be added in both branches** — light is not a filter over dark, the
hues are re-tuned.
`--slate`/`--moss`/`--clay`/`--brass` are held at the same weight deliberately:
one accent per action, so a press says which lane it belongs to, with none of
them competing with ember. Dark is the default (`color-scheme: dark light`);
light is a `@media (prefers-color-scheme: light)` override of the same names.
**Any new colour must be added in both branches** — light is not a filter over
dark, the hues are re-tuned.
## 3. Type
@@ -83,11 +97,10 @@ hues are re-tuned.
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
the top of `style.css` and embedded in the binary by the existing
`//go:embed static`. There is no request to Google — this UI is read in Bromite,
where `fonts.googleapis.com` is routinely blocked, and over a LAN with no
internet route. `staticHandler()` in `web.go` registers the `.woff2` MIME type
because Go's built-in table lacks it and the scratch image has no
`/etc/mime.types`.
`//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
`.woff2` MIME type because Go's built-in table lacks it and the scratch image
has no `/etc/mime.types`.
Adding a weight means adding a file: grab the *latin* `@font-face` block from
`https://fonts.googleapis.com/css2?...` **with a browser User-Agent** (Google
@@ -102,37 +115,57 @@ root is at the mercy of the host site's CSP.
Recurring specs (copy these rather than inventing sizes):
- Brand: `400 26px/1 display`, with `<em>` in ember italic — `manga<em>Bookmark</em>`.
- Row title: `400 19px/1.2 display` (21px ≥720px).
- Tab: `400 17px display` (18px ≥720px), active gets `border-bottom: 2px` in
- Brand: `400 26px/1 display` (`30px` ≥720px), inline SVG mark (§4) + `<em>` in
ember italic — `manga<em>Bookmark</em>`.
- Row title: `400 21px/1.2 display` (`22px` ≥720px).
- Tab: `400 17px display` (`18px` ≥720px), active gets `border-bottom: 2px` in
`--paper` (`--ember` for Updated) plus `margin-bottom: -1px` so it lands on
the row's own hairline.
- Meta / label / badge: `500 10px mono`, `letter-spacing: .12em`,
`text-transform: uppercase`. Eyebrows ("CONTINUE READING") use `.2em`.
- Meta / label / badge / action key: `500 10–11px mono`, `letter-spacing:
.04em`–`.2em`, `text-transform: uppercase`. Eyebrows use the widest tracking.
- Empty-state heading: `400 20px display`; body `400 14px/1.6 sans`, `max-width: 44ch`.
- Primary button: `--paper` fill, `--ink` text, `400 17px display`, no border radius.
- Primary button: `--paper` fill, `--ink` text, `400 17–19px display`, no border radius.
- Ghost button: mono small-caps, transparent, `border-bottom: 1px --field-line`.
## 4. Components (web UI)
```
.sheet
.topbar .brand + .ghost (log out)
.topbar .brand (mark + wordmark) + .ghost (log out)
.chrome .searchbar + nav.tabs (column on phone, row ≥720px via order:)
.keyrow one-line action key: Read / Fav / Chapter / Archive / Done / Delete
.recent h2 eyebrow + .recent-strip > a.recent-card
main#list article.card … | .empty
```
**Brand mark**: an inline `<svg class="mark">` (`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.
**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`
carry their icon's resting accent so the key itself teaches the colour
vocabulary in §1/§2.
`article.card` — the row, and the only per-series component:
```
article.card[.is-new|.is-dim]#card-<key>[data-title]
.row
a.cover img | span.monogram, + span.foot-rule[.brass]
a.cover[tabindex="-1" aria-hidden] img | span.monogram, + span.foot-rule[.brass]
.body .title-line (h3.title + svg.fav-mark) , p.meta
.actions play, favourite, chapter, archive|restore, finish, remove
form.chapter-form[hidden] .hint + .field(input + Save)
.confirm-row[hidden] span + (Remove, Cancel)
.actions play, favourite, chapter | lifecycle: archive/restore, finish, remove
form.chapter-form[hidden] .hint + .field(input + Save) + .hint (latest known)
.confirm-row[.calm][hidden] × one per lifecycle action, span + (go/danger-solid, Cancel)
p.error-inline[hidden]
```
@@ -143,9 +176,29 @@ Rules that are easy to break:
dim rule is a descendant selector off those two classes, so a new sub-element
inherits the state for free.
- `.actions` is `flex: 1 0 100%` inside `.row`, which is what makes it a
full-width strip under the row on a phone and a group of 40px squares beside
full-width strip under the row on a phone and a group of 44px squares beside
the row at ≥720px. Cells are 46px tall on phone (thumb target) and divided by
`border-right: 1px var(--rule)`, last child none.
- Three clusters by consequence, in this order: navigate (`.play`) | organize
(`.fav`, `.pencil`) | lifecycle (`.box`/`.restore`, `.finish`, `.remove`,
each carrying the `.lifecycle` class). Lifecycle cells sit on a recessed
`--ash` ground so the thumb reads "this one moves the series" before it
reads which icon it landed on; ≥720px they separate by a 10px gap instead of
the phone's inset hairline.
- Every lifecycle button that moves a series out of the list is
**confirm-gated**: it opens its own `.confirm-row` (`archive`, `finish`,
`remove` — `toggleConfirmRow(key, kind)` in `filter.js`). Archive and finish
ask in `.calm` grey since they're reversible; remove alone gets the
`--danger-wash` treatment and names the series in its question. Restore
fires instantly — no confirm — because it's the reversal.
- Per-action hover/press accent: `.fav` → `--brass`, `.pencil` → `--clay`,
`.box` → `--slate`, `.finish` → `--moss`. `.play` stays paper/ember (ember
only when `.is-new`). `.remove` stays `--trash` at rest, `--danger` on
hover. Desktop cell borders follow the same accent on hover
(`border-color: currentColor`); the two coloured *resting* states
(`.is-new .play`, `.fav.on`) get their own dim border tokens
(`--play-hot-line`, `--fav-line`) instead of the full accent, since a
resting border needs less contrast than a hover one.
- Icons are `<use href="#i-…">` against the sprite in `templates/icons.html`,
included once by `app.html`. htmx-swapped card fragments reference the
page's sprite, so a card never inlines a path. New icon → add a `<symbol>`
@@ -155,11 +208,15 @@ Rules that are easy to break:
- Cover foot rule: ember when new, brass when favourite-and-not-new. Never both.
- `[hidden] { display: none !important; }` is load-bearing — every disclosure
panel is a flex container, and `display` beats `hidden`.
- Busy state is `.card.htmx-request::before`, a 1px ember bar sliding across the
- Busy state is `.card.htmx-request::before`, a 1px grey bar sliding across the
top hairline (`barSlide`), plus the action strip at `opacity: .5`. Never a
spinner.
- `.open` on the pencil / trash cell marks which panel is showing; `filter.js`
`togglePanel()` owns that class alongside `hidden`.
spinner, and deliberately `--mute` not `--ember` — on a list screen ember
means "new chapter" and nothing else, so a system state can't borrow it.
- `.open` on the pencil / lifecycle cell marks which panel is showing;
`filter.js` `togglePanel()`/`toggleConfirmRow()` own that class alongside
`hidden`. An open lifecycle cell needs the next surface step up from
`--hover` (`--rule`) to stay legible as the panel's owner, since the panel
itself already sits on `--ash`.
## 5. Components (userscript panel)
@@ -167,9 +224,9 @@ Same tokens, same heat rule, structure unchanged from before the revamp
(`#fab`/`#hit`, `#panel`, `#nav` chips, `#context`, `#tabs`, `#list` of `.item`).
Cinder-specific: `.item.hot` (new chapter) and `.item.dim` (archived) mirror
`.is-new` / `.is-dim`; chips and `.btn`s are mono small-caps with hairline
borders instead of pills; loading is the same sliding ember hairline (`.spinner`
is now a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
ember-washed when `.err`.
borders instead of pills; loading is the same sliding hairline (`.spinner`
is a 1px bar, not a rotating ring); toasts are `--ash` with a 2px left rule,
`--danger`-washed when `.err`.
**Do not touch** the FAB geometry while restyling: `#fab` keeps
`touch-action: none`, must not regain `overflow: hidden`, and `#hit` keeps the
@@ -179,36 +236,48 @@ ember-washed when `.err`.
## 6. Motion
Three animations, all ≤ 1.15s and all disabled under
`prefers-reduced-motion: reduce`:
`prefers-reduced-motion: reduce` (pseudo-elements need naming explicitly in
that query — `*` does not match `::before`/`::after`, so the busy bar and
error dot are listed by name and fall back to their static drawn form):
- `sheetIn` — 180ms fade + 4px rise, on a row and on each disclosure panel.
- `barSlide` — the burning hairline, for any busy state.
- `emberPulse` — the 5px dot on `.error-inline`.
- `barSlide` — the sliding hairline, for any busy state.
- `mutePulse` — the 5px dot on `.error-inline`.
No transforms on hover, no scale, no easing curves beyond `ease-out`/`linear`.
## 7. Accessibility floor (not negotiable)
- Touch targets on the phone layout are 44–46px; the 40px desktop cells are
- Touch targets on the phone layout are 44–46px; the 44px desktop cells are
pointer-only (≥720px).
- Every icon-only control keeps `title` + `aria-label`; the SVG inside is
`aria-hidden`.
`aria-hidden`. Lifecycle buttons also carry `aria-expanded` +
`aria-controls` pointing at their `.confirm-row`.
- The cover link is `tabindex="-1" aria-hidden="true"` because the title link
and the play cell already reach the same URL — do not make it a third tab stop.
- Tabs keep `role="tab"` / `role="tablist"`; the active one is marked by class,
and `setActiveTab()` in `filter.js` maintains it after an htmx swap.
- `.confirm-row` and `.error-inline` are `role="group"`/`role="status"` with
`aria-live="polite"` so a disclosure opening is announced.
- Light and dark are both first-class. Check any new colour in both.
## 8. Adding something new — checklist
1. Can it be a hairline, a small-caps label, or a serif line instead of a new
component? Prefer that.
2. Tokens only, both colour branches.
2. Tokens only, both colour branches. A new action gets its own named accent
(like `--slate`/`--moss`/`--clay`) at the same weight as the existing set —
never reuse `--ember` or `--danger` for anything but their one meaning.
3. If it is per-series, hang it off `.is-new` / `.is-dim` rather than adding a
third state class.
4. Icon → `templates/icons.html`; nothing inlines SVG paths.
5. Phone first (44px targets, single column), then the ≥720px block.
6. Verify: `cd backend && go test ./...`, then run the binary and screenshot
4. If it removes a series from the current view (archive/finish/remove-shaped),
it is confirm-gated via its own `.confirm-row` — no exceptions, restore is
the only instant action because it's the one that's reversible by nature.
5. Icon → `templates/icons.html`; nothing inlines SVG paths. Brand mark stays
the one exception (`chrome.html`'s `mark` template), since it takes
page-level custom properties the sprite can't carry per-instance.
6. Phone first (44px targets, single column), then the ≥720px block.
7. Verify: `cd backend && go test ./...`, then run the binary and screenshot
both widths and both colour schemes (Playwright: `emulateMedia`,
`setViewportSize`; disable the browser cache — `/static/*` is served with
`max-age=3600`, and templates are `go:embed`ed so the binary must be rebuilt