$birthdays * @var Collection|null $anniversaries * @var Collection|null $historical * @var bool $include_anniversaries * @var int $window_days * @var array $avatar_srcs xref => https URL of the highlight image * @var array $relationships xref => "your mother" (per-viewer when signed-in + linked) */ // ─── Palette ──────────────────────────────────────────────────────────── // // Bind to BockenTheme CSS custom properties instead of fixed hex values // so the block automatically tracks the user's light/dark preference // (and any future theme tweak) without us re-implementing dark-mode // overrides. Where the theme has no semantic token for a particular // role — the rail or the dot, for instance — we lean on `--color-border` // (subtle in both modes) and `--color-link` (the Nord blue swap-pair). $palette = [ 'bg' => 'var(--color-bg-primary)', 'surface' => 'var(--color-bg-secondary)', 'border' => 'var(--color-border)', 'ink' => 'var(--color-text-primary)', 'ink2' => 'var(--color-text-secondary)', 'ink3' => 'var(--color-text-tertiary)', 'mute' => 'var(--color-text-muted)', 'link' => 'var(--color-link)', 'link_hov' => 'var(--color-link-hover)', 'accent' => 'var(--color-accent)', 'birth' => 'var(--color-link)', 'death' => 'var(--color-text-secondary)', 'marr' => 'var(--nord15)', 'rail' => 'var(--color-border)', 'dot' => 'var(--color-link)', ]; $font_stack = "inherit"; $avatar_size = 56; $relationships = $relationships ?? []; $avatar_srcs = $avatar_srcs ?? []; // ─── Helpers ──────────────────────────────────────────────────────────── $linked_name = static function (Individual $individual) use ($palette): string { $name = strip_tags($individual->fullName()); $url = $individual->url(); // No bottom border — link affordance comes from the color token // and the theme's :hover underline, keeping the card surface // visually quiet. $style = 'color:' . $palette['ink'] . ';text-decoration:none;'; return '' . e($name) . ''; }; $record_label = static function (Fact $fact) use ($linked_name, $relationships, $palette): string { $record = $fact->record(); $names = []; $rels = []; if ($record instanceof Individual) { $names[] = $linked_name($record); if (isset($relationships[$record->xref()])) { $rels[] = strip_tags($relationships[$record->xref()]); } } elseif ($record instanceof Family) { foreach ([$record->husband(), $record->wife()] as $spouse) { if ($spouse instanceof Individual) { $names[] = $linked_name($spouse); if (isset($relationships[$spouse->xref()])) { $rels[] = strip_tags($relationships[$spouse->xref()]); } } } } else { return e($record->xref()); } $html = implode(' & ', $names); if ($rels !== []) { $rels = array_values(array_unique(array_map(static fn ($r) => e($r), $rels))); $html .= '
' . implode(' & ', $rels) . '
'; } return $html; }; $event_date_display = static function (Fact $fact): string { $date = $fact->date(); if (!$date instanceof Date || !$date->isOK()) { return ''; } return strip_tags($date->display()); }; $date_parts = static function (Fact $fact): array { static $gregorian = null; static $months_de = [ 1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', ]; static $months_en = [ 1 => 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; $gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar(); $date = $fact->date(); if (!$date->isOK()) { return ['day_month' => '', 'year' => '']; } [$year, $month, $day] = $gregorian->jdToYmd($date->minimumJulianDay()); if (str_starts_with(I18N::languageTag(), 'de')) { $day_month = $day . '. ' . mb_strtoupper($months_de[$month] ?? ''); } else { $day_month = mb_strtoupper(($months_en[$month] ?? '') . ' ' . $day); } return ['day_month' => $day_month, 'year' => (string) $year]; }; $event_kind = static function (Fact $fact): string { $parts = explode(':', $fact->tag()); return end($parts); }; // SVG attribute values can't reference CSS custom properties directly // — only the `style` attribute does — so each glyph carries its // fill/stroke as inline style and inherits the right Nord shade from // BockenTheme automatically. $event_icon = static function (string $kind) use ($palette): string { $svg_open = ' $svg_open . 'viewBox="0 0 24 24" width="18" height="18">' . '' . '', 'DEAT' => $svg_open . 'viewBox="0 0 24 24" width="14" height="18">' . '' . '' . '', 'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">' . '' . '' . '', default => '', }; }; $avatar = static function (Individual|null $individual) use ($avatar_srcs, $avatar_size, $palette, $font_stack): string { if (!$individual instanceof Individual) { return ''; } $alt = e(strip_tags($individual->fullName())); if (isset($avatar_srcs[$individual->xref()])) { $src = $avatar_srcs[$individual->xref()]; $inner = '' . $alt . ''; } else { // Gendered silhouette placeholder — same artwork as the // full-diagram plugin's `.photo-placeholder` + `.silhouette` // shapes (see resources/css/full-diagram.css). Sex token // gives the bg pastel; the silhouette stays the same shape // regardless. // Reuse the BockenTheme `.person-card .photo-placeholder` + // `.silhouette` rules verbatim so the avatar shading matches // the full-diagram plugin exactly (including dark mode). $sex = strtolower($individual->sex()); $wrap_cls = 'person-card' . ($sex === 'm' ? ' sex-m' : ($sex === 'f' ? ' sex-f' : '')); $inner = '' . '' . '' . '' . ''; } 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 ''; }; $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'); }; // ─── Row styles ───────────────────────────────────────────────────────── $section_title_style = 'margin:0 0 4px;' . 'font-family:' . $font_stack . ';' . 'font-weight:400;font-size:22px;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'] . ';'; $card_padding_y = 14; $row_gap = 6; // Card surface follows the IndividualPage facts-table treatment in // BockenTheme: surface tone + 8 px radius + a soft drop shadow for // the visual lift. The class hook `nl-card` carries everything so // it survives Bootstrap's `.table-bordered` overrides — see the //