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:
parent
d85583b4a3
commit
8ca5e88618
4 changed files with 170 additions and 106 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</a>
|
||||
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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 2–4 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.
Loading…
Add table
Reference in a new issue