diff --git a/next.config.ts b/next.config.ts
index c4ce4803..c0dcac25 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,5 +1,18 @@
import type { NextConfig } from "next";
import { withBotId } from "botid/next/config";
+import { execSync } from "node:child_process";
+import pkg from "./package.json" with { type: "json" };
+
+// The running version (docs: learnings/2026-10-23-app-version-in-footer.md): baked in at build time, read only by src/lib/version.ts.
+const git = (cmd: string) => {
+ try {
+ return execSync(cmd).toString().trim();
+ } catch {
+ return "";
+ }
+};
+const sha = process.env.VERCEL_GIT_COMMIT_SHA || git("git rev-parse HEAD");
+const dirty = !process.env.VERCEL && git("git status --porcelain") !== "";
const distDir = process.env.NEXT_DIST_DIR || ".next";
@@ -10,7 +23,16 @@ const nextConfig: NextConfig = {
typescript: { tsconfigPath: distDir === ".next" ? "tsconfig.json" : "tsconfig.dist.json" },
cacheComponents: true,
// BotID is checked only on Vercel (VERCEL=1 during Vercel builds); see src/server/bot.ts.
- env: { NEXT_PUBLIC_BUSKMAP_BOTID: process.env.VERCEL ? "1" : "" },
+ env: {
+ NEXT_PUBLIC_BUSKMAP_BOTID: process.env.VERCEL ? "1" : "",
+ NEXT_PUBLIC_APP_VERSION: pkg.version,
+ NEXT_PUBLIC_APP_SHA: sha,
+ NEXT_PUBLIC_APP_ENV: process.env.VERCEL_ENV || "local",
+ NEXT_PUBLIC_APP_BRANCH: process.env.VERCEL_GIT_COMMIT_REF || git("git rev-parse --abbrev-ref HEAD"),
+ NEXT_PUBLIC_APP_DIRTY: dirty ? "1" : "",
+ NEXT_PUBLIC_APP_BUILT_AT: new Date().toISOString(),
+ NEXT_PUBLIC_APP_REPO: "https://github.com/T04435/busk-map",
+ },
partialPrefetching: true,
// Vercel restores .next/cache between deployments. A stale Turbopack cache served an old globals.css (the create-next-app one:
// no Honeycomb tokens, fonts or --taste-*) on a preview, so the build cache is off; builds recompile from the source every time.
diff --git a/package.json b/package.json
index d23454b2..062a095e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "busk-map",
- "version": "0.1.0",
+ "version": "1.0.2",
"private": true,
"scripts": {
"dev": "node scripts/copy-maplibre-worker.mjs && next dev",
@@ -16,6 +16,7 @@
"test:e2e": "playwright test",
"e2e:late": "BUSKMAP_FAKE_NOW=2026-10-16T21:30:00+11:00 playwright test late-clock.spec.ts",
"check:fixtures": "node scripts/check-fixtures.mjs",
+ "check:version-tag": "node scripts/check-version-tag.mjs",
"check": "pnpm typecheck && pnpm lint && pnpm test:coverage && pnpm check:fixtures && pnpm build",
"db:migrate:dev": "node --env-file=.env.development.local scripts/migrate.mjs",
"prepare": "simple-git-hooks || true",
diff --git a/scripts/check-version-tag.mjs b/scripts/check-version-tag.mjs
new file mode 100644
index 00000000..b50ccc86
--- /dev/null
+++ b/scripts/check-version-tag.mjs
@@ -0,0 +1,26 @@
+// Fails if the git tag is not "v" + package.json's version. Tag = the first argument, else GITHUB_REF_NAME (tag pushes in CI),
+// else the tag at HEAD (local pre-push). With no tag at all there is nothing to check.
+import { execSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+
+const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
+const fromGit = () => {
+ try {
+ return execSync("git tag --points-at HEAD", { stdio: ["ignore", "pipe", "ignore"] }).toString().split("\n").filter((t) => /^v\d/.test(t));
+ } catch {
+ return [];
+ }
+};
+const tags = process.argv[2] ? [process.argv[2]] : process.env.GITHUB_REF_TYPE === "tag" && process.env.GITHUB_REF_NAME ? [process.env.GITHUB_REF_NAME] : fromGit();
+
+if (tags.length === 0) {
+ console.log("check-version-tag: no version tag to check.");
+ process.exit(0);
+}
+const expected = `v${pkg.version}`;
+const bad = tags.filter((t) => t !== expected);
+if (bad.length > 0) {
+ console.error(`check-version-tag: tag ${bad.join(", ")} does not match package.json version (expected ${expected}).`);
+ process.exit(1);
+}
+console.log(`check-version-tag: ${tags.join(", ")} matches package.json.`);
diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts
new file mode 100644
index 00000000..7dbd53a8
--- /dev/null
+++ b/src/app/api/version/route.ts
@@ -0,0 +1,5 @@
+import { appVersion } from "@/lib/version";
+// No `dynamic = "force-static"`: it is not allowed with cacheComponents. A GET with no request data is static by default.
+export function GET() {
+ return Response.json({ ...appVersion, deploymentId: process.env.VERCEL_DEPLOYMENT_ID ?? null });
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 7ecc0541..91ca8cf7 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -6,6 +6,7 @@ import { AppHeader, AppHeaderView } from "@/components/honeycomb/app-header";
import { HAS_BOTTOM_NAV } from "@/components/honeycomb/nav-items";
import { MobileNav, MobileNavView } from "@/components/honeycomb/mobile-nav";
import { SiteFooter, SiteFooterView } from "@/components/honeycomb/site-footer";
+import { versionText } from "@/lib/version";
// Honeycomb's three families (design-handoff/fonts.md). Google renamed "Big Shoulders Display" to "Big Shoulders" (the same
// family, now with an optical-size axis), so next/font imports it under the new name; the CSS stack still names both. globals.css pins opsz to 72, the Display cut. globals.css reads the -src variables and adds the fallback stacks.
@@ -28,6 +29,7 @@ export const metadata: Metadata = {
description: DESCRIPTION,
openGraph: { type: "website", siteName: TITLE, title: TITLE, description: DESCRIPTION, locale: "en_AU", images: [OG_IMAGE] },
twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION, images: [{ url: OG_IMAGE.url, alt: OG_IMAGE.alt }] },
+ other: { "app-version": versionText() },
};
export default function RootLayout({ children }: LayoutProps<"/">) {
diff --git a/src/components/honeycomb/site-footer.tsx b/src/components/honeycomb/site-footer.tsx
index b300f926..465114a2 100644
--- a/src/components/honeycomb/site-footer.tsx
+++ b/src/components/honeycomb/site-footer.tsx
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import { cn } from "cn";
import { HexCell } from "@/components/honeycomb/hex-cell";
import { Logo, QlooWordmark } from "@/components/honeycomb/logo";
+import { VersionBadge } from "@/components/honeycomb/version-badge";
const REPO = "https://github.com/T04435/busk-map";
const AUTHOR = "https://github.com/T04435";
@@ -29,7 +30,7 @@ const LINK = "inline-flex min-h-tap items-center font-bold text-foreground under
/**
* The footer on every page, in three parts: on the left the wordmark and the Qloo credit, in the centre Privacy, Brain and Brand,
- * on the right GitHub (an icon in a cell) and "Built by T04435" with the pixel critter. At 390px the parts stack. The legal line
+ * on the right GitHub (an icon in a cell) and "Built by T04435" with the pixel critter, and on a line of its own at the bottom, centred, the running version (VersionBadge). At 390px the parts stack. The legal line
* lives on /terms, which the footer links to. On the landing (`dark`) it continues the closing call to action's dark band, as in
* the deck; on every other page it sits on paper.
*/
@@ -73,6 +74,9 @@ export function SiteFooterView({ repo = REPO, dark = false, night = false }: { r
+
+
+
);
diff --git a/src/components/honeycomb/version-badge.tsx b/src/components/honeycomb/version-badge.tsx
new file mode 100644
index 00000000..e2d6d215
--- /dev/null
+++ b/src/components/honeycomb/version-badge.tsx
@@ -0,0 +1,26 @@
+import { Badge } from "@/components/ui/badge";
+import { appVersion, envTag, type AppVersion } from "@/lib/version";
+
+/**
+ * The running version (learnings/2026-10-23-app-version-in-footer.md): the shared Badge in its default (primary) variant, mono, one
+ * colour for everything. `v1.0.2 @ 3f1addd`, then ` · preview · ` or ` · local` (+ ` · dirty`) off production.
+ * The tag links to its release and the hash to its commit; links inherit the colour and underline on hover.
+ */
+export function VersionBadge({ version = appVersion, className }: { version?: AppVersion; className?: string }) {
+ const env = envTag(version);
+ const rest = env === "production" ? [] : env === "preview" ? ["preview", version.branch] : ["local", ...(version.dirty ? ["dirty"] : [])];
+ return (
+
+
+ v{version.version}
+
+ @
+
+ {version.shortSha}
+
+ {rest.filter(Boolean).map((part) => (
+ · {part}
+ ))}
+
+ );
+}
diff --git a/src/lib/version.ts b/src/lib/version.ts
new file mode 100644
index 00000000..ee0673da
--- /dev/null
+++ b/src/lib/version.ts
@@ -0,0 +1,44 @@
+export type AppVersion = {
+ version: string;
+ sha: string;
+ shortSha: string;
+ env: "production" | "preview" | "development" | "local";
+ branch: string;
+ dirty: boolean;
+ builtAt: string;
+ releaseUrl: string;
+ commitUrl: string;
+};
+
+const repo = process.env.NEXT_PUBLIC_APP_REPO ?? "";
+const version = process.env.NEXT_PUBLIC_APP_VERSION ?? "0.0.0";
+const sha = process.env.NEXT_PUBLIC_APP_SHA ?? "";
+
+export const appVersion: AppVersion = {
+ version,
+ sha,
+ shortSha: sha.slice(0, 7),
+ env: (process.env.NEXT_PUBLIC_APP_ENV as AppVersion["env"]) ?? "local",
+ branch: process.env.NEXT_PUBLIC_APP_BRANCH ?? "",
+ dirty: process.env.NEXT_PUBLIC_APP_DIRTY === "1",
+ builtAt: process.env.NEXT_PUBLIC_APP_BUILT_AT ?? "",
+ releaseUrl: `${repo}/releases/tag/v${version}`,
+ commitUrl: `${repo}/commit/${sha}`,
+};
+
+export type EnvTag = "production" | "preview" | "local";
+export const envTag = (v: AppVersion = appVersion): EnvTag =>
+ v.env === "production" ? "production" : v.env === "preview" ? "preview" : "local";
+
+/** "v1.1.0 @ b7595e4": the pill's main text (and the title for screen readers). */
+export function versionLabel(v: AppVersion = appVersion): string {
+ return `v${v.version} @ ${v.shortSha}`;
+}
+
+/** Plain-text form for logs, meta and bug reports: "v1.1.0 @ 3f1addd (preview, feat/sing)". */
+export function versionText(v: AppVersion = appVersion): string {
+ const env = envTag(v);
+ if (env === "production") return versionLabel(v);
+ const extra = env === "preview" ? v.branch : v.dirty ? "dirty" : "";
+ return `${versionLabel(v)} (${[env, extra].filter(Boolean).join(", ")})`;
+}
diff --git a/tests/e2e/version.spec.ts b/tests/e2e/version.spec.ts
new file mode 100644
index 00000000..557a3ae8
--- /dev/null
+++ b/tests/e2e/version.spec.ts
@@ -0,0 +1,25 @@
+import { expect, test } from "./fixtures";
+
+test("the footer and /api/version show the same version", async ({ page, request }) => {
+ const response = await request.get("/api/version");
+ expect(response.ok()).toBe(true);
+ const api = await response.json();
+ expect(api.version).toMatch(/^\d+\.\d+\.\d+$/);
+ expect(api.shortSha).toBe(api.sha.slice(0, 7));
+
+ await page.goto("/privacy");
+ // The Suspense fallback footer can still be in the DOM while streaming; take the visible one.
+ const footer = page.getByRole("contentinfo");
+ const badge = footer.locator('[data-slot="version-badge"]');
+ await expect(badge).toBeVisible();
+ await expect(badge.getByRole("link", { name: `v${api.version}`, exact: true })).toHaveAttribute("href", api.releaseUrl);
+ await expect(badge.getByRole("link", { name: api.shortSha, exact: true })).toHaveAttribute("href", api.commitUrl);
+ await expect(badge).toContainText(`v${api.version} @ ${api.shortSha}`);
+ await expect(badge).toHaveAttribute("data-variant", "default");
+ // Its own centred line at the bottom of the footer.
+ const [b, f] = [await badge.boundingBox(), await footer.boundingBox()];
+ expect(Math.abs(b!.x + b!.width / 2 - (f!.x + f!.width / 2))).toBeLessThan(2);
+ expect(f!.y + f!.height - (b!.y + b!.height)).toBeLessThan(40);
+ const meta = await page.locator('meta[name="app-version"]').getAttribute("content");
+ expect(meta).toContain(`v${api.version} @ ${api.shortSha}`);
+});
diff --git a/tests/unit/lib/version.test.ts b/tests/unit/lib/version.test.ts
new file mode 100644
index 00000000..63a4d6b0
--- /dev/null
+++ b/tests/unit/lib/version.test.ts
@@ -0,0 +1,70 @@
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { VersionBadge } from "@/components/honeycomb/version-badge";
+import { envTag, versionLabel, versionText, type AppVersion } from "@/lib/version";
+
+const base: AppVersion = {
+ version: "1.1.0",
+ sha: "b7595e4aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ shortSha: "b7595e4",
+ env: "production",
+ branch: "main",
+ dirty: false,
+ builtAt: "2026-10-23T00:00:00.000Z",
+ releaseUrl: "https://github.com/o/r/releases/tag/v1.1.0",
+ commitUrl: "https://github.com/o/r/commit/b7595e4",
+};
+
+describe("version text", () => {
+ it("formats production as the tag and hash only", () => {
+ expect(versionLabel(base)).toBe("v1.1.0 @ b7595e4");
+ expect(versionText(base)).toBe("v1.1.0 @ b7595e4");
+ });
+
+ it("adds the env and branch on preview", () => {
+ const v = { ...base, env: "preview" as const, shortSha: "3f1addd", branch: "feat/sing" };
+ expect(versionText(v)).toBe("v1.1.0 @ 3f1addd (preview, feat/sing)");
+ });
+
+ it("adds local, and dirty only with uncommitted changes", () => {
+ const clean = { ...base, env: "local" as const, shortSha: "3f1addd" };
+ expect(versionText(clean)).toBe("v1.1.0 @ 3f1addd (local)");
+ expect(versionText({ ...clean, dirty: true })).toBe("v1.1.0 @ 3f1addd (local, dirty)");
+ });
+
+ it("maps Vercel's development env to local", () => {
+ expect(envTag({ ...base, env: "development" })).toBe("local");
+ expect(envTag({ ...base, env: "production" })).toBe("production");
+ expect(envTag({ ...base, env: "preview" })).toBe("preview");
+ });
+});
+
+describe("VersionBadge", () => {
+ const html = (env: AppVersion["env"]) => renderToStaticMarkup(createElement(VersionBadge, { version: { ...base, env, branch: "feat/sing", dirty: true } }));
+ const text = (out: string) => out.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
+
+ it("is the primary Badge on every env: one colour, mono, no marker, no brand or tint", () => {
+ for (const env of ["production", "preview", "local", "development"] as const) {
+ const out = html(env);
+ expect(out).toContain('data-variant="default"');
+ expect(out).toContain("bg-primary");
+ expect(out).toContain("font-mono");
+ expect(out).not.toContain("size-2");
+ expect(out).not.toContain("--tone");
+ expect(out).not.toContain("--brand");
+ }
+ });
+
+ it("reads tag @ hash on production, with links to the release and the commit", () => {
+ const out = html("production");
+ expect(text(out)).toBe("v1.1.0 @ b7595e4");
+ expect(out).toContain('href="https://github.com/o/r/releases/tag/v1.1.0"');
+ expect(out).toContain('href="https://github.com/o/r/commit/b7595e4"');
+ });
+
+ it("adds the env and branch on preview, and local with dirty", () => {
+ expect(text(html("preview"))).toBe("v1.1.0 @ b7595e4 · preview · feat/sing");
+ expect(text(html("local"))).toBe("v1.1.0 @ b7595e4 · local · dirty");
+ });
+});