diff --git a/components/layout/header/environment-badge.tsx b/components/layout/header/environment-badge.tsx
new file mode 100644
index 0000000..df889bf
--- /dev/null
+++ b/components/layout/header/environment-badge.tsx
@@ -0,0 +1,207 @@
+"use client";
+
+import * as React from "react";
+import { Badge } from "@/components/ui/badge";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ readBuildMetadata,
+ type BuildMetadata,
+} from "@/lib/build-metadata";
+
+const HOVER_OPEN_DELAY_MS = 200;
+const HOVER_CLOSE_DELAY_MS = 150;
+
+export function EnvironmentBadge() {
+ if (process.env.NODE_ENV !== "development") {
+ return null;
+ }
+
+ return ;
+}
+
+function DevelopmentBadge({ metadata }: { metadata: BuildMetadata }) {
+ const [open, setOpen] = React.useState(false);
+ const openSource = React.useRef<"explicit" | "hover" | null>(null);
+ const openTimer = React.useRef(null);
+ const closeTimer = React.useRef(null);
+ const closingFromHover = React.useRef(false);
+
+ React.useEffect(
+ () => () => {
+ if (openTimer.current !== null) {
+ window.clearTimeout(openTimer.current);
+ }
+ if (closeTimer.current !== null) {
+ window.clearTimeout(closeTimer.current);
+ }
+ },
+ []
+ );
+
+ function clearTimers() {
+ if (openTimer.current !== null) {
+ window.clearTimeout(openTimer.current);
+ openTimer.current = null;
+ }
+
+ if (closeTimer.current !== null) {
+ window.clearTimeout(closeTimer.current);
+ closeTimer.current = null;
+ }
+ }
+
+ function openOnHover(event: React.PointerEvent) {
+ if (event.pointerType === "touch" || openSource.current === "explicit") {
+ return;
+ }
+
+ clearTimers();
+ openTimer.current = window.setTimeout(() => {
+ openSource.current = "hover";
+ setOpen(true);
+ }, HOVER_OPEN_DELAY_MS);
+ }
+
+ function closeAfterHover() {
+ clearTimers();
+
+ if (openSource.current !== "hover") {
+ return;
+ }
+
+ closeTimer.current = window.setTimeout(() => {
+ closingFromHover.current = true;
+ openSource.current = null;
+ setOpen(false);
+ }, HOVER_CLOSE_DELAY_MS);
+ }
+
+ function changeOpen(nextOpen: boolean) {
+ clearTimers();
+
+ if (nextOpen) {
+ openSource.current ??= "explicit";
+ } else {
+ closingFromHover.current = openSource.current === "hover";
+ openSource.current = null;
+ }
+
+ setOpen(nextOpen);
+ }
+
+ function pinHoveredPopover(event: React.MouseEvent) {
+ if (open && openSource.current === "hover") {
+ event.preventDefault();
+ clearTimers();
+ openSource.current = "explicit";
+ }
+ }
+
+ return (
+
+
+
+ DEV
+
+
+ {
+ if (openSource.current === "hover") {
+ event.preventDefault();
+ }
+ }}
+ onCloseAutoFocus={(event) => {
+ if (closingFromHover.current) {
+ event.preventDefault();
+ closingFromHover.current = false;
+ }
+ }}
+ >
+
+
+
+ );
+}
+
+export function BuildDetails({ metadata }: { metadata: BuildMetadata }) {
+ const builtAt = new Date(metadata.builtAt);
+ const builtAtLabel = new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(builtAt);
+ const relativeBuiltAt = formatRelativeTime(builtAt, new Date());
+
+ return (
+
+
+ {metadata.branch}
+
+
+ {metadata.commitSha.slice(0, 8)}
+ {metadata.dirty ? (dirty) : null}
+
+
+ {metadata.commitSubject}
+
+
+
+
+
+ );
+}
+
+function BuildDetail({
+ children,
+ label,
+ title,
+}: {
+ children: React.ReactNode;
+ label: string;
+ title: string;
+}) {
+ return (
+ <>
+ {label}
+
+ {children}
+
+ >
+ );
+}
+
+function formatRelativeTime(date: Date, now: Date) {
+ const seconds = Math.round((date.getTime() - now.getTime()) / 1000);
+ const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
+ const absoluteSeconds = Math.abs(seconds);
+
+ if (absoluteSeconds < 60) {
+ return formatter.format(seconds, "second");
+ }
+ if (absoluteSeconds < 60 * 60) {
+ return formatter.format(Math.round(seconds / 60), "minute");
+ }
+ if (absoluteSeconds < 60 * 60 * 24) {
+ return formatter.format(Math.round(seconds / (60 * 60)), "hour");
+ }
+
+ return formatter.format(Math.round(seconds / (60 * 60 * 24)), "day");
+}
diff --git a/components/layout/header/page-header.tsx b/components/layout/header/page-header.tsx
index eefc7aa..2cb967f 100644
--- a/components/layout/header/page-header.tsx
+++ b/components/layout/header/page-header.tsx
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CategoryIcon } from "@/components/common/icons/category-icon";
import { Skeleton } from "@/components/ui/skeleton";
+import { EnvironmentBadge } from "@/components/layout/header/environment-badge";
interface PageHeaderProps {
title: string;
@@ -78,14 +79,19 @@ export function PageHeader({ title, description, children, appLogo }: PageHeader
-
{title}
+
+
{title}
+
+
{description && (
{description}
)}
{children && (
- {children}
+
+ {children}
+
)}
diff --git a/lib/build-metadata.build.ts b/lib/build-metadata.build.ts
new file mode 100644
index 0000000..1096b61
--- /dev/null
+++ b/lib/build-metadata.build.ts
@@ -0,0 +1,30 @@
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import { parseBuildMetadata } from "./build-metadata";
+
+const repositoryRoot = fileURLToPath(new URL("..", import.meta.url));
+
+interface DiscoverBuildMetadataOptions {
+ repositoryRoot?: string;
+}
+
+export function discoverBuildMetadata(options: DiscoverBuildMetadataOptions = {}) {
+ const root = options.repositoryRoot ?? repositoryRoot;
+ const branch = runGit(["branch", "--show-current"], root) || "detached";
+
+ return parseBuildMetadata({
+ branch,
+ builtAt: new Date().toISOString(),
+ commitSha: runGit(["rev-parse", "HEAD"], root),
+ commitSubject: runGit(["log", "-1", "--format=%s", "HEAD"], root),
+ dirty: String(runGit(["status", "--porcelain=v1"], root).length > 0),
+ });
+}
+
+function runGit(args: string[], cwd: string) {
+ return execFileSync("git", args, {
+ cwd,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "inherit"],
+ }).trim();
+}
diff --git a/lib/build-metadata.ts b/lib/build-metadata.ts
new file mode 100644
index 0000000..ec5d64d
--- /dev/null
+++ b/lib/build-metadata.ts
@@ -0,0 +1,65 @@
+export interface BuildMetadata {
+ branch: string;
+ builtAt: string;
+ commitSha: string;
+ commitSubject: string;
+ dirty: boolean;
+}
+
+interface BuildMetadataInput {
+ branch: string | undefined;
+ builtAt: string | undefined;
+ commitSha: string | undefined;
+ commitSubject: string | undefined;
+ dirty: string | undefined;
+}
+
+const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,64}$/i;
+const ISO_8601_TIMESTAMP_PATTERN =
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
+
+export function readBuildMetadata(): BuildMetadata {
+ return parseBuildMetadata({
+ branch: process.env.APP_BUILD_BRANCH,
+ builtAt: process.env.APP_BUILD_AT,
+ commitSha: process.env.APP_BUILD_COMMIT_SHA,
+ commitSubject: process.env.APP_BUILD_COMMIT_SUBJECT,
+ dirty: process.env.APP_BUILD_DIRTY,
+ });
+}
+
+export function parseBuildMetadata(input: BuildMetadataInput): BuildMetadata {
+ const branch = requireValue("APP_BUILD_BRANCH", input.branch);
+ const builtAt = requireValue("APP_BUILD_AT", input.builtAt);
+ const commitSha = requireValue("APP_BUILD_COMMIT_SHA", input.commitSha);
+ const commitSubject = requireValue("APP_BUILD_COMMIT_SUBJECT", input.commitSubject);
+ const dirty = requireValue("APP_BUILD_DIRTY", input.dirty);
+
+ if (!COMMIT_SHA_PATTERN.test(commitSha)) {
+ throw new Error("APP_BUILD_COMMIT_SHA must be a Git commit SHA");
+ }
+
+ if (!ISO_8601_TIMESTAMP_PATTERN.test(builtAt) || Number.isNaN(Date.parse(builtAt))) {
+ throw new Error("APP_BUILD_AT must be an ISO 8601 timestamp");
+ }
+
+ if (dirty !== "false" && dirty !== "true") {
+ throw new Error('APP_BUILD_DIRTY must be either "true" or "false"');
+ }
+
+ return {
+ branch,
+ builtAt,
+ commitSha,
+ commitSubject,
+ dirty: dirty === "true",
+ };
+}
+
+function requireValue(name: string, value: string | undefined) {
+ if (!value) {
+ throw new Error(`${name} is required`);
+ }
+
+ return value;
+}
diff --git a/next.config.ts b/next.config.ts
index ba1e7c7..71c5a19 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,12 +1,26 @@
import type { NextConfig } from "next";
+import { PHASE_DEVELOPMENT_SERVER, type PHASE_TYPE } from "next/constants";
+import { discoverBuildMetadata } from "./lib/build-metadata.build";
import packageJson from "./package.json";
-const nextConfig: NextConfig = {
- // Produce standalone output for self-hosted deploys (Docker or zipped bundle)
- output: "standalone",
- env: {
- NEXT_PUBLIC_APP_VERSION: packageJson.version,
- },
-};
+export default function createNextConfig(phase: PHASE_TYPE): NextConfig {
+ const buildMetadata =
+ phase === PHASE_DEVELOPMENT_SERVER ? discoverBuildMetadata() : null;
-export default nextConfig;
+ return {
+ // Produce standalone output for self-hosted deploys (Docker or zipped bundle)
+ output: "standalone",
+ env: {
+ NEXT_PUBLIC_APP_VERSION: packageJson.version,
+ ...(buildMetadata
+ ? {
+ APP_BUILD_AT: buildMetadata.builtAt,
+ APP_BUILD_BRANCH: buildMetadata.branch,
+ APP_BUILD_COMMIT_SHA: buildMetadata.commitSha,
+ APP_BUILD_COMMIT_SUBJECT: buildMetadata.commitSubject,
+ APP_BUILD_DIRTY: String(buildMetadata.dirty),
+ }
+ : {}),
+ },
+ };
+}
diff --git a/package.json b/package.json
index e0b7fb4..1cc982c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "crapdash",
- "version": "1.4.1",
+ "version": "1.4.2",
"private": true,
"license": "MIT",
"packageManager": "pnpm@11.0.3",
diff --git a/tests/components/environment-badge.test.tsx b/tests/components/environment-badge.test.tsx
new file mode 100644
index 0000000..3a4c368
--- /dev/null
+++ b/tests/components/environment-badge.test.tsx
@@ -0,0 +1,60 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ BuildDetails,
+ EnvironmentBadge,
+} from "@/components/layout/header/environment-badge";
+
+const buildMetadata = {
+ branch: "environment-badge",
+ builtAt: "2026-08-07T07:15:00.000Z",
+ commitSha: "98556cc1d4a18439855616c0b86e4eaa6b5d2821",
+ commitSubject: "Add environment badge details",
+ dirty: true,
+};
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe("EnvironmentBadge", () => {
+ it("renders a local badge in development", () => {
+ vi.stubEnv("NODE_ENV", "development");
+ vi.stubEnv("APP_BUILD_BRANCH", buildMetadata.branch);
+ vi.stubEnv("APP_BUILD_AT", buildMetadata.builtAt);
+ vi.stubEnv("APP_BUILD_COMMIT_SHA", buildMetadata.commitSha);
+ vi.stubEnv("APP_BUILD_COMMIT_SUBJECT", buildMetadata.commitSubject);
+ vi.stubEnv("APP_BUILD_DIRTY", String(buildMetadata.dirty));
+
+ const html = renderToStaticMarkup();
+
+ expect(html).toContain('data-slot="badge"');
+ expect(html).toContain('data-variant="outline"');
+ expect(html).toContain("rounded-sm");
+ expect(html).not.toContain("rounded-full");
+ expect(html).toContain("DEV");
+ expect(html).toContain("Development environment. Show build details");
+ expect(html).toContain('aria-haspopup="dialog"');
+ expect(html).toContain("