feat(component): LatestDispatchBanner — slim single-row card

White card with 0.5px border, 12px radius, 22px/26px padding. Three-
column grid (auto / 1fr / auto):
  - Left: 30px deterministic-pigment avatar + tracked 'LATEST DISPATCH ·
    {relative}' eyebrow + 11px '{first_name} · {title || team}' line.
  - Middle: 19px serif title + 12px single-line ellipsis excerpt.
  - Right: 11px terracotta 'READ DISPATCH' link (1px terracotta bottom
    border) + 10px muted 'ALL DISPATCHES →' below it.

The whole card is one <a> targeting the dispatch slug — hover lifts it
1px and tints the surface; entire surface is the click target.

Hidden entirely when getLatestPublishedDispatches(1) returns []. The
banner doesn't render an empty-state placeholder — the /roadmap page
just starts with the route in that case.

At <768px the grid collapses to a single column and the excerpt wraps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 11:41:00 +02:00
parent 66b460c35f
commit 884cca85f1

View file

@ -0,0 +1,121 @@
---
import Avatar from './Avatar.astro';
import { getLatestPublishedDispatches } from '../lib/db';
import {
dispatchSlug, dispatchExcerptParas, relativeTime,
} from '../lib/format';
const [latest] = getLatestPublishedDispatches(1);
const excerpt = latest ? dispatchExcerptParas(latest).lead : '';
---
{latest && (
<a href={`/dispatches/${dispatchSlug(latest)}`} class="banner">
<div class="b-left">
<Avatar id={latest.author_id} name={latest.author_name} size={30} />
<div class="b-byline">
<span class="b-eyebrow">Latest dispatch · {relativeTime(latest.published_at ?? latest.created_at)}</span>
<span class="b-author">
{latest.author_name.split(' ')[0]} · {latest.author_title ?? 'team'}
</span>
</div>
</div>
<div class="b-mid">
<span class="b-title">{latest.title}</span>
<span class="b-excerpt">{excerpt}</span>
</div>
<div class="b-right">
<span class="b-read">Read dispatch</span>
<span class="b-all">All dispatches →</span>
</div>
</a>
)}
<style>
.banner {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 20px;
align-items: center;
padding: 22px 26px;
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: 12px;
color: inherit;
text-decoration: none;
border-bottom: none;
transition: background var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.banner:hover {
border-bottom: none;
transform: translateY(-1px);
background: color-mix(in oklab, var(--surface-card) 92%, var(--background));
}
.b-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
.b-byline { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.b-eyebrow {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
white-space: nowrap;
}
.b-author {
font-size: 11px;
color: var(--on-surface);
white-space: nowrap;
}
.b-mid { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.b-title {
font-family: var(--font-serif);
font-size: 19px;
line-height: 1.3;
color: var(--on-surface);
}
.b-excerpt {
font-size: 12px;
color: var(--on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.b-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
white-space: nowrap;
}
.b-read {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
border-bottom: 1px solid var(--pigment-terracotta);
padding-bottom: 1px;
}
.b-all {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
}
@media (max-width: 767px) {
.banner {
grid-template-columns: 1fr;
gap: 12px;
padding: 18px 20px;
}
.b-excerpt { white-space: normal; }
.b-right { align-items: flex-start; }
}
</style>