diff --git a/scripts/docs-site/build.mjs b/scripts/docs-site/build.mjs
index 4b20ea234c..36b3249b25 100644
--- a/scripts/docs-site/build.mjs
+++ b/scripts/docs-site/build.mjs
@@ -28,6 +28,7 @@ import { parseFrontmatter } from "../../.openclaw-sync/lib/docs-markdown.mjs";
import { renderPageOgCards, renderOgPng } from "./og-cards.mjs";
import { renderDefaultOgSvg } from "./og-card-template.mjs";
import { activeTabTitle, groupForPage, flattenNavEntries, flattenNav } from "./navigation.mjs";
+import { releaseNavigationEntries, patchReleaseSlugs, patchReleaseBadgeHtml } from "./release-navigation.mjs";
import { resolveRedirects } from "../../.openclaw-sync/lib/docs-redirects.mjs";
const root = process.cwd();
@@ -99,6 +100,7 @@ const allPages = [...collectPages(locales), ...(includeElementsFixture ? [elemen
const allPageByKey = new Map(allPages.map((page) => [pageKey(page.locale, page.slug), page]));
const announcement = latestReleaseAnnouncement(allPages);
const announcedReleasePage = announcement?.page ?? null;
+const patchReleases = patchReleaseSlugs(allPages);
let pages = allPages;
let pageByKey = new Map(pages.map((page) => [pageKey(page.locale, page.slug), page]));
const navByLocale = new Map(locales.map((locale) => [locale.code, buildNav(locale)]));
@@ -194,6 +196,7 @@ function collectPages(localeList) {
raw,
title,
summary: String(parsed.data.summary ?? ""),
+ description: String(parsed.data.description ?? ""),
readWhen: parsed.data.read_when ?? [],
body: parsed.content,
meta: {
@@ -709,19 +712,19 @@ function icon(name) {
}
function navGroupHtml(activePage, group) {
- return `${escapeHtml(group.title)}
${icon("chevron-down")}
${group.pages.map((entry) => navEntryHtml(activePage, entry)).join("")} `;
+ return `${escapeHtml(group.title)}
${icon("chevron-down")}
${releaseNavigationEntries(group.pages, activePage.locale, activePage.slug).map((entry) => navEntryHtml(activePage, entry)).join("")} `;
}
function navEntryHtml(activePage, entry) {
const current = entry.group && flattenNavEntries(entry.pages).some((page) => page.locale === activePage.locale && page.slug === activePage.slug);
return entry.group
- ? `${escapeHtml(entry.group)}
${icon("chevron-down")}
${entry.pages.map((child) => navEntryHtml(activePage, child)).join("")} `
+ ? `${escapeHtml(entry.group)}
${icon("chevron-down")}
${entry.pages.map((child) => navEntryHtml(activePage, child)).join("")} `
: navLink(activePage, entry);
}
function navLink(activePage, page) {
const active = activePage.locale === page.locale && activePage.slug === page.slug ? " active" : "";
- return `${escapeHtml(page.title)}${announcement && page.slug === announcedReleasePage.slug ? releaseBadgeHtml(announcement, activePage.locale) : ""}`;
+ return `${escapeHtml(page.title)}${patchReleases.has(page.slug) ? patchReleaseBadgeHtml(activePage.locale) : ""}${announcement && page.slug === announcedReleasePage.slug ? releaseBadgeHtml(announcement, activePage.locale) : ""}`;
}
function tableOfContents(html) {
diff --git a/scripts/docs-site/release-announcement.css b/scripts/docs-site/release-announcement.css
index 1701f9491e..64f4fcfd88 100644
--- a/scripts/docs-site/release-announcement.css
+++ b/scripts/docs-site/release-announcement.css
@@ -2,7 +2,8 @@
.docs-layout .release-nav-version { display:inline-flex; align-items:center; gap:5px; flex:none; margin-inline-start:auto; padding:3px 5px; border-radius:5px; background:var(--oc-status-success-bg); color:var(--oc-status-success-fg); font:10px/1.3 var(--oc-font-mono); white-space:nowrap; }
.docs-layout :is(.docs-section > summary,.docs-section-trigger):has(.release-nav-version) > .icon { margin-inline-start:0; }
.release-nav-version i, .release-entry-badge i, .release-strip-heading > i { display:inline-block; width:5px; height:5px; flex:0 0 5px; border-radius:50%; background:var(--oc-status-success-fg); }
-.docs-layout .nav-link:has(.release-entry-badge) { display:flex; align-items:center; gap:8px; }
+.docs-layout .nav-link:has(.release-entry-badge, .release-patch-badge) { display:flex; align-items:center; flex-wrap:wrap; gap:6px; }
+.release-patch-badge { display:inline-flex; flex:none; padding:1px 5px; border:1px solid var(--docs-border); border-radius:4px; color:var(--docs-muted); background:var(--docs-surface); font:500 10px/1.5 var(--oc-font-body); }
.release-entry-badge { display:inline-flex; align-items:center; gap:5px; padding:1px 5px; border-radius:4px; color:var(--oc-status-success-fg); background:var(--oc-status-success-bg); font:500 10px/1.5 var(--oc-font-body); }
/* Keep the release announcement a single compact row at every width. */
.docs-layout .doc .release-strip { position:relative; isolation:isolate; container:release-strip / inline-size; display:flex; align-items:center; justify-content:space-between; gap:20px; min-height:44px; margin:0 0 24px; padding:10px 16px; border:1px solid var(--docs-border); border-radius:var(--oc-radius-lg); background:var(--docs-surface); color:var(--ink); text-decoration:none; box-shadow:var(--docs-surface-shadow); }
diff --git a/scripts/docs-site/release-navigation.mjs b/scripts/docs-site/release-navigation.mjs
new file mode 100644
index 0000000000..fbf5515805
--- /dev/null
+++ b/scripts/docs-site/release-navigation.mjs
@@ -0,0 +1,63 @@
+import { flattenNavEntries } from "./navigation.mjs";
+import { escapeUiText } from "./home-strings.mjs";
+
+// Use release-owned English descriptions/introductions, never calendar version
+// numbers or translated prose, to classify releases consistently in every locale.
+export function patchReleaseSlugs(pages) {
+ return new Set(pages.filter(page => page.locale === "en" && releaseMonth(page.slug)
+ && (/\bpatch release\b/i.test(page.description ?? "")
+ || /^This (?:is a )?patch release(?: |[.,])/m.test((page.body ?? "").split(/^## /m)[0])))
+ .map(page => page.slug));
+}
+
+const patchLabels = {
+ en: "Patch", sv: "Patch", de: "Patch", fr: "Correctif", es: "Parche", "pt-BR": "Correção",
+ ar: "تصحيح", fa: "وصله", "zh-CN": "补丁", "zh-TW": "修補", "ja-JP": "パッチ", ko: "패치",
+ hi: "पैच", it: "Correzione", vi: "Bản vá", nl: "Patch", tr: "Yama", uk: "Виправлення",
+ id: "Patch", pl: "Poprawka", ru: "Исправление", th: "แพตช์",
+};
+
+export function patchReleaseBadgeHtml(locale) {
+ return ` ${escapeUiText(patchLabels[locale] ?? patchLabels.en)}`;
+}
+
+function releaseMonth(slug) {
+ const match = /^releases\/(\d{4})\.(\d{1,2})\.\d+(?:[-.][\w.-]+)?$/.exec(slug ?? "");
+ if (!match || Number(match[2]) < 1 || Number(match[2]) > 12) return null;
+ return `${match[1]}-${match[2].padStart(2, "0")}`;
+}
+
+// Group adjacent release pages without changing source order, links, or groups.
+export function releaseNavigationEntries(entries, locale, activeSlug) {
+ const firstMonth = flattenNavEntries(entries).map(page => releaseMonth(page.slug)).find(Boolean);
+ if (!firstMonth) return entries;
+ const selectedMonth = releaseMonth(activeSlug) ?? firstMonth;
+ const formatter = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", calendar: "gregory", timeZone: "UTC" });
+ function groupMonths(pages) {
+ const result = [];
+ for (const entry of pages) {
+ if (entry.group) {
+ result.push({ ...entry, pages: groupMonths(entry.pages) });
+ continue;
+ }
+ const month = releaseMonth(entry.slug);
+ if (!month) {
+ result.push(entry);
+ continue;
+ }
+ let group = result.at(-1);
+ if (group?.releaseMonth !== month) {
+ group = {
+ group: formatter.format(new Date(`${month}-01T00:00:00Z`)),
+ releaseMonth: month,
+ defaultOpen: month === selectedMonth,
+ pages: [],
+ };
+ result.push(group);
+ }
+ group.pages.push(entry);
+ }
+ return result;
+ }
+ return groupMonths(entries);
+}
diff --git a/scripts/docs-site/release-navigation.test.mjs b/scripts/docs-site/release-navigation.test.mjs
new file mode 100644
index 0000000000..97f40602b3
--- /dev/null
+++ b/scripts/docs-site/release-navigation.test.mjs
@@ -0,0 +1,128 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import test from "node:test";
+import { chromium } from "playwright";
+import { releaseNavigationEntries, patchReleaseSlugs, patchReleaseBadgeHtml } from "./release-navigation.mjs";
+import { homeStrings } from "./home-strings.mjs";
+import { flattenNavEntries } from "./navigation.mjs";
+import { fixture, write } from "./test-helpers/redirect-fixture.mjs";
+
+const slugs = ["releases/index", "releases/2026.11.3", "releases/2026.11.2", "releases/2026.10.10", "releases/2026.10.1", "releases/2026.9.9", "releases/2025.11.1", "changelog"];
+const entries = slugs.map(slug => ({ slug, locale: "en", title: slug }));
+
+test("release months preserve every source link and order without mutating navigation", () => {
+ const original = JSON.stringify(entries);
+ const grouped = releaseNavigationEntries(entries, "en", "releases/2026.10.1");
+ assert.deepEqual(flattenNavEntries(grouped), entries);
+ assert.equal(JSON.stringify(entries), original);
+ assert.deepEqual(grouped.filter(entry => entry.releaseMonth).map(entry => [entry.group, entry.defaultOpen]), [
+ ["November 2026", false], ["October 2026", true], ["September 2026", false], ["November 2025", false],
+ ]);
+ assert.equal(grouped[0], entries[0]);
+ assert.equal(grouped.at(-1), entries.at(-1));
+});
+
+test("overview defaults to the newest source month and labels follow the page locale", () => {
+ const grouped = releaseNavigationEntries(entries, "fr", "releases/index");
+ assert.equal(grouped[1].group, "novembre 2026");
+ assert.deepEqual(grouped.filter(entry => entry.defaultOpen).map(entry => entry.releaseMonth), ["2026-11"]);
+});
+
+test("localized month names retain the Gregorian release calendar", () => {
+ for (const [locale, expected] of [["fa", "ژوئن ۲۰۲۶"], ["th", "มิถุนายน 2026"]]) {
+ const grouped = releaseNavigationEntries([{ slug: "releases/2026.6.11" }], locale, "releases/2026.6.11");
+ assert.equal(grouped[0].group, expected);
+ assert.equal(grouped[0].releaseMonth, "2026-06");
+ assert.equal(grouped[0].defaultOpen, true);
+ assert.equal(grouped[0].pages[0].slug, "releases/2026.6.11");
+ }
+});
+
+test("nested source groups, non-release pages and invalid months are preserved", () => {
+ const other = { group: "Reference", pages: [{ slug: "guide/2026.11.1" }, { slug: "releases/2026.13.1" }] };
+ const source = [{ group: "Archive", pages: entries }, other];
+ const grouped = releaseNavigationEntries(source, "en", "releases/2025.11.1");
+ assert.deepEqual(flattenNavEntries(grouped), flattenNavEntries(source));
+ assert.equal(grouped[0].group, "Archive");
+ assert.equal(grouped[0].pages.at(-2).defaultOpen, true);
+ assert.deepEqual(grouped[1], other);
+});
+
+test("patch badges require a release-owned introduction rather than version guessing", () => {
+ const page = (slug, body, locale = "en") => ({ slug, body, locale });
+ assert.deepEqual([...patchReleaseSlugs([
+ page("releases/2026.11.3", "This is a patch release with no new features."),
+ page("releases/2026.11.1", "This patch release adds no new features."),
+ page("releases/2026.10.10", "New features.\n\n## History\nThis is a patch release with no new features."),
+ page("guide", "This is a patch release with no new features."),
+ page("releases/2026.11.2", "This is a patch release with no new features.", "fr"),
+ { ...page("releases/2026.10.2", "Fixes for updates."), description: "OpenClaw v2026.10.2 patch release notes" },
+ ])], ["releases/2026.11.3", "releases/2026.11.1", "releases/2026.10.2"]);
+ assert.match(patchReleaseBadgeHtml("fr"), />Correctif);
+ for (const locale of Object.keys(homeStrings)) assert.ok(!patchReleaseBadgeHtml(locale).includes("undefined"));
+});
+
+test("responsive release disclosures reveal the selected month on direct and PJAX navigation", async t => {
+ const browser = await chromium.launch({ headless: true });
+ t.after(() => browser.close());
+ const f = fixture(t, [], Object.fromEntries(slugs.map(slug => [`${slug}.md`, `---\ntitle: "${slug.startsWith("releases/202") ? `v${slug.slice(9)}` : slug}"\n---\n# Release fixture\n\n${["releases/2026.11.3", "releases/2026.10.1"].includes(slug) ? "This is a patch release with no new features.\n\n" : ""}[Newest](/releases/2026.11.3)\n`])));
+ write(f.root, "docs/docs.json", JSON.stringify({ navigation: { languages: [{ language: "en", tabs: [
+ { tab: "Get started", groups: [{ group: "Docs", pages: ["index"] }] },
+ { tab: "Releases", groups: [{ group: "Release notes", pages: slugs }] },
+ ] }] } }));
+ const built = f.build();
+ assert.equal(built.status, 0, built.stderr);
+ const site = path.join(f.root, "dist/docs-site");
+ for (const width of [390, 1440]) {
+ const context = await browser.newContext({ viewport: { width, height: 900 } });
+ context.setDefaultTimeout(5000);
+ await context.route("**/*", async route => {
+ const url = new URL(route.request().url());
+ if (url.origin !== "http://docs.test") return route.abort();
+ let file = path.join(site, url.pathname);
+ if (!path.extname(file) || fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html");
+ if (!fs.existsSync(file)) return route.fulfill({ status: 404, body: "Not found" });
+ const contentType = ({ ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2" })[path.extname(file)] || "application/octet-stream";
+ return route.fulfill({ contentType, body: fs.readFileSync(file) });
+ });
+ const page = await context.newPage();
+ await page.goto("http://docs.test/releases/2026.10.1");
+ await page.locator('.docs-sidebar-levels[data-level="section"]').waitFor({ state: "attached" });
+ if (width < 821) await page.locator("[data-nav-toggle]").click();
+ const months = page.locator(".release-nav-month");
+ assert.equal(await months.count(), 4);
+ assert.equal(await page.locator('.sidebar a[href="/releases/2026.10.1"] .release-patch-badge').textContent(), "Patch");
+ assert.equal(await page.locator('.sidebar a[href="/releases/2026.10.10"] .release-patch-badge').count(), 0);
+ assert.equal(await page.locator('.sidebar a[href="/releases/2026.11.3"] .release-entry-badge').count(), 1);
+ assert.deepEqual(await months.evaluateAll(nodes => nodes.map(node => node.open)), [false, true, false, false]);
+ assert.equal(await page.locator('.nav-link.active[href="/releases/2026.10.1"]').isVisible(), true);
+ // A month disclosure remains a keyboard-operable native control.
+ const november = months.nth(0).locator(":scope > summary");
+ await november.focus();
+ await page.keyboard.press("Enter");
+ assert.equal(await months.nth(0).evaluate(node => node.open), true);
+ await page.locator('.sidebar a[href="/releases/2026.11.3"]').click();
+ await page.waitForURL("**/releases/2026.11.3");
+ if (width < 821) await page.locator("[data-nav-toggle]").click();
+ assert.equal(await page.locator('.nav-link.active[href="/releases/2026.11.3"]').isVisible(), true);
+ assert.equal(await months.nth(0).evaluate(node => node.open), true);
+ // Navigating to a closed month through article content must reveal it too.
+ await page.goto("http://docs.test/releases/2026.9.9");
+ await page.locator('.docs-sidebar-levels[data-level="section"]').waitFor({ state: "attached" });
+ await page.locator(".doc a", { hasText: "Newest" }).click();
+ await page.waitForURL("**/releases/2026.11.3");
+ if (width < 821) await page.locator("[data-nav-toggle]").click();
+ assert.equal(await page.locator('.nav-link.active[href="/releases/2026.11.3"]').isVisible(), true);
+ await page.goto("http://docs.test/releases/2026.11.3");
+ await page.locator('.docs-sidebar-levels[data-level="section"]').waitFor({ state: "attached" });
+ if (width < 821) await page.locator("[data-nav-toggle]").click();
+ assert.deepEqual(await months.evaluateAll(nodes => nodes.map(node => node.open)), [true, false, false, false]);
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
+ if (process.env.RELEASE_NAV_ARTIFACT_DIR) {
+ fs.mkdirSync(process.env.RELEASE_NAV_ARTIFACT_DIR, { recursive: true });
+ await page.screenshot({ path: path.join(process.env.RELEASE_NAV_ARTIFACT_DIR, `release-months-${width}.png`) });
+ }
+ await context.close();
+ }
+});
diff --git a/scripts/docs-site/smoke.mjs b/scripts/docs-site/smoke.mjs
index f93808c105..e0a46f4dfd 100644
--- a/scripts/docs-site/smoke.mjs
+++ b/scripts/docs-site/smoke.mjs
@@ -558,6 +558,15 @@ if (docsSections?.attribs["aria-label"] !== homeStrings.en.navigation[6]
|| /class="(?:tabs|mobile-tabs)"/.test(index)) {
throw new Error("assets: complete vertical docs navigation is missing");
}
+const releaseLinks = DomUtils.findAll(node => node.name === "a"
+ && /^\/releases\/\d{4}\.\d{1,2}\.\d+$/.test((node.attribs.href ?? "").slice(basePath.length)), docsSections.children);
+for (const link of releaseLinks) {
+ let group = link.parent;
+ while (group && !group.attribs?.class?.split(" ").includes("release-nav-month")) group = group.parent;
+ if (!group || group.name !== "details" || !group.children.some(node => node.name === "summary")) {
+ throw new Error(`release navigation: version link must be inside a native month disclosure: ${link.attribs.href}`);
+ }
+}
if (!/function initChat/.test(siteJs)
|| !/data-chat-form/.test(siteJs)
|| !/new URL\("\/ask-molty\/sign-in",location\.href\)/.test(siteJs)