diff --git a/CHANGELOG.md b/CHANGELOG.md
index e6d11d7d7..555e238f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,8 @@ is for things you can see or feel when running the app.
- **Opening your library no longer runs the same search twice.** Every visit to the library asked the server for the first page of books, then immediately asked for it again at a different size and threw the first answer away. Nothing looked wrong — the wasted answer never reached the screen — but on a large library that first page is the slowest query on it, and it was being run twice on every load, for every reader. The grid now waits until it knows how many columns it has before asking, so the page is fetched once ([#1144](https://github.com/new-usemame/Calibre-Web-NextGen/issues/1144)).
+- **A book whose title, author or series contains one very long word no longer drags the page sideways on a phone.** The header column beside the cover is only about 217 pixels wide on a phone, and a word longer than that — a German compound title, a long transliterated name, a lengthy series title — ran straight past the edge and made the whole book page scroll horizontally. Those three lines now wrap mid-word when a word cannot fit on a line by itself; ordinary titles are unchanged, since wrapping still prefers to break between words ([#1178](https://github.com/new-usemame/Calibre-Web-NextGen/pull/1178)).
+
- **Book pages no longer scroll sideways on a phone when a book carries long subject tags.** Libraries catalogued with Library-of-Congress subject headings — "France -- History -- Revolution, 1789-1799 -- Fiction" and the like — carry tags wider than a phone screen, and the tag row would not break one onto a second line, so the whole book page could be dragged sideways. It hit anyone reading without an editor account, guests included, because the editing view already wrapped its tags. Long tags now wrap ([#1170](https://github.com/new-usemame/Calibre-Web-NextGen/issues/1170)).
- **The series line on a book cover is dark enough to read again.** The small series text under a title on each cover was styled with the muted grey the rest of the interface uses, and then faded a second time on top of that. The two together took it to a contrast of about 4:1 against the card, below the 4.5:1 that small text needs to stay legible, which made it hard to read for anyone whose eyesight or screen is less than ideal. The second fade is gone, so the line is the muted grey it was meant to be ([#1135](https://github.com/new-usemame/Calibre-Web-NextGen/issues/1135)).
diff --git a/CHANGES-vs-upstream.md b/CHANGES-vs-upstream.md
index 26ae01648..d4b9d1868 100644
--- a/CHANGES-vs-upstream.md
+++ b/CHANGES-vs-upstream.md
@@ -62,6 +62,7 @@ Format: each row is one fork-PR, mapped to its upstream PR or issue (if any), wi
| Fork PR | Upstream issue | Description | SHA | Release |
|---|---|---|---|---|
+| [#1178](https://github.com/new-usemame/Calibre-Web-NextGen/pull/1178) | (fork-original) | **The book detail page scrolled horizontally on a phone when a title, author or series carried one long unbreakable token.** `frontend/src/pages/BookDetail.module.css` `.title`/`.authors`/`.series` had no break rule, and the header text column is ~217px at a 390px viewport, so any wider token overflowed its own box and propagated to `documentElement`. Measured on cwn-local: 290px of document overflow for a 35-character compound title, `h1` scroll 519 vs client 217; 0px after. This is the ~35px CI baseline overflow tracked in `notes/e2e-mobile-overflow-ci-triage.md` that resisted several rounds of diagnosis — no element BORDER BOX crosses the viewport edge, so every `getBoundingClientRect` sweep came back empty and the tag-pill/chip/actions-row hypotheses were all looking for the wrong shape. The `scrollWidth > clientWidth` probe added in #1177 named `
` (48px more content than its box) on its first CI run. Fix is `overflow-wrap: anywhere`, the same treatment the tag pill got in #1170; normal wrapping still breaks between words first. Regression test measures the delta the long metadata adds (seed-independent): red 109px against a redeployed pre-fix build, green 0 after, plus a direct pin on the title box. CI E2E `hidden-books.spec.ts:252` went RED->GREEN on both projects, 267->273 passed. | TBD | v4.1.22 |
| [#1171](https://github.com/new-usemame/Calibre-Web-NextGen/pull/1171) | (fork issue [#1170](https://github.com/new-usemame/Calibre-Web-NextGen/issues/1170)) | **The book detail page scrolled horizontally on a phone when a book carried a long subject tag.** The read-only tag pill (`frontend/src/components/Pill.module.css`) was `white-space: nowrap` with no `max-width`, so a Library-of-Congress heading such as `France -- History -- Revolution, 1789-1799 -- Fiction` rendered wider than the wrapping flex row containing it and pushed the document past the viewport. Measured on a stock library as a guest: 12px of overflow at 390px, 82px at 320px, 323px with a single unbroken token. Only the no-edit-role render is affected — the editor branch uses removable chips that already wrap, which is why every admin-authenticated mobile spec missed it. Fix drops `nowrap` and adds `max-width: 100%` + `overflow-wrap: anywhere`; pills that fit still occupy one line, only over-long ones wrap. Verified `max-width` alone with `nowrap` retained does **not** fix it (82px unchanged), confirming `nowrap` as the root cause. Regression test measures the overflow the long tags ADD (baseline vs pathological render), not absolute page overflow, so it holds on any fixture — CI’s detail page carries ~35px of unrelated baseline overflow (see `notes/e2e-mobile-overflow-ci-triage.md`). Red at 323px against a redeployed pre-fix build, green after. | 41905a99e | v4.1.22 |
| [#1165](https://github.com/new-usemame/Calibre-Web-NextGen/pull/1165) | (fork issue [#1109](https://github.com/new-usemame/Calibre-Web-NextGen/issues/1109)) | **Books carrying an ISO 639-2/B language code (`ger`, `fre`, `dut`) displayed "Unknown" and were rejected on upload.** `cps/iso_language_names.py`'s `LANGUAGE_NAMES` is keyed on ISO 639-2/**T** only (`deu`, `fra`, `nld`); the 20 bibliographic variants are absent from all 28 locale tables. `get_language_name()` did a bare `names.get(lang_code, "Unknown")`, so a /B code missed the table on every read path — book detail (`cps/web.py:1886`, `:3515`), the language browse list (`cps/db.py:1975`/`:1995`), the edit form (`cps/editbooks.py:572`, `:1684`), magic shelves and OPDS — and wrote `ERROR {cps.isoLanguages:79} Missing translation for language name: ger` once per lookup per book, polluting logs users attach to unrelated reports. **Second, worse surface**: `get_valid_language_codes_from_code()` matches input against table keys, so a /B code fell through to *remainder*, and `edit_book_languages(upload_mode=True)` turns a remainder entry into `ValueError("'ger' is not a valid language")` — an EPUB whose OPF declares `ger` (passed through untouched by `get_lang3`, which returns any 3-letter code as-is) was refused. **Fix**: `ISO6392B_TO_T`, a fixed 20-entry /B→/T table in `cps/isoLanguages.py`, consulted on miss by a new `_resolve_lang_code()` helper (returns `(name, resolved)` so callers distinguish a hit from the "Unknown" sentinel) plus a `canonical_lang_code()` normalizer; `get_valid_language_codes_from_code()` now accepts /B input and stores the /T form, de-duplicating when a book declares both. The table is written out rather than derived from pycountry at import (a packaging change could silently empty it) — 639-2/B is a closed list — and `test_iso6392b_table_matches_pycountry` pins it against pycountry as a drift alarm. The unmappable-code log drops ERROR→WARNING: an unrecognised code is a metadata property, not an application fault. **21 new tests** in `tests/unit/test_1109_iso6392b_language_codes.py` (all 20 codes × display + validation, cross-locale non-divergence, log level, dedupe, drift pin) plus the **3 pre-written smoke tests un-quarantined** from `tests/quarantine.py`. Mutation-verified: removing the display alias fails 46, removing the validation alias fails 23, reverting the log level fails 1. **Live-verified on cwn-local** — book 2 tagged `ger`: pre-fix `Unknown` + 2 ERROR lines per page load; post-fix `German` on the detail page, `German` in the `/language` browse list, 0 ERROR lines. Suite 2F/4327P = baseline. **Two further defects, fixed in follow-up commits on the same PR.** (1) `get_valid_language_codes_from_code()` validated against the *per-locale* name table. Those tables are translation data and are legitimately incomplete — 27 of the 28 shipped locales are missing between 1 and 48 of the 424 codes, and `ell` alone is absent from 11 — so a valid code missing from the user's UI locale fell into *remainder* and became the same upload `ValueError`: **a Greek book tagged `ell` was refused for a pt_BR user and imported fine for an English one**, and a book declaring both `ger` and `deu` was rejected outright because `deu` resolved while `ger` did not. Validity is a property of the code, not of the locale's translation coverage, so validation now resolves against `_reference_language_codes()` — the one complete table — with `test_reference_table_is_the_superset_of_every_locale` pinning the property that makes it a valid oracle. (2) From the cross-family review: canonicalization ran *after* the locale-table lookup, so a table that ever gained a /B key would capture `ger` and store it verbatim while defeating the `ger`+`deu` de-duplication; the two passes are now one that normalizes /B→/T before any lookup. That collapse surfaced that `names.items()` on an unrecognised locale had always raised `AttributeError` even though `get_language_names()` is documented to return `None` for one and `get_language_name()` already guards it — the validator guards it too now and falls through to the reference table. **The real caller is under test**: `edit_book_languages(upload_mode=True)` is the only production consumer and the place *remainder* becomes the reporter's `ValueError`, and nothing exercised it, so four tests now drive it directly. **Terra review**: no critical/high/medium; six low/nit, five fixed (ordering, the `canonical_lang_code` monkeypatch test that only proved routing, the backend-coupled `AttributeError` assertion, the reference-table docstring overclaim, the missing caller test) and one declined with reason. **107 tests** total, each red-proofed against the commit it pins. **Live A/B through the production module in cwn-local** (Python 3.13, pycountry 24.6.1): `ell`@pt_BR, `ell`@gl, `ger`@en and `ger,deu`@en all go rejected→accepted, `zzz` stays rejected, display unchanged (`en/ger`→German, `de/ger`→Deutsch). Suite 2F/4340P = baseline. | `30dd3fae5` | v4.1.22 |
| [#1157](https://github.com/new-usemame/Calibre-Web-NextGen/pull/1157) | (fork issue [#1094](https://github.com/new-usemame/Calibre-Web-NextGen/issues/1094) @auspex) | **A failed conversion dropped the book from the library entirely.** `main()` in `scripts/ingest_processor.py` imported the incoming book only inside `if convert_successful:` and had no `else`, so any conversion failure — the reporter's 63MB PDF killed by the service safety timeout, but equally a corrupt source, unsupported internals, or a missing input plugin — returned without ever calling `add_book_to_library()`, while the `finally` block's `delete_current_file()` removed the source from the ingest folder. The service then logged `Successfully processed`. The two sibling branches that decline to convert (`auto_convert` off; format in `auto_convert_ignored_formats`) already import the original, so the fallback makes behaviour consistent rather than adding a rule. **Fix**: an `elif conversion_attempted:` branch imports the original. The guard flag matters — the ignore-list branch reports `convert_successful=False` *having already imported*, so a bare `else` would have filed every deliberately-unconverted book twice; that near-miss is pinned by `test_ignored_format_is_imported_exactly_once`. Also addressed from the same report: `backup()` now logs the absolute destination and the safety-timeout branch names `/config/processed_books/failed/` (the reporter's "I've got no idea where 'failed backup' is"), and the service no longer claims the processor "should have timed out internally at N seconds" — `ingest_timeout_minutes` bounds only `is_file_in_use()`, so no internal conversion timeout exists; it now points at the setting that does govern it. **Second defect, found by the cross-family review and confirmed by reading `run:245`:** the fallback could not help the reporter's own case at all. The service runs the processor under `timeout "$safety_timeout"`, and nothing inside the processor bounded conversion — so an overrun was a SIGTERM to the processor, not a Python-visible failure, and `main()` never regained control to reach the new branch. (Their log shows `CON_ERROR ... EXIT/ERROR CODE: -15` a second before the shell's `SAFETY TIMEOUT`, which is `timeout` signalling the process group: whether Python survives long enough to print anything is a race, and whether it survives long enough to *import* is a race it usually loses.) **Fix**: `convert_book()`/`convert_to_kepub()` now run the converter with `subprocess.run(timeout=...)` from `CWA_CONVERSION_DEADLINE_SECONDS`, which the s6 script derives from the same `safety_timeout` it enforces (one source of truth; ~10% reserve, capped at a fifth of the budget; unset when `timeout 0` means no hard limit). An overrun is now an ordinary `(False, "")` and the book is imported. **Fourth defect, found by the second review leg:** that deadline was read fresh at each `subprocess.run()`, so a kepub ingest of a non-EPUB — which converts twice back to back inside one hard `timeout` — granted each stage the full deadline, letting the two outlast the watchdog and re-opening this very bug at the DEFAULT setting (at `S=2700/D=2430`, stage one can burn 2429s and stage two starts with a fresh 2430s against 271s of real time). The budget is now ONE allowance anchored to process start (the span `timeout` actually measures); each stage draws on what is left, and an exhausted budget yields a small positive value so it expires as a handled `TimeoutExpired` rather than a `ValueError` escaping the handlers. Two smaller repairs from the same leg: the shell reserve had a cliff (a flat 30s floor inverted the limits — a 31s timeout left 1s to convert while 30s left 23s), now capped so the deadline is monotonic in the timeout, with the shipped 900s/2700s endpoints unchanged and pinned; and `float("inf")` parses but fails at `int()` with `OverflowError`, which is neither `TypeError` nor `ValueError` and escaped while evaluating the `timeout=` argument, now caught. The whole-run anchor means a slow copy-in eats conversion time — the correct direction, since the watchdog starts at process launch and anchoring later lets the inner deadline outrun it; the timeout message now says so. **Third defect, same review**: `convert_to_kepub()`'s generic `except Exception` printed and fell through, returning `None` — `main()` unpacks that call, so it raised `TypeError`, skipped the fallback and dropped the book. Every non-success path now returns a pair. Also, that path backed up only the intermediate epub, so for a non-epub input the failed folder never held the file the user actually dropped in; it now keeps both. **Declined, with reason**: the review's `automerge=overwrite` finding (a rescued file could replace a good existing format) is real but pre-existing and not introduced here — the same unconditional `add_book_to_library(filepath)` runs on the auto-convert-off and ignore-list branches today, and the default is `new_record` (`cwa_schema.sql:46`). Tracked separately rather than folded into this fix. **Verified live in cwn-local mirroring the reporter's config** (`auto_convert=1`, target epub): pre-fix an unconvertible PDF logged `CON_ERROR` then `Successfully processed` with the book count unchanged at 42; post-fix the same file imports (42→43), `/book/205` and `/api/v1/books/205` both 200 with the PDF format attached. Deadline path verified live too: a 12MB txt with a 1s deadline overran, logged the actionable message, backed up to the named folder and imported as **TXT** — the original, not a converted file. Happy path re-checked (txt→epub converts, one EPUB row, fallback silent), ignore-list path re-checked (exactly one row), and an `.acsm` ticket re-checked (not filed, guidance printed, its promise about processed_books/failed now true). 27 regression tests, mutation-verified falsifiable; full unit+smoke suite 3F/4206P against a 3F baseline on `main` — the same three pre-existing failures. 49 regression tests in the module, every fix mutation-verified falsifiable; full unit+smoke 2F/4246P against the same 2F baseline on `main` (`0e423fb04`). Live re-verified in cwn-local (container Python 3.13.14): a 309MB txt with a 1s budget fires the deadline, backs up and imports as **TXT** (42→43), while a generous budget and an unset budget both still convert to **EPUB** — the anti-vacuity control that the fallback fires only on failure. `/security-review` clean (no finding above the confidence bar). Split out rather than stretched: **#1161** (converter descendants can outlive the deadline; `start_new_session` would take them out of the outer `timeout`'s group, so it needs its own signal design) and **#1163** (a failure *after* a successful conversion still unwinds without importing either file). No dependency, license, route or external URL change. Fork-original. | `a7f13064e` | v4.1.22 |
diff --git a/frontend/e2e/book-detail.spec.ts b/frontend/e2e/book-detail.spec.ts
index 0e708e42d..833f8164b 100644
--- a/frontend/e2e/book-detail.spec.ts
+++ b/frontend/e2e/book-detail.spec.ts
@@ -238,3 +238,56 @@ test('long tag names add no horizontal overflow to the read-only detail page', a
`long tags widened the page by ${withLongTags - baseline}px (baseline ${baseline}px)`,
).toBeLessThanOrEqual(1);
});
+
+// The header text column is ~217px wide on a 390px phone, and `.title`,
+// `.authors` and `.series` had no break rule. A single token wider than that
+// column overflowed it and dragged the whole page into horizontal scroll —
+// measured at 290px of document overflow for one 35-character German compound
+// title on cwn-local, 0 after adding `overflow-wrap: anywhere`.
+//
+// This is the cause of the ~35px baseline overflow CI's detail page carried for
+// several releases (notes/e2e-mobile-overflow-ci-triage.md). It stayed invisible
+// because no element's BORDER BOX crosses the viewport edge — the text overflows
+// its own box, so a getBoundingClientRect sweep finds nothing. What identified it
+// was the `scrollWidth > clientWidth` probe added in #1177, which named
+// `` as holding 48px more content than its box.
+//
+// Measures the DELTA the long metadata adds, not absolute overflow, so it holds
+// on any fixture regardless of what other baseline the page happens to carry.
+test('long title, author and series tokens add no horizontal overflow', async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto('/app');
+ const bookId = await firstBookId(page);
+ test.skip(bookId == null, 'seed has no books');
+
+ await page.goto(`/app/book/${bookId}`, { waitUntil: 'domcontentloaded' });
+ await expect(page.locator('main h1')).toBeVisible({ timeout: 10_000 });
+ const baseline = await pageOverflow(page);
+
+ // Each is a real shape a library carries: a German compound noun, a long
+ // transliterated name, a long series title. None contains a break opportunity.
+ const longTitle = 'Kraftfahrzeughaftpflichtversicherungsgesetz';
+ await page.route(`**/api/v1/books/${bookId}`, async (route) => {
+ const res = await route.fetch();
+ const book = await res.json();
+ book.title = longTitle;
+ book.authors = [{ id: 990101, name: 'Nebuchadnezzarssonssonssonsdottir' }];
+ book.series = { id: 990102, name: 'Donaudampfschiffahrtsgesellschaftskapitaen' };
+ book.series_index = 1;
+ await route.fulfill({ response: res, json: book });
+ });
+
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await expect(page.locator('main h1')).toContainText('Kraftfahrzeug', { timeout: 10_000 });
+ const withLongText = await pageOverflow(page);
+
+ expect(
+ withLongText - baseline,
+ `long title/author/series widened the page by ${withLongText - baseline}px (baseline ${baseline}px)`,
+ ).toBeLessThanOrEqual(1);
+
+ // And the title box itself must not hold more content than it can show — this
+ // is the measurement that localised the bug, so pin it directly.
+ const titleExcess = await page.locator('main h1').evaluate((el) => el.scrollWidth - el.clientWidth);
+ expect(titleExcess, 'the title must wrap inside its column, not overflow it').toBeLessThanOrEqual(1);
+});
diff --git a/frontend/src/data/whatsNew.ts b/frontend/src/data/whatsNew.ts
index 09a2dffee..1569ba0b0 100644
--- a/frontend/src/data/whatsNew.ts
+++ b/frontend/src/data/whatsNew.ts
@@ -91,6 +91,11 @@ export const WHATS_NEW: WhatsNewRelease[] = [
body: 'Every visit asked the server for the first page of books, then immediately asked again at a different size and threw the first answer away. Nothing looked wrong, but on a large library that first page is the slowest query there is, and it was running twice on every load for every reader. The grid now waits until it knows how many columns it has before asking.',
category: 'Under the hood',
},
+ {
+ title: 'A very long title or author name no longer breaks the book page on a phone',
+ body: 'The header column beside the cover is only about 217 pixels wide on a phone. A word longer than that — a German compound title, a long transliterated name, a lengthy series title — ran past the edge and made the whole book page scroll sideways. Those lines now wrap mid-word when a word cannot fit on a line by itself. Ordinary titles look exactly as they did, because wrapping still prefers to break between words.',
+ category: 'Library',
+ },
{
title: 'Book pages stop scrolling sideways on a phone',
body: 'Libraries catalogued with Library-of-Congress subject headings — "France -- History -- Revolution, 1789-1799 -- Fiction" and the like — carry tags wider than a phone screen, and the tag row would not break one onto a second line, so the whole book page could be dragged sideways. It hit anyone reading without an editor account, guests included, because the editing view already wrapped its tags. Long tags now wrap.',
diff --git a/frontend/src/pages/BookDetail.module.css b/frontend/src/pages/BookDetail.module.css
index 71e766e06..8eff102fb 100644
--- a/frontend/src/pages/BookDetail.module.css
+++ b/frontend/src/pages/BookDetail.module.css
@@ -160,6 +160,19 @@
gap: var(--sp-5);
}
+/* The header text column is ~217px on a 390px phone, and these three lines carry
+ arbitrary library metadata: a German compound title, a transliterated name, a
+ long series title. Without a break rule a single token wider than the column
+ overflows it and drags the whole page into horizontal scroll — measured at
+ 290px of document overflow for one 35-character title. Normal wrapping still
+ breaks between words first; `anywhere` only splits a token that cannot fit on
+ a line by itself. Same treatment as the tag pill in #1170. */
+.title,
+.authors,
+.series {
+ overflow-wrap: anywhere;
+}
+
.title {
font-family: var(--font-display);
font-size: var(--fs-2xl);