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

+17
View File
@@ -42,6 +42,9 @@ import {
routineToAsk,
type Routine,
} from "../shared/routines.ts";
import { ENTRIES, NOTES, newestId, unseen } from "../shared/changelog.ts";
import { ChangelogSheet } from "./components/ChangelogSheet.tsx";
import { WhatsNew } from "./components/WhatsNew.tsx";
import { strandedOccurrences } from "../shared/recurrence.ts";
import { orderGames } from "./state/gameOrder.ts";
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
@@ -108,6 +111,8 @@ function useNow(intervalMs = 1000): number {
export function App() {
const [state, setState] = useState<FeedState>({ status: "loading" });
const [openId, setOpenId] = useState<string | null>(null);
/** Whether the changelog sheet is open. Per-visit, like the detail sheet. */
const [changelogOpen, setChangelogOpen] = useState(false);
// The event most recently ignored, so it can be put back without hunting for
// a row that just disappeared.
const [lastIgnored, setLastIgnored] = useState<{ id: string; title: string } | null>(null);
@@ -641,6 +646,13 @@ export function App() {
</aside>
<div className="min-w-0">
{/* What changed since they last looked: above the lists, never in the
rail, which holds what the reader has to do. */}
<WhatsNew
entries={unseen(ENTRIES, prefs.changelogSeen, new Date(now).toISOString().slice(0, 10))}
onDismiss={() => update({ changelogSeen: newestId(ENTRIES) })}
onSeeAll={() => setChangelogOpen(true)}
/>
{live.length > 0 && (
<Section
legend
@@ -811,6 +823,7 @@ export function App() {
now={now}
hiddenGames={prefs.hiddenGames}
online={online}
onChangelog={() => setChangelogOpen(true)}
onRefresh={async (): Promise<RefreshOutcome> => {
// The newest published feed, past the browser's copy. Swapped in only
// when it carries something new, so a check that found nothing leaves
@@ -840,6 +853,10 @@ export function App() {
/>
)}
{changelogOpen && (
<ChangelogSheet notes={NOTES} entries={ENTRIES} onClose={() => setChangelogOpen(false)} />
)}
{openRow !== null && (
<EventDetail
row={openRow}
+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>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { ChangelogSheet } from "../src/client/components/ChangelogSheet.tsx";
import { WhatsNew } from "../src/client/components/WhatsNew.tsx";
import { Colophon } from "../src/client/components/Colophon.tsx";
import type { Entry, Note } from "../src/shared/changelog.ts";
const e = (id: string, kind: Entry["kind"] = "feature", note?: string): Entry => ({
id, date: "2026-10-13", kind, title: `Title ${id}`, ref: "abcdef1", ...(note ? { note } : {}),
});
describe("What's new", () => {
const noop = () => {};
test("lists unseen entries with their kind, at most five", () => {
const html = renderToStaticMarkup(
<WhatsNew entries={["a", "b", "c", "d", "e", "f", "g"].map((x) => e(x))} onDismiss={noop} onSeeAll={noop} />,
);
expect(html).toContain("What&#x27;s new");
expect(html).toContain("Title e");
expect(html).not.toContain("Title f");
expect(html).toContain("See all (2 more)");
expect(html).toContain("Got it");
expect(html).toContain("New");
});
test("renders nothing with nothing unseen", () => {
expect(renderToStaticMarkup(<WhatsNew entries={[]} onDismiss={noop} onSeeAll={noop} />)).toBe("");
});
});
describe("the changelog sheet", () => {
const note: Note = {
id: "goals", date: "2026-10-13", title: "Check-in goals", summary: ["Para one.", "Para two."],
from: "1deb0cb", to: "064cea2", specs: ["docs/superpowers/specs/2026-10-03-check-in-goals-design.md"],
};
test("a note shows its summary, its commit range and its specs, with its entries under it", () => {
const html = renderToStaticMarkup(
<ChangelogSheet notes={[note]} entries={[e("x", "fix", "goals"), e("loose", "games")]} onClose={() => {}} />,
);
expect(html).toContain("Check-in goals");
expect(html).toContain("Para two.");
expect(html).toContain("/compare/1deb0cb...064cea2");
expect(html).toContain("/blob/main/docs/superpowers/specs/2026-10-03-check-in-goals-design.md");
expect(html).toContain("Title x");
expect(html).toContain("/commit/abcdef1");
// An entry with no note is still listed.
expect(html).toContain("Title loose");
expect(html).toContain("Games &amp; sources");
});
});
describe("the footer link", () => {
test("opens the changelog when the app can show it", () => {
const html = renderToStaticMarkup(<Colophon sources={[]} now={0} onChangelog={() => {}} />);
expect(html).toContain("Changelog");
});
});