feat(changelog): say what's new after an update, and keep the full record

A card above the lists shows the entries the reader hasn't seen, five at
most; Got it records the newest. The footer opens the changelog: release
notes with their commit range and specs, the short entries each covers,
and the rest by month.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-13 14:26:21 +02:00
1 parent a74bbd3efe
commit 65505e17b5
5 files changed
+299

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
import { useEffect } from "react";
import {
commitUrl,
compareUrl,
KIND_LABEL,
specUrl,
type Entry,
type Note,
} from "../../shared/changelog.ts";
import { asOfLabel } from "../../shared/routines.ts";
import { REPO_URL } from "./Colophon.tsx";
const link =
"underline decoration-hairline underline-offset-2 transition-colors hover:text-ink";
/**
* The fuller record (PRD F18): release notes, each a few paragraphs from the
* history and the specs, with the commit range and the specs it draws on, and
* the short entries it covers listed under it. Entries that belong to no note
* follow, by month.
*
* The detail sheet's shape — the same scrim, the same Escape — because it is
* the same kind of thing: something opened to read, then closed.
*/
export function ChangelogSheet({
notes,
entries,
onClose,
}: {
notes: readonly Note[];
entries: readonly Entry[];
onClose: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const loose = entries.filter((e) => e.note === undefined);
const months = [...new Set(loose.map((e) => e.date.slice(0, 7)))];
return (
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
<button
type="button"
aria-label="Close changelog"
onClick={onClose}
className="absolute inset-0 bg-scrim backdrop-blur-sm"
/>
<div
role="dialog"
aria-modal="true"
aria-label="Changelog"
className="relative max-h-[88vh] w-full overflow-y-auto rounded-t-2xl border border-hairline bg-surface p-5 sm:max-w-lg sm:rounded-2xl"
>
<h2 className="font-display text-xl font-semibold">Changelog</h2>
<p className="mt-1 text-xs leading-relaxed text-faint">
What changed, summarised from the project's history and design notes. Each links to the
commits it covers.
</p>
{notes.map((note) => {
const covered = entries.filter((e) => e.note === note.id);
return (
<article key={note.id} className="mt-6 border-t border-hairline pt-4">
<p className="eyebrow">{asOfLabel(note.date)}</p>
<h3 className="mt-1 text-base font-semibold">{note.title}</h3>
{note.summary.map((paragraph, i) => (
<p key={i} className="mt-2 text-sm leading-relaxed text-muted">
{paragraph}
</p>
))}
{covered.length > 0 && (
<ul className="mt-3 flex flex-col gap-1">
{covered.map((entry) => (
<EntryLine key={entry.id} entry={entry} />
))}
</ul>
)}
<p className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-[0.6875rem] text-faint">
<a
href={compareUrl(REPO_URL, note.from, note.to)}
target="_blank"
rel="noreferrer noopener"
className={link}
>
commits {note.from}…{note.to}
</a>
{(note.specs ?? []).map((path) => (
<a
key={path}
href={specUrl(REPO_URL, path)}
target="_blank"
rel="noreferrer noopener"
className={link}
>
{path.split("/").at(-1)}
</a>
))}
</p>
</article>
);
})}
{months.length > 0 && (
<section className="mt-6 border-t border-hairline pt-4">
<h3 className="text-base font-semibold">Other changes</h3>
{months.map((month) => (
<div key={month} className="mt-3">
<p className="eyebrow">{asOfLabel(`${month}-01`).replace(/^1 /, "")}</p>
<ul className="mt-1.5 flex flex-col gap-1">
{loose
.filter((e) => e.date.startsWith(month))
.map((entry) => (
<EntryLine key={entry.id} entry={entry} />
))}
</ul>
</div>
))}
</section>
)}
</div>
</div>
);
}
function EntryLine({ entry }: { entry: Entry }) {
return (
<li className="flex items-baseline gap-2 text-xs">
<span className="w-24 shrink-0 text-faint">{KIND_LABEL[entry.kind]}</span>
<span className="min-w-0 flex-1 text-muted">{entry.title}</span>
<a
href={commitUrl(REPO_URL, entry.ref)}
target="_blank"
rel="noreferrer noopener"
className={`tnum shrink-0 text-faint ${link}`}
>
{entry.ref}
</a>
</li>
);
}
+14
View File
@@ -177,6 +177,7 @@ export function Colophon({
hiddenGames = [],
onRefresh,
online = true,
onChangelog,
}: {
sources: SourceHealth[];
now: number;
@@ -187,6 +188,8 @@ export function Colophon({
onRefresh?: (() => Promise<RefreshOutcome>) | undefined;
/** Offline the check cannot reach anything, so it is disabled and says so. */
online?: boolean;
/** Open the changelog sheet. Absent where there is none to open. */
onChangelog?: (() => void) | undefined;
/**
* Lanes the reader has switched off, so the staleness notice can leave them
* out. Optional and empty by default: a caller that does not know the
@@ -485,6 +488,17 @@ export function Colophon({
<GitHubMark />
Source code
</a>
{/* Beside the source code it summarises: the changelog is the history,
told for readers rather than for git. */}
{onChangelog !== undefined && (
<button
type="button"
onClick={onChangelog}
className="inline-flex items-center gap-1.5 text-muted transition-colors duration-150 hover:text-ink"
>
Changelog
</button>
)}
{/*
Last in the row of small links, in the row's own muted grey, with no
accent colour, no button and no sentence asking for anything. This
+64
View File
@@ -0,0 +1,64 @@
import { KIND_LABEL, type Entry } from "../../shared/changelog.ts";
import { asOfLabel } from "../../shared/routines.ts";
/** At most this many lines on the card; the rest are a tap away. */
const SHOWN = 5;
/**
* What changed since the reader last looked (PRD F18).
*
* Shown in the new version, after the reload — the update notice (F14) is the
* old bundle and cannot know what the new one brings. Top of the main column,
* never in the rail: the rail holds what the reader has to *do*, and this is
* something to read once. Never modal; "Got it" records the newest entry.
*/
export function WhatsNew({
entries,
onDismiss,
onSeeAll,
}: {
/** Unseen entries, newest first. */
entries: readonly Entry[];
onDismiss: () => void;
onSeeAll: () => void;
}) {
if (entries.length === 0) return null;
const more = entries.length - SHOWN;
return (
<section aria-labelledby="whats-new" className="border-b border-hairline px-4 py-4">
<h2 id="whats-new" className="eyebrow">
What's new
</h2>
<ul className="mt-2.5 flex flex-col gap-1.5">
{entries.slice(0, SHOWN).map((entry) => (
<li key={entry.id} className="flex items-baseline gap-2 text-sm">
<span className="w-24 shrink-0 text-[0.6875rem] text-faint">
{KIND_LABEL[entry.kind]}
</span>
<span className="min-w-0 flex-1 text-muted">{entry.title}</span>
<span className="tnum hidden shrink-0 text-[0.6875rem] text-faint sm:inline">
{asOfLabel(entry.date)}
</span>
</li>
))}
</ul>
<div className="mt-3 flex gap-3 text-xs">
<button
type="button"
onClick={onDismiss}
className="rounded-full border border-near/70 bg-near/15 px-3 py-1 font-medium text-near"
>
Got it
</button>
<button
type="button"
onClick={onSeeAll}
className="text-faint transition-colors hover:text-muted"
>
{more > 0 ? `See all (${more} more)` : "See all"}
</button>
</div>
</section>
);
}