$birthdays * @var Collection|null $anniversaries * @var Collection|null $historical * @var bool $include_anniversaries * @var bool $include_historical * @var int $lookahead_days * @var int $historical_lookahead * @var int $generated_at * @var array $avatar_cids xref => CID name * @var string $account_url */ // ─── BockenTheme light-mode palette ───────────────────────────────────── // Pulled from src/scss/theme.scss + config/_theme-variables.scss in the // Bocken theme so the newsletter and the website read as one product. $palette = [ 'bg' => '#f8f6f1', // body / page background (--color-bg-primary) 'surface' => '#efecea', // raised section cards (--color-bg-secondary) 'elevated' => '#dfdcd8', // tertiary surface (--color-bg-elevated) 'border' => '#ddd', // hairline borders (--color-border) 'ink' => '#2a2a2a', // primary text (--color-text-primary) 'ink2' => '#555', // secondary text (--color-text-secondary) 'ink3' => '#777', // tertiary text (--color-text-tertiary) 'mute' => '#aaa', // muted text (--color-text-muted) 'link' => '#5E81AC', // nord10 — primary link 'link_hov' => '#81A1C1', // nord9 'accent' => '#BF616A', // nord11 — red FAB accent 'birth' => '#5E81AC', // nord10 — birth (blue) 'death' => '#4C566A', // nord3 — death (graphite) 'marr' => '#B48EAD', // nord15 — marriage (purple/pink) ]; $font_stack = "'Open Sans', Helvetica, Arial, 'Noto Sans', sans-serif"; $avatar_size = 56; // ─── Helpers ──────────────────────────────────────────────────────────── $linked_name = static function (Individual $individual) use ($palette): string { $name = strip_tags($individual->fullName()); $url = $individual->url(); $style = 'color:' . $palette['ink'] . ';text-decoration:none;' . 'border-bottom:1px solid ' . $palette['border'] . ';' . 'padding-bottom:1px;'; return '' . e($name) . ''; }; $record_label = static function (Fact $fact) use ($linked_name): string { $record = $fact->record(); if ($record instanceof Individual) { return $linked_name($record); } if ($record instanceof Family) { $parts = []; foreach ([$record->husband(), $record->wife()] as $spouse) { if ($spouse instanceof Individual) { $parts[] = $linked_name($spouse); } } return implode(' & ', $parts); } return e($record->xref()); }; $event_date_display = static function (Fact $fact): string { $date = $fact->date(); if (!$date instanceof Date || !$date->isOK()) { return ''; } return strip_tags($date->display()); }; $event_kind = static function (Fact $fact): string { $parts = explode(':', $fact->tag()); return end($parts); }; /** * Compact inline SVG glyph for each event type. Coloured to match the * BockenTheme Nord palette: birth in nord10 blue, death in nord3 * graphite, marriage in nord15 mauve. */ $event_icon = static function (string $kind) use ($palette): string { $svg_open = ' $svg_open . 'viewBox="0 0 24 24" width="18" height="18">' . '' . '', // Latin obelus — the typographic mark for "died" in obituaries. 'DEAT' => $svg_open . 'viewBox="0 0 24 24" width="14" height="18">' . '' . '' . '', // Two interlocking rings — universal mark of marriage. 'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">' . '' . '' . '', default => '', }; }; /** * Map an event type to the colour used for that row's timeline dot * (so the right rail reads as a colour-coded ribbon). */ $event_color = static function (string $kind) use ($palette): string { return match ($kind) { 'BIRT' => $palette['birth'], 'DEAT' => $palette['death'], 'MARR' => $palette['marr'], default => $palette['ink3'], }; }; $avatar = static function (Individual|null $individual) use ($avatar_cids, $avatar_size, $palette): string { if (!$individual instanceof Individual) { return ''; } $alt = e(strip_tags($individual->fullName())); $shadow = 'box-shadow:0 0 0 1px ' . $palette['border'] . ',0 1px 3px rgba(0,0,0,0.08);'; if (isset($avatar_cids[$individual->xref()])) { $cid = $avatar_cids[$individual->xref()]; $inner = '' . $alt . ''; } else { // Initial-disc fallback. Hue is hashed from the xref so the // same person keeps the same colour across editions. $hue = hexdec(substr(md5($individual->xref()), 0, 2)) * 360 / 255; $first = strip_tags($individual->getAllNames()[0]['givn'] ?? $individual->xref()); $last = strip_tags($individual->getAllNames()[0]['surn'] ?? ''); $initials = e(mb_strtoupper(mb_substr($first, 0, 1) . mb_substr($last, 0, 1))); $inner = '' . $initials . ''; } return '' . $inner . ''; }; $record_avatars = static function (Fact $fact) use ($avatar): string { $record = $fact->record(); if ($record instanceof Individual) { return $avatar($record); } if ($record instanceof Family) { $parts = []; foreach ([$record->husband(), $record->wife()] as $spouse) { if ($spouse instanceof Individual) { $parts[] = $avatar($spouse); } } return '' . '' . (isset($parts[1]) ? '' : '') . '
' . ($parts[0] ?? '') . '' . $parts[1] . '
'; } return ''; }; // Locale-aware ordinal: German uses "N.", English uses st/nd/rd/th. $ordinal = static function (int $n): string { if (str_starts_with(I18N::languageTag(), 'de')) { return $n . '.'; } $abs = abs($n); $mod100 = $abs % 100; if ($mod100 >= 11 && $mod100 <= 13) { return $n . 'th'; } return $n . match ($abs % 10) { 1 => 'st', 2 => 'nd', 3 => 'rd', default => 'th', }; }; $upcoming_age = static function (Fact $fact): int { static $gregorian = null; $gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar(); $date = $fact->date(); if (!$date->isOK()) { return 0; } $event_year = $date->gregorianYear(); $upcoming_jd = $fact->jd ?? 0; if ($upcoming_jd > 0) { [$upcoming_year] = $gregorian->jdToYmd($upcoming_jd); } else { $upcoming_year = (int) date('Y'); } return max(0, $upcoming_year - $event_year); }; $birthday_label = static function (int $age) use ($ordinal): string { return $age > 0 ? I18N::translate('%s birthday', $ordinal($age)) : I18N::translate('Birthday'); }; $anniversary_label = static function (int $age) use ($ordinal): string { return $age > 0 ? I18N::translate('%s wedding anniversary', $ordinal($age)) : I18N::translate('Wedding anniversary'); }; $masthead_date = static function (int $timestamp): string { if (str_starts_with(I18N::languageTag(), 'de')) { $months = [ 1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', ]; return (int) date('j', $timestamp) . '. ' . $months[(int) date('n', $timestamp)] . ' ' . date('Y', $timestamp); } return date('F j, Y', $timestamp); }; // ─── Row styles ───────────────────────────────────────────────────────── $section_title_style = 'margin:0 0 4px;' . 'font-family:' . $font_stack . ';' . 'font-weight:400;font-size:24px;line-height:1.2;' . 'color:' . $palette['ink'] . ';' . 'letter-spacing:-0.005em;'; $section_kicker_style = 'margin:0 0 18px;' . 'font-family:' . $font_stack . ';' . 'font-style:italic;font-weight:300;font-size:14px;' . 'color:' . $palette['ink3'] . ';'; $divider_style = 'border-bottom:1px solid ' . $palette['border'] . ';'; $avatar_cell_style = 'width:72px;vertical-align:middle;padding:18px 0 18px 18px;'; $content_cell_style = 'vertical-align:middle;' . 'padding:18px 18px 18px 16px;' . 'font-family:' . $font_stack . ';font-size:15px;line-height:1.4;' . 'font-weight:300;color:' . $palette['ink'] . ';'; $timeline_cell_style = 'width:170px;vertical-align:middle;' . 'padding:18px 18px 18px 26px;' . 'border-left:1px solid ' . $palette['border'] . ';' . 'font-family:' . $font_stack . ';' . 'font-weight:500;font-size:11px;letter-spacing:0.12em;text-transform:uppercase;' . 'color:' . $palette['ink3'] . ';white-space:nowrap;'; // Renders one event row in the consistent 3-column layout shared by // every section (avatar | content | date on the timeline rail). $event_row = static function (Fact $fact, string $body_html, string $dot_color) use ( $record_avatars, $event_date_display, $avatar_cell_style, $content_cell_style, $timeline_cell_style, $divider_style, ): string { return '' . '' . $record_avatars($fact) . '' . '' . $body_html . '' . '' . '' . e($event_date_display($fact)) . '' . ''; }; // Wraps a list of rows in the "card" surface — rounded background // patch matching .card on the website. $card_open = ''; $card_close = '
'; ?> <?= e(I18N::translate('Family newsletter — %s', $masthead_date($generated_at))) ?>
isEmpty()) : ?> isEmpty()) : ?> isEmpty()) : ?>

title()) ?>

·

' . $event_icon('BIRT') . '' . '' . '' . $record_label($fact) . '' . ' — ' . e($birthday_label($age)) . '' . ''; echo $event_row($fact, $body, $palette['birth']); ?>

' . $event_icon('MARR') . '' . '' . '' . $record_label($fact) . '' . ' — ' . e($anniversary_label($age)) . '' . ''; echo $event_row($fact, $body, $palette['marr']); ?>

' . $event_icon($kind) . '' . '' . '' . $record_label($fact) . '' . ' — ' . e($fact->label()) . '' . ''; echo $event_row($fact, $body, $event_color($kind)); ?>

title())) ?>
' . e(I18N::translate('My account')) . '', ) ?>