diff --git a/src/client/App.tsx b/src/client/App.tsx index e93cbe4..bf49f88 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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({ status: "loading" }); const [openId, setOpenId] = useState(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() {
+ {/* What changed since they last looked: above the lists, never in the + rail, which holds what the reader has to do. */} + update({ changelogSeen: newestId(ENTRIES) })} + onSeeAll={() => setChangelogOpen(true)} + /> {live.length > 0 && (
setChangelogOpen(true)} onRefresh={async (): Promise => { // 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 && ( + setChangelogOpen(false)} /> + )} + {openRow !== null && ( 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 ( +
+
+ ); +} + +function EntryLine({ entry }: { entry: Entry }) { + return ( +
  • + {KIND_LABEL[entry.kind]} + {entry.title} + + {entry.ref} + +
  • + ); +} diff --git a/src/client/components/Colophon.tsx b/src/client/components/Colophon.tsx index 61aa779..ab67173 100644 --- a/src/client/components/Colophon.tsx +++ b/src/client/components/Colophon.tsx @@ -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) | 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({ Source code + {/* Beside the source code it summarises: the changelog is the history, + told for readers rather than for git. */} + {onChangelog !== undefined && ( + + )} {/* 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 diff --git a/src/client/components/WhatsNew.tsx b/src/client/components/WhatsNew.tsx new file mode 100644 index 0000000..aac6b5e --- /dev/null +++ b/src/client/components/WhatsNew.tsx @@ -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 ( +
    +

    + What's new +

    +
      + {entries.slice(0, SHOWN).map((entry) => ( +
    • + + {KIND_LABEL[entry.kind]} + + {entry.title} + + {asOfLabel(entry.date)} + +
    • + ))} +
    +
    + + +
    +
    + ); +} diff --git a/test/changelog-ui.test.tsx b/test/changelog-ui.test.tsx new file mode 100644 index 0000000..6b02e62 --- /dev/null +++ b/test/changelog-ui.test.tsx @@ -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( + 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()).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( + {}} />, + ); + 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( {}} />); + expect(html).toContain("Changelog"); + }); +});