From 5de614a0b962836798dee62bed37cbbe2c6a5b72 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Fri, 9 Oct 2026 22:43:40 +0200 Subject: [PATCH 1/5] Book page on a phone: no row of buttons or facts is left uneven - Four quick facts (year, pages, format, ISBN) go four in a row when they fit and two by two otherwise, never three and one. - Share buttons and citation actions go one per row on a phone; the citation styles stay on one line that scrolls sideways. - The card titles keep room above their buttons: main.css forces .card-header margins to 0 !important, so the space is padding. - The citation card no longer clips the bottom border of its last button. --- public/assets/pinakes-2026.css | 26 ++++++++++++++-- tests/mobile-public-layout.spec.js | 49 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/public/assets/pinakes-2026.css b/public/assets/pinakes-2026.css index 79efe441a..ddbb2d570 100644 --- a/public/assets/pinakes-2026.css +++ b/public/assets/pinakes-2026.css @@ -987,6 +987,10 @@ body.pk .pk-availbox .btn-fav-custom:hover { border-color: var(--pk-accent); bac body.pk .pk-availbox .btn-fav-custom.btn-danger { border-color: var(--pk-accent); color: var(--pk-accent-strong); background: var(--pk-accent-soft); } body.pk .pk-quick { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); border-top: 1px solid var(--pk-line); border-bottom: 1px solid var(--pk-line); margin: 0; } +/* Four facts are never three and one left alone: four in a row when they + fit (480px, four 120px columns), otherwise two by two. The clamp() picks + 25% or 50% as the column minimum from the strip's own width. */ +body.pk .pk-quick:has(> .pk-quick__item:nth-child(4):last-child) { grid-template-columns: repeat(auto-fit, minmax(clamp(25%, (480px - 100%) * 999, 50%), 1fr)); } body.pk .pk-quick__item { padding: 16px 0; display: flex; flex-direction: column; gap: 4px; } body.pk .pk-quick dt { font-size: 12px; color: var(--pk-faint); text-transform: uppercase; letter-spacing: .06em; font-weight: 400; } body.pk .pk-quick dd { margin: 0; font-size: 15px; font-weight: 600; color: var(--pk-ink); overflow-wrap: anywhere; } @@ -1071,7 +1075,9 @@ body.pk .pk-bookbody .keyword-chip:hover { background: var(--pk-accent-line); } body.pk .pk-bookbody__aside .card { padding: 22px; border-radius: 20px; background: var(--pk-surface); border: 1px solid var(--pk-line); box-shadow: none; margin: 0; z-index: auto; } -body.pk .pk-bookbody__aside .card-header { padding: 0; margin: 0 0 8px; border: 0; background: none; } +/* main.css forces `.card-header { margin: 0 !important }`: the space under a + card's title is padding, or the buttons sit right against it. */ +body.pk .pk-bookbody__aside .card-header { padding: 0 0 12px; margin: 0; border: 0; background: none; } body.pk .pk-bookbody__aside .card-header h6 { font: 600 12px/1.2 var(--pk-sans); letter-spacing: .08em; text-transform: uppercase; color: var(--pk-muted); margin: 0; } body.pk .pk-bookbody__aside .card-header h6 i { color: var(--pk-faint); } body.pk .pk-bookbody__aside .card-body { padding: 0; } @@ -1083,8 +1089,8 @@ body.pk .pk-bookbody__aside .book-status-inline { padding: 0; background: none; /* Cite section */ .pk-cite { padding: 0 0 80px; } -body.pk .pk-cite #book-cite-card { background: none; border: 0; box-shadow: none; padding: 0; margin: 0; } -body.pk .pk-cite #book-cite-card > .card-header { padding: 0; margin: 0 0 18px; background: none; border: 0; } +body.pk .pk-cite #book-cite-card { background: none; border: 0; box-shadow: none; padding: 0; margin: 0; overflow: visible; } +body.pk .pk-cite #book-cite-card > .card-header { padding: 0 0 18px; margin: 0; background: none; border: 0; } body.pk .pk-cite #book-cite-card > .card-header h6 { font-family: var(--pk-serif); font-weight: 500; font-size: 32px; letter-spacing: -.015em; color: var(--pk-ink); margin: 0; text-transform: none; } body.pk .pk-cite #book-cite-card > .card-header h6 i { font-size: 18px; color: var(--pk-faint); } body.pk .pk-cite #book-cite-card > .card-body { padding: 0; } @@ -1095,6 +1101,15 @@ body.pk .pk-cite #book-cite-card > .card-body { padding: 0; } .pk-citebox__text { padding: 22px 24px; border-radius: 16px; background: var(--pk-surface); border: 1px solid var(--pk-line); font-family: var(--pk-serif); font-size: 18px; max-width: 880px; line-height: 1.5; color: var(--pk-ink-2); } .pk-citebox__actions { display: flex; gap: 8px; flex-wrap: wrap; } body.pk .pk-cite .cite-block { margin-top: 18px; } +/* On a phone a row of buttons is never wrapped unevenly: the citation styles + stay on one line that scrolls sideways, the actions go one per row. */ +@media (max-width: 640px) { + .pk-citebox__tabs { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; margin-right: calc(-1 * var(--pk-gutter)); padding-right: var(--pk-gutter); scroll-snap-type: x proximity; } + .pk-citebox__tabs::-webkit-scrollbar { display: none; } + .pk-citebox__tab { flex: 0 0 auto; white-space: nowrap; scroll-snap-align: start; } + .pk-citebox__actions { flex-direction: column; } + .pk-citebox__actions > *, body.pk .pk-citebox__actions #cite-open { width: 100%; justify-content: center; } +} /* Series and related */ body.pk .book-series-section { padding: 0 0 56px; background: none; } @@ -1446,6 +1461,11 @@ body.pk #book-share-card .social-share-btn:hover { border-color: var(--pk-accent body.pk #book-share-card .social-share-btn:hover i { color: inherit; } body.pk #book-share-card .social-share-btn__label { display: inline; } body.pk #book-share-card .card-body > div { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; } +@media (max-width: 640px) { + /* One share button per row on a phone, never three and two. */ + body.pk #book-share-card .card-body > div { flex-direction: column; gap: 8px; } + body.pk #book-share-card .social-share-btn { width: 100%; justify-content: center; padding: 10px 14px; font-size: 14px; } +} @media (max-width: 1023px) { /* book-detail.css centres the hero on small screens; the design keeps it left-aligned. */ body.pk .pk-bookhero__info { text-align: left; } diff --git a/tests/mobile-public-layout.spec.js b/tests/mobile-public-layout.spec.js index 2054213b5..2c524a4cc 100644 --- a/tests/mobile-public-layout.spec.js +++ b/tests/mobile-public-layout.spec.js @@ -144,6 +144,55 @@ test.describe('Book page details', () => { expect(r.fullWidth).toBe(true); expect(r.wrapped).toEqual([]); }); + + // On a phone no row of the book page is left uneven: four facts (year, + // pages, format, ISBN) go two by two, not three and one; the share buttons + // and the citation actions one per row; the citation styles on one line. + // Each title keeps room above its buttons. 412px is a common large phone, + // where the facts used to fit three to a row. + for (const width of [390, 412]) { + test(`on a ${width}px phone every row of the book page is even`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto(BASE + '/catalogo?search=978', { waitUntil: 'networkidle' }); + const hrefs = await page.locator('main a[href]').evaluateAll(as => [...new Set(as.map(a => a.getAttribute('href')).filter(h => /^\/[^/]+\/[^/]+\/\d+$/.test(h || '')))].slice(0, 8)); + test.skip(hrefs.length === 0, 'no book in the catalogue'); + let found = false; + for (const href of hrefs) { + await page.goto(new URL(href, BASE).href, { waitUntil: 'networkidle' }); + if (await page.locator('.pk-quick > .pk-quick__item').count() === 4) { found = true; break; } + } + test.skip(!found, 'no book with year, pages, format and ISBN'); + const r = await page.evaluate(() => { + const tops = (els) => els.filter((e) => e.offsetParent).map((e) => Math.round(e.getBoundingClientRect().top)); + const rows = (t) => Object.values(t.reduce((m, y) => { m[y] = (m[y] || 0) + 1; return m; }, {})); + const gap = (title, first) => (title && first ? Math.round(first.getBoundingClientRect().top - title.getBoundingClientRect().bottom) : null); + const share = [...document.querySelectorAll('#book-share-card .social-share-btn')]; + const actions = document.querySelector('.pk-citebox__actions'); + const acts = actions ? [...actions.querySelectorAll('a, button')] : []; + const last = acts.filter((e) => e.offsetParent).pop(); + const lastBox = last ? last.getBoundingClientRect() : null; + const clipper = last ? last.closest('#book-cite-card') : null; + return { + facts: rows(tops([...document.querySelectorAll('.pk-quick > .pk-quick__item')])), + share: rows(tops(share)), + shareGap: gap(document.querySelector('#book-share-card .card-header h6'), share.find((e) => e.offsetParent)), + tabs: rows(tops([...document.querySelectorAll('.pk-citebox__tab')])), + citeGap: gap(document.querySelector('#book-cite-card .card-header h6'), document.querySelector('.pk-citebox__tabs')), + actions: rows(tops(acts)), + lastActionClipped: !!(clipper && lastBox && getComputedStyle(clipper).overflow !== 'visible' && clipper.getBoundingClientRect().bottom <= lastBox.bottom + 0.5), + sideways: document.documentElement.scrollWidth > window.innerWidth, + }; + }); + expect(r.facts, 'four facts, two by two').toEqual([2, 2]); + if (r.share.length) expect(Math.max(...r.share), 'one share button per row').toBe(1); + if (r.shareGap !== null) expect(r.shareGap, 'room under "Condividi"').toBeGreaterThanOrEqual(8); + if (r.tabs.length) expect(r.tabs.length, 'the citation styles on one line').toBe(1); + if (r.citeGap !== null) expect(r.citeGap, 'room under "Cita questo libro"').toBeGreaterThanOrEqual(8); + if (r.actions.length) expect(Math.max(...r.actions), 'one citation action per row').toBe(1); + expect(r.lastActionClipped, 'the last citation button is not cut').toBe(false); + expect(r.sideways).toBe(false); + }); + } }); test.describe('Search boxes draw one border', () => { From 69c96bd70525712687fe893e072662aac97e594e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Fri, 9 Oct 2026 23:04:42 +0200 Subject: [PATCH 2/5] Book page on a phone: the availability buttons line up with the status book-detail.css caps the loan and favourite buttons at 300px and centres them, so on a phone they started a step to the right of "Disponibile" / "Non disponibile oggi". They now fill the box from the same left edge. --- public/assets/pinakes-2026.css | 4 ++- tests/mobile-public-layout.spec.js | 39 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/public/assets/pinakes-2026.css b/public/assets/pinakes-2026.css index ddbb2d570..6bdfe1ccb 100644 --- a/public/assets/pinakes-2026.css +++ b/public/assets/pinakes-2026.css @@ -975,7 +975,9 @@ body.pk .pk-availbox #btn-request-loan:disabled { opacity: .55; cursor: not-allo @media (max-width: 640px) { body.pk .pk-availbox { gap: 16px; } body.pk .pk-availbox .action-buttons { flex-direction: column; width: 100%; } - body.pk .pk-availbox .action-buttons .ui-button { width: 100%; justify-content: center; } + /* Full width, so the buttons start where "Disponibile" starts: book-detail.css + caps them at 300px and centres them inside the box. */ + body.pk .pk-availbox .action-buttons .ui-button { width: 100%; max-width: none; justify-content: center; } } body.pk .pk-availbox .btn-fav-custom, body.pk .pk-availbox .action-buttons .btn-secondary { background: var(--pk-surface); color: var(--pk-ink); border: 1px solid var(--pk-line-2); font-weight: 500; padding: 13px 18px; diff --git a/tests/mobile-public-layout.spec.js b/tests/mobile-public-layout.spec.js index 2c524a4cc..3bee4db35 100644 --- a/tests/mobile-public-layout.spec.js +++ b/tests/mobile-public-layout.spec.js @@ -181,6 +181,12 @@ test.describe('Book page details', () => { actions: rows(tops(acts)), lastActionClipped: !!(clipper && lastBox && getComputedStyle(clipper).overflow !== 'visible' && clipper.getBoundingClientRect().bottom <= lastBox.bottom + 0.5), sideways: document.documentElement.scrollWidth > window.innerWidth, + // The availability ("Disponibile") and the buttons under it share one left edge. + availOffsets: (() => { + const badge = document.querySelector('.pk-availbox .availability-badge'); + const btns = [...document.querySelectorAll('.pk-availbox .action-buttons .ui-button')].filter((e) => e.offsetParent); + return badge ? btns.map((b) => Math.round(Math.abs(b.getBoundingClientRect().left - badge.getBoundingClientRect().left))) : []; + })(), }; }); expect(r.facts, 'four facts, two by two').toEqual([2, 2]); @@ -191,6 +197,39 @@ test.describe('Book page details', () => { if (r.actions.length) expect(Math.max(...r.actions), 'one citation action per row').toBe(1); expect(r.lastActionClipped, 'the last citation button is not cut').toBe(false); expect(r.sideways).toBe(false); + for (const off of r.availOffsets) expect(off, 'the buttons start where "Disponibile" starts').toBeLessThanOrEqual(1); + }); + + // Available or not ("Disponibile", "Non disponibile oggi"), the status and + // the buttons under it (Request a loan / Reserve, Favourites) start at the + // same left edge and fill the box: they used to be capped at 300px and + // centred, a step to the right of the status. + test(`on a ${width}px phone the availability lines up with its buttons`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto(BASE + '/catalogo', { waitUntil: 'networkidle' }); + const hrefs = await page.locator('main a[href]').evaluateAll(as => [...new Set(as.map(a => a.getAttribute('href')).filter(h => /^\/[^/]+\/[^/]+\/\d+$/.test(h || '')))].slice(0, 8)); + test.skip(hrefs.length === 0, 'no book in the catalogue'); + let checked = 0; + for (const href of hrefs) { + await page.goto(new URL(href, BASE).href, { waitUntil: 'networkidle' }); + const m = await page.evaluate(() => { + const box = document.querySelector('.pk-availbox'); + const badge = box && box.querySelector('.availability-badge'); + const btns = box ? [...box.querySelectorAll('.action-buttons .ui-button')].filter((e) => e.offsetParent) : []; + if (!badge || btns.length === 0) return null; + const inner = box.getBoundingClientRect().right - parseFloat(getComputedStyle(box).paddingRight); + return { + state: badge.textContent.trim(), + left: btns.map((b) => Math.round(Math.abs(b.getBoundingClientRect().left - badge.getBoundingClientRect().left))), + right: btns.map((b) => Math.round(Math.abs(b.getBoundingClientRect().right - inner))), + }; + }); + if (!m) continue; + checked++; + for (const off of m.left) expect(off, `${m.state}: the buttons start where the status starts`).toBeLessThanOrEqual(1); + for (const off of m.right) expect(off, `${m.state}: the buttons fill the box`).toBeLessThanOrEqual(1); + } + test.skip(checked === 0, 'no book with loan buttons'); }); } }); From 3b9ea5848990aa04ac11d8ba7d06969731eecb21 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sat, 10 Oct 2026 01:44:04 +0200 Subject: [PATCH 3/5] Catalogue filters keep their sidebar; one tap on the tab bar; no inner search ring - Catalogue: the filters column has a width of its own (25%, 250-300px) and never grows to its content. A publisher named in a hundred characters, shown with an ellipsis, still counted at full length in the column's minimum width: on a live library the column took the whole page and the books went under the filters. - Phone tab bar: the cookie banner moves above it, as the cookie icon already did. Over the bar, it took the first tap of a visitor who had not answered it yet, and "Catalogo" needed a second tap. - Home search: the field draws no ring of its own while typing; the rounded box already shows the focus. --- public/assets/pinakes-2026.css | 17 ++++- tests/catalog-filters-sidebar.spec.js | 73 +++++++++++++++++++++ tests/mobile-tabbar-cookie-banner.spec.js | 79 +++++++++++++++++++++++ tests/search-suggestions-layer.spec.js | 5 ++ 4 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 tests/catalog-filters-sidebar.spec.js create mode 100644 tests/mobile-tabbar-cookie-banner.spec.js diff --git a/public/assets/pinakes-2026.css b/public/assets/pinakes-2026.css index 6bdfe1ccb..e6c6e44ae 100644 --- a/public/assets/pinakes-2026.css +++ b/public/assets/pinakes-2026.css @@ -401,7 +401,10 @@ body.pk .pk-hero__title em { color: var(--pk-accent-ink); } body.pk .pk-search input { flex: 1; min-width: 0; border: 0; outline: none; font-size: 16px; background: transparent; color: var(--pk-ink); padding: 8px 0; box-shadow: none; } /* The field has no frame of its own: the whole search box shows the focus. */ .pk-search:focus-within { border-color: var(--pk-focus); box-shadow: 0 0 0 1px var(--pk-focus), 0 12px 32px -18px color-mix(in srgb, var(--pk-accent-strong) 35%, transparent); } -body.pk .pk-search input:focus-visible { outline: none; } +/* The field draws nothing of its own when focused: the whole rounded box + already shows the focus (:focus-within above). A form-reset rule gave the + input a 1px accent ring, a second rectangle around the typed text. */ +body.pk .pk-search input:focus, body.pk .pk-search input:focus-visible { outline: none; box-shadow: none !important; border-color: transparent; } .pk-search button { min-height: 44px; padding: 12px 22px; border-radius: 11px; border: 0; background: var(--pk-button); color: var(--pk-button-text); font-weight: 600; font-size: 15px; cursor: pointer; transition: background-color .2s; @@ -603,8 +606,12 @@ body.pk .pk-catalog-head .catalog-header-content { text-align: left; max-width: .pk-catalog { padding: 0 0 88px; } .pk-catalog__layout { display: flex; flex-wrap: wrap; gap: 40px; align-items: flex-start; } -.pk-filters { flex: 1 1 250px; max-width: 100%; position: sticky; top: 88px; } -.pk-results { flex: 999 1 520px; min-width: 0; display: flex; flex-direction: column; gap: 28px; } +/* The filters column has a width of its own: a quarter of the layout, 250 to + 300px. It never grows to its content: a publisher named in a hundred + characters (shown with an ellipsis) still counted at full length in the + column's minimum width, which pushed the books under the filters. */ +.pk-filters { flex: 0 0 clamp(250px, 25%, 300px); min-width: 0; max-width: 100%; position: sticky; top: 88px; } +.pk-results { flex: 1 1 520px; min-width: 0; display: flex; flex-direction: column; gap: 28px; } @media (max-width: 860px) { .pk-filters { position: static; flex-basis: 100%; } } body.pk :is(.pk-filters, .filters-panel) .filters-panel { background: none; border: 0; box-shadow: none; padding: 0; position: static; max-height: none; overflow: visible; } @@ -1540,6 +1547,10 @@ body.pk.pk-hero-centered .pk-fan { display: none; } /* The back-to-top button and the cookie icon move above the bar. */ body.pk-tabbar-on #scroll-to-top { bottom: calc(76px + env(safe-area-inset-bottom, 0px)) !important; } body.pk-tabbar-on #silktide-cookie-icon { bottom: calc(76px + env(safe-area-inset-bottom, 0px)) !important; transition: bottom .28s var(--pk-ease); } + /* The cookie banner too: at its own bottom: 16px it lay over the bar, and a + visitor who had not answered it yet tapped the banner instead of the + tab, which then needed a second tap. */ + body.pk-tabbar-on #silktide-banner { bottom: calc(76px + env(safe-area-inset-bottom, 0px)) !important; max-height: calc(100vh - 92px - env(safe-area-inset-bottom, 0px)); transition: bottom .28s var(--pk-ease); } } @media (max-width: 767px) and (prefers-reduced-motion: reduce) { .pk-tabbar, .pk-tabbar.is-visible { transition: none; } diff --git a/tests/catalog-filters-sidebar.spec.js b/tests/catalog-filters-sidebar.spec.js new file mode 100644 index 000000000..5ebe5a38d --- /dev/null +++ b/tests/catalog-filters-sidebar.spec.js @@ -0,0 +1,73 @@ +// @ts-check +/** + * On a desktop the catalogue filters are a sidebar beside the books, whatever + * the names in them. A live library has publishers named in a hundred + * characters ("Cross-National Research Group, European Research Centre, + * Loughborough University of Technology"): shown with an ellipsis, they still + * counted at full length in the column's minimum width, the column grew to + * the whole page and the books went under the filters. + * + * The data is seeded (a publisher with such a name on three books), the check + * is what a visitor sees: the filters on the left, at most 300px, the books + * beside them. + * + * Run: /tmp/run-e2e.sh tests/catalog-filters-sidebar.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 LONG = 'Cross-National Research Group, European Research Centre, Loughborough University of Technology E2E'; + +function db(sql) { + const args = ['--default-character-set=utf8mb4', '-N', '-B', '--raw', '-e', sql]; + if (process.env.E2E_DB_HOST) args.push('-h', process.env.E2E_DB_HOST, ...(process.env.E2E_DB_PORT ? ['-P', process.env.E2E_DB_PORT] : [])); + else if (process.env.E2E_DB_SOCKET) args.push('-S', process.env.E2E_DB_SOCKET); + args.push('-u', process.env.E2E_DB_USER || '', process.env.E2E_DB_NAME || ''); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 15000, env: { ...process.env, MYSQL_PWD: process.env.E2E_DB_PASS || '' } }).trim(); +} + +test.skip(!process.env.E2E_DB_USER, 'database credentials required'); + +test.describe.serial('Catalogue filters sidebar', () => { + let publisherId = ''; + /** @type {Array<[string, string]>} */ + let books = []; + + test.beforeAll(() => { + db(`INSERT INTO editori (nome) VALUES ('${LONG}')`); + publisherId = db(`SELECT id FROM editori WHERE nome = '${LONG}' ORDER BY id DESC LIMIT 1`); + books = db('SELECT id, IFNULL(editore_id, \'NULL\') FROM libri WHERE deleted_at IS NULL ORDER BY id DESC LIMIT 3') + .split('\n').filter(Boolean).map((l) => /** @type {[string, string]} */ (l.split('\t'))); + for (const [id] of books) { + db(`UPDATE libri SET editore_id = ${publisherId} WHERE id = ${id}`); + db(`INSERT IGNORE INTO libri_editori (libro_id, editore_id, ordine) VALUES (${id}, ${publisherId}, 99)`); + } + }); + + test.afterAll(() => { + try { + for (const [id, prev] of books) db(`UPDATE libri SET editore_id = ${prev} WHERE id = ${id}`); + if (publisherId) { + db(`DELETE FROM libri_editori WHERE editore_id = ${publisherId}`); + db(`DELETE FROM editori WHERE id = ${publisherId}`); + } + } catch { /* best effort */ } + }); + + for (const width of [1440, 1024]) { + test(`at ${width}px the filters stay a sidebar beside the books`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto(`${BASE}/catalogo`, { waitUntil: 'networkidle' }); + await expect(page.locator('#publishers-filter')).toContainText('Loughborough'); + const r = await page.evaluate(() => { + const f = document.querySelector('.pk-filters').getBoundingClientRect(); + const g = document.querySelector('.pk-results').getBoundingClientRect(); + return { fw: Math.round(f.width), fRight: Math.round(f.right), gLeft: Math.round(g.left), sameRow: Math.abs(f.top - g.top) < 120 }; + }); + expect(r.fw, 'the sidebar keeps its width').toBeLessThanOrEqual(300); + expect(r.sameRow, 'the books sit beside the filters, not under them').toBe(true); + expect(r.gLeft, 'the books start right of the filters').toBeGreaterThan(r.fRight); + }); + } +}); diff --git a/tests/mobile-tabbar-cookie-banner.spec.js b/tests/mobile-tabbar-cookie-banner.spec.js new file mode 100644 index 000000000..1f3c4991b --- /dev/null +++ b/tests/mobile-tabbar-cookie-banner.spec.js @@ -0,0 +1,79 @@ +// @ts-check +/** + * On a phone, a visitor who has not answered the cookie banner yet opens the + * catalogue from the tab bar with ONE tap. The banner sat at bottom: 16px over + * the bar: the first tap landed on the banner and only the second reached the + * tab. Signed-in readers had answered it long before, so only visitors saw it. + * + * The administrator switches the banner on from Settings → Privacy (and back + * as it was afterwards); the visitor is a fresh phone with no consent stored. + * + * Run: /tmp/run-e2e.sh tests/mobile-tabbar-cookie-banner.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect, devices } = require('@playwright/test'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; + +test.skip(!ADMIN_EMAIL || !ADMIN_PASS, 'admin credentials required'); + +test.describe.serial('Tab bar with the cookie banner open', () => { + /** @type {import('@playwright/test').Page} */ + let admin; + let wasOn = null; + + async function setBanner(on) { + await admin.goto(`${BASE}/admin/settings?tab=privacy`); + const box = admin.locator('#cookie_banner_enabled'); + if ((await box.isChecked()) !== on) { + await box.setChecked(on, { force: true }); + await Promise.all([ + admin.waitForLoadState('load'), + admin.locator('form[action*="cookie-banner"] button[type="submit"]').first().click(), + ]); + } + } + + test.beforeAll(async ({ browser }) => { + admin = await browser.newPage(); + await admin.goto(`${BASE}/accedi`); + await admin.fill('input[name="email"]', ADMIN_EMAIL); + await admin.fill('input[name="password"]', ADMIN_PASS); + await admin.locator('button[type="submit"]').click(); + await admin.waitForURL((u) => !u.pathname.includes('accedi'), { timeout: 30000 }); + await admin.goto(`${BASE}/admin/settings?tab=privacy`); + wasOn = await admin.locator('#cookie_banner_enabled').isChecked(); + await setBanner(true); + }); + + test.afterAll(async () => { + try { if (wasOn !== null) await setBanner(wasOn); } catch { /* best effort */ } + await admin?.close(); + }); + + for (const path of ['/', '/catalogo?page=2']) { + test(`from ${path}, one tap on "Catalogo" opens the catalogue`, async ({ browser }) => { + const ctx = await browser.newContext({ ...devices['Pixel 7'] }); + const phone = await ctx.newPage(); + try { + await phone.goto(BASE + path, { waitUntil: 'networkidle' }); + await expect(phone.locator('#silktide-banner'), 'the banner waits for an answer').toBeVisible({ timeout: 10000 }); + // The reader scrolls; the bar comes up. + await phone.evaluate(() => window.scrollTo(0, 400)); + const bar = phone.locator('[data-pk-tabbar].is-visible'); + test.skip(await phone.evaluate(() => document.documentElement.scrollHeight <= window.innerHeight + 30), 'page too short for the bar'); + await expect(bar).toBeVisible(); + const tab = bar.locator('.pk-tabbar__item').nth(1); + await phone.waitForTimeout(400); // the bar's slide-in + const b = await tab.boundingBox(); + expect(b).not.toBeNull(); + const from = phone.url(); + await phone.touchscreen.tap(b.x + b.width / 2, b.y + b.height / 2); + await phone.waitForURL((u) => u.href !== from && /catalog/.test(u.pathname + u.search) && !/page=2/.test(u.search), { timeout: 5000 }); + } finally { + await ctx.close(); + } + }); + } +}); diff --git a/tests/search-suggestions-layer.spec.js b/tests/search-suggestions-layer.spec.js index 2fc0718e1..17a5d896e 100644 --- a/tests/search-suggestions-layer.spec.js +++ b/tests/search-suggestions-layer.spec.js @@ -24,6 +24,11 @@ for (const [name, viewport] of [['phone', { width: 375, height: 812 }], ['deskto const results = page.locator('.pk-hero .search-results.is-visible'); await expect(results).toBeVisible({ timeout: 10000 }); await expect(results.locator('a').first()).toBeVisible(); + // While typing, only the rounded box shows the focus: no second rectangle + // around the text. + const ring = await input.evaluate((el) => ({ shadow: getComputedStyle(el).boxShadow, outline: getComputedStyle(el).outlineStyle })); + expect(ring.shadow, 'no ring around the typed text').toBe('none'); + expect(ring.outline).toBe('none'); // Bring the box to the top of the screen, as a visitor scrolls to read on. await page.evaluate(() => { From 59857fa1d8e313e3a6f72408f1a52b1185cf61fc Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sat, 10 Oct 2026 01:47:04 +0200 Subject: [PATCH 4/5] Release 0.8.2: version, changelog and README highlights --- CHANGELOG.md | 10 ++++++++++ README.md | 6 +++++- version.json | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3feaff49..0a08b54ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. +## [0.8.2] + +### Fixed +- **The catalogue filters are a sidebar again, whatever the names in them.** A publisher named in a hundred characters (shown with an ellipsis) still counted at full length in the width of the filters column: on a live library the column took the whole page and the books went under the filters. The column now has a width of its own, a quarter of the page between 250 and 300px. +- **One tap on the phone's tab bar.** For a visitor who had not answered the cookie banner yet, the banner lay over the bar and took the first tap, so "Catalogo" needed a second one. The banner now sits above the bar. +- **The book page on a phone has no uneven rows.** Year, pages, format and ISBN go two by two instead of three and one; the share buttons and the citation actions go one per row, the citation styles stay on one line that scrolls sideways; the card titles keep room above their buttons; the loan and favourite buttons start where "Available" / "Not available today" starts and fill the box. +- The home search no longer draws a second rectangle around the text while typing: the rounded box alone shows the focus. + +No migration. + ## [0.8.1] ### Fixed diff --git a/README.md b/README.md index e456ae8f5..368c74f9b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,11 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri Highlights of the latest release are below. The full version-by-version history (v0.7.59 → v0.6.x) lives in **[CHANGELOG.md](CHANGELOG.md)**. -### v0.8.1 — latest +### v0.8.2 — latest + +**The catalogue keeps its sidebar.** A publisher with a very long name no longer pushes the books under the filters: the filters column has a width of its own. On phones the tab bar opens the catalogue with one tap even while the cookie banner is waiting for an answer, the book page lines up its facts two by two and its buttons one per row, and the home search draws no extra ring around the text. No migration. + +### v0.8.1 **What the CMS says is what the site shows.** Every text of the home, the catalogue header and the new events header is edited in the admin and shown exactly as written: no hidden default replaces an empty field. The image of a CMS page such as About us is saved when Save is pressed, the search suggestions under the home's search box open on top of the page instead of under the next section, the catalogue grid lines its cards up, and the events page title and subtitle are editable per language. No migration. diff --git a/version.json b/version.json index d134ae6b1..df38dcf92 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "name": "Pinakes", - "version": "0.8.1", + "version": "0.8.2", "description": "Library Management System - Sistema di Gestione Bibliotecaria" } From 829c116c6f52f43737367d8c1c3c422ebbe00ae5 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sat, 10 Oct 2026 02:22:00 +0200 Subject: [PATCH 5/5] Review: results never wrap above 860px; tests confirm the banner save and report cleanup errors - .pk-results takes a zero flex basis: between 861px and ~880px the filters, the gap and a 520px basis did not fit the row and the books still wrapped under the filters. The spec now also checks 862px. - The cookie-banner spec accepts a confirmation after saving, then reads the setting back from a fresh load before running; cleanup errors are logged in both new specs instead of swallowed. --- public/assets/pinakes-2026.css | 4 +++- tests/catalog-filters-sidebar.spec.js | 8 ++++++-- tests/mobile-tabbar-cookie-banner.spec.js | 8 +++++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/public/assets/pinakes-2026.css b/public/assets/pinakes-2026.css index e6c6e44ae..10f4e61a8 100644 --- a/public/assets/pinakes-2026.css +++ b/public/assets/pinakes-2026.css @@ -611,7 +611,9 @@ body.pk .pk-catalog-head .catalog-header-content { text-align: left; max-width: characters (shown with an ellipsis) still counted at full length in the column's minimum width, which pushed the books under the filters. */ .pk-filters { flex: 0 0 clamp(250px, 25%, 300px); min-width: 0; max-width: 100%; position: sticky; top: 88px; } -.pk-results { flex: 1 1 520px; min-width: 0; display: flex; flex-direction: column; gap: 28px; } +/* A zero basis: the results never wrap under the filters on their own, only when the + filters take the full width below 860px. */ +.pk-results { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 28px; } @media (max-width: 860px) { .pk-filters { position: static; flex-basis: 100%; } } body.pk :is(.pk-filters, .filters-panel) .filters-panel { background: none; border: 0; box-shadow: none; padding: 0; position: static; max-height: none; overflow: visible; } diff --git a/tests/catalog-filters-sidebar.spec.js b/tests/catalog-filters-sidebar.spec.js index 5ebe5a38d..612d82fad 100644 --- a/tests/catalog-filters-sidebar.spec.js +++ b/tests/catalog-filters-sidebar.spec.js @@ -52,10 +52,14 @@ test.describe.serial('Catalogue filters sidebar', () => { db(`DELETE FROM libri_editori WHERE editore_id = ${publisherId}`); db(`DELETE FROM editori WHERE id = ${publisherId}`); } - } catch { /* best effort */ } + } catch (e) { + console.warn('Seeded publisher not cleaned up:', e); + } }); - for (const width of [1440, 1024]) { + // 862px: just above the 860px breakpoint, where filters + gap + a 520px results + // basis did not fit the row and the books still wrapped under the filters. + for (const width of [1440, 1024, 862]) { test(`at ${width}px the filters stay a sidebar beside the books`, async ({ page }) => { await page.setViewportSize({ width, height: 900 }); await page.goto(`${BASE}/catalogo`, { waitUntil: 'networkidle' }); diff --git a/tests/mobile-tabbar-cookie-banner.spec.js b/tests/mobile-tabbar-cookie-banner.spec.js index 1f3c4991b..9f8d2596b 100644 --- a/tests/mobile-tabbar-cookie-banner.spec.js +++ b/tests/mobile-tabbar-cookie-banner.spec.js @@ -32,7 +32,13 @@ test.describe.serial('Tab bar with the cookie banner open', () => { admin.waitForLoadState('load'), admin.locator('form[action*="cookie-banner"] button[type="submit"]').first().click(), ]); + // A confirmation, when the page asks for one, is accepted as the administrator would. + const ok = admin.locator('.swal2-confirm'); + if (await ok.isVisible().catch(() => false)) await ok.click(); } + // The setting is what the form now shows after a fresh load, or the test would run on a guess. + await admin.goto(`${BASE}/admin/settings?tab=privacy`); + expect(await admin.locator('#cookie_banner_enabled').isChecked(), 'the banner setting was saved').toBe(on); } test.beforeAll(async ({ browser }) => { @@ -48,7 +54,7 @@ test.describe.serial('Tab bar with the cookie banner open', () => { }); test.afterAll(async () => { - try { if (wasOn !== null) await setBanner(wasOn); } catch { /* best effort */ } + try { if (wasOn !== null) await setBanner(wasOn); } catch (e) { console.warn('Cookie banner setting not restored:', e); } await admin?.close(); });