diff --git a/webviews/src/pages/changelog/Localizable.xcstrings b/webviews/src/pages/changelog/Localizable.xcstrings
index 58f306a64b46..45e12cffd094 100644
--- a/webviews/src/pages/changelog/Localizable.xcstrings
+++ b/webviews/src/pages/changelog/Localizable.xcstrings
@@ -6460,6 +6460,270 @@
}
}
}
+ },
+ "changelog.page.close": {
+ "comment": "Changelog page: closes the enlarged picture.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Close"
+ }
+ },
+ "ar": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "إغلاق"
+ }
+ },
+ "bs": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zatvori"
+ }
+ },
+ "da": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Luk"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Schließen"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Cerrar"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Fermer"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Chiudi"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "閉じる"
+ }
+ },
+ "km": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "បិទ"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "닫기"
+ }
+ },
+ "nb": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Lukk"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Zamknij"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Fechar"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Закрыть"
+ }
+ },
+ "th": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ปิด"
+ }
+ },
+ "tr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Kapat"
+ }
+ },
+ "uk": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Закрити"
+ }
+ },
+ "vi": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Đóng"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "关闭"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "關閉"
+ }
+ }
+ }
+ },
+ "changelog.page.enlarge": {
+ "comment": "Changelog page: label of a change's picture, which opens it enlarged; the picture's description follows.",
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Enlarge picture"
+ }
+ },
+ "ar": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "تكبير الصورة"
+ }
+ },
+ "bs": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Uvećaj sliku"
+ }
+ },
+ "da": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Forstør billedet"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Bild vergrößern"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ampliar imagen"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Agrandir l’image"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ingrandisci immagine"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "画像を拡大"
+ }
+ },
+ "km": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ពង្រីករូបភាព"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "이미지 확대"
+ }
+ },
+ "nb": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Forstørr bildet"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Powiększ obraz"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Ampliar imagem"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Увеличить изображение"
+ }
+ },
+ "th": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ขยายภาพ"
+ }
+ },
+ "tr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Resmi büyüt"
+ }
+ },
+ "uk": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Збільшити зображення"
+ }
+ },
+ "vi": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Phóng to hình ảnh"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "放大图片"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "放大圖片"
+ }
+ }
+ }
}
},
"version": "1.0"
diff --git a/webviews/src/pages/changelog/ReleaseParts.tsx b/webviews/src/pages/changelog/ReleaseParts.tsx
index f17dfaf4dc73..88f3909f41f3 100644
--- a/webviews/src/pages/changelog/ReleaseParts.tsx
+++ b/webviews/src/pages/changelog/ReleaseParts.tsx
@@ -1,7 +1,9 @@
// The pieces every changelog design shares (cx-urd.61): release meta, the hero, the grouped
// change rows and the folded commit list by area. Colors are theme tokens, sizes the type scale
// (styles.css); nothing here knows which design it sits in.
-import { useState, useSyncExternalStore } from "react";
+import { useRef, useState, useSyncExternalStore } from "react";
+import { Dialog } from "../../ui/Dialog";
+import { DialogButton } from "../../ui/DialogButton";
import type { Strings } from "../shared/i18n";
import { getColorScheme, subscribeColorScheme } from "./colorScheme";
import { byArea } from "./areas";
@@ -31,21 +33,77 @@ export function ReleaseMeta({ date, version, strings }: { date: string; version:
);
}
-/** The release's picture in the theme's scheme; the box keeps its ratio before the file arrives. */
-export function Media({ media, className }: { media: MediaRef; className?: string }) {
+/** The release's picture in the theme's scheme; the box keeps its ratio before the file arrives. It
+ loads as it nears the viewport. Given `strings`, a click (or Return) opens it enlarged. */
+export function Media({ media, className, strings }: { media: MediaRef; className?: string; strings?: Strings }) {
const scheme = useSyncExternalStore(subscribeColorScheme, getColorScheme, () => "light" as const);
const src = scheme === "dark" ? media.dark : media.light;
+ const [open, setOpen] = useState(false);
+ const picture = media.video ? (
+
+ ) : (
+
+ );
return (
- {media.video ? (
-
+ {strings ? (
+
) : (
-
+ picture
)}
+ {strings && open && setOpen(false)} />}
);
}
+/** A picture enlarged to the window, on glass over the page; Escape, Close or a press outside closes it. */
+function MediaLightbox({
+ media,
+ src,
+ strings,
+ onClose,
+}: {
+ media: MediaRef;
+ src: string;
+ strings: Strings;
+ onClose(): void;
+}) {
+ const close = useRef(null);
+ return (
+
+ );
+}
+
export function ItemActions({ item, ctx }: { item: ReleaseItem; ctx: PartsContext }) {
if (!item.action && !item.docs) return null;
return (
@@ -123,7 +181,7 @@ export function Hero({
{p}
))}
- {item.media && }
+ {item.media && }
>
);
@@ -191,7 +249,7 @@ function WideRow({ item, ctx }: { item: ReleaseItem; ctx: PartsContext }) {
))}
- {item.media && }
+ {item.media && }
);
}
diff --git a/webviews/src/pages/changelog/main.tsx b/webviews/src/pages/changelog/main.tsx
index 61dd0dc5af92..2de173eb8d8d 100644
--- a/webviews/src/pages/changelog/main.tsx
+++ b/webviews/src/pages/changelog/main.tsx
@@ -11,6 +11,8 @@ import { MockChangelogProvider } from "./mockProvider";
import { ChangelogStore, parseDesign, parseSpan } from "./store";
import { mountUpdateSurface, parseSurface } from "./surface";
import "../shared/pageBase.css";
+// The enlarged picture is a ui/Dialog: this sheet places a dialog and its backdrop over the page.
+import "../../ui/popupSurface.css";
import "./styles.css";
export function mountChangelogPage(root: HTMLElement, client: PageClient | null = defaultClient()): ChangelogStore {
diff --git a/webviews/src/pages/changelog/model.ts b/webviews/src/pages/changelog/model.ts
index 9065d117fd5a..4b7f9a19238d 100644
--- a/webviews/src/pages/changelog/model.ts
+++ b/webviews/src/pages/changelog/model.ts
@@ -106,6 +106,16 @@ function fromEntry(entry: WhatsNewEntry, language: string): ReleaseItem {
};
}
+/** A highlight file's id as scripts/whats-new/digest.py writes it: lower case, other runs as "-".
+ An id with no letter or digit to keep stays as it is, so two such ids never collide. */
+function entryId(id: string): string {
+ const normalized = id
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+ return normalized || id;
+}
+
function fromHighlight(highlight: Highlight): ReleaseItem {
const media = highlight.media?.find((m) => admissibleSource(m.url));
const src = media ? admissibleSource(media.url)! : undefined;
@@ -217,19 +227,30 @@ export function releaseView(notes: ReleaseNotes, language: string): ReleaseView
const items: ReleaseItem[] = [];
const ids = new Set();
const seen = new Set();
+ // Ids compare as digest.py writes them, so a highlight file and its digest entry are one change
+ // even when the entry's title is in another language.
const add = (item: ReleaseItem, byId: boolean, machine: boolean) => {
- if (byId && ids.has(item.id)) return;
+ if (byId && ids.has(entryId(item.id))) return;
const readable = readableItem(item, raw, machine);
if (!readable) return;
const key = readable.title.toLowerCase();
if (seen.has(key)) return;
- ids.add(item.id);
+ ids.add(entryId(item.id));
seen.add(key);
items.push(readable);
};
+ // A digest entry has no picture of its own in a nightly; its highlight file's picture (taken
+ // from the change's pull request, release-notes.py) stands in. Ids match as digest.py makes them.
+ const pictures = new Map(notes.highlights.map((h) => [entryId(h.id), fromHighlight(h).media]));
// A digest the pipeline made from summary lines names its entries "summary-N".
- for (const entry of notes.whatsNew?.entries ?? [])
- add(fromEntry(entry, language), false, entry.id.startsWith("summary-"));
+ for (const entry of notes.whatsNew?.entries ?? []) {
+ const item = fromEntry(entry, language);
+ add(
+ item.media ? item : { ...item, media: pictures.get(entryId(entry.id)) },
+ false,
+ entry.id.startsWith("summary-"),
+ );
+ }
for (const highlight of notes.highlights) add(fromHighlight(highlight), true, false);
for (const [index, line] of (notes.summary ?? []).entries()) {
add({ id: `summary-${index}`, category: summaryCategory(line.group), title: line.title, summary: "" }, false, true);
diff --git a/webviews/src/pages/changelog/styles.css b/webviews/src/pages/changelog/styles.css
index 01fa4bafc9e6..dbc305882761 100644
--- a/webviews/src/pages/changelog/styles.css
+++ b/webviews/src/pages/changelog/styles.css
@@ -53,6 +53,46 @@ html[data-cmux-page="changelog"] body {
}
.cl-media img,
.cl-media video { display: block; width: 100%; height: 100%; object-fit: cover; object-position: left top; }
+/* A picture opens enlarged: the whole frame is the button, with no chrome of its own. */
+.cl-media-open { display: block; width: 100%; height: 100%; padding: 0; border: 0; background: none; cursor: zoom-in; }
+.cl-media-open:focus-visible { outline: 2px solid var(--cmux-accent, currentColor); outline-offset: -2px; }
+/* The enlarged picture: on glass over the page, the picture whole (contain), never cut. */
+.cl-lightbox-backdrop {
+ background: color-mix(in srgb, var(--cmux-surface-background, Canvas) 60%, transparent);
+ -webkit-backdrop-filter: var(--surface-blur);
+ backdrop-filter: var(--surface-blur);
+}
+.cl-lightbox.ui-dialog {
+ inset: 24px;
+ width: auto;
+ max-width: none;
+ max-height: none;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px;
+ background: transparent;
+ box-shadow: none;
+}
+.cl-lightbox-bar { display: flex; align-items: center; gap: 12px; min-width: 0; }
+.cl-lightbox-alt {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--page-text-2);
+ font-size: var(--text-detail);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.cl-lightbox-stage { display: grid; flex: 1; min-height: 0; place-items: center; }
+.cl-lightbox-stage :is(img, video) {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ border-radius: 10px;
+ box-shadow: 0 0 0 1px var(--page-separator);
+}
.cl-actions { display: flex; align-items: center; gap: 14px; margin: 4px 0 8px; }
.cl-button {
height: 26px;
diff --git a/webviews/test/changelog-pictures.test.tsx b/webviews/test/changelog-pictures.test.tsx
new file mode 100644
index 000000000000..519ace197bac
--- /dev/null
+++ b/webviews/test/changelog-pictures.test.tsx
@@ -0,0 +1,114 @@
+// Leo 2026-10-21 (What's New design pass, 2): each change can carry a picture, lazy-loaded in a
+// glass frame, that opens enlarged on a click. A nightly's digest entries carry no picture of
+// their own; the highlight file's picture (release-notes.py) stands in for its entry.
+import { afterAll, beforeAll, expect, test } from "bun:test";
+import { JSDOM } from "jsdom";
+import { act } from "react";
+import { releaseView } from "../src/pages/changelog/model";
+import type { ReleaseNotes } from "../src/pages/changelog/types";
+
+const scope = globalThis as Record;
+const keys = ["window", "document", "navigator", "HTMLElement", "location", "IS_REACT_ACT_ENVIRONMENT"] as const;
+const saved = keys.map((key) => [key, key in scope, scope[key]] as const);
+let dom: JSDOM;
+
+beforeAll(() => {
+ dom = new JSDOM("", { url: "cmux-page://cmux.changelog/" });
+ Object.assign(scope, {
+ window: dom.window,
+ document: dom.window.document,
+ navigator: dom.window.navigator,
+ HTMLElement: dom.window.HTMLElement,
+ location: dom.window.location,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+});
+
+afterAll(() => {
+ for (const [key, had, value] of saved) {
+ if (had) scope[key] = value;
+ else delete scope[key];
+ }
+});
+
+const picture = "data:image/gif;base64,R0lGODlhAQABAAAAACw=";
+
+function nightly(): ReleaseNotes {
+ return {
+ build: "3937488754203",
+ shortVersion: "1.0.0-nightly.3937488754203",
+ date: "2026-10-21",
+ changes: [],
+ // As release-notes.py writes a highlight: its file name as the id, its PR's picture as media.
+ highlights: [
+ {
+ id: "Group_Band_Colors",
+ title: "Group labels keep their band color",
+ body: "A label keeps its band's color.",
+ category: "improved",
+ media: [{ url: picture, sha256: "0".repeat(64), kind: "image", alt: "Groups with colored labels" }],
+ },
+ ],
+ // As digest.py writes the same file: a normalized id and no picture.
+ whatsNew: {
+ version: "1.0.0-nightly.3937488754203",
+ channel: "nightly",
+ date: "2026-10-21",
+ headline: { en: "Group labels keep their band color" },
+ entries: [
+ {
+ id: "group-band-colors",
+ category: "improved",
+ title: { en: "Group labels keep their band color" },
+ summary: { en: "A label keeps its band's color." },
+ },
+ ],
+ },
+ };
+}
+
+test("a digest entry with no picture shows its highlight file's picture", () => {
+ const view = releaseView(nightly(), "en");
+ const items = [view.hero, ...view.groups.flatMap((g) => g.items)].filter((i) => i !== undefined);
+ expect(items).toHaveLength(1);
+ expect(items[0]!.media?.light).toBe(picture);
+ expect(items[0]!.media?.alt).toBe("Groups with colored labels");
+});
+
+test("in another language the highlight file and its digest entry stay one change", () => {
+ const notes = nightly();
+ notes.whatsNew!.entries[0]!.title.ja = "グループのラベルは帯の色を保つ";
+ notes.whatsNew!.entries[0]!.summary.ja = "ラベルは帯の色のままです。";
+ const view = releaseView(notes, "ja");
+ const items = [view.hero, ...view.groups.flatMap((g) => g.items)].filter((i) => i !== undefined);
+ expect(items.map((i) => i.title)).toEqual(["グループのラベルは帯の色を保つ"]);
+ expect(items[0]!.media?.light).toBe(picture);
+});
+
+test("a change's picture opens enlarged on a click, and Close closes it", async () => {
+ const { MockChangelogProvider } = await import("../src/pages/changelog/mockProvider");
+ const { mountChangelogPage } = await import("../src/pages/changelog/main");
+ const root = dom.window.document.createElement("div");
+ dom.window.document.body.append(root);
+ await act(async () => {
+ mountChangelogPage(root, new MockChangelogProvider([nightly()] as never) as never);
+ });
+ // Until the notes load and the picture renders: a condition, not a fixed delay.
+ for (let turn = 0; turn < 50 && !root.querySelector(".cl-media-open"); turn++)
+ await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
+ const open = root.querySelector(".cl-media-open")!;
+ expect(open.getAttribute("aria-label")).toContain("Groups with colored labels");
+ expect(open.querySelector("img")?.getAttribute("loading")).toBe("lazy");
+ await act(async () => open.click());
+ const doc = dom.window.document;
+ for (let turn = 0; turn < 50 && !doc.querySelector(".cl-lightbox img"); turn++)
+ await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
+ expect(doc.querySelector(".cl-lightbox img")?.getAttribute("src")).toBe(picture);
+ const close = [...doc.querySelectorAll(".cl-lightbox button")].find((b) =>
+ b.textContent?.includes("Close"),
+ )!;
+ await act(async () => close.click());
+ for (let turn = 0; turn < 50 && doc.querySelector(".cl-lightbox"); turn++)
+ await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
+ expect(doc.querySelector(".cl-lightbox")).toBeNull();
+});