diff --git a/src/components/pages/RoadmapPage.astro b/src/components/pages/RoadmapPage.astro
index 403fd7a..361d0c8 100644
--- a/src/components/pages/RoadmapPage.astro
+++ b/src/components/pages/RoadmapPage.astro
@@ -65,6 +65,81 @@ const journey = [...closed].sort(
/** Full counts travel with each milestone; items are capped in the script. */
const overflow = (milestone: RoadmapMilestone) =>
Math.max(0, milestone.openIssues + milestone.closedIssues - milestone.items.length);
+
+/** #132 — the lede: the first sentence of the description, not the whole
+ * block — the rest of the story stays where GitHub keeps it, one link
+ * away. Two guards, both from the real data: a one-word sentence
+ * ("BLOCKING.") is not a lede, so sentences accumulate up to a floor; and
+ * a change-log description that is one giant sentence (Phase 13's is
+ * ~800 characters of it) is cut at a ceiling, on a word boundary, with an
+ * ellipsis marking the cut. */
+const LEDE_FLOOR = 24;
+const LEDE_CEILING = 220;
+const ledeOf = (description: string): { lede: string; continues: boolean } => {
+ const text = description.trim();
+ const sentences = (text.match(/[^.!?]+[.!?]+(?=\s|$)/g) ?? []).map((sentence) =>
+ sentence.trim(),
+ );
+ let taken = "";
+ for (const sentence of sentences) {
+ taken = taken ? `${taken} ${sentence}` : sentence;
+ if (taken.length >= LEDE_FLOOR) break;
+ }
+ if (!taken) taken = text;
+ if (taken.length <= LEDE_CEILING) {
+ return { lede: taken, continues: taken.length < text.length };
+ }
+ const cut = taken.slice(0, LEDE_CEILING);
+ const edge = cut.lastIndexOf(" ");
+ return {
+ lede: `${(edge > LEDE_FLOOR ? cut.slice(0, edge) : cut).trimEnd()}…`,
+ continues: true,
+ };
+};
+
+/** #132 — Jesse's shape: the timeline breaks into chapters at each new
+ * month in the closedAt dates ("July 2026" → "August 2026"). Labels derive
+ * from the data — c.formatEra, never hand-written — and a closed milestone
+ * GitHub left undated falls back to the honest no-date chapter instead of
+ * an invented one. */
+interface JourneyEntry {
+ milestone: RoadmapMilestone;
+ /** Position in the whole journey, oldest = 0 — drives the alternating
+ * sides, so the zigzag carries across era boundaries instead of
+ * restarting with each chapter. */
+ index: number;
+ /** The first sentence of the description — the entry's prose. */
+ lede: string;
+ /** The description continues past the lede (on the milestone's page). */
+ continues: boolean;
+}
+interface JourneyEra {
+ /** closedAt's YYYY-MM; "undated" when GitHub recorded no date. */
+ key: string;
+ label: string;
+ entries: JourneyEntry[];
+}
+
+const eras: JourneyEra[] = [];
+let journeyIndex = 0;
+for (const milestone of journey) {
+ const entry: JourneyEntry = {
+ milestone,
+ index: journeyIndex++,
+ ...ledeOf(milestone.description ?? ""),
+ };
+ const key = milestone.closedAt?.slice(0, 7) ?? "undated";
+ const current = eras.at(-1);
+ if (current?.key === key) {
+ current.entries.push(entry);
+ } else {
+ eras.push({
+ key,
+ label: milestone.closedAt ? c.formatEra(milestone.closedAt) : c.noDate,
+ entries: [entry],
+ });
+ }
+}
---
}
{
- data.milestones.length === 0 ? (
+ data.milestones.length === 0 && (
{c.empty}
@@ -98,8 +173,64 @@ const overflow = (milestone: RoadmapMilestone) =>
- ) : (
- <>
+ )
+ }
+
+
+ {
+ data.milestones.length > 0 && (
+ <>
+ {/* #132 — story first: the journey leads the page and, unlike the
+ reading column below, takes the container's full width — the
+ alternating map needs the breadth; its prose stays ≤ 58ch. */}
+ {journey.length > 0 && (
+
+ {c.journeyTitle}
+ {c.journeyIntro}
+
+ {eras.map((era) => (
+
+
{era.label}
+
+ {era.entries.map(({ milestone, index, lede, continues }) => (
+ -
+
+ {lede &&
{lede}
}
+
+ {milestone.closedAt && (
+ {c.closedOn(formatDate(milestone.closedAt))}
+ )}
+ ·
+
+ {c.progress(
+ milestone.closedIssues,
+ milestone.closedIssues + milestone.openIssues,
+ )}
+
+
+ {continues && (
+
+ {c.viewMilestone} →
+
+ )}
+
+ ))}
+
+
+ ))}
+
+
+ )}
+
+
{open.length > 0 && (
{c.openTitle}
@@ -149,30 +280,6 @@ const overflow = (milestone: RoadmapMilestone) =>
)}
- {journey.length > 0 && (
-
- {c.journeyTitle}
- {c.journeyIntro}
-
- {journey.map((milestone) => (
- -
-
- {milestone.description &&
{milestone.description}
}
-
- {milestone.closedAt && (
- {c.closedOn(formatDate(milestone.closedAt))}
- )}
- ·
- {c.progress(milestone.closedIssues, milestone.closedIssues + milestone.openIssues)}
-
-
- ))}
-
-
- )}
-
{closed.length > 0 && (
{c.closedTitle}
@@ -217,10 +324,10 @@ const overflow = (milestone: RoadmapMilestone) =>
)}
- >
- )
- }
-
+
+ >
+ )
+ }
@@ -345,7 +452,12 @@ const overflow = (milestone: RoadmapMilestone) =>
font-size: 0.88rem;
}
- /* #129 — the journey: a vertical timeline, oldest at the top. */
+ /* #132 — the journey as Jesse's vertical historical map. From lg up: a
+ center spine, entries alternating sides, era markers centered on the
+ spine breaking it into chapters. Below lg the spine hugs the
+ inline-start edge and every entry sits on one side — alternating is
+ unreadable at 390px — with the era markers inline. Logical properties
+ only, so the Arabic edition mirrors: spine, sides and dots included. */
.journey {
margin-block: 2.5rem;
}
@@ -357,36 +469,75 @@ const overflow = (milestone: RoadmapMilestone) =>
}
.journey-timeline {
- list-style: none;
- margin: 1.2rem 0 0;
- padding: 0;
+ /* The rail's distance from the timeline's inline-start edge (mobile),
+ and the gap between the centered spine and a card (desktop). */
+ --spine: 0.7rem;
+ --gutter: 1.9rem;
position: relative;
- display: grid;
- gap: 0.9rem;
+ margin-block: 2.25rem 0.5rem;
}
+ /* One spine behind everything — era markers and dot beads sit on top of
+ it, so the line reads as passing behind each chapter and each entry. */
.journey-timeline::before {
content: "";
position: absolute;
- inset-inline-start: 0.55rem;
- top: 0.6rem;
- bottom: 0.6rem;
+ inset-inline-start: calc(var(--spine) - 1px);
+ top: 1rem;
+ bottom: 1rem;
width: 2px;
- background: var(--border);
+ border-radius: 2px;
+ background: color-mix(in oklab, var(--accent) 30%, var(--border));
+ }
+
+ /* Chapter heading: a pill sitting on the spine, breaking it. The label
+ derives from closedAt (see c.formatEra) — the marker is data, not
+ decoration, so it stays a real heading. */
+ .era-marker {
+ position: relative; /* above the spine */
+ width: max-content;
+ margin: 0 0 1.3rem;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+ color: var(--accent);
+ background: var(--surface);
+ border: 1px solid color-mix(in oklab, var(--accent) 35%, var(--border));
+ border-radius: 999px;
+ padding: 0.32rem 0.95rem;
+ }
+
+ /* Letter-spacing pulls Arabic script apart — the joined letters must
+ stay joined, so the RTL edition tracks normally. */
+ :global([dir="rtl"]) .era-marker {
+ letter-spacing: normal;
}
- .journey-timeline .milestone {
+ .era + .era {
+ margin-block-start: 2.1rem;
+ }
+
+ .era-entries {
+ list-style: none;
margin: 0;
- padding-inline-start: 2rem;
+ padding: 0;
+ display: grid;
+ gap: 1rem;
+ }
+
+ .entry {
position: relative;
- border: 1px solid var(--border);
+ margin-inline-start: 2.2rem; /* clear of the rail */
}
- .journey-timeline .milestone::before {
+ /* The dot: an accent bead on the spine, ringed with the page surface so
+ the line visually passes behind it. */
+ .entry::before {
content: "";
position: absolute;
- inset-inline-start: -0.35rem;
- top: 1.2rem;
+ inset-inline-start: calc(-2.2rem + var(--spine) - 0.35rem);
+ top: 1.15rem;
width: 0.7rem;
height: 0.7rem;
border-radius: 999px;
@@ -394,10 +545,92 @@ const overflow = (milestone: RoadmapMilestone) =>
border: 2px solid var(--surface);
}
- .journey-timeline .milestone-meta {
+ .entry-title {
+ margin: 0 0 0.5rem;
+ font-size: 1.08rem;
+ line-height: 1.4;
+ }
+
+ .entry-title a {
+ color: inherit;
+ text-decoration: none;
+ }
+
+ .entry-title a:hover {
+ text-decoration: underline;
+ text-decoration-color: var(--accent);
+ text-underline-offset: 3px;
+ }
+
+ /* The story leads: the first sentence reads as prose, not as muted
+ metadata — the rest stays one link away, on GitHub. */
+ .entry-lede {
+ color: var(--ink);
+ font-size: 0.95rem;
+ line-height: 1.55;
+ margin: 0 0 0.75rem;
+ overflow-wrap: anywhere;
+ }
+
+ .entry .milestone-meta {
margin: 0.35rem 0 0;
}
+ .entry .rest {
+ display: inline-block;
+ margin-top: 0.55rem;
+ font-size: 0.88rem;
+ color: var(--link);
+ text-decoration: underline;
+ text-decoration-color: color-mix(in oklab, var(--link) 40%, transparent);
+ text-underline-offset: 3px;
+ }
+
+ /* ≥ lg (64rem, the site's own large breakpoint) — Jesse's map: the spine
+ centers, entries alternate sides, era markers center on the spine.
+ Sides come from the server-computed index, not :nth-child, so the
+ zigzag carries across the chapter boundaries. */
+ @media (min-width: 64rem) {
+ /* inset-inline: 0 + auto margins centers the 2px line in either
+ direction — left:0;right:0;width:2px;margin:auto. */
+ .journey-timeline::before {
+ inset-inline-start: 0;
+ inset-inline-end: 0;
+ margin-inline: auto;
+ }
+
+ .era-marker {
+ margin-inline: auto;
+ }
+
+ .era-entries {
+ gap: 1.25rem;
+ }
+
+ .entry {
+ margin-inline-start: 0;
+ width: calc(50% - var(--gutter));
+ }
+
+ .entry-start {
+ justify-self: start;
+ }
+
+ .entry-end {
+ justify-self: end;
+ }
+
+ .entry::before {
+ inset-inline-start: auto;
+ inset-inline-end: calc(-1 * var(--gutter) - 0.35rem);
+ }
+
+ .entry-end::before {
+ inset-inline-end: auto;
+ inset-inline-start: calc(-1 * var(--gutter) - 0.35rem);
+ }
+ }
+
/* Recently-shipped section: quieter, compact list. */
.shipped {
border-top: 1px solid var(--border);
diff --git a/src/i18n/pages/roadmap.ts b/src/i18n/pages/roadmap.ts
index 8e0dd40..7714f81 100644
--- a/src/i18n/pages/roadmap.ts
+++ b/src/i18n/pages/roadmap.ts
@@ -14,6 +14,25 @@ import type { LocalizedPage } from "../config";
* Sonar: parallel tri-locale blocks trip the duplication gate otherwise —
* see CONTRIBUTING.md; fields stay flat single strings / small functions.
*/
+/**
+ * #132 — era markers: the month-year chapter headings ("July 2026") that
+ * break the journey timeline. The date always derives from the milestone's
+ * closedAt; only its rendering is localized, never hand-written.
+ *
+ * Gregorian and UTC — the calendar and day GitHub itself recorded — so the
+ * label can't drift with the build machine's timezone or locale defaults.
+ * `ar-MA` is deliberate: Moroccan month names (يوليوز, غشت) in Western
+ * digits, matching the site's numeric dates; MSA's يوليو/أغسطس would read
+ * as a different register than the Arabic edition's voice.
+ */
+const eraLabel = (locale: string, iso: string): string =>
+ new Intl.DateTimeFormat(locale, {
+ month: "long",
+ year: "numeric",
+ timeZone: "UTC",
+ calendar: "gregory",
+ }).format(new Date(`${iso.slice(0, 10)}T00:00:00Z`));
+
export interface RoadmapContent {
title: string;
description: string;
@@ -25,6 +44,9 @@ export interface RoadmapContent {
/** #129 — the chronological journey, oldest first. */
journeyTitle: string;
journeyIntro: string;
+ /** #132 — the chapter heading a run of closed milestones shares on the
+ * journey timeline; derived from closedAt's month and year. */
+ formatEra: (iso: string) => string;
/** Milestone due date, when the repo sets one. */
dueOn: (date: string) => string;
/** A milestone with no due date says so — never an invented date. */
@@ -48,7 +70,7 @@ export interface RoadmapContent {
export const roadmap: LocalizedPage = {
en: {
- rev: "2026-08-29.1",
+ rev: "2026-08-29.2",
title: "keel Roadmap — read from the repo, refreshed hourly",
description:
"keel's roadmap: the open milestones of CodeGateSoftware/keel and the issues under them, read from GitHub at build time. An open milestone is an intention, not a commitment.",
@@ -60,6 +82,7 @@ export const roadmap: LocalizedPage = {
journeyTitle: "The journey so far",
journeyIntro:
"Every closed milestone, oldest first — the project's history read straight from the repository, dated by GitHub on the day each phase actually finished. No narrative was written after the fact; this is the record.",
+ formatEra: (iso) => eraLabel("en", iso),
closedTitle: "Recently shipped",
dueOn: (date) => `Target date: ${date}`,
noDate: "No date set",
@@ -76,8 +99,8 @@ export const roadmap: LocalizedPage = {
},
ar: {
- rev: "2026-08-29.1",
- translatedFromRev: "2026-08-29.1",
+ rev: "2026-08-29.2",
+ translatedFromRev: "2026-08-29.2",
title: "خارطةُ طريق كيل — تُقرأ من المستودع وتتجدّد كل ساعة",
description:
"خارطةُ طريق كيل: المراحلُ المفتوحة في CodeGateSoftware/keel والبنودُ تحتها، تُجلب من GitHub وقت البناء. والمرحلةُ المفتوحة نيّةٌ لا التزام.",
@@ -89,6 +112,7 @@ export const roadmap: LocalizedPage = {
journeyTitle: "المسيرةُ حتى الآن",
journeyIntro:
"كلُّ المعالم المغلقة، من الأقدم إلى الأحدث — تاريخُ المشروع مقروءًا من المستودع مباشرةً، بتواريخٍ دوّنها GitHub في اليوم الذي اكتملت فيه كلُّ مرحلةٍ فعلًا. لا سردَ كُتب بعد وقوعه؛ هذا هو السجلّ.",
+ formatEra: (iso) => eraLabel("ar-MA", iso),
closedTitle: "أُنجز مؤخّرًا",
dueOn: (date) => `تاريخٌ مستهدف: ${date}`,
noDate: "لم يُحدَّد تاريخ",
@@ -105,8 +129,8 @@ export const roadmap: LocalizedPage = {
},
fr: {
- rev: "2026-08-29.1",
- translatedFromRev: "2026-08-29.1",
+ rev: "2026-08-29.2",
+ translatedFromRev: "2026-08-29.2",
title: "Feuille de route de keel — lue depuis le dépôt, actualisée toutes les heures",
description:
"La feuille de route de keel : les jalons ouverts de CodeGateSoftware/keel et leurs tickets, lus depuis GitHub à la construction. Un jalon ouvert est une intention, pas un engagement.",
@@ -118,6 +142,7 @@ export const roadmap: LocalizedPage = {
journeyTitle: "Le parcours jusqu'ici",
journeyIntro:
"Chaque jalon fermé, du plus ancien au plus récent — l'histoire du projet lue directement du dépôt, datée par GitHub au jour où chaque phase s'est réellement achevée. Aucun récit écrit après coup ; ceci est la trace.",
+ formatEra: (iso) => eraLabel("fr", iso),
closedTitle: "Livré récemment",
dueOn: (date) => `Date cible : ${date}`,
noDate: "Aucune date fixée",