groups: a group's page lists its members as contacts
The members were tags in a row of the group's card, beside the
addresses and note it lends. They are what a group holds, so they
now list under the card the way /contacts lists everyone, avatar and
name, in the same order (Format.sort_key, lifted out of
ContactsIndex). The card keeps only what the group lends and drops
its grid when that is nothing; "edit members" moves to the list's
heading.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUeUZZ5774jTPhGrwGDcfN
change
commit 98c4f21ce53a8cbc9933c486099d0edcfdea3ed7
author Claude <noreply@anthropic.com>
date
parent ymzpsmts
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 8410f01..22b5762 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -106,14 +106,12 @@ module ProTacts
       # belongs to frames the values under it, and several of those
       # values are the group's rather than the contact's. Every tag opens
       # the group it names (docs/DESIGN.md, "Relationships are
-      # navigable"), the other half of the member tags on a group's own
-      # card (Admin::GroupsShow). Rendered for a contact in no group
+      # navigable"), the other half of the member list on a group's own
+      # page (Admin::GroupsShow). Rendered for a contact in no group
       # too when the dialog is on the page, even before any group
-      # exists, because the row holds the way to join or start one —
-      # the group card's members row, for the same reason, with its
-      # "edit members" in the same place. Without the dialog the row
-      # stands only when there is a member to name: an empty row with
-      # nothing to press is nothing at all.
+      # exists, because the row holds the way to join or start one.
+      # Without the dialog the row stands only when there is a member
+      # to name: an empty row with nothing to press is nothing at all.
       #: () -> void
       def groups_row
         dt(class: "type-label") { "groups" }
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 0663674..c401b35 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -10,18 +10,16 @@ module ProTacts
   module Admin
     # GET /contacts — every contact in one list, sorted by last name:
     # the one screen that browses the whole contact set (docs/DESIGN.md,
-    # "The core idea"). The sort key is N's family name (RFC 2426
-    # section 3.1.2), falling back the way Format.initials does — FN
-    # for a card with no N, the id for a card with no name at all.
-    # Rows carry the label the dashboard's rows do (Format.name_label)
-    # and the contact's groups as chips under it.
+    # "The core idea"), in Format.sort_key's order. Rows carry the
+    # label the dashboard's rows do (Format.name_label) and the
+    # contact's groups as chips under it.
     class ContactsIndex < Phlex::HTML
       # @rbs @rows: Array[Contact]
       # @rbs @groups_of: Hash[String, Array[Store::Group]]
 
       #: (contacts: Array[Contact], groups: Array[Store::Group]) -> void
       def initialize(contacts:, groups:)
-        @rows = contacts.sort_by { [sort_key(it), it.id] }
+        @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
         # store's fact, read from that side once instead of per row.
@@ -52,14 +50,6 @@ module ProTacts
 
       private
 
-      # The order a contact lists under, case-insensitive so the card's
-      # spelling of a family name does not move it.
-      #: (Contact contact) -> String
-      def sort_key(contact)
-        family, = contact.name_components || []
-        (family || contact.name || contact.id).to_s.downcase
-      end
-
       #: (Contact contact) -> void
       def render_row(contact)
         render ListItem.new(
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 3f0a7db..b216b33 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -22,6 +22,18 @@ module ProTacts
         nickname || name || contact.id
       end
 
+      # The order a contact lists under: N's family name (RFC 2426
+      # section 3.1.2), falling back the way #initials does — FN for a
+      # card with no N, the id for a card with no name at all — and
+      # case-insensitive, so the card's spelling of a family name does
+      # not move it. Every listing of contacts sorts by it, the id
+      # breaking a tie.
+      #: (Contact contact) -> String
+      def self.sort_key(contact)
+        family, = contact.name_components || []
+        (family || contact.name || contact.id).to_s.downcase
+      end
+
       # A contact's initials. N's given and family names (RFC 2426
       # section 3.1.2) are structured data and the real answer to "what
       # are this contact's initials" — falling back, when the card
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index dd4504d..391a86a 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -1,17 +1,21 @@
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts/admin/avatar"
 require "pro_tacts/admin/format"
 require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/layout"
+require "pro_tacts/admin/list_item"
 
 module ProTacts
   module Admin
-    # GET /groups/:id — a group's card. Groups are records, not labels
-    # (docs/DESIGN.md, "Relationships are navigable"), so this is a
-    # contact's card in every way that carries over: what the group
-    # lends on the same type-and-value grid, and an attribute with no
-    # data gets no row. Its members are tags that open their records,
-    # the other half of the tags on a member's own card.
+    # GET /groups/:id — a group's card, and under it the contacts it
+    # holds. Groups are records, not labels (docs/DESIGN.md,
+    # "Relationships are navigable"), so the card is a contact's card
+    # in every way that carries over: what the group lends on the same
+    # type-and-value grid, and an attribute with no data gets no row.
+    # The members are what a group is, so they are a list rather than a
+    # row: /contacts' rows, narrowed to this group and in its order
+    # (Format.sort_key).
     class GroupsShow < Phlex::HTML
       # @rbs @group: Store::Group
       # @rbs @reading: Contact
@@ -21,7 +25,7 @@ module ProTacts
       def initialize(group:, members:)
         @group = group
         @reading = group.reading
-        @members = members
+        @members = members.sort_by { [Format.sort_key(it), it.id] }
       end
 
       def view_template
@@ -36,22 +40,25 @@ module ProTacts
                 div(class: "detail-header") do
                   h1(class: "type-h2") { render GroupLabel.new(group: @group) }
                 end
-                # Never empty, unlike ContactsShow's: the members row
-                # always renders (#members_row).
-                dl(class: "detail-grid") { rows }
+                # No grid for a group that lends nothing, so its name
+                # sits alone in the card (.detail-header in admin.css).
+                dl(class: "detail-grid") { rows } if lends?
               end
             end
           end
+          members_list
         end
       end
 
       private
 
-      # Members first, the order a contact's card puts its groups in:
-      # the relationship frames the rows under it.
+      #: () -> bool
+      def lends?
+        @reading.addresses.any? || @reading.notes.any?
+      end
+
       #: () -> void
       def rows
-        members_row
         @reading.addresses.each do |address|
           row(Format.type_label(nil, address.types, "address")) do
             Format.address_lines(address).each { |line| div { line } }
@@ -62,22 +69,30 @@ module ProTacts
         end
       end
 
-      # Rendered even with no members, because its link is the way to
-      # add one. Membership is edited here rather than in the editor,
-      # which edits only the lines the group holds. The link sits at the
-      # row's right edge where a lent row's badge sits (admin.css), and
-      # has no href until the members screen exists.
+      # Rendered even with no members, because its head holds the way
+      # to add one. Membership is edited here rather than in the
+      # editor, which edits only the lines the group holds. The link
+      # sits where a section's action does (admin.css), and has no
+      # href until the members screen exists.
       #: () -> void
-      def members_row
-        row("members") do
-          if @members.any?
-            div(class: "tag-set") do
+      def members_list
+        section do
+          div(class: "section-head") do
+            h2(class: "type-label") { "members (#{@members.length})" }
+            a(class: "type-label") { "edit members" }
+          end
+          if @members.empty?
+            p(class: "type-body-sm") { "No members yet." }
+          else
+            ul(class: "card") do
               @members.each do |member|
-                a(href: "/contacts/#{member.id}", class: "tag") { member.name || member.id }
+                render ListItem.new(
+                  href: "/contacts/#{member.id}",
+                  avatar: Avatar.new(contact: member, size: "lg"),
+                ) { Format.name_label(member) }
               end
             end
           end
-          a(class: "type-label") { "edit members" }
         end
       end
 
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index 06880f9..62bf84d 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -92,7 +92,7 @@ module ProTacts
       Admin::GroupsEdit.call(group:, notice:)
     end
 
-    # A group's members as contacts, in the listing's own order.
+    # A group's members as contacts; the page puts them in order.
     #: (Store::Group group) -> Array[Contact]
     def members_of(group)
       store.contacts.select { group.members.include?(it.id) }
diff --git a/public/admin.css b/public/admin.css
index 16abecb..f6b9598 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -647,6 +647,15 @@ section > h2 {
   inset-inline-end: 0;
   translate: 0 -50%;
 }
+/* An action that is a type-label link rather than a button — a group
+   page's "edit members" — is the heading's own size, so it needs none
+   of the pinning above: in flow at the right edge, on the heading's
+   baseline. */
+.section-head:has(> a.type-label) {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+}
 
 /* base/typography.css's `li + li { margin-top: var(--gl-space-3xs); }`
    is a prose-list rule (Markdown bullet lists) that also matches
@@ -761,12 +770,10 @@ span.avatar[data-size="lg"] {
 
    Baseline-aligned: a grid item blockifies, so the tag's inline-flex
    becomes a flex container, which takes its baseline from its first
-   item — the tag's text — and so shares the value's. A group card's
-   "edit members" takes the same place, and is plain text with a
-   baseline of its own; a contact card's "edit groups" button takes
-   it too, a flex container like the tag. */
+   item — the tag's text — and so shares the value's. A contact
+   card's "edit groups" button takes the same place, a flex container
+   like the tag. */
 .detail-grid dd:has(> .tag),
-.detail-grid dd:has(> a.type-label),
 .detail-grid dd:has(> button) {
   display: grid;
   grid-template-columns: minmax(0, 1fr) auto;
@@ -774,13 +781,11 @@ span.avatar[data-size="lg"] {
   column-gap: var(--gl-space-s);
 }
 .detail-grid dd > .tag,
-.detail-grid dd > a.type-label,
 .detail-grid dd > button {
   grid-column: 2;
   grid-row: 1;
 }
 .detail-grid dd:has(> .tag) > div,
-.detail-grid dd:has(> a.type-label) > div,
 .detail-grid dd:has(> button) > div {
   grid-column: 1;
 }
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 1e93bbe..96f84b0 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -130,23 +130,50 @@ class AdminGroupsPagesTest < Minitest::Test
       assert_equal 200, last_response.status
       assert_includes body, "7 Calculus Close"
       assert_includes body, "Gate code 1854."
-      assert_includes body, %(<a href="/contacts/george" class="tag">George Boole</a>)
-      assert_includes body, %(<a href="/contacts/mary" class="tag">Mary Boole</a>)
+      assert_includes body, "members (2)"
+      assert_match %r{<a href="/contacts/george">.*?George Boole</div>}, body
+      assert_match %r{<a href="/contacts/mary">.*?Mary Boole</div>}, body
       assert_includes body, "edit members"
       refute_includes body, "Ada Lovelace"
     end
   end
 
+  # The members list the way /contacts does (Format.sort_key), not in
+  # the store's id order. Seeded so the two disagree.
+  def test_the_members_list_in_name_order
+    with_contacts(BOOLES.merge("zz" => "Alice Aardvark")) do |store|
+      id = household(store, members: %w[mary zz george])
+
+      get "/groups/#{id}"
+
+      assert_equal %w[zz george mary], last_response.body.scan(%r{<a href="/contacts/([^"]+)">}).flatten
+    end
+  end
+
   def test_a_memberless_card_still_offers_to_edit_members
     with_contacts(BOOLES) do |store|
       id = household(store, members: [])
 
       get "/groups/#{id}"
 
+      assert_includes last_response.body, "members (0)"
+      assert_includes last_response.body, "No members yet."
       assert_includes last_response.body, "edit members"
     end
   end
 
+  # A group that lends nothing renders no grid, so its name is not
+  # held above an empty one (.detail-header in admin.css).
+  def test_a_card_that_lends_nothing_has_no_grid
+    with_contacts(BOOLES) do |store|
+      id = store.create_group(name: "Booles")
+
+      get "/groups/#{id}"
+
+      refute_includes last_response.body, "detail-grid"
+    end
+  end
+
   def test_an_unknown_group_is_a_404
     with_contacts({}) do
       get "/groups/zzzz"