SaaS — Changelog / What's New
A polished product changelog and what's-new page built as a vertical release timeline. Each entry shows version, date, colored New / Improved / Fixed tags, a summary, and expandable details. Visitors can filter by update type, search across releases, copy a deep link per entry, and see a new-since-your-last-visit marker driven by localStorage. Includes a subscribe and RSS call to action, a working light and dark theme toggle, and a fully responsive layout.
MCP
Code
:root {
--bg: #f7f8fb;
--surface: #ffffff;
--surface-2: #fbfbfe;
--ink: #0f1222;
--muted: #646b85;
--brand: #6366f1;
--brand-d: #4f46e5;
--ok: #16a34a;
--warn: #d97706;
--danger: #dc2626;
--line: rgba(15, 18, 34, .1);
--line-strong: rgba(15, 18, 34, .16);
--shadow-sm: 0 1px 2px rgba(15, 18, 34, .06);
--shadow-md: 0 6px 24px -8px rgba(15, 18, 34, .18);
--radius: 14px;
--new-bg: #eef2ff; --new-ink: #4338ca;
--imp-bg: #ecfdf5; --imp-ink: #047857;
--fix-bg: #fff7ed; --fix-ink: #b45309;
}
[data-theme="dark"] {
--bg: #0b0d17;
--surface: #14172a;
--surface-2: #181c33;
--ink: #eef0fb;
--muted: #9aa1bd;
--brand: #818cf8;
--brand-d: #a5b4fc;
--line: rgba(255, 255, 255, .1);
--line-strong: rgba(255, 255, 255, .18);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);
--shadow-md: 0 10px 34px -10px rgba(0, 0, 0, .6);
--new-bg: rgba(99, 102, 241, .18); --new-ink: #c7d2fe;
--imp-bg: rgba(16, 163, 74, .16); --imp-ink: #86efac;
--fix-bg: rgba(217, 119, 6, .16); --fix-ink: #fcd34d;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "cv02", "cv03", "ss01";
}
.wrap { width: min(820px, 100% - 2.5rem); margin-inline: auto; }
.skip {
position: absolute; left: -999px; top: 0;
background: var(--brand); color: #fff; padding: .6rem 1rem;
border-radius: 0 0 10px 0; z-index: 50;
}
.skip:focus { left: 0; }
a { color: var(--brand-d); }
/* ---------- topbar ---------- */
.topbar {
position: sticky; top: 0; z-index: 20;
background: color-mix(in srgb, var(--surface) 88%, transparent);
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line);
}
.topbar-inner {
display: flex; align-items: center; justify-content: space-between;
gap: 1rem; padding: .75rem 0;
}
.brand { display: flex; align-items: center; gap: .65rem; }
.brand-mark {
display: grid; place-items: center; width: 38px; height: 38px;
border-radius: 11px; color: #fff;
background: linear-gradient(135deg, var(--brand), var(--brand-d));
box-shadow: var(--shadow-sm);
}
.brand strong { display: block; font-size: .98rem; letter-spacing: -.01em; }
.brand-sub { display: block; font-size: .72rem; color: var(--muted); font-weight: 500; }
.topbar-actions { display: flex; align-items: center; gap: .5rem; }
/* ---------- buttons ---------- */
.btn {
display: inline-flex; align-items: center; gap: .45rem;
font: inherit; font-size: .85rem; font-weight: 600;
padding: .5rem .9rem; border-radius: 10px; cursor: pointer;
border: 1px solid transparent;
background: var(--brand); color: #fff;
box-shadow: var(--shadow-sm);
transition: transform .12s ease, background .15s ease, box-shadow .15s ease;
}
.btn:hover { background: var(--brand-d); }
.btn:active { transform: translateY(1px); }
.btn.ghost {
background: var(--surface); color: var(--ink);
border-color: var(--line-strong);
box-shadow: none;
}
.btn.ghost:hover { background: var(--surface-2); border-color: var(--brand); color: var(--brand-d); }
.icon-btn {
display: grid; place-items: center; width: 38px; height: 38px;
border-radius: 10px; border: 1px solid var(--line-strong);
background: var(--surface); color: var(--ink); cursor: pointer;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.icon-btn:hover { border-color: var(--brand); color: var(--brand-d); }
.i-moon { display: none; }
[data-theme="dark"] .i-sun { display: none; }
[data-theme="dark"] .i-moon { display: block; }
:focus-visible {
outline: 2.5px solid var(--brand);
outline-offset: 2px;
border-radius: 8px;
}
/* ---------- hero ---------- */
.hero { padding: 2.6rem 0 1.4rem; }
.eyebrow {
margin: 0 0 .5rem; font-size: .76rem; font-weight: 700;
letter-spacing: .08em; text-transform: uppercase; color: var(--brand-d);
}
.hero h1 {
margin: 0 0 .55rem; font-size: clamp(1.9rem, 5vw, 2.7rem);
font-weight: 800; letter-spacing: -.025em; line-height: 1.1;
}
.lede { margin: 0; color: var(--muted); font-size: 1.02rem; max-width: 52ch; }
.unread-lede { color: var(--brand-d); font-weight: 600; }
/* ---------- controls ---------- */
.controls {
position: sticky; top: 64px; z-index: 10;
display: flex; flex-wrap: wrap; gap: .75rem; align-items: center;
padding: .85rem; margin-bottom: 1.6rem;
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow-sm);
}
.search {
display: flex; align-items: center; gap: .55rem; flex: 1 1 240px;
padding: .15rem .8rem; border-radius: 10px;
background: var(--surface-2); border: 1px solid var(--line);
color: var(--muted);
}
.search:focus-within { border-color: var(--brand); color: var(--brand-d); }
.search input {
flex: 1; border: 0; background: transparent; font: inherit;
color: var(--ink); padding: .55rem 0; min-width: 0;
}
.search input:focus { outline: none; }
.search input::placeholder { color: var(--muted); }
.filters { display: flex; flex-wrap: wrap; gap: .4rem; }
.chip {
display: inline-flex; align-items: center; gap: .4rem;
font: inherit; font-size: .82rem; font-weight: 600;
padding: .45rem .8rem; border-radius: 999px; cursor: pointer;
background: var(--surface-2); color: var(--muted);
border: 1px solid var(--line);
transition: all .14s ease;
}
.chip:hover { border-color: var(--line-strong); color: var(--ink); }
.chip .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.chip-new .dot { background: var(--brand); }
.chip-improved .dot { background: var(--ok); }
.chip-fixed .dot { background: var(--warn); }
.chip.is-active {
background: var(--ink); color: var(--surface); border-color: var(--ink);
}
.chip-new.is-active { background: var(--brand); border-color: var(--brand); color: #fff; }
.chip-improved.is-active { background: var(--ok); border-color: var(--ok); color: #fff; }
.chip-fixed.is-active { background: var(--warn); border-color: var(--warn); color: #fff; }
.chip.is-active .dot { background: currentColor; }
/* ---------- feed / timeline ---------- */
.feed { position: relative; padding-left: 2.1rem; margin-bottom: 2.5rem; }
.feed::before {
content: ""; position: absolute; left: 7px; top: 6px; bottom: 6px;
width: 2px; background: linear-gradient(var(--line-strong), transparent);
border-radius: 2px;
}
.release { position: relative; margin-bottom: 1.4rem; }
.release::before {
content: ""; position: absolute; left: calc(-2.1rem + 2px); top: 1.15rem;
width: 12px; height: 12px; border-radius: 50%;
background: var(--surface); border: 2.5px solid var(--brand-d);
box-shadow: 0 0 0 4px var(--bg);
}
.release.is-unread::before { background: var(--brand); border-color: var(--brand); }
.release-card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); box-shadow: var(--shadow-sm);
overflow: hidden; transition: border-color .15s ease, box-shadow .15s ease;
}
.release.is-unread .release-card { border-color: color-mix(in srgb, var(--brand) 45%, var(--line)); }
.release-card:hover { box-shadow: var(--shadow-md); }
.release-head {
display: flex; align-items: flex-start; gap: 1rem;
padding: 1.05rem 1.2rem;
}
.release-meta { flex: 1; min-width: 0; }
.release-top { display: flex; flex-wrap: wrap; align-items: center; gap: .55rem; margin-bottom: .4rem; }
.version {
font-weight: 800; font-size: 1.08rem; letter-spacing: -.02em;
}
.release-date { font-size: .8rem; color: var(--muted); font-weight: 500; }
.new-badge {
font-size: .68rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
padding: .18rem .5rem; border-radius: 999px;
background: var(--brand); color: #fff;
}
.tags { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .5rem; }
.tag {
font-size: .72rem; font-weight: 700; padding: .2rem .55rem;
border-radius: 999px; letter-spacing: .01em;
}
.tag-new { background: var(--new-bg); color: var(--new-ink); }
.tag-improved { background: var(--imp-bg); color: var(--imp-ink); }
.tag-fixed { background: var(--fix-bg); color: var(--fix-ink); }
.release-summary { margin: 0; color: var(--ink); font-size: .95rem; }
.release-actions { display: flex; align-items: center; gap: .35rem; flex-shrink: 0; }
.mini-btn {
display: grid; place-items: center; width: 34px; height: 34px;
border-radius: 9px; border: 1px solid var(--line); cursor: pointer;
background: var(--surface-2); color: var(--muted);
transition: all .14s ease;
}
.mini-btn:hover { color: var(--brand-d); border-color: var(--brand); background: var(--surface); }
.mini-btn[aria-expanded="true"] .chevron { transform: rotate(180deg); }
.chevron { transition: transform .2s ease; }
.release-details {
display: grid; grid-template-rows: 0fr;
transition: grid-template-rows .26s ease;
}
.release-details > div { overflow: hidden; }
.release.is-open .release-details { grid-template-rows: 1fr; }
.details-inner {
padding: 0 1.2rem 1.15rem 1.2rem;
border-top: 1px solid var(--line);
margin-top: -1px;
}
.details-inner ul { margin: .95rem 0 0; padding-left: 1.15rem; }
.details-inner li { margin: .35rem 0; color: var(--muted); font-size: .9rem; }
.details-inner li strong { color: var(--ink); font-weight: 600; }
/* ---------- empty ---------- */
.empty {
display: none; flex-direction: column; align-items: center; gap: .55rem;
text-align: center; padding: 3rem 1rem; color: var(--muted);
background: var(--surface); border: 1px dashed var(--line-strong);
border-radius: var(--radius); margin-bottom: 2.5rem;
}
.empty:not([hidden]) { display: flex; }
.empty svg { color: var(--brand); opacity: .7; }
.empty strong { color: var(--ink); font-size: 1.02rem; }
/* ---------- footer ---------- */
.footer { border-top: 1px solid var(--line); padding: 1.5rem 0; }
.footer-inner {
display: flex; flex-wrap: wrap; gap: .75rem;
align-items: center; justify-content: space-between;
font-size: .82rem; color: var(--muted);
}
.rss { display: inline-flex; align-items: center; gap: .4rem; font-weight: 600; text-decoration: none; }
.rss:hover { text-decoration: underline; }
/* ---------- toast ---------- */
.toast-wrap {
position: fixed; left: 50%; bottom: 1.4rem; transform: translateX(-50%);
display: flex; flex-direction: column; gap: .5rem; z-index: 60;
width: max-content; max-width: calc(100% - 2rem);
}
.toast {
display: flex; align-items: center; gap: .55rem;
background: var(--ink); color: var(--bg);
padding: .65rem 1rem; border-radius: 11px;
font-size: .87rem; font-weight: 500;
box-shadow: var(--shadow-md);
animation: toast-in .25s ease;
}
.toast.out { animation: toast-out .25s ease forwards; }
.toast svg { color: var(--ok); flex-shrink: 0; }
[data-theme="dark"] .toast { background: var(--surface); color: var(--ink); border: 1px solid var(--line-strong); }
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } }
@keyframes toast-out { to { opacity: 0; transform: translateY(10px); } }
@media (max-width: 640px) {
.wrap { width: min(820px, 100% - 1.5rem); }
.controls { top: 60px; }
.brand-sub { display: none; }
.topbar-actions .btn span { display: none; }
.release-head { flex-direction: column; gap: .6rem; }
.release-actions { align-self: flex-start; }
}
@media (max-width: 420px) {
#markRead { display: none; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}(function () {
"use strict";
/* ---------------- data ---------------- */
// Fictional release feed. `ts` is an epoch (ms) used for the "new since last visit" marker.
var RELEASES = [
{
v: "2026.6.2", date: "Jun 12, 2026", ts: 1781568000000,
types: ["new", "improved"],
summary: "Realtime collaboration cursors and a faster project sidebar.",
details: [
["Live cursors", "See teammates move and select inside any board in realtime."],
["Sidebar", "Project list now renders 4× faster on workspaces with 500+ projects."],
["Presence", "Avatar stack shows who is currently viewing a document."]
]
},
{
v: "2026.6.1", date: "Jun 6, 2026", ts: 1781049600000,
types: ["new"],
summary: "Webhooks 2.0 — signed payloads, retries, and a delivery log.",
details: [
["Signed payloads", "Every webhook now ships an HMAC-SHA256 signature header."],
["Auto-retry", "Failed deliveries retry up to 5× with exponential backoff."],
["Delivery log", "Inspect, filter, and replay the last 30 days of webhook events."]
]
},
{
v: "2026.5.4", date: "May 28, 2026", ts: 1780358400000,
types: ["improved", "fixed"],
summary: "Export performance overhaul and several CSV edge-case fixes.",
details: [
["CSV exports", "Large exports stream instead of buffering — no more 60s timeouts."],
["Fix", "Commas inside quoted fields no longer break column alignment."],
["Fix", "Date columns now respect the workspace timezone setting."]
]
},
{
v: "2026.5.2", date: "May 19, 2026", ts: 1779580800000,
types: ["new", "improved"],
summary: "SAML SSO for Business plans and a redesigned login screen.",
details: [
["SAML SSO", "Connect Okta, Azure AD, or any SAML 2.0 identity provider."],
["SCIM", "Automatic user provisioning and deprovisioning."],
["Login", "Cleaner, faster sign-in with passkey support."]
]
},
{
v: "2026.5.0", date: "May 7, 2026", ts: 1778457600000,
types: ["fixed"],
summary: "Stability release — patched 14 reported issues across the editor.",
details: [
["Editor", "Undo history no longer drops the last action after a paste."],
["Mobile", "Keyboard no longer obscures the comment box on iOS."],
["Search", "Special characters in queries are now escaped correctly."]
]
},
{
v: "2026.4.3", date: "Apr 24, 2026", ts: 1777593600000,
types: ["new"],
summary: "Dark mode is here — system-aware, per-workspace, fully themed.",
details: [
["Dark mode", "Automatically follows your OS setting, or pin it manually."],
["Charts", "All analytics charts re-tuned for low-light contrast."],
["API", "New `theme` field on the user preferences endpoint."]
]
}
];
var TYPE_LABEL = { new: "New", improved: "Improved", fixed: "Fixed" };
var STORAGE_KEY = "nw_changelog_last_visit";
/* ---------------- state ---------------- */
var lastVisit = Number(localStorage.getItem(STORAGE_KEY)) || 0;
var activeType = "all";
var query = "";
/* ---------------- elements ---------------- */
var feed = document.getElementById("feed");
var emptyEl = document.getElementById("empty");
var searchEl = document.getElementById("search");
var chips = Array.prototype.slice.call(document.querySelectorAll(".chip"));
var unreadLede = document.getElementById("unreadLede");
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
});
}
function unreadCount() {
return RELEASES.filter(function (r) { return r.ts > lastVisit; }).length;
}
/* ---------------- render ---------------- */
function render() {
var q = query.trim().toLowerCase();
var matches = RELEASES.filter(function (r) {
var typeOk = activeType === "all" || r.types.indexOf(activeType) !== -1;
if (!typeOk) return false;
if (!q) return true;
var hay = (r.v + " " + r.summary + " " + r.details.map(function (d) { return d.join(" "); }).join(" ")).toLowerCase();
return hay.indexOf(q) !== -1;
});
feed.innerHTML = "";
emptyEl.hidden = matches.length !== 0;
matches.forEach(function (r, i) {
var isUnread = r.ts > lastVisit;
var id = "rel-" + r.v.replace(/\./g, "-");
var tags = r.types.map(function (t) {
return '<span class="tag tag-' + t + '">' + TYPE_LABEL[t] + "</span>";
}).join("");
var detailItems = r.details.map(function (d) {
return "<li><strong>" + esc(d[0]) + ":</strong> " + esc(d[1]) + "</li>";
}).join("");
var art = document.createElement("article");
art.className = "release" + (isUnread ? " is-unread" : "");
art.id = id;
art.innerHTML =
'<div class="release-card">' +
'<div class="release-head">' +
'<div class="release-meta">' +
'<div class="release-top">' +
'<span class="version">v' + esc(r.v) + "</span>" +
'<span class="release-date">' + esc(r.date) + "</span>" +
(isUnread ? '<span class="new-badge">New</span>' : "") +
"</div>" +
'<div class="tags">' + tags + "</div>" +
'<p class="release-summary">' + esc(r.summary) + "</p>" +
"</div>" +
'<div class="release-actions">' +
'<button class="mini-btn js-copy" type="button" aria-label="Copy link to v' + esc(r.v) + '" title="Copy link">' +
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true"><path d="M9 9h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2Z" stroke="currentColor" stroke-width="2"/><path d="M15 5H6a2 2 0 0 0-2 2v9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>' +
"</button>" +
'<button class="mini-btn js-toggle" type="button" aria-expanded="false" aria-controls="' + id + '-d" aria-label="Show details for v' + esc(r.v) + '">' +
'<svg class="chevron" viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
"</button>" +
"</div>" +
"</div>" +
'<div class="release-details" id="' + id + '-d"><div><div class="details-inner"><ul>' + detailItems + "</ul></div></div></div>" +
"</div>";
feed.appendChild(art);
});
var n = unreadCount();
if (n > 0) {
unreadLede.hidden = false;
unreadLede.textContent = n + (n === 1 ? " release" : " releases") + " new since your last visit.";
} else {
unreadLede.hidden = true;
}
}
/* ---------------- toast ---------------- */
var toastWrap = document.getElementById("toastWrap");
function toast(msg) {
var el = document.createElement("div");
el.className = "toast";
el.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true"><path d="m5 13 4 4L19 7" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg><span></span>';
el.querySelector("span").textContent = msg;
toastWrap.appendChild(el);
setTimeout(function () {
el.classList.add("out");
setTimeout(function () { el.remove(); }, 250);
}, 2400);
}
/* ---------------- interactions ---------------- */
feed.addEventListener("click", function (e) {
var toggle = e.target.closest(".js-toggle");
if (toggle) {
var rel = toggle.closest(".release");
var open = rel.classList.toggle("is-open");
toggle.setAttribute("aria-expanded", String(open));
return;
}
var copy = e.target.closest(".js-copy");
if (copy) {
var relC = copy.closest(".release");
var url = location.origin + location.pathname + "#" + relC.id;
var done = function () { toast("Link copied to clipboard"); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(done).catch(function () { fallbackCopy(url); done(); });
} else { fallbackCopy(url); done(); }
}
});
function fallbackCopy(text) {
var ta = document.createElement("textarea");
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); } catch (e) {}
ta.remove();
}
// filter chips
chips.forEach(function (chip) {
chip.addEventListener("click", function () {
activeType = chip.getAttribute("data-type");
chips.forEach(function (c) {
var on = c === chip;
c.classList.toggle("is-active", on);
c.setAttribute("aria-pressed", String(on));
});
render();
});
});
// search (debounced)
var t;
searchEl.addEventListener("input", function () {
clearTimeout(t);
t = setTimeout(function () { query = searchEl.value; render(); }, 120);
});
// clear filters from empty state
document.getElementById("clearFilters").addEventListener("click", function () {
activeType = "all"; query = ""; searchEl.value = "";
chips.forEach(function (c) {
var on = c.getAttribute("data-type") === "all";
c.classList.toggle("is-active", on);
c.setAttribute("aria-pressed", String(on));
});
render();
searchEl.focus();
});
// mark all as read
document.getElementById("markRead").addEventListener("click", function () {
lastVisit = Date.now();
localStorage.setItem(STORAGE_KEY, String(lastVisit));
render();
toast("All releases marked as read");
});
// subscribe / rss CTAs (demo)
document.getElementById("subscribe").addEventListener("click", function () {
toast("Subscribed — we'll email you each release");
});
document.getElementById("rss").addEventListener("click", function (e) {
e.preventDefault();
toast("RSS feed URL copied (demo)");
});
// theme toggle
var themeBtn = document.getElementById("themeToggle");
var savedTheme = localStorage.getItem("nw_theme");
if (savedTheme === "dark" || (!savedTheme && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.setAttribute("data-theme", "dark");
themeBtn.setAttribute("aria-pressed", "true");
}
themeBtn.addEventListener("click", function () {
var dark = document.documentElement.getAttribute("data-theme") === "dark";
if (dark) { document.documentElement.removeAttribute("data-theme"); localStorage.setItem("nw_theme", "light"); }
else { document.documentElement.setAttribute("data-theme", "dark"); localStorage.setItem("nw_theme", "dark"); }
themeBtn.setAttribute("aria-pressed", String(!dark));
});
/* ---------------- init ---------------- */
render();
// auto-open the release referenced in the URL hash, if any
if (location.hash) {
var target = document.getElementById(location.hash.slice(1));
if (target && target.classList.contains("release")) {
target.classList.add("is-open");
var tg = target.querySelector(".js-toggle");
if (tg) tg.setAttribute("aria-expanded", "true");
target.scrollIntoView({ block: "center" });
}
}
// record this visit (so the "new" markers reflect *next* time) after a short read window
setTimeout(function () {
if (!localStorage.getItem(STORAGE_KEY)) {
localStorage.setItem(STORAGE_KEY, String(RELEASES[2].ts));
}
}, 1500);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Northwind — Changelog & What's New</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip" href="#feed">Skip to changelog</a>
<header class="topbar" role="banner">
<div class="wrap topbar-inner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none">
<path d="M4 13.5 10 7l4 4 6-6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="20" cy="5" r="2" fill="currentColor"/>
</svg>
</span>
<div>
<strong>Northwind</strong>
<span class="brand-sub">Product updates</span>
</div>
</div>
<div class="topbar-actions">
<button class="btn ghost" id="markRead" type="button">Mark all as read</button>
<button class="btn" id="subscribe" type="button">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true"><path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><circle cx="5" cy="19" r="1.6" fill="currentColor"/></svg>
Subscribe
</button>
<button class="icon-btn" id="themeToggle" type="button" aria-label="Toggle dark mode" aria-pressed="false">
<svg class="i-sun" viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="4.2" stroke="currentColor" stroke-width="2"/><path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<svg class="i-moon" viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
</header>
<main class="wrap" id="main">
<section class="hero" aria-labelledby="hero-title">
<p class="eyebrow">Changelog</p>
<h1 id="hero-title">What’s new in Northwind</h1>
<p class="lede">Every release, fix, and improvement we ship to the platform. <span id="unreadLede" class="unread-lede" hidden></span></p>
</section>
<div class="controls" role="search">
<div class="search">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="2"/><path d="m20 20-3.2-3.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<input id="search" type="search" placeholder="Search releases, e.g. webhooks, SSO…" aria-label="Search releases" autocomplete="off" />
</div>
<div class="filters" role="group" aria-label="Filter by type">
<button class="chip is-active" data-type="all" type="button" aria-pressed="true">All</button>
<button class="chip chip-new" data-type="new" type="button" aria-pressed="false"><span class="dot"></span>New</button>
<button class="chip chip-improved" data-type="improved" type="button" aria-pressed="false"><span class="dot"></span>Improved</button>
<button class="chip chip-fixed" data-type="fixed" type="button" aria-pressed="false"><span class="dot"></span>Fixed</button>
</div>
</div>
<section class="feed" id="feed" aria-label="Release timeline" aria-live="polite"></section>
<p class="empty" id="empty" hidden>
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="2"/><path d="m20 20-3.2-3.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<strong>No releases match your filters.</strong>
<span>Try a different keyword or clear the type filter.</span>
<button class="btn ghost" id="clearFilters" type="button">Clear filters</button>
</p>
</main>
<footer class="footer" role="contentinfo">
<div class="wrap footer-inner">
<span>© 2026 Northwind, Inc. · Fictional product for demo purposes.</span>
<a class="rss" href="#" id="rss">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" aria-hidden="true"><path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/><circle cx="5" cy="19" r="1.6" fill="currentColor"/></svg>
RSS feed
</a>
</div>
</footer>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Changelog / What’s New
A tidy product-update feed for a fictional SaaS called Northwind. Releases are laid out as a vertical timeline with connector dots: each card shows the version number, ship date, colored type tags (New, Improved, Fixed), a one-line summary, and an expandable list of details. The layout stays readable from wide screens down to about 360px.
The toolbar offers type filters and a live search that matches against versions, summaries, and detail text. Every release exposes a copy-link button that writes a deep link (with a hash anchor) to the clipboard, and a chevron to expand its details inline. A “new since your last visit” marker — backed by localStorage — highlights unread releases and surfaces a count in the intro; “Mark all as read” clears it.
Rounding it out: a subscribe and RSS call to action, a working light/dark theme toggle that persists and respects the OS preference, toast notifications, an intentional empty state with a clear-filters action, and full keyboard plus screen-reader support via landmarks, ARIA, and focus-visible rings.
Illustrative SaaS UI only — fictional product, metrics, and billing. No real backend.