Reorganize the site header and footer
Assisted-by: GLM-5.3 via pi
change zyvzlpsonnuzsuxmwxqsxlzxsyktuwtw
commit 0b4aa5af8d0000e8e6d56c8e12658c045abcfc10
author Alpha Chen <alpha@kejadlen.dev>
date
parent ksnkswqs
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index cde1a95..0fb51a7 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -109,14 +109,17 @@ 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. `rake dev`
-names its version after the jj change it runs from, since a working copy
-dressed as the deployment is the confusion the line exists to end. A run
-with its own accent fills the header and footer with it, so the difference
-shows before a word of either is read. 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.
+search can turn up because none of it is a record: the version answering and
+whether debug logging is recording, centered and alone — the frame's other
+edge is the build's own label. The destinations that are not records live in
+the account menu at the header's right edge, behind the login the proxy
+vouches for: import and device setup, the two places typing cannot reach.
+`rake dev` names its version after the jj change it runs from, since a
+working copy dressed as the deployment is the confusion the line exists to
+end. A run with its own accent fills the header and footer with it, so the
+difference shows before a word of either is read. 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
 
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index f738243..e480d7c 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -72,6 +72,7 @@ module ProTacts
       ].freeze #: Array[[String, String]]
 
       # @rbs @contact: Contact
+      # @rbs @login: String
       # @rbs @own: Contact
       # @rbs @sidebar: Phlex::HTML?
       # @rbs @save: String
@@ -101,10 +102,11 @@ module ProTacts
       # about is a field an import writes and an edit cannot undo.
       # Defaulted to the contact's own, so the ordinary edit says
       # nothing about any of it.
-      #: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: Phlex::HTML?, ?fields: Phlex::HTML?, ?lead: Phlex::HTML?, ?sidebar: Phlex::HTML?, ?save: String?) -> void
-      def initialize(contact:, notice: nil, action: nil, back: nil, aside: nil, fields: nil,
+      #: (contact: Contact, login: String, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: Phlex::HTML?, ?fields: Phlex::HTML?, ?lead: Phlex::HTML?, ?sidebar: Phlex::HTML?, ?save: String?) -> void
+      def initialize(contact:, login:, notice: nil, action: nil, back: nil, aside: nil, fields: nil,
                      lead: nil, sidebar: nil, save: nil)
         @contact = contact
+        @login = login
         @action = action || "/contacts/#{contact.id}"
         @back = back
         @aside = aside
@@ -133,7 +135,7 @@ module ProTacts
         # Wide only when something is beside the editor: the reading
         # width a single column wants is still what one card gets
         # (admin.css).
-        render Layout.new(title: "Edit #{@contact.name || @contact.id}",
+        render Layout.new(title: "Edit #{@contact.name || @contact.id}", login: @login,
                           wide: !@aside.nil? || !@sidebar.nil?, notice: @notice) do
           # The editor's Alpine scope, wrapping the record and the add
           # dialog both: the dialog names a type and the form grows a
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index d2219f9..926dbcb 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -14,10 +14,12 @@ module ProTacts
     # contact's groups as chips under it.
     class ContactsIndex < Phlex::HTML
       # @rbs @rows: Array[Contact]
+      # @rbs @login: String
       # @rbs @groups_of: Hash[String, Array[Store::Group]]
 
-      #: (contacts: Array[Contact], groups: Array[Store::Group]) -> void
-      def initialize(contacts:, groups:)
+      #: (contacts: Array[Contact], groups: Array[Store::Group], login: String) -> void
+      def initialize(contacts:, groups:, login:)
+        @login = login
         @rows = contacts.sort_by { [Format.sort_key(it), it.id] }
         # Whole groups inverted into memberships, the way the dashboard
         # inverts them for search: which groups a contact is in is the
@@ -29,7 +31,7 @@ module ProTacts
       end
 
       def view_template
-        render Layout.new(title: "Contacts") do
+        render Layout.new(title: "Contacts", login: @login) do
           section do
             div(class: "section-head") do
               h2(class: "type-label") { "contacts (#{@rows.length})" }
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 9463312..99708bf 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -26,9 +26,10 @@ module ProTacts
       # one: a card with no entries is a card whose history was lost,
       # and an empty default would render that as an ordinary quiet
       # record. Every group is the groups dialog's (GroupDialog).
-      #: (Contact contact, Array[Store::Group] groups, Array[Store::GroupChoice] all_groups, Array[Store::Change] changes, ?notice: String?) -> void
-      def initialize(contact:, groups:, all_groups:, changes:, notice: nil)
+      #: (Contact contact, String login, Array[Store::Group] groups, Array[Store::GroupChoice] all_groups, Array[Store::Change] changes, ?notice: String?) -> void
+      def initialize(contact:, login:, groups:, all_groups:, changes:, notice: nil)
         @contact = contact
+        @login = login
         @groups = groups
         @all_groups = all_groups
         @changes = changes
@@ -36,7 +37,7 @@ module ProTacts
       end
 
       def view_template
-        render Layout.new(title: @contact.name || @contact.id, notice: @notice) do
+        render Layout.new(title: @contact.name || @contact.id, login: @login, notice: @notice) do
           # The back-link line and the record card are one block, the
           # line its caption row: spaced like the dashboard's
           # section-head over its card (see .record in admin.css), not
diff --git a/lib/pro_tacts/admin/dashboard.rb b/lib/pro_tacts/admin/dashboard.rb
index 048c18b..7d6db31 100644
--- a/lib/pro_tacts/admin/dashboard.rb
+++ b/lib/pro_tacts/admin/dashboard.rb
@@ -23,6 +23,7 @@ module ProTacts
       private_constant :RECENT_LIMIT
 
       # @rbs @query: String
+      # @rbs @login: String
       # @rbs @upcoming: Array[Store::UpcomingBirthday]
       # @rbs @labels: Hash[String, Array[String]]
       # @rbs @groups: Array[Store::Group]
@@ -32,9 +33,10 @@ module ProTacts
       # The groups arrive whole because a search answers from both
       # sides of the relationship: a group is findable by its own name,
       # and a contact by the names of the groups it is in.
-      #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, groups: Array[Store::Group], ?notice: String?) -> void
-      def initialize(recent:, upcoming:, query:, groups:, notice: nil)
+      #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, login: String, groups: Array[Store::Group], ?notice: String?) -> void
+      def initialize(recent:, upcoming:, query:, login:, groups:, notice: nil)
         @query = query.to_s.strip
+        @login = login
         @upcoming = upcoming
         @notice = notice
         @labels = {}
@@ -45,7 +47,7 @@ module ProTacts
       end
 
       def view_template
-        render Layout.new(title: "Home", wide: true, query: @query,
+        render Layout.new(title: "Home", login: @login, wide: true, query: @query,
                           autofocus: @query.empty?, notice: @notice) do
           div(class: "dashboard") do
             section do
diff --git a/lib/pro_tacts/admin/device_setup.rb b/lib/pro_tacts/admin/device_setup.rb
index 28c607a..d02a90b 100644
--- a/lib/pro_tacts/admin/device_setup.rb
+++ b/lib/pro_tacts/admin/device_setup.rb
@@ -20,16 +20,18 @@ module ProTacts
     # about to carry, not a copy of them.
     class DeviceSetup < Phlex::HTML
       # @rbs @hostname: String
+      # @rbs @login: String
       # @rbs @name: String
 
-      #: (hostname: String, name: String) -> void
-      def initialize(hostname:, name:)
+      #: (hostname: String, login: String, name: String) -> void
+      def initialize(hostname:, login:, name:)
         @hostname = hostname
+        @login = login
         @name = name
       end
 
       def view_template
-        render Layout.new(title: "Device setup") do
+        render Layout.new(title: "Device setup", login: @login) do
           div(class: "record") do
             div(class: "record-nav") do
               a(href: "/", class: "type-label") { "‹ contacts" }
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index 9b2e7ad..f346548 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -24,6 +24,7 @@ module ProTacts
     # (#apply_edit's is-a-Hash posture) and leaves it as it stands.
     class GroupsEdit < Phlex::HTML
       # @rbs @group: Store::Group
+      # @rbs @login: String
       # @rbs @reading: Contact
       # @rbs @notice: String?
 
@@ -31,15 +32,16 @@ module ProTacts
       FORM = "group-form" #: String
       private_constant :FORM
 
-      #: (group: Store::Group, ?notice: String?) -> void
-      def initialize(group:, notice: nil)
+      #: (group: Store::Group, login: String, ?notice: String?) -> void
+      def initialize(group:, login:, notice: nil)
         @group = group
+        @login = login
         @reading = group.reading
         @notice = notice
       end
 
       def view_template
-        render Layout.new(title: "Edit #{@group.label}", notice: @notice) do
+        render Layout.new(title: "Edit #{@group.label}", login: @login, notice: @notice) do
           # The scope wraps the whole record because the add button sits
           # in the caption row above the card, where the contact editor's
           # add button sits, and the row it reveals is inside the form.
diff --git a/lib/pro_tacts/admin/groups_index.rb b/lib/pro_tacts/admin/groups_index.rb
index faa099f..bc0411e 100644
--- a/lib/pro_tacts/admin/groups_index.rb
+++ b/lib/pro_tacts/admin/groups_index.rb
@@ -15,10 +15,12 @@ module ProTacts
     # it will hold is added.
     class GroupsIndex < Phlex::HTML
       # @rbs @groups: Array[Store::Group]
+      # @rbs @login: String
       # @rbs @notice: String?
 
-      #: (groups: Array[Store::Group], ?notice: String?) -> void
-      def initialize(groups:, notice: nil)
+      #: (groups: Array[Store::Group], login: String, ?notice: String?) -> void
+      def initialize(groups:, login:, notice: nil)
+        @login = login
         # The `sync:` groups first — the books a client syncs, which
         # are the store's own rather than anything a person made — and
         # the rest alphabetically. Sorted here rather than asked of the
@@ -33,7 +35,7 @@ module ProTacts
       end
 
       def view_template
-        render Layout.new(title: "Groups", notice: @notice) do
+        render Layout.new(title: "Groups", login: @login, notice: @notice) do
           section do
             div(class: "section-head") do
               h2(class: "type-label") { "groups" }
diff --git a/lib/pro_tacts/admin/groups_members.rb b/lib/pro_tacts/admin/groups_members.rb
index e8cba2c..a5bff21 100644
--- a/lib/pro_tacts/admin/groups_members.rb
+++ b/lib/pro_tacts/admin/groups_members.rb
@@ -32,6 +32,7 @@ module ProTacts
     # being able to mint a contact.
     class GroupsMembers < Phlex::HTML
       # @rbs @group: Store::Group
+      # @rbs @login: String
       # @rbs @contacts: Array[Contact]
       # @rbs @capped: Array[String]
 
@@ -39,9 +40,10 @@ module ProTacts
       FORM = "members-form" #: String
       private_constant :FORM
 
-      #: (group: Store::Group, contacts: Array[Contact]) -> void
-      def initialize(group:, contacts:)
+      #: (group: Store::Group, contacts: Array[Contact], login: String) -> void
+      def initialize(group:, contacts:, login:)
         @group = group
+        @login = login
         # The order the show screen's list reads (Format.sort_key),
         # which is the order the book is browsed in everywhere else.
         @contacts = contacts.sort_by { [Format.sort_key(it), it.id] }
@@ -51,7 +53,7 @@ module ProTacts
       end
 
       def view_template
-        render Layout.new(title: "Members of #{@group.label}") do
+        render Layout.new(title: "Members of #{@group.label}", login: @login) do
           form(action: "/groups/#{@group.id}/members", method: "post",
                class: "member-picker", id: FORM, x_data: GroupFilter::STATE) do
             render RecordCard.new(
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index f893a24..be70a60 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -22,14 +22,16 @@ module ProTacts
     # a person reads; the members list is the narrowing of it.
     class GroupsShow < Phlex::HTML
       # @rbs @group: Store::Group
+      # @rbs @login: String
       # @rbs @reading: Contact
       # @rbs @contacts: Array[Contact]
       # @rbs @members: Array[Contact]
       # @rbs @changes: Array[Store::GroupChange]
 
-      #: (group: Store::Group, contacts: Array[Contact], changes: Array[Store::GroupChange]) -> void
-      def initialize(group:, contacts:, changes:)
+      #: (group: Store::Group, contacts: Array[Contact], changes: Array[Store::GroupChange], login: String) -> void
+      def initialize(group:, contacts:, changes:, login:)
         @group = group
+        @login = login
         @contacts = contacts
         @changes = changes
         @reading = group.reading
@@ -38,7 +40,7 @@ module ProTacts
       end
 
       def view_template
-        render Layout.new(title: @group.label) do
+        render Layout.new(title: @group.label, login: @login) do
           div(class: "record") do
             div(class: "record-nav") do
               a(href: "/groups", class: "type-label") { "‹ groups" }
diff --git a/lib/pro_tacts/admin/icons.rb b/lib/pro_tacts/admin/icons.rb
index 54f9911..78dd31d 100644
--- a/lib/pro_tacts/admin/icons.rb
+++ b/lib/pro_tacts/admin/icons.rb
@@ -19,11 +19,12 @@ module ProTacts
       # One glyph's shapes, each an element name and its attributes,
       # verbatim from the lucide-static SVG's own children: x, the
       # remove control's two strokes; calendar, the birthday row's
-      # picker; and the import row's verdict, a circle either way and
-      # a check in it when the row is in the book. A tag per shape
-      # rather than a bare list of path data, because calendar's
-      # frame is a rect and redrawing it as a path would be this file
-      # copying something other than what Lucide ships.
+      # picker; the import row's verdict, a circle either way and
+      # a check in it when the row is in the book; and
+      # chevron-down, the account menu's opening direction. A tag
+      # per shape rather than a bare list of path data, because
+      # calendar's frame is a rect and redrawing it as a path would
+      # be this file copying something other than what Lucide ships.
       GLYPHS = {
         x: [
           [:path, {d: "M18 6 6 18"}],
@@ -42,6 +43,9 @@ module ProTacts
           [:circle, {cx: 12, cy: 12, r: 10}],
           [:path, {d: "m9 12 2 2 4-4"}],
         ],
+        chevron_down: [
+          [:path, {d: "m6 9 6 6 6-6"}],
+        ],
       } #: Hash[Symbol, Array[[Symbol, Hash[Symbol, untyped]]]]
 
       #: (Symbol name, ?size: Integer) -> void
diff --git a/lib/pro_tacts/admin/import_saved.rb b/lib/pro_tacts/admin/import_saved.rb
index b62ad88..32b851f 100644
--- a/lib/pro_tacts/admin/import_saved.rb
+++ b/lib/pro_tacts/admin/import_saved.rb
@@ -18,6 +18,7 @@ module ProTacts
     # ContactCard's, the one renderer of a contact's record.
     class ImportSaved < Phlex::HTML
       # @rbs @contact: Contact
+      # @rbs @login: String
       # @rbs @groups: Array[Store::Group]
       # @rbs @all_groups: Array[Store::GroupChoice]
       # @rbs @aside: Phlex::HTML
@@ -26,9 +27,10 @@ module ProTacts
       # @rbs @land: String
       # @rbs @notice: String?
 
-      #: (contact: Contact, groups: Array[Store::Group], all_groups: Array[Store::GroupChoice], aside: Phlex::HTML, sidebar: Phlex::HTML, edit: String, land: String, ?notice: String?) -> void
-      def initialize(contact:, groups:, all_groups:, aside:, sidebar:, edit:, land:, notice: nil)
+      #: (contact: Contact, login: String, groups: Array[Store::Group], all_groups: Array[Store::GroupChoice], aside: Phlex::HTML, sidebar: Phlex::HTML, edit: String, land: String, ?notice: String?) -> void
+      def initialize(contact:, login:, groups:, all_groups:, aside:, sidebar:, edit:, land:, notice: nil)
         @contact = contact
+        @login = login
         @groups = groups
         @all_groups = all_groups
         @aside = aside
@@ -40,7 +42,7 @@ module ProTacts
 
       def view_template
         edit = @edit
-        render Layout.new(title: @contact.name || @contact.id, wide: true, notice: @notice) do
+        render Layout.new(title: @contact.name || @contact.id, login: @login, wide: true, notice: @notice) do
           render RecordCard.new(
             nav: -> { a(href: edit, class: "btn", data_size: "sm") { "edit" } },
             aside: @aside,
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
index ddf2449..5691380 100644
--- a/lib/pro_tacts/admin/import_upload.rb
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -21,18 +21,20 @@ module ProTacts
     # contacts and is still there tomorrow, so the walk ends on that
     # rather than on a copy of it under this form (Web#close_walk).
     class ImportUpload < Phlex::HTML
+      # @rbs @login: String
       # @rbs @notice: String?
 
       FORM = "import-form" #: String
       private_constant :FORM
 
-      #: (?notice: String?) -> void
-      def initialize(notice: nil)
+      #: (login: String, ?notice: String?) -> void
+      def initialize(login:, notice: nil)
+        @login = login
         @notice = notice
       end
 
       def view_template
-        render Layout.new(title: "Import", notice: @notice) do
+        render Layout.new(title: "Import", login: @login, notice: @notice) do
           render RecordCard.new(heading: "Import contacts", submit: "Import", form: FORM) do
             form(action: "/import", method: "post", enctype: "multipart/form-data", id: FORM) do
               label(class: "field") do
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index de9376c..59e0252 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -1,6 +1,7 @@
 require "pro_tacts/admin/phlex"
 
 require "pro_tacts"
+require "pro_tacts/admin/icons"
 
 module ProTacts
   module Admin
@@ -30,9 +31,22 @@ module ProTacts
     # job it keeps doing it: the popovers open and close by the
     # Popover API, not by script.
     class Layout < Phlex::HTML
-      #: (title: String, ?wide: bool, ?query: String?, ?autofocus: bool, ?notice: String?) -> void
-      def initialize(title:, wide: false, query: nil, autofocus: false, notice: nil)
+      # The account menu's popover, opened by the button that names
+      # the login.
+      MENU = "user-menu" #: String
+      private_constant :MENU
+
+      # @rbs @title: String
+      # @rbs @login: String
+      # @rbs @wide: bool
+      # @rbs @query: String?
+      # @rbs @autofocus: bool
+      # @rbs @notice: String?
+
+      #: (title: String, login: String, ?wide: bool, ?query: String?, ?autofocus: bool, ?notice: String?) -> void
+      def initialize(title:, login:, wide: false, query: nil, autofocus: false, notice: nil)
         @title = title
+        @login = login
         @wide = wide
         @query = query
         @autofocus = autofocus
@@ -74,39 +88,68 @@ module ProTacts
           end
           body do
             header(class: "admin-header") do
-              # The groups list rides beside the name because it is
-              # where a group is created, and creating one is a thing
-              # to reach from anywhere; search still finds one by name.
-              # The contacts list is the browse surface (docs/DESIGN.md,
-              # "The core idea").
-              nav do
-                a(href: "/") { "pro-tacts" }
-                a(href: "/contacts") { "contacts" }
-                a(href: "/groups") { "groups" }
-              end
-              # Every screen's chrome, not the dashboard's alone: the
-              # input keeps the query it carries — a results page has
+              # The name and, beside it, the one control every screen
+              # shares: what this app is and how it is asked a
+              # question, on the left where reading starts. The input
+              # keeps the query it carries — a results page has
               # somewhere to be besides the input — and focuses only
               # where the screen asked for it.
+              a(href: "/") { "pro-tacts" }
               form(action: "/", method: "get", class: "search-form") do
                 input(type: "search", name: "q", value: @query,
                       placeholder: "Search contacts", autofocus: @autofocus)
               end
+              # The destinations and the account, at the right edge:
+              # the contacts list is the browse surface and the groups
+              # list is where a group is created — both reachable from
+              # anywhere (docs/DESIGN.md, "The core idea"), search
+              # still finding a group by name — with who is asking
+              # named beside them.
+              nav do
+                a(href: "/contacts") { "contacts" }
+                a(href: "/groups") { "groups" }
+              end
+              # The account the proxy vouches for (ProxyAuth), named
+              # where the session reads as belonging to someone. The
+              # button opens a menu rather than a page because the
+              # account has no page: a login decides which cards sync
+              # to whose devices (docs/plans/2026-09-12-per-user-
+              # books.md) and nothing else, so what hangs off it is
+              # the two destinations that are not records and so
+              # cannot be searched for — import, a handful of times in
+              # a book's life (docs/plans/2026-09-21-import-a-vcf.md),
+              # and device setup. A popover by the Popover API, like
+              # every floated layer here; the list sits beside its
+              # button in the header so it can be positioned from it
+              # (admin.css) while the Popover API still renders it
+              # above everything when open.
+              div(class: "user-menu") do
+                button(type: "button", data_size: "sm", popovertarget: MENU) do
+                  span { @login }
+                  render Icon.new(:chevron_down)
+                end
+                ul(id: MENU, popover: "auto", class: "user-menu-list") do
+                  li { a(href: "/import") { "import" } }
+                  li { a(href: "/setup") { "device setup" } }
+                end
+              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 shell's other edge: which build is answering and
+            # whether it is logging what it answers, both 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. `rake dev` stamps the jj
             # change it runs from instead; a run with neither says
-            # nothing rather than guessing what it is.
+            # nothing rather than guessing what it is. Centered, with
+            # nothing else in the footer: the frame's other edge is
+            # the build's own label, and a build with no label still
+            # draws the edge.
             #
             # Debug logging says so while it is on because it dumps
             # every sync whole, contact data included (see ExchangeLog),
@@ -116,20 +159,10 @@ module ProTacts
             # 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. Import is
-            # here for the same reason and is rarer still: an address
-            # book arrives a handful of times in a book's life
-            # (docs/plans/2026-09-21-import-a-vcf.md).
             footer(class: "admin-footer") do
               version = ProTacts.config.version
               span(class: "type-label") { version } if version
               span(class: "type-label") { "+debug" } if ProTacts.config.debug?
-              a(href: "/import", class: "type-label") { "import" }
-              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
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index efa9731..44e1e3f 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -23,7 +23,7 @@ module ProTacts
         # contacts (docs/DESIGN.md, "The core idea").
         r.get do
           response["Content-Type"] = "text/html; charset=utf-8"
-          Admin::ContactsIndex.call(contacts: store.contacts, groups: store.all_groups)
+          Admin::ContactsIndex.call(contacts: store.contacts, groups: store.all_groups, login: @login)
         end
 
         # The browser's create, from the dashboard's dialog: POST is
@@ -71,7 +71,7 @@ module ProTacts
           # not_found handler fills in, same as the page below.
           if contact
             response["Content-Type"] = "text/html; charset=utf-8"
-            Admin::ContactsEdit.call(contact:, back: edit_back(contact))
+            Admin::ContactsEdit.call(contact:, login: @login, back: edit_back(contact))
           end
         end
 
@@ -124,6 +124,7 @@ module ProTacts
         recent: store.contacts_by_recency,
         upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
         query:,
+        login: @login,
         groups: store.all_groups,
         notice:,
       )
@@ -262,6 +263,7 @@ module ProTacts
       response["Content-Type"] = "text/html; charset=utf-8"
       Admin::ContactsShow.call(
         contact:,
+        login: @login,
         groups: store.groups_of(contact.id),
         all_groups: store.group_choices,
         changes: store.changes_of(contact.id),
@@ -272,7 +274,7 @@ module ProTacts
     #: (Contact contact, ?notice: String) -> String
     def edit_screen(contact, notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::ContactsEdit.call(contact:, notice:, back: edit_back(contact))
+      Admin::ContactsEdit.call(contact:, login: @login, notice:, back: edit_back(contact))
     end
 
     # The edit's way back, named by the route rather than left to the
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index cabf95b..0bc5413 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -19,7 +19,7 @@ module ProTacts
       r.is do
         r.get do
           response["Content-Type"] = "text/html; charset=utf-8"
-          Admin::GroupsIndex.call(groups: store.all_groups)
+          Admin::GroupsIndex.call(groups: store.all_groups, login: @login)
         end
 
         r.post do
@@ -29,7 +29,8 @@ module ProTacts
         rescue Sequel::UniqueConstraintViolation
           # A taken name (db/migrations/008_group_names.rb).
           response["Content-Type"] = "text/html; charset=utf-8"
-          Admin::GroupsIndex.call(groups: store.all_groups, notice: "Another group is already named #{name}.")
+          Admin::GroupsIndex.call(groups: store.all_groups, login: @login,
+                                   notice: "Another group is already named #{name}.")
         end
       end
 
@@ -62,6 +63,7 @@ module ProTacts
             response["Content-Type"] = "text/html; charset=utf-8"
             Admin::GroupsShow.call(
               group:,
+              login: @login,
               contacts: store.contacts,
               changes: store.group_changes_of(id),
             )
@@ -127,13 +129,13 @@ module ProTacts
     #: (Store::Group group, ?notice: String) -> String
     def group_edit_screen(group, notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::GroupsEdit.call(group:, notice:)
+      Admin::GroupsEdit.call(group:, login: @login, notice:)
     end
 
     #: (Store::Group group) -> String
     def members_screen(group)
       response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::GroupsMembers.call(group:, contacts: store.contacts)
+      Admin::GroupsMembers.call(group:, contacts: store.contacts, login: @login)
     end
   end
 end
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index b49b2d1..b5a5983 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -204,6 +204,7 @@ module ProTacts
       response["Content-Type"] = "text/html; charset=utf-8"
       return Admin::ImportSaved.call(
         contact:,
+        login: @login,
         groups: store.groups_of(contact.id),
         all_groups: store.group_choices,
         aside: Admin::ImportOriginal.new(card: original, dropped:),
@@ -223,6 +224,7 @@ module ProTacts
       merge = target && Import::Merge.new(target, card)
       Admin::ContactsEdit.call(
         contact: merge ? merge.contact : arriving,
+        login: @login,
         notice:,
         action: "/import/#{upload}/#{index}",
         # The submit says what it does — the import of this one
@@ -279,6 +281,7 @@ module ProTacts
       response["Content-Type"] = "text/html; charset=utf-8"
       Admin::ContactsEdit.call(
         contact:,
+        login: @login,
         notice:,
         action: "/import/#{upload}/#{index}",
         save: "Save",
@@ -605,7 +608,7 @@ module ProTacts
     #: (?notice: String?) -> String
     def import_screen(notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::ImportUpload.call(notice:)
+      Admin::ImportUpload.call(login: @login, notice:)
     end
 
     #: () -> String
diff --git a/lib/pro_tacts/web/setup.rb b/lib/pro_tacts/web/setup.rb
index 57a4d63..0af4772 100644
--- a/lib/pro_tacts/web/setup.rb
+++ b/lib/pro_tacts/web/setup.rb
@@ -23,7 +23,7 @@ module ProTacts
       r.is do
         r.get do
           response["Content-Type"] = "text/html; charset=utf-8"
-          Admin::DeviceSetup.call(hostname: r.host, name: ProTacts.config.instance_name)
+          Admin::DeviceSetup.call(hostname: r.host, login: @login, name: ProTacts.config.instance_name)
         end
       end
 
diff --git a/public/admin.css b/public/admin.css
index efa0d44..9369215 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -421,7 +421,6 @@ textarea:focus {
 .admin-header {
   display: flex;
   align-items: center;
-  justify-content: space-between;
   gap: var(--gl-space-s);
   padding: var(--gl-space-2xs) var(--gl-space-m);
   border-bottom: 1px solid var(--gl-color-border-hairline);
@@ -432,20 +431,22 @@ textarea:focus {
   color: var(--gl-color-text-primary);
   text-decoration: none;
 }
-/* The name and the destinations as one group on the left, so the
-   header's space-between still splits two things. The name sits a
-   tier above the links after it — one type step, the identity
-   rendered in type (docs/DESIGN.md) — while they step down a shade:
-   destinations, not the name. */
+/* Two groups in one flex row: the name and the search it carries on
+   the left, the destinations and the account at the right edge — the
+   nav's auto margin is what pushes the second group there, no
+   wrappers to hold. The name sits a tier above the links — one type
+   step, the identity rendered in type (docs/DESIGN.md) — while they
+   step down a shade: destinations, not the name. */
 .admin-header nav {
   display: flex;
   align-items: baseline;
   gap: var(--gl-space-s);
+  margin-left: auto;
 }
-.admin-header nav a:first-child {
+.admin-header > a {
   font-size: var(--gl-step-0);
 }
-.admin-header nav a + a {
+.admin-header nav a {
   color: var(--gl-color-text-secondary);
 }
 
@@ -454,25 +455,20 @@ textarea:focus {
    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. */
+   page taller than the viewport it simply follows the content. The
+   version it carries is centered: it is the only thing in the bar,
+   and a build with no label still draws the frame's edge. */
 .admin-footer {
   display: flex;
   flex-wrap: wrap;
   align-items: center;
+  justify-content: 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;
-}
-
 /* A run with an accent configured (PRO_TACTS_ACCENT; Layout marks the
    root) wears that Gloss accent instead of the default, and the chrome
    bars fill with it, so a working copy cannot pass for the deployment
@@ -491,7 +487,8 @@ textarea:focus {
   background: var(--gl-color-accent);
   border-color: var(--gl-color-accent);
 }
-:root[data-accent] :is(.admin-header a, .admin-footer .type-label) {
+:root[data-accent] :is(.admin-header a, .admin-header .user-menu button,
+.admin-footer .type-label) {
   color: var(--gl-color-accent-fg);
 }
 /* The one focus ring is drawn in the accent, which vanishes against a
@@ -499,6 +496,12 @@ textarea:focus {
 :root[data-accent] :is(.admin-header, .admin-footer) :focus-visible {
   outline-color: var(--gl-color-accent-fg);
 }
+/* The menu's list is a DOM child of the header but floats over the
+   page, where the accent ring is the one every link off the chrome
+   bars gets — the rule above would reach it by descent alone. */
+:root[data-accent] .admin-header .user-menu ul :focus-visible {
+  outline-color: var(--gl-color-accent);
+}
 
 /* The row hover Gloss gives is surface-fill — which the page repoint
    at the top of this file made the page's own color, so a hovered
@@ -616,8 +619,8 @@ body {
 
 /* field.css's `form { max-width: 28rem; }` is sized for the
    settings-form pattern; the header's search is a single input, and
-   flex: 1 + a narrower cap puts a deliberate 18rem box at the
-   header's right and lets it shrink on a narrow viewport. */
+   flex: 1 + a narrower cap lets the box grow beside the name toward
+   a deliberate 18rem and shrink on a narrow viewport. */
 .search-form {
   flex: 1;
   max-width: 18rem;
@@ -632,6 +635,54 @@ body {
   padding-block: var(--gl-space-3xs);
 }
 
+/* The account menu, the header's third region: the button naming the
+   login and the popover it opens. The wrapper is the popover's
+   positioning context — a popover is promoted to the top layer on
+   opening but keeps its DOM position for `position: absolute`, so
+   the list hangs below the button at the header's right edge with
+   no script and no anchor positioning; closed, the Popover API
+   itself keeps it out of the layout and the tab order. */
+.user-menu {
+  position: relative;
+  display: flex;
+}
+/* The UA popover styles center (`position: fixed; inset: 0; margin:
+   auto`), overridden to open off the button. A card's construction —
+   surface, hairline, the one --gl-shadow-float a floated layer
+   carries (Gloss's dialog, here) — at menu size; the rows take the
+   quiet button's own hover tint. */
+.user-menu ul {
+  position: absolute;
+  inset: auto;
+  inset-block-start: calc(100% + var(--gl-space-2xs));
+  inset-inline-end: 0;
+  margin: 0;
+  padding: var(--gl-space-2xs);
+  background: var(--gl-color-surface-card);
+  border: 1px solid var(--gl-color-border-hairline);
+  border-radius: var(--gl-radius-md);
+  box-shadow: var(--gl-shadow-float);
+}
+.user-menu ul a {
+  display: block;
+  padding: var(--gl-space-2xs) var(--gl-space-xs);
+  border-radius: var(--gl-radius-sm);
+  font-family: var(--gl-font-mono);
+  font-size: var(--gl-step--1);
+  color: var(--gl-color-text-primary);
+  text-decoration: none;
+  white-space: nowrap;
+}
+.user-menu ul a:hover {
+  background: var(--gl-color-surface-fill);
+}
+/* The caret says which way the menu opens, and turns around once it
+   has — state the markup can express on its own, the header's own
+   no-script rule holding where nothing else is needed. */
+.user-menu:has(ul:popover-open) svg {
+  rotate: 180deg;
+}
+
 /* Gloss zeroes every heading's margin (base/typography.css), which
    leaves a section's type-label flush against the list it names. The
    one vertical rhythm this layer adds, and shared by both dashboard
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 677e2b7..f25ff78 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -7,46 +7,46 @@
 module ProTacts
   module Admin
     class ContactsShow
-      def self.call: (contact: Contact, groups: Array[Store::Group], all_groups: Array[Store::GroupChoice], changes: Array[Store::Change], ?notice: String?) -> String
+      def self.call: (contact: Contact, login: String, groups: Array[Store::Group], all_groups: Array[Store::GroupChoice], changes: Array[Store::Change], ?notice: String?) -> String
     end
 
     # `aside` is a Phlex component, which has no type here: phlex
     # ships no signatures and the views are ignored (see the
     # Steepfile).
     class ContactsEdit
-      def self.call: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: untyped, ?fields: untyped, ?lead: untyped, ?sidebar: untyped, ?save: String?) -> String
+      def self.call: (contact: Contact, login: String, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: untyped, ?fields: untyped, ?lead: untyped, ?sidebar: untyped, ?save: String?) -> String
     end
 
     class Dashboard
-      def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, groups: Array[Store::Group], ?notice: String?) -> String
+      def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, login: String, groups: Array[Store::Group], ?notice: String?) -> String
     end
 
     class ContactsIndex
-      def self.call: (contacts: Array[Contact], groups: Array[Store::Group]) -> String
+      def self.call: (contacts: Array[Contact], groups: Array[Store::Group], login: String) -> String
     end
 
     class GroupsIndex
-      def self.call: (groups: Array[Store::Group], ?notice: String?) -> String
+      def self.call: (groups: Array[Store::Group], login: String, ?notice: String?) -> String
     end
 
     class GroupsShow
-      def self.call: (group: Store::Group, contacts: Array[Contact], changes: Array[Store::GroupChange]) -> String
+      def self.call: (group: Store::Group, contacts: Array[Contact], changes: Array[Store::GroupChange], login: String) -> String
     end
 
     class GroupsEdit
-      def self.call: (group: Store::Group, ?notice: String?) -> String
+      def self.call: (group: Store::Group, login: String, ?notice: String?) -> String
     end
 
     class GroupsMembers
-      def self.call: (group: Store::Group, contacts: Array[Contact]) -> String
+      def self.call: (group: Store::Group, contacts: Array[Contact], login: String) -> String
     end
 
     class DeviceSetup
-      def self.call: (hostname: String, name: String) -> String
+      def self.call: (hostname: String, login: String, name: String) -> String
     end
 
     class ImportUpload
-      def self.call: (?notice: String?) -> String
+      def self.call: (login: String, ?notice: String?) -> String
     end
 
     # Rendered into ContactsEdit rather than called, so these are the
@@ -68,7 +68,7 @@ module ProTacts
     end
 
     class ImportSaved
-      def self.call: (contact: Contact, groups: Array[Store::Group], all_groups: Array[Store::GroupChoice], aside: untyped, sidebar: untyped, edit: String, land: String, ?notice: String?) -> String
+      def self.call: (contact: Contact, login: String, groups: Array[Store::Group], all_groups: Array[Store::GroupChoice], aside: untyped, sidebar: untyped, edit: String, land: String, ?notice: String?) -> String
     end
 
     # Rendered by Dashboard, not called from checked code; only
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index a371a60..09c9852 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -77,8 +77,10 @@ class AdminGroupsPagesTest < Minitest::Test
     with_contacts({}) do
       get "/"
 
-      assert_includes last_response.body, %(<a href="/">pro-tacts</a><a href="/contacts">contacts</a><a href="/groups">groups</a>)
-      refute_includes last_response.body, %(<a href="/groups" class="type-label">)
+      body = last_response.body
+      assert_includes body, %(<a href="/">pro-tacts</a>)
+      assert_includes body, %(<nav><a href="/contacts">contacts</a><a href="/groups">groups</a></nav>)
+      refute_includes body, %(<a href="/groups" class="type-label">)
     end
   end
 
diff --git a/test/pro_tacts/admin/test_footer.rb b/test/pro_tacts/admin/test_layout.rb
similarity index 68%
rename from test/pro_tacts/admin/test_footer.rb
rename to test/pro_tacts/admin/test_layout.rb
index 64691ef..6f00d63 100644
--- a/test/pro_tacts/admin/test_footer.rb
+++ b/test/pro_tacts/admin/test_layout.rb
@@ -6,10 +6,10 @@ 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
+# The shell — header and footer — exercised through /setup: it is the
+# one screen that renders the layout without a store behind it, and
+# both bars are the layout's, so what holds here holds on every screen.
+class AdminLayoutTest < Minitest::Test
   include Rack::Test::Methods
 
   def app
@@ -30,16 +30,35 @@ class AdminFooterTest < Minitest::Test
     get "/setup"
   end
 
-  def test_the_footer_links_to_device_setup
+  # Everything above the fold, before the screen's own content starts.
+  #: () -> String
+  def header_html
+    last_response.body.split("<main").first
+  end
+
+  def test_the_header_names_who_is_logged_in
+    with_env({})
+
+    assert_includes header_html, "test@example.com"
+  end
+
+  def test_the_account_menu_opens_on_the_login
+    with_env({})
+
+    assert_includes header_html, %(popovertarget="user-menu")
+    assert_includes header_html, %(<ul id="user-menu" popover="auto")
+  end
+
+  def test_the_account_menu_links_to_the_import_screen
     with_env({})
 
-    assert_includes last_response.body, %(<a href="/setup" class="type-label">device setup</a>)
+    assert_includes header_html, %(<a href="/import">import</a>)
   end
 
-  def test_the_footer_links_to_the_import_screen
+  def test_the_account_menu_links_to_device_setup
     with_env({})
 
-    assert_includes last_response.body, %(<a href="/import" class="type-label">import</a>)
+    assert_includes header_html, %(<a href="/setup">device setup</a>)
   end
 
   def test_the_footer_names_the_version_the_image_carries
@@ -51,15 +70,16 @@ class AdminFooterTest < Minitest::Test
   # 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
-  # version to name, and the footer does not invent one.
+  # version to name, and the footer does not invent one — but the bar
+  # itself stays, the frame's other edge.
   def test_an_unversioned_run_names_no_version
     with_env("VERSION" => "")
 
-    assert_includes last_response.body, %(<footer class="admin-footer"><a href="/import")
+    assert_includes last_response.body, %(<footer class="admin-footer"></footer>)
 
     with_env({})
 
-    assert_includes last_response.body, %(<footer class="admin-footer"><a href="/import")
+    assert_includes last_response.body, %(<footer class="admin-footer"></footer>)
   end
 
   # The label is a standing notice, not a status: the log is recording