group tag: name the group's first five members on hover
A group tag carries Gloss's tooltip listing the group's members in the
contacts page's order, the first five by name and then "and N more".
The store lists a group's members only for the reads that tag groups
(Store#groups_of with listed_members:, Store#all_groups with listing:),
so a group composed for a client reads none. Contact and ListedContact
answer <=>, which replaces Format.sort_key at each sort.

Gloss's card clips its contents and centers a tooltip on its trigger,
so a card holding a tooltip overflows, its end rows keep its corners
themselves, and a tag's list aligns to the tag.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191V6gdWnYUcU5DvJBAWoMj
change
commit 976f4cb5b386296b2cd60df4b5bee37acaa2371d
author Claude <noreply@anthropic.com>
date
parent bbdb64e2
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index f6fc42a..061f9e9 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -33,6 +33,7 @@ module ProTacts
       # @rbs @contact: Contact
       # @rbs @groups: Array[Group]
       # @rbs @walk: bool
+      # @rbs @listed_groups: Hash[String, Group]
 
       #: (contact: Contact, groups: Array[Group], ?walk: bool) -> void
       def initialize(contact:, groups:, walk: false)
@@ -40,6 +41,7 @@ module ProTacts
         @groups = groups
         @walk = walk
         @birthday = Format.birthday(contact)
+        @listed_groups = groups.to_h { [it.id, it] }
       end
 
       def view_template
@@ -122,7 +124,7 @@ module ProTacts
           dt(class: "type-label") { "notes" }
           dd(class: "type-body-sm") do
             div(style: "white-space: pre-wrap;") { text }
-            render GroupTag.new(group: lent.group)
+            render GroupTag.new(group: @listed_groups.fetch(lent.group.id))
           end
         end
       end
@@ -183,7 +185,7 @@ module ProTacts
           # The groups row's own tag, pinned to this row's first line
           # at the right edge of the value column (admin.css) — under
           # the value it could be read as marking the row below.
-          render GroupTag.new(group: lent_by) if lent_by
+          render GroupTag.new(group: @listed_groups.fetch(lent_by.id)) if lent_by
         end
       end
 
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 97b7227..ee9979e 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -9,7 +9,7 @@ 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"), in Format.sort_key's order. Rows carry the
+    # "The core idea"), in Contact#<=>'s order. Rows carry the
     # label the dashboard's rows do (Format.name_label) and the
     # contact's groups as chips under it.
     #
@@ -25,7 +25,7 @@ module ProTacts
       #: (contacts: Array[ListedContact], groups: Array[Group], login: String) -> void
       def initialize(contacts:, groups:, login:)
         @login = login
-        @rows = contacts.sort_by { [Format.sort_key(it), it.id] }
+        @rows = contacts.sort
         # Whole groups inverted into memberships, the way Admin::Search
         # inverts them: which groups a contact is in is the store's
         # fact, read from that side once instead of per row. The
@@ -81,7 +81,7 @@ module ProTacts
         end
       end
 
-      # The rows cut at each first letter of Format.sort_key, the way
+      # The rows cut at each first letter of Contact#sort_key, the way
       # a phone's contacts list is: a contact files under the letter
       # it sorts by, so one with no family name files under its FN's.
       # An accent files with its base letter, whose rows it follows,
@@ -97,7 +97,7 @@ module ProTacts
 
       #: (ListedContact contact) -> String
       def letter_of(contact)
-        base = Format.sort_key(contact).unicode_normalize(:nfd)[0].to_s
+        base = contact.sort_key.unicode_normalize(:nfd)[0].to_s
         base.match?(/[a-z]/) ? base.upcase : "#"
       end
     end
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 7ada939..d6826bd 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -23,18 +23,6 @@ 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 | ListedContact contact) -> String
-      def self.sort_key(contact)
-        family, = contact.name_components || []
-        (family || contact.name || contact.id).to_s.downcase
-      end
-
       # The string a search compares on: the query and the value it
       # is matched against both lowercased and accents folded to the
       # letters they sit on, so "munoz" finds "Muñoz". NFD plus
diff --git a/lib/pro_tacts/admin/group_tag.rb b/lib/pro_tacts/admin/group_tag.rb
index 876c07c..5d5ecf5 100644
--- a/lib/pro_tacts/admin/group_tag.rb
+++ b/lib/pro_tacts/admin/group_tag.rb
@@ -1,25 +1,40 @@
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts/admin/format"
 require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/icons"
 
 module ProTacts
   module Admin
     # A contact's group as a chip linking to the group, marked when the
-    # group has a photo so a reader knows its page holds one.
+    # group has a photo so a reader knows its page holds one, and naming
+    # its first few members in Gloss's tooltip.
     class GroupTag < Phlex::HTML
+      SHOWN = 5 #: Integer
+
       # @rbs @group: Group
+      # @rbs @members: Array[ListedContact]
 
       #: (group: Group) -> void
       def initialize(group:)
         @group = group
+        @members = group.listed_members || raise(ArgumentError, "group #{group.id} was read without its members listed")
       end
 
       def view_template
-        a(href: "/groups/#{@group.id}", class: "tag") do
-          render Icon.new(:image, size: 12) if @group.photo
-          render GroupLabel.new(group: @group)
-          span(class: "gl-visually-hidden") { "has a photo" } if @group.photo
+        shown = @members.take(SHOWN)
+        rest = @members.drop(SHOWN)
+        span do
+          a(href: "/groups/#{@group.id}", class: "tag") do
+            render Icon.new(:image, size: 12) if @group.photo
+            render GroupLabel.new(group: @group)
+            span(class: "gl-visually-hidden") { "has a photo" } if @group.photo
+          end
+          # Not a ul, which Gloss's .card-body ul sets as prose.
+          ol(role: "tooltip") do
+            shown.each { |contact| li { Format.name_label(contact) } }
+            li { "and #{rest.length} more" } if rest.any?
+          end
         end
       end
     end
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index 3b899ff..828dcb0 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -15,7 +15,7 @@ module ProTacts
     # 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). Under them the group's history, the change
+    # (Contact#<=>). Under them the group's history, the change
     # log the contact page renders in this page's own shape. The whole
     # book comes in beside the members (Store#contacts) because that
     # log names the cards a join or a leave moved, and a name is what
@@ -35,8 +35,7 @@ module ProTacts
         @contacts = contacts
         @changes = changes
         @reading = group.reading
-        @members = contacts.select { group.members.include?(it.id) }
-          .sort_by { [Format.sort_key(it), it.id] }
+        @members = contacts.select { group.members.include?(it.id) }.sort
       end
 
       def view_template
diff --git a/lib/pro_tacts/admin/member_picker.rb b/lib/pro_tacts/admin/member_picker.rb
index acdbe23..86e3825 100644
--- a/lib/pro_tacts/admin/member_picker.rb
+++ b/lib/pro_tacts/admin/member_picker.rb
@@ -45,9 +45,9 @@ module ProTacts
       def initialize(contacts:, login:, notice: nil)
         @login = login
         @notice = notice
-        # The order the show screen's list reads (Format.sort_key),
+        # The order the show screen's list reads (Contact#<=>),
         # which is the order the book is browsed in everywhere else.
-        @contacts = contacts.sort_by { [Format.sort_key(it), it.id] }
+        @contacts = contacts.sort
         # The ticked are never capped (GroupFilter.capped).
         @capped = GroupFilter.capped(@contacts.map(&:id), joined: ticked)
       end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 9f22592..c74c18e 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -342,6 +342,23 @@ module ProTacts
       text_of(vcard.property("NICKNAME"))
     end
 
+    # The order a contact lists under: N's family name (RFC 2426
+    # section 3.1.2), falling back the way Admin::Format.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.
+    #: () -> String
+    def sort_key
+      family, = name_components || []
+      (family || name || id).to_s.downcase
+    end
+
+    # Not Comparable, for Group#<=>'s reason.
+    #: (Contact | ListedContact other) -> Integer?
+    def <=>(other)
+      [sort_key, id] <=> [other.sort_key, other.id]
+    end
+
     # ORG's value (RFC 2426 section 3.5.5), in text form — the whole
     # value as the card spells it, org name and units together, which
     # is what a search reads; no screen has asked for the components
diff --git a/lib/pro_tacts/group.rb b/lib/pro_tacts/group.rb
index e73b72e..d55df22 100644
--- a/lib/pro_tacts/group.rb
+++ b/lib/pro_tacts/group.rb
@@ -7,22 +7,23 @@ require "pro_tacts/vcard"
 module ProTacts
   # A group as the admin screens read one: its own row, the lines it
   # lends in the order it lends them, its members' card ids, and
-  # whether it has a photo (docs/plans/2026-10-12-group-photos.md). The
-  # label is the SQL one Store#group_label computes, so a tag and a
+  # whether it has a photo (docs/plans/2026-10-12-group-photos.md).
+  # Only the reads that ask fill in `listed_members`, so a group
+  # composed into a card for a client reads none. The label is the SQL one Store#group_label computes, so a tag and a
   # heading can never disagree about what to call a nameless group.
   #
   # The signature lives in sig/pro_tacts/group.rbs, this being a Data
   # class.
   # @rbs skip
-  Group = Data.define(:id, :name, :label, :lines, :members, :photo)
+  Group = Data.define(:id, :name, :label, :lines, :members, :photo, :listed_members)
 
   # Reopened rather than defined in the block above, for the reason
   # CardDiff is.
   class Group
     # A photo is the exception, not the rule, so a group built without
     # saying is one without.
-    #: (?photo: bool, **untyped) -> void
-    def initialize(photo: false, **)
+    #: (?photo: bool, ?listed_members: Array[ListedContact]?, **untyped) -> void
+    def initialize(photo: false, listed_members: nil, **)
       super
     end
 
diff --git a/lib/pro_tacts/listed_contact.rb b/lib/pro_tacts/listed_contact.rb
index b46f06d..987e524 100644
--- a/lib/pro_tacts/listed_contact.rb
+++ b/lib/pro_tacts/listed_contact.rb
@@ -18,6 +18,7 @@ module ProTacts
   class ListedContact
     # @rbs @contact: Contact
     # @rbs @photo: bool
+    # @rbs @sort_key: String?
 
     #: (Contact contact, photo: bool) -> void
     def initialize(contact, photo:)
@@ -37,6 +38,16 @@ module ProTacts
     #: () -> String?
     def nickname = @contact.nickname
 
+    # Kept, since a sort asks at every comparison; nothing writes
+    # through a listed contact to change it.
+    #: () -> String
+    def sort_key = @sort_key ||= @contact.sort_key
+
+    #: (Contact | ListedContact other) -> Integer?
+    def <=>(other)
+      [sort_key, id] <=> [other.sort_key, other.id]
+    end
+
     #: () -> String?
     def organization = @contact.organization
 
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 205ba62..141b3dc 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -360,15 +360,17 @@ module ProTacts
     # rather than on it: what a group lends is in the card and belongs
     # to the model of one, and which groups a contact is in is a
     # relationship the card never carries.
-    #: (String id) -> Array[Group]
-    def groups_of(id)
-      load_groups(
-        labeled_groups
-          .join(:group_members, group_id: :id)
-          .where(card_id: id)
-          .order(Sequel[:groups][:id])
-          .all,
-      )
+    #: (String id, ?listed_members: bool) -> Array[Group]
+    def groups_of(id, listed_members: false)
+      rows = labeled_groups
+        .join(:group_members, group_id: :id)
+        .where(card_id: id)
+        .order(Sequel[:groups][:id])
+        .all
+      return load_groups(rows) unless listed_members
+
+      members = group_members.where(group_id: rows.map { it.fetch(:id) }).select_map(:card_id).map(&:to_s).uniq
+      load_groups(rows, listing: listed_contacts(members))
     end
 
     # The cards one user's client syncs: every member of `sync:*` and of
@@ -661,10 +663,12 @@ module ProTacts
     end
 
     # Every group, ordered by id — the order #groups_of and the
-    # composition both use, so a rename never moves one.
-    #: () -> Array[Group]
-    def all_groups
-      load_groups(labeled_groups.order(:id).all)
+    # composition both use, so a rename never moves one. A `listing`
+    # the caller already read lists each group's members out of it,
+    # since `sync:*` would otherwise read the whole book again.
+    #: (?listing: Array[ListedContact]?) -> Array[Group]
+    def all_groups(listing: nil)
+      load_groups(labeled_groups.order(:id).all, listing:)
     end
 
     # Every group as a picker needs it: the row, its label and how many
@@ -1124,24 +1128,29 @@ module ProTacts
 
     # Groups rows read whole: the lines and members of every group in
     # `rows`, and which have a photo, three reads however many groups
-    # there are. The photo read takes ids, never bytes.
-    #: (Array[Hash[Symbol, untyped]] rows) -> Array[Group]
-    def load_groups(rows)
+    # there are. The photo read takes ids, never bytes. A `listing` is
+    # sorted once here rather than by every tag that shows a group.
+    #: (Array[Hash[Symbol, untyped]] rows, ?listing: Array[ListedContact]?) -> Array[Group]
+    def load_groups(rows, listing: nil)
       ids = rows.map { it.fetch(:id).to_s }
       lines = group_properties.where(group_id: ids).order(:group_id, :position).all
         .group_by { it.fetch(:group_id).to_s }
       members = group_members.where(group_id: ids).order(:card_id).all
         .group_by { it.fetch(:group_id).to_s }
       photos = group_photos.where(group_id: ids).select_map(:group_id).map(&:to_s)
+      sorted = listing&.sort
       rows.map { |row|
         id = row.fetch(:id).to_s
+        member_ids = members.fetch(id, []).map { it.fetch(:card_id).to_s }
+        joined = member_ids.to_set
         Group.new(
           id:,
           name: row.fetch(:name)&.to_s,
           label: row.fetch(:label).to_s,
           lines: lines.fetch(id, []).map { it.fetch(:line).to_s },
-          members: members.fetch(id, []).map { it.fetch(:card_id).to_s },
+          members: member_ids,
           photo: photos.include?(id),
+          listed_members: sorted&.select { joined.include?(it.id) },
         )
       }
     end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 574bd99..46dbaf1 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -24,7 +24,8 @@ 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.listed_contacts, groups: store.all_groups, login: @login)
+          contacts = store.listed_contacts
+          Admin::ContactsIndex.call(contacts:, groups: store.all_groups(listing: contacts), login: @login)
         end
 
         # The browser's create, from the dashboard's dialog: POST is
@@ -295,7 +296,7 @@ module ProTacts
       Admin::ContactsShow.call(
         contact:,
         login: @login,
-        groups: store.groups_of(contact.id),
+        groups: store.groups_of(contact.id, listed_members: true),
         all_groups: store.group_choices,
         changes: store.changes_of(contact.id),
         notice:,
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 2872db6..8d5f38f 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -152,11 +152,11 @@ module ProTacts
     #: (VCard card, Integer index, Integer losses) -> Import::Staged::Row
     def import_row(card, index, losses)
       contact = import_contact(card, index)
-      Import::Staged::Row.new(label: Admin::Format.name_label(contact), sort_key: Admin::Format.sort_key(contact), losses:)
+      Import::Staged::Row.new(label: Admin::Format.name_label(contact), sort_key: contact.sort_key, losses:)
     end
 
     # The rows' places in the file, in the order the list shows them:
-    # the contacts page's (Format.sort_key), so a file reads the way
+    # the contacts page's (Contact#sort_key), so a file reads the way
     # the book it is joining does. The place in the file breaks a tie
     # where the book's id would, as a number: a row's id is that place
     # spelled as a string, and "10" sorts before "2". A row is still
@@ -215,7 +215,7 @@ module ProTacts
         Admin::ImportSaved.call(
           contact:,
           login: @login,
-          groups: store.groups_of(contact.id),
+          groups: store.groups_of(contact.id, listed_members: true),
           all_groups: store.group_choices,
           aside: Admin::ImportOriginal.new(card: original, dropped:),
           sidebar:,
diff --git a/public/admin.css b/public/admin.css
index a0e0bd2..d35c978 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -964,6 +964,55 @@ section > h2 {
   position: relative;
 }
 
+/* Gloss's card clips, which would cut off a group tag's member list
+   (Admin::GroupTag). The clip kept a row's hover fill in the card's
+   corners, so the end rows take the corners themselves. */
+.card:has([role="tooltip"]) {
+  overflow: visible;
+}
+.card:has([role="tooltip"]) > li:first-child {
+  border-start-start-radius: inherit;
+  border-start-end-radius: inherit;
+}
+.card:has([role="tooltip"]) > li:last-child {
+  border-end-start-radius: inherit;
+  border-end-end-radius: inherit;
+}
+/* Gloss centers a tooltip, which runs a list wider than its tag past
+   the edge the tag sits near: a set's tag starts the list with it, a
+   lent row's tag, at the column's end, ends it with it. */
+.tag-set [role="tooltip"] {
+  left: 0;
+  transform: none;
+}
+.detail-grid dd > span > [role="tooltip"] {
+  left: auto;
+  right: 0;
+  transform: none;
+}
+ol[role="tooltip"] {
+  list-style: none;
+}
+/* base.css's prose gap between list items. */
+ol[role="tooltip"] > li {
+  margin: 0;
+}
+/* On a phone a late tag sits too near the edge for a name after it,
+   so the set holds the list, which wraps inside it. */
+@media (max-width: 48rem) {
+  .tag-set {
+    position: relative;
+  }
+  .tag-set > span:has(> [role="tooltip"]) {
+    position: static;
+  }
+  .tag-set [role="tooltip"] {
+    width: max-content;
+    max-width: 100%;
+    white-space: normal;
+  }
+}
+
 /* The members screen (Admin::GroupsMembers). The form owns the
    picker's Alpine state and so wraps both cards — the record's own,
    holding the filter and the footer, and the list under it —
@@ -1116,12 +1165,13 @@ span.avatar[data-size="lg"] {
    them one flex item, and no source-order inversion — the value is
    still read before the group that lends it.
 
-   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 contact
+   Baseline-aligned: a grid item blockifies, so the span the tag's
+   tooltip hangs from (Admin::GroupTag), inline-flex in Gloss, becomes
+   a flex container, which takes its baseline from its first item —
+   the tag, and so the tag's text — and shares the value's. A contact
    card's "edit / new" take the same place as Gloss' button group,
    a flex container like the tag. */
-.detail-grid dd:has(> .tag),
+.detail-grid dd:has(> span > .tag),
 .detail-grid dd:has(> button),
 .detail-grid dd:has(> [role="group"]) {
   display: grid;
@@ -1129,13 +1179,13 @@ span.avatar[data-size="lg"] {
   align-items: baseline;
   column-gap: var(--gl-space-s);
 }
-.detail-grid dd > .tag,
+.detail-grid dd > span:has(> .tag),
 .detail-grid dd > button,
 .detail-grid dd > [role="group"] {
   grid-column: 2;
   grid-row: 1;
 }
-.detail-grid dd:has(> .tag) > div,
+.detail-grid dd:has(> span > .tag) > div,
 .detail-grid dd:has(> button) > div,
 .detail-grid dd:has(> [role="group"]) > div:not([role="group"]) {
   grid-column: 1;
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 3254979..9f3199c 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -86,11 +86,9 @@ module ProTacts
       def self.call: (contact: Contact, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], aside: untyped, sidebar: untyped, edit: String, land: String, ?notice: String?) -> String
     end
 
-    # Its sort_key and name_label are the import's list rows'
-    # (Web#import_row), ordered the way the listings of contacts are, and
+    # Its name_label is the import's list rows' (Web#import_row), and
     # its search_key is the fold Search matches on.
     module Format
-      def self.sort_key: (Contact | ListedContact contact) -> String
       def self.name_label: (Contact | ListedContact contact) -> String
       def self.search_key: (String value) -> String
     end
diff --git a/sig/pro_tacts/group.rbs b/sig/pro_tacts/group.rbs
index 7c5ebc1..8cb6d8a 100644
--- a/sig/pro_tacts/group.rbs
+++ b/sig/pro_tacts/group.rbs
@@ -10,7 +10,8 @@ module ProTacts
     attr_reader lines: Array[String]
     attr_reader members: Array[String]
     attr_reader photo: bool
+    attr_reader listed_members: Array[ListedContact]?
 
-    def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String], ?photo: bool) -> instance
+    def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String], ?photo: bool, ?listed_members: Array[ListedContact]?) -> instance
   end
 end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 3915856..9acf827 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -186,7 +186,8 @@ class AdminContactsPagesTest < Minitest::Test
 
       get "/contacts"
 
-      assert_includes last_response.body, %(<a href="/groups/#{group_id}" class="tag">Booles</a>)
+      assert_includes last_response.body,
+                      %(<a href="/groups/#{group_id}" class="tag">Booles</a><ol role="tooltip"><li>Ada Lovelace</li></ol>)
     end
   end
 
@@ -604,7 +605,7 @@ class AdminContactsPagesTest < Minitest::Test
       id = FixtureData.seed_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
 
       get "/contacts/ada"
-      marks = last_response.body.scan(%(<a href="/groups/#{id}" class="tag">Booles</a></dd>))
+      marks = last_response.body.scan(%(<a href="/groups/#{id}" class="tag">Booles</a><ol role="tooltip"><li>Ada Lovelace</li></ol></span></dd>))
 
       assert_equal 2, marks.size
       assert_includes last_response.body, "7 Calculus Close"
@@ -642,12 +643,29 @@ class AdminContactsPagesTest < Minitest::Test
       id = FixtureData.seed_group(store, members: ["ada"], lines: HOUSEHOLD)
 
       get "/contacts/ada"
-      marks = last_response.body.scan(%r{<a href="/groups/#{id}" class="tag">((?:(?!</a>).)*)</a></dd>})
+      marks = last_response.body.scan(%r{<a href="/groups/#{id}" class="tag">((?:(?!</a>).)*)</a><ol role="tooltip"><li>[^<]*</li></ol></span></dd>})
 
       assert_equal [[%(<span data-nameless>#{id}</span>)]] * 2, marks
     end
   end
 
+  # In the group page's order, by family name.
+  def test_a_tag_names_the_first_five_members_and_counts_the_rest
+    families = %w[Grey Abel Fox Cole Eddy Baker Drew]
+    cards = families.to_h {
+      [it.downcase, "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Sam #{it}\r\nN:#{it};Sam;;;\r\nUID:#{it.downcase}\r\nEND:VCARD\r\n"]
+    }
+
+    with_contacts(cards) do |store|
+      FixtureData.seed_group(store, name: "Club", members: cards.keys)
+
+      get "/contacts/grey"
+      names = last_response.body[%r{<ol role="tooltip">(.*?)</ol>}, 1].to_s.scan(%r{<li>(.*?)</li>}).flatten
+
+      assert_equal ["Sam Abel", "Sam Baker", "Sam Cole", "Sam Drew", "Sam Eddy", "and 2 more"], names
+    end
+  end
+
   # A contact in no group is marked nowhere: the tag is provenance,
   # not decoration, so a card with nothing lent renders none.
   def test_show_marks_nothing_on_a_contact_in_no_group
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 13dbb1c..c3dda56 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -155,7 +155,7 @@ class AdminGroupsPagesTest < Minitest::Test
     end
   end
 
-  # The members list the way /contacts does (Format.sort_key), not in
+  # The members list the way /contacts does (Contact#<=>), not in
   # the store's id order. Seeded so the two disagree. The scan takes
   # the list's own rows — an li's link, not the change log's, which
   # names the same cards for other reasons — and only from the
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index afc4eda..81be911 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -190,7 +190,7 @@ class AdminImportPagesTest < Minitest::Test
     end
   end
 
-  # The list reads the way the contacts page does (Format.sort_key),
+  # The list reads the way the contacts page does (Contact#sort_key),
   # not in the file's order, and the walk goes down it as it reads:
   # the upload opens on the first row, and a Save steps to the next.
   # The row keeps its place in the file as its name.
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 2dca832..5ec0610 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1372,6 +1372,17 @@ class StoreTest < Minitest::Test
     end
   end
 
+  def test_a_group_read_with_its_members_lists_them_in_order
+    with_store({"znorth" => ZED, "aiden" => AIDEN}) do |store|
+      FixtureData.seed_group(store, name: "Booles", members: ["znorth", "aiden"])
+
+      assert_equal [%w[aiden znorth]], store.groups_of("znorth", listed_members: true).map { it.listed_members.to_a.map(&:id) }
+      assert_equal [%w[aiden znorth]], store.all_groups(listing: store.listed_contacts).map { it.listed_members.to_a.map(&:id) }
+      assert_nil store.groups_of("znorth").fetch(0).listed_members
+      assert_nil store.all_groups.fetch(0).listed_members
+    end
+  end
+
   def test_a_group_inspects_to_its_label_and_member_count
     with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
       id = FixtureData.seed_group(store, name: "Household", members: %w[znorth aiden])