From acb9e85c7a2a12afd7ef8e79f473aa89f6814021 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 7 Oct 2026 00:22:34 +0200 Subject: [PATCH 01/45] Restyle the public site and the account pages (2026 design) Home, catalogue and book page follow the 2026 mockup; every other public page, the sign-in pages and the account pages use the same design system (public/assets/pinakes-2026.css, pinakes-2026.js), with Geist and Newsreader self-hosted. - One book card for the home, the catalogue and related books (partials/pk-book-card.php): the cover as a 3D book on a panel tinted from the cover, with every element the old card had (availability, live badge, digital-content icons, wanted badge, media badge, subtitle, publisher, Details) plus a wishlist heart. - Hero without a background image: the CMS picks the latest covers or up to four chosen books; the first cover is preloaded as the LCP image. - Catalogue: filter column, author finder, Grid/List toggle remembered per visitor. - Book page: cover beside the identity, availability box with copies, quick facts, inline citation with Copy and RIS. - Theme colours drive every surface: a filled surface always carries its paired text colour (button/button_text). - frontend-layouts.css is no longer linked on public and account pages; its functional rules (mobile filters toggle) are ported. - Source-level tests updated to the new markup, asserting the same guarantees (escaping, pending badge, 44px search target, mobile collapse of an empty publisher line). --- app/Controllers/CmsController.php | 44 + app/Controllers/FrontendController.php | 80 +- app/Support/ContentCache.php | 2 +- app/Views/auth/partials/auth-theme.php | 36 +- app/Views/cms/edit-home.php | 197 ++- app/Views/frontend/book-detail.php | 297 ++--- app/Views/frontend/catalog-grid.php | 109 +- app/Views/frontend/catalog.php | 230 ++-- app/Views/frontend/home-books-grid.php | 98 +- app/Views/frontend/home-sections/cta.php | 24 +- .../frontend/home-sections/features_title.php | 68 +- app/Views/frontend/home-sections/hero.php | 157 ++- .../home-sections/latest_books_title.php | 47 +- .../frontend/home-sections/text_content.php | 40 +- app/Views/frontend/home.php | 523 +------- app/Views/frontend/layout.php | 251 ++-- app/Views/frontend/partials/article-card.php | 45 +- app/Views/frontend/partials/breadcrumb.php | 20 +- app/Views/frontend/partials/catalog-hero.php | 18 +- app/Views/frontend/partials/pk-book-card.php | 115 ++ app/Views/partials/cite-inline.php | 39 + app/Views/user_layout.php | 66 +- locale/da_DK.json | 26 +- locale/de_DE.json | 26 +- locale/en_US.json | 26 +- locale/fr_FR.json | 26 +- locale/it_IT.json | 26 +- .../assets/fonts/Geist-normal-latin-ext.woff2 | Bin 0 -> 16540 bytes public/assets/fonts/Geist-normal-latin.woff2 | Bin 0 -> 29288 bytes .../fonts/Newsreader-italic-latin-ext.woff2 | Bin 0 -> 95412 bytes .../fonts/Newsreader-italic-latin.woff2 | Bin 0 -> 147060 bytes .../fonts/Newsreader-normal-latin-ext.woff2 | Bin 0 -> 86628 bytes .../fonts/Newsreader-normal-latin.woff2 | Bin 0 -> 131848 bytes public/assets/fonts/fonts.css | 57 + public/assets/pinakes-2026.css | 1142 +++++++++++++++++ public/assets/pinakes-2026.js | 198 +++ tests/frontend-layout-variants.unit.php | 25 +- tests/frontend-partials.unit.php | 12 +- tests/frontend-theme-a11y.unit.php | 3 +- tests/hero-upload-292.spec.js | 104 -- tests/hero-upload-server-292.spec.js | 313 ----- tests/home-hero-covers-2026.spec.js | 100 ++ tests/hot-dataset-cache-387.unit.php | 19 +- tests/litespeed-edge-cache.unit.php | 8 +- tests/related-books-responsive-278.spec.js | 5 +- tests/uppy-image-preview.spec.js | 51 - 46 files changed, 2644 insertions(+), 2029 deletions(-) create mode 100644 app/Views/frontend/partials/pk-book-card.php create mode 100644 app/Views/partials/cite-inline.php create mode 100644 public/assets/fonts/Geist-normal-latin-ext.woff2 create mode 100644 public/assets/fonts/Geist-normal-latin.woff2 create mode 100644 public/assets/fonts/Newsreader-italic-latin-ext.woff2 create mode 100644 public/assets/fonts/Newsreader-italic-latin.woff2 create mode 100644 public/assets/fonts/Newsreader-normal-latin-ext.woff2 create mode 100644 public/assets/fonts/Newsreader-normal-latin.woff2 create mode 100644 public/assets/pinakes-2026.css create mode 100644 public/assets/pinakes-2026.js delete mode 100644 tests/hero-upload-292.spec.js delete mode 100644 tests/hero-upload-server-292.spec.js create mode 100644 tests/home-hero-covers-2026.spec.js diff --git a/app/Controllers/CmsController.php b/app/Controllers/CmsController.php index 2a29b1b7c..f4c3022f2 100644 --- a/app/Controllers/CmsController.php +++ b/app/Controllers/CmsController.php @@ -180,6 +180,27 @@ public function editHome(Request $request, Response $response, \mysqli $db, arra // Include the specific view first ob_start(); + // The hero's picked cover books, with their titles for the picker. + $heroCoverConfig = \App\Controllers\FrontendController::heroCoverConfig($sections['hero']['content'] ?? null); + $heroCoverBooks = []; + if ($heroCoverConfig['books'] !== []) { + $marks = implode(',', array_fill(0, count($heroCoverConfig['books']), '?')); + $coverStmt = $db->prepare("SELECT id, titolo, copertina_url FROM libri WHERE deleted_at IS NULL AND id IN ($marks)"); + if ($coverStmt !== false) { + $coverStmt->bind_param(str_repeat('i', count($heroCoverConfig['books'])), ...$heroCoverConfig['books']); + $coverStmt->execute(); + $coverRows = []; + foreach ($coverStmt->get_result()->fetch_all(MYSQLI_ASSOC) as $coverRow) { + $coverRows[(int) $coverRow['id']] = $coverRow; + } + $coverStmt->close(); + foreach ($heroCoverConfig['books'] as $coverId) { + if (isset($coverRows[$coverId])) { + $heroCoverBooks[] = $coverRows[$coverId]; + } + } + } + } include __DIR__ . '/../Views/cms/edit-home.php'; $content = ob_get_clean(); @@ -442,6 +463,29 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar ); $stmt->execute(); $stmt->close(); + + // Hero covers (2026 design): the latest catalogued covers, or up + // to four books picked here, in the order picked. Stored as JSON + // in the hero row's `content`, read by FrontendController::heroCovers(). + if (isset($heroData['cover_mode'])) { + $coverIds = []; + foreach ((array) ($heroData['cover_books'] ?? []) as $coverId) { + $coverId = (int) $coverId; + if ($coverId > 0 && !in_array($coverId, $coverIds, true) && count($coverIds) < 4) { + $coverIds[] = $coverId; + } + } + $coverConfig = json_encode([ + 'cover_mode' => $heroData['cover_mode'] === 'selected' ? 'selected' : 'latest', + 'cover_books' => $coverIds, + ]); + $coverStmt = $db->prepare("UPDATE home_content SET content = ? WHERE section_key = 'hero'"); + if ($coverStmt !== false && $coverConfig !== false) { + $coverStmt->bind_param('s', $coverConfig); + $coverStmt->execute(); + $coverStmt->close(); + } + } } } diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index a772c1076..4ea9e2007 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -34,7 +34,7 @@ public function home(Request $request, Response $response, mysqli $db, ?Containe // events) clear the 'home_' prefix via ContentCache — which also // covers the home_api_count_* keys below — while the TTL covers // loan-driven availability drift. - $homeData = \App\Support\QueryCache::remember('home_page_data_v1', function () use ($db) { + $homeData = \App\Support\QueryCache::remember('home_page_data_v2', function () use ($db) { return $this->buildHomePageData($db); }, 300); @@ -57,6 +57,7 @@ public function home(Request $request, Response $response, mysqli $db, ?Containe } $homeEvents = $homeData['homeEvents']; $heroTotalBooks = $homeData['totalBooks']; + $heroCovers = $homeData['heroCovers'] ?? []; $heroAvailableBooks = $homeData['availableBooks']; $homeEventsEnabled = $homeData['eventsFeatureEnabled'] && !empty($homeEvents); @@ -2667,6 +2668,80 @@ private function collectGenreTreeIds(array $childrenByParent, int $rootId): arra * latestBooksTotal: int, genres_with_books: array, genreCarouselEnabled: bool, * eventsFeatureEnabled: bool, homeEvents: array, totalBooks: int, availableBooks: int} */ + /** + * The hero's cover settings, stored as JSON in home_content.content of the + * 'hero' row: {"cover_mode": "latest"|"selected", "cover_books": [ids]}. + * Anything else reads as the default, the latest covers. + * + * @return array{mode: string, books: list} + */ + public static function heroCoverConfig(?string $raw): array + { + $data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : null; + $mode = is_array($data) && ($data['cover_mode'] ?? '') === 'selected' ? 'selected' : 'latest'; + $books = []; + foreach ((array) (is_array($data) ? ($data['cover_books'] ?? []) : []) as $id) { + $id = (int) $id; + if ($id > 0 && !in_array($id, $books, true)) { + $books[] = $id; + } + } + return ['mode' => $mode, 'books' => array_slice($books, 0, 4)]; + } + + /** + * Up to four books with a cover for the home hero: the ones the CMS picked, + * in the order it picked them, or the latest catalogued covers. A picked + * book that lost its cover, was deleted or left the catalogue is skipped; + * when none is left the hero falls back to the latest covers. + * + * @return list> + */ + private function heroCovers(mysqli $db, ?string $raw): array + { + $config = self::heroCoverConfig($raw); + $select = "SELECT l.id, l.titolo, l.copertina_url, + (SELECT " . \App\Support\AuthorName::displaySql('a') . " FROM libri_autori la JOIN autori a ON la.autore_id = a.id + WHERE la.libro_id = l.id AND la.ruolo IN ('principale','co-autore') ORDER BY la.ruolo = 'principale' DESC LIMIT 1) AS autore, + (SELECT a.nome FROM libri_autori la JOIN autori a ON la.autore_id = a.id + WHERE la.libro_id = l.id AND la.ruolo IN ('principale','co-autore') ORDER BY la.ruolo = 'principale' DESC LIMIT 1) AS autore_principale_nome + FROM libri l + WHERE l.deleted_at IS NULL AND " . \App\Support\BookVisibility::catalogue($db, 'l') . " + AND l.copertina_url IS NOT NULL AND l.copertina_url <> '' AND l.copertina_url NOT LIKE '%placeholder%'"; + $rows = []; + try { + if ($config['mode'] === 'selected' && $config['books'] !== []) { + $marks = implode(',', array_fill(0, count($config['books']), '?')); + $stmt = $db->prepare($select . " AND l.id IN ($marks)"); + if ($stmt !== false) { + $stmt->bind_param(str_repeat('i', count($config['books'])), ...$config['books']); + $stmt->execute(); + $byId = []; + foreach ($stmt->get_result()->fetch_all(MYSQLI_ASSOC) as $row) { + $byId[(int) $row['id']] = $row; + } + $stmt->close(); + foreach ($config['books'] as $id) { + if (isset($byId[$id])) { + $rows[] = $byId[$id]; + } + } + } + } + if ($rows === []) { + $result = $db->query($select . ' ORDER BY l.created_at DESC, l.id DESC LIMIT 4'); + if ($result instanceof \mysqli_result) { + $rows = $result->fetch_all(MYSQLI_ASSOC); + $result->free(); + } + } + } catch (\Throwable $e) { + \App\Support\SecureLogger::error('[Home] hero covers: ' . $e->getMessage()); + return []; + } + return $rows; + } + private function buildHomePageData(mysqli $db): array { // Carica i contenuti CMS della home (inclusi campi SEO completi) @@ -2849,7 +2924,7 @@ private function buildHomePageData(mysqli $db): array } } - // This payload is stored in the SHARED home cache (home_page_data_v1). + // This payload is stored in the SHARED home cache (home_page_data_v2). // Strip live availability (copie_*/stato — a stale count is a // double-loan risk) AND the private/non-shareable columns (l.* pulled // private_comment, lending_patron, search_index, …). Availability is @@ -2862,6 +2937,7 @@ private function buildHomePageData(mysqli $db): array return [ 'homeContent' => $homeContent, 'sectionsOrdered' => $sectionsOrdered, + 'heroCovers' => $this->heroCovers($db, $homeContent['hero']['content'] ?? null), 'latest_books' => $latest_books, 'latestBooksTotal' => $totalBooks, 'genres_with_books' => $genres_with_books, diff --git a/app/Support/ContentCache.php b/app/Support/ContentCache.php index 35384d5e9..63833ab79 100644 --- a/app/Support/ContentCache.php +++ b/app/Support/ContentCache.php @@ -18,7 +18,7 @@ final class ContentCache /** * Book metadata or taxonomy changed: invalidate catalog counts/facets, - * every home entry (home_page_data_v1 and home_api_count_*), the cached + * every home entry (home_page_data_v2 and home_api_count_*), the cached * genre tree and static detail DTOs. Availability-only writes use the * narrower availabilityChanged() path below. */ diff --git a/app/Views/auth/partials/auth-theme.php b/app/Views/auth/partials/auth-theme.php index da2a3b3fa..8e0a1b64c 100644 --- a/app/Views/auth/partials/auth-theme.php +++ b/app/Views/auth/partials/auth-theme.php @@ -42,30 +42,32 @@ --button-color: ; --button-text-color: ; --button-hover: ; - --text-color: #0f172a; - --text-light: #4b5563; - --border-color: #e5e7eb; - --auth-field-border: #737373; - --serif: 'Fraunces', Georgia, 'Times New Roman', serif; - --sans: 'Instrument Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + /* 2026 design (public/assets/pinakes-2026.css): same ink, lines and type. */ + --text-color: #1b1720; + --text-light: #6b6470; + --border-color: #ece8ea; + --auth-field-border: #cfc7cc; + --serif: 'Newsreader', Georgia, 'Times New Roman', serif; + --sans: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; color-scheme: light; } - .auth-body { margin: 0; background: #f9fafb; color: var(--text-color); font-family: var(--sans); letter-spacing: -.008em; } + .auth-body { margin: 0; background: linear-gradient(180deg, color-mix(in srgb, var(--primary-color) 5%, #fff) 0%, #fbfaf9 420px) no-repeat, #fbfaf9; color: var(--text-color); font-family: var(--sans) !important; /* main.css forces Inter on body */ -webkit-font-smoothing: antialiased; } .auth-page { min-height: 100vh; padding: 48px 16px; } .auth-wrap { max-width: 28rem; width: 100%; margin: 0 auto; } .auth-wrap--wide { max-width: 42rem; } .auth-brand { text-align: center; margin-bottom: 24px; } .auth-brand-logo { display: block; height: 56px; width: auto; max-width: 100%; margin: 0 auto 8px; object-fit: contain; } - .auth-brand-tile { width: 56px; height: 56px; margin: 0 auto 8px; display: flex; align-items: center; justify-content: center; border-radius: 3px; background: var(--primary-color); color: var(--button-text-color); font-size: 1.5rem; } + .auth-brand-tile { width: 56px; height: 56px; margin: 0 auto 8px; display: flex; align-items: center; justify-content: center; border-radius: 16px; background: var(--primary-color); color: var(--button-text-color); font-size: 1.5rem; } .auth-brand-name { margin: 0; font-size: .9375rem; font-weight: 600; color: var(--text-color); } - .auth-card { background: #fff; border: 1px solid var(--border-color); border-radius: 2px; box-shadow: none; padding: 32px; } - .auth-title { margin: 0 0 8px; font-family: var(--serif); font-size: 1.875rem; line-height: 1.15; font-weight: 440; letter-spacing: -.025em; color: var(--text-color); } + .auth-card { background: #fff; border: 1px solid var(--border-color); border-radius: 20px; box-shadow: 0 16px 40px -28px rgba(80,20,50,.35); padding: 36px; } + .auth-title { margin: 0 0 8px; font-family: var(--serif); font-size: 2.5rem; line-height: 1.05; font-weight: 500; letter-spacing: -.025em; color: var(--text-color); } .auth-subtitle { margin: 0 0 24px; color: var(--text-light); font-size: .9375rem; } .auth-form > * + * { margin-top: 20px; } .auth-label { display: block; margin-bottom: 6px; font-size: .875rem; font-weight: 600; color: var(--text-color); } - .auth-input { display: block; width: 100%; min-height: 44px; padding: 10px 12px; box-sizing: border-box; font: inherit; font-size: 1rem; color: var(--text-color); background: #fff; border: 1px solid var(--auth-field-border); border-radius: 2px; } + .auth-input { display: block; width: 100%; min-height: 44px; padding: 10px 12px; box-sizing: border-box; font: inherit; font-size: 1rem; color: var(--text-color); background: #fff; border: 1px solid var(--auth-field-border); border-radius: 10px; } textarea.auth-input { min-height: 88px; } - .auth-input:focus-visible, .auth-check input:focus-visible { outline: 3px solid var(--primary-color); outline-offset: 1px; } + .auth-input:focus-visible { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent); } + .auth-check input:focus-visible { outline: 3px solid var(--primary-color); outline-offset: 1px; } .auth-help { margin: 6px 0 0; font-size: .8125rem; color: var(--text-light); } .auth-field-error { display: block; margin-top: 4px; font-size: .875rem; color: #b91c1c; } .auth-field-error.hidden { display: none; } @@ -74,19 +76,19 @@ .auth-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 4px 16px; } .auth-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px 16px; } @media (min-width: 640px) { .auth-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } - .auth-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: 100%; min-height: 44px; padding: 10px 16px; box-sizing: border-box; font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none; cursor: pointer; color: var(--button-text-color); background: var(--button-color); border: 1px solid var(--button-color); border-radius: 2px; box-shadow: none; } + .auth-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: 100%; min-height: 44px; padding: 10px 16px; box-sizing: border-box; font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none; cursor: pointer; color: var(--button-text-color); background: var(--button-color); border: 1px solid var(--button-color); border-radius: 12px; box-shadow: none; padding-top: 13px; padding-bottom: 13px; } .auth-btn:hover { background: var(--button-hover); border-color: var(--button-hover); color: var(--button-text-color); } .auth-btn:focus-visible { outline: 3px solid var(--primary-color); outline-offset: 2px; } .auth-btn:disabled { opacity: .7; cursor: not-allowed; } - .auth-link { display: inline-flex; align-items: center; min-height: 44px; color: var(--text-color); font-weight: 500; text-decoration: underline; text-underline-offset: 2px; } - .auth-link:hover { color: var(--primary-dark); } + .auth-link { display: inline-flex; align-items: center; min-height: 44px; color: var(--primary-color); font-weight: 500; text-decoration: none; } + .auth-link:hover { color: var(--primary-dark); text-decoration: underline; text-underline-offset: 2px; } .auth-link:focus-visible { outline: 3px solid var(--primary-color); outline-offset: 1px; } .auth-switch { margin: 16px 0 0; text-align: center; font-size: .875rem; color: var(--text-light); } .auth-footer { margin-top: 24px; text-align: center; font-size: .875rem; color: var(--text-light); } .auth-footer-links { display: flex; justify-content: center; flex-wrap: wrap; gap: 0 24px; } .auth-footer .auth-link { font-weight: 400; color: var(--text-light); } .auth-copy { margin: 8px 0 0; font-size: .75rem; color: var(--text-light); } - .auth-alert { padding: 12px 16px; font-size: .875rem; border: 1px solid; border-radius: 2px; } + .auth-alert { padding: 12px 16px; font-size: .875rem; border: 1px solid; border-radius: 12px; } .auth-alert-body { display: flex; align-items: flex-start; gap: 12px; } .auth-alert i { margin-top: 2px; } .auth-strength { display: flex; align-items: center; gap: 8px; } @@ -96,7 +98,7 @@ .auth-alert--success { background: #f0fdf4; border-color: #bbf7d0; color: #166534; } .auth-alert p { margin: 0; } .auth-alert p + p { margin-top: 8px; } - .auth-success-icon { width: 56px; height: 56px; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center; border-radius: 3px; background: #f0fdf4; color: #166534; font-size: 1.5rem; } + .auth-success-icon { width: 56px; height: 56px; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: #f0fdf4; color: #166534; font-size: 1.5rem; } .auth-center { text-align: center; } @media (max-width: 480px) { .auth-card { padding: 24px 16px; } } diff --git a/app/Views/cms/edit-home.php b/app/Views/cms/edit-home.php index bcc1b6027..d1d66861d 100644 --- a/app/Views/cms/edit-home.php +++ b/app/Views/cms/edit-home.php @@ -152,34 +152,45 @@ class="block w-full rounded-xl border-gray-300 focus:border-gray-500 focus:ring- -
- - -
- Sfondo hero -
- -
-
-

1 ? __('Nessuna altra proposta oltre questa pagina.') : __('Non sono ancora arrivate proposte.') ?>

@@ -170,7 +170,7 @@

- +

1 ? __('Nessuna altra richiesta oltre questa pagina.') : __('Non ci sono richieste aperte. Crea una scheda libro e seleziona Desiderata prima del numero di copie.') ?>

diff --git a/storage/plugins/desiderata/views/dashboard.php b/storage/plugins/desiderata/views/dashboard.php index 10e71390c..c30525a2f 100644 --- a/storage/plugins/desiderata/views/dashboard.php +++ b/storage/plugins/desiderata/views/dashboard.php @@ -38,7 +38,7 @@
-
+
From 0f55a260eb6ff438372df521f15093ecda5c2cb9 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 7 Oct 2026 17:05:28 +0200 Subject: [PATCH 20/45] Let the admin take Emeroteca and Archive out of the public menu Events could already be hidden from the menu; Emeroteca and Archive could not, short of deactivating the plugin. Each plugin's admin page now has the same visibility card as the Events page, stored as cms.emeroteca_in_menu and cms.archives_in_menu. Only the menu entry goes, on the desktop, mobile and account menus: the pages stay reachable, since catalogue and search results link to them. The account pages' menu also lacked the Archive entry the public menu has; it now lists it under the same conditions. --- app/Support/ConfigStore.php | 13 +++ .../admin/partials/menu-visibility-toggle.php | 96 +++++++++++++++++++ app/Views/frontend/layout.php | 4 +- app/Views/user_layout.php | 28 ++++++ locale/da_DK.json | 8 +- locale/de_DE.json | 8 +- locale/en_US.json | 8 +- locale/fr_FR.json | 8 +- locale/it_IT.json | 8 +- storage/plugins/archives/ArchivesPlugin.php | 10 ++ storage/plugins/archives/views/index.php | 5 + storage/plugins/emeroteca/EmerotecaPlugin.php | 10 ++ storage/plugins/emeroteca/src/Views/index.php | 5 + tests/menu-visibility-plugins.spec.js | 79 +++++++++++++++ 14 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 app/Views/admin/partials/menu-visibility-toggle.php create mode 100644 tests/menu-visibility-plugins.spec.js diff --git a/app/Support/ConfigStore.php b/app/Support/ConfigStore.php index 7ee37d647..1bb017ed0 100644 --- a/app/Support/ConfigStore.php +++ b/app/Support/ConfigStore.php @@ -132,6 +132,10 @@ public static function all(): array ], 'cms' => [ 'events_page_enabled' => '1', // Default to enabled + // Plugin sections listed in the public menu (the pages stay + // reachable: catalogue and search results link to them). + 'emeroteca_in_menu' => '1', + 'archives_in_menu' => '1', ], 'sharing' => [ 'enabled_providers' => 'facebook,x,whatsapp,email', @@ -325,6 +329,15 @@ public static function isCatalogueMode(): bool return (bool) self::get('system.catalogue_mode', false); } + /** + * Whether a plugin section ('emeroteca', 'archives') has its entry in the + * public menu. Only the menu entry: the section's pages stay reachable. + */ + public static function isInPublicMenu(string $section): bool + { + return (string) self::get("cms.{$section}_in_menu", '1') === '1'; + } + private static function mergeRecursiveDistinct(array $base, array $replacements): array { foreach ($replacements as $key => $value) { diff --git a/app/Views/admin/partials/menu-visibility-toggle.php b/app/Views/admin/partials/menu-visibility-toggle.php new file mode 100644 index 000000000..ea61acda9 --- /dev/null +++ b/app/Views/admin/partials/menu-visibility-toggle.php @@ -0,0 +1,96 @@ + string absolute URL the form posts to (already url()-ed), + * 'enabled' => bool current state, + * 'title' => string translated card title, + * ]; + */ +$menuToggleEnabled = (bool) ($menuToggle['enabled'] ?? true); +?> +
+ +
+ + diff --git a/app/Views/frontend/layout.php b/app/Views/frontend/layout.php index fe98421ca..9cd6f12fb 100644 --- a/app/Views/frontend/layout.php +++ b/app/Views/frontend/layout.php @@ -1555,10 +1555,10 @@ $publicNavItems = [ ['href' => $catalogRoute, 'label' => __('Catalogo'), 'icon' => 'fa-book', 'active' => $navPathActive((string) $catalogRoute)], ]; - if ($archivesAvailable) { + if ($archivesAvailable && ConfigStore::isInPublicMenu('archives')) { $publicNavItems[] = ['href' => $archivesRoute, 'label' => __('Archivio'), 'icon' => 'fa-archive', 'active' => $navPathActive((string) $archivesRoute)]; } - if ($emerotecaAvailable) { + if ($emerotecaAvailable && ConfigStore::isInPublicMenu('emeroteca')) { $publicNavItems[] = ['href' => '/emeroteca', 'label' => __('Emeroteca'), 'icon' => 'fa-newspaper', 'active' => $navPathActive('/emeroteca')]; } if ($eventsEnabled) { diff --git a/app/Views/user_layout.php b/app/Views/user_layout.php index 069726c1c..1079d66fa 100644 --- a/app/Views/user_layout.php +++ b/app/Views/user_layout.php @@ -67,6 +67,23 @@ } catch (\Throwable $e) { $emerotecaAvailable = false; } +$emerotecaAvailable = $emerotecaAvailable && ConfigStore::isInPublicMenu('emeroteca'); +// Archive (archives plugin): as in the public layout, listed when the plugin +// is active, at least one unit is published, and the menu entry is on. +$archivesAvailable = false; +$archivesRoute = '/archive'; +try { + if (isset($container, $db) && $db instanceof \mysqli && $container->has('pluginManager') + && $container->get('pluginManager')->isActive('archives') + && ConfigStore::isInPublicMenu('archives')) { + $unitCheck = $db->query('SELECT 1 FROM archival_units WHERE deleted_at IS NULL LIMIT 1'); + $archivesAvailable = $unitCheck instanceof \mysqli_result && $unitCheck->num_rows === 1; + if ($unitCheck instanceof \mysqli_result) { $unitCheck->free(); } + $archivesRoute = \App\Support\RouteTranslator::route('archives') ?: '/archive'; + } +} catch (\Throwable $e) { + $archivesAvailable = false; +} if (isset($db)) { try { $settingsRepository = new \App\Models\SettingsRepository($db); @@ -928,6 +945,11 @@ class="logo-image">
  • + +
  • +
  • +
  • @@ -1034,6 +1056,12 @@ class="hidden md:inline">"> + + + + + diff --git a/locale/da_DK.json b/locale/da_DK.json index 457dfc6e7..fe5f57677 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8095,5 +8095,11 @@ "Tinta": "Tonet", "Il libro su un pannello colorato con la tinta della sua copertina.": "Bogen på et panel i farven fra dens omslag.", "Scegli come appaiono l'hero della homepage e le copertine dei libri nel sito pubblico. Colori e contenuti restano quelli del tema.": "Vælg, hvordan forsidens hero og bogomslagene ser ud på det offentlige websted. Farver og indhold forbliver temaets.", - "Titolo e descrizione mostrati sopra gli eventi in programma": "Titel og beskrivelse vist over de kommende begivenheder" + "Titolo e descrizione mostrati sopra gli eventi in programma": "Titel og beskrivelse vist over de kommende begivenheder", + "Voce Emeroteca nel menu": "Tidsskrifter i menuen", + "Voce Archivio nel menu": "Arkiv i menuen", + "Visibile nel menu del sito": "Vist i webstedets menu", + "Nascosta dal menu del sito": "Skjult i webstedets menu", + "Quando è nascosta, la voce sparisce dal menu del sito pubblico. Le pagine restano raggiungibili dai risultati del catalogo e della ricerca.": "Når den er skjult, forsvinder punktet fra det offentlige websteds menu. Siderne kan stadig nås fra katalog- og søgeresultater.", + "Nascosta": "Skjult" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 64d955a71..c5b271ad2 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8095,5 +8095,11 @@ "Tinta": "Getönt", "Il libro su un pannello colorato con la tinta della sua copertina.": "Das Buch auf einer Fläche in der Farbe seines Covers.", "Scegli come appaiono l'hero della homepage e le copertine dei libri nel sito pubblico. Colori e contenuti restano quelli del tema.": "Wählen Sie, wie der Hero der Startseite und die Buchcover auf der öffentlichen Website aussehen. Farben und Inhalte bleiben die des Themes.", - "Titolo e descrizione mostrati sopra gli eventi in programma": "Titel und Beschreibung über den kommenden Veranstaltungen" + "Titolo e descrizione mostrati sopra gli eventi in programma": "Titel und Beschreibung über den kommenden Veranstaltungen", + "Voce Emeroteca nel menu": "Menüeintrag Zeitschriften", + "Voce Archivio nel menu": "Menüeintrag Archiv", + "Visibile nel menu del sito": "Im Menü der Website sichtbar", + "Nascosta dal menu del sito": "Im Menü der Website ausgeblendet", + "Quando è nascosta, la voce sparisce dal menu del sito pubblico. Le pagine restano raggiungibili dai risultati del catalogo e della ricerca.": "Ausgeblendet verschwindet der Eintrag aus dem Menü der öffentlichen Website. Die Seiten bleiben über Katalog- und Suchergebnisse erreichbar.", + "Nascosta": "Ausgeblendet" } diff --git a/locale/en_US.json b/locale/en_US.json index f34b374db..686659c0e 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8095,5 +8095,11 @@ "Tinta": "Tinted", "Il libro su un pannello colorato con la tinta della sua copertina.": "The book on a panel tinted with its cover's colour.", "Scegli come appaiono l'hero della homepage e le copertine dei libri nel sito pubblico. Colori e contenuti restano quelli del tema.": "Choose how the home page hero and the book covers look on the public site. Colours and content stay those of the theme.", - "Titolo e descrizione mostrati sopra gli eventi in programma": "Title and description shown above the upcoming events" + "Titolo e descrizione mostrati sopra gli eventi in programma": "Title and description shown above the upcoming events", + "Voce Emeroteca nel menu": "Periodicals entry in the menu", + "Voce Archivio nel menu": "Archive entry in the menu", + "Visibile nel menu del sito": "Shown in the site menu", + "Nascosta dal menu del sito": "Hidden from the site menu", + "Quando è nascosta, la voce sparisce dal menu del sito pubblico. Le pagine restano raggiungibili dai risultati del catalogo e della ricerca.": "When hidden, the entry disappears from the public site's menu. The pages stay reachable from catalogue and search results.", + "Nascosta": "Hidden" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index d121435ab..07a2cc5c0 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8095,5 +8095,11 @@ "Tinta": "Teinté", "Il libro su un pannello colorato con la tinta della sua copertina.": "Le livre sur un panneau teinté de la couleur de sa couverture.", "Scegli come appaiono l'hero della homepage e le copertine dei libri nel sito pubblico. Colori e contenuti restano quelli del tema.": "Choisissez l'apparence du hero de la page d'accueil et des couvertures sur le site public. Les couleurs et les contenus restent ceux du thème.", - "Titolo e descrizione mostrati sopra gli eventi in programma": "Titre et description affichés au-dessus des événements à venir" + "Titolo e descrizione mostrati sopra gli eventi in programma": "Titre et description affichés au-dessus des événements à venir", + "Voce Emeroteca nel menu": "Entrée Périodiques dans le menu", + "Voce Archivio nel menu": "Entrée Archives dans le menu", + "Visibile nel menu del sito": "Visible dans le menu du site", + "Nascosta dal menu del sito": "Masquée dans le menu du site", + "Quando è nascosta, la voce sparisce dal menu del sito pubblico. Le pagine restano raggiungibili dai risultati del catalogo e della ricerca.": "Masquée, l'entrée disparaît du menu du site public. Les pages restent accessibles depuis les résultats du catalogue et de la recherche.", + "Nascosta": "Masquée" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 3f2bfb66b..39ce5480e 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8095,5 +8095,11 @@ "Tinta": "Tinta", "Il libro su un pannello colorato con la tinta della sua copertina.": "Il libro su un pannello colorato con la tinta della sua copertina.", "Scegli come appaiono l'hero della homepage e le copertine dei libri nel sito pubblico. Colori e contenuti restano quelli del tema.": "Scegli come appaiono l'hero della homepage e le copertine dei libri nel sito pubblico. Colori e contenuti restano quelli del tema.", - "Titolo e descrizione mostrati sopra gli eventi in programma": "Titolo e descrizione mostrati sopra gli eventi in programma" + "Titolo e descrizione mostrati sopra gli eventi in programma": "Titolo e descrizione mostrati sopra gli eventi in programma", + "Voce Emeroteca nel menu": "Voce Emeroteca nel menu", + "Voce Archivio nel menu": "Voce Archivio nel menu", + "Visibile nel menu del sito": "Visibile nel menu del sito", + "Nascosta dal menu del sito": "Nascosta dal menu del sito", + "Quando è nascosta, la voce sparisce dal menu del sito pubblico. Le pagine restano raggiungibili dai risultati del catalogo e della ricerca.": "Quando è nascosta, la voce sparisce dal menu del sito pubblico. Le pagine restano raggiungibili dai risultati del catalogo e della ricerca.", + "Nascosta": "Nascosta" } diff --git a/storage/plugins/archives/ArchivesPlugin.php b/storage/plugins/archives/ArchivesPlugin.php index 00c363b1e..9d8a66497 100644 --- a/storage/plugins/archives/ArchivesPlugin.php +++ b/storage/plugins/archives/ArchivesPlugin.php @@ -646,6 +646,16 @@ public function registerRoutes($app): void return $plugin->indexAction($request, $response); })->add($adminMiddleware); + // POST /admin/archives/menu-visibility — show or hide the section's entry in the public menu + $app->post('/admin/archives/menu-visibility', function ( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + $body = $request->getParsedBody(); + \App\Support\ConfigStore::set('cms.archives_in_menu', is_array($body) && isset($body['in_menu']) ? '1' : '0'); + return $response->withHeader('Location', url('/admin/archives'))->withStatus(302); + })->add($csrfMiddleware)->add($adminMiddleware); + // GET /admin/archives/new — blank create form $app->get('/admin/archives/new', function ( ServerRequestInterface $request, diff --git a/storage/plugins/archives/views/index.php b/storage/plugins/archives/views/index.php index 4fd54886d..608d4ddb1 100644 --- a/storage/plugins/archives/views/index.php +++ b/storage/plugins/archives/views/index.php @@ -185,6 +185,11 @@ class="flex items-center px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50"
  • + url('/admin/archives/menu-visibility'), 'enabled' => \App\Support\ConfigStore::isInPublicMenu('archives'), 'title' => __("Voce Archivio nel menu")]; + require dirname(__DIR__, 4) . '/app/Views/admin/partials/menu-visibility-toggle.php'; + ?> +
    diff --git a/storage/plugins/emeroteca/EmerotecaPlugin.php b/storage/plugins/emeroteca/EmerotecaPlugin.php index 190dd9985..b80baf92f 100644 --- a/storage/plugins/emeroteca/EmerotecaPlugin.php +++ b/storage/plugins/emeroteca/EmerotecaPlugin.php @@ -1690,6 +1690,16 @@ public function registerRoutes($app): void $export = 'App\\Plugins\\Emeroteca\\Controllers\\ExportAdminController'; $public = 'App\\Plugins\\Emeroteca\\Controllers\\PublicController'; + // POST /admin/periodicals/menu-visibility — show or hide the section's entry in the public menu + $app->post('/admin/periodicals/menu-visibility', function ( + \Psr\Http\Message\ServerRequestInterface $request, + \Psr\Http\Message\ResponseInterface $response + ): \Psr\Http\Message\ResponseInterface { + $body = $request->getParsedBody(); + \App\Support\ConfigStore::set('cms.emeroteca_in_menu', is_array($body) && isset($body['in_menu']) ? '1' : '0'); + return $response->withHeader('Location', url('/admin/periodicals'))->withStatus(302); + })->add($csrfMiddleware)->add($adminMiddleware); + $articles = 'App\\Plugins\\Emeroteca\\Controllers\\ContributionController'; foreach (['' => 'index', '/create' => 'form', '/{id:[0-9]+}' => 'show', '/{id:[0-9]+}/edit' => 'form', '/import' => 'importForm', '/export' => 'export', '/issues' => 'issueOptions', '/{id:[0-9]+}/pdf' => 'pdf', '/{id:[0-9]+}/citation.ris' => 'ris', '/{id:[0-9]+}/marc.xml' => 'marcXml'] as $path => $method) { $app->get('/admin/periodicals/articles' . $path, function ($rq, $rs, $args) use ($plugin, $articles, $method) { diff --git a/storage/plugins/emeroteca/src/Views/index.php b/storage/plugins/emeroteca/src/Views/index.php index e3694705b..6e4ad1e30 100644 --- a/storage/plugins/emeroteca/src/Views/index.php +++ b/storage/plugins/emeroteca/src/Views/index.php @@ -123,6 +123,11 @@
    + url('/admin/periodicals/menu-visibility'), 'enabled' => \App\Support\ConfigStore::isInPublicMenu('emeroteca'), 'title' => __("Voce Emeroteca nel menu")]; + require dirname(__DIR__, 5) . '/app/Views/admin/partials/menu-visibility-toggle.php'; + ?> +
    diff --git a/tests/menu-visibility-plugins.spec.js b/tests/menu-visibility-plugins.spec.js new file mode 100644 index 000000000..815721ea8 --- /dev/null +++ b/tests/menu-visibility-plugins.spec.js @@ -0,0 +1,79 @@ +// @ts-check +/** + * Emeroteca and Archive: the admin can take their entry out of the public + * menu, like the Events page. Switched off from the plugin's admin page, the + * entry leaves the desktop menu, the mobile menu and the account pages' menu; + * the section's pages stay reachable (catalogue and search link to them). + * Switched back on, the entry returns. + */ +const { test, expect } = 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 || ''; + +const SECTIONS = [ + { name: 'Emeroteca', admin: '/admin/periodicals', page: '/emeroteca', href: /\/emeroteca$/ }, + { name: 'Archivio', admin: '/admin/archives', page: '/archivio', href: /\/(archivio|archive)$/ }, +]; + +async function login(page) { + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(url => url.pathname.startsWith('/admin'), { timeout: 30000 }); +} + +async function setInMenu(page, adminPath, on) { + await page.goto(BASE + adminPath); + const box = page.locator('#menuVisibilityForm input[name="in_menu"]'); + if ((await box.isChecked()) !== on) { + await Promise.all([page.waitForURL(url => url.pathname === adminPath), box.dispatchEvent('click')]); + } + await expect(page.locator('#menuVisibilityForm input[name="in_menu"]')).toBeChecked({ checked: on }); +} + +/** Links to the section in the public header (desktop and mobile menus). */ +async function menuLinks(page, path, href) { + await page.goto(BASE + path, { waitUntil: 'domcontentloaded' }); + return page.locator('header a, .mobile-menu a, .mobile-nav a, nav a').evaluateAll( + (links, source) => links.filter(a => new RegExp(source).test(new URL(a.href).pathname)).length, + href.source, + ); +} + +test.describe('Plugin sections in the public menu', () => { + test.skip(!ADMIN_EMAIL || !ADMIN_PASS, 'admin credentials not set'); + + for (const section of SECTIONS) { + test(`${section.name}: the admin switch hides and restores the menu entry`, async ({ browser }) => { + const admin = await browser.newPage(); + await login(admin); + await admin.goto(BASE + section.admin); + test.skip(await admin.locator('#menuVisibilityForm').count() === 0, `${section.name} plugin not active`); + + const visitor = await browser.newPage(); + // Where the section is listed at all (an archive with no published + // unit has no entry), the switch must take it out and put it back. + await setInMenu(admin, section.admin, true); + const listed = await menuLinks(visitor, '/catalogo', section.href); + test.skip(listed === 0, `${section.name} has nothing to list`); + const accountListed = await menuLinks(admin, '/utente/bacheca', section.href); + + try { + await setInMenu(admin, section.admin, false); + expect(await menuLinks(visitor, '/catalogo', section.href), 'public menus').toBe(0); + expect(await menuLinks(visitor, '/', section.href), 'home menus').toBe(0); + expect(await menuLinks(admin, '/utente/bacheca', section.href), 'account menus').toBe(0); + // Only the menu entry goes: the section itself is still served. + const res = await visitor.goto(BASE + section.page); + expect(res && res.status()).toBe(200); + } finally { + await setInMenu(admin, section.admin, true); + } + expect(await menuLinks(visitor, '/catalogo', section.href)).toBe(listed); + expect(await menuLinks(admin, '/utente/bacheca', section.href)).toBe(accountListed); + }); + } +}); From 2a7b67e53acac3a2f71a116543fe9b58bc25b35a Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 7 Oct 2026 18:07:51 +0200 Subject: [PATCH 21/45] Check the theme CSS sanitisation in its shared partial The render-time sanitisation of the theme's custom CSS moved from the frontend layout into the partial every public layout now includes. The guard reads it there, and also checks that the frontend, account and auth layouts all include it. --- tests/settings-themes-hardening.unit.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/settings-themes-hardening.unit.php b/tests/settings-themes-hardening.unit.php index e31f84cfd..53b46f893 100644 --- a/tests/settings-themes-hardening.unit.php +++ b/tests/settings-themes-hardening.unit.php @@ -280,10 +280,27 @@ str_contains($layoutSource, "\$themePalette['primary_dark']"), '--primary-dark is fed from the generated palette' ); +// The theme's custom CSS is printed by one shared partial, included by every +// public layout (frontend, account pages, auth pages). +$themeCssPartial = (string) file_get_contents($root . '/app/Views/auth/partials/theme-custom-css.php'); $check( - str_contains($layoutSource, "ContentSanitizer::sanitizeCustomCss(\$themeAdvanced['custom_css'])"), + str_contains($themeCssPartial, "ContentSanitizer::sanitizeCustomCss(\$themeCssAdvanced['custom_css'])"), 'theme custom_css is re-sanitized through ContentSanitizer::sanitizeCustomCss at render' ); +foreach ([ + 'app/Views/frontend/layout.php', + 'app/Views/user_layout.php', + 'app/Views/auth/login.php', + 'app/Views/auth/register.php', + 'app/Views/auth/forgot-password.php', + 'app/Views/auth/reset-password.php', + 'app/Views/auth/register_success.php', +] as $view) { + $check( + str_contains((string) file_get_contents($root . '/' . $view), 'theme-custom-css.php'), + "{$view} includes the theme's custom CSS" + ); +} // --------------------------------------------------------------------------- // 8. BEHAVIORAL — SettingsRepository round-trip on cookie_banner: set a From 37d233ce358facd26d9ec72402187c466df07438 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 7 Oct 2026 18:50:08 +0200 Subject: [PATCH 22/45] Bring two browser specs in line with the 2026 footer and the CI database social-links looked for the old Twitter bird; the footer shows X's logo (fa-x-twitter) since the restyle, and prints the six profiles from one list in a loop, so the two source checks now verify that every profile is in the list and that the loop's href is escaped. The spec is serial, so the first failure skipped the 24 checks after it. home-hero-covers-2026 reached MySQL through the socket only, which the CI runner does not have; it now uses the host and port when they are set, like the other specs. --- tests/home-hero-covers-2026.spec.js | 17 +++++++++++++++-- tests/social-links.spec.js | 21 +++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/tests/home-hero-covers-2026.spec.js b/tests/home-hero-covers-2026.spec.js index f849fab0a..a97b15db3 100644 --- a/tests/home-hero-covers-2026.spec.js +++ b/tests/home-hero-covers-2026.spec.js @@ -12,9 +12,22 @@ const { execFileSync } = require('child_process'); const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const e2e = (key) => { + const v = process.env[key]; + return v === undefined || v === 'undefined' ? '' : v; +}; + function db(sql) { - const args = ['-S', process.env.E2E_DB_SOCKET || '', '-u', process.env.E2E_DB_USER || '', process.env.E2E_DB_NAME || '', '-N', '-B', '-e', sql]; - return execFileSync('mysql', args, { encoding: 'utf-8', env: { ...process.env, MYSQL_PWD: process.env.E2E_DB_PASS || '' } }).trim(); + // TCP when a host is set (CI), the socket otherwise (local dev); the + // password goes through MYSQL_PWD, never argv. + const args = ['-u', e2e('E2E_DB_USER'), e2e('E2E_DB_NAME'), '-N', '-B', '-e', sql]; + if (e2e('E2E_DB_HOST')) { + args.splice(2, 0, '-h', e2e('E2E_DB_HOST')); + if (e2e('E2E_DB_PORT')) args.splice(4, 0, '-P', e2e('E2E_DB_PORT')); + } else if (e2e('E2E_DB_SOCKET')) { + args.splice(2, 0, '-S', e2e('E2E_DB_SOCKET')); + } + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000, env: { ...process.env, MYSQL_PWD: e2e('E2E_DB_PASS') } }).trim(); } async function login(page) { diff --git a/tests/social-links.spec.js b/tests/social-links.spec.js index d8eb28961..0216ee498 100644 --- a/tests/social-links.spec.js +++ b/tests/social-links.spec.js @@ -47,7 +47,7 @@ function dbQuery(sql) { const SOCIALS = [ { key: 'facebook', icon: 'fa-facebook' }, - { key: 'twitter', icon: 'fa-twitter' }, + { key: 'twitter', icon: 'fa-x-twitter' }, // X's own logo since the 2026 footer { key: 'instagram', icon: 'fa-instagram' }, { key: 'linkedin', icon: 'fa-linkedin' }, { key: 'bluesky', icon: 'fa-bluesky' }, @@ -223,8 +223,15 @@ test.describe.serial('Social links — E2E + hardening contract (26 checks)', () test('17. frontend/layout.php escapes social hrefs with htmlspecialchars', async () => { const src = read('app/Views/frontend/layout.php'); - const count = (src.match(/href="<\?= htmlspecialchars\(\$social\w+, ENT_QUOTES, 'UTF-8'\) \?>"/g) || []).length; - expect(count).toBe(6); + // The 2026 footer lists the six profiles in $footerSocials and prints + // them in one loop: every profile is in the list, and the loop's href is + // the only place a social URL reaches the markup, escaped. + for (const s of SOCIALS) { + const name = s.key.charAt(0).toUpperCase() + s.key.slice(1); + expect(src).toMatch(new RegExp(`\\['href' => \\$social${name},`)); + } + expect(src).toContain(`"`); + expect(src).not.toMatch(/href="<\?= \$social/); }); test('20. SettingsController saves all six socials', async () => { From 2069d62d23de90a32fa0c0f37ec32d6921ab029f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 7 Oct 2026 17:12:56 +0000 Subject: [PATCH 23/45] Keep site scripts off the auth pages and retire the dead restyle leftovers - The login, registration and password-reset pages no longer run the custom scripts from Settings > Advanced: a third-party snippet could read the password field or send the reset token in the URL elsewhere. - The theme CSS sanitiser repeats until nothing changes, so a tag split by another (le>) can no longer be rebuilt. - window.PK carries a CSRF token only for a signed-in reader, so anonymous pages stay token-free and cacheable (#387). - The hero background photo is retired: the CMS no longer accepts an upload and it is no longer the og:image / twitter:image fallback. - user_layout.php (rendered by no controller), account-pages.css and frontend-layouts.css (their selectors need body.layout-*, which the 2026 body no longer has) are removed, with the dead layout-* rules in archive-pages.css and book-detail.css. The reservations' section icons stay hidden through pinakes-2026.css. - fonts.css uses URLs relative to itself, so the fonts load under a subfolder install, and the OFL licences ship next to the fonts. --- app/Controllers/CmsController.php | 145 +- app/Controllers/FrontendController.php | 10 +- app/Support/ContentSanitizer.php | 16 +- app/Views/auth/forgot-password.php | 1 - app/Views/auth/login.php | 1 - app/Views/auth/partials/theme-custom-css.php | 4 +- app/Views/auth/register.php | 1 - app/Views/auth/register_success.php | 1 - app/Views/auth/reset-password.php | 1 - app/Views/frontend/layout.php | 9 +- app/Views/frontend/partials/breadcrumb.php | 8 +- app/Views/partials/custom-js.php | 6 +- app/Views/profile/reservations.php | 1 - app/Views/profile/wishlist.php | 1 - app/Views/user_dashboard/prenotazioni.php | 1 - app/Views/user_layout.php | 1576 ------------ public/assets/account-pages.css | 717 ------ public/assets/archive-pages.css | 49 - public/assets/book-detail.css | 14 +- public/assets/fonts/OFL-Fraunces.txt | 93 + public/assets/fonts/OFL-Geist.txt | 93 + public/assets/fonts/OFL-InstrumentSans.txt | 93 + public/assets/fonts/OFL-Newsreader.txt | 93 + public/assets/fonts/fonts.css | 51 +- public/assets/frontend-layouts.css | 2234 ------------------ public/assets/pinakes-2026.css | 3 + tests/custom-css-injection.unit.php | 2 + tests/hero-upload-292.unit.php | 63 - tests/settings-themes-hardening.unit.php | 19 +- tests/social-links.spec.js | 24 +- 30 files changed, 465 insertions(+), 4865 deletions(-) delete mode 100644 app/Views/user_layout.php delete mode 100644 public/assets/account-pages.css create mode 100644 public/assets/fonts/OFL-Fraunces.txt create mode 100644 public/assets/fonts/OFL-Geist.txt create mode 100644 public/assets/fonts/OFL-InstrumentSans.txt create mode 100644 public/assets/fonts/OFL-Newsreader.txt delete mode 100644 public/assets/frontend-layouts.css delete mode 100644 tests/hero-upload-292.unit.php diff --git a/app/Controllers/CmsController.php b/app/Controllers/CmsController.php index 130dd311e..b77993b6a 100644 --- a/app/Controllers/CmsController.php +++ b/app/Controllers/CmsController.php @@ -213,34 +213,9 @@ public function editHome(Request $request, Response $response, \mysqli $db, arra return $response; } - /** - * Map a PHP file-upload error code to a user-facing message, or null when - * there is nothing to report (UPLOAD_ERR_OK / UPLOAD_ERR_NO_FILE). - * - * #292: a hero photo bigger than upload_max_filesize arrives with a non-OK - * error code BEFORE the app can validate it. The upload block only ran on - * UPLOAD_ERR_OK, so the failure fell through silently and the page reported - * success with no image. Extracted so the mapping is unit-testable without a - * specific php.ini (the E2E INI_SIZE case needs upload_max < post_max, which - * not every environment has). - */ - public static function heroUploadErrorMessage(int $err): ?string - { - return match ($err) { - UPLOAD_ERR_OK, UPLOAD_ERR_NO_FILE => null, - UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => - "L'immagine supera il limite di upload del server. Riduci la dimensione dell'immagine, oppure aumenta upload_max_filesize e post_max_size nella configurazione PHP.", - UPLOAD_ERR_PARTIAL => "L'upload dell'immagine è stato interrotto. Riprova.", - UPLOAD_ERR_NO_TMP_DIR => "Cartella temporanea mancante sul server. Contatta l'amministratore.", - UPLOAD_ERR_CANT_WRITE => "Impossibile scrivere il file sul server. Controlla i permessi.", - default => "Errore durante l'upload dell'immagine (codice {$err}).", - }; - } - public function updateHome(Request $request, Response $response, \mysqli $db, array $args): Response { $data = $request->getParsedBody(); - $files = $request->getUploadedFiles(); // CRITICAL: Set UTF-8 charset to prevent corruption of Greek/Unicode characters $db->set_charset('utf8mb4'); @@ -286,113 +261,13 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar } $heroData['button_link'] = $buttonLink; - $bgImagePath = null; - - // #292: the browser sent a hero image but PHP rejected it BEFORE the - // app could validate/save it — almost always because the file exceeds - // upload_max_filesize / post_max_size on a self-hosted install (a - // phone photo easily beats the 2M PHP default). This used to fall - // through silently: the block below (getError() === OK) was skipped, - // $errors stayed empty, the UPSERT ran WITHOUT a background, and the - // page reported "saved successfully". Surface a clear error instead. - $heroUpload = $files['hero_background'] ?? null; - $heroUploadErr = $heroUpload !== null ? $heroUpload->getError() : UPLOAD_ERR_NO_FILE; - $heroUploadError = self::heroUploadErrorMessage($heroUploadErr); - if ($heroUploadError !== null) { - $errors[] = $heroUploadError; - } - - // SECURITY: Enhanced file upload validation - if (isset($files['hero_background']) && $files['hero_background']->getError() === UPLOAD_ERR_OK) { - $uploadedFile = $files['hero_background']; - $filename = $uploadedFile->getClientFilename(); - $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); - - // SECURITY: Validate file extension - $allowedExtensions = ['jpg', 'jpeg', 'png', 'webp']; - if (!in_array($extension, $allowedExtensions)) { - $errors[] = 'Formato immagine non supportato. Usa JPG, PNG o WebP.'; - } else { - // SECURITY: Validate file size (max 5MB) - if ($uploadedFile->getSize() > 5 * 1024 * 1024) { - $errors[] = 'L\'immagine è troppo grande. Max 5MB.'; - } else { - // SECURITY: Validate MIME type with magic number check - $tmpPath = $uploadedFile->getStream()->getMetadata('uri'); - $finfo = new \finfo(FILEINFO_MIME_TYPE); - $mimeType = $finfo->file($tmpPath); - - $allowedMimes = ['image/jpeg', 'image/png', 'image/webp']; - if (!in_array($mimeType, $allowedMimes)) { - $errors[] = 'Tipo di file non valido. Il file deve essere un\'immagine reale.'; - } else { - // SECURITY: Secure path handling to prevent directory traversal - $baseDir = realpath(__DIR__ . '/../../public/uploads'); - if ($baseDir === false) { - \App\Support\SecureLogger::error('CmsController: Upload base directory not found'); - $errors[] = 'Errore di configurazione directory upload.'; - } else { - $targetDir = $baseDir . '/assets'; - - // Create directory if it doesn't exist - if (!is_dir($targetDir)) { - mkdir($targetDir, 0755, true); - } - - // SECURITY: Generate cryptographically secure random filename - $randomSuffix = ''; - try { - $randomSuffix = bin2hex(random_bytes(8)); - } catch (\Throwable $e) { - \App\Support\SecureLogger::error('CmsController: random_bytes() failed: ' . $e->getMessage()); - $errors[] = 'Errore di sistema. Riprova più tardi.'; - } - - if (empty($errors)) { - $newFilename = 'hero_bg_' . $randomSuffix . '.' . $extension; - // Sanitize filename to prevent null byte injection - $newFilename = str_replace("\\0", '', $newFilename); - $uploadPath = $targetDir . '/' . basename($newFilename); - - // SECURITY: Verify final path is within allowed directory - $realUploadPath = realpath(dirname($uploadPath)); - if ($realUploadPath === false || strpos($realUploadPath, $baseDir) !== 0) { - \App\Support\SecureLogger::error('CmsController: Path traversal attempt detected'); - $errors[] = 'Percorso file non valido.'; - } else { - try { - $uploadedFile->moveTo($uploadPath); - // SECURITY: Set secure file permissions - @chmod($uploadPath, 0644); - // #292: the file is written under - // public/uploads/assets, so the stored - // URL must be /uploads/assets/… — - // /assets/ resolves to public/assets - // (a different dir) and 404s, so the - // hero image never rendered even when - // the upload succeeded. - $bgImagePath = '/uploads/assets/' . $newFilename; - } catch (\Throwable $e) { - \App\Support\SecureLogger::error('CmsController: Image upload error: ' . $e->getMessage()); - $errors[] = 'Errore durante l\'upload dell\'immagine. Riprova.'; - } - } - } - } - } - } - } - } + // The hero background photo is retired with the 2026 design (the + // hero shows a fan of covers instead): the form no longer offers + // an upload, so nothing here accepts or writes one. A photo stored + // by an older version stays in `background_image`, unused. if (empty($errors)) { // UPSERT: Insert if not exists, update if exists - $backgroundImage = null; - if (isset($heroData['remove_background']) && $heroData['remove_background'] == '1') { - $backgroundImage = null; - } elseif ($bgImagePath) { - $backgroundImage = $bgImagePath; - } - // SEO fields for hero (base) $seoTitle = $sanitizeText($heroData['seo_title'] ?? ''); $seoDescription = $sanitizeText($heroData['seo_description'] ?? ''); @@ -413,19 +288,18 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar $stmt = $db->prepare(" INSERT INTO home_content ( - section_key, title, subtitle, button_text, button_link, background_image, + section_key, title, subtitle, button_text, button_link, seo_title, seo_description, seo_keywords, og_image, og_title, og_description, og_type, og_url, twitter_card, twitter_title, twitter_description, twitter_image, is_active, display_order ) - VALUES ('hero', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, -1) + VALUES ('hero', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, -1) ON DUPLICATE KEY UPDATE title = VALUES(title), subtitle = VALUES(subtitle), button_text = VALUES(button_text), button_link = VALUES(button_link), - background_image = IF(VALUES(background_image) IS NOT NULL OR ? = 1, VALUES(background_image), background_image), seo_title = VALUES(seo_title), seo_description = VALUES(seo_description), seo_keywords = VALUES(seo_keywords), @@ -439,14 +313,12 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar twitter_description = VALUES(twitter_description), twitter_image = VALUES(twitter_image) "); - $removeBackground = isset($heroData['remove_background']) && $heroData['remove_background'] == '1' ? 1 : 0; $stmt->bind_param( - 'sssssssssssssssssi', + 'ssssssssssssssss', $heroData['title'], $heroData['subtitle'], $heroData['button_text'], $heroData['button_link'], - $backgroundImage, $seoTitle, $seoDescription, $seoKeywords, @@ -458,8 +330,7 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar $twitterCard, $twitterTitle, $twitterDescription, - $twitterImage, - $removeBackground + $twitterImage ); $stmt->execute(); $stmt->close(); diff --git a/app/Controllers/FrontendController.php b/app/Controllers/FrontendController.php index 4ea9e2007..d7f284f86 100644 --- a/app/Controllers/FrontendController.php +++ b/app/Controllers/FrontendController.php @@ -112,12 +112,12 @@ public function home(Request $request, Response $response, mysqli $db, ?Containe // OG URL (priority: custom og_url > canonical URL) $ogUrl = !empty($hero['og_url']) ? $hero['og_url'] : $seoCanonical; - // OG Image (priority: custom og_image > hero background > app logo > default cover) + // OG Image (priority: custom og_image > app logo > default cover). + // The retired hero background photo is no longer a fallback: it is + // not shown on the page and the admin can no longer change it. $ogImage = $defaultSocialImage; if (!empty($hero['og_image'])) { $ogImage = HtmlHelper::absoluteUrl($hero['og_image']); - } elseif (!empty($hero['background_image'])) { - $ogImage = HtmlHelper::absoluteUrl($hero['background_image']); } elseif ($brandLogoUrl !== '') { $ogImage = $brandLogoUrl; } @@ -143,14 +143,12 @@ public function home(Request $request, Response $response, mysqli $db, ?Containe (!empty($hero['subtitle']) ? $hero['subtitle'] : ($footerDescription ?: __('Esplora il nostro vasto catalogo di libri, prenota i tuoi titoli preferiti e scopri nuove letture'))))); - // Twitter Image (priority: custom twitter_image > og_image > hero background > app logo > default cover) + // Twitter Image (priority: custom twitter_image > og_image > app logo > default cover) $twitterImage = $defaultSocialImage; if (!empty($hero['twitter_image'])) { $twitterImage = HtmlHelper::absoluteUrl($hero['twitter_image']); } elseif (!empty($hero['og_image'])) { $twitterImage = HtmlHelper::absoluteUrl($hero['og_image']); - } elseif (!empty($hero['background_image'])) { - $twitterImage = HtmlHelper::absoluteUrl($hero['background_image']); } elseif ($brandLogoUrl !== '') { $twitterImage = $brandLogoUrl; } diff --git a/app/Support/ContentSanitizer.php b/app/Support/ContentSanitizer.php index 942869c1a..c0a94ba0d 100644 --- a/app/Support/ContentSanitizer.php +++ b/app/Support/ContentSanitizer.php @@ -55,11 +55,17 @@ public static function sanitizeCustomCss(string $css): string // Rimuove ogni apertura/chiusura di `, + // `ipt>` → ` - - - - - - - - - - - - - - - - - - -
    -
    -
    -
    - - - <?= HtmlHelper::e($appName) ?> - - - - - - - - - - - - - - - - -
    - -
    -
    -
    -
    - - -
    -
    -
    - - -
    - -
    -
    -
    - - -
    - -
    -
    - - -
    -
    - - - - -
    -
    - - -
    -
    - - - -
    - -
    -
    - - - $socialFacebook, 'icon' => 'fab fa-facebook', 'label' => 'Facebook'], - ['href' => $socialTwitter, 'icon' => 'fa-brands fa-x-twitter', 'label' => 'X'], - ['href' => $socialInstagram, 'icon' => 'fab fa-instagram', 'label' => 'Instagram'], - ['href' => $socialLinkedin, 'icon' => 'fab fa-linkedin', 'label' => 'LinkedIn'], - ['href' => $socialBluesky, 'icon' => 'fa-brands fa-bluesky', 'label' => 'Bluesky'], - ['href' => $socialTelegram, 'icon' => 'fa-brands fa-telegram', 'label' => 'Telegram'], - ]; - ?> -
    - -
    - - - - - - - - - - - - - - - - - - diff --git a/public/assets/account-pages.css b/public/assets/account-pages.css deleted file mode 100644 index fe68432a8..000000000 --- a/public/assets/account-pages.css +++ /dev/null @@ -1,717 +0,0 @@ -/* Public account surfaces: loans, reservations and wishlist. */ - -body[class*="layout-"] { - --account-radius: 8px; - --account-radius-lg: 12px; - --account-surface: color-mix(in srgb, var(--white) 98%, var(--primary-color) 2%); - --account-subtle: color-mix(in srgb, var(--light-bg) 94%, var(--primary-color) 6%); - --account-line: color-mix(in srgb, var(--border-color) 88%, transparent); - --account-muted: var(--text-muted, #667085); - --account-success: #16794a; - --account-warning: #946200; - --account-danger: #b42318; -} - -body.layout-editorial { - --account-radius: 2px; - --account-radius-lg: 4px; -} - -body.layout-workspace { - --account-radius: 7px; - --account-radius-lg: 10px; -} - -body.layout-command { - --account-radius: 9px; - --account-radius-lg: 12px; -} - -body.layout-soft { - --account-radius: 14px; - --account-radius-lg: 20px; -} - -body[class*="layout-"] :where(.loans-container, .wishlist-hero, .wishlist-info-card, .wishlist-filter-card, .wishlist-empty, .wishlist-card) { - font-family: var(--font-family, Inter, system-ui, sans-serif); -} - -/* Loans and reservations ------------------------------------------------- */ - -body[class*="layout-"] .loans-container { - max-width: 1180px; - padding: clamp(2rem, 4vw, 3.5rem) clamp(1rem, 3vw, 2rem) 4rem; -} - -.account-page-heading { - display: flex; - align-items: end; - justify-content: space-between; - gap: 2rem; - margin-bottom: 1.5rem; - padding-bottom: 1.5rem; - border-bottom: 1px solid var(--account-line); -} - -.loans-container .account-page-heading { - border-bottom: 0; -} - -.account-page-heading__eyebrow { - margin: 0 0 .55rem; - color: var(--primary-text, var(--primary-color)); - font-size: .72rem; - font-weight: 750; - letter-spacing: .12em; - text-transform: uppercase; -} - -.account-page-heading h1 { - margin: 0; - color: var(--text-color); - font-family: var(--font-family, Inter, system-ui, sans-serif); - font-size: 2rem; - font-weight: 720; - letter-spacing: -.035em; -} - -body.layout-editorial .account-page-heading h1 { - font-family: var(--serif, Fraunces, Georgia, serif); - font-size: 2.4rem; - font-weight: 500; -} - -.account-page-heading__subtitle { - max-width: 62ch; - margin: .65rem 0 0; - color: var(--account-muted); - font-size: .95rem; - line-height: 1.65; -} - -.account-page-heading__actions { - display: flex; - flex: 0 0 auto; - flex-wrap: wrap; - gap: .6rem; -} - -.account-page-link { - display: inline-flex; - min-height: 44px; - align-items: center; - justify-content: center; - padding: .65rem 1rem; - border: 1px solid var(--account-line); - border-radius: var(--account-radius); - background: var(--account-surface); - color: var(--text-color); - font-size: .86rem; - font-weight: 650; - text-decoration: none; - transition: border-color 180ms cubic-bezier(.25, 1, .5, 1), color 180ms ease, background-color 180ms ease; -} - -.account-page-link:hover { - border-color: var(--primary-color); - background: var(--white); - color: var(--primary-text, var(--primary-color)); - text-decoration: none; -} - -body[class*="layout-"] .section-header { - display: block; - margin: 0; - padding: 1.35rem 0 .75rem; - border-top: 1px solid var(--account-line); -} - -body[class*="layout-"] .section-icon, -body[class*="layout-"] .section-icon[style] { - display: none; -} - -body[class*="layout-"] .section-icon :where(i, svg), -body[class*="layout-"] .section-icon i[style] { - width: 1rem; - height: 1rem; - color: currentColor !important; - fill: currentColor; - font-size: .9rem; -} - -body[class*="layout-"] .account-line-icon { - width: 1rem; - height: 1rem; - fill: none !important; - stroke: currentColor; - stroke-width: 1.65; - stroke-linecap: round; - stroke-linejoin: round; -} - -body[class*="layout-"] .section-title h2 { - margin: 0; - color: var(--text-color); - font-family: var(--font-family, Inter, system-ui, sans-serif); - font-size: 1.08rem; - font-weight: 700; - letter-spacing: -.015em; -} - -body[class*="layout-"] .section-title p { - margin: 0; - color: var(--account-muted); - font-size: .8rem; -} - -body[class*="layout-"] .section-title { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 1rem; -} - -body[class*="layout-"] .section-divider { - display: none; -} - -body[class*="layout-"] .items-grid { - grid-template-columns: repeat(auto-fit, minmax(min(100%, 430px), 1fr)); - gap: 1rem; - margin-bottom: 1.5rem; -} - -body[class*="layout-"] .item-card { - padding: 1.1rem; - border: 1px solid var(--account-line); - border-radius: var(--account-radius-lg); - background: var(--account-surface); - box-shadow: none; - transition: transform 190ms cubic-bezier(.25, 1, .5, 1), border-color 190ms ease, box-shadow 190ms ease; -} - -body[class*="layout-"] .item-card:hover { - transform: translateY(-2px); - border-color: color-mix(in srgb, var(--primary-color) 28%, var(--account-line)); - box-shadow: 0 10px 28px color-mix(in srgb, var(--secondary-color) 8%, transparent); -} - -body[class*="layout-"] .item-inner { - gap: 1rem; -} - -body[class*="layout-"] .item-cover { - width: 82px; - height: 112px; - border-radius: max(2px, calc(var(--account-radius) - 3px)); - background: var(--account-subtle); - box-shadow: none; -} - -body[class*="layout-"] .item-cover:hover { - transform: translateY(-2px); -} - -body[class*="layout-"] .item-title, -body[class*="layout-"] .item-title a { - color: var(--text-color); -} - -body[class*="layout-"] .item-title { - margin-bottom: .75rem; - font-size: 1rem; - font-weight: 700; -} - -body[class*="layout-"] .item-title a:hover { - color: var(--primary-text, var(--primary-color)); -} - -body[class*="layout-"] :where(.badge, .status-badge), -body[class*="layout-"] :where(.badge, .status-badge)[style] { - gap: .25rem; - padding: .32rem .5rem; - border: 0 !important; - border-radius: max(4px, calc(var(--account-radius) - 2px)); - background: var(--account-subtle) !important; - color: var(--text-color) !important; - font-size: .75rem !important; - font-weight: 620; -} - -body[class*="layout-"] .badge i, -body[class*="layout-"] .badge i[style] { - color: currentColor !important; - font-size: .72rem; - opacity: .72; -} - -body[class*="layout-"] :where(.badge-active, .badge-scheduled) { - background: color-mix(in srgb, var(--account-success) 10%, var(--white)) !important; - color: var(--account-success) !important; -} - -body[class*="layout-"] :where(.badge-pending, .badge-review--pendente) { - background: color-mix(in srgb, var(--account-warning) 10%, var(--white)) !important; - color: var(--account-warning) !important; -} - -body[class*="layout-"] .badge-review--approvata { - background: color-mix(in srgb, var(--account-success) 10%, var(--white)) !important; - color: var(--account-success) !important; -} - -body[class*="layout-"] .badge-review--rifiutata { - background: color-mix(in srgb, var(--account-danger) 9%, var(--white)) !important; - color: var(--account-danger) !important; -} - -body[class*="layout-"] .badge-overdue { - background: color-mix(in srgb, var(--account-danger) 9%, var(--white)) !important; - color: var(--account-danger) !important; -} - -body[class*="layout-"] :where(.btn-cancel, .btn-review) { - width: auto; - min-height: 40px; - margin-top: .9rem; - padding: .55rem .8rem; - border-radius: var(--account-radius); - font-size: .8rem; - font-weight: 680; - transition: transform 180ms cubic-bezier(.25, 1, .5, 1), background-color 180ms ease, border-color 180ms ease; -} - -body[class*="layout-"] .btn-cancel { - border: 1px solid color-mix(in srgb, var(--account-danger) 22%, var(--account-line)); - background: color-mix(in srgb, var(--account-danger) 6%, var(--white)); - color: var(--account-danger); -} - -body[class*="layout-"] .btn-cancel:hover { - border-color: var(--account-danger); - background: color-mix(in srgb, var(--account-danger) 10%, var(--white)); -} - -body[class*="layout-"] .btn-review:hover { - transform: translateY(-1px); -} - -body[class*="layout-"] .empty-state { - display: grid; - grid-template-columns: minmax(12rem, .45fr) minmax(0, 1fr); - gap: 1.25rem; - align-items: baseline; - padding: .15rem 0 1.5rem; - border: 0; - border-radius: 0; - background: transparent; - text-align: left; -} - -body[class*="layout-"] .empty-state-icon { - display: inline-flex; - width: 30px; - height: 30px; - align-items: center; - justify-content: center; - margin: 0 0 .75rem; - border-radius: var(--account-radius); - background: var(--account-subtle); - color: var(--account-muted); - font-size: .9rem; -} - -body[class*="layout-"] .empty-state h3 { - margin: 0; - color: var(--text-color); - font-family: var(--font-family, Inter, system-ui, sans-serif); - font-size: .95rem; - font-weight: 680; -} - -body[class*="layout-"] .empty-state p { - margin: 0; - color: var(--account-muted); - font-size: .84rem; -} - -body[class*="layout-"] .review-heading { - margin-top: .5rem; - color: var(--text-color); - font-size: .875rem; - font-weight: 650; -} - -body[class*="layout-"] :where(.alert-overdue, .alert-outcome) { - border-width: 1px; - border-radius: var(--account-radius); - box-shadow: none; -} - -body[class*="layout-"] .alert-overdue-icon { - width: 34px; - height: 34px; - border-radius: var(--account-radius); - font-size: .9rem; -} - -/* Wishlist --------------------------------------------------------------- */ - -body[class*="layout-"] .wishlist-hero { - margin: 0; - padding: clamp(3rem, 6vw, 5rem) 0 2rem; - background: transparent; - color: var(--text-color); -} - -body[class*="layout-"] .wishlist-hero .container { - max-width: 1180px; - padding-inline: 1rem; - text-align: left !important; -} - -body[class*="layout-"] .wishlist-hero .hero-title { - margin: 0; - color: var(--text-color); - font-family: var(--font-family, Inter, system-ui, sans-serif); - font-size: 2rem; - font-weight: 720; - letter-spacing: -.035em; -} - -body.layout-editorial .wishlist-hero .hero-title { - font-family: var(--serif, Fraunces, Georgia, serif); - font-size: 2.4rem; - font-weight: 500; -} - -body[class*="layout-"] .wishlist-hero .hero-subtitle { - max-width: 64ch; - margin: .65rem 0 0 !important; - color: var(--account-muted); - font-size: .95rem; - line-height: 1.65; - opacity: 1; -} - -body[class*="layout-"] .wishlist-info-card { - padding: 1.5rem 0 1.75rem; - border-top: 1px solid var(--account-line); - border-bottom: 1px solid var(--account-line); - border-radius: 0; - background: transparent; - box-shadow: none; -} - -body[class*="layout-"] .wishlist-info-card h2 { - color: var(--text-color); - font-family: var(--font-family, Inter, system-ui, sans-serif); - font-size: 1.08rem; - font-weight: 700; -} - -body[class*="layout-"] .wishlist-info-card p { - max-width: 60ch; - color: var(--account-muted) !important; - font-size: .88rem; - line-height: 1.6; -} - -body[class*="layout-"] .wishlist-stat-badges { - gap: 0; - margin-top: 1.25rem; -} - -body[class*="layout-"] .wishlist-stat { - gap: .45rem; - padding: .15rem 1rem; - border-right: 1px solid var(--account-line); - border-radius: 0; - background: transparent; - color: var(--text-color); - font-size: .82rem; - font-weight: 620; -} - -body[class*="layout-"] .wishlist-stat:first-child { - padding-left: 0; -} - -body[class*="layout-"] .wishlist-stat:last-child { - border-right: 0; -} - -body[class*="layout-"] .wishlist-stat i { - color: var(--primary-text, var(--primary-color)); - font-size: .75rem; -} - -body[class*="layout-"] .wishlist-actions { - gap: .6rem; -} - -body[class*="layout-"] .wishlist-actions .btn-outline { - display: inline-flex; - min-height: 44px; - align-items: center; - justify-content: center; - padding: .65rem 1rem; - border: 1px solid var(--account-line); - border-radius: var(--account-radius); - background: var(--account-surface); - color: var(--text-color); - font-size: .84rem; - font-weight: 650; - box-shadow: none; - transition: border-color 180ms ease, color 180ms ease, background-color 180ms ease; -} - -body[class*="layout-"] .wishlist-actions .btn-outline:hover { - border-color: var(--primary-color); - background: var(--white); - color: var(--primary-text, var(--primary-color)); - box-shadow: none; -} - -body[class*="layout-"] .wishlist-actions i { - font-size: .76rem; -} - -body[class*="layout-"] .wishlist-filter-card { - display: grid !important; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end !important; - gap: 1rem; - margin: 1.25rem 0 2rem; - padding: 0 0 1.25rem; - border-bottom: 1px solid var(--account-line); - border-radius: 0; - background: transparent; - box-shadow: none; -} - -body[class*="layout-"] .wishlist-filter-card label { - margin-bottom: .45rem !important; - color: var(--account-muted); - font-size: .7rem; - letter-spacing: .1em; -} - -body[class*="layout-"] .wishlist-filter-card input[type="search"] { - height: 48px; - min-height: 44px; - border: 1px solid var(--account-line); - border-radius: var(--account-radius); - background: var(--account-surface); - color: var(--text-color); -} - -body[class*="layout-"] .wishlist-filter-card input[type="search"]:focus { - border-color: var(--primary-color); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 14%, transparent); -} - -body[class*="layout-"] .wishlist-filter-card button { - height: 48px; - min-height: 44px; - padding: .5rem .75rem; - align-self: end; - border: 1px solid var(--account-line); - border-radius: var(--account-radius); - background: var(--account-surface); - color: var(--account-muted); - font-size: .72rem; -} - -body[class*="layout-"] .wishlist-empty { - padding: 2.25rem 0 4rem; - border-radius: 0; - background: transparent; - box-shadow: none; - text-align: left; -} - -body[class*="layout-"] .wishlist-empty-icon { - width: 36px; - height: 36px; - margin: 0 0 1rem; - border-radius: var(--account-radius); - background: var(--account-subtle); - color: var(--account-muted); - font-size: 1rem; -} - -body[class*="layout-"] .wishlist-empty-icon .account-line-icon { - width: 1.05rem; - height: 1.05rem; -} - -body[class*="layout-"] .wishlist-empty h2 { - color: var(--text-color); - font-family: var(--font-family, Inter, system-ui, sans-serif); - font-size: 1.08rem; -} - -body[class*="layout-"] .wishlist-empty p { - max-width: 62ch; - color: var(--account-muted) !important; - font-size: .88rem; - line-height: 1.6; -} - -body[class*="layout-"] .wishlist-empty .wishlist-actions { - justify-content: flex-start !important; -} - -body[class*="layout-"] .wishlist-card { - border: 1px solid var(--account-line); - border-radius: var(--account-radius-lg); - background: var(--account-surface); - box-shadow: none; - transition: transform 190ms cubic-bezier(.25, 1, .5, 1), border-color 190ms ease, box-shadow 190ms ease; -} - -body[class*="layout-"] .wishlist-card:hover { - transform: translateY(-2px); - border-color: color-mix(in srgb, var(--primary-color) 28%, var(--account-line)); - box-shadow: 0 10px 28px color-mix(in srgb, var(--secondary-color) 8%, transparent); -} - -body[class*="layout-"] .wishlist-card-cover { - padding: 1.25rem; - background: var(--account-subtle); -} - -body[class*="layout-"] .wishlist-card-cover img { - height: 220px; -} - -body[class*="layout-"] .wishlist-card-body { - gap: .75rem; - padding: 1.15rem; -} - -body[class*="layout-"] .wishlist-status { - align-self: flex-start; - padding: .35rem .55rem; - border-radius: max(4px, calc(var(--account-radius) - 2px)); - font-size: .72rem; - letter-spacing: .02em; -} - -body[class*="layout-"] .wishlist-status.available { - background: color-mix(in srgb, var(--account-success) 10%, var(--white)); - color: var(--account-success); -} - -body[class*="layout-"] .wishlist-status.pending { - background: color-mix(in srgb, var(--account-warning) 10%, var(--white)); - color: var(--account-warning); -} - -body[class*="layout-"] .wishlist-card-title { - color: var(--text-color); - font-size: 1rem; -} - -body[class*="layout-"] .wishlist-card :where(.btn-outline-dark, .btn-light) { - min-height: 42px; - border-color: var(--account-line); - border-radius: var(--account-radius); - background: transparent; - color: var(--text-color); -} - -body[class*="layout-"] .wishlist-card .remove-fav-btn { - width: 42px; - color: var(--account-danger); -} - -body[class*="layout-"] #wishlist-no-results { - margin-bottom: 1rem; - border-radius: var(--account-radius); - background: var(--account-subtle); - color: var(--text-color); -} - -@media (max-width: 767.98px) { - .account-page-heading { - align-items: flex-start; - flex-direction: column; - gap: 1.25rem; - } - - body[class*="layout-"] .loans-container { - padding-top: 2rem; - } - - body[class*="layout-"] .section-title { - display: block; - } - - body[class*="layout-"] .section-title p { - margin-top: .15rem; - } - - body[class*="layout-"] .empty-state { - grid-template-columns: 1fr; - gap: .25rem; - padding-bottom: 1.35rem; - } - - .account-page-heading__actions, - .account-page-link { - width: 100%; - } - - body[class*="layout-"] .wishlist-hero { - padding-top: 2.5rem; - } - - body[class*="layout-"] .wishlist-hero .hero-title, - body.layout-editorial .wishlist-hero .hero-title { - font-size: 2rem; - } - - body[class*="layout-"] .wishlist-stat-badges { - row-gap: .65rem; - } - - body[class*="layout-"] .wishlist-stat { - width: 100%; - padding: 0; - border-right: 0; - } - - body[class*="layout-"] .wishlist-filter-card { - grid-template-columns: 1fr; - } - - body[class*="layout-"] .wishlist-filter-card button { - width: 100%; - border-color: var(--account-line); - border-radius: var(--account-radius); - } - - body[class*="layout-"] .wishlist-actions, - body[class*="layout-"] .wishlist-actions .btn-outline { - width: 100%; - } - - body[class*="layout-"] .item-inner { - align-items: flex-start; - } - - body[class*="layout-"] .item-cover { - width: 70px; - height: 98px; - } -} - -@media (prefers-reduced-motion: reduce) { - body[class*="layout-"] :where(.item-card, .wishlist-card, .account-page-link, .wishlist-actions .btn-outline) { - transition: none; - } -} diff --git a/public/assets/archive-pages.css b/public/assets/archive-pages.css index 57f3b8c34..e40f5fd16 100644 --- a/public/assets/archive-pages.css +++ b/public/assets/archive-pages.css @@ -202,54 +202,6 @@ text-decoration: underline; } -/* ── Layout variants ─────────────────────────────────────────────────── */ -body.layout-workspace .archive-title, -body.layout-command .archive-title, -body.layout-soft .archive-title, -body.layout-workspace .archive-section-header h2, -body.layout-command .archive-section-header h2, -body.layout-soft .archive-section-header h2 { - font-family: var(--sans, Inter, sans-serif) !important; - font-weight: 760; -} - -body.layout-workspace .archive-hero { - background: var(--light-bg); - padding-block: 4rem 3.5rem; -} - -body.layout-command .archive-hero { - background: var(--secondary-color); - color: var(--white); -} - -body.layout-command .archive-hero .archive-title, -body.layout-command .archive-hero .archive-count, -body.layout-command .archive-hero .book-breadcrumb .breadcrumb-item, -body.layout-command .archive-hero .book-breadcrumb a, -body.layout-command .archive-hero .book-breadcrumb .active { - color: var(--white); -} - -body.layout-command .archive-kicker, -body.layout-command .archive-count i { - color: color-mix(in srgb, var(--primary-color) 65%, var(--white)); -} - -body.layout-command .archive-avatar { - background: color-mix(in srgb, var(--white) 12%, transparent); - color: var(--white); -} - -body.layout-soft .archive-hero { - margin: 0 1rem; - border-radius: 24px; -} - -body.layout-soft .archive-avatar { - background: var(--white); -} - @media (max-width: 62rem) { .archive-page .archive-books-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @@ -261,7 +213,6 @@ body.layout-soft .archive-avatar { .archive-title { font-size: 2rem; } .archive-section-header { align-items: start; flex-direction: column; gap: 0; } .archive-page .archive-books-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } - body.layout-soft .archive-hero { margin: 0 .65rem; } } @media (prefers-reduced-motion: reduce) { diff --git a/public/assets/book-detail.css b/public/assets/book-detail.css index c0f311fa9..58d8160d3 100644 --- a/public/assets/book-detail.css +++ b/public/assets/book-detail.css @@ -4,8 +4,8 @@ * Extracted from the inline $additional_css of app/Views/frontend/book-detail.php * so any public "scheda" (a book, a periodical, an issue, an article) can wear * the same hero: blurred-cover band (.book-hero), cover + identity columns, - * breadcrumb, kicker, title, metadata and related sections. The per-layout - * overrides in frontend-layouts.css apply unchanged. + * breadcrumb, kicker, title, metadata and related sections. The 2026 design + * (pinakes-2026.css) is linked after it and has the last word. * * Opt in with `$bookDetailStyles = true;` before the frontend layout renders; * the layout links this file right after its own ]]>', 'only-script-open' => 'body{} diff --git a/tests/menu-visibility-plugins.spec.js b/tests/menu-visibility-plugins.spec.js index 815721ea8..8543b68f3 100644 --- a/tests/menu-visibility-plugins.spec.js +++ b/tests/menu-visibility-plugins.spec.js @@ -53,15 +53,18 @@ test.describe('Plugin sections in the public menu', () => { await admin.goto(BASE + section.admin); test.skip(await admin.locator('#menuVisibilityForm').count() === 0, `${section.name} plugin not active`); - const visitor = await browser.newPage(); - // Where the section is listed at all (an archive with no published - // unit has no entry), the switch must take it out and put it back. - await setInMenu(admin, section.admin, true); - const listed = await menuLinks(visitor, '/catalogo', section.href); - test.skip(listed === 0, `${section.name} has nothing to list`); - const accountListed = await menuLinks(admin, '/utente/bacheca', section.href); + // Remember the admin's own setting so the test leaves it as it found it. + const initiallyInMenu = await admin.locator('#menuVisibilityForm input[name="in_menu"]').isChecked(); + const visitor = await browser.newPage(); try { + // Where the section is listed at all (an archive with no published + // unit has no entry), the switch must take it out and put it back. + await setInMenu(admin, section.admin, true); + const listed = await menuLinks(visitor, '/catalogo', section.href); + test.skip(listed === 0, `${section.name} has nothing to list`); + const accountListed = await menuLinks(admin, '/utente/bacheca', section.href); + await setInMenu(admin, section.admin, false); expect(await menuLinks(visitor, '/catalogo', section.href), 'public menus').toBe(0); expect(await menuLinks(visitor, '/', section.href), 'home menus').toBe(0); @@ -69,11 +72,13 @@ test.describe('Plugin sections in the public menu', () => { // Only the menu entry goes: the section itself is still served. const res = await visitor.goto(BASE + section.page); expect(res && res.status()).toBe(200); - } finally { + await setInMenu(admin, section.admin, true); + expect(await menuLinks(visitor, '/catalogo', section.href)).toBe(listed); + expect(await menuLinks(admin, '/utente/bacheca', section.href)).toBe(accountListed); + } finally { + await setInMenu(admin, section.admin, initiallyInMenu); } - expect(await menuLinks(visitor, '/catalogo', section.href)).toBe(listed); - expect(await menuLinks(admin, '/utente/bacheca', section.href)).toBe(accountListed); }); } }); diff --git a/tests/public-style-defaults.spec.js b/tests/public-style-defaults.spec.js index 829a00ef1..a79452d83 100644 --- a/tests/public-style-defaults.spec.js +++ b/tests/public-style-defaults.spec.js @@ -4,8 +4,12 @@ * the hero with the fan of covers and the "classic" book cards, chosen as * the defaults in the admin themes page. A theme that never saved the two * choices (every theme a fresh install seeds, every theme an upgrade carries - * over) must read as covers + classic. Run by scripts/reinstall-test.sh on - * both Test A and Test B, and on its own against a running install. + * over) must read as covers + classic. Not listed in + * tests/ci-playwright-policy.json, so CI runs it in the Deep Regression Gate + * (.github/workflows/ci-deep-regression.yml, via + * `node scripts/ci-playwright-policy.js shard`) against a fresh install; run + * it on its own against any running install (fresh or upgraded) with + * E2E_BASE_URL, E2E_ADMIN_EMAIL and E2E_ADMIN_PASS set. */ const { test, expect } = require('@playwright/test'); diff --git a/tests/session-fixes.spec.js b/tests/session-fixes.spec.js index 3fcc53899..c4578c04f 100644 --- a/tests/session-fixes.spec.js +++ b/tests/session-fixes.spec.js @@ -180,7 +180,10 @@ test.describe.serial('Book detail UI', () => { }).length; return { width: box.width, parentWidth: parent.getBoundingClientRect().width, shared }; }); - test.skip(row === null, 'no "Cerca su" block on this book (GoodLib inactive)'); + // Test 6 already requires the block on this page, so its absence is a + // failure here too, not a reason to skip. + expect(row, '"Cerca su" block present on the book page').not.toBeNull(); + if (row === null) return; expect(row.width).toBeGreaterThanOrEqual(row.parentWidth - 1); expect(row.shared).toBe(0); }); diff --git a/tests/theme-readable-accent.unit.php b/tests/theme-readable-accent.unit.php index f533624bb..f5c5da503 100644 --- a/tests/theme-readable-accent.unit.php +++ b/tests/theme-readable-accent.unit.php @@ -5,7 +5,9 @@ * The accent as a text colour (ThemeColorizer::readableOnTint, exposed as * --primary-text). Every theme preset must read at WCAG AA (4.5:1) on its own * soft tint and on white, keep its hue, and stay untouched when the accent is - * already dark enough. + * already dark enough. Also covers the 2026 surfaces (readableSurface, + * readableOnDark and the palette's button_surface / secondary_surface / + * primary_on_dark): AA with their text for every preset. * * Run: php tests/theme-readable-accent.unit.php */ @@ -51,5 +53,54 @@ $palette = $c->generateColorPalette(['primary' => '#0d9488']); $check(($palette['primary_text'] ?? '') === $c->readableOnTint('#0d9488'), 'generateColorPalette() carries primary_text'); +// 2026 design: filled surfaces that carry text (readableSurface) and the +// accent as text on the dark surface (readableOnDark), exposed by +// generateColorPalette() as button_surface, secondary_surface, primary_on_dark. +// The ten bundled presets, as seeded by installer/database/data_en_US.sql. +$themes = [ + ['primary' => '#d70161', 'secondary' => '#111827', 'button' => '#d70262', 'button_text' => '#ffffff'], + ['primary' => '#404040', 'secondary' => '#000000', 'button' => '#808080', 'button_text' => '#ffffff'], + ['primary' => '#0284c7', 'secondary' => '#0c4a6e', 'button' => '#0ea5e9', 'button_text' => '#ffffff'], + ['primary' => '#059669', 'secondary' => '#064e3b', 'button' => '#10b981', 'button_text' => '#ffffff'], + ['primary' => '#ea580c', 'secondary' => '#7c2d12', 'button' => '#f97316', 'button_text' => '#ffffff'], + ['primary' => '#be123c', 'secondary' => '#881337', 'button' => '#e11d48', 'button_text' => '#ffffff'], + ['primary' => '#0d9488', 'secondary' => '#134e4a', 'button' => '#14b8a6', 'button_text' => '#ffffff'], + ['primary' => '#475569', 'secondary' => '#1e293b', 'button' => '#64748b', 'button_text' => '#ffffff'], + ['primary' => '#f43f5e', 'secondary' => '#9f1239', 'button' => '#fb7185', 'button_text' => '#ffffff'], + ['primary' => '#1e40af', 'secondary' => '#1e3a8a', 'button' => '#3b82f6', 'button_text' => '#ffffff'], +]; +foreach ($themes as $theme) { + $p = $c->generateColorPalette($theme); + $name = $theme['primary']; + $check( + ($p['button_surface'] ?? '') === $c->readableSurface($theme['button'], $theme['button_text']) + && $c->getContrastRatio($theme['button_text'], $p['button_surface']) >= 4.5, + "{$name}: button_surface {$p['button_surface']} reads at AA under {$theme['button_text']}" + ); + $check( + ($p['secondary_surface'] ?? '') === $c->readableSurface($theme['secondary'], '#ffffff') + && $c->getContrastRatio('#ffffff', $p['secondary_surface']) >= 4.5, + "{$name}: secondary_surface {$p['secondary_surface']} reads at AA under white" + ); + $check( + ($p['primary_on_dark'] ?? '') === $c->readableOnDark($theme['primary'], $p['secondary_surface']) + && $c->getContrastRatio($p['primary_on_dark'], $p['secondary_surface']) >= 4.5, + "{$name}: primary_on_dark {$p['primary_on_dark']} reads at AA on secondary_surface" + ); +} + +// A pair that already reads comes back unchanged. +$check($c->readableSurface('#111827', '#ffffff') === '#111827', '#111827 under white already reads and is left as it is'); +$check($c->readableSurface('#ffffff', '#111827') === '#ffffff', 'white under #111827 already reads and is left as it is'); +$check($c->readableOnDark('#ffffff', '#111827') === '#ffffff', 'white on #111827 already reads and is left as it is'); + +// Dark text on a mid surface: the surface is lightened, not darkened. +$mid = '#808080'; +$lifted = $c->readableSurface($mid, '#111827'); +$check($c->getContrastRatio('#111827', $lifted) >= 4.5, "dark text: {$mid} → {$lifted} reads at AA under #111827"); +$check($c->getContrastRatio($lifted, '#000000') > $c->getContrastRatio($mid, '#000000'), "dark text: {$mid} is lightened, not darkened"); +$tooDark = $c->readableSurface('#1e3a8a', '#111827'); +$check($c->getContrastRatio('#111827', $tooDark) >= 4.5, "dark text: #1e3a8a → {$tooDark} reads at AA under #111827"); + echo PHP_EOL . "Passed: {$passed}, Failed: {$failed}" . PHP_EOL; exit($failed === 0 ? 0 : 1); diff --git a/tests/uppy-image-preview.spec.js b/tests/uppy-image-preview.spec.js index 420e165a6..dea211f37 100644 --- a/tests/uppy-image-preview.spec.js +++ b/tests/uppy-image-preview.spec.js @@ -18,22 +18,8 @@ const os = require('os'); 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 || ''; -const DB_USER = process.env.E2E_DB_USER || ''; -const DB_PASS = process.env.E2E_DB_PASS || ''; -const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; -const DB_NAME = process.env.E2E_DB_NAME || ''; -const INSTALL_ROOT = process.env.E2E_INSTALL_ROOT || ''; - -test.skip(!ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_NAME, 'E2E credentials not configured'); - -function db(sql) { - const args = []; - 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: 10000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); -} -function sqlq(s) { return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; } +test.skip(!ADMIN_EMAIL || !ADMIN_PASS, 'E2E admin credentials not configured'); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uppyprev-')); const JPG = path.join(tmp, 'preview.jpg'); @@ -112,8 +98,4 @@ test.describe.serial('native Uppy image preview', () => { // gets its src, so check the src rather than on-screen visibility. await expectPreview(page, '#uppy-logo-upload', '#logo-preview-image', '#logo-file-input', false); }); - - // The full #292 loop, through the real UI: pick a file in Uppy, submit the - // actual form, and prove the image is persisted under /uploads/assets AND - // served over HTTP. This is the end-to-end the reporter's flow exercises. }); From ffc398eeac988f6786741f73dc6cd66465fc970f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 7 Oct 2026 19:31:13 +0200 Subject: [PATCH 26/45] Make the hero covers spec independent of the catalogue it finds In CI the spec stopped on is_desiderata, a column the desiderata plugin adds, and would then have found no book with a cover, since the seeded catalogue has none. It now filters on the column only where it exists, lends a searchable book a cover for the run when there is none and gives it back afterwards, and picks the book by its whole title, since a prefix can match another edition listed first. --- tests/home-hero-covers-2026.spec.js | 30 +++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/home-hero-covers-2026.spec.js b/tests/home-hero-covers-2026.spec.js index a97b15db3..c93086871 100644 --- a/tests/home-hero-covers-2026.spec.js +++ b/tests/home-hero-covers-2026.spec.js @@ -39,18 +39,39 @@ async function login(page) { } let original = null; +let hasWanted = false; +let lent = null; let pick = { id: 0, title: '' }; test.describe.serial('Home hero covers (2026)', () => { test.beforeAll(() => { if (!process.env.E2E_DB_USER) throw new Error('Run with /tmp/run-e2e.sh'); original = db("SELECT COALESCE(content, '') FROM home_content WHERE section_key='hero'"); - const row = db("SELECT id, titolo FROM libri WHERE deleted_at IS NULL AND COALESCE(is_desiderata,0)=0 AND copertina_url <> '' AND copertina_url NOT LIKE '%placeholder%' ORDER BY id LIMIT 1").split('\t'); + // is_desiderata belongs to the desiderata plugin: filter on it only where + // the plugin has added it (a fresh CI install may not have it). + hasWanted = db("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'libri' AND COLUMN_NAME = 'is_desiderata'") === '1'; + const notWanted = hasWanted ? ' AND COALESCE(is_desiderata,0)=0' : ''; + let found = db(`SELECT id, titolo FROM libri WHERE deleted_at IS NULL${notWanted} AND copertina_url <> '' AND copertina_url NOT LIKE '%placeholder%' ORDER BY id LIMIT 1`); + if (found === '') { + // The hero shows covered books only. A catalogue seeded without covers + // (CI) lends one book a cover for the run; afterAll gives it back. + // A book the picker can find: its search runs on search_index, which + // rows written straight into the table do not have. + const plain = db(`SELECT id, COALESCE(copertina_url, '') FROM libri WHERE deleted_at IS NULL${notWanted} AND COALESCE(search_index, '') <> '' ORDER BY id LIMIT 1`).split('\t'); + lent = { id: Number(plain[0]), cover: plain[1] || '' }; + db(`UPDATE libri SET copertina_url='/assets/brand/logo_small.png' WHERE id=${lent.id}`); + found = db(`SELECT id, titolo FROM libri WHERE id=${lent.id}`); + } + const row = found.split('\t'); pick = { id: Number(row[0]), title: row[1] }; expect(pick.id, 'a catalogued book with a cover exists').toBeGreaterThan(0); }); test.afterAll(() => { + if (lent && lent.id > 0) { + const cover = lent.cover === '' ? 'NULL' : "'" + lent.cover.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; + db(`UPDATE libri SET copertina_url=${cover} WHERE id=${lent.id}`); + } if (original !== null) { const value = original === '' ? 'NULL' : "'" + original.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; db(`UPDATE home_content SET content=${value} WHERE section_key='hero'`); @@ -71,8 +92,9 @@ test.describe.serial('Home hero covers (2026)', () => { await page.locator('input[name="hero[cover_mode]"][value="selected"]').check(); await expect(page.locator('#hero-cover-picker')).toBeVisible(); await page.locator('#hero-cover-selected .hero-cover-remove').evaluateAll(btns => btns.forEach(b => b.click())); - await page.locator('#hero-cover-search').fill(pick.title.slice(0, 12)); - const option = page.locator('#hero-cover-results button', { hasText: pick.title.slice(0, 12) }).first(); + // The whole title: a prefix can match another edition listed first. + await page.locator('#hero-cover-search').fill(pick.title); + const option = page.locator('#hero-cover-results button', { hasText: pick.title }).first(); await expect(option).toBeVisible({ timeout: 10000 }); await option.click(); await expect(page.locator(`#hero-cover-selected input[value="${pick.id}"]`)).toHaveCount(1); @@ -105,7 +127,7 @@ test.describe.serial('Home hero covers (2026)', () => { try { await visitor.goto(`${BASE}/`); await expect(visitor.locator('.pk-fan .pk-fan__book').first()).toBeVisible(); - await expect(visitor.locator('.pk-fan .pk-fan__book')).toHaveCount(Number(db("SELECT LEAST(4, COUNT(*)) FROM libri WHERE deleted_at IS NULL AND COALESCE(is_desiderata,0)=0 AND copertina_url <> '' AND copertina_url NOT LIKE '%placeholder%'"))); + await expect(visitor.locator('.pk-fan .pk-fan__book')).toHaveCount(Number(db(`SELECT LEAST(4, COUNT(*)) FROM libri WHERE deleted_at IS NULL${hasWanted ? ' AND COALESCE(is_desiderata,0)=0' : ''} AND copertina_url <> '' AND copertina_url NOT LIKE '%placeholder%'`))); } finally { await visitor.context().close(); } From 9f0bc64e05ffd020e5dec6542591bef136e861f4 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 7 Oct 2026 20:26:10 +0200 Subject: [PATCH 27/45] Set the accent's text shade too when the field test switches theme The field rule is now mixed from --primary-text, the accent's readable text shade, so changing only --primary-color left the border as it was. A theme sets both; the test now does the same. --- tests/mobile-public-layout.spec.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/mobile-public-layout.spec.js b/tests/mobile-public-layout.spec.js index 6f3be0c33..d2450f88e 100644 --- a/tests/mobile-public-layout.spec.js +++ b/tests/mobile-public-layout.spec.js @@ -106,7 +106,12 @@ test.describe('Form fields follow the theme', () => { expect(before.bg).not.toBe('rgb(255, 255, 255)'); // Another theme's accent: the same field must recolour, with no other change. // (Through the CSSOM: the site's CSP rightly refuses an injected