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 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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue