diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml
index 231e59b2..0157cda3 100644
--- a/.github/workflows/release-stable.yml
+++ b/.github/workflows/release-stable.yml
@@ -271,59 +271,13 @@ jobs:
curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
+ bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json
- cat > _site/index.html << 'HTMLEOF'
-
-
-
-
-
- Tolaria — Release History
-
-
-
- Tolaria Release History
- Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.
-
-
-
-
- HTMLEOF
-
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d50d8763..9fe39057 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -307,59 +307,13 @@ jobs:
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
+ bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json
- cat > _site/index.html << 'HTMLEOF'
-
-
-
-
-
- Tolaria — Release History
-
-
-
- Tolaria Release History
- Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.
-
-
-
-
- HTMLEOF
-
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
diff --git a/scripts/build-release-history-page.ts b/scripts/build-release-history-page.ts
new file mode 100644
index 00000000..b014de4e
--- /dev/null
+++ b/scripts/build-release-history-page.ts
@@ -0,0 +1,33 @@
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+
+import { buildReleaseHistoryPage } from '../src/utils/releaseHistoryPage'
+
+function getArg(flag: string): string {
+ const index = process.argv.indexOf(flag)
+ const value = index >= 0 ? process.argv[index + 1] : null
+
+ if (!value) {
+ throw new Error(`Missing required argument: ${flag}`)
+ }
+
+ return value
+}
+
+function readReleasePayload(filePath: string): unknown {
+ try {
+ return JSON.parse(readFileSync(filePath, 'utf8'))
+ } catch {
+ return []
+ }
+}
+
+const releasesJsonPath = resolve(getArg('--releases-json'))
+const outputFilePath = resolve(getArg('--output-file'))
+const releasesPayload = readReleasePayload(releasesJsonPath)
+const html = buildReleaseHistoryPage(releasesPayload)
+
+mkdirSync(dirname(outputFilePath), { recursive: true })
+writeFileSync(outputFilePath, html)
+
+console.log(`Release history page written to ${outputFilePath}`)
diff --git a/src/utils/releaseHistoryPage.test.ts b/src/utils/releaseHistoryPage.test.ts
new file mode 100644
index 00000000..b876bbf6
--- /dev/null
+++ b/src/utils/releaseHistoryPage.test.ts
@@ -0,0 +1,63 @@
+import { buildReleaseHistoryPage } from './releaseHistoryPage'
+
+describe('buildReleaseHistoryPage', () => {
+ it('renders markdown notes into separate stable and alpha panels with stable selected by default', () => {
+ const html = buildReleaseHistoryPage([
+ {
+ assets: [
+ {
+ browser_download_url: 'https://example.com/Tolaria.dmg',
+ name: 'Tolaria.dmg',
+ },
+ ],
+ body: '## Highlights\n\n- Faster startup\n- Better release notes',
+ html_url: 'https://github.com/refactoringhq/tolaria/releases/tag/stable-v2026.4.19',
+ name: 'Tolaria Stable 2026.4.19',
+ prerelease: false,
+ published_at: '2026-04-19T11:00:00Z',
+ tag_name: 'stable-v2026.4.19',
+ },
+ {
+ assets: [
+ {
+ browser_download_url: 'https://example.com/Tolaria.app.tar.gz',
+ name: 'Tolaria.app.tar.gz',
+ },
+ ],
+ body: '**Alpha** notes with [details](https://example.com/details).',
+ html_url: 'https://github.com/refactoringhq/tolaria/releases/tag/2026.4.19-alpha.1',
+ name: 'Alpha 2026.4.19.1',
+ prerelease: true,
+ published_at: '2026-04-19T10:00:00Z',
+ tag_name: '2026.4.19-alpha.1',
+ },
+ ])
+
+ expect(html).toContain('role="tablist"')
+ expect(html).toContain('id="tab-stable"')
+ expect(html).toContain('aria-selected="true"')
+ expect(html).toContain('data-release-panel="alpha" hidden')
+ expect(html).toContain('Highlights
')
+ expect(html).toContain('Faster startup')
+ expect(html).toContain('Alpha notes')
+ expect(html).toContain('Tolaria.app.tar.gz')
+ expect(html).toContain('View on GitHub')
+ })
+
+ it('filters draft releases and shows an empty state for channels without published builds', () => {
+ const html = buildReleaseHistoryPage([
+ {
+ body: 'Draft release',
+ draft: true,
+ name: 'Draft release',
+ prerelease: false,
+ published_at: '2026-04-19T11:00:00Z',
+ tag_name: 'stable-v2026.4.19',
+ },
+ ])
+
+ expect(html).not.toContain('Draft release')
+ expect(html).toContain('No stable releases published yet.')
+ expect(html).toContain('No alpha releases published yet.')
+ })
+})
diff --git a/src/utils/releaseHistoryPage.ts b/src/utils/releaseHistoryPage.ts
new file mode 100644
index 00000000..0498be43
--- /dev/null
+++ b/src/utils/releaseHistoryPage.ts
@@ -0,0 +1,586 @@
+import { createElement } from 'react'
+import Markdown from 'react-markdown'
+import { renderToStaticMarkup } from 'react-dom/server'
+import remarkGfm from 'remark-gfm'
+
+type ReleaseAssetPayload = {
+ browser_download_url?: unknown
+ name?: unknown
+}
+
+type GitHubReleasePayload = {
+ assets?: ReleaseAssetPayload[]
+ body?: unknown
+ draft?: unknown
+ html_url?: unknown
+ name?: unknown
+ prerelease?: unknown
+ published_at?: unknown
+ tag_name?: unknown
+}
+
+type ReleaseChannel = 'stable' | 'alpha'
+
+type ReleaseDownload = {
+ label: string
+ url: string
+}
+
+type ReleaseEntry = {
+ downloads: ReleaseDownload[]
+ githubUrl: string | null
+ notesHtml: string
+ publishedLabel: string
+ tagName: string
+ title: string
+}
+
+type ReleaseSections = Record
+
+const RELEASE_HISTORY_PAGE_STYLES = `
+ :root {
+ color-scheme: light;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ }
+
+ * {
+ box-sizing: border-box;
+ }
+
+ body {
+ margin: 0;
+ min-height: 100vh;
+ background: #f7f6f3;
+ color: #37352f;
+ padding: 32px 20px 48px;
+ }
+
+ main {
+ width: min(100%, 840px);
+ margin: 0 auto;
+ }
+
+ header {
+ margin-bottom: 24px;
+ }
+
+ h1 {
+ margin: 0 0 8px;
+ font-size: clamp(2rem, 4vw, 2.5rem);
+ line-height: 1.1;
+ }
+
+ .subtitle,
+ .keyboard-hint {
+ margin: 0;
+ color: #787774;
+ line-height: 1.6;
+ }
+
+ .keyboard-hint {
+ margin-top: 8px;
+ font-size: 0.9375rem;
+ }
+
+ .channel-tabs {
+ margin-bottom: 24px;
+ border-bottom: 1px solid #e9e9e7;
+ }
+
+ .channel-tablist {
+ display: inline-flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-bottom: -1px;
+ }
+
+ .channel-tab {
+ appearance: none;
+ border: 1px solid transparent;
+ border-bottom: none;
+ border-radius: 12px 12px 0 0;
+ background: transparent;
+ color: #5e5c57;
+ cursor: pointer;
+ font: inherit;
+ font-weight: 600;
+ padding: 12px 16px;
+ }
+
+ .channel-tab:hover {
+ background: rgba(21, 93, 255, 0.06);
+ color: #155dff;
+ }
+
+ .channel-tab:focus-visible {
+ outline: 2px solid #155dff;
+ outline-offset: 2px;
+ }
+
+ .channel-tab[aria-selected="true"] {
+ background: #ffffff;
+ border-color: #d8e3ff;
+ color: #155dff;
+ box-shadow: 0 -1px 0 #ffffff inset;
+ }
+
+ .tab-count {
+ color: #787774;
+ font-weight: 500;
+ margin-left: 6px;
+ }
+
+ .release-panel {
+ display: grid;
+ gap: 16px;
+ outline: none;
+ }
+
+ .release-panel[hidden] {
+ display: none;
+ }
+
+ .release-card {
+ background: #ffffff;
+ border: 1px solid #e9e9e7;
+ border-radius: 18px;
+ padding: 20px 22px;
+ box-shadow: 0 16px 40px rgba(15, 23, 42, 0.05);
+ }
+
+ .release-card--alpha {
+ border-left: 4px solid #f59e0b;
+ }
+
+ .release-card--stable {
+ border-left: 4px solid #155dff;
+ }
+
+ .release-header {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 14px;
+ }
+
+ .release-header h2 {
+ margin: 0;
+ font-size: 1.25rem;
+ line-height: 1.25;
+ }
+
+ .release-meta {
+ margin: 4px 0 0;
+ color: #787774;
+ font-size: 0.9375rem;
+ }
+
+ .release-channel {
+ align-self: start;
+ background: #f1f5ff;
+ border-radius: 999px;
+ color: #155dff;
+ font-size: 0.8125rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ padding: 6px 10px;
+ text-transform: uppercase;
+ }
+
+ .release-card--alpha .release-channel {
+ background: #fff3d6;
+ color: #b45309;
+ }
+
+ .release-notes {
+ color: #44403c;
+ font-size: 0.98rem;
+ line-height: 1.7;
+ }
+
+ .release-notes > :first-child {
+ margin-top: 0;
+ }
+
+ .release-notes > :last-child {
+ margin-bottom: 0;
+ }
+
+ .release-notes h1,
+ .release-notes h2,
+ .release-notes h3 {
+ line-height: 1.25;
+ margin: 1.2em 0 0.4em;
+ }
+
+ .release-notes p,
+ .release-notes ul,
+ .release-notes ol,
+ .release-notes blockquote,
+ .release-notes pre {
+ margin: 0 0 1em;
+ }
+
+ .release-notes ul,
+ .release-notes ol {
+ padding-left: 1.4rem;
+ }
+
+ .release-notes code {
+ background: #f4f4f2;
+ border-radius: 6px;
+ padding: 0.12em 0.35em;
+ }
+
+ .release-notes pre {
+ overflow-x: auto;
+ background: #f4f4f2;
+ border-radius: 12px;
+ padding: 14px;
+ }
+
+ .release-notes pre code {
+ background: transparent;
+ padding: 0;
+ }
+
+ .release-notes blockquote {
+ border-left: 3px solid #d6d3d1;
+ color: #57534e;
+ padding-left: 14px;
+ }
+
+ .release-notes a,
+ .release-downloads a {
+ color: #155dff;
+ text-decoration-thickness: 0.08em;
+ text-underline-offset: 0.18em;
+ }
+
+ .release-downloads {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 18px;
+ }
+
+ .release-downloads a {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 42px;
+ padding: 0 14px;
+ border-radius: 10px;
+ background: #155dff;
+ color: #ffffff;
+ font-weight: 600;
+ text-decoration: none;
+ }
+
+ .release-downloads a[data-secondary="true"] {
+ background: #ebeef5;
+ color: #37352f;
+ }
+
+ .release-downloads a:hover,
+ .release-downloads a:focus-visible {
+ filter: brightness(0.96);
+ }
+
+ .release-downloads a:focus-visible {
+ outline: 2px solid #155dff;
+ outline-offset: 2px;
+ }
+
+ .empty-state {
+ background: #ffffff;
+ border: 1px dashed #d6d3d1;
+ border-radius: 18px;
+ color: #787774;
+ padding: 28px 24px;
+ text-align: center;
+ }
+
+ @media (max-width: 640px) {
+ body {
+ padding-inline: 16px;
+ }
+
+ .release-card {
+ padding: 18px;
+ }
+
+ .channel-tablist {
+ width: 100%;
+ }
+
+ .channel-tab {
+ flex: 1 1 180px;
+ text-align: center;
+ }
+ }
+`
+
+const RELEASE_HISTORY_PAGE_SCRIPT = `
+ (() => {
+ const tabs = Array.from(document.querySelectorAll('[data-release-tab]'));
+ const panels = Array.from(document.querySelectorAll('[data-release-panel]'));
+ if (!tabs.length || !panels.length) return;
+
+ const activateTab = (nextTab) => {
+ const nextChannel = nextTab.getAttribute('data-release-tab');
+ tabs.forEach((tab) => {
+ const selected = tab === nextTab;
+ tab.setAttribute('aria-selected', String(selected));
+ tab.tabIndex = selected ? 0 : -1;
+ });
+ panels.forEach((panel) => {
+ panel.hidden = panel.getAttribute('data-release-panel') !== nextChannel;
+ });
+ };
+
+ const moveFocus = (currentIndex, offset) => {
+ const nextIndex = (currentIndex + offset + tabs.length) % tabs.length;
+ tabs[nextIndex].focus();
+ activateTab(tabs[nextIndex]);
+ };
+
+ const activateFocusedTab = (tab) => {
+ tab.focus();
+ activateTab(tab);
+ };
+
+ const handleTabKeydown = (event, tab, index) => {
+ const keyActions = {
+ ' ': () => activateTab(tab),
+ ArrowDown: () => moveFocus(index, 1),
+ ArrowLeft: () => moveFocus(index, -1),
+ ArrowRight: () => moveFocus(index, 1),
+ ArrowUp: () => moveFocus(index, -1),
+ End: () => activateFocusedTab(tabs[tabs.length - 1]),
+ Enter: () => activateTab(tab),
+ Home: () => activateFocusedTab(tabs[0]),
+ };
+ const action = keyActions[event.key];
+ if (!action) return;
+
+ event.preventDefault();
+ action();
+ };
+
+ tabs.forEach((tab, index) => {
+ tab.addEventListener('click', () => activateTab(tab));
+ tab.addEventListener('keydown', (event) => handleTabKeydown(event, tab, index));
+ });
+ })();
+`
+
+const MARKDOWN_REMARK_PLUGINS = [remarkGfm]
+const RELEASE_CHANNEL_LABELS: Record = {
+ alpha: 'Alpha',
+ stable: 'Stable',
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+}
+
+function normalizeText(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+ const trimmedValue = value.trim()
+ return trimmedValue.length > 0 ? trimmedValue : null
+}
+
+function normalizeUrl(value: unknown): string | null {
+ const text = normalizeText(value)
+ if (text === null) return null
+
+ try {
+ const parsedUrl = new URL(text)
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') return null
+ return parsedUrl.toString()
+ } catch {
+ return null
+ }
+}
+
+function formatPublishedLabel(value: unknown): string {
+ const text = normalizeText(value)
+ if (text === null) return 'Unknown publish date'
+
+ const publishedAt = new Date(text)
+ if (Number.isNaN(publishedAt.getTime())) return 'Unknown publish date'
+
+ return publishedAt.toLocaleDateString('en-US', {
+ day: 'numeric',
+ month: 'long',
+ timeZone: 'UTC',
+ year: 'numeric',
+ })
+}
+
+function isDownloadableAsset(name: string): boolean {
+ return name.endsWith('.dmg') || name.endsWith('.app.tar.gz') || name.endsWith('.zip')
+}
+
+function normalizeDownloads(assets: ReleaseAssetPayload[] | undefined): ReleaseDownload[] {
+ if (!Array.isArray(assets)) return []
+
+ const seenUrls = new Set()
+ const downloads: ReleaseDownload[] = []
+
+ for (const asset of assets) {
+ const name = normalizeText(asset.name)
+ const url = normalizeUrl(asset.browser_download_url)
+ if (name === null || url === null || !isDownloadableAsset(name) || seenUrls.has(url)) continue
+
+ seenUrls.add(url)
+ downloads.push({ label: name, url })
+ }
+
+ return downloads
+}
+
+function renderReleaseNotes(markdown: string): string {
+ return renderToStaticMarkup(createElement(Markdown, { remarkPlugins: MARKDOWN_REMARK_PLUGINS }, markdown))
+}
+
+function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel, ReleaseEntry] | null {
+ if (release.draft === true) return null
+
+ const title = normalizeText(release.name) ?? normalizeText(release.tag_name) ?? 'Untitled release'
+ const tagName = normalizeText(release.tag_name) ?? 'Unknown tag'
+ const notes = normalizeText(release.body) ?? 'No release notes provided.'
+ const channel: ReleaseChannel = release.prerelease === true ? 'alpha' : 'stable'
+
+ return [channel, {
+ downloads: normalizeDownloads(release.assets),
+ githubUrl: normalizeUrl(release.html_url),
+ notesHtml: renderReleaseNotes(notes),
+ publishedLabel: formatPublishedLabel(release.published_at),
+ tagName,
+ title,
+ }]
+}
+
+function collectReleaseSections(payload: unknown): ReleaseSections {
+ const sections: ReleaseSections = { alpha: [], stable: [] }
+ if (!Array.isArray(payload)) return sections
+
+ for (const item of payload) {
+ if (!item || typeof item !== 'object') continue
+
+ const normalizedRelease = normalizeReleaseEntry(item as GitHubReleasePayload)
+ if (normalizedRelease === null) continue
+
+ const [channel, release] = normalizedRelease
+ sections[channel].push(release)
+ }
+
+ return sections
+}
+
+function buildTabMarkup(channel: ReleaseChannel, count: number, selected: boolean): string {
+ const label = RELEASE_CHANNEL_LABELS[channel]
+ return `
+ `
+}
+
+function buildReleaseMarkup(channel: ReleaseChannel, release: ReleaseEntry): string {
+ const downloads = [...release.downloads]
+ if (release.githubUrl !== null) {
+ downloads.push({ label: 'View on GitHub', url: release.githubUrl })
+ }
+
+ const channelLabel = RELEASE_CHANNEL_LABELS[channel]
+ const downloadsMarkup = downloads.length > 0
+ ? `
+
+ ${downloads.map(download => {
+ const isSecondary = download.label === 'View on GitHub'
+ return `
${escapeHtml(download.label)}`
+ }).join('')}
+
`
+ : ''
+
+ return `
+
+
+ ${release.notesHtml}
${downloadsMarkup}
+ `
+}
+
+function buildPanelMarkup(channel: ReleaseChannel, releases: ReleaseEntry[], selected: boolean): string {
+ const releasesMarkup = releases.length > 0
+ ? releases.map(release => buildReleaseMarkup(channel, release)).join('')
+ : `No ${channel} releases published yet.
`
+
+ return `
+ `
+}
+
+export function buildReleaseHistoryPage(releasesPayload: unknown): string {
+ const sections = collectReleaseSections(releasesPayload)
+
+ return `
+
+
+
+
+ Tolaria — Release History
+
+
+
+
+
+ Tolaria Release History
+ Stable builds appear when a stable-vYYYY.M.D tag is promoted. Alpha builds update on every push to main.
+ Use Tab to reach the channel picker, then use the arrow keys to switch between Stable and Alpha.
+
+
+
+ ${buildTabMarkup('stable', sections.stable.length, true)}
+ ${buildTabMarkup('alpha', sections.alpha.length, false)}
+
+
+ ${buildPanelMarkup('stable', sections.stable, true)}
+ ${buildPanelMarkup('alpha', sections.alpha, false)}
+
+
+
+
+`
+}