admin: a footer names the build and links device setup
The shell grew a bottom edge to say which build replied,
"dev server" where no release tag was ever baked inside.
"+debug" while the log is taking whole requests down,
and setup a click from any screen in town.

Assisted-by: Claude Opus 5 via Claude Code
change rkuronxxwpyutrtouuwkmnpqsprkpmwo
commit 34c4d9f5122591d21079582b0bdacb44d41d8ab4
author Alpha Chen <alpha@kejadlen.dev>
date
parent lxqvyxtk
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 311cb68..0c39bae 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -80,6 +80,7 @@ jobs:
             COMMIT_SHA=${{ steps.short-sha.outputs.sha }}
             CHANGE_ID=${{ steps.change-id.outputs.id }}
             BUILD_DATE=${{ github.event.head_commit.timestamp }}
+            VERSION=${{ steps.meta.outputs.version }}
           cache-from: type=gha
           cache-to: type=gha,mode=max
       - env:
diff --git a/Dockerfile b/Dockerfile
index 990aaf6..470a270 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,9 +14,13 @@ FROM ruby:4.0-slim
 ARG COMMIT_SHA
 ARG CHANGE_ID
 ARG BUILD_DATE
+# The image's own tag, and the release's name (see ci.yml) — the one
+# build fact the app reads back out, in the admin footer.
+ARG VERSION
 ENV COMMIT_SHA=${COMMIT_SHA}
 ENV CHANGE_ID=${CHANGE_ID}
 ENV BUILD_DATE=${BUILD_DATE}
+ENV VERSION=${VERSION}
 # Set TZ at runtime — Date.today drives the birthday list's rollover.
 
 WORKDIR /app
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 249aaf5..c850540 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -88,6 +88,14 @@ columns are equal and fluid, collapsing to one — birthdays under contacts —
 when the viewport cannot hold two side by side. Every other screen keeps a
 single reading column.
 
+Under every screen sits the footer, the header's counterpart, holding what no
+search can turn up because none of it is a record: the version answering,
+whether debug logging is recording, and a link to device setup. A run with no
+release behind it says "dev server" in the version's place, since a working
+copy dressed as the deployment is the confusion the line exists to end. Debug
+logging appears only while it is on, written `+debug` — a flag on the build,
+not a sentence beside it — and a quiet log has nothing to say.
+
 ## Records are cards, not forms
 
 A contact or group opens as a full card, not a row in a split pane and not a
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 349e2e0..a1e3eea 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -1,5 +1,7 @@
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts"
+
 module ProTacts
   module Admin
     # The one page shell every admin screen renders inside: head, the
@@ -78,6 +80,40 @@ module ProTacts
               end
             end
             main(class: "admin-main", **(@wide ? {data: {wide: true}} : {})) { yield }
+            # The shell's other edge: which build is answering, whether
+            # it is logging what it answers, and the one screen search
+            # cannot reach. All of it read off the environment (see
+            # Config) rather than passed down through every screen's
+            # call — these are the shell's own facts, the same on all
+            # of them.
+            #
+            # The version is the image's tag and the release's name at
+            # once, so it stands alone: the commit and the build time
+            # are spelled inside it, and a second label repeating either
+            # would be the same fact twice. Nothing built by the release
+            # job carries one, and that absence is its own fact worth
+            # stating — a screen that looks like the deployment but
+            # answers out of a working copy is exactly the confusion the
+            # footer exists to end.
+            #
+            # Debug logging says so while it is on because it dumps
+            # whole requests, contact data included (see DebugLogger),
+            # and a log left recording is the kind of thing found months
+            # later. Off, the label is absent: the footer states what is
+            # true, and the quiet case is the normal one. It reads as a
+            # flag on the version rather than a sentence beside it —
+            # "+debug" is a build with something switched on, which is
+            # what it is, and the plus is its own separator.
+            #
+            # Device setup belongs here because the dashboard is a
+            # search over contacts and /setup is not a contact — a
+            # header link would put it beside the search on every
+            # screen, at the weight the app's own name has.
+            footer(class: "admin-footer") do
+              span(class: "type-label") { ProTacts.config.version || "dev server" }
+              span(class: "type-label") { "+debug" } if ProTacts.config.debug?
+              a(href: "/setup", class: "type-label") { "device setup" }
+            end
             # A server-rendered toast, Gloss's [role=status] contract:
             # the one line a refused write leaves behind, fixed to the
             # corner until the next navigation carries it off — no
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index e9bf084..8ae3ddd 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -64,5 +64,21 @@ module ProTacts
     def debug_log_path
       @env.fetch("PRO_TACTS_DEBUG_LOG", "log/debug.log")
     end
+
+    # The running image's version, baked in as a build arg (see
+    # Dockerfile and the release job in ci.yml): the image tag, which is
+    # the GitHub release's name too — `YYYYMMDD-HHmm-<short sha>`. One
+    # string carrying when it was built and what from, and the one to
+    # go look up.
+    #
+    # Blank is absent here: `ENV VERSION=${VERSION}` with no build arg
+    # behind it sets an empty string, so an image built by hand says ""
+    # where a process outside one says nothing at all, and neither names
+    # a version.
+    #: () -> String?
+    def version
+      value = @env.fetch("VERSION", nil)
+      value unless value.nil? || value.strip.empty?
+    end
   end
 end
diff --git a/public/admin.css b/public/admin.css
index 1991288..3f37299 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -334,11 +334,53 @@ textarea:focus {
   text-decoration: none;
 }
 
+/* The page's other chrome bar, built off the header's rule: the same
+   block and inline padding, and the hairline on the opposite edge, so
+   the two rules read as one frame around the column between them.
+   `margin-top: auto` is what pins it to the bottom of a short page —
+   the body below is a column tall enough to have a bottom — and on a
+   page taller than the viewport it simply follows the content. */
+.admin-footer {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: var(--gl-space-s);
+  margin-top: auto;
+  padding: var(--gl-space-2xs) var(--gl-space-m);
+  border-top: 1px solid var(--gl-color-border-hairline);
+}
+
+/* The header's own split, upside down: what is true on the left, the
+   one thing to click on the right. At an even gap the link sat a word
+   away from the state beside it, two labels of one weight reading as
+   one phrase; the edge is what tells them apart. */
+.admin-footer a {
+  margin-left: auto;
+}
+
+/* The viewport-tall column the footer's `margin-top: auto` measures
+   against. Gloss's own body rule sets type and surface and leaves
+   layout to the page, which until there was a footer meant nothing at
+   all. */
+body {
+  min-height: 100dvh;
+  display: flex;
+  flex-direction: column;
+}
+
 /* The reading column's width: wide enough that the editor's widest
    row — the birthday's three controls, ~343px at minimum — fits the
    value column with room for the month select to grow (the value
-   column at 40rem is ~412px), which 34rem missed by ~30px. */
+   column at 40rem is ~412px), which 34rem missed by ~30px.
+
+   The declared width is what the body's column costs: auto inline
+   margins on a flex item override the cross-axis stretch that used to
+   size this box, leaving it shrink-to-fit — which took the dashboard's
+   two columns down to one, its grid tracks being fractions of a width
+   that had stopped existing. The auto margins go on centering it under
+   the cap. */
 .admin-main {
+  width: 100%;
   max-width: 40rem;
   margin: 0 auto;
   padding: var(--gl-space-l) var(--gl-space-m);
diff --git a/test/pro_tacts/admin/test_footer.rb b/test/pro_tacts/admin/test_footer.rb
new file mode 100644
index 0000000..f118d1c
--- /dev/null
+++ b/test/pro_tacts/admin/test_footer.rb
@@ -0,0 +1,73 @@
+require_relative "../../test_helper"
+
+require "rack/test"
+
+require "pro_tacts"
+require "pro_tacts/config"
+require "pro_tacts/web"
+
+# The shell's footer, exercised through /setup: it is the one screen
+# that renders the layout without a store behind it, and the footer is
+# the layout's, so what holds here holds on every screen.
+class AdminFooterTest < Minitest::Test
+  include Rack::Test::Methods
+
+  def app
+    ProTacts::Web
+  end
+
+  def setup
+    header "Tailscale-User-Login", "test@example.com"
+    @config = ProTacts.config
+  end
+
+  def teardown
+    ProTacts.config = @config
+  end
+
+  def with_env(env)
+    ProTacts.config = ProTacts::Config.new(env)
+    get "/setup"
+  end
+
+  def test_the_footer_links_to_device_setup
+    with_env({})
+
+    assert_includes last_response.body, %(<a href="/setup" class="type-label">device setup</a>)
+  end
+
+  def test_the_footer_names_the_version_the_image_carries
+    with_env("VERSION" => "20260904-1822-a1b2c3d")
+
+    assert_includes last_response.body, "20260904-1822-a1b2c3d"
+  end
+
+  # Blank is what an image built by hand says (see Config#version): the
+  # Dockerfile sets the variable from a build arg that was never passed,
+  # so it arrives empty rather than absent. Either way there is no
+  # release behind the screen, which is the thing to say.
+  def test_an_unversioned_run_says_it_is_the_dev_server
+    with_env("VERSION" => "")
+
+    assert_includes last_response.body, "dev server"
+
+    with_env({})
+
+    assert_includes last_response.body, "dev server"
+  end
+
+  # The label is a standing notice, not a status: the log is recording
+  # whole requests, contact data included, for as long as it is up. It
+  # reads as a flag on the build, which is what it is.
+  def test_debug_logging_says_so_while_it_is_on
+    with_env("PRO_TACTS_DEBUG" => "1")
+
+    assert_includes last_response.body, "+debug"
+  end
+
+  def test_the_quiet_case_is_silent
+    with_env({})
+
+    refute_includes last_response.body, "+debug"
+  end
+end