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:
1 parent
a74bbd3efe
commit
65505e17b5
5 files changed
+299
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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 & 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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user