Skip to content

Rebuild the torrent detail page and the catalogue around the reader's questions - #30

Merged
Dim145 merged 28 commits into
mainfrom
feat/torrent-page-redesign
Sep 10, 2026
Merged

Dim145 merged 28 commits into
mainfrom
feat/torrent-page-redesign

Conversation

@Dim145

@Dim145 Dim145 commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Two pages rebuilt around the questions their readers actually arrive with — the
torrent detail page and the catalogue — plus the review passes that followed.
28 commits, and the findings that mattered came from probing the running stack:
semgrep and gitleaks were clean or already arbitrated, while calling the changed
routes without a session, then with another member's, is what turned up the
leaks below.

The torrent detail page

A hero band that says what the release is above the fold, two columns with the
decision pinned, swarm health, and what the download costs — the page used to
answer "what is this?" 700px down, repeated every track twice, and offered no
way to say "wrong film". The work's own colour tints the band, and the only
motions left are the ones that mean something.

Along the way: the Hit & Run thresholds were read by the tracker and the API but
exposed by no interface (the 24h on every torrent page was a code default); a
timed-out metadata lookup was cached as "no such record" for an hour, so a
network hiccup cost thirty posters; the poster relay accepted any client with no
Sec-Fetch headers, any port and any image type; and a held-down D key
downloaded on repeat.

The catalogue

A search bar that understands what you type (s02e04, @member, a TMDb link, a
resolution), a rail of counted facets, works before releases, and an empty state
that proposes rather than apologises. A work card opens on its own header with a
chevron, and inside, seasons and episodes fold down to the releases — the scope
badge used to be the only way in. Every release carries its age, with the exact
date on hover.

Behind it: relevance ranking for text searches, work titles behind a
did-you-mean, saved searches that match what the page filters, a record of
searches that found nothing, category merge, per-instance catalogue defaults,
and a hover menu on Torrents with shortcuts, recent searches and the
category tree.

Fixes that were not cosmetic

  • The abuse counter is per address, and the web container has one for the
    whole site
    : 25 page views in 10 seconds blacklisted the instance for
    everyone. Server-side rendering is now recognised by a discriminant an
    Internet client cannot forge, and only the coarse counter is relaxed.
  • anonymous_uploads was honoured by the detail page and the feeds but by
    none of the new catalogue paths
    : the listing returned the name,
    ?uploader=<name> listed an anonymous member's whole history, and a saved
    search on that name notified every one of their uploads.
  • "frieren" found one release out of nine: a dotted release name was a
    single token for the text-search parser, and the fuzzy fallback hid the hole
    whenever the exact search returned zero.
  • backdrop-filter written before its -webkit twin was dropped by the
    minifier
    : five glass surfaces across the site had no blur in Chrome, with a
    correct source and a stripped stylesheet.
  • Unbounded work: tagGroups fanned out to one query per group on a pool of
    ten, search_misses grew a row per distinct empty search, and both the
    listing and the facets ran without a rate limit.
  • Visibility: the did-you-mean read work titles with no veil, and two per-hash
    routes answered for a torrent the member cannot see.
  • Category merge failed with a 500 on its nominal case, and could make an adult
    category's torrents visible to members who opted out.

Verified

nuxt typecheck and the API tsc clean; 558 API and 948 web unit tests; schema
parity between schema.ts and the 77 migrations; the e2e suite (13 scenarios,
543 checks) on images built from this branch; and a browser pass at 1280 and
390, dark and light, with no horizontal scroll, no truncated text, no dangling
aria-controls and no failed asset.

🤖 Generated with Claude Code

Dim145 and others added 28 commits September 4, 2026 22:03
The compose header said it plainly — "no tracker (nothing here announces)" —
and no scenario ever noticed, because no scenario asserts on a swarm. But on
a kept stack the consequence is visible to the naked eye: every page that
shows peers showed none, the decision card on a torrent page rendered its
zero-seeder state forever, and that was the only state anyone had ever looked
at. A swarm display cannot be judged without a swarm.

The tracker is now a service on port 54200. `demoTorrents.mjs` and
`demoSwarm.mjs` announce to it with each account's REAL passkey, so what a
page displays comes from the real path: announce, Redis, peers.

- `demoTorrents.mjs` deposits nineteen releases chosen to light up one piece
  of interface each: a true cross-seed (same file list, different piece
  length, so the same content signature under a different info hash), four
  editions of one film under one tmdbId, a supersession, a freeleech with an
  expiry, a pin, comments, a favourite, a seed obligation, an adult fixture
  and a pending one. Plus nine releases of one series that cover the three
  scopes the group endpoint distinguishes — four versions of episode 9, two
  of episode 10, two season packs and an integral. `season` and `episode` are
  not form fields: they are parsed from the release name, so the names are
  scrupulously conventional.
- `demoSwarm.mjs` gives a swarm to a catalogue that arrived by another route —
  a restore, an import. Each swarm's shape is derived from the infohash, so a
  second run re-announces the same peers instead of doubling them.
- `import-dump.sh` fills the stack from an operator's own pg_dump. Hand-made
  fixtures are clean by construction, which is exactly what a layout is not
  tested by; a real catalogue carries 3 KiB descriptions with dead images in
  them, 50 KiB NFOs, and TMDb ids entered as URLs. It never imports the
  `users` table — that one carries auth_verifier, passkey, totp_secret and
  panic_password_hash — so uploader and author ids are remapped onto the
  harness accounts. Column names are read from the dump's own COPY headers and
  checked against the running database, so an older dump either applies or
  stops.
- `tmdb.env`, read by the api service when it exists and written by `run.sh`
  from the repository's `.env`. A file rather than `${TMDB_API_KEY}` in
  `environment:`, because a shell substitution is lost: `docker compose up -d
  web` from a terminal without the variable re-evaluates every service, sees
  `api` change, and recreates it without the key. Silently.

Three bugs found while testing the importer itself, all of which looked like
success: `docker compose exec -T` ate the check loop's stdin so eight tables
of nine were verified, a missing trailing newline dropped the last line of the
column manifest — which pg_dump orders alphabetically, so the lost one was
`torrents` — and a non-greedy capture over an EMPTY COPY block swallowed the
following table's rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page had grown to 3578 lines in one file — 812 of script, 1855 of style —
and nothing in it was testable: every permission, every derived label, every
request lived inside the component that displayed them. It also made SEVEN
awaits at setup level, under a comment claiming the side requests were done
that way "so a slow signature lookup never blocks the page render". The
opposite was true: the first byte left after seven round trips.

It is now 411 lines of page plus 21 components, one composable, three utils
and a guard test. One request blocks the server render — the detail, without
which there is no page — and the rest are lazy.

Four features were already built end to end with no way in from the site, and
now have one: comments (route, POST, notification, push and e-mail, and no web
caller), the quality chips, the group page at `/torrents/group/{key}`, and the
seed obligation in `hnr_tracking`. The last one needed a route, so
`GET /api/torrents/:hash/my-obligation` is new: it answers `null` rather than
404 when there is no row, because "this member never took this torrent" is an
answer, not a failure.

VERSIONS

`/api/torrents/group` has distinguished episode, season and integral scopes
for a long time; the detail page never asked. It does now, and it names the
unit — "Épisode 09 · Saison 01" — before listing the versions of THAT unit.

The old table ranked by size into Light / Balanced / Max picture, which is a
lie twice over. Across units it compared two seasons as if one were a better
encode of the other. Within a unit it crowned the biggest file: measured on
the catalogue, a 40 GiB WEB-DL took "Max picture" over the 20.9 GiB BluRay
that actually has the better image. The markers are now properties, not a
ranking — Max picture on resolution then HDR then source, Compat for "plays
anywhere", Light only when the gap is real — and a marker that separates
nothing stays silent.

RESTORED

The rebuild dropped four things the old page carried, three of them reported:
"uploaded by" in its three distinct states (member, anonymous upload, deleted
account — a concealed uploader and a deleted one both arrive as a null
uploader, and only `uploaderAnonymous` tells them apart), the full infohash
with a copy button, the IMDb / TMDb / TVDB / IGDB / Open Library links, and
the torrent's own tags, which no longer appeared anywhere.

FIXED ALONG THE WAY

- The composable was `async`, so its first `await` lost the Nuxt context and
  every page returned 500 (`NUXT_E1001`) — server-side only. The typecheck
  returned 0 and 861 tests passed, because the client has a global nuxtApp
  fallback. Vue's compiler wraps top-level awaits in a `<script setup>`; a
  `.ts` file has no such net.
- The DecisionCard never rendered the `cta` slot the page passed it. Vue says
  nothing about a slot given and never used, so above 1280px the page offered
  NO way to take the torrent — the only other download button is the dock's,
  hidden at that width.
- The description and the comments were wrapped in `<ClientOnly>`, which meant
  a BBCode comment went over the wire with its brackets showing, and the
  server's placeholder was replaced by different markup on hydration. DOMPurify
  runs under Node here; the wrappers were never needed.
- The poster was fetched client-side, so the server always wrote "no poster"
  and the client always replaced it — a hydration mismatch on every page whose
  work is known. The lookup is awaited at the page's own top level now, where
  the compiler restores the context; it serves from Redis, so only the first
  visit to a work pays a round trip.
- `groupKey` stripped the `movie/` or `tv/` prefix from the TMDb id. The
  server's key contains it, the group route is a catch-all precisely so the
  slash survives the URL, and the season split tests `tmdb_id LIKE 'tv/%'`.
  Every series whose id was entered as a URL — which the upload form
  encourages — resolved to nothing.
- The favourite button called an i18n key that exists in no locale, and read
  its initial state from `torrent.favorited` where the route projects
  `viewerFavorited`. The star came back empty on reload and the next click
  re-added what was already there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`/torrents?tag=2160p&v=grouped` returned the whole catalogue — books, music,
games, 1080p releases — while the same filter on the flat view returned the six
torrents that actually carry the tag.

The defect was on both sides of the wire, which is why neither half alone would
have fixed it:

- `groups.get.ts` never declared `tag` in its Zod schema, and Zod strips what it
  does not declare. The parameter arrived and was dropped without an error, a
  warning, or a log line. A filter that is absent is worse than one that is
  broken: nothing points at it.
- The listing page did not send `tag` on its grouped query either. It passed
  search, categoryId, sources, page, limit, sortBy and order.

The predicate now lives once, in `utils/tags.ts`, rather than being copied into
the second caller — two copies of "carries every one of these tags" would
diverge, and the first divergence is what this commit is about. Its behaviour is
unchanged: an unknown slug yields an empty result rather than widening to
"ignore the filter", because no torrent can carry a tag that does not exist.

The federation mirror is excluded whenever a tag filter is asked for.
`remote_torrents` does carry a `tags` column, but as `jsonb` in the partner's own
vocabulary; the table is empty on the test stack and nothing in the ingestion
path pins its shape, so writing a `jsonb` predicate blind would reproduce exactly
the defect being fixed — rows crossing a filter without being evaluated. An
absent mirror beats an unfiltered one. The flat listing does not federate at all,
so both views now agree when a tag is in play.

Measured after: 6 groups for `?tag=2160p`, the same count the flat view returns,
and 0 for a slug that does not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A design pass on the detail page, measured against the rest of the site rather
than judged alone. The page was coherent with itself and with nothing else.

WHAT THE MEASUREMENT SAID

The site's own vocabulary, counted across 71 pages: `.btn` on 36, `.eyebrow` on
34, `.card` on 21. And `me.vue` — the most worked-on page — already had the
recipe this one was approximating: `§` mark, italic display title in
`clamp(1.15rem, 2.2vw, 1.55rem)`, a rule tinted by `--section-tint`.

Against that, the detail page:

- Rendered section headings in TWO typographic systems on one screen. "Versions"
  at 25.6px italic display, "NFO" and "Pistes audio" at 19.2px upright Inter —
  peers of the same rank, and no rule saying which applied when. `compact` was
  switching typeface, not size.
- Stacked eleven panels carrying FOUR radii (0, 6, 8, 12px plus an asymmetric
  pair), five of them with no surface at all and five with one.
- Sized its `h1` at 21px while its own section headings reached 25.6 and the
  index that leads to it uses 29.6. The deepest page in the hierarchy had the
  smallest title, dominated by its own subheadings.
- Drew 30 small-caps labels using five sizes, four weights, nine colours and
  THIRTEEN letter-spacings — 0.025em, 0.06, 0.07, 0.08, 0.09, 0.10, 0.11, 0.12,
  0.14, 0.16, 0.18, 0.20, 0.22. Nobody decided that 0.11 and 0.12 were different
  things; that is thirty decisions taken thirty times, whose sum has no rule.

WHAT CHANGED

One heading language (compact varies size, never typeface), one shell for every
top-level block (`me.vue`'s `.panel` recipe), a title that outranks its own
sections, and a label scale of three sizes, one weight and two trackings —
declared on the page rather than in `main.css`, because these are layout values
and `themeTokens.test.ts` reads that file as the theme's source of truth.

Two alignment defects fell out of the same reading. The NFO panel was the only
block with no padding, its heading one pixel from the border, and it kept its
heading's bottom margin while its content was collapsed — 54px of panel for 34px
of heading, all the slack below. And the bonus row aligned its columns on their
BOTTOM edge, so the one field carrying help text pushed its label 24px above its
neighbours; the `padding-bottom: 0.55rem` on the checkbox was a hand-cut
compensation for exactly that.

FIVE DEFECTS, AND TWO NON-DEFECTS

- The download button was the 34th and last tab stop: the dock must be the last
  child for `position: sticky` to work, and below 1280px it was the only CTA.
  The inline one now shows at every width and the dock's copy is a pointer-only
  duplicate. 34th to 15th.
- `<html lang>` was EMPTY on a bilingual site — WCAG 3.1.1, level A. Set in
  `app.head` (the static build never runs `useHead` in `app.vue` — the same trap
  this repo has hit twice) and refined to the real BCP-47 tag at runtime.
- The poster carried `loading="lazy"` above the fold.
- Ten pointer targets sat under the 24×24 of WCAG 2.5.8.
- Three tables had no `<caption>`.

Two suspicions did not survive checking, and are recorded so nobody re-opens
them: the poster's box was already reserved by `aspect-ratio`, so there was no
layout shift; and the two checkboxes measured at 18 and 13px are wrapped in
labels of 468×44 and 188×28, so their real target always passed.

MADE HANDSOMER, TOO

`backdropUrl` had been arriving in the TMDb payload since forever and was
rendered nowhere. It becomes a wash behind the poster, masked against the poster
column so text keeps its ground — worst case measured at 12.27:1 in dark and
15.17:1 in light, against 18.42 and 21.00 on the bare surface. The poster stops
being a 72px thumbnail, the decision card is the only panel lifted off the
stack, and the blocks arrive staggered by 40ms through `--motion-scale`, which
`main.css` already zeroes under `prefers-reduced-motion`.

THE CHIPS ABSORB THE TAGS THEY ALREADY SAY

Measured on 49 torrents: 149 tags, of which 107 (72%) repeated a chip word for
word, and most of the rest were the same fact under another name — `x264` nine
times while the chip read `AVC`. The only real addition was the audio codec, and
it was not missing from the parser: `2.0` sat before `AAC` in one list and the
first match took the slot. Format and channels now compose into `AAC 2.0`, the
chips link to the filtered catalogue, and the tag row keeps only what no chip
says — so it disappears entirely on most releases.

The equivalence table behind that is deliberately timid: `x264/h264/xvid → AVC`
and `x265/h265 → HEVC`, nothing else. `MULTI` and `VOSTFR` are NOT folded
together — several audio tracks is not a subtitled track — and 13 tests hold that
line, including the one asserting `2.0` is never split into the atoms `2` and
`0`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… with 381px of nothing to its right

The "tout afficher (Ctrl+F)" control had a row to itself, 468x44px for a box
and two words, planted at rank 6 of the page's 13 blocks. Directly above it,
the "back to index" link occupied 119x24 and left the rest of its line empty.

Both are commands of the PAGE — as opposed to the commands of the torrent,
which live in the decision card — so they now share one `.page-bar` row:
link left, toggle flush right. `flex-wrap` because the label is translated and
a wordier language must push the control onto a second line rather than out of
the frame. Measured: one line at 1208px wide and 32 tall, wrapping below ~460px
with no horizontal scroll.

The pill drops from 2.75rem to 2rem. It now sits beside a 24px link, where a
44px capsule outweighed the page; 32px still clears the 24 of WCAG 2.5.8, and
the target is the whole `<label>`, not the 17.6px box.

The confirmation sentence used to appear under the box once checked. In a
single-line bar it would cost more height than the control itself, and what it
announces — every section is open — is what you see when you look at the page.
It stays in the document as `sr-only`, tied to the box by `aria-describedby`,
for whoever does not get that glance. The description is dropped when the box
is unchecked: the unchecked state has nothing to describe.

`useExpandAll()` is a `useState` defaulting to false, so the conditional
attribute renders identically on both sides — no hydration mismatch. Verified
in the served HTML, not only in the DOM.

Also removes a Python format-string artefact left in the `.back-link` comment
by an earlier automated edit ("Mesuré à lien de retour, 20 px avant").

893 web tests green, `nuxt typecheck` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the infohash kept an empty column beside it

The identity card was a two-column grid: poster left, everything else right.
That column stays open for the full height of the card, so at 540px the poster
stopped at y=254 while the release name and the infohash — 141px of content —
still started at x=114.6, with 66px of nothing to their left. The card was
338.9px tall for it.

The poster now floats. What sits at its height goes beside it; what comes
after takes the full width. No breakpoint to write, because the handover
depends on the real height of the content, which varies with whether TMDb
answered, whether there is an original title, four genres or none. Same page,
same width: both blocks now start at x=49, flush with the poster, and the card
is 319.2px tall.

The rule to know before touching this file: a child that establishes a block
formatting context (`flex`, `grid`, non-visible `overflow`) never overlaps a
float — it is placed entirely beside it, for its whole height. That is exactly
what the BORDERED blocks need; without it the release-name box would draw its
border under the poster while its text avoided it. It is also why `.idc-meta`
stopped being a flex container: it runs to two lines as soon as there are four
genres, and the poster often ends between them. In inline flow each LINE
shortens or not according to what faces it. Vue strips the whitespace text
nodes between tags, so the separators carry the spacing themselves.

The release name and the infohash stay one unit: they move down together or
not at all. This file already argues they designate the same release; letting
them align on different left edges would have produced a staircase.

Also single-sources the poster width. It was copied in three places — the grid
track, the image, the backdrop mask — and the narrow breakpoint corrected only
two: the wash stayed anchored at 4.5rem while the poster dropped to 3.5, so it
spread under text it was meant to clear. Worst-case contrast for the labels
over the wash measures 4.75:1 in dark (`--fg-muted`, 14% opacity, a white
backdrop pixel), above the threshold.

Checked at 380/540/700/768/1100/1600: no bordered box and no text line
overlaps the poster, no horizontal overflow. Both themes. The no-poster
placeholder floats the same way. 893 web tests green, `nuxt typecheck` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… could shrink to, so the tag list dropped under its own label

Two defects, one cause.

The visible one: below 768px every `.prov-cell` was forced into a column, so
"@Donator" — one word — sat under "UPLOADÉ PAR" with 228px of nothing to its
right at 390px. The comment justifying it ("a key and its value side by side
leave the value two words per line") was true of the tags, never of the
uploader.

Restricting that rule to the tags cell only moved the defect: a stacked cell
still shared the row with the uploader, so "TAGS" rose to the username's line
and the pills hung below it, indented. Same shape, different width.

The cause is one line of flex layout. A wrapping container decides to break on
the HYPOTHETICAL main size of its items, not on what they could shrink to.
With an auto basis the pill list offers its max-content width — every pill end
to end — so it does not fit beside "TAGS" and wraps. It then restarts at the
CELL's left edge, which is under the word "TAGS" itself, leaving the gutter to
its left. Observed with three real tags at 501px, reproduced with twelve at
768.

`flex: 1 1 0` on the tags value: the list asks for nothing, forces no break,
takes what is left and wraps INSIDE. The label stays level with the first row
of pills and the rest align under them — a hanging indent, which is what a
definition list should do. The breakpoint goes with it; it had nothing left to
correct. `.prov-cell` keeps `flex-wrap: wrap` so a genuinely oversized value
still drops to its own line: a 39-character username at 390px wraps and stays
inside the ribbon (right edge 340 against 358), no horizontal scroll.

Measured at 390/501/640/767/768/1024/1440 with three tags and with twelve: the
pill block never lands under its label, "TAGS" is always level with the first
pill, and the gutter is a constant 32.5px — the label plus its gap. At 501px,
the width of the report, the ribbon goes from 92.2px tall to 44.3 on a single
line.

893 web tests green, `nuxt typecheck` clean, verified in the CSS actually
served to the browser rather than in the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rivacy link was a 15px target on 49 routes out of 63

A measured pass over the whole site, not an opinion: a harness loads each of
the 63 routes in an iframe of a fixed width and records, inside that document,
horizontal overflow, boxes crushed to zero, pointer targets under 24px (WCAG
2.5.8) and text under 10px.

## Horizontal overflow at 375px: four pages, now none

  /mod/pending    179px   segmented control, inline-flex with no wrap: it
                          asked for 517px inside a 309px card
  /stats           65px   minmax(26rem, 1fr) — the track keeps its 416px
  /admin/invites   58px   the same segmented control
  /me               6px   flex-shrink: 0 on a translated label pushed the
                          chevron 26px outside its own button

Three systemic causes behind them, not four incidents:

1. Twenty-four grid tracks that cannot collapse. `repeat(auto-fit, minmax(N,
   1fr))` does NOT go below N — the track keeps its width and pushes the page
   out. The safe idiom, `minmax(min(100%, N), 1fr)`, was ALREADY used five
   times in this repository (Roles, templates, forum); the other twenty-four
   never got it. Applied everywhere, so none of these grids can overflow again
   whatever the viewport or the operator's `--ui-scale`.

2. Seven full-bleed backdrops on `width: 100vw`. `100vw` COUNTS the scrollbar,
   so the backdrop was 15px wider than the visual viewport. They are already
   `position: fixed`, where `left: 0; right: 0` is exact by construction —
   plus `overflow: hidden`, so the 520px blur blobs stop escaping.

3. Three segmented controls (`.segments`, `.ledger-segments`,
   `.queue-segments`) that were `inline-flex` with no `flex-wrap`.

## Pointer targets: from about 200 under 24px to 33

The largest single item was the footer's "retention register" link — 168x15px
on 49 of the 63 routes — while its immediate neighbour, the globe icon,
already carries `min-h-[1.5rem]`. The rule was known; it just never reached
the text link. Then a family of seventeen row-link classes, all between 14 and
22px: `.st-rank-name` x20, `.user-cell-name` x10, the torznab name links x11,
`.mc-hot-name`, `.log-target`, `.au-actor-name`, `.ac-case-target`,
`.room-msg-author`, `.article-crumb-link`, `.tq-who`, `.urt-switch`,
`.media-card-link`, `.back-link`, `.kpi-sub`, `.queue-link`, `.queue-name`,
`.profile-back`.

Two treatments, deliberately: `min-height` where the box is already a flex,
and vertical PADDING where the rule truncates with an ellipsis — a min-height
there would have shoved the text to the top of its line box while the row
around it stayed centred.

`/torrents/upload` and `/templates` write `class="back-link"` without ever
defining it: they render the global rule in `upload-form.css`, so that is
where their fix went.

## Method, twice corrected

A fixed 500ms settle was reading a render still in flight — it produced as
many false positives as false negatives; replaced by waiting for the DOM to
stop changing. And one whole pass measured the login page without saying so,
because ~130 rapid loads had put the web container on the API's own DDoS
blacklist. Every number here comes from after both corrections.

Verified on 30 routes at 375px: zero horizontal overflow. `/admin/users`
checked at 1440px so the added padding did not disturb the desktop tables.
893 web tests green, `nuxt typecheck` clean, md5 cross-checked host vs
container on all 36 files before each build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion walked out of the page — 78px of sideways scroll at 320

Same measured method as the site-wide pass, aimed at one page: six data
shapes (full metadata, no poster, NFO + comments, long description, awaiting
moderation, adult gate) across seven widths from 320 to 1920.

## Horizontal overflow: 78px at 320, 23px at 375, now zero

One cause behind three symptoms. `.section-head` was `display: flex` with no
wrap, so the action at the end of the line left the head instead of dropping
below it: "Toutes les releases →" is 158px wide, the head offers 269 once the
title is placed, and the link reached x=390 inside a 367px viewport. The NFO
hint (125px) and the swarm tag (126px) did the same. Above 430px none of it
showed.

`flex-wrap: wrap` cannot change a layout where everything already fits on one
line, so this is safe for the other pages that share the component.

## Two incoherences, settled against this codebase's own vocabulary

The versions table broke release names mid-token — `MU`/`LTi`, `VO`/`STFR` —
because below 768px the column switches to `overflow-wrap: anywhere`. The
identity card, 500px higher on the same page, has solved exactly this from the
start with a `<wbr>` after every `.`, `_` and `-`, and its docblock names the
failure. And this table is the one place on the page where release names are
COMPARED, which is where an unreadable break costs most. Same treatment
applied; `overflow-wrap: anywhere` stays as the last resort for a segment that
still does not fit.

The unit bar split a term from its definition when it wrapped: at 390px,
"ÉPISODE  Épisode 09  SAISON" on the first line and "Saison 01" orphaned on
the second. Each pair now sits in its own `<div>` — a valid child of `<dl>`,
and the element that groups a term with its definition. Three ragged lines
become two clean ones.

## Pointer targets: eight under 24px, now none

`.cm-who`, the comment author link, measured 19px at EVERY width — not a
layout accident, an omission. `.buffs-toggle`'s checkbox is 13x13 and its
`<label>` hugged the text instead of carrying the target; it also gains the
pointer cursor it always implied.

Verified on all 42 combinations (6 shapes x 7 widths): no horizontal overflow,
no pointer target under 24px. Left alone and reported instead: `.idc-poster-note`
renders at 9px — the "no poster" caption inside a 56px tile, a deliberate size
documented in the component and doubled by an `aria-label`.

893 web tests green, `nuxt typecheck` clean, md5 cross-checked host vs
container before each build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… one for the whole site — 25 page views in 10 seconds blacklisted the instance

Measured on the e2e stack in production trust mode (TRUST_PROXY=true): a
browser call routed through Caddy to /api/* is counted against the member's
IP, as intended. A server-side render is not. Its API calls arrive from the
web container with no forwarded-client header, so they land on that
container's own address — one counter shared by every visitor. Three anonymous
renders of /privacy put 12 requests on it: four per page view. With
DDOS_THRESHOLD at 100 requests per 10 seconds, about twenty-five page views in
ten seconds blacklisted the whole instance for five minutes, doubling on each
repeat up to a day, and the only log line was "Blocked blacklisted IP".

The discriminator already existed; the web proxy produced it without knowing.
A request it RELAYS for a browser has an observed peer, so it sets
x-forwarded-for. A request the renderer makes for itself has no peer, so no
forwarding header is set at all. Both halves were measured: the container's
counter only ever grew from the second population, the peer's from the first.

`isInternalOrigin(event)`: private socket peer AND no forwarded-client header.
Neither condition can be forged from the internet — a socket address is not
chosen, and Caddy sets X-Forwarded-For on everything it relays, so a browser
request never arrives without one. Adding a header can only make a request
count MORE. The filter reads the socket peer, never the resolved IP, so
TRUST_PROXY cannot turn a header into a private address; a test pins that.

Exempted: the coarse counter and the blacklist it feeds. Not exempted: the
user-agent filter, path and parameter validation, IP bans, per-route limits,
authentication. What remains is a network neighbour reaching the API with no
header — it still meets everything else, and someone already inside the
network has better levers than this one.

After the fix, ten server-side renders create no counter for the web
container; a relayed browser call and a direct call behind Caddy are still
counted, the latter under the member's own IP. The web proxy carries a note:
the ABSENCE of a fallback x-forwarded-for is now a contract, and adding one
would put every render back on the shared counter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… swarm health and what the download costs

The page was a stack of thirteen panels of equal weight; the eye had nowhere
to land, and the one decision — which version, then download — sat fifth,
then in a dock. Three moves, in the order they were proposed.

## The hero

The TMDb backdrop, in the payload since the beginning and painted at 14%
inside a card, becomes a band flush with the header that bleeds past the page
frame — through negative margins equal to what <main> and the page add, no
`vw`, so no scrollbar counted twice (the defect behind seven full-bleed
backdrops elsewhere). The poster overlaps its lower edge; the work title moves
to Fraunces italic, the face of this site's page titles and section heads; the
synopsis, also in the payload and never rendered, gets two lines. The identity
strip (release name, infohash, quality chips) follows at full width, with the
uploader at the end of the chip line — a fact about this release, not a card
of its own. The command bar floats over the band as two translucent pills: a
row above it left 53px of bare ground between header and backdrop.

## Two columns from 1024px

Reading on the left: versions first (the central question moves from rank 6
to 1), note, tracks, NFO, comments. Decision on the right, pinned: download,
swarm health, figures, cost, obligation, then actions and an in-page table of
contents. The decision column comes FIRST in the document, so on one column it
follows the band where the decision card used to be, and the download control
keeps its early tab stop. The dock disappears at 1024px: the pinned card never
leaves the screen, and two copies of one gesture is a duplicate.

Two regressions of the new width were found by measurement and fixed. The
versions table read its breakpoints off the VIEWPORT and, in an 836px column
at 1280px, laid out eleven columns and broke every name over six lines; it now
queries its own width. And the column tracks were implicit `auto`, sized by
the widest child — a moderation banner whose text fits one 885px line imposed
1025px on a 580px column at 1024px; `minmax(0, 1fr)` bounds them.

The pinned column also had to FIT a 900px-tall screen without its own
scrollbar. Four stat tiles stacked two by two were 310px for three numbers and
a size the button already stated; they become one three-cell strip. Actions
become a wrapping row instead of one button per line. Provenance leaves the
column. The card goes from about 600px to 385px, the column from 1150px with
a scrollbar to 733px without; the table of contents withdraws under 820px of
height, since a table of contents you must scroll to see is not one.

## What the download costs

Ratio before → after, with the download multiplier applied — the figure that
actually decides a download on a private tracker, and one nobody displayed:
size, own counter and current bonus had to be known and worked out by hand.
Everything was already on the page. "Unchanged" is judged on what is SHOWN:
1.4 GiB against 290 GiB received moves 1.4157 to 1.4090, both written "1,42",
and an arrow between two identical numbers read as a bug.

A track summary heads the tracks section: the video, every audio track, every
subtitle, with language, format, channels, bit rate and the default flag.
"Is there a French subtitle?" used to mean thirty lines of MediaInfo or two
folded tables; the tables stay below, folded, as sub-sections.

## Swarm health (front + back)

`torrent_stats_history`, one row per torrent per day, keyed (info_hash, day).
`torrent_stats` is a snapshot rewritten on every pass; it says how many
sources there are now, never whether there were more yesterday, and that trend
is what answers "will it come down?". The collector writes it at the end of a
COMPLETE pass — read back from torrent_stats after the zeroing sweep, so
swarms that die are recorded too — and prunes past thirty days. A truncated
scan saw an arbitrary subset; a missing day beats a false one.

`GET /api/torrents/:hash/stats-history` serves the last seven points to
members. `SwarmHealth` shows the state in a word (healthy from five sources,
fragile, no source — the word doubles the colour), the last announce from the
anonymised peers already in the payload, and the seven-day curve whose last
point is the live value. With one day of history it says so instead of
drawing a line through one point.

Verified on 36 combinations — six data shapes (full metadata, no poster,
NFO + comments, long description, awaiting moderation, adult gate) at 320,
375, 768, 1024, 1280 and 1440 — no horizontal overflow, no pointer target
under 24px, the right column count at every width, both themes, no hydration
warning. On the stack: the migration applies at boot, the first pass wrote 48
history rows. 895 web tests, 545 API tests, `nuxt typecheck` clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…placeholder tile, a plain band and the release name twice

A review pass over twelve data shapes (series episode, film, lone film,
superseded, awaiting moderation, adult gate, long description, a 104-character
name, music, game, book, an oddly titled series) at six widths from 320 to
1440, in both themes.

## The common case was the broken one

Music, games, books — everything TMDb does not know — is the COMMON case, and
the hero treated it as the exception: a 192x288 "no poster" tile, a plain
192px band, and the raw release name as a two-line display title, repeated
verbatim in the identity strip 40px below. About 480px of hero for zero
information; on a phone the title ran to four lines and rose under the two
floating command pills.

Now a "plain" mode: no tile (a placeholder only makes sense where a poster was
expected), the band shrinks to a 7–9rem threshold, and the title is what the
parser extracts from the name — "Radiohead - In Rainbows", "Frank Herbert -
Le Cycle de Dune" — used only when it RECOGNISED something (a kind or a year).
Otherwise nothing: the strip carries the h1, the page is never without one,
and that is what saves "Baldur's Gate 3", whose version the parser mangles to
"v4 1 1 4667176". On phones the plain band is 8.5rem: the pills wrap to two
lines and reach 165px, a 7rem band put the title at 161. Measured, then
measured again: 185.

## Two pages scrolled sideways

Re:ZERO at 375px scrolled 322px: a 104-character token in the uploader's note
with `overflow-wrap: normal` — the paragraph kept its width, its text did not.
The superseded Dune scrolled 18px: the sentence quoting the replacing release
name had no break point either. `overflow-wrap: anywhere` on both.

## Less repetition, less wall

The detailed track tables now render only when the summary had to truncate
(more than four tracks of a kind): on a FLAC album, "Tracks (1)" followed by a
folded "Audio tracks (1)" said the same line twice. Notes over 1,200
characters fold to 26rem with a fade and a "show more" button — decided on
LENGTH at server render, not on height after mount, or the section would
shrink after paint and everything below would jump; the "show all" toggle
unfolds it too. "0 per leecher" under zero sources no longer renders.

Verified after the fixes on the same 72 combinations: no horizontal overflow
from the page, no pointer target under 24px, no title under the pills. Two
things the review surfaced that are not the page: the bogus
`http://ReZERO.Starting.Life…` link is in the IMPORTED text (the source
tracker's converter linkified a dotted name; our renderer does not autolink
bare domains), and Sicario showing Nightcrawler is a wrong tmdb_id in the
fixture — which does expose the lack of a "wrong match" affordance. The site
header overflows 20px at exactly 768px on every page now that the test account
carries real figures; left alone by request.

895 web tests green, `nuxt typecheck` clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e API but exposed by no interface — the 24h on every torrent page was a code default

`hnr_enabled`, `hnr_required_seed_time` and `hnr_grace_period` exist as
settings: the Go tracker reads them (`cache.go`, `KeyHnr*`), the API freezes
the threshold onto every `hnr_tracking` row it creates, and the torrent page
announces it — "commits you to 24 h of seeding". Nothing let an operator set
them. The 24 h came from the fallback in `getHnrRequiredSeedTime()`, the only
way to change it was SQL, and doc/guide/hit-and-run.md said "configurable from
/admin/settings".

A "Hit & Run" card on the settings page: tracking on/off, seed threshold and
grace period in HOURS — nobody sets a seeding requirement in seconds, and
86400 does not read as a day — converted on the server, validated by Zod and
bounded to a year, read and written through the existing settings route. The
inputs are disabled while tracking is off, since nothing is created then.

Round trip on the stack: 48 h written, a never-downloaded torrent's obligation
answers 172800; back to 24 h, 86400.

One real gap on the way. The obligation route returned nothing until a row
existed, so the page only stated the commitment AFTER the click. It now
answers with the current global threshold for a release the member has never
taken — the value that will be frozen if they do — and nothing at all when
tracking is off. The "what it costs you" card finally says it up front.

The threshold is frozen per download at click time; changing it does not
re-grade past downloads. The guide says so now, in hours, with the setting
names and the default-off switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… fold, repeated every track twice and had no way to say "wrong film"

Six additions to the torrent page, then the three faults they surfaced.

## A breadcrumb in the hero

Category / work / unit above the title — "Séries / Frieren: Beyond Journey's
End / Saison 01 · Épisode 09", the unit in the accent colour. The episode was
only stated in the versions table's unit bar, some 700px down: a member opened
the page without knowing WHICH episode they were looking at. The category
links to the filtered catalogue, the work to the group page. Under 40rem the
work drops out of the trail: the Fraunces title repeats it 40px below, and at
390px the three items took three lines (73px) — now one.

The category link exposed a dead one. The catalogue filters on `?c=<id>`, not
`?category=<slug>`; the stats page had linked with the slug since it existed,
so every category there landed on the unfiltered catalogue. Both use the id.

## "Wrong match?" next to the public records

Sicario displayed Nightcrawler's record and a member had no way to say so. A
flat button at the end of the TMDB/IMDb/TVDB pills opens the existing report
flow with a new reason, "wrong work record", preselected: the moderator gets
a report already qualified. `ReportModal` takes a `presetReason` applied on
each opening; the plain "Report" button still opens it blank.

## D and F

`D` clicks the download call to action, `F` toggles the favourite. Never while
typing (input, textarea, editable), never with a modifier — Ctrl+F stays the
browser's search, which is precisely what the "show everything" checkbox
serves. The keys are written in the actions card and declared through
`aria-keyshortcuts`; a shortcut nobody knows is a shortcut nobody uses.

## What differs between versions

On Frieren S01E09 the four versions were all 1080p WEB-DL x264: three columns
identical on four rows, and the one that decides — the language — set like
the others. Each row is now compared to the release shown on four axes
(language, resolution, source, codec). Same values lose their weight and
recede; differing ones gain weight, `--fg-strong` and an underline in the
accent — a cue that is not a colour, named in the legend. An absent value stays
bold but is not underlined: an underlined dash reads as an equals sign. The
release group is a name, not a quality, and is not compared. Without a
"here" row in the table nothing changes.

## The band drifts

The backdrop moves at 30% of the scroll — the only motion on the page, and the
only one with a meaning here: the veil suggests depth, the drift gives it. The
image sits on its own layer, 24% taller than the frame and pinned to the top
of the picture, so that at rest the frame shows exactly what `center 28%`
showed (measured at 1280: image rows 92–476 against 94–478 before) and the
extra 24% is what the drift consumes — bounded to it, so no edge ever shows.
The mask stays on the frame; the veil does not move. A first cut used a 35%
margin positioned at 30%: it showed a lower slice, and Subaru's head left the
frame on Re:ZERO. Under 48rem the layer IS the frame and nothing moves: the
band there is nearly as tall as the image. `prefers-reduced-motion` moves
nothing. Both gates are read on every frame, not once at mount — a window
widened after load gains the drift; the first version tested the width once
and a page loaded narrow never moved.

## Tracks: the summary OR the table

The summary listed four tracks, then a full table rendered underneath; open,
the first four read twice, and closed, two controls — an inert "+16 more" and
a chevron — pointed at the same thing. Measured on Re:ZERO, 20 audio tracks
and 18 subtitles. "+16 more" is now the button: it opens the full table IN
PLACE of the four lines, in the same box, with the count and a "Collapse"
control. Never both. The separate "Audio tracks" and "Subtitles" sections are
gone; "show everything" still opens all.

## The uploader's note opens unfolded

A long note now starts open, and the member sets that default: folding it
folds every page, unfolding it unfolds every page. A cookie rather than
localStorage — the server render reads it, so the page arrives in the chosen
state instead of a note that snaps shut after hydration. Verified: fold,
reload folded server-side, unfold, reload unfolded.

Typecheck clean, 897 tests, md5 host/container identical, checked in the
browser at 390 and 1280 on Frieren, Re:ZERO, Dune, Radiohead and Baldur's Gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… gutter, a lighter track and the hero cut short of the edge, even on a Mac set to overlay bars

`main.css` styled `::-webkit-scrollbar`, `-track` and `-thumb` globally. In
Chromium and WebKit that does far more than colour: any rule on those
pseudo-elements switches the scroller to a "classic" custom bar that takes a
gutter and never overlays — including on macOS with overlay scrollbars, which
would otherwise show no bar at all. Measured on the root: 8px of gutter with
the block, 0 without; the hero backdrop ended at 636 of 644px and now reaches
644. Three components repeated the pattern on their own lists: the
notification list (6px), the icon picker grid (8px), the branding preview
(4px).

The two standard properties, `scrollbar-width` and `scrollbar-color`, do not
have that effect. Where the system floats its bars (macOS by default, iOS,
Android) they float: content underneath, bar on top. Where it fixes them
(Windows, Linux, macOS "always") they are thin, the transparent track shows the
page or the box behind, and the thumb takes the theme's strong line.
`scrollbar-color` inherits, `scrollbar-width` does not — hence `*`. No
`::-webkit-scrollbar` fallback for Safari before 18.2, on purpose: it would
recreate the fault; those keep the native, floating bar.

The two intentional hides of horizontal rails (`.no-scrollbar`, `.cats-row`)
stay — `display: none` on the pseudo-element hides without forcing a visible
classic bar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…" sat over "0 sources", and every small control was 24px under a thumb

An audit of the torrent page on five fixtures (series episode, season with a
long note, film remux, plain music, mismatched film), both themes, 390 and
1280px: contrast computed by compositing the backgrounds, pointer targets,
accessible names, heading outline, overflow, a real Tab walk. No page text
fell under 4.5:1 in either theme, nothing scrolled sideways, focus rings and
order were right. What follows is what did not pass.

## Structure

The uploader's note renders Markdown, and a `# Title` became an `<h1>` under
the section's `<h2>` — on Re:ZERO the note alone contributed h1, h2, h2, h3.
`DescriptionRender` takes a `headingOffset`; the page passes 2, so h1 → h3
and the document keeps a single h1 (measured: H3,H4,H4,H5 in the note now).
Sizes for h4–h6 added so the shifted levels still step down.

## Copy that repeated or contradicted itself

- "Aucune source" over "0 sources": the dead state now shows the last
  announce, or "Personne ne la partage en ce moment."
- "snatchs" → "au total": the tile is the all-time count, that is the fact
  that distinguishes it from the two live ones beside it.
- "Pas encore prise" → "Pas encore téléchargée".
- "ratio inchangé" next to "Il compte en entier dans votre ratio" read as a
  contradiction; a dedicated note says why both are true. On a freeleech
  release the cost block repeated the GRATUIT band's sentence; it now keeps
  only "Sans le bonus : 1,42 → 1,11."
- "Tout afficher (Ctrl+F)" read as the key that toggles the box — all the
  more since D and F are announced in <kbd> below. Now "pour Ctrl+F", with a
  tooltip saying what it is for.
- "0 S" in the versions table (the column head hides under 42rem) becomes the
  decision card's chevron plus a screen-reader word; `seedUnit` is gone, the
  related-releases panel that also read it uses the same glyph.

## Hero facts wrapped on their separators

At 390px on Dune the meta row broke on a dot: "· Science Fiction ·
Adventure ·" orphaned at both ends. The dots are `::before` content inside a
1.1rem left padding, with the row shifted and clipped by the same amount: a
fact that opens a line has its dot in the clipped zone, a fact mid-line shows
it between its neighbours. Verified fact by fact: none at a line start.

## Header contrast and targets (visible on this page, fixed in place)

The version label was 2.45:1 (`text-text-muted/60`); it passes now. The
notification badge used a hard-coded `#f43f5e` under white text: 3.67:1 in
the light theme. With `rgb(var(--danger))` it measures 6.47:1 light, 4.62
dark. The stats refresh button was 20×20 (`p-1` around a 12px icon); 24×24.

## Pointer targets under a thumb

Chips, provider pills, breadcrumb links, "+16 autres", the action buttons
and the copy buttons all sat at 24–28px on a phone. Under `pointer: coarse`
they are 36px, from one `:deep()` rule on the page. The buffs form's native
13×13 checkbox is 16px in the site's accent.

## Screen reader

The copy buttons only changed icon and tooltip; a `role="status"` region now
announces "Copié".

Left as they are, on purpose: the 9px mono labels are a system token already
decided; the English synopsis has no `lang` because the provider's language
is not known; "e:ZERO" without its R is the fixture's own text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y motions are the ones that mean something — the click, the curve, the folds, the numbers, the place

Ten additions from a motion and finish proposal, under one principle: a
movement says either that something just changed or where you are. Every
duration rides the `--dur-*` tokens, and each component also cuts its own
motion under `prefers-reduced-motion` — the `--motion-scale` token is fixed
and does not follow the system preference, so the global kill switch in
`main.css` is the only other guard.

## The click is the one rewarded moment

The arrow becomes a check that pops in, and a ring closes around the button
in 300ms. The page then re-reads the obligation. Not once, 1.2s later — the
first cut did that and measured the row still "not downloaded": the download
route creates the tracking row, but the state only moves at the client's
FIRST announce, when `downloaded` leaves zero. So it re-reads at 20, 45, 80
and 120s and stops as soon as the state moves: four requests at most. On the
stack, the card went from "Pas encore téléchargée" to "En cours" with its
meter when bytes appeared on the row, and the state tint sweeps across it.

## The curve draws itself

`pathLength="1"` on the sparkline, so the stroke animates in CSS with no
measuring: 600ms left to right when the card ENTERS the viewport (an
IntersectionObserver at 40%), the area fades in after, the present-day dot
pops last. Measured mid-course at 9% of the path, then complete. A living
swarm's pip breathes three times after arrival, at low amplitude, then
rests; a dead swarm does not move.

## Folds that open instead of jumping

The tracks table and the four summary lines settle into place after a
gesture — never on first render, gated by an `interacted` flag. The versions
rows crossfade through a `TransitionGroup` when the unit changes, exits
shorter than entries and out of flow. The note folds in height: 3114px, 871
at 60ms, 416 at the end, and back. Two traps on the way: the computed value
of a custom property is the string `calc(200ms * 1)`, which `parseFloat`
cannot read — the duration is read off the transition once set; and the
start value must be COMPUTED before the target or the transition runs from
`none`, which does not animate — reading `offsetHeight` forces it.

## Numbers, and the place

A swarm value that changes slides up (a keyed `<Transition>`) and its tile
tints for half a second. The table of contents' active bar glides between
entries in 180ms, measured from 0 to 30px on a section change, instead of
jumping. And the entrance is confined to the first screen: the backdrop
develops from a 1.03 scale, the poster rises 80ms later, title and identity
strip at 160, the decision card at 200 — done by 400ms. The previous version
animated every section, including those below the fold: motion nobody saw
that delayed what they read. `animation-fill-mode: backwards` on the backdrop
so the drift's own `transform` takes over when the sequence ends.

## The colour of the work

The poster is sampled on a 12×18 canvas: saturation-weighted mean of the
pixels, blacks and whites excluded, then clamped in saturation and lightness
so it is a HUE, never a brightness, and mixes the same on both themes.
Frieren pulls green (70 185 112), Dune orange (211 129 44), Re:ZERO violet
(185 70 160). It touches decorative surfaces only: the top of the veil and
the decision card's border, mixed a third into the gold.

Poster CDNs send no CORS header — measured on TMDB: the image does not load
in `anonymous` mode, and without it the canvas is tainted. `/_media/sample`,
a route on this server, relays the pixel: three known hosts, HTTPS only, no
redirects followed, an `image/*` type, 400 KB and four seconds at most, and
only from our own pages (`Sec-Fetch-Site`). Probed: 200 for TMDB, 400 for a
foreign host, 403 cross-site. Cached a day.

A 6% SVG noise on the veil keeps the gradient from banding on large screens,
and the versions table — the one that decides — gets a little more air.

Not verified: the breathing pip (no fixture with live sources on the stack)
and the reduced-motion path (the browser pane cannot emulate it).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y port and any image type — and a held-down D key downloaded on repeat

A review pass over the seventeen commits of the branch: the server-side
changes read line by line, the composable and the page script, the forms;
semgrep in two passes over the 101 changed files, gitleaks over the commits
(nothing). Semgrep's findings were all known arbitrations or a URL inside a
comment; what follows came from reading.

## `/_media/sample`, the one new server route

It relayed a poster pixel so the canvas could sample it. Six holes, each
closed:

- `Sec-Fetch-Site` was only checked when present, so curl and a typed URL
  went through. Now `same-origin` AND `Sec-Fetch-Dest: image` are REQUIRED;
  a client without them (curl, a navigation, an old Safari) gets 403 and the
  page is simply untinted. Probed: 403 without, 200 with.
- Any `content-type: image/*` was echoed, SVG included. An SVG served from
  our origin and opened as a document runs its scripts as us. Raster types
  only: jpeg, png, webp, gif, avif.
- The body was read whole before the size check, so a chunked response with
  no `content-length` could fill memory first. It is now read in chunks and
  cancelled at the first byte over 400 KB.
- `https://image.tmdb.org:8443/…` and `https://u:p@image.tmdb.org/…` passed
  the host check. Port and credentials are refused (probed: 400).
- The response now carries `Content-Security-Policy: default-src 'none';
  sandbox`, so even a mis-typed asset cannot act as a document.
- Sixty requests a minute per address, so nobody makes this server hammer
  the CDNs; a page asks for one.

## The keyboard

Holding `D` fired a download per key repeat; a composing IME keystroke
counted as a letter; and `D`/`F` kept working with the report or confirm
dialog open. `e.repeat`, `e.isComposing` and an open `[aria-modal]` now
return early. Verified with the report dialog open: zero clicks.

## Two small hardenings on the hero

The backdrop URL went into a CSS variable as `url(…)` unquoted; it is
`url("…")` with quotes and backslashes escaped, so a URL never closes its own
function. The tint sampler now ignores a result that arrives after unmount
or after the poster changed underneath it. The page's meta description
carried raw BBCode; tags and markup punctuation are stripped.

Reviewed and left as is: `isInternalOrigin` (peer socket AND no forwarding
header, not forgeable from outside, four tests), the two new routes (session
required, infohash validated, a member reads only their own row), the
collector's parameterised SQL, the migration's cascade FK and increasing
timestamp, heading demotion applied after DOMPurify, forms relying on server
validation, and the page remounting between torrents so the obligation
polls stop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…for an hour — thirty posters gone for a network hiccup

The three source clients (TMDB, IGDB, Open Library + Google Books) returned
`null` for a 404 and for everything else alike — a timeout, a 5xx, a 429, an
unreadable body. The callers could not tell them apart and wrote the same
negative sentinel with the same TTL: `META_TTL.NEG_S`, one hour. Measured on
the e2e stack: a burst of lookups on the catalogue page hit the 8s timeout,
and every one of them was remembered as "TMDB does not know this id" for an
hour. Thirty pages without a poster, from a network that coughed once.

An absence and an outage are now two different things. The clients throw
`UpstreamUnavailableError` on anything that is neither 200 nor 404 — timeout,
network error, 5xx, 429, 401, unreadable JSON. A shared `guarded()` wraps the
eight functions that write the cache: on that error it stores the empty
sentinel under the same key for `META_TTL.ERR_S` — two minutes, long enough
not to hammer an upstream that is struggling, short enough for the poster to
come back with it — and returns the empty value. A real 404 still costs an
hour, a hit still lasts a day. The registry's three entry points catch the
error as a last resort, so a page renders without a poster rather than
failing with a 500.

Four unit tests with a fake Redis and a stubbed `fetch`: a 404 is kept 3600s,
a `TimeoutError` and a 503 are kept 120s, a hit 86400s. Full API suite: 37
files, 549 tests. On the stack, a known id still answers from cache and a
nonexistent one produces a 3600s empty entry, as Redis shows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…— now a bar that understands what you type, a rail of counted facets, works before releases, and an empty state that proposes

Members type "frieren 1080p x265 vostfr s01": the bar turns the four
qualifiers into typed chips at the first space and keeps the title as text.
Chips are OR within a family (1080p 2160p), AND across families; synonyms
travel with each chip (x265 = hevc = h265) so a slug missing from this
instance's vocabulary never empties the result. A pasted TMDb/IMDb/TVDB
link still becomes the id filter. Chips, options, view, sort and page all
live in the URL (tk/se/ep/y/u/o/v/s/d), so a search is a link.

Under the bar, when it has focus: the works the text designates (poster,
year, release count — chosen with the arrows and Enter, a choice filters
the catalogue on that work), the forms the bar understands, and the six
most recent searches.

Works are the default view: one card per work with poster, title and year
read from the metadata cache (never from upstream — that burst is what put
a whole page without posters for an hour), category, release count, the
resolutions and sources present, the swarm state, and the releases folded
inside by unit (S01 · E09, Season, Complete) with the page's own filters
applied. The card's left rule takes the poster's dominant colour, which the
detail page already computes: it now deposits it (POST /api/metadata/tint,
30 days in Redis) and worksFromCache serves it with the title.

The Releases view rows read in the order the eye asks: work title and
unit, then the file name in mono, the detail page's quality chips, size,
swarm with a named state pip, uploader; badges Free / Replaced / Taken;
↑↓ Enter D F on the keyboard. Density (comfortable/compact) is a cookie.

A sticky rail counts every facet under the current search — category,
resolution, source, codec, language, HDR, audio, a year histogram, and
four member questions (with seeders, free, not taken yet, hide replaced).
Facet rule: a dimension is counted with every filter but its own, and
tagGroups is dropped one group at a time (tagsByGroup) so ticking 1080p
keeps the other resolutions readable. Under 1100px the rail is a bottom
sheet: a floating "Filters (n)" button, a modal with backdrop and focus,
"See n results" to close.

Above the results: saved searches as pills with "n new" since last opened
here (seen_count, migration 0074, POST …/seen), pinned releases as poster
cards, and when nothing matches, what the same search gives without each
criterion — counted with the listing's own exact-then-fuzzy rule, because a
suggestion that promises 9 and opens a page of 1 is worse than none — then
"Create a request" and "Alert me when it lands".

API: the listing's predicates move to utils/torrentListing.ts and are shared
by the flat listing, the grouped view (which had silently dropped the tag
filter once, and now also accepts imdbid/tmdbid/tvdbid) and the new facets
route. New filters: tagGroups, uploader, year, season, episode, minSeeders,
freeleech, notTaken, hideSuperseded, groupKey. Rows carry uploader,
viewerTaken, freeleech and work; groups carry work and tagSlugs; two reads
per page regardless of row count.

Measured on the e2e stack: 923 web tests and 549 API tests green, both
typechecks at 0 errors, pages checked at 1280 and 390 in both themes.
Two traps met and noted: a literal @ in a vue-i18n string breaks server
rendering with the bare message "10"; drizzle renders a column unqualified
inside a SELECT list, so a correlated subquery there read the wrong table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…se name was a single token for the text-search parser, and the fuzzy fallback hid the hole whenever the exact search returned zero

`Sousou.no.Frieren.S01E09.VOSTFR.1080p.WEBRip` is one token for Postgres'
default parser (a host- or file-shaped word), so the prefix query
"frieren:*" matched only the release whose name used spaces. Because the
listing falls back to trigram similarity when the exact search is empty,
the miss showed as a wrong COUNT rather than an empty page — and the
catalogue's "without this criterion" suggestions made it visible.

The indexed vector now replaces dots and underscores with spaces before
to_tsvector, in the same expression on both sides (ftsVector is what
schema.ts emits as the GIN index and what the routes use as predicate);
hyphens stay, the parser already splits them. The saved-search fan-out
compared the new torrent's name with a literal to_tsvector and would have
skipped "frieren" alerts on dotted uploads: it uses the shared vector now.

Migration 0075 drops and recreates the four GIN indexes — generated by
drizzle-kit from the 0074 snapshot, then documented. On the e2e catalogue:
frieren 1 → 9, "sousou no frieren" 0 → 9, "frieren s01e09" 0 → 4.
A name with no separator at all (ReZERO) is still one word; that is the
name, not the parser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ing, work titles behind a did-you-mean, alerts that match what the page filters, search misses, category merge and catalogue settings

- `relevance` sort key: ts_rank_cd on the exact match, word_similarity on the
  fuzzy fallback; groups fall back to age since they have no rank
- `work_titles` (source, id, locale) filled by lookupMetadata and a warmer
  plugin (one lookup per tick, cron lock, done-set in Redis); the facets answer
  `didYouMean` from it with word_similarity >= 0.45
- facets: 20 s Redis cache per user and query, strict total, `statsAt` as a
  real UTC instant, `dropOne` counts computed like the listing (exact, then
  fuzzy), options incl. favorites, hand-qualified `torrents.*` in correlated
  subqueries (drizzle renders selected columns unqualified)
- saved searches carry tagGroups, season, episode, year, uploader; the fanout
  matches them (candidates get season/episode from the release name); `seen`
  aligns seenCount on matchCount
- `search_misses` recorded only for a text-only, page-1, empty search; admin
  routes to list and delete them
- admin: merge a category into another in one transaction (torrents, children,
  saved searches, requests, upload rules, remote map); catalogue settings
  (default view, default sort, page size, visible facets) exposed by branding
- shared listing predicates (`torrentListing.ts`): visibility, filters, search,
  enrichment from `torrent_stats` in one query; `groupKey`/`groupScope` for the
  releases of one work; `firstScope` per group under the requested sort
- me: `catalogueDefaults` (<= 12 keys); metadata tint stored 30 days
- migration 0076 with its snapshot (generated files come out 600: chmod 644)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… and episodes as folding rows, the age on every release; one band for filters and count, a type scale, alerts capped at five

The scope pill was the only way into a work's releases, and a small one.
Now the header itself opens on the first torrent's scope, a chevron at the
end says so, and the pills only choose the cut when there is more than one.
Inside, the structure reads before the files: a block per season, episode
rows with their count, resolutions and seeders, releases under the one you
unfold; the latest season and its first episode start open.

- nested release rows: file name on top (two lines at most), size, swarm and
  age at the right, qualities in one line of value-only chips below; the unit
  chip is gone since the header above names it; the exact publication date on
  hover of every age (rows and cards) — lifted above the stretched title link,
  which swallowed the hover of age, swarm and status dot
- one sticky band: count and total size when no filter, the sources toggle
  among the tools; the separate results line and its duplicate count are gone
- type scale 10·11·12·13·14·16·20·26 px across the page and the six search
  components; card meta in Inter lowercase; 16/24/16/8 vertical rhythm; card
  radius aligned with the detail page; mobile card without the quality ladder
- pinned releases: horizontal eyebrow (the vertical spine was an invalid grid
  the browser dropped whole) and hidden in the works view (their data comes
  with the flat listing, which the works view never reloads)
- rail alerts capped at five with "Manage · n more"; suggestions panel below
  the field; recognised works heading; "load n more" says the real remainder
- stuck state of the bar measured under the site header, not the screen edge;
  the filters sheet gives focus back to its button; aria-controls only when
  the target exists; row titles clamp to two lines instead of an ellipsis
- relevance sort with text, saved defaults on the account, recently viewed in
  the suggestions, version grouping, load more, favorites option, did-you-mean
  pills in the empty state; admin screens for search misses, category merge
  and catalogue settings
- six dead `search.*` keys and a transition's leftover CSS removed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… by the minifier — five glass surfaces had no blur in Chrome

The build merges `backdrop-filter` and `-webkit-backdrop-filter` into one
logical property and keeps the last one written. Where the source listed the
standard form first, only the prefixed one survived, and Chrome does not read
it: `getComputedStyle(el).backdropFilter` was `none` on the home KPIs, the
anti-cheat bulk bar, the upload status pin (three scoped copies) and the
catalogue's sticky bar. Sources that wrote only the standard form came out
with both. The twins are gone; the build adds the prefixes the targets need.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… ways it told a member more than it should

## The menu needed a filter that did not exist

`since=24h|7d|30d` on the listing, the grouped view and the facets, from a
closed table of intervals read with `Object.hasOwn` so no inherited property
can reach `sql.raw`. The facets count the last 24 hours next to the other
options, and `since` joins the criteria the empty state drops one by one.

## Security review

- `users.anonymous_uploads` was honoured by the detail page and the feeds but
  by none of the new catalogue paths: the enriched listing returned the name,
  `?uploader=<name>` listed an anonymous member's whole history, and a saved
  search on that name notified every one of their uploads. `redactUploader`
  now runs in `enrichListing`, the filter resolves the name only for the
  member themself and staff, and the write path answers 400 like an unknown
  member so the two cases stay indistinguishable.
- `tagGroups` was unbounded: one facets request fanned out to one query per
  group, each carrying the others as correlated `EXISTS`, on a pool of ten.
  Capped at eight groups of ten, deduplicated, and both `facets` and the flat
  listing now take `RATE_LIMITS.public` like the grouped view already did.
- `search_misses` grew one row per distinct empty search with no ceiling.
  Thirty per member per hour, five thousand rows total, the least asked and
  oldest pruned first — and `since` counts as a filter, so a window that
  returns nothing no longer claims the catalogue lacks the title.
- `didYouMean` read `work_titles` with no visibility: a suggestion could name
  an adult or unapproved work to a member who cannot open it. It now requires
  at least one release that member can see.
- `stats-history` and `my-obligation` answered for any hash: an existence
  oracle for a pending or withdrawn release. `assertVisibleTorrent` gives them
  the listing's own rule.
- `/api/metadata/tint` accepted any 1–128 character id, so a member could sow
  Redis keys at will. The shape is now checked per source and the work must
  already be in the metadata cache.
- Category merge: the upload-pattern table is keyed on the category, so the
  nominal case (two categories that both carry a pattern) rolled back with a
  500; an adult category merged into an ordinary one made its torrents visible
  to members who opted out; and nothing refused a target that descends from
  the source. Four explicit refusals, ids validated as UUIDs.
- `search-misses` DELETE parsed its body with a bare `.parse`, so `{query:""}`
  escaped as a 500.

## Correctness review

- The grouped view searched name/description/nfo only — no work titles, no
  fuzzy fallback — while the facets counted all three. The band read
  "9 releases · 0 works" over an empty grid. It now shares `searchConditions`.
- That work-title arm was a correlated `EXISTS` inside the `OR`, which cannot
  join a BitmapOr: every text search became a sequential scan with a per-row
  subplan. Two steps instead — the titles first, then `inArray` on the id
  columns — and `EXPLAIN` shows the BitmapOr over both GIN indexes.
- The grouped tag enrichment filtered on `groupKeySql IN (…)`, a CASE
  expression no index serves; it now uses the same member predicates the
  single-group listing uses.
- `age`, `relevance` and the group orders had no unique last key, so equal
  timestamps could repeat or drop a row between appended pages.
- The metadata warmer marked a work done for seven days when the upstream was
  merely unavailable; failures now land in a one-hour retry set.
- Saved-search fanout compared a bare TMDb id against the prefixed form and
  ignored sub-categories, so alerts saved from the work picker never fired.
- The year facet took the FIRST match per name and counted `1920x1080`: a name
  with two years under-counted one of them. One shared pattern for the filter
  and the facet, every occurrence counted, resolutions excluded.

## Comments no longer ship whole

The detail payload carried every comment on every visit. It now carries the
first twenty and the total; `GET /api/torrents/:hash/comments` serves the rest
by cursor, because an `OFFSET` shifts under a comment posted between clicks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… block

## The menu

Hovering "Torrents" in the header opens the catalogue: four shortcuts (all,
today, freeleech, favourites), this browser's recent searches, then every
category family with its sub-categories, each a direct link to the filtered
catalogue. Hover has an intention — 90 ms to open, 220 ms of grace to leave,
an invisible bridge to the panel — and it is never the only way in: the
chevron opens on click and on the keyboard, Escape returns focus to it, and on
a touch screen hover is ignored. The categories are fetched once, on the first
open, and only for a signed-in member.

## The panel that linked the cards

`.card` paints `--bg-surface`, exactly the colour of a work card, so the 8 px
between two cards read as a divider inside one panel rather than as space
between two objects — and its `:hover` brightened the whole thing. The grouped
view no longer wraps anything; the flat view keeps its panel, which holds a
real table.

## Review fixes

- Picking a recent search dropped its text: the route watcher saw the still
  focused input and skipped `q`. The state is now applied at the source.
- "Load more" had no generation guard, so a slow page landed under a newer
  query; the open work cards reloaded on every keystroke and could keep a
  stale response. Both now carry a guard, and the cards get the query the page
  actually fetched with.
- A repeated parameter (`?q=a&q=b`) arrived as an array and threw during
  server-side rendering; every read goes through one coercion.
- A view change coming from the URL no longer resets the page: back from a
  menu link kept sending the reader to page one.
- A rail toggle was not reversible through the URL — unchecking a synonym did
  not survive a reload, because the typed word re-expanded its whole family.
  The URL now carries the exact list.
- An unknown, withdrawn or refused hash rendered a blank page with status 200
  instead of the site's 404. Comment authors linked to a route that does not
  exist. The mobile dock did not restart the obligation polling after a
  download. The "Tracks" entry appeared then vanished on mount. A duration of
  four days and 23 h 50 read "4 d 24 hr". DivX and XviD were folded into AVC,
  so a hand-set tag could be hidden as redundant. A failed Hit & Run save was
  silent.
- The poster relay mapped a timeout during the body read to a 500 with a stack
  trace; the API proxy's header stripping was a no-op under h3, so a browser's
  own `x-real-ip`, `cf-connecting-ip`, `true-client-ip` and `forwarded`
  reached the API.
- Alert pills replayed only half of what an alert now stores, so a search
  saved as "frieren S02" reopened the whole series.
- Rail alerts, facet checkbox ids, the suggestions panel id and the menu's
  error copy: one identifier per control, stable between server and client.

## Comments by pages

Twenty at a time with a cursor, and the table of contents announces the real
total rather than what the page happens to hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ness waited on the wrong signal

Rendered through the same dialect the query builder uses, so the assertions
read the statement Postgres receives: the year pattern and its parameter, the
three `since` windows and the refusal of anything else (including an inherited
property), favourites and taken scoped to the viewer, a group key that stays
indexable, and the tag-group bounds. Plus the saved-search link's newer
criteria, the rail toggle's round trip through the URL, the duration carry at
24 h, and the codecs that must not be folded together.

The e2e tracker waited on `service_started` while the comment above it claimed
it waited for the migrations the API applies at boot; the API image has a
health check, so it now waits for that. And `sed | head -1` under `pipefail`
could abort the whole run on a SIGPIPE — `grep -m1` stops on its own.

`doc/guide/security.md` claimed the server-side rendering exemption applies in
production. Behind the shipped Caddy those calls inherit the visitor's
`X-Forwarded-For`, so they are counted on the visitor's address and the
exemption does not apply; the paragraph now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…four of its own fixes were wrong

A security and a correctness review of `7d60548`/`a1ff305` — the commits that
carried the previous review's fixes — plus semgrep, gitleaks and runtime
probes. Semgrep found nothing new (17 already-arbitrated warnings), gitleaks
nothing. The reviews found this.

## What the last commit message asserted and the code did not do

`rateLimit(event, RATE_LIMITS.public)` on the flat listing and on the facets:
the edit was lost, and `git log -S` confirms the line never existed in either
file. One member could therefore issue the fifty-odd statements of an
empty-state facets request as fast as they liked, bounded only by the coarse
abuse counter, against a pool of ten connections. Measured after the fix: ten
of a hundred and ten requests answer 429.

## Four fixes that were themselves wrong

- The work-title search took `LIMIT 500` with **no `ORDER BY`**: an arbitrary
  five hundred, re-picked per call, so the count in the band and the rows in
  the grid could be built from different sets — the very failure it was meant
  to close. Ordered now, and the cap is named.
- It also invented both TMDb prefixes for every title row, so a series title
  matched the film that shares its number. Only the row's own form is used,
  and the prefixes only when the row carries none.
- The year pattern excluded a lowercase `x` only, so `1920X1080` still filled a
  bogus bucket, and its prefix class silently **lost** a year glued after a
  letter x (`Matrix2019`). Four lookarounds instead, verified against Postgres
  on eleven names.
- The metadata warmer's retry set was marked with `SADD` + `EXPIRE`, and
  `EXPIRE` moves the whole key: with one mark every twenty seconds the set
  never expired and a failed work was never retried. The done-set had the same
  shape. One marker per work now, each with its own lifetime.

## And four more

- Posting a comment after loading older pages dropped one comment from the
  thread and left the button pointing at a cursor that returned nothing.
- The comment cursor was a bare timestamp: two comments written in the same
  transaction (an import shares one `now()`) hid the boundary row forever. It
  now carries the id as well, compared as a row.
- `POST /api/metadata/tint` answered 404 for a work the cache does not hold,
  which answers "does this instance carry work X" to anyone who asks. It
  answers the same either way and writes only what it knows.
- The category-merge ancestry walk stopped after ten hops, and nothing caps
  the tree's depth; a visited set walks the whole chain instead.

## Smaller

The saved-search link emitted one synonym per family, which the bar re-expanded
to the whole family — it now carries the exact list. The flat table showed an
anonymous uploader as a deleted account. An API 500 on the detail page was
reported to the member as "release not found". A veiled adult page fired the
obligation fetch on every visit, for a 404 it could not use. The saved-search
fanout compared TMDb ids without their prefix, so `movie/603` matched
`tv/603`. And the search-miss budget lost its expiry if the process died
between `INCR` and `EXPIRE`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Dim145
Dim145 merged commit 14b7fba into main Sep 10, 2026
12 checks passed
@Dim145
Dim145 deleted the feat/torrent-page-redesign branch September 10, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant