feat(banner): editorial dispatch banner — title + 2-paragraph excerpt + author block

LatestDispatchBanner rebuilt from the three-column horizontal row into
an editorial card that reads as something to engage with, not a
database row.

New layout:
  - Meta row (1fr / auto): tracked 'LATEST DISPATCH · {relative}' +
    kind pill on the left; 'All dispatches →' tracked link on the right.
  - 30px serif headline beneath, max-width 720px so a real title can
    breathe across two lines if needed.
  - Body grid (1fr / auto): the prose on the left, an author block on
    the right. Prose splits into two paragraphs — p1 in primary text,
    p2 in muted (--on-surface-variant) ending in an ellipsis if the
    source extends beyond what was rendered. Author block has a small
    'Jonathan / team' stack alongside a 36px serif italic initial in
    an ink-coloured circle, plus a terracotta 'Read full dispatch →'
    CTA with a 1px terracotta bottom border.

Kind pills get per-kind tinted backgrounds (decision → indigo, update
→ copper, behind_the_scenes → walnut, note → terracotta) matching the
established kind-pigment mapping.

splitExcerpt helper added to src/lib/format.ts:
  - Prefers a markdown \\n\\n paragraph break (admin-controllable);
  - Falls back to the first sentence boundary past character 120;
  - Returns [first, null] when no good split exists — banner renders
    just p1 in that case and skips p2 entirely.

Admin: the excerpt field on /admin?tab=dispatches grows from a
single-line input to a 4-row textarea with the spec'd helper text
nudging admins to write 2-4 sentences with a blank-line break.

Seed: the decision dispatch's excerpt rewritten as the spec's
two-paragraph block so the new layout has real content to render.
Body stays unchanged.

Mobile: the body collapses to single-column; the author block jumps
above the prose with order: -1, so the byline reads first on small
screens and the text flows freely below it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 14:38:51 +02:00
parent d85583b4a3
commit 8ca5e88618
4 changed files with 170 additions and 106 deletions

View file

@ -247,7 +247,9 @@ for (const c of contribs) {
const dispatchSeed = [
{ kind: 'decision', ageDays: 2,
title: 'We are deprioritising public-cloud parity for Q3',
excerpt: 'After three weeks of pilot feedback, the team is locking the platform to on-prem and Hetzner sovereign cloud for the next quarter.',
excerpt: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3.
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction. So for Q3 the platform supports two deployment targets only on-prem inside the customer's own VPC, and our Hetzner sovereign cloud in Helsinki.`,
body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3.
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction.

View file

@ -1,147 +1,208 @@
---
import Avatar from './Avatar.astro';
import { getLatestPublishedDispatches } from '../lib/db';
import {
dispatchSlug, dispatchExcerptParas, relativeTime,
dispatchKindLabel, dispatchKindPigment,
dispatchSlug, dispatchKindLabel, splitExcerpt, relativeTime,
} from '../lib/format';
const [latest] = getLatestPublishedDispatches(1);
const excerpt = latest ? dispatchExcerptParas(latest).lead : '';
const [p1, p2] = latest
? splitExcerpt(latest.excerpt || latest.body)
: ['', null];
// Mark p2 with an ellipsis when the source extends beyond what we used —
// i.e. the body is longer than excerpt + paragraph break.
const sourceLen = latest ? (latest.excerpt || latest.body).trim().length : 0;
const usedLen = p1.length + (p2 ? p2.length + 2 : 0);
const truncated = sourceLen > usedLen + 4;
const authorFirstName = latest ? latest.author_name.split(' ')[0] : '';
const authorInitial = authorFirstName ? authorFirstName[0].toUpperCase() : '';
const authorRole = latest?.author_title ?? 'team';
---
{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="rr-dispatch">
<div class="b-mid">
<div class="b-title-row">
<span class="b-title">{latest.title}</span>
<span class="b-kind-pill" style={`--pill: ${dispatchKindPigment(latest.kind)}`}>
<div class="rr-dispatch-meta">
<div class="rr-dispatch-meta-left">
<span class="rr-dispatch-eyebrow">
Latest dispatch · {relativeTime(latest.published_at ?? latest.created_at)}
</span>
<span class:list={['rr-dispatch-kind', `rr-dispatch-kind-${latest.kind}`]}>
{dispatchKindLabel(latest.kind)}
</span>
</div>
<span class="b-excerpt">{excerpt}</span>
<a class="rr-dispatch-all" href="/dispatches">All dispatches →</a>
</div>
<div class="b-right">
<span class="b-read">Read dispatch</span>
<span class="b-all">All dispatches →</span>
<h2 class="rr-dispatch-title">{latest.title}</h2>
<div class="rr-dispatch-body">
<div class="rr-dispatch-text">
<p class="rr-dispatch-p1">{p1}</p>
{p2 && (
<p class="rr-dispatch-p2">{p2}{truncated ? '…' : ''}</p>
)}
</div>
<div class="rr-dispatch-author">
<div class="rr-dispatch-author-row">
<div class="rr-dispatch-author-text">
<p class="rr-dispatch-author-name">{authorFirstName}</p>
<p class="rr-dispatch-author-role">{authorRole}</p>
</div>
<div class="rr-dispatch-author-avatar">{authorInitial}</div>
</div>
<a class="rr-dispatch-cta" href={`/dispatches/${dispatchSlug(latest)}`}>
Read full dispatch →
</a>
</div>
</div>
</div>
)}
<style>
.banner {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 20px;
align-items: center;
padding: 28px 26px 26px;
.rr-dispatch {
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));
border-radius: 14px;
padding: 36px 40px;
}
.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: 6px; min-width: 0; }
.b-title-row {
display: flex;
.rr-dispatch-meta {
display: grid;
grid-template-columns: 1fr auto;
gap: 24px;
align-items: baseline;
margin-bottom: 22px;
}
.rr-dispatch-meta-left {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
gap: 8px;
}
.b-title {
font-family: var(--font-serif);
font-size: 19px;
line-height: 1.3;
color: var(--on-surface);
}
.b-kind-pill {
background: color-mix(in oklab, var(--pill) 10%, transparent);
color: var(--pill);
padding: 2px 8px;
border-radius: 3px;
.rr-dispatch-eyebrow {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: var(--tracking-wider);
font-weight: 600;
font-size: 10px;
letter-spacing: 1.6px;
text-transform: uppercase;
white-space: nowrap;
}
.b-excerpt {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 13px;
line-height: 1.55;
color: var(--on-surface-variant);
}
.rr-dispatch-kind {
font-family: var(--font-sans);
font-size: 9px;
letter-spacing: 0.8px;
padding: 2px 8px;
border-radius: 3px;
text-transform: uppercase;
font-weight: 500;
}
.rr-dispatch-kind-decision { background: rgba(44,58,82,0.10); color: #2c3a52; }
.rr-dispatch-kind-update { background: rgba(109,140,124,0.12);color: #6d8c7c; }
.rr-dispatch-kind-behind_the_scenes { background: rgba(120,95,83,0.12); color: #785f53; }
.rr-dispatch-kind-note { background: rgba(185,107,88,0.10); color: #b96b58; }
.b-right {
.rr-dispatch-all {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: 1px;
color: var(--on-surface-variant);
text-transform: uppercase;
text-decoration: none;
border-bottom: none;
}
.rr-dispatch-all:hover { color: var(--on-surface); border-bottom: none; }
.rr-dispatch-title {
font-family: var(--font-serif);
font-size: 30px;
line-height: 1.2;
color: var(--on-surface);
margin: 0 0 22px;
max-width: 720px;
}
.rr-dispatch-body {
display: grid;
grid-template-columns: 1fr auto;
gap: 40px;
align-items: end;
}
.rr-dispatch-text { max-width: 720px; }
.rr-dispatch-p1 {
font-size: 14px;
line-height: 1.7;
color: var(--on-surface);
margin: 0 0 10px;
}
.rr-dispatch-p2 {
font-size: 14px;
line-height: 1.7;
color: var(--on-surface-variant);
margin: 0;
}
.rr-dispatch-author {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
white-space: nowrap;
gap: 14px;
padding-bottom: 4px;
}
.b-read {
.rr-dispatch-author-row {
display: flex;
align-items: center;
gap: 10px;
}
.rr-dispatch-author-text { text-align: right; }
.rr-dispatch-author-name {
font-size: 13px;
margin: 0;
color: var(--on-surface);
}
.rr-dispatch-author-role {
font-size: 11px;
margin: 1px 0 0;
color: var(--on-surface-variant);
}
.rr-dispatch-author-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--ink);
color: #fffcf7;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-serif);
font-style: italic;
font-size: 14px;
flex-shrink: 0;
}
.rr-dispatch-cta {
font-family: var(--font-sans);
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
letter-spacing: 1.2px;
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);
text-decoration: none;
padding-bottom: 2px;
border-bottom: 1px solid var(--pigment-terracotta);
white-space: nowrap;
}
.rr-dispatch-cta:hover { opacity: 0.78; }
@media (max-width: 767px) {
.banner {
grid-template-columns: 1fr;
gap: 12px;
padding: 18px 20px;
.rr-dispatch { padding: 28px 24px; }
.rr-dispatch-title { font-size: 24px; }
.rr-dispatch-body { grid-template-columns: 1fr; gap: 22px; }
.rr-dispatch-author {
flex-direction: row;
justify-content: space-between;
align-items: center;
order: -1;
}
.b-excerpt { white-space: normal; }
.b-right { align-items: flex-start; }
}
</style>

View file

@ -66,8 +66,9 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
</div>
<div class="field">
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional — falls back to first ~200 chars of body)</label>
<input type="text" id="d-excerpt" name="excerpt" class="input body-md" value={editing?.excerpt ?? ''} />
<label for="d-excerpt" class="label-sm field-label">Excerpt (optional)</label>
<textarea id="d-excerpt" name="excerpt" class="input body-md" rows="4">{editing?.excerpt ?? ''}</textarea>
<span class="body-sm muted">Write 24 sentences. The first sentence becomes the lead paragraph on the /roadmap dispatch banner; the rest follows in muted text. Use a blank line to control the paragraph break. Falls back to the first ~200 chars of the body if empty.</span>
</div>
<div class="field">

Binary file not shown.