- {filteredEntries.map((entry, index) => {
- const isLatest =
- index === 0 && activeCategory === "All" && !search;
+ {/* 6. Release Timeline Stream */}
+ {filteredEntries.length === 0 ? (
+
+ {filteredEntries.map((entry, index) => {
+ const isLatest =
+ index === 0 &&
+ activeCategory === "All" &&
+ activeChannel === "All" &&
+ !search;
+
+ if (entry.isMilestone) {
+ return (
+
+
+
+
+ Architecture Milestone
+
+
+ {formatDate(entry.date)}
+
+
+
+
+
+ {entry.title}
+
+
+
+
+ {entry.summary}
+
+
+ {entry.highlights &&
+ entry.highlights.length > 0 && (
+
+ {entry.highlights.map(
+ (h, hIdx) => (
+
+
+
+ {h}
+
+
+ )
+ )}
+
+ )}
+
+
+
+
+ Read architectural overview
+
+
+
+
+
+
+ );
+ }
+
+ const isDeprecated = isDeprecatedEntry(entry);
- if (entry.isMilestone) {
return (
-
-
-
- Architecture Milestone
-
-
- May 2026
-
-
-
-
-
- {entry.title}
-
-
-
-
- {entry.summary}
-
-
- {entry.highlights &&
- entry.highlights.length > 0 && (
-
- {entry.highlights.map(
- (h, hIdx) => (
-
-
-
- {h}
-
-
- )
+
+ {/* Left Column: Metadata & Category Node */}
+
+
+
+ {isDeprecated && (
+
+ Deprecated
+
+ )}
+ {entry.isYanked ? (
+
+ Yanked
+
+ ) : (
+
+ {entry.releaseType}
+
+ )}
+ {isLatest && (
+
+ Latest
+
+ )}
+ {entry.isMajor && !isLatest && (
+
+ Major
+
)}
- )}
-
-
-
- Read architectural overview
-
-
-
-
-
-
- );
- }
-
- const isDeprecated = isDeprecatedEntry(entry);
-
- return (
-
-
- {/* Left Column: Metadata & Category Node */}
-
-
-
- {isDeprecated && (
-
- Deprecated
-
- )}
- {entry.isYanked ? (
-
- Yanked
-
- ) : (
+
- {entry.releaseType}
+ {entry.version}
- )}
- {isLatest && (
-
- Latest
+
+
+
+
+
+ {formatDate(entry.date)}
+
+
+
+
+
+
+
+ {entry.category}
+
+
+
+ {entry.internalDetails && (
+
+ {entry.internalDetails.expoSdk && (
+
Expo SDK {entry.internalDetails.expoSdk}
+ )}
+ {entry.internalDetails.minAndroid && (
+
Android {entry.internalDetails.minAndroid.split(" ")[0]}+
+ )}
+
)}
- {entry.isMajor && !isLatest && (
-
- Major
-
+
+ {entry.githubUrl && (
+
+
+
+ GitHub Release
+
+
+
+
)}
+
-
-
+
+
- {entry.version}
-
-
-
-
-
-
- {new Date(
- entry.date
- ).toLocaleDateString(
- "en-US",
- {
- year: "numeric",
- month: "short",
- day: "numeric",
- }
- )}
-
-
-
-
-
-
-
- {entry.category}
-
-
-
+ {entry.title}
+
+
- {entry.internalDetails && (
-
- {entry.internalDetails.expoSdk && (
-
Expo SDK {entry.internalDetails.expoSdk}
- )}
- {entry.internalDetails.minAndroid && (
-
Android {entry.internalDetails.minAndroid.split(" ")[0]}+
- )}
+ {isDeprecated && (
+
+
+
+ {entry.deprecatedReason ||
+ "This release is no longer supported. Superseded by the newer database architecture introduced in later releases. Please use a current release instead."}
+
)}
- {entry.githubUrl && (
-
-
-
- GitHub Release
-
-
-
+ {entry.isYanked && entry.yankedReason && (
+
+ Release Notice:
+ {entry.yankedReason}
)}
-
-
-
- {/* Right Column: Editorial Summary & Highlights */}
-
-
-
- {entry.title}
-
-
-
- {isDeprecated && (
-
-
-
- {entry.deprecatedReason ||
- "This release is no longer supported. Superseded by the newer database architecture introduced in later releases. Please use a current release instead."}
-
-
- )}
- {entry.isYanked && entry.yankedReason && (
-
- Release Notice:
- {entry.yankedReason}
-
- )}
+
+ {entry.summary}
+
-
- {entry.summary}
-
-
- {/* Highlights Bullet List */}
- {entry.highlights &&
- entry.highlights.length > 0 && (
-
- {entry.highlights.map(
- (highlight, hIdx) => (
-
-
-
- {highlight}
-
-
- )
- )}
-
- )}
+ {/* Highlights Bullet List */}
+ {entry.highlights &&
+ entry.highlights.length > 0 && (
+
+ {entry.highlights.map(
+ (highlight, hIdx) => (
+
+
+
+ {highlight}
+
+
+ )
+ )}
+
+ )}
- {/* Link to detail page */}
-
-
-
- Read full release notes
-
-
-
+ {/* Link to detail page */}
+
+
+
+ Read full release notes
+
+
+
+
-
-
-
- );
- })}
-
- )}
+
+
+ );
+ })}
+
+ )}
+
);
}
diff --git a/src/components/changelog/CurrentReleaseHero.tsx b/src/components/changelog/CurrentReleaseHero.tsx
new file mode 100644
index 0000000..1cb6dfe
--- /dev/null
+++ b/src/components/changelog/CurrentReleaseHero.tsx
@@ -0,0 +1,148 @@
+import Link from "next/link";
+import {
+ Download,
+ ArrowRight,
+ ExternalLink,
+ Calendar,
+ CheckCircle2,
+} from "lucide-react";
+import type { ChangelogEntry } from "@/lib/changelog";
+import { formatDate } from "@passcodes/passalgo";
+
+interface CurrentReleaseHeroProps {
+ release: ChangelogEntry;
+}
+
+export function CurrentReleaseHero({ release }: CurrentReleaseHeroProps) {
+ const highlights = release.highlights?.slice(0, 4) || [];
+
+ return (
+
+ {/* Ambient subtle glow */}
+
+
+
+ {/* Header & Badges */}
+
+
+
+
+ Current Production Release
+
+
+ {release.releaseType}
+
+
+ {release.version}
+
+
+
+
+
+
+ {formatDate(release.date)}
+
+
+
+
+ {/* Title & Editorial Summary */}
+
+
+ {release.title}
+
+
+ {release.summary}
+
+
+
+ {/* Important Highlights */}
+ {highlights.length > 0 && (
+
+
+
+ Key Highlights in this Release
+
+
+ {highlights.map((item, idx) => (
+
+
+ {item}
+
+ ))}
+
+
+ )}
+
+ {/* Build Specifications Pill Group */}
+ {release.internalDetails && (
+
+ Specifications:
+ {release.internalDetails.expoSdk && (
+
+ Expo SDK {release.internalDetails.expoSdk}
+
+ )}
+ {release.internalDetails.minAndroid && (
+
+ Android {release.internalDetails.minAndroid.split(" ")[0]}+
+
+ )}
+ {release.internalDetails.masterDbVersion && (
+
+ DB Schema {release.internalDetails.masterDbVersion}
+
+ )}
+ {release.internalDetails.packageName && (
+
+ {release.internalDetails.packageName}
+
+ )}
+
+ )}
+
+ {/* Actions */}
+
+
+
+
Get {release.version} APK
+
+
+
+
Release Details
+
+
+
+ {release.githubUrl && (
+
+
GitHub Release
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/changelog/MilestonesSection.tsx b/src/components/changelog/MilestonesSection.tsx
new file mode 100644
index 0000000..1b2fded
--- /dev/null
+++ b/src/components/changelog/MilestonesSection.tsx
@@ -0,0 +1,108 @@
+import Link from "next/link";
+import { ArrowRight, Layers, Milestone, GitCommit } from "lucide-react";
+import type { ChangelogEntry } from "@/lib/changelog";
+import { formatDate } from "@passcodes/passalgo";
+import { isDeprecatedEntry } from "@/lib/changelog";
+
+interface MilestonesSectionProps {
+ milestones: ChangelogEntry[];
+}
+
+export function MilestonesSection({ milestones }: MilestonesSectionProps) {
+ if (milestones.length === 0) return null;
+
+ return (
+
+
+
+ Architecture & History
+
+
+ Major Architectural Milestones
+
+
+ Documented architectural shifts and foundational transitions across Passcodes' open-source history.
+
+
+
+
+ {milestones.map((milestone) => {
+ const isExplicitMilestone = milestone.isMilestone;
+ const isDeprecated = isDeprecatedEntry(milestone);
+
+ return (
+
+
+
+ {isExplicitMilestone ? (
+
+ Architecture Shift
+
+ ) : (
+
+ Milestone
+
+ )}
+
+ {isDeprecated && (
+
+ Deprecated
+
+ )}
+
+
+ {milestone.version}
+
+
+
+ · {formatDate(milestone.date)}
+
+
+
+
+
+ {milestone.title}
+
+
+
+
+ {milestone.summary}
+
+
+ {milestone.highlights && milestone.highlights.length > 0 && (
+
+ {milestone.highlights.slice(0, 2).map((item, idx) => (
+
+
+ {item}
+
+ ))}
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/changelog/RecentUpdatesGrid.tsx b/src/components/changelog/RecentUpdatesGrid.tsx
new file mode 100644
index 0000000..dce540f
--- /dev/null
+++ b/src/components/changelog/RecentUpdatesGrid.tsx
@@ -0,0 +1,116 @@
+import Link from "next/link";
+import { ArrowRight, Calendar } from "lucide-react";
+import type { ChangelogEntry } from "@/lib/changelog";
+import { ReleaseVisual } from "@/components/visuals/ReleaseVisual";
+import { formatDate } from "@passcodes/passalgo";
+import { cn } from "@/lib/utils";
+
+interface RecentUpdatesGridProps {
+ entries: ChangelogEntry[];
+}
+
+export function RecentUpdatesGrid({ entries }: RecentUpdatesGridProps) {
+ if (entries.length === 0) return null;
+
+ return (
+
+
+
+ What's New
+
+
+ Recent Updates
+
+
+ The latest stable releases, iterative improvements, and maintenance builds.
+
+
+
+
+ {entries.map((entry) => (
+
+
+ {/* Card Top: Channel, Date, Category */}
+
+
+
+ {entry.releaseType}
+
+
+ {entry.version}
+
+
+
+
+
+
+
+
+ {/* Date */}
+
+
+
+ {formatDate(entry.date)}
+
+
+
+ {/* Title */}
+
+
+ {entry.title}
+
+
+
+ {/* Summary */}
+
+ {entry.summary}
+
+
+ {/* Highlights Preview */}
+ {entry.highlights && entry.highlights.length > 0 && (
+
+ {entry.highlights.slice(0, 2).map((h, i) => (
+
+
+ {h}
+
+ ))}
+
+ )}
+
+
+ {/* Card Footer Link */}
+
+
+
Read release notes
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/data/normalized-releases.json b/src/data/normalized-releases.json
index 3a8a49f..b09e12c 100644
--- a/src/data/normalized-releases.json
+++ b/src/data/normalized-releases.json
@@ -50,7 +50,7 @@
"isYanked": false,
"isDeprecated": false,
"assetsCount": 5,
- "downloadCount": 31
+ "downloadCount": 43
},
{
"slug": "v3-2-0-stable",
@@ -266,7 +266,7 @@
"isDeprecated": true,
"deprecatedReason": "Superseded by the newer database architecture introduced in later releases.",
"assetsCount": 6,
- "downloadCount": 37
+ "downloadCount": 38
},
{
"slug": "v2-1-0-beta",
@@ -573,7 +573,7 @@
"isDeprecated": true,
"deprecatedReason": "Superseded by the newer database architecture introduced in later releases.",
"assetsCount": 6,
- "downloadCount": 56
+ "downloadCount": 57
},
{
"slug": "v1-1-0-alpha",
diff --git a/src/data/raw-releases.json b/src/data/raw-releases.json
index c590da0..18d1c34 100644
--- a/src/data/raw-releases.json
+++ b/src/data/raw-releases.json
@@ -68,7 +68,7 @@
"state": "uploaded",
"size": 35922426,
"digest": "sha256:b8d20f36f9b87dcc31e5ca10ab4b0d1db7f485cf4907bfc39f53e811daa997b8",
- "download_count": 1,
+ "download_count": 3,
"created_at": "2026-08-02T14:54:35Z",
"updated_at": "2026-08-02T15:02:05Z",
"browser_download_url": "https://github.com/PasscodesApp/Passcodes/releases/download/v3.2.1/v3.2.1-passcodes-x86_64-app.apk"
@@ -104,7 +104,7 @@
"state": "uploaded",
"size": 36455085,
"digest": "sha256:75efa1becac99053b7e144f9d4a83ca32941ee2f90275ad2707dadee6e44c453",
- "download_count": 0,
+ "download_count": 2,
"created_at": "2026-08-02T14:56:24Z",
"updated_at": "2026-08-02T15:02:05Z",
"browser_download_url": "https://github.com/PasscodesApp/Passcodes/releases/download/v3.2.1/v3.2.1-passcodes-x86-app.apk"
@@ -176,7 +176,7 @@
"state": "uploaded",
"size": 28447625,
"digest": "sha256:110e35124036cd19002f3d838f0b559e3840c4b5fd8ec4a087e9fd1862174411",
- "download_count": 1,
+ "download_count": 4,
"created_at": "2026-08-02T15:00:28Z",
"updated_at": "2026-08-02T15:02:05Z",
"browser_download_url": "https://github.com/PasscodesApp/Passcodes/releases/download/v3.2.1/v3.2.1-passcodes-armeabi_v7a-app.apk"
@@ -212,7 +212,7 @@
"state": "uploaded",
"size": 35428199,
"digest": "sha256:1db1e993f9421007bab6601e16453d90883384608345be3bff351aefc2db7f96",
- "download_count": 27,
+ "download_count": 32,
"created_at": "2026-08-02T15:00:58Z",
"updated_at": "2026-08-02T15:02:05Z",
"browser_download_url": "https://github.com/PasscodesApp/Passcodes/releases/download/v3.2.1/v3.2.1-passcodes-arm64_v8a-app.apk"
@@ -1072,7 +1072,7 @@
"state": "uploaded",
"size": 9239174,
"digest": "sha256:d21a45f18f27157fa2b6a4b38f2fad3a136de36d1a4f074dc5601b503047a3e0",
- "download_count": 23,
+ "download_count": 24,
"created_at": "2026-05-19T13:32:39Z",
"updated_at": "2026-05-19T13:32:48Z",
"browser_download_url": "https://github.com/PasscodesApp/Passcodes/releases/download/v2.1.1/v2.1.1-passcodes-app-universal-release.apk"
@@ -2716,7 +2716,7 @@
"state": "uploaded",
"size": 3101486,
"digest": "sha256:7ec6fb3c60b6f5b3f7844ba7fa7a386825b9d35faaae7eff833b8f41022c7c7d",
- "download_count": 38,
+ "download_count": 39,
"created_at": "2025-09-10T14:44:46Z",
"updated_at": "2025-09-10T14:44:54Z",
"browser_download_url": "https://github.com/PasscodesApp/Passcodes/releases/download/v1.1.1/app-universal-release.apk"
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
index e8b484b..3de2018 100644
--- a/src/lib/changelog.ts
+++ b/src/lib/changelog.ts
@@ -106,3 +106,158 @@ export function isDeprecatedEntry(entry: ChangelogEntry): boolean {
const major = parseInt(match[1], 10);
return major === 1 || major === 2;
}
+
+export type ReleaseState =
+ | "Stable"
+ | "Beta"
+ | "Alpha"
+ | "Deprecated"
+ | "Yanked"
+ | "Milestone";
+
+/**
+ * Classify a changelog entry into its primary semantic release state.
+ */
+export function getReleaseState(entry: ChangelogEntry): ReleaseState {
+ if (entry.isMilestone) return "Milestone";
+ if (entry.isYanked) return "Yanked";
+ if (isDeprecatedEntry(entry)) return "Deprecated";
+ return entry.releaseType;
+}
+
+/**
+ * Get the current stable production release.
+ * Excludes milestones, yanked releases, deprecated releases, and non-stable channels.
+ */
+export function getLatestProductionRelease(): ChangelogEntry | undefined {
+ return CHANGELOG_ENTRIES.find(
+ (entry) =>
+ !entry.isMilestone &&
+ !entry.isYanked &&
+ !isDeprecatedEntry(entry) &&
+ entry.releaseType === "Stable"
+ );
+}
+
+/**
+ * Get the most recent meaningful updates (active, non-milestone releases).
+ */
+export function getRecentUpdates(count: number = 3): ChangelogEntry[] {
+ return CHANGELOG_ENTRIES.filter(
+ (entry) =>
+ !entry.isMilestone &&
+ !isDeprecatedEntry(entry) &&
+ !entry.isYanked
+ ).slice(0, count);
+}
+
+/**
+ * Get documented major architectural milestones.
+ * Includes the explicit architecture transition milestone and major version baseline releases.
+ */
+export function getProjectMilestones(): ChangelogEntry[] {
+ return CHANGELOG_ENTRIES.filter(
+ (entry) =>
+ entry.isMilestone ||
+ (entry.isMajor &&
+ ["v3.0.0", "v2.0.0", "v1.0.0"].includes(entry.version))
+ );
+}
+
+export const RELEASE_CHANNEL_FILTERS = [
+ "All",
+ "Stable",
+ "Beta",
+ "Alpha",
+ "Milestones",
+ "Deprecated",
+ "Yanked",
+] as const;
+
+export type ReleaseChannelFilter = (typeof RELEASE_CHANNEL_FILTERS)[number];
+
+/**
+ * Search across version, title, summary, highlights, technical details, and release state.
+ */
+export function matchesReleaseSearch(
+ entry: ChangelogEntry,
+ query: string
+): boolean {
+ const searchLower = query.trim().toLowerCase();
+ if (!searchLower) return true;
+
+ const state = getReleaseState(entry).toLowerCase();
+ const releaseType = entry.releaseType.toLowerCase();
+ const isDep = isDeprecatedEntry(entry) ? "deprecated" : "";
+ const isYank = entry.isYanked ? "yanked" : "";
+ const isMs = entry.isMilestone ? "milestone" : "";
+
+ // Version, title, summary, tldr
+ if (entry.version.toLowerCase().includes(searchLower)) return true;
+ if (entry.title.toLowerCase().includes(searchLower)) return true;
+ if (entry.summary.toLowerCase().includes(searchLower)) return true;
+ if (entry.tldr?.toLowerCase().includes(searchLower)) return true;
+
+ // Highlights
+ if (entry.highlights?.some((h) => h.toLowerCase().includes(searchLower))) {
+ return true;
+ }
+
+ // Sections
+ if (
+ entry.sections?.some(
+ (s) =>
+ s.title.toLowerCase().includes(searchLower) ||
+ s.items.some((item) =>
+ item.toLowerCase().includes(searchLower)
+ )
+ )
+ ) {
+ return true;
+ }
+
+ // Release states & channels
+ if (
+ state.includes(searchLower) ||
+ releaseType.includes(searchLower) ||
+ isDep.includes(searchLower) ||
+ isYank.includes(searchLower) ||
+ isMs.includes(searchLower)
+ ) {
+ return true;
+ }
+
+ // Technical build & runtime details
+ if (entry.internalDetails) {
+ const details = entry.internalDetails;
+ if (details.packageName?.toLowerCase().includes(searchLower)) {
+ return true;
+ }
+ if (details.versionName?.toLowerCase().includes(searchLower)) {
+ return true;
+ }
+ if (details.masterDbVersion?.toLowerCase().includes(searchLower)) {
+ return true;
+ }
+ if (details.minAndroid?.toLowerCase().includes(searchLower)) {
+ return true;
+ }
+ if (details.maxAndroid?.toLowerCase().includes(searchLower)) {
+ return true;
+ }
+ if (
+ details.expoSdk &&
+ String(details.expoSdk).toLowerCase().includes(searchLower)
+ ) {
+ return true;
+ }
+ if (
+ details.versionCode &&
+ String(details.versionCode).includes(searchLower)
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/tests/functional/changelog-search.spec.ts b/tests/functional/changelog-search.spec.ts
index e9ca501..c35d58a 100644
--- a/tests/functional/changelog-search.spec.ts
+++ b/tests/functional/changelog-search.spec.ts
@@ -44,6 +44,24 @@ test.describe("Changelog Search Functional Tests", () => {
expect(restoredCount).toBeGreaterThan(5);
});
+ test("searching by release state filters to deprecated or yanked entries", async ({ page }) => {
+ const searchInput = page.getByLabel("Search changelog entries");
+
+ // Search for "yanked"
+ await searchInput.fill("yanked");
+ const yankedArticles = page.locator("article").filter({
+ has: page.locator(".tag").filter({ hasText: /yanked/i }),
+ });
+ await expect(yankedArticles.first()).toBeVisible();
+
+ // Search for "deprecated"
+ await searchInput.fill("deprecated");
+ const deprecatedArticles = page.locator("article").filter({
+ has: page.locator(".tag.deprecated"),
+ });
+ await expect(deprecatedArticles.first()).toBeVisible();
+ });
+
test("no results message appears on unmatched query and reset works", async ({ page }) => {
const searchInput = page.getByLabel("Search changelog entries");
await searchInput.fill("nonexistent-release-query-xyz");
diff --git a/tests/smoke/changelog.spec.ts b/tests/smoke/changelog.spec.ts
index f3085b7..7563278 100644
--- a/tests/smoke/changelog.spec.ts
+++ b/tests/smoke/changelog.spec.ts
@@ -7,7 +7,12 @@ test.describe("Changelog Smoke & Detail Page Tests", () => {
test("changelog index loads and displays timeline stream", async ({ page }) => {
await expect(page).toHaveTitle(/Passcodes/i);
- await expect(page.getByRole("heading", { name: "Release Highlights & Milestones", level: 1 })).toBeVisible();
+ await expect(
+ page.getByRole("heading", {
+ name: "Release Highlights & Milestones",
+ level: 1,
+ })
+ ).toBeVisible();
// Articles/timeline entries render
const articles = page.locator("article");
@@ -17,6 +22,59 @@ test.describe("Changelog Smoke & Detail Page Tests", () => {
await expect(page.getByText("v3.2.1").first()).toBeVisible();
});
+ test("current production release hero renders prominently with actions", async ({ page }) => {
+ // Current production badge
+ await expect(page.getByText(/Current Production Release/i)).toBeVisible();
+
+ // Top production heading
+ const heroHeading = page.locator("#current-release");
+ await expect(heroHeading).toBeVisible();
+ await expect(heroHeading.getByRole("heading", { level: 2 })).toContainText("v3.2.1");
+
+ // Action links
+ const downloadBtn = heroHeading.getByRole("link", { name: /Get v3\.2\.1 APK/i });
+ await expect(downloadBtn).toBeVisible();
+ await expect(downloadBtn).toHaveAttribute("href", /\/downloads\/?/);
+
+ const detailsBtn = heroHeading.getByRole("link", { name: /Release Details/i });
+ await expect(detailsBtn).toBeVisible();
+ });
+
+ test("recent updates and architecture milestones sections render properly", async ({ page }) => {
+ // Recent Updates
+ await expect(
+ page.getByRole("heading", { name: "Recent Updates", level: 2 })
+ ).toBeVisible();
+
+ // Major Architectural Milestones
+ await expect(
+ page.getByRole("heading", {
+ name: "Major Architectural Milestones",
+ level: 2,
+ })
+ ).toBeVisible();
+ await expect(
+ page.getByText(/End of Android-Only Codebase/i).first()
+ ).toBeVisible();
+ });
+
+ test("release channel filter pills update visible timeline entries", async ({ page }) => {
+ const betaChannelBtn = page.getByRole("button", { name: "Beta", exact: true });
+ await expect(betaChannelBtn).toBeVisible();
+ await betaChannelBtn.click();
+
+ // Beta releases should be visible
+ const articles = page.locator("article");
+ await expect(articles.first()).toBeVisible();
+ await expect(articles.locator(".tag.beta").first()).toBeVisible();
+ // Should not show active stable in beta filter
+ await expect(articles.locator(".tag.stable")).toHaveCount(0);
+
+ // Reset by clicking All
+ const allChannelBtn = page.getByRole("button", { name: "All", exact: true }).filter({ hasText: /^All$/ }).last();
+ await allChannelBtn.click();
+ });
+
test("historical releases display deprecated and yanked indicators appropriately", async ({ page }) => {
// v2.1.1 should have Deprecated tag
const v2Entry = page.locator("article").filter({ hasText: /v2\.1\.1/i }).first();
@@ -24,10 +82,15 @@ test.describe("Changelog Smoke & Detail Page Tests", () => {
await expect(v2Entry.locator(".tag.deprecated")).toBeVisible();
// v1.2.0 should display Yanked tag and Deprecated tag
- const v120Entry = page.locator("article").filter({ has: page.getByRole("heading", { name: /v1\.2\.0/i }) }).first();
+ const v120Entry = page
+ .locator("article")
+ .filter({ has: page.getByRole("heading", { name: /v1\.2\.0/i }) })
+ .first();
await expect(v120Entry).toBeVisible();
await expect(v120Entry.locator(".tag.deprecated")).toBeVisible();
- await expect(v120Entry.locator(".tag").filter({ hasText: /yanked/i })).toBeVisible();
+ await expect(
+ v120Entry.locator(".tag").filter({ hasText: /yanked/i })
+ ).toBeVisible();
});
test("release detail page loads with metadata and navigates back", async ({ page }) => {
@@ -42,6 +105,14 @@ test.describe("Changelog Smoke & Detail Page Tests", () => {
// Release Highlights or sections are visible
await expect(page.getByText(/Release Highlights/i)).toBeVisible();
+ // Documentation resources are visible
+ await expect(page.getByText(/Relevant Documentation/i)).toBeVisible();
+
+ // Download APK button is visible for stable release
+ await expect(
+ page.getByRole("link", { name: /Get v3\.2\.1 APK/i })
+ ).toBeVisible();
+
// Back link works
const backLink = page.getByRole("link", { name: /Back to all updates/i });
await expect(backLink).toBeVisible();
@@ -57,4 +128,16 @@ test.describe("Changelog Smoke & Detail Page Tests", () => {
await expect(page.locator(".tag.deprecated")).toContainText(/Deprecated Release/i);
await expect(page.getByText(/Deprecated Release Notice/i)).toBeVisible();
});
+
+ test("yanked release detail page displays yanked notice banner without download button", async ({ page }) => {
+ // Navigate to yanked release detail page
+ await page.goto("/changelog/v1-2-0-alpha-yanked/");
+
+ await expect(page).toHaveTitle(/v1\.2\.0.*Passcodes Changelog/i);
+ await expect(page.getByText(/Yanked Release Notice/i)).toBeVisible();
+ // Should not offer a direct get APK button for yanked release
+ await expect(
+ page.getByRole("link", { name: /Get v1\.2\.0 APK/i })
+ ).toHaveCount(0);
+ });
});
From 4c29dc20dd69950ab0d3e6fe189f5d96c2a693af Mon Sep 17 00:00:00 2001
From: harsha-vardhan-burra
Date: Fri, 25 Sep 2026 18:36:10 +0530
Subject: [PATCH 2/4] feat(site): improve release discovery navigation
Signed-off-by: harsha-vardhan-burra
---
src/app/changelog/[slug]/page.tsx | 67 ++++++++++++++++++-
.../changelog/CurrentReleaseHero.tsx | 22 ++++--
src/components/downloads/DownloadCard.tsx | 40 ++++++++++-
src/components/downloads/DownloadsContent.tsx | 11 ++-
src/components/home/Hero.tsx | 8 ++-
src/lib/changelog.ts | 19 ++++++
tests/smoke/changelog.spec.ts | 8 +++
tests/smoke/downloads.spec.ts | 10 +++
8 files changed, 171 insertions(+), 14 deletions(-)
diff --git a/src/app/changelog/[slug]/page.tsx b/src/app/changelog/[slug]/page.tsx
index b114584..c6aa66c 100644
--- a/src/app/changelog/[slug]/page.tsx
+++ b/src/app/changelog/[slug]/page.tsx
@@ -78,7 +78,7 @@ export default function ChangelogEntryPage({
{/* 1. Header & Release Metadata */}
-
+
Back to all updates
+
+
+ Home
+ /
+ Project Updates
+ /
+ {entry.version}
+
@@ -170,12 +178,22 @@ export default function ChangelogEntryPage({
Get {entry.version} APK
)}
+
+
+ Setup Guide
+
+
{entry.githubUrl && (
GitHub Release
@@ -379,6 +397,51 @@ export default function ChangelogEntryPage({
+
+ {/* Next Steps Journey Card */}
+
+
+
+
+ Next Steps
+
+
+ Ready to run Passcodes?
+
+
+ {isDeprecated || entry.isYanked
+ ? "This build is deprecated or yanked. Download the verified production release or review the setup guide."
+ : "Download the latest signed Android package, read our configuration walkthrough, or explore all releases."}
+
+
+
+
+
+
Download APK
+
+
+
+
Documentation
+
+
+
+
All Updates
+
+
+
+
+
diff --git a/src/components/changelog/CurrentReleaseHero.tsx b/src/components/changelog/CurrentReleaseHero.tsx
index 1cb6dfe..caec599 100644
--- a/src/components/changelog/CurrentReleaseHero.tsx
+++ b/src/components/changelog/CurrentReleaseHero.tsx
@@ -5,9 +5,11 @@ import {
ExternalLink,
Calendar,
CheckCircle2,
+ BookOpen,
} from "lucide-react";
import type { ChangelogEntry } from "@/lib/changelog";
import { formatDate } from "@passcodes/passalgo";
+import { USER_GUIDE_URL } from "@/lib/constants";
interface CurrentReleaseHeroProps {
release: ChangelogEntry;
@@ -88,7 +90,7 @@ export function CurrentReleaseHero({ release }: CurrentReleaseHeroProps) {
{/* Build Specifications Pill Group */}
{release.internalDetails && (
- Specifications:
+ Specs:
{release.internalDetails.expoSdk && (
Expo SDK {release.internalDetails.expoSdk}
@@ -104,11 +106,6 @@ export function CurrentReleaseHero({ release }: CurrentReleaseHeroProps) {
DB Schema {release.internalDetails.masterDbVersion}
)}
- {release.internalDetails.packageName && (
-
- {release.internalDetails.packageName}
-
- )}
)}
@@ -130,12 +127,23 @@ export function CurrentReleaseHero({ release }: CurrentReleaseHeroProps) {
+
+
+
Documentation
+
+
+
{release.githubUrl && (
GitHub Release
diff --git a/src/components/downloads/DownloadCard.tsx b/src/components/downloads/DownloadCard.tsx
index a5242c8..4982827 100644
--- a/src/components/downloads/DownloadCard.tsx
+++ b/src/components/downloads/DownloadCard.tsx
@@ -1,7 +1,16 @@
"use client";
import { useState } from "react";
-import { Download, Calendar, Tag, AlertTriangle } from "lucide-react";
+import Link from "next/link";
+import {
+ Download,
+ Calendar,
+ Tag,
+ AlertTriangle,
+ ArrowRight,
+ BookOpen,
+ ExternalLink,
+} from "lucide-react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { ArchDownload } from "@/components/downloads/ArchDownload";
@@ -13,6 +22,8 @@ import {
} from "@/hooks/useGithubRelease";
import type { GithubRelease } from "@/types/github";
import { formatDate, formatNumber } from "@passcodes/passalgo";
+import { USER_GUIDE_URL } from "@/lib/constants";
+import { getChangelogEntryByTagName } from "@/lib/changelog";
export function DownloadCard({
release,
@@ -29,6 +40,7 @@ export function DownloadCard({
const channel = classifyRelease(release);
const isYanked = isYankedRelease(release);
const isDeprecated = isDeprecatedRelease(release);
+ const changelogEntry = getChangelogEntryByTagName(release.tag_name);
return (
+
+ {/* Discovery & Navigation Links */}
+
+
+
View in Project Updates
+
+
+
+
+
+
Setup Guide
+
+
+
);
}
diff --git a/src/components/downloads/DownloadsContent.tsx b/src/components/downloads/DownloadsContent.tsx
index b20273d..05d0caa 100644
--- a/src/components/downloads/DownloadsContent.tsx
+++ b/src/components/downloads/DownloadsContent.tsx
@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
-import { Search, XCircle, AlertTriangle, Cpu, Layers } from "lucide-react";
+import { Search, XCircle, AlertTriangle, Cpu, Layers, ArrowRight } from "lucide-react";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { DeviceWarning } from "@/components/downloads/DeviceWarning";
import { DownloadCard } from "@/components/downloads/DownloadCard";
@@ -156,13 +156,20 @@ export function DownloadsContent() {
latestRelease && (