diff --git a/resources/views/email.phtml b/resources/views/email.phtml
index a8bcb17..cfaa2cb 100644
--- a/resources/views/email.phtml
+++ b/resources/views/email.phtml
@@ -24,27 +24,38 @@ use Illuminate\Support\Collection;
* @var string $account_url
*/
-// ─── Aesthetic constants ────────────────────────────────────────────────
+// ─── 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 = [
- 'paper' => '#f8f1e3', // warm ivory background
- 'ink' => '#3a2820', // deep oxblood-brown body text
- 'gold' => '#a17536', // aged-gold accents
- 'rule' => '#d4be91', // hairline rule
- 'mute' => '#7a6a5e', // muted secondary text
- 'shadow' => '#5a4a3e', // for darker symbols (death)
+ '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)
];
-$serif_display = "'EB Garamond', Georgia, 'Times New Roman', serif";
-$serif_body = "Georgia, 'Iowan Old Style', 'Palatino Linotype', serif";
+$font_stack = "'Open Sans', Helvetica, Arial, 'Noto Sans', sans-serif";
$avatar_size = 56;
// ─── Helpers ────────────────────────────────────────────────────────────
-$linked_name = static function (Individual $individual): string {
+$linked_name = static function (Individual $individual) use ($palette): string {
$name = strip_tags($individual->fullName());
$url = $individual->url();
- $style = 'color:inherit;text-decoration:none;border-bottom:1px solid #c8a96a;';
+ $style = 'color:' . $palette['ink'] . ';text-decoration:none;'
+ . 'border-bottom:1px solid ' . $palette['border'] . ';'
+ . 'padding-bottom:1px;';
return '' . e($name) . ' ';
};
@@ -82,38 +93,35 @@ $event_date_display = static function (Fact $fact): string {
};
$event_kind = static function (Fact $fact): string {
- $tag = $fact->tag();
- $parts = explode(':', $tag);
+ $parts = explode(':', $fact->tag());
return end($parts);
};
/**
- * Compact inline SVG glyph for each event type. Rendered in modern
- * webmail (Gmail web, Apple Mail, iOS, Outlook 365); Outlook desktop
- * strips SVG silently — the textual event label still carries the
- * meaning, so the email reads correctly without it.
+ * 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/dagger — the typographic convention for "died" in
- // obituaries since the 18th century. Restrained, narrow.
+ // 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 heraldic mark of marriage.
+ // Two interlocking rings — universal mark of marriage.
'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">'
- . ' '
- . ' '
+ . ' '
+ . ' '
. '',
default => '',
@@ -121,25 +129,34 @@ $event_icon = static function (string $kind) use ($palette): string {
};
/**
- * A single circular avatar — either the embedded image, or a coloured
- * disc with the person's initials. Wraps in an linking to the
- * individual's webtrees page when available.
+ * 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['rule'] . ',0 2px 6px rgba(58,40,32,0.18);';
+ $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 = ' xref()];
+ $inner = ' ';
} 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'] ?? '');
@@ -147,9 +164,9 @@ $avatar = static function (Individual|null $individual) use ($avatar_cids, $avat
$inner = '' . $initials . ' ';
+ . 'border-radius:50%;background:hsl(' . (int) $hue . ',32%,60%);color:#fff;'
+ . "font:600 19px/{$avatar_size}px " . $font_stack . ';text-align:center;'
+ . 'letter-spacing:0.3px;' . $shadow . '">' . $initials . '';
}
return ' ' . $inner . ' ';
@@ -171,21 +188,16 @@ $record_avatars = static function (Fact $fact) use ($avatar): string {
}
}
- // Two circles overlapping by a few px reads as "couple" without
- // needing additional glue characters.
return '
'
. '' . ($parts[0] ?? '') . ' '
- . ' '
- . (isset($parts[1])
- ? '' . $parts[1] . ' '
- : '')
+ . (isset($parts[1]) ? '' . $parts[1] . ' ' : '')
. '
';
}
return '';
};
-// Locale-aware ordinal: English uses st/nd/rd/th; German just appends ".".
+// 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 . '.';
@@ -240,48 +252,6 @@ $anniversary_label = static function (int $age) use ($ordinal): string {
: I18N::translate('Wedding anniversary');
};
-// Styles reused across the event lists.
-$row_padding = '20px 0';
-$divider_style = 'border-bottom:1px solid ' . $palette['rule'] . ';';
-
-$section_title_style = 'margin:48px 0 8px;'
- . 'font-family:' . $serif_display . ';'
- . 'font-weight:500;font-size:28px;line-height:1.1;'
- . 'color:' . $palette['ink'] . ';'
- . 'letter-spacing:0.005em;';
-
-$section_kicker_style = 'margin:0 0 22px;'
- . 'font-family:' . $serif_display . ';'
- . 'font-style:italic;font-size:15px;'
- . 'color:' . $palette['mute'] . ';'
- . 'letter-spacing:0.02em;';
-
-$timeline_cell_style = 'width:170px;vertical-align:top;'
- . 'padding:24px 0 24px 28px;'
- . 'border-left:1px solid ' . $palette['rule'] . ';'
- . 'font-family:' . $serif_body . ';'
- . 'color:' . $palette['mute'] . ';'
- . 'font-size:12px;letter-spacing:0.12em;text-transform:uppercase;'
- . 'white-space:nowrap;';
-
-$timeline_dot_style = 'display:inline-block;'
- . 'width:11px;height:11px;background:' . $palette['gold'] . ';'
- . 'border-radius:50%;'
- . 'margin-left:-34px;margin-right:14px;vertical-align:middle;';
-
-$content_cell_style = 'vertical-align:middle;'
- . 'padding:24px 24px 24px 18px;'
- . 'font-family:' . $serif_body . ';font-size:16px;line-height:1.4;'
- . 'color:' . $palette['ink'] . ';';
-
-$avatar_cell_style = 'width:64px;vertical-align:middle;padding:18px 0;';
-
-// Renders one event row in the consistent 3-column layout shared by all
-// sections (avatar | content | timeline + date).
-/**
- * Locale-aware long-form date for the masthead. German edition reads
- * "15. Mai 2026" rather than the English "May 15, 2026".
- */
$masthead_date = static function (int $timestamp): string {
if (str_starts_with(I18N::languageTag(), 'de')) {
$months = [
@@ -297,150 +267,184 @@ $masthead_date = static function (int $timestamp): string {
return date('F j, Y', $timestamp);
};
-$event_row = static function (Fact $fact, string $body_html)
+// ─── 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,
- $timeline_dot_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 = '';
+
?>
-= e(I18N::translate('Family newsletter — %s', date('F j, Y', $generated_at))) ?>
+= e(I18N::translate('Family newsletter — %s', $masthead_date($generated_at))) ?>
-
+
+ style="width:100%;background:= $palette['bg'] ?>;">
-
+
+ style="width:100%;max-width:720px;background:= $palette['bg'] ?>;font-family:= $font_stack ?>;">
-
-
+
+
= e(I18N::translate('Family Chronicle')) ?>
-
+
= e($tree->title()) ?>
-
+
= e($masthead_date($generated_at)) ?>
+ ·
+ = e(I18N::translate('Events in the next %d days.', $lookahead_days)) ?>
-
-
❦
-
-
-
-
-
-
- = e(I18N::translate('Events in the next %d days.', $lookahead_days)) ?>
isEmpty()) : ?>
-
+
= e(I18N::translate('Upcoming birthdays')) ?>
= e(I18N::translate('Living kin who will celebrate this fortnight.')) ?>
-
+ = $card_open ?>
'
- . '' . $record_label($fact) . ' '
- . ' — '
- . e($birthday_label($age)) . ' '
+ $body = '' . $event_icon('BIRT') . ' '
+ . ''
+ . '' . $record_label($fact) . ' '
+ . ' — ' . e($birthday_label($age)) . ' '
. ' ';
- echo $event_row($fact, $body);
+ echo $event_row($fact, $body, $palette['birth']);
?>
-
+ = $card_close ?>
isEmpty()) : ?>
-
+
= e(I18N::translate('Upcoming marriage anniversaries')) ?>
= e(I18N::translate('Marriages still intact.')) ?>
-
+ = $card_open ?>
'
- . '' . $record_label($fact) . ' '
- . ' — '
- . e($anniversary_label($age)) . ' '
+ $body = '' . $event_icon('MARR') . ' '
+ . ''
+ . '' . $record_label($fact) . ' '
+ . ' — ' . e($anniversary_label($age)) . ' '
. ' ';
- echo $event_row($fact, $body);
+ echo $event_row($fact, $body, $palette['marr']);
?>
-
+ = $card_close ?>
isEmpty()) : ?>
-
+
= e(I18N::translate('On this month in history')) ?>
= e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
-
+ = $card_open ?>
'
- . '' . $record_label($fact) . ' '
- . ' — '
- . e($fact->label()) . ' '
+ $body = '' . $event_icon($kind) . ' '
+ . ''
+ . '' . $record_label($fact) . ' '
+ . ' — ' . e($fact->label()) . ' '
. ' ';
- echo $event_row($fact, $body);
+ echo $event_row($fact, $body, $event_color($kind));
?>
-
+ = $card_close ?>
-
- ❦
-
+
+
+
= e(I18N::translate('You are receiving this email because you subscribed to the %s newsletter.', $tree->title())) ?>
= I18N::translate(
'To change or cancel your subscription, edit the “Newsletter subscription” section on your %s page.',
- '' . e(I18N::translate('My account')) . ' ',
+ '' . e(I18N::translate('My account')) . ' ',
) ?>