Reorganize the site header and footer
Assisted-by: GLM-5.3 via pi
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