diff --git a/build.ts b/build.ts
index e9e35ac..c4a9ecd 100644
--- a/build.ts
+++ b/build.ts
@@ -104,6 +104,10 @@ const result = await Bun.build({
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
"process.env.BUN_PUBLIC_BASE_PATH": JSON.stringify(basePath),
+ // The same `version` the service worker gets, so the colophon can name the
+ // build a bug report is about. Only defined here, never in dev, which is
+ // why it is read through a try/catch accessor.
+ "process.env.BUN_PUBLIC_BUILD_VERSION": JSON.stringify(version),
// Double-encoded on purpose: the inlined literal must be a JSON *string*
// for parseBakedRates to parse at runtime.
"process.env.BUN_PUBLIC_BAKED_FX_RATES": JSON.stringify(JSON.stringify(bakedFxRates)),
diff --git a/docs/TODO.md b/docs/TODO.md
index 52c7ad9..f1cb7fd 100755
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -8,7 +8,7 @@ decisions behind it are in `docs/specs/` — one spec per sub-project,
each recording the alternatives that were rejected. Rules that outlived their
task moved to `AGENTS.md`.
-**Current state:** merged to `main`, not yet pushed. 446 tests passing,
+**Current state:** merged to `main`. 452 tests passing,
`bunx tsc --noEmit` clean, `bun run build` succeeds on both the live-fetch and
offline paths.
diff --git a/src/ui/buildVersion.test.ts b/src/ui/buildVersion.test.ts
new file mode 100644
index 0000000..7598f99
--- /dev/null
+++ b/src/ui/buildVersion.test.ts
@@ -0,0 +1,40 @@
+import { test, expect } from "bun:test";
+import { describeBuild, readBuildVersionEnv } from "./buildVersion.ts";
+
+test("an absent stamp reads as development, not as a blank or a lie", () => {
+ expect(describeBuild(undefined)).toBe("development");
+ expect(describeBuild("")).toBe("development");
+});
+
+test("build.ts's default timestamp becomes a date", () => {
+ // String(Date.now()) for 2026-09-02T00:00:00Z.
+ expect(describeBuild("1788307200000")).toBe("2026-09-02");
+});
+
+test("a version someone chose is printed as given", () => {
+ // A sha, a tag and a release number must survive untouched: guessing at a
+ // format the builder did not ask for is worse than showing their string.
+ expect(describeBuild("bd5a01e")).toBe("bd5a01e");
+ expect(describeBuild("v1.4.0")).toBe("v1.4.0");
+ expect(describeBuild("2026.09.02-nightly")).toBe("2026.09.02-nightly");
+});
+
+test("digits that are not a real date fall back to the raw string", () => {
+ // 21 digits is past the range Date accepts, so this must not become
+ // "Invalid Date" or throw.
+ expect(describeBuild("999999999999999999999")).toBe("999999999999999999999");
+});
+
+// Regression, same mechanism as basePath.test.ts: Bun only inlines
+// `process.env.FOO` when FOO is SET. Unset, the reference survives into the
+// browser bundle and throws `ReferenceError: process is not defined` before the
+// app can boot, so reading it must tolerate `process` not existing at all.
+test("reading the build version does not throw when process is undefined", () => {
+ const saved = (globalThis as { process?: unknown }).process;
+ try {
+ delete (globalThis as { process?: unknown }).process;
+ expect(readBuildVersionEnv()).toBeUndefined();
+ } finally {
+ (globalThis as { process?: unknown }).process = saved;
+ }
+});
diff --git a/src/ui/buildVersion.ts b/src/ui/buildVersion.ts
new file mode 100644
index 0000000..031bedb
--- /dev/null
+++ b/src/ui/buildVersion.ts
@@ -0,0 +1,45 @@
+/**
+ * The build stamp shown in the colophon.
+ *
+ * `build.ts` already computes a version for the service worker; this exposes
+ * the same value to the app bundle. It is genuinely absent in development,
+ * which is the normal case rather than an error.
+ */
+
+/**
+ * try/catch, NOT a `typeof process` guard — see AGENTS.md §2. Bun inlines the
+ * literal only when the variable is set; unset, the bare reference reaches the
+ * browser and throws before the app boots, and a `typeof` guard would survive
+ * inlining and discard the inlined value.
+ */
+export function readBuildVersionEnv(): string | undefined {
+ try {
+ return process.env.BUN_PUBLIC_BUILD_VERSION;
+ } catch {
+ return undefined;
+ }
+}
+
+/** A millisecond epoch, which is what build.ts falls back to. */
+const EPOCH_MS = /^\d{10,}$/;
+
+/**
+ * `BUILD_VERSION` may be anything the person building chose — a git sha, a tag,
+ * a release number — so an unrecognised value is printed as given rather than
+ * reformatted into something it is not. The one shape worth translating is
+ * build.ts's own default, a `Date.now()` timestamp, because "1756809600000"
+ * tells a reader nothing and "2026-09-02" tells them what they wanted to know.
+ *
+ * ISO rather than a locale format: this string appears in bug reports against
+ * the linked repository, and it should not vary by who is reading it.
+ */
+export function describeBuild(raw: string | undefined): string {
+ if (!raw) return "development";
+ if (EPOCH_MS.test(raw)) {
+ const at = new Date(Number(raw));
+ if (!Number.isNaN(at.getTime())) return at.toISOString().slice(0, 10);
+ }
+ return raw;
+}
+
+export const BUILD_VERSION = describeBuild(readBuildVersionEnv());
diff --git a/src/ui/routes/SettingsRoute.tsx b/src/ui/routes/SettingsRoute.tsx
index e3c6e35..8d3387b 100644
--- a/src/ui/routes/SettingsRoute.tsx
+++ b/src/ui/routes/SettingsRoute.tsx
@@ -1,6 +1,7 @@
import { CurrencySection } from "./settings/CurrencySection.tsx";
import { PostsSection } from "./settings/PostsSection.tsx";
import { DataSection } from "./settings/DataSection.tsx";
+import { ColophonSection } from "./settings/ColophonSection.tsx";
/**
* Each section is its own card. They are unrelated settings — money, posts,
@@ -15,6 +16,7 @@ export function SettingsRoute() {
+
+