Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
acb9e85
Restyle the public site and the account pages (2026 design)
fabiodalez-dev Oct 6, 2026
7fbfa30
Match the book page to the design; hero and card style as theme options
fabiodalez-dev Oct 7, 2026
4f8946d
Make the catalogue list view a real list
fabiodalez-dev Oct 7, 2026
2d41036
Fix the public site on phones: hero order, folded filters, profile fi…
fabiodalez-dev Oct 7, 2026
f8aa632
Let the hero books' shadow fall past the fan when it is stacked
fabiodalez-dev Oct 7, 2026
7540e43
Wanted books with real covers, genre rows aligned, archive year field…
fabiodalez-dev Oct 7, 2026
59a2049
Show the book's genre path as the first row of its details
fabiodalez-dev Oct 7, 2026
bab76e8
Give every public form field a soft fill and a thin rule tinted by th…
fabiodalez-dev Oct 7, 2026
c599873
Bring the wishlist page into the 2026 design
fabiodalez-dev Oct 7, 2026
6b3edf3
Lay the footer out on a grid and fix its Seguici column
fabiodalez-dev Oct 7, 2026
29242ee
List the footer's social profiles in four rows, level with Menu and A…
fabiodalez-dev Oct 7, 2026
9e6b8fd
Send readers to the dashboard in the install's language
fabiodalez-dev Oct 7, 2026
1dad80b
Bring the loans and reservations cards into the 2026 design
fabiodalez-dev Oct 7, 2026
c747259
Merge main (0.7.94) into the 2026 restyle
fabiodalez-dev Oct 7, 2026
211add6
Keep the catalogue list a list after paging
fabiodalez-dev Oct 7, 2026
e40d34f
Rebuild main.css from the current views
fabiodalez-dev Oct 7, 2026
b5c8938
Make theme-coloured text readable on every theme
fabiodalez-dev Oct 7, 2026
b736319
Apply the custom CSS, custom scripts and cookie banner on every publi…
fabiodalez-dev Oct 7, 2026
f86575f
Let the home's events section title be edited
fabiodalez-dev Oct 7, 2026
53d6e5f
Raise the admin counters and card notes to AA contrast
fabiodalez-dev Oct 7, 2026
0f55a26
Let the admin take Emeroteca and Archive out of the public menu
fabiodalez-dev Oct 7, 2026
2a7b67e
Check the theme CSS sanitisation in its shared partial
fabiodalez-dev Oct 7, 2026
37d233c
Bring two browser specs in line with the 2026 footer and the CI database
fabiodalez-dev Oct 7, 2026
2069d62
Keep site scripts off the auth pages and retire the dead restyle left…
fabiodalez-dev Oct 7, 2026
76bfbd3
Make the 2026 design readable and operable on every theme
fabiodalez-dev Oct 7, 2026
ad33b4e
Close the remaining review findings: escaping, media types, hero, cat…
fabiodalez-dev Oct 7, 2026
ffc398e
Make the hero covers spec independent of the catalogue it finds
fabiodalez-dev Oct 7, 2026
24500a4
Merge branch 'pr-460' into verify-460
fabiodalez-dev Oct 7, 2026
9f0bc64
Set the accent's text shade too when the field test switches theme
fabiodalez-dev Oct 7, 2026
9415eef
Show an article's image in the header search suggestions (#453)
fabiodalez-dev Oct 7, 2026
2113ab6
Use Fraunces as the serif instead of Newsreader
fabiodalez-dev Oct 7, 2026
7b1bd47
Draw form fields white, and one border per search box
fabiodalez-dev Oct 7, 2026
7a58ff5
Keep 404s and routine plugin saves out of the error log
fabiodalez-dev Oct 7, 2026
3e534ba
Run the book details and keywords across the whole width
fabiodalez-dev Oct 8, 2026
6fa337f
Preserve author searches across facet rebuilds and show complete covers
fabiodalez-dev Oct 8, 2026
a3418d0
Fix catalog navigation and staff article PDF access (#461)
fabiodalez-dev Oct 8, 2026
afcf2ca
Close restyle review findings and keep theme hover contrast readable
fabiodalez-dev Oct 8, 2026
a75562e
test: keep article genre regression aligned with open drill-down
fabiodalez-dev Oct 8, 2026
84a023a
Polish missing book covers with coordinated cloth bindings
fabiodalez-dev Oct 8, 2026
45a7b8f
Complete mobile collection APIs and reconcile Uwe catalogue requests
fabiodalez-dev Oct 8, 2026
f23c2b9
Match localized catalogue language facets to analytic ISO codes
fabiodalez-dev Oct 8, 2026
f561684
Keep article detail independent of mobile middleware loading and tran…
fabiodalez-dev Oct 8, 2026
5fbe76e
Record final Uwe parity validation
fabiodalez-dev Oct 8, 2026
f1a68e8
Preserve release review evidence for mobile and Android parity
fabiodalez-dev Oct 8, 2026
a14ac7b
Exclude worktree Git metadata from release ZIPs and verify real packa…
fabiodalez-dev Oct 8, 2026
14559e1
Tidy the 2026 design and close the review findings
fabiodalez-dev Oct 8, 2026
2e6078e
Line up the catalogue rows and the book actions on phones
fabiodalez-dev Oct 8, 2026
a07b028
Phone catalogue rows: cover level with the title
fabiodalez-dev Oct 8, 2026
a42b8c6
Drop the hero photo remnants and the plugins' stale version caps
fabiodalez-dev Oct 8, 2026
1b7b787
Warm black for the stock theme, lighter archive search, deep Dewey nu…
fabiodalez-dev Oct 9, 2026
d398449
Show the image uploaded for a CMS page
fabiodalez-dev Oct 9, 2026
abbe641
Encrypt push messages and harden the mobile API
fabiodalez-dev Oct 9, 2026
d00e87b
Archives: MARC 21 export, publication flag, EAD3 fixes; FRBR expressi…
fabiodalez-dev Oct 9, 2026
af4b83f
Interoperability: align OAI-PMH, NCIP, SRU, ResourceSync, BIBFRAME, O…
fabiodalez-dev Oct 9, 2026
1c08de6
Periodicals and book clubs: localized addresses, private clubs, invit…
fabiodalez-dev Oct 9, 2026
5460d2d
Catalogue sources and media plugins: safer keys, honest defaults, sha…
fabiodalez-dev Oct 9, 2026
919da80
Quick search: one list of records, best title match first, then alpha…
fabiodalez-dev Oct 9, 2026
4594f0b
Translate the new plugin messages; pass waitForFunction timeouts corr…
fabiodalez-dev Oct 9, 2026
eeece36
Close the review findings on unpublished archives, PDF ranges and the…
fabiodalez-dev Oct 9, 2026
bdd63c5
Make the CI suites match the new behaviour and the runner
fabiodalez-dev Oct 9, 2026
ed28e2c
Do not read non-HTML responses into memory in the security-header mid…
fabiodalez-dev Oct 9, 2026
5e8455d
Fix the desiderata ResourceSync check for the new change-list signatu…
fabiodalez-dev Oct 9, 2026
ec6994a
Run the mirrored integration suites in strict mode, as CI does
fabiodalez-dev Oct 9, 2026
f5ed1ab
Browser specs: tolerate the doubled nosniff header and skip inactive …
fabiodalez-dev Oct 9, 2026
710a970
Book covers fill the whole book again
fabiodalez-dev Oct 9, 2026
483f195
Update the cover comments to the restored fill behaviour
fabiodalez-dev Oct 9, 2026
b11e638
Phone tab bar, as the Android app's bottom navigation
fabiodalez-dev Oct 9, 2026
89612f8
Keep the favourites badge in step with the book page and the favourit…
fabiodalez-dev Oct 9, 2026
6f5ffc7
Mobile book club: a private club's card and Join stay reachable, its …
fabiodalez-dev Oct 9, 2026
9690cee
Release 0.8.0: version, changelog and README highlights
fabiodalez-dev Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/Controllers/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ public function login(Request $request, Response $response, mysqli $db): Respons
} elseif (in_array($row['tipo_utente'], ['admin', 'staff'], true)) {
$redirectUrl = '/admin/dashboard';
} else {
$redirectUrl = '/user/dashboard';
// The dashboard in the install's language (/utente/bacheca on an Italian
// install), not the English path, which also answers but is not canonical.
$redirectUrl = RouteTranslator::route('user_dashboard');
}

return $response->withHeader('Location', $redirectUrl)->withStatus(302);
Expand Down
64 changes: 64 additions & 0 deletions app/Controllers/CmsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();
}
}
}
}

Expand Down Expand Up @@ -544,6 +588,26 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar
$stmt->close();
}

// Events section (the home's list of upcoming events)
if (isset($data['events']) && empty($errors)) {
$events = $data['events'];
$title = $sanitizeText($events['title'] ?? '');
$subtitle = $sanitizeText($events['subtitle'] ?? '');
$isActive = isset($events['is_active']) ? 1 : 0;

$stmt = $db->prepare("
INSERT INTO home_content (section_key, title, subtitle, is_active, display_order)
VALUES ('events', ?, ?, ?, 9)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
subtitle = VALUES(subtitle),
is_active = VALUES(is_active)
");
$stmt->bind_param('ssi', $title, $subtitle, $isActive);
$stmt->execute();
$stmt->close();
}

// Text content section
if (isset($data['text_content']) && empty($errors)) {
$textContent = $data['text_content'];
Expand Down
80 changes: 78 additions & 2 deletions app/Controllers/FrontendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -57,6 +57,7 @@ public function home(Request $request, Response $response, mysqli $db, ?Containe
}
$homeEvents = $homeData['homeEvents'];
$heroTotalBooks = $homeData['totalBooks'];
$heroCovers = $homeData['heroCovers'] ?? [];
Comment thread
pullfrog[bot] marked this conversation as resolved.
$heroAvailableBooks = $homeData['availableBooks'];

$homeEventsEnabled = $homeData['eventsFeatureEnabled'] && !empty($homeEvents);
Expand Down Expand Up @@ -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<int>}
*/
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<array<string, mixed>>
*/
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)
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
40 changes: 27 additions & 13 deletions app/Controllers/ThemeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public function index(Request $request, Response $response): Response

$themes = $this->themeManager->getAllThemes();
$activeTheme = $this->themeManager->getActiveTheme();
$activeLayoutVariant = $this->themeManager->getLayoutVariant($activeTheme);
$publicStyle = $this->themeManager->getPublicStyle($activeTheme);
$pageTitle = __('Gestione Temi');

// Render view
Expand Down Expand Up @@ -73,7 +73,7 @@ public function customize(Request $request, Response $response, array $args): Re
$settings = json_decode($theme['settings'], true) ?? [];
$colors = $settings['colors'] ?? [];
$advanced = $settings['advanced'] ?? [];
$layoutVariant = $this->themeManager->getLayoutVariant($theme);
$publicStyle = $this->themeManager->getPublicStyle($theme);
$pageTitle = __('Personalizza Tema') . ': ' . $theme['name'];

// Render view
Expand Down Expand Up @@ -149,16 +149,16 @@ public function save(Request $request, Response $response, array $args): Respons
}
}

$layoutVariant = $parsedBody['layout_variant'] ?? ThemeManager::DEFAULT_LAYOUT_VARIANT;
if (!is_string($layoutVariant) || !in_array($layoutVariant, ThemeManager::LAYOUT_VARIANTS, true)) {
$publicStyle = $this->postedPublicStyle($parsedBody);
if ($publicStyle === null) {
$_SESSION['error'] = __('Stile interfaccia non valido');
return $response
->withHeader('Location', url('/admin/themes/' . $themeId . '/customize'))
->withStatus(302);
}

// Build the optional advanced block before writing anything. Colors,
// layout and CSS are then persisted by ThemeManager in one JSON update,
// public style and CSS are then persisted by ThemeManager in one JSON update,
// so a failure cannot leave a partially-saved customization.
$advanced = null;
if (isset($parsedBody['advanced']) && is_array($parsedBody['advanced'])) {
Expand All @@ -177,7 +177,7 @@ public function save(Request $request, Response $response, array $args): Respons
$advanced['custom_css'] = \App\Support\ContentSanitizer::sanitizeCustomCss($advanced['custom_css']);
}

$success = $this->themeManager->updateThemeColors($themeId, $colors, $layoutVariant, $advanced);
$success = $this->themeManager->updateThemeColors($themeId, $colors, $publicStyle, $advanced);

if ($success) {
$_SESSION['success'] = __('Tema salvato con successo');
Expand All @@ -191,28 +191,26 @@ public function save(Request $request, Response $response, array $args): Respons
}

/**
* Save only the active theme's public layout from the themes overview.
* Save only the active theme's public style (hero, cards) from the themes overview.
*/
public function saveLayout(Request $request, Response $response, array $args): Response
{
// Check authorization — AdminAuthMiddleware also admits 'staff', but
// changing the site-wide public layout is admin-only (matches index()/customize()).
// changing the site-wide public style is admin-only (matches index()/customize()).
if (!isset($_SESSION['user']) || $_SESSION['user']['tipo_utente'] !== 'admin') {
return $response->withHeader('Location', url('/admin/dashboard'))->withStatus(302);
}

$themeId = (int) ($args['id'] ?? 0);
$theme = $this->themeManager->getThemeById($themeId);
$parsedBody = $request->getParsedBody();
$layoutVariant = is_array($parsedBody) && is_string($parsedBody['layout_variant'] ?? null)
? $parsedBody['layout_variant']
: '';
$publicStyle = is_array($parsedBody) ? $this->postedPublicStyle($parsedBody) : null;

if (!$theme || empty($theme['active'])) {
$_SESSION['error'] = __('Tema non trovato');
} elseif (!in_array($layoutVariant, ThemeManager::LAYOUT_VARIANTS, true)) {
} elseif ($publicStyle === null) {
$_SESSION['error'] = __('Stile interfaccia non valido');
} elseif ($this->themeManager->updateLayoutVariant($themeId, $layoutVariant)) {
} elseif ($this->themeManager->updatePublicStyle($themeId, $publicStyle)) {
$_SESSION['success'] = __('Tema salvato con successo');
} else {
$_SESSION['error'] = __('Errore nel salvataggio del tema');
Expand All @@ -223,6 +221,22 @@ public function saveLayout(Request $request, Response $response, array $args): R
->withStatus(302);
}

/**
* The hero and card styles a form posted, or null when either is not one
* of ThemeManager::HERO_STYLES / CARD_STYLES.
*
* @param array<mixed> $parsedBody
* @return array{hero_style:string,card_style:string}|null
*/
private function postedPublicStyle(array $parsedBody): ?array
{
$style = [
'hero_style' => is_string($parsedBody['hero_style'] ?? null) ? $parsedBody['hero_style'] : '',
'card_style' => is_string($parsedBody['card_style'] ?? null) ? $parsedBody['card_style'] : '',
];
return ThemeManager::isValidPublicStyle($style) ? $style : null;
}

/**
* Activate a theme
*/
Expand Down
8 changes: 7 additions & 1 deletion app/Controllers/UserWishlistController.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@ public function page(Request $request, Response $response, mysqli $db, mixed $co
// A favourite is HIDDEN, never deleted, while the book is flagged as a
// request: manage('received') clears the flag when the donation arrives,
// and the entry — plus its availability notification — must come back.
$sql = "SELECT l.id, l.titolo, l.copertina_url, l.copie_disponibili
// The main author: shown on the card, and what book_url() needs for the
// canonical /author/title/id address (without it the slug fell back to "autore").
$sql = "SELECT l.id, l.titolo, l.copertina_url, l.copie_disponibili,
(SELECT a.nome FROM libri_autori la JOIN autori a ON a.id = la.autore_id
WHERE la.libro_id = l.id
ORDER BY la.ruolo = 'principale' DESC, la.ordine_credito IS NULL, la.ordine_credito, a.id
LIMIT 1) AS autore
FROM wishlist w JOIN libri l ON l.id=w.libro_id
WHERE w.utente_id=? AND l.deleted_at IS NULL
AND " . \App\Support\BookVisibility::catalogue($db, 'l') . "
Expand Down
2 changes: 1 addition & 1 deletion app/Support/ContentCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
27 changes: 27 additions & 0 deletions app/Support/ThemeColorizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,33 @@ public function generateColorPalette(array $colors): array
'primary_dark' => $this->darken($primary, 15),
'secondary_hover' => $this->darken($secondary, 10),
'button_hover' => $this->darken($button, 10),
'primary_text' => $this->readableOnTint($primary),
];
}

/**
* The accent as a text colour: the same hue, darkened only as far as it
* takes to read at WCAG AA (4.5:1) on the accent's own soft tint, the
* lightest surface the public site sets accent text on
* (--pk-accent-soft: 9% accent over white). A dark accent comes back
* unchanged; a light one (orange, teal, coral) gets just deep enough.
*/
public function readableOnTint(string $hex): string
{
$hex = $this->normalizeHex($hex);
$rgb = $this->hexToRgb($hex);
$tint = $this->rgbToHex(
(int) round($rgb['r'] * 0.09 + 255 * 0.91),
(int) round($rgb['g'] * 0.09 + 255 * 0.91),
(int) round($rgb['b'] * 0.09 + 255 * 0.91)
);
for ($percent = 0; $percent <= 60; $percent += 2) {
$candidate = $this->darken($hex, $percent);
// A little over 4.5 so rounding in the browser cannot tip it under.
if ($this->getContrastRatio($candidate, $tint) >= 4.6) {
return $candidate;
}
}
return $this->darken($hex, 60);
}
}
Loading
Loading