diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php
index 08de97f36..1d86b9075 100644
--- a/app/Views/frontend/book-detail.php
+++ b/app/Views/frontend/book-detail.php
@@ -1473,6 +1473,16 @@
}
/* Related Books Section */
+ /* Keep the (max 3) related cards grouped and centred instead of spread
+ across the theme's ultra-wide container. ~3 cards of 280px + gutters.
+ plain wrapper div (not the Bootstrap .row) so it doesn't fight .row's
+ negative gutter margins — no !important needed. */
+ .related-books-wrap {
+ max-width: 960px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
.related-book-card {
background: var(--white);
border-radius: 16px;
@@ -1482,6 +1492,14 @@
height: 100%;
display: flex;
flex-direction: column;
+ /* Deliberate book-sane cap, centred in its column, so the cover (140%
+ of the card width) never balloons on wide screens where each column
+ is very wide. 280px is the catalog grid's minmax() floor; here it's a
+ hard cap (the catalog stretches beyond it, this doesn't) — related
+ cards read slightly narrower than catalog cards, which is fine. */
+ max-width: 280px;
+ margin-left: auto;
+ margin-right: auto;
}
.related-book-card:hover {
@@ -1602,12 +1620,10 @@
color: white;
}
- @media (max-width: 768px) {
- .related-book-card {
- max-width: 400px;
- margin: 0 auto;
- }
- }
+ /* No mobile-only max-width override: a wider cap on mobile (≤768px) than on
+ desktop (280px) caused an inverted jump — widening the window past 768px
+ *shrank* the card. The uniform 280px cap above keeps the transition
+ monotonic across every breakpoint. */
/* Favorites button custom styling */
.btn-fav-custom {
@@ -2329,9 +2345,10 @@ class="badge bg-light text-dark border px-3 py-2 text-decoration-none keyword-ch
= __("Potrebbero interessarti") ?>
-
+
diff --git a/tests/related-books-responsive-278.spec.js b/tests/related-books-responsive-278.spec.js
new file mode 100644
index 000000000..7c4879a1f
--- /dev/null
+++ b/tests/related-books-responsive-278.spec.js
@@ -0,0 +1,132 @@
+// @ts-check
+/**
+ * E2E — Related-books ("Potrebbero interessarti") responsive layout (#278).
+ *
+ * Guards the fix that stopped the related-book covers ballooning on large
+ * screens. Reusable regression coverage for:
+ * - the card is capped to a book-sane width on desktop (never full-column);
+ * - NO size inversion across the 768px boundary (widening must not shrink
+ * the card within the same column count);
+ * - responsive column count grows 1 → 2 → 3 with the viewport;
+ * - the row is grouped/centred (bounded width) on ultra-wide screens.
+ *
+ * Book detail is public — no login needed. The spec finds a book that actually
+ * renders related books (an author with ≥2 catalogued books) so it never
+ * silently passes on a page with no section.
+ *
+ * Run: /tmp/run-e2e.sh tests/related-books-responsive-278.spec.js --config=tests/playwright.config.js --workers=1
+ */
+
+const { test, expect } = require('@playwright/test');
+const { execFileSync } = require('child_process');
+
+const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081';
+const DB_USER = process.env.E2E_DB_USER || '';
+const DB_PASS = process.env.E2E_DB_PASS || '';
+const DB_NAME = process.env.E2E_DB_NAME || '';
+const DB_SOCKET = process.env.E2E_DB_SOCKET || '';
+const DB_HOST = process.env.E2E_DB_HOST || '';
+const DB_PORT = process.env.E2E_DB_PORT || '';
+
+test.skip(!DB_USER || !DB_NAME, 'Missing E2E env: DB_USER / DB_NAME required to locate a book with related books');
+
+function dbQuery(sql) {
+ const args = [];
+ if (DB_HOST) { args.push('-h', DB_HOST); if (DB_PORT) args.push('-P', DB_PORT); }
+ else if (DB_SOCKET) { args.push('-S', DB_SOCKET); }
+ args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql);
+ return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 15000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim();
+}
+
+/** A book whose author has ≥2 catalogued books → getRelatedBooks() returns rows. */
+function findBookWithRelated() {
+ const row = dbQuery(
+ "SELECT la.libro_id FROM libri_autori la " +
+ "JOIN libri l ON l.id = la.libro_id AND l.deleted_at IS NULL " +
+ "WHERE la.ruolo = 'principale' " +
+ "AND la.autore_id IN (" +
+ " SELECT autore_id FROM libri_autori la2 " +
+ " JOIN libri l2 ON l2.id = la2.libro_id AND l2.deleted_at IS NULL " +
+ " WHERE la2.ruolo = 'principale' GROUP BY autore_id HAVING COUNT(DISTINCT la2.libro_id) >= 2" +
+ ") LIMIT 1"
+ );
+ return parseInt(row, 10) || 0;
+}
+
+/** Measure the related-books section at a given viewport width. */
+async function measure(page, bookId, width) {
+ await page.setViewportSize({ width, height: 900 });
+ await page.goto(`${BASE}/libro/${bookId}`, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(600);
+ return page.evaluate(() => {
+ const cards = Array.from(document.querySelectorAll('.related-book-card'));
+ if (cards.length === 0) return { present: false };
+ const rects = cards.map((c) => c.getBoundingClientRect());
+ const top0 = Math.round(rects[0].top);
+ const perRow = rects.filter((r) => Math.abs(Math.round(r.top) - top0) < 5).length;
+ const wrap = document.querySelector('.related-books-wrap');
+ return {
+ present: true,
+ count: cards.length,
+ cardW: Math.round(rects[0].width),
+ perRow,
+ wrapW: wrap ? Math.round(wrap.getBoundingClientRect().width) : null,
+ };
+ });
+}
+
+test.describe.serial('Related books — responsive layout (#278)', () => {
+ /** @type {number} */
+ let bookId = 0;
+
+ test.beforeAll(() => {
+ bookId = findBookWithRelated();
+ });
+
+ test('section renders for a book that has related books', async ({ page }) => {
+ test.skip(bookId === 0, 'no book with related books in the catalog');
+ const m = await measure(page, bookId, 1440);
+ expect(m.present).toBe(true);
+ expect(m.count).toBeGreaterThan(0);
+ });
+
+ test('card is capped to a book-sane width on desktop (never balloons)', async ({ page }) => {
+ test.skip(bookId === 0, 'no book with related books');
+ for (const w of [1440, 1920, 2560]) {
+ const m = await measure(page, bookId, w);
+ // The card must never exceed the 280px cap on desktop, no matter how
+ // wide the viewport/column is. Pre-fix it grew to ~440px+.
+ expect(m.cardW, `card width at ${w}px`).toBeLessThanOrEqual(285);
+ }
+ });
+
+ test('no size inversion across the 768px boundary', async ({ page }) => {
+ test.skip(bookId === 0, 'no book with related books');
+ // Bug: a mobile rule capped the card wider (400px) than the desktop cap
+ // (280px), so widening past 768px SHRANK the card. Widening must never
+ // shrink the card.
+ const below = await measure(page, bookId, 760);
+ const above = await measure(page, bookId, 820);
+ expect(above.cardW, 'widening past 768px must not shrink the card').toBeGreaterThanOrEqual(below.cardW - 1);
+ });
+
+ test('column count grows with the viewport (1 → 2 → 3)', async ({ page }) => {
+ test.skip(bookId === 0, 'no book with related books');
+ const narrow = await measure(page, bookId, 480); // col-12 → 1 per row
+ const mid = await measure(page, bookId, 700); // col-sm-6 → 2 per row
+ const wide = await measure(page, bookId, 1400); // col-lg-4 → up to 3 per row
+ expect(narrow.perRow).toBe(1);
+ expect(mid.perRow).toBeLessThanOrEqual(2);
+ // wide shows as many columns as there are cards, up to 3.
+ expect(wide.perRow).toBeGreaterThanOrEqual(Math.min(3, wide.count));
+ });
+
+ test('row is grouped/centred (bounded) on ultra-wide screens', async ({ page }) => {
+ test.skip(bookId === 0, 'no book with related books');
+ const m = await measure(page, bookId, 2560);
+ // The .related-books-wrap caps the row so the cards don't spread across
+ // the whole 2560px container.
+ expect(m.wrapW, 'wrap width on 2560px').not.toBeNull();
+ expect(m.wrapW).toBeLessThanOrEqual(1000);
+ });
+});