feat(roadmap): 'In motion right now' strip + subtitle copy + admin helper

New <RoadmapInMotion> component renders between the dispatch banner
and the route's section header. Pulls the most-recent shipping item
(same selection rule the .rr-current marker uses) and prints the
first sentence of its description as a 18px serif italic line
preceded by an 'IN MOTION RIGHT NOW' tracked eyebrow.

A member who only spends 5 seconds on /roadmap now still walks away
with a sentence about what just shipped — no scroll, no hover.

firstSentenceOf() is the obvious regex against the first
[.!?](?=\s|$). Bails to the 200-char slice if no sentence boundary
fits (covers 'Dr.' / 'e.g.' confusables). Returns '' on null. The
strip hides itself entirely when there's no shipping item, or when
the shipping item has no description text.

Page subtitle: 'Hover any milestone for the full story.' →
'Tap or hover any milestone for the full story.' — touch devices
don't have hover, and the kind of detail that says we're paying
attention.

Admin description-field gains a helper note: 'For shipping items:
the first sentence appears on /roadmap as the "In motion right now"
line. Make it count.' Nudges good first-sentence writing without
adding a new field to maintain.

Banner margin under the dispatch banner reduces 56 → 40px because
the in-motion strip carries its own 36px bottom margin to the route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 12:00:14 +02:00
parent ac52e97c28
commit 1ec01a2257
3 changed files with 79 additions and 6 deletions

View file

@ -0,0 +1,69 @@
---
import type { RoadmapItemWithAttribution } from '../lib/db';
interface Props {
items: RoadmapItemWithAttribution[];
}
const { items } = Astro.props;
/** First sentence of a description — naive but matches the user's needs.
* Returns '' for null/empty input. Falls back to a 200-char slice if no
* sentence-ending punctuation is found in a reasonable window. */
function firstSentenceOf(text: string | null): string {
if (!text) return '';
const trimmed = text.trim();
const match = trimmed.match(/^[^.!?]*[.!?](?=\s|$)/);
return match ? match[0] : trimmed.slice(0, 200);
}
// Most recent shipping item, in display_order (same selection rule as
// the .rr-current marker on the route).
let currentItem: RoadmapItemWithAttribution | null = null;
items.forEach((it) => { if (it.status === 'shipping') currentItem = it; });
const line = currentItem ? firstSentenceOf(currentItem.description) : '';
const visible = !!currentItem && line.length > 0;
---
{visible && (
<div class="rr-in-motion">
<span class="rr-in-motion-eyebrow">In motion right now</span>
<p class="rr-in-motion-line">{line}</p>
</div>
)}
<style>
.rr-in-motion {
display: flex;
align-items: baseline;
gap: 18px;
margin-bottom: 36px;
padding: 0 4px;
}
.rr-in-motion-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: 1.4px;
text-transform: uppercase;
color: var(--on-surface-muted);
flex-shrink: 0;
}
.rr-in-motion-line {
font-family: var(--font-serif);
font-style: italic;
font-size: 18px;
line-height: 1.4;
color: var(--on-surface);
margin: 0;
max-width: 720px;
}
@media (max-width: 767px) {
.rr-in-motion {
flex-direction: column;
gap: 6px;
margin-bottom: 28px;
}
.rr-in-motion-line { font-size: 16px; }
}
</style>

View file

@ -64,6 +64,7 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
<div class="field"> <div class="field">
<label for="description" class="label-sm field-label">Description</label> <label for="description" class="label-sm field-label">Description</label>
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea> <textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
<span class="body-sm muted">For shipping items: the first sentence appears on /roadmap as the "In motion right now" line. Make it count.</span>
</div> </div>
<div class="field"> <div class="field">

View file

@ -1,6 +1,7 @@
--- ---
import AppLayout from '../layouts/AppLayout.astro'; import AppLayout from '../layouts/AppLayout.astro';
import LatestDispatchBanner from '../components/LatestDispatchBanner.astro'; import LatestDispatchBanner from '../components/LatestDispatchBanner.astro';
import RoadmapInMotion from '../components/RoadmapInMotion.astro';
import RoadmapRoute from '../components/RoadmapRoute.astro'; import RoadmapRoute from '../components/RoadmapRoute.astro';
import { getAllRoadmapItems } from '../lib/db'; import { getAllRoadmapItems } from '../lib/db';
@ -18,13 +19,15 @@ const items = getAllRoadmapItems()
<h1 class="page-title">What we are building.</h1> <h1 class="page-title">What we are building.</h1>
<p class="page-sub"> <p class="page-sub">
A live picture of the work. What's in motion, what's queued, A live picture of the work. What's in motion, what's queued,
what we're still thinking about. Hover any milestone for the what we're still thinking about. Tap or hover any milestone
full story. for the full story.
</p> </p>
</header> </header>
<LatestDispatchBanner /> <LatestDispatchBanner />
<RoadmapInMotion items={items} />
<RoadmapRoute items={items} /> <RoadmapRoute items={items} />
</div> </div>
</AppLayout> </AppLayout>
@ -63,14 +66,14 @@ const items = getAllRoadmapItems()
max-width: 540px; max-width: 540px;
} }
/* The banner already has its own bottom-margin via the layout in /* In-motion strip lives between banner and route; banner margin
the parent. Add the spec'd 56px below it before the route header. */ tightens to 40px because the strip carries its own 36px below. */
.page :global(.banner) { margin-bottom: 56px; } .page :global(.banner) { margin-bottom: 40px; }
@media (max-width: 767px) { @media (max-width: 767px) {
.page { padding: 32px 24px 64px; } .page { padding: 32px 24px 64px; }
.page-title { font-size: 36px; } .page-title { font-size: 36px; }
.page-header { margin-bottom: 28px; } .page-header { margin-bottom: 28px; }
.page :global(.banner) { margin-bottom: 36px; } .page :global(.banner) { margin-bottom: 28px; }
} }
</style> </style>