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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user