Continuous timeline rail across card gaps; arrowhead cap

The previous border-spacing approach made each card stand on its
own — but it also fragmented the rail. Each rail segment ended
at the bottom of its row, leaving visible breaks between cards.

Restructured each event row to wrap the avatar+content cells in
a nested card table sitting in the row's left outer TD. The
right TD carries the rail as its border-left. With the outer
section table now using border-collapse:collapse, consecutive
rows' left-borders touch and merge into one unbroken line that
runs through the card gaps.

Added a downward triangle TR after the last event of each
section as a visual cap on the rail. Pure CSS (border-style
trick), coloured to match the rail.
This commit is contained in:
2026-05-15 13:31:18 +02:00
parent abe77a9b9d
commit 6f0a55de5c
+58 -45
View File
@@ -375,35 +375,36 @@ $section_kicker_style = 'margin:0 0 18px;'
. 'font-style:italic;font-weight:300;font-size:14px;' . 'font-style:italic;font-weight:300;font-size:14px;'
. 'color:' . $palette['ink3'] . ';'; . 'color:' . $palette['ink3'] . ';';
// The card surface is built per-row out of avatar+content TDs (the
// outer table itself stays transparent). The timeline TD lives on the
// page background and carries a thick rail on its left edge.
$card_padding_y = 18; $card_padding_y = 18;
$card_corner_radius = 8; $card_corner_radius = 8;
$row_gap = 12; // vertical gap between consecutive cards
$avatar_cell_base = 'width:72px;vertical-align:middle;' // Inner card table — full rounded surface containing the avatar +
. 'padding:' . $card_padding_y . 'px 0 ' . $card_padding_y . 'px 18px;' // content cells. Each event row drops one of these into the outer
// section table's left column.
$card_outer_style = 'width:100%;'
. 'background:' . $palette['surface'] . ';' . 'background:' . $palette['surface'] . ';'
. 'border-left:1px solid ' . $palette['border'] . ';'; . 'border:1px solid ' . $palette['border'] . ';'
. 'border-radius:' . $card_corner_radius . 'px;'
. 'box-shadow:0 1px 3px rgba(0,0,0,0.04);';
$content_cell_base = 'vertical-align:middle;' $avatar_inner_td = 'width:72px;vertical-align:middle;'
. 'padding:' . $card_padding_y . 'px 18px ' . $card_padding_y . 'px 16px;' . 'padding:' . $card_padding_y . 'px 0 ' . $card_padding_y . 'px 18px;';
. 'background:' . $palette['surface'] . ';'
. 'border-right:1px solid ' . $palette['border'] . ';' $content_inner_td = 'vertical-align:middle;'
. 'padding:' . $card_padding_y . 'px 18px;'
. 'font-family:' . $font_stack . ';font-size:15px;line-height:1.4;' . 'font-family:' . $font_stack . ';font-size:15px;line-height:1.4;'
. 'font-weight:300;color:' . $palette['ink'] . ';'; . 'font-weight:300;color:' . $palette['ink'] . ';';
$timeline_cell_base = 'width:140px;vertical-align:middle;' // Outer row cells: card on the left, a small gutter, then the rail.
. 'padding:' . $card_padding_y . 'px 14px ' . $card_padding_y . 'px 24px;' $outer_card_td = 'vertical-align:middle;padding-bottom:' . $row_gap . 'px;';
$outer_gutter_td = 'width:16px;padding-bottom:' . $row_gap . 'px;';
$outer_rail_td = 'width:140px;vertical-align:middle;'
. 'padding:0 14px ' . $row_gap . 'px 24px;'
. 'border-left:4px solid ' . $palette['rail'] . ';' . 'border-left:4px solid ' . $palette['rail'] . ';'
. 'background:' . $palette['bg'] . ';'
. 'font-family:' . $font_stack . ';' . 'font-family:' . $font_stack . ';'
. 'color:' . $palette['ink3'] . ';white-space:nowrap;'; . 'color:' . $palette['ink3'] . ';white-space:nowrap;';
// A 16px wide gutter cell sits between the card and the timeline so
// the rail visibly separates from the card edge.
$gutter_cell_style = 'width:16px;background:' . $palette['bg'] . ';';
// Renders one event row in the consistent 3-column layout shared by // Renders one event row in the consistent 3-column layout shared by
// every section (avatar | content | date on the timeline rail). // every section (avatar | content | date on the timeline rail).
$summary_kicker_style = 'margin:18px 0 8px;' $summary_kicker_style = 'margin:18px 0 8px;'
@@ -431,10 +432,11 @@ $summary_item_style = 'padding:3px 0;';
// //
// $show_dot suppresses the dot on rows whose upcoming-day matches // $show_dot suppresses the dot on rows whose upcoming-day matches
// the previous row, so each calendar day has exactly one dot. // the previous row, so each calendar day has exactly one dot.
// Every row is now a self-contained mini-card (full border on all // Each event row builds a fully-rounded inner card (a nested table)
// sides, rounded corners). Vertical gaps between rows come from the // and parks it in the outer row's left TD; the rail TD on the right
// outer table's border-spacing — the rail naturally breaks between // carries the continuous timeline rail as a left-border. The outer
// rows along with the cards. // table uses border-collapse:collapse so adjacent rail TDs merge
// their left-borders into ONE unbroken line across the row gaps.
// //
// $show_dot suppresses the dot AND the day+month line on rows whose // $show_dot suppresses the dot AND the day+month line on rows whose
// upcoming-day matches the previous (only the year is then shown). // upcoming-day matches the previous (only the year is then shown).
@@ -446,22 +448,20 @@ $event_row = static function (
use ( use (
$record_avatars, $record_avatars,
$date_parts, $date_parts,
$avatar_cell_base, $card_outer_style,
$content_cell_base, $avatar_inner_td,
$timeline_cell_base, $content_inner_td,
$gutter_cell_style, $outer_card_td,
$card_corner_radius, $outer_gutter_td,
$outer_rail_td,
$palette, $palette,
): string { ): string {
$avatar_extra = 'border-top:1px solid ' . $palette['border'] . ';' $card_html =
. 'border-bottom:1px solid ' . $palette['border'] . ';' '<table role="presentation" cellpadding="0" cellspacing="0" border="0" '
. 'border-top-left-radius:' . $card_corner_radius . 'px;' . 'style="' . $card_outer_style . '"><tr>'
. 'border-bottom-left-radius:' . $card_corner_radius . 'px;'; . '<td style="' . $avatar_inner_td . '">' . $record_avatars($fact) . '</td>'
. '<td style="' . $content_inner_td . '">' . $body_html . '</td>'
$content_extra = 'border-top:1px solid ' . $palette['border'] . ';' . '</tr></table>';
. 'border-bottom:1px solid ' . $palette['border'] . ';'
. 'border-top-right-radius:' . $card_corner_radius . 'px;'
. 'border-bottom-right-radius:' . $card_corner_radius . 'px;';
$parts = $date_parts($fact); $parts = $date_parts($fact);
@@ -472,9 +472,6 @@ $event_row = static function (
. 'box-shadow:0 0 0 4px ' . $palette['bg'] . ';"></span>' . 'box-shadow:0 0 0 4px ' . $palette['bg'] . ';"></span>'
: '<span style="display:inline-block;width:14px;margin-left:-31px;margin-right:14px;vertical-align:middle;"></span>'; : '<span style="display:inline-block;width:14px;margin-left:-31px;margin-right:14px;vertical-align:middle;"></span>';
// When the dot is suppressed, also drop the day+month (it's
// the same as the row above) and show only the year — a touch
// larger than the always-visible year so it reads on its own.
if ($show_dot) { if ($show_dot) {
$date_html = $date_html =
'<span style="display:inline-block;vertical-align:middle;">' '<span style="display:inline-block;vertical-align:middle;">'
@@ -492,19 +489,32 @@ $event_row = static function (
} }
return '<tr>' return '<tr>'
. '<td style="' . $avatar_cell_base . $avatar_extra . '">' . $record_avatars($fact) . '</td>' . '<td style="' . $outer_card_td . '">' . $card_html . '</td>'
. '<td style="' . $content_cell_base . $content_extra . '">' . $body_html . '</td>' . '<td style="' . $outer_gutter_td . '"></td>'
. '<td style="' . $gutter_cell_style . '"></td>' . '<td style="' . $outer_rail_td . '">' . $dot_html . $date_html . '</td>'
. '<td style="' . $timeline_cell_base . '">' . $dot_html . $date_html . '</td>'
. '</tr>'; . '</tr>';
}; };
// Outer section table is transparent. border-spacing puts a 10px // Outer section table uses border-collapse:collapse so the rail
// vertical gap between rows so each card stands on its own. // border-lefts merge into a continuous line across all row gaps.
$card_open = '<table role="presentation" cellpadding="0" cellspacing="0" border="0" ' $card_open = '<table role="presentation" cellpadding="0" cellspacing="0" border="0" '
. 'style="width:100%;border-collapse:separate;border-spacing:0 10px;">'; . 'style="width:100%;border-collapse:collapse;">';
$card_close = '</table>'; $card_close = '</table>';
// Downward-pointing CSS triangle, coloured to match the rail, used as
// the closing arrow that visually caps each section's timeline.
$timeline_close_row = '<tr>'
. '<td></td>'
. '<td></td>'
. '<td style="height:18px;padding:0 14px 0 24px;'
. 'border-left:4px solid ' . $palette['rail'] . ';'
. 'font-size:0;line-height:0;vertical-align:bottom;">'
. '<span style="display:inline-block;width:0;height:0;'
. 'border-left:7px solid transparent;border-right:7px solid transparent;'
. 'border-top:11px solid ' . $palette['rail'] . ';'
. 'margin-left:-33px;margin-bottom:-1px;"></span>'
. '</td></tr>';
?><!doctype html> ?><!doctype html>
<html lang="<?= e(I18N::languageTag()) ?>"> <html lang="<?= e(I18N::languageTag()) ?>">
<head> <head>
@@ -574,6 +584,7 @@ $card_close = '</table>';
echo $event_row($fact, $body, $show_dot); echo $event_row($fact, $body, $show_dot);
?> ?>
<?php endforeach ?> <?php endforeach ?>
<?= $timeline_close_row ?>
<?= $card_close ?> <?= $card_close ?>
<?php endif ?> <?php endif ?>
<?php if ($summary !== []) : ?> <?php if ($summary !== []) : ?>
@@ -623,6 +634,7 @@ $card_close = '</table>';
echo $event_row($fact, $body, $show_dot); echo $event_row($fact, $body, $show_dot);
?> ?>
<?php endforeach ?> <?php endforeach ?>
<?= $timeline_close_row ?>
<?= $card_close ?> <?= $card_close ?>
<?php endif ?> <?php endif ?>
<?php if ($summary !== []) : ?> <?php if ($summary !== []) : ?>
@@ -672,6 +684,7 @@ $card_close = '</table>';
echo $event_row($fact, $body, $show_dot); echo $event_row($fact, $body, $show_dot);
?> ?>
<?php endforeach ?> <?php endforeach ?>
<?= $timeline_close_row ?>
<?= $card_close ?> <?= $card_close ?>
<?php endif ?> <?php endif ?>
<?php if ($summary !== []) : ?> <?php if ($summary !== []) : ?>