From 8ca5e8861891e48d381241d697f2fe287acea321 Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Tue, 12 May 2026 14:38:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(banner):=20editorial=20dispatch=20banner?= =?UTF-8?q?=20=E2=80=94=20title=20+=202-paragraph=20excerpt=20+=20author?= =?UTF-8?q?=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/seed-demo.js | 4 +- src/components/LatestDispatchBanner.astro | 267 +++++++++++++--------- src/components/admin/DispatchesTab.astro | 5 +- src/lib/format.ts | Bin 12332 -> 13219 bytes 4 files changed, 170 insertions(+), 106 deletions(-) diff --git a/scripts/seed-demo.js b/scripts/seed-demo.js index 9f84a8e..abcf664 100644 --- a/scripts/seed-demo.js +++ b/scripts/seed-demo.js @@ -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. diff --git a/src/components/LatestDispatchBanner.astro b/src/components/LatestDispatchBanner.astro index 227b6c2..949d94a 100644 --- a/src/components/LatestDispatchBanner.astro +++ b/src/components/LatestDispatchBanner.astro @@ -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 && ( -
-
- Read dispatch - All dispatches → +

{latest.title}

+ +
+
+

{p1}

+ {p2 && ( +

{p2}{truncated ? '…' : ''}

+ )} +
+ +
+
+
+

{authorFirstName}

+

{authorRole}

+
+
{authorInitial}
+
+ + Read full dispatch → + +
- + +
)} diff --git a/src/components/admin/DispatchesTab.astro b/src/components/admin/DispatchesTab.astro index a0628bd..bd620a7 100644 --- a/src/components/admin/DispatchesTab.astro +++ b/src/components/admin/DispatchesTab.astro @@ -66,8 +66,9 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
- - + + + 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.
diff --git a/src/lib/format.ts b/src/lib/format.ts index 0fce83e1efbb11a88baf899d123ae8c05ae0107e..2b588814aa33ff84b9dbae63d34ffd022b8c425e 100644 GIT binary patch delta 883 zcmZ8fyN;Vc6zx*D*%WCb9T$tK6JTi(cpyS%r=mNJIRLmmb+jAd5Yi2OtH2WeA8 z${*!RG7liiA|V0y%(>^m&rd(nUoWQZ2vgiuJXc@>)I=mJg<8p6q{xLTp)MsVCRr+3 zmDK^>OG-%AU?`Y8#pOj9inu%GkI?~SfWTG%Jj0xJS}e)0X~vCOHm`VCub06&JVa`Bp^zpWf`Nk@~O#dRR^PQ z%d2Ot{j_s#JVlLK^`0IqgTF%POwRrv9PoJrMG1}D(LZn5yKW(-o5Y^|`M=6OWkVg?KKJB71>*v%c!HEw#q!EE?qd}teYLseZ< z+K={bTCxQVswHX3b1w|WuMVxhudnB+;o+oEtaOd(uBmnnMO!J^JzNb2aLIB`@Vr7F zmlTkOc%5_K$9S`Gg>@cHGlfe8w;Q9Nci*>S!fe%6X}TVGjUrqg0!|`Tnb~QszSutH jAuQX*R`qE-j_c9bhGpV%w_|;Zu%qpMn%~c-S5N)|