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:
parent
1325422056
commit
16938026bc
1 changed files with 42 additions and 178 deletions
|
|
@ -1,212 +1,76 @@
|
|||
---
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
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;
|
||||
|
||||
// Single-file roadmap — not a content collection
|
||||
const raw = readFileSync(join(process.cwd(), 'content/roadmap.md'), 'utf-8');
|
||||
|
||||
// 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)',
|
||||
};
|
||||
// Admin orders chronologically nearest-to-furthest via display_order.
|
||||
const items = getAllRoadmapItems()
|
||||
.sort((a, b) => a.display_order - b.display_order || a.id - b.id);
|
||||
---
|
||||
<AppLayout title="Roadmap" user={user}>
|
||||
<div class="page">
|
||||
|
||||
<header class="page-header">
|
||||
<h1 class="display-md page-title">What we are building.</h1>
|
||||
<p class="subtitle">
|
||||
Three horizons. What is in progress now, what comes next,
|
||||
and what is further out. This is the live picture.
|
||||
<p class="page-eyebrow">Roadmap</p>
|
||||
<h1 class="page-title">What we are building.</h1>
|
||||
<p class="page-sub">
|
||||
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>
|
||||
</header>
|
||||
|
||||
<div class="horizons">
|
||||
{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>
|
||||
<LatestDispatchBanner />
|
||||
|
||||
<RoadmapRoute items={items} />
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: var(--space-12) var(--space-20) var(--space-16);
|
||||
padding: 40px 36px 80px;
|
||||
max-width: var(--content-max);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
max-width: 44rem;
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
/* ── Page header ─────────────────────────────────────────────── */
|
||||
.page-header { margin-bottom: 36px; max-width: 540px; }
|
||||
.page-eyebrow {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase;
|
||||
color: var(--on-surface-muted);
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--on-surface-variant);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.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-weight: 400;
|
||||
letter-spacing: var(--tracking-snug);
|
||||
margin: 0;
|
||||
font-size: 48px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--on-surface);
|
||||
flex: 1;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.pilot-badge {
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
.page-sub {
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue