Redesign changelog page with feature highlights (#630)

* Redesign changelog page with feature highlights and visual hierarchy

Major releases now show narrative summaries, feature highlight cards
(with image support for screenshots), colored section badges, and
contributor avatars. Minor releases stay compact. Adds changelog-media.ts
as a supplementary data layer alongside CHANGELOG.md.

* Use Next.js Image for optimized loading and GitHub avatar remotes

* Add screenshots for Open With and Tab Colors features

* Add workspace metadata screenshot

* Switch to single-column inline layout, add command palette screenshot

* Conductor-style titles, narrower body, narrative descriptions, reorder features

* Add pin workspace and tab context menu screenshots, remove subtitle

* Add View Changelog link to front page, add DevTools to 0.60.0

* Add CJK input screenshot to 0.60.0

* Read real PNG dimensions at build time, add proper sizes attribute

* Fix image overflow: wrap in overflow-hidden container, add max-w-full

* Fix CSS cascade: move docs-content styles into @layer base

Unlayered CSS beats @layer utilities in the cascade, so .docs-content
rules (margins, padding, list-style) were overriding Tailwind utilities
on ul, li, h2 elements in the changelog page. Moving them into
@layer base lets utilities win without needing !important hacks.

* Switch docs-content spacing from margin to padding

Margins were collapsing and conflicting with Tailwind layout utilities
in the changelog. Padding doesn't collapse and can't interfere with
external spacing set by parent containers.

* Fix changelog layout: use flex column + inline styles for all spacing

Block layout was collapsing when articles had media content (h2, feature
divs, section divs all rendered at the same position). Switching to
display:flex + flex-direction:column on articles and using inline styles
for all spacing guarantees proper vertical stacking regardless of
docs-content CSS interference.

* Remove border from changelog images

* Replace devtools screenshot with cmux inspecting cmux.dev
This commit is contained in:
Lawrence Chen 2026-02-27 03:41:52 -08:00 committed by GitHub
parent 23d140a2a6
commit 851c706db7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 465 additions and 142 deletions

View file

@ -0,0 +1,107 @@
/**
* Supplementary media and narrative for changelog versions.
*
* CHANGELOG.md remains the source of truth for the raw list of changes.
* This file adds titles, feature highlights, and narrative descriptions
* for major releases. Versions not listed here render as plain bullet lists.
*
* Images live in public/changelog/ and should be 2x (e.g. 1600×900 for a
* 800px display width). Use PNG for UI screenshots, WebP for photos.
*/
export interface FeatureHighlight {
title: string;
description: string;
/** Path relative to /public, e.g. "/changelog/0.61.0-command-palette.png" */
image?: string;
}
export interface VersionMedia {
/** Big title shown as a heading, summarizing the main features. */
title: string;
/** Hero image shown at the top of the version entry. */
hero?: string;
/** Feature highlights shown inline below the title. */
features?: FeatureHighlight[];
}
export const changelogMedia: Record<string, VersionMedia> = {
"0.61.0": {
title: "Tab Colors, Command Palette, Pin Workspaces",
features: [
{
title: "Tab Colors",
description:
"Right-click any workspace in the sidebar to assign it a color. There are 17 presets to choose from, or pick a custom color. Colors show on the tab itself and on the workspace indicator rail.",
image: "/changelog/0.61.0-tab-colors.png",
},
{
title: "Command Palette",
description:
"Hit Cmd+Shift+P to open a searchable command palette. Every action in cmux is here: creating workspaces, toggling the sidebar, checking for updates, switching windows. Keyboard shortcuts are shown inline so you can learn them as you go.",
image: "/changelog/0.61.0-command-palette.png",
},
{
title: "Open With",
description:
"You can now open your current directory in VS Code, Cursor, Zed, Xcode, Finder, or any other editor directly from the command palette. Type \"open\" and pick your editor.",
image: "/changelog/0.61.0-open-with.png",
},
{
title: "Pin Workspaces",
description:
"Pin a workspace to keep it at the top of the sidebar. Pinned workspaces stay put when other workspaces reorder from notifications or activity.",
image: "/changelog/0.61.0-pin-workspace.png",
},
{
title: "Workspace Metadata",
description:
"The sidebar now shows richer context for each workspace: PR links that open in the browser, listening ports, git branches, and working directories across all panes.",
image: "/changelog/0.61.0-workspace-metadata.png",
},
],
},
"0.60.0": {
title: "Tab Context Menu, DevTools, Notification Rings, CJK Input",
features: [
{
title: "Tab Context Menu",
description:
"Right-click any tab in a pane to rename it, close tabs to the left or right, move it to another pane, or create a new terminal or browser tab next to it. You can also zoom a pane to full size and mark tabs as unread.",
image: "/changelog/0.60.0-tab-context-menu.png",
},
{
title: "Browser DevTools",
description:
"The embedded browser now has full WebKit DevTools. Open them with the standard shortcut and they persist across tab switches. Inspect elements, debug JavaScript, and monitor network requests without leaving cmux.",
image: "/changelog/0.60.0-devtools.png",
},
{
title: "Notification Rings",
description:
"When a background process sends a notification (like a long build finishing), the terminal pane shows an animated ring so you can spot it at a glance without switching workspaces.",
},
{
title: "CJK Input",
description:
"Full IME support for Korean, Chinese, and Japanese. Preedit text renders inline with proper anchoring and sizing, so composing characters works the way you'd expect.",
image: "/changelog/0.60.0-cjk-input.png",
},
{
title: "Claude Code",
description:
"Claude Code integration is now enabled by default. Each workspace gets its own routing context, and agents can read terminal screen contents via the API.",
},
],
},
"0.32.0": {
title: "Sidebar Metadata",
features: [
{
title: "Sidebar Metadata",
description:
"The sidebar now displays git branch, listening ports, log entries, progress bars, and status pills for each workspace.",
},
],
},
};

View file

@ -1,6 +1,18 @@
import type { Metadata } from "next";
import fs from "fs";
import path from "path";
import Image from "next/image";
import { changelogMedia, type VersionMedia } from "./changelog-media";
/** Read PNG dimensions from the IHDR chunk (bytes 16-23). */
function pngDimensions(filePath: string): { width: number; height: number } {
const abs = path.join(process.cwd(), "public", filePath);
const buf = fs.readFileSync(abs);
return {
width: buf.readUInt32BE(16),
height: buf.readUInt32BE(24),
};
}
export const metadata: Metadata = {
title: "Changelog",
@ -52,7 +64,6 @@ function parseChangelog(markdown: string): ChangelogVersion[] {
if (currentSection) {
currentSection.items.push(itemMatch[1]);
} else {
// Items without a ### heading (e.g. 1.0.x initial release)
if (!current.sections.length) {
currentSection = { heading: "", items: [] };
current.sections.push(currentSection);
@ -64,7 +75,6 @@ function parseChangelog(markdown: string): ChangelogVersion[] {
continue;
}
// Non-empty lines that aren't headings or items (intro text)
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
current.intro = trimmed;
@ -97,39 +107,226 @@ function InlineMarkdown({ text }: { text: string }) {
);
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
function HeroImage({ src, version }: { src: string; version: string }) {
const { width, height } = pngDimensions(src);
return (
<div style={{ paddingTop: 16, paddingBottom: 24 }}>
<div className="overflow-hidden rounded-lg">
<Image
src={src}
alt={`cmux ${version}`}
width={width}
height={height}
sizes="(max-width: 640px) 100vw, 640px"
className="w-full h-auto"
priority
/>
</div>
</div>
);
}
function FeatureImage({ src, alt }: { src: string; alt: string }) {
const { width, height } = pngDimensions(src);
return (
<div style={{ paddingTop: 12 }}>
<div className="overflow-hidden rounded-lg">
<Image
src={src}
alt={alt}
width={width}
height={height}
sizes="(max-width: 640px) 100vw, 640px"
className="block w-full max-w-full h-auto"
/>
</div>
</div>
);
}
function FeatureList({ media }: { media: VersionMedia }) {
if (!media.features?.length) return null;
return (
<div style={{ paddingTop: 20, display: "flex", flexDirection: "column", gap: 24 }}>
{media.features.map((feature, i) => (
<div key={i}>
<p style={{ margin: 0, padding: 0 }}>
<strong>{feature.title}.</strong>{" "}
<span className="text-muted">{feature.description}</span>
</p>
{feature.image && (
<FeatureImage src={feature.image} alt={feature.title} />
)}
</div>
))}
</div>
);
}
function ContributorList({ items }: { items: string[] }) {
return (
<div className="flex flex-wrap gap-2" style={{ paddingTop: 8 }}>
{items.map((item, i) => {
const match = item.match(
/\[@([^\]]+)\]\((https:\/\/github\.com\/[^)]+)\)/
);
if (match) {
return (
<a
key={i}
href={match[2]}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border text-[13px] text-muted hover:text-foreground transition-colors no-underline!"
>
<Image
src={`https://github.com/${match[1]}.png?size=48`}
alt={match[1]}
width={18}
height={18}
className="rounded-full"
/>
{match[1]}
</a>
);
}
return (
<span key={i} className="text-[13px] text-muted">
<InlineMarkdown text={item} />
</span>
);
})}
</div>
);
}
function SectionBadge({ heading }: { heading: string }) {
const lower = heading.toLowerCase();
let color = "bg-border/50 text-muted";
let label = heading;
if (lower === "added") {
color = "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400";
label = "Added";
} else if (lower === "changed") {
color = "bg-blue-500/10 text-blue-600 dark:text-blue-400";
label = "Changed";
} else if (lower === "fixed") {
color = "bg-amber-500/10 text-amber-600 dark:text-amber-400";
label = "Fixed";
} else if (lower.startsWith("thanks")) {
color = "bg-purple-500/10 text-purple-600 dark:text-purple-400";
label = "Contributors";
}
return (
<span
className={`inline-block text-[12px] font-medium px-2 py-0.5 rounded-md ${color}`}
>
{label}
</span>
);
}
export default function ChangelogPage() {
const changelogPath = path.join(process.cwd(), "..", "CHANGELOG.md");
const markdown = fs.readFileSync(changelogPath, "utf-8");
const versions = parseChangelog(markdown);
return (
<>
<h1>Changelog</h1>
<p>All notable changes to cmux are documented here.</p>
<div className="max-w-[640px] overflow-hidden">
<h1 style={{ margin: 0, padding: 0, paddingBottom: 8 }}>Changelog</h1>
{versions.map((v) => (
<div key={v.version} className="mb-8">
<h2>
{v.version}{" "}
<span className="text-muted font-normal text-[14px]">
{v.date}
</span>
</h2>
{v.intro && <p>{v.intro}</p>}
{v.sections.map((section, i) => (
<div key={i}>
{section.heading && <h3>{section.heading}</h3>}
<ul>
{section.items.map((item, j) => (
<li key={j}>
<InlineMarkdown text={item} />
</li>
))}
</ul>
</div>
))}
</div>
))}
</>
<div style={{ paddingTop: 32 }}>
{versions.map((v) => {
const media = changelogMedia[v.version];
return (
<article
key={v.version}
id={`v${v.version}`}
className="border-t border-border first:border-t-0"
style={{ display: "flex", flexDirection: "column", padding: "40px 0" }}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<a
href={`#v${v.version}`}
className="no-underline! hover:no-underline!"
>
<span className="inline-block text-[13px] font-mono text-muted bg-code-bg px-2 py-0.5 rounded-md">
{v.version}
</span>
</a>
<time
className="text-[13px] text-muted"
dateTime={v.date}
>
{formatDate(v.date)}
</time>
</div>
{media?.title && (
<div style={{ paddingTop: 12, margin: 0, fontSize: "1.5rem", fontWeight: 700, letterSpacing: "-0.025em" }}>
{media.title}
</div>
)}
{media?.hero && (
<HeroImage src={media.hero} version={v.version} />
)}
{media && <FeatureList media={media} />}
{v.intro && !media && (
<div className="text-[14px] text-muted italic" style={{ paddingTop: 12 }}>
{v.intro.replace(/^_/, "").replace(/_$/, "")}
</div>
)}
<div style={{ paddingTop: 20, display: "flex", flexDirection: "column", gap: 16 }}>
{v.sections.map((section, i) => {
const isContributors = section.heading
.toLowerCase()
.startsWith("thanks");
if (isContributors) {
return (
<div key={i}>
<SectionBadge heading={section.heading} />
<ContributorList items={section.items} />
</div>
);
}
return (
<div key={i}>
{section.heading && (
<SectionBadge heading={section.heading} />
)}
<ul style={{ margin: 0, paddingTop: 8, paddingBottom: 0, paddingLeft: 24, listStyle: "disc" }}>
{section.items.map((item, j) => (
<li key={j} style={{ margin: 0, padding: 0, fontSize: 14, lineHeight: 1.6, color: "var(--muted)" }}>
<InlineMarkdown text={item} />
</li>
))}
</ul>
</div>
);
})}
</div>
</article>
);
})}
</div>
</div>
);
}

View file

@ -75,100 +75,102 @@ body {
animation: blink 1s step-end infinite;
}
/* Docs prose styles */
.docs-content h1 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.025em;
margin-bottom: 0.75rem;
}
/* Docs prose styles — in @layer base so Tailwind utilities can override */
@layer base {
.docs-content h1 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.025em;
padding-bottom: 0.75rem;
}
.docs-content h2 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 2.5rem;
margin-bottom: 0.75rem;
letter-spacing: -0.01em;
}
.docs-content h2 {
font-size: 1.25rem;
font-weight: 600;
padding-top: 2.5rem;
padding-bottom: 0.75rem;
letter-spacing: -0.01em;
}
.docs-content h3 {
font-size: 1rem;
font-weight: 600;
margin-top: 1.75rem;
margin-bottom: 0.5rem;
}
.docs-content h3 {
font-size: 1rem;
font-weight: 600;
padding-top: 1.75rem;
padding-bottom: 0.5rem;
}
.docs-content h4 {
font-size: 0.9375rem;
font-weight: 600;
margin-top: 1.25rem;
margin-bottom: 0.375rem;
font-family: var(--font-geist-mono);
}
.docs-content h4 {
font-size: 0.9375rem;
font-weight: 600;
padding-top: 1.25rem;
padding-bottom: 0.375rem;
font-family: var(--font-geist-mono);
}
.docs-content > p {
line-height: 1.7;
margin-bottom: 1rem;
color: var(--muted);
}
.docs-content > p {
line-height: 1.7;
padding-bottom: 1rem;
color: var(--muted);
}
.docs-content ul,
.docs-content ol {
padding-left: 1.5rem;
margin-bottom: 1rem;
}
.docs-content ul,
.docs-content ol {
padding-left: 1.5rem;
padding-bottom: 1rem;
}
.docs-content ul {
list-style: disc;
}
.docs-content ul {
list-style: disc;
}
.docs-content ol {
list-style: decimal;
}
.docs-content ol {
list-style: decimal;
}
.docs-content li {
line-height: 1.7;
margin-bottom: 0.25rem;
color: var(--muted);
}
.docs-content li {
line-height: 1.7;
padding-bottom: 0.25rem;
color: var(--muted);
}
.docs-content code {
font-family: var(--font-geist-mono);
font-size: 0.8125em;
background: var(--code-bg);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.docs-content code {
font-family: var(--font-geist-mono);
font-size: 0.8125em;
background: var(--code-bg);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.docs-content kbd {
font-family: var(--font-geist-mono);
font-size: 0.75em;
line-height: 1;
white-space: nowrap;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 0.3125rem;
padding: 0.2rem 0.375rem;
min-width: 1.4em;
text-align: center;
display: inline-block;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08);
}
.docs-content kbd {
font-family: var(--font-geist-mono);
font-size: 0.75em;
line-height: 1;
white-space: nowrap;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 0.3125rem;
padding: 0.2rem 0.375rem;
min-width: 1.4em;
text-align: center;
display: inline-block;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08);
}
.dark .docs-content kbd {
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.4);
}
.dark .docs-content kbd {
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.4);
}
.docs-content pre {
overflow-x: auto;
max-width: 100%;
}
.docs-content pre {
overflow-x: auto;
max-width: 100%;
}
.docs-content pre code {
background: none;
padding: 0;
font-size: 1em;
font-family: inherit;
.docs-content pre code {
background: none;
padding: 0;
font-size: 1em;
font-family: inherit;
}
}
/* Shiki dual theme */
@ -183,40 +185,42 @@ body {
color: var(--shiki-dark) !important;
}
.docs-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
font-size: 0.875rem;
}
@layer base {
.docs-content table {
width: 100%;
border-collapse: collapse;
padding-bottom: 1.5rem;
font-size: 0.875rem;
}
.docs-content th {
text-align: left;
font-weight: 600;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.docs-content th {
text-align: left;
font-weight: 600;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.docs-content td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
}
.docs-content td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
}
.docs-content strong {
font-weight: 600;
color: var(--foreground);
}
.docs-content strong {
font-weight: 600;
color: var(--foreground);
}
.docs-content a:not([class]) {
color: var(--foreground);
text-decoration: underline;
text-decoration-skip-ink: none;
text-underline-offset: 3px;
text-decoration-thickness: 1px;
text-decoration-color: var(--border);
}
.docs-content a:not([class]) {
color: var(--foreground);
text-decoration: underline;
text-decoration-skip-ink: none;
text-underline-offset: 3px;
text-decoration-thickness: 1px;
text-decoration-color: var(--border);
}
.docs-content a:not([class]):hover {
text-decoration-color: var(--foreground);
.docs-content a:not([class]):hover {
text-decoration-color: var(--foreground);
}
}

View file

@ -259,13 +259,19 @@ export default function Home() {
<DownloadButton location="bottom" />
<GitHubButton />
</div>
<div className="flex justify-center mt-6">
<div className="flex justify-center gap-4 mt-6">
<a
href="/docs"
className="text-sm text-muted hover:text-foreground transition-colors underline underline-offset-2 decoration-border hover:decoration-foreground"
>
Read the Docs
</a>
<a
href="/docs/changelog"
className="text-sm text-muted hover:text-foreground transition-colors underline underline-offset-2 decoration-border hover:decoration-foreground"
>
View Changelog
</a>
</div>
</main>

View file

@ -2,6 +2,15 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
skipTrailingSlashRedirect: true,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "github.com",
pathname: "/*.png",
},
],
},
async rewrites() {
return [
{

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB