feat(page): /roadmap rebuild — header + dispatch banner + route

Body content of /roadmap is fully replaced. The previous implementation
parsed content/roadmap.md with a hand-written regex into three flat
columns (IN PROGRESS / NEXT / LATER) — gone in its entirety, including
the parseSections helper and horizonColors map (page-local, not exported,
so nothing else broke).

New layout:
  1. Page header — tracked 'ROADMAP' eyebrow + 48px serif 'What we
     are building.' + a 14px sub line up to ~540px wide that explains
     the hover affordance. 36px margin below.
  2. <LatestDispatchBanner /> — renders nothing if zero dispatches.
     56px below before the route's section header.
  3. <RoadmapRoute items={items} /> — pulls all roadmap_items ordered
     by display_order asc, falls back to id asc on ties.

Page padding 40/36/80 desktop, 32/24/64 mobile. h1 drops to 36px on
phones; banner gap collapses to 36px.

content/roadmap.md is no longer read; admin manages everything via
/admin?tab=roadmap. The markdown file stays in the repo as the seed
source for fresh databases (still consumed by scripts/seed-roadmap.js)
but the live page is database-driven.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 11:44:08 +02:00
parent 1325422056
commit 16938026bc

View file

@ -1,212 +1,76 @@
--- ---
import { readFileSync } from 'fs';
import { join } from 'path';
import AppLayout from '../layouts/AppLayout.astro'; import AppLayout from '../layouts/AppLayout.astro';
import { marked } from 'marked'; import LatestDispatchBanner from '../components/LatestDispatchBanner.astro';
import RoadmapRoute from '../components/RoadmapRoute.astro';
import { getAllRoadmapItems } from '../lib/db';
const user = Astro.locals.user; const user = Astro.locals.user;
// Single-file roadmap — not a content collection // Admin orders chronologically nearest-to-furthest via display_order.
const raw = readFileSync(join(process.cwd(), 'content/roadmap.md'), 'utf-8'); const items = getAllRoadmapItems()
.sort((a, b) => a.display_order - b.display_order || a.id - b.id);
// Strip YAML frontmatter
const body = raw.replace(/^---[\s\S]*?---\n/, '');
// Parse sections by ## headings
function parseSections(md: string) {
const sectionRe = /^## (.+)$/gm;
const sections: { title: string; items: { title: string; body: string; pilotOnly: boolean }[] }[] = [];
const matches = [...md.matchAll(sectionRe)];
for (let i = 0; i < matches.length; i++) {
const m = matches[i];
const start = m.index! + m[0].length;
const end = matches[i + 1]?.index ?? md.length;
const sectionBody = md.slice(start, end).trim();
// Each item starts with **Title** — description
const itemRe = /\*\*([^*]+)\*\*\s*—\s*([\s\S]*?)(?=\n\n\*\*|\n\n##|$)/g;
const items: { title: string; body: string; pilotOnly: boolean }[] = [];
let itemMatch: RegExpExecArray | null;
while ((itemMatch = itemRe.exec(sectionBody)) !== null) {
const rawBody = itemMatch[2].trim();
const pilotOnly = rawBody.includes('`pilot-only`');
const cleanBody = rawBody.replace(/`pilot-only`/g, '').trim();
items.push({ title: itemMatch[1], body: cleanBody, pilotOnly });
}
sections.push({ title: m[1], items });
}
return sections;
}
const sections = parseSections(body);
const horizonColors: Record<string, string> = {
'In progress': 'var(--pigment-copper)',
'Next': 'var(--pigment-ochre)',
'Later': 'var(--pigment-indigo)',
};
--- ---
<AppLayout title="Roadmap" user={user}> <AppLayout title="Roadmap" user={user}>
<div class="page"> <div class="page">
<header class="page-header"> <header class="page-header">
<h1 class="display-md page-title">What we are building.</h1> <p class="page-eyebrow">Roadmap</p>
<p class="subtitle"> <h1 class="page-title">What we are building.</h1>
Three horizons. What is in progress now, what comes next, <p class="page-sub">
and what is further out. This is the live picture. A live picture of the work. What's in motion, what's queued,
what we're still thinking about. Hover any milestone for the
full story.
</p> </p>
</header> </header>
<div class="horizons"> <LatestDispatchBanner />
{sections.map((section) => (
<section class="horizon">
<div class="horizon-header">
<span
class="horizon-dot"
style={`background: ${horizonColors[section.title] ?? 'var(--on-surface-muted)'}`}
aria-hidden="true"
/>
<h2 class="headline-sm horizon-title">{section.title}</h2>
</div>
<ul class="item-list">
{section.items.map((item) => (
<li class="item">
<div class="item-header">
<h3 class="item-title body-lg">{item.title}</h3>
{item.pilotOnly && (
<span class="pilot-badge label-sm" title="Available to pilot participants only">
Pilot
</span>
)}
</div>
<p class="body-md item-body">{item.body}</p>
</li>
))}
</ul>
</section>
))}
</div>
<RoadmapRoute items={items} />
</div> </div>
</AppLayout> </AppLayout>
<style> <style>
.page { .page {
padding: var(--space-12) var(--space-20) var(--space-16); padding: 40px 36px 80px;
max-width: var(--content-max); max-width: var(--content-max);
margin: 0 auto; margin: 0 auto;
} }
/* ── Header ──────────────────────────────────────────────────────── */ /* ── Page header ─────────────────────────────────────────────── */
.page-header { .page-header { margin-bottom: 36px; max-width: 540px; }
max-width: 44rem; .page-eyebrow {
margin-bottom: var(--space-12); font-family: var(--font-sans);
} font-size: 11px;
.eyebrow {
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--on-surface-muted); color: var(--on-surface-variant);
margin-bottom: var(--space-3); margin: 0 0 12px;
} }
.page-title { .page-title {
margin-bottom: var(--space-5);
}
.subtitle {
color: var(--on-surface-variant);
max-width: var(--reading-max);
margin: 0;
}
/* ── Horizons ────────────────────────────────────────────────────── */
.horizons {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-8);
align-items: start;
}
.horizon {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.horizon-header {
display: flex;
align-items: center;
gap: var(--space-3);
padding-bottom: var(--space-4);
border-bottom: var(--ghost-border);
}
.horizon-dot {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
flex-shrink: 0;
}
.horizon-title {
font-family: var(--font-sans);
font-weight: 500;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
font-size: var(--text-label-md);
margin: 0;
color: var(--on-surface-variant);
}
/* ── Items ───────────────────────────────────────────────────────── */
.item-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.item {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.item-header {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.item-title {
font-family: var(--font-serif); font-family: var(--font-serif);
font-weight: 400; font-weight: 400;
letter-spacing: var(--tracking-snug); font-size: 48px;
margin: 0; line-height: 1.05;
letter-spacing: var(--tracking-tight);
color: var(--on-surface); color: var(--on-surface);
flex: 1; margin: 0 0 14px;
} }
.page-sub {
.pilot-badge { font-size: 14px;
flex-shrink: 0; line-height: 1.55;
padding: 0.2em var(--space-2);
background: var(--surface-container);
border-radius: var(--radius-full);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
white-space: nowrap;
}
.item-body {
margin: 0;
color: var(--on-surface-variant); color: var(--on-surface-variant);
line-height: var(--leading-relaxed); margin: 0;
max-width: 540px;
}
/* The banner already has its own bottom-margin via the layout in
the parent. Add the spec'd 56px below it before the route header. */
.page :global(.banner) { margin-bottom: 56px; }
@media (max-width: 767px) {
.page { padding: 32px 24px 64px; }
.page-title { font-size: 36px; }
.page-header { margin-bottom: 28px; }
.page :global(.banner) { margin-bottom: 36px; }
} }
</style> </style>