From e8298e63022d27151f0dd6000790e6558644289a Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sat, 29 Aug 2026 02:30:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(roadmap):=20Jesse's=20journey=20=E2=80=94?= =?UTF-8?q?=20alternating=20timeline=20with=20era=20chapters=20(#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The journey so far becomes the vertical historical map from Jesse's history page: a center spine with the 14 closed milestones alternating sides (>= lg), broken into chapters by era markers derived from the closedAt months (July 2026 / August 2026 — c.formatEra, localized juillet 2026 / يوليوز 2026, never hand-written). Each entry carries the milestone title, the first sentence of its description as prose, the ship date, and the item count; the rest of the description stays on the milestone's GitHub page, one link away. Below lg the spine hugs one edge, every entry sits on a single side, and the era markers run inline. Story first: the journey now leads the page (at the container's width) with the open-milestones section below, framing untouched. Pure CSS — one grid, logical properties only, so the Arabic edition mirrors spine, sides and dots. Zero JS; the fetch is untouched. --- src/components/pages/RoadmapPage.astro | 329 +++++++++++++++++++++---- src/i18n/pages/roadmap.ts | 35 ++- 2 files changed, 311 insertions(+), 53 deletions(-) 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 }) => ( +
  1. +

    + {milestone.title} +

    + {lede &&

    {lede}

    } +

    + {milestone.closedAt && ( + {c.closedOn(formatDate(milestone.closedAt))} + )} + + + {c.progress( + milestone.closedIssues, + milestone.closedIssues + milestone.openIssues, + )} + +

    + {continues && ( + + {c.viewMilestone} → + + )} +
  2. + ))} +
+
+ ))} +
+
+ )} + +
{open.length > 0 && (

{c.openTitle}

@@ -149,30 +280,6 @@ const overflow = (milestone: RoadmapMilestone) =>
)} - {journey.length > 0 && ( -
-

{c.journeyTitle}

-

{c.journeyIntro}

-
    - {journey.map((milestone) => ( -
  1. -

    - {milestone.title} -

    - {milestone.description &&

    {milestone.description}

    } -

    - {milestone.closedAt && ( - {c.closedOn(formatDate(milestone.closedAt))} - )} - - {c.progress(milestone.closedIssues, milestone.closedIssues + milestone.openIssues)} -

    -
  2. - ))} -
-
- )} - {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",