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 -
+ 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); + }); +});