Add the group members screen
The contact side of membership picks from a dialog capped by its own
popover; a group picks from the whole book, so the screen is the show
screen's list made tickable and the save moves only what was toggled.

Assisted-by: GLM-5.3 via pi
change xwvyropvyruywmlsqnrrplnntovrxsrv
commit f67ef7d323ecb7a13aa113dd42393b6a391dcde7
author Alpha Chen <alpha@kejadlen.dev>
date
parent tmtqlmsu
diff --git a/Steepfile b/Steepfile
index e7a0a43..65dc443 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,7 +22,7 @@ target :lib do
   # the files stay checked.
   ignore(*%w[
     avatar contact_card contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
-    group_dialog group_filter group_label groups_edit groups_index groups_show icons import_groups import_original import_saved import_sidebar
+    group_dialog group_filter group_label groups_edit groups_index groups_members groups_show icons import_groups import_original import_saved import_sidebar
     import_target import_upload layout list_item phlex record_card upcoming_birthdays
   ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
   signature "sig"
diff --git a/docs/plans/2026-09-23-the-members-screen.md b/docs/plans/2026-09-23-the-members-screen.md
new file mode 100644
index 0000000..6522891
--- /dev/null
+++ b/docs/plans/2026-09-23-the-members-screen.md
@@ -0,0 +1,71 @@
+# The members screen
+
+2026-09-23. The group's card grows its "edit members" link a working
+one: `/groups/:id/members`, a box per contact in the book, ticked for
+the ones in the group, filtered and capped the way the groups pickers
+are. The link had stood without an href since the show screen was
+written, waiting for this.
+
+## Why a screen and not the dialog
+
+The contact's side of the same relationship is a popover
+(`Admin::GroupDialog`). A contact's groups are a handful; a group's
+members are the whole book. The popover caps its list at eight rows
+because a popover cannot scroll past its own footer
+(docs/plans/2026-09-22-a-few-groups-at-a-time.md); a page scrolls, and
+the cap there is for the reading rather than the clipping — which is
+the import's situation too. And the show screen already renders the
+members list under the card; the screen is that list made editable,
+which is where its link has always said it would be.
+
+## The save is a diff, not a set
+
+The dialog's own posture (`was[]`, `Web#apply_groups`): the form sends
+what it toggled beside what the page loaded with, and the save moves
+only the difference. A membership moved elsewhere since the page
+loaded is in neither list, so the save says nothing about it — no
+version guard, nothing to revert, no snapshot to refuse. The editor's
+guard protects a wholesale replace (a name, the lines); membership
+toggles touch only what was toggled, so there is no overlap to lose.
+
+`Store#edit_members` wraps the moves in one transaction, leavers first
+and joiners last (`#edit_group`'s reason), over `add_member` and
+`remove_member` themselves — so a `sync:` group still moves cards
+between books exactly as the editor moves them, and the change log
+still records every card whose served bytes moved.
+
+## The filter is the groups' own
+
+`Admin::GroupFilter` takes a third consumer. Its nouns are
+parameterized — the placeholder, the empty line, the show-all count —
+and the members screen passes "contacts"; the filter, the cap, the
+never-capped ticked row, and the show-all button are unchanged, for
+the plan's own reason: a second answer to that problem would only be
+a place where the two could disagree.
+
+The offer to make what the filter names is not rendered. A filter
+word cannot mint a contact — that is the dashboard's dialog, with its
+name fields — so the placeholder says nothing about adding, and
+`Empty` drops its `!creatable` clause along with the offer: the clause
+exists to hold the message back while the make-it row stands in for
+it, and on a list where nothing can be made it would hide "No
+contacts match" behind a name no row will ever carry.
+
+The list is the show screen's own — `ul.card` rows, avatar and name
+at the list typography and spacing, the checkbox standing where a
+row's link does — because the screen is that list made editable, and
+a settings-checkbox stack wearing list parts reads as neither. The
+rows being `li`s rather than the labels the dialog and the import
+tick is why `GroupFilter`'s state reads its rows off `data-label`
+(the attribute every row already carried) instead of the element:
+the cap must hide the whole row, border and padding included, not
+the box alone inside an li that stays standing. Rows are matched on
+`Format.name_label`, nickname and all, because that is the name a
+person filters by, and sort the way every contact list sorts
+(`Format.sort_key`).
+
+The form owns the picker's Alpine state and so wraps both cards, the
+record's own above and the list under it — which puts the filter
+inside the form it filters, Enter in it guarded rather than
+submitted, the import's own answer where there is no outside to
+stand in.
diff --git a/lib/pro_tacts/admin/group_filter.rb b/lib/pro_tacts/admin/group_filter.rb
index e6acec2..61b9f0e 100644
--- a/lib/pro_tacts/admin/group_filter.rb
+++ b/lib/pro_tacts/admin/group_filter.rb
@@ -2,18 +2,25 @@ require "pro_tacts/admin/phlex"
 
 module ProTacts
   module Admin
-    # Picking groups out of a list too long to read: the filter over
+    # Picking from a list too long to read: the filter over
     # the rows, the cap on how many stand shown, the button that lifts
     # it, and the offer to make the group the filter names
     # (docs/plans/2026-09-22-a-few-groups-at-a-time.md).
     #
-    # Shared by the two places a contact's groups are picked: the
-    # dialog on a stored contact's card (Admin::GroupDialog) and the
-    # boxes beside an arriving one (Admin::ImportGroups). A book with
+    # Shared by the three places too long a list is picked from:
+    # the two where a contact's groups are picked — the dialog on a
+    # stored contact's card (Admin::GroupDialog) and the boxes beside
+    # an arriving one (Admin::ImportGroups) — and the members screen,
+    # where a group's contacts are (Admin::GroupsMembers). A book with
     # three groups and a book with three hundred are the same screen,
     # and the import's is the screen the walk opens once per contact,
     # so the two are the same problem — and a second answer to it
-    # would only be a place where they could disagree.
+    # would only be a place where they could disagree. The members
+    # screen renders the filter, the cap, and the show-all button,
+    # and not the offer to make what the filter names: a filter word
+    # cannot mint a contact — that is the dashboard's dialog, with
+    # its name fields — so its placeholder says nothing about adding,
+    # and `named` idles, nothing rendering Fresh to push onto it.
     #
     # Alpine does the filtering, matching each row's lowercased label
     # in `data-label` so no group's name is ever spliced into script.
@@ -41,19 +48,20 @@ module ProTacts
       # make one vanish would be hiding a decision rather than
       # narrowing a list — and the tick is read off the checkbox at
       # each evaluation, so a row answers the next filter change
-      # already ticked. `rows` is every row standing in the options,
-      # the element list `none` reads: no row visible, which is what
-      # "No groups match" claims. `named` is the names a tick on the
-      # offer committed to making (Named): `creatable` and `none`
-      # read it directly rather than off the rows' `data-label`s,
-      # which those two only re-read when the filter changes — a
-      # name committed since would be invisible to both until the
-      # next keystroke.
+      # already ticked. `rows` is whatever carries `data-label` — a
+      # label in the dialog and the import, an li on the members
+      # screen, whose label sits inside its row — and is the element
+      # list `none` reads: no row visible, which is what "No groups
+      # match" claims. `named` is the names a tick on the offer
+      # committed to making (Named): `creatable` and `none` read it
+      # directly rather than off the rows' `data-label`s, which those
+      # two only re-read when the filter changes — a name committed
+      # since would be invisible to both until the next keystroke.
       STATE = "{ filter: '', all: false, named: [], " \
               "get needle() { return this.filter.trim().toLowerCase() }, " \
               "get labels() { return [...this.$refs.options.querySelectorAll('[data-label]')]" \
               ".map(row => row.dataset.label) }, " \
-              "get rows() { return [...this.$refs.options.querySelectorAll('label')] }, " \
+              "get rows() { return [...this.$refs.options.querySelectorAll('[data-label]')] }, " \
               "shows(label) { return label.includes(this.needle) }, " \
               "visible(row) { return row.querySelector('input[type=checkbox]').checked || " \
               "(this.shows(row.dataset.label) && " \
@@ -66,10 +74,10 @@ module ProTacts
       # What a filterable row wears: the label to match on, read off
       # the element rather than out of a closure, whether the cap holds
       # it back, and the hiding the two of them drive. Every row the
-      # filter is meant to see carries it — a group this book has, and
-      # a name an import is about to make — because `creatable` is
-      # "none of these", and a row the filter cannot see is a row it
-      # would offer to create twice.
+      # filter is meant to see carries it — a group this book has, a
+      # name an import is about to make, a contact the book holds —
+      # because `creatable` is "none of these", and a row the filter
+      # cannot see is a row it would offer to create twice.
       #: (String label, ?capped: bool) -> Hash[untyped, untyped]
       def self.row(label, capped: false)
         {data: {label: label.downcase, capped:}, ":hidden": "!visible($el)"}
@@ -93,6 +101,7 @@ module ProTacts
 
       # @rbs @autofocus: bool
       # @rbs @in_form: bool
+      # @rbs @placeholder: String
 
       # `autofocus` where the picker is a popover that just opened and
       # nothing else wants the caret; not where it is one field among
@@ -103,15 +112,20 @@ module ProTacts
       # find a group is not a save. The dialog needs neither the flag
       # nor the guard, its filter standing outside the form it
       # filters, which is the sturdier answer where it is available.
-      #: (?autofocus: bool, ?in_form: bool) -> void
-      def initialize(autofocus: false, in_form: false)
+      #
+      # `placeholder` where the list is not groups and the filter
+      # cannot offer to make a row of it: the default names the offer,
+      # which is the groups' own.
+      #: (?autofocus: bool, ?in_form: bool, ?placeholder: String) -> void
+      def initialize(autofocus: false, in_form: false, placeholder: "Filter or add groups")
         @autofocus = autofocus
         @in_form = in_form
+        @placeholder = placeholder
       end
 
       def view_template
-        input(type: "search", placeholder: "Filter or add groups",
-              aria_label: "Filter or add groups", autofocus: @autofocus,
+        input(type: "search", placeholder: @placeholder,
+              aria_label: @placeholder, autofocus: @autofocus,
               x_model: "filter", **enter)
       end
 
@@ -167,18 +181,25 @@ module ProTacts
 
       # What the list says when the filter matches nothing and is not
       # a name either — which, the filter being blank, is also what an
-      # empty book says.
+      # empty book says. `offer` where the picker renders Fresh: there
+      # the make-it row stands in for the message while the filter
+      # names something creatable, and the message is held back for
+      # it; where nothing can be made, `none` alone is the message.
       class Empty < Phlex::HTML
         # @rbs @any: bool
+        # @rbs @offer: bool
+        # @rbs @noun: String
 
-        #: (any: bool) -> void
-        def initialize(any:)
+        #: (any: bool, ?offer: bool, ?noun: String) -> void
+        def initialize(any:, offer: true, noun: "groups")
           @any = any
+          @offer = offer
+          @noun = noun
         end
 
         def view_template
-          template(x_if: "none && !creatable") do
-            p(class: "gl-muted") { @any ? "No groups match." : "No groups yet." }
+          template(x_if: @offer ? "none && !creatable" : "none") do
+            p(class: "gl-muted") { @any ? "No #{@noun} match." : "No #{@noun} yet." }
           end
         end
       end
@@ -197,10 +218,12 @@ module ProTacts
       # left to x-text alone, so the button reads before Alpine runs.
       class Rest < Phlex::HTML
         # @rbs @count: Integer
+        # @rbs @noun: String
 
-        #: (count: Integer) -> void
-        def initialize(count:)
+        #: (count: Integer, ?noun: String) -> void
+        def initialize(count:, noun: "groups")
           @count = count
+          @noun = noun
         end
 
         def view_template
@@ -211,7 +234,7 @@ module ProTacts
         private
 
         #: () -> String
-        def label_text = "show all #{@count} groups"
+        def label_text = "show all #{@count} #{@noun}"
       end
     end
   end
diff --git a/lib/pro_tacts/admin/groups_members.rb b/lib/pro_tacts/admin/groups_members.rb
new file mode 100644
index 0000000..e8cba2c
--- /dev/null
+++ b/lib/pro_tacts/admin/groups_members.rb
@@ -0,0 +1,91 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/group_filter"
+require "pro_tacts/admin/group_label"
+require "pro_tacts/admin/layout"
+require "pro_tacts/admin/record_card"
+
+module ProTacts
+  module Admin
+    # GET /groups/:id/members — the members screen, the groups
+    # dialog's mirror (Admin::GroupDialog): a box per contact in the
+    # book, ticked for the ones in the group, and a save that moves
+    # only what was toggled (`was[]`, Web#apply_members). A screen
+    # and not the dialog's popover for the reason the cap gives from
+    # the other side: a contact's groups are a handful, a group's
+    # members are the whole book, and a page scrolls where a popover
+    # clips. The list is the show screen's own (admin.css, the rows
+    # carrying a checkbox where a row's link stands), so the screen
+    # reads as that list made editable rather than as a form's option
+    # stack (Admin::GroupsShow).
+    #
+    # The form owns the picker's Alpine state and so wraps both
+    # cards, the record's own above and the list under it — $refs and
+    # the state reach only inside their x-data element — which puts
+    # the filter inside the form it filters: Enter in it is guarded
+    # rather than submitted, the import's own answer where there is
+    # no outside to stand in (Admin::GroupFilter). The filter, the
+    # cap, and the show-all button are GroupFilter's; the offer to
+    # make what the filter names is not rendered, a filter word not
+    # being able to mint a contact.
+    class GroupsMembers < Phlex::HTML
+      # @rbs @group: Store::Group
+      # @rbs @contacts: Array[Contact]
+      # @rbs @capped: Array[String]
+
+      # The form's id, for the footer's Save outside it.
+      FORM = "members-form" #: String
+      private_constant :FORM
+
+      #: (group: Store::Group, contacts: Array[Contact]) -> void
+      def initialize(group:, contacts:)
+        @group = group
+        # 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] }
+        # The members are never capped, GroupFilter.capped's own rule:
+        # those are the boxes the screen is opened to untick.
+        @capped = GroupFilter.capped(@contacts.map(&:id), joined: group.members)
+      end
+
+      def view_template
+        render Layout.new(title: "Members of #{@group.label}") do
+          form(action: "/groups/#{@group.id}/members", method: "post",
+               class: "member-picker", id: FORM, x_data: GroupFilter::STATE) do
+            render RecordCard.new(
+              back: ["/groups/#{@group.id}", GroupLabel.new(group: @group)],
+              submit: "Save", form: FORM,
+            ) do
+              render GroupFilter.new(autofocus: true, in_form: true, placeholder: "Filter contacts")
+            end
+            div(class: "member-list") do
+              ul(class: "card", x_ref: "options") do
+                @contacts.each do |contact|
+                  member = @group.members.include?(contact.id)
+                  # The row's label is the name the lists show
+                  # (Format.name_label), nickname and all, because
+                  # that is the name a person filters by; the row
+                  # itself is the li, so the cap hides the whole of
+                  # it and not the box alone (Admin::GroupFilter).
+                  li(**GroupFilter.row(Format.name_label(contact),
+                                       capped: @capped.include?(contact.id))) do
+                    label do
+                      input(type: "checkbox", name: "members[]", value: contact.id, checked: member)
+                      render Avatar.new(contact:, size: "lg")
+                      div(style: "flex: 1; min-width: 0; font-weight: 550;") { Format.name_label(contact) }
+                    end
+                    input(type: "hidden", name: "was[]", value: contact.id) if member
+                  end
+                end
+              end
+              render GroupFilter::Empty.new(any: @contacts.any?, offer: false, noun: "contacts")
+              render GroupFilter::Rest.new(count: @contacts.length, noun: "contacts") if @capped.any?
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index 391a86a..afa68bd 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -71,15 +71,16 @@ module ProTacts
 
       # 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.
+      # editor, which edits only the lines the group holds; the link
+      # opens the members screen (Admin::GroupsMembers), this list
+      # made editable. It sits where a section's action does
+      # (admin.css).
       #: () -> void
       def members_list
         section do
           div(class: "section-head") do
             h2(class: "type-label") { "members (#{@members.length})" }
-            a(class: "type-label") { "edit members" }
+            a(href: "/groups/#{@group.id}/members", class: "type-label") { "edit members" }
           end
           if @members.empty?
             p(class: "type-body-sm") { "No members yet." }
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 005ac82..aa194d2 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -756,6 +756,18 @@ module ProTacts
       end
     end
 
+    # The members screen's save, #regroup's shape over a group: what
+    # it toggled rather than the set it shows, one transaction so a
+    # failure part-way leaves the membership as it was. Leavers first
+    # and joiners last, #edit_group's reason.
+    #: (String id, join: Array[String], leave: Array[String]) -> void
+    def edit_members(id, join:, leave:)
+      @database.transaction do
+        leave.each { remove_member(id, it) }
+        join.each { add_member(id, it) }
+      end
+    end
+
     # A card joins a group, and starts serving what the group lends.
     # Joining twice is joining once. Joining a `sync:` group puts the
     # card in a book, which is logged whether or not its bytes moved.
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index 62bf84d..d261cdd 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -1,13 +1,17 @@
 require "pro_tacts/admin/card_form"
 require "pro_tacts/admin/groups_edit"
 require "pro_tacts/admin/groups_index"
+require "pro_tacts/admin/groups_members"
 require "pro_tacts/admin/groups_show"
 
 module ProTacts
   class Web < Roda
     # The group screens, the contacts' own shape: the collection's
-    # GET and create, then a record's GET, its editor, and the POST
-    # that applies it. What the writes rest on is
+    # GET and create, then a record's GET, its editor, the POST that
+    # applies it, and the members screen beside both — membership is
+    # a relationship rather than a line the group holds, so it is
+    # edited from the card's own screen, not the editor
+    # (Admin::GroupsShow's reason). What the writes rest on is
     # docs/plans/2026-09-09-group-edits-propagate.md. A create lands
     # on the editor, a new group being nothing until something is
     # added to it.
@@ -35,6 +39,18 @@ module ProTacts
           group_edit_screen(group) if group
         end
 
+        r.get "members" do
+          group = store.group(id)
+          members_screen(group) if group
+        end
+
+        # Membership from the group's side, the members screen's save
+        # (Admin::GroupsMembers). Above the editor's POST, whose bare
+        # verb block would match this path too.
+        r.post "members" do
+          apply_members(r, id)
+        end
+
         r.post do
           apply_group_edit(r, id)
         end
@@ -86,12 +102,36 @@ module ProTacts
       r.redirect "/groups/#{id}", 303
     end
 
+    # The members screen's save, #apply_groups' shape over a group:
+    # the diff of what came back against what the page loaded, so a
+    # membership moved elsewhere since stays as it is, and a stale
+    # page has nothing to revert and no snapshot to refuse. Only ids
+    # that name a card — a membership row is a foreign key
+    # (#apply_group_edit's reason).
+    #: (untyped r, String id) -> String?
+    def apply_members(r, id)
+      group = store.group(id)
+      return if group.nil?
+
+      known = store.contacts.map(&:id)
+      checked = ids_in(r.params["members"]) & known
+      was = ids_in(r.params["was"]) & known
+      store.edit_members(id, join: checked - was, leave: was - checked)
+      r.redirect "/groups/#{id}", 303
+    end
+
     #: (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:)
     end
 
+    #: (Store::Group group) -> String
+    def members_screen(group)
+      response["Content-Type"] = "text/html; charset=utf-8"
+      Admin::GroupsMembers.call(group:, contacts: store.contacts)
+    end
+
     # A group's members as contacts; the page puts them in order.
     #: (Store::Group group) -> Array[Contact]
     def members_of(group)
diff --git a/public/admin.css b/public/admin.css
index 85b2932..dcd4782 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -717,6 +717,38 @@ section > h2 {
   position: relative;
 }
 
+/* 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 —
+   because $refs and the state reach only inside their x-data
+   element. Gloss bounds a bare form at the settings width and
+   spaces its children like fields; this one is a page, so it stacks
+   its cards the way main stacks a record and its sections
+   (.admin-main's own gap) and runs as wide as the column. */
+.member-picker {
+  max-width: none;
+  gap: var(--gl-space-m);
+}
+/* The list card and what trails it — the empty line, the show-all
+   button — one step apart, the dialog's own spacing under its
+   rows. */
+.member-list {
+  display: flex;
+  flex-direction: column;
+  gap: var(--gl-space-2xs);
+}
+/* A card list's row, tickable: the label is the row's clickable
+   surface and fills it, .card > li's own geometry doing the rest,
+   and its gap is the row's — the label rule's own 2xs is a settings
+   row's, and a list row's is the li's. The :has matches the label
+   rule's own specificity game, this layer loading after Gloss but
+   order not beating it there. */
+.card > li > label:has(> input[type="checkbox"]) {
+  flex: 1;
+  min-width: 0;
+  gap: var(--gl-space-xs);
+}
+
 /* base/typography.css's `li + li { margin-top: var(--gl-space-3xs); }`
    is a prose-list rule (Markdown bullet lists) that also matches
    Card's row list, adding a stray gap below every row but the last. */
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 84b5a72..73448a9 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -37,6 +37,10 @@ module ProTacts
       def self.call: (group: Store::Group, ?notice: String?) -> String
     end
 
+    class GroupsMembers
+      def self.call: (group: Store::Group, contacts: Array[Contact]) -> String
+    end
+
     class DeviceSetup
       def self.call: (hostname: String, name: String) -> String
     end
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 96f84b0..3526fe9 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -298,6 +298,129 @@ class AdminGroupsPagesTest < Minitest::Test
     end
   end
 
+  ## The members screen
+
+  def test_the_card_links_to_the_members_screen
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+
+      get "/groups/#{id}"
+
+      assert_includes last_response.body, %(<a href="/groups/#{id}/members" class="type-label">edit members</a>)
+    end
+  end
+
+  def test_the_screen_ticks_the_members_and_only_them
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+
+      get "/groups/#{id}/members"
+
+      body = last_response.body
+      assert_equal 200, last_response.status
+      assert_includes body, %(placeholder="Filter contacts")
+      %w[george mary].each do |member|
+        assert_includes body, %(name="members[]" value="#{member}" checked)
+        assert_includes body, %(name="was[]" value="#{member}")
+      end
+      assert_includes body, %(<input type="checkbox" name="members[]" value="ada">)
+      refute_includes body, %(name="was[]" value="ada")
+      # Every row carries the name a person filters by, nickname and all
+      assert_includes body, %(data-label="george boole")
+      assert_includes body, %(data-label="ada lovelace")
+    end
+  end
+
+  def test_the_members_list_in_name_order_on_the_screen_too
+    with_contacts(BOOLES.merge("zz" => "Alice Aardvark")) do |store|
+      id = household(store, members: %w[mary zz george])
+
+      get "/groups/#{id}/members"
+
+      assert_equal ["ada lovelace", "alice aardvark", "george boole", "mary boole"],
+                   last_response.body.scan(%r{<li data-label="([^"]+)"}).flatten
+    end
+  end
+
+  # The whole save: only what was toggled moves, and every card whose
+  # served bytes moved is told so in the change log.
+  def test_a_save_moves_only_what_was_toggled
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+
+      post "/groups/#{id}/members", members: ["george", "ada"], was: %w[george mary]
+
+      assert_equal 303, last_response.status
+      assert_equal "/groups/#{id}", URI(last_response["Location"]).path
+      assert_equal %w[ada george], store.group(id).members.sort
+      assert_equal "group", store.changes_of("mary").first.action
+      assert_equal "group", store.changes_of("ada").first.action
+    end
+  end
+
+  # The `was[]` posture: a membership moved elsewhere since the page
+  # loaded is in neither list, and the save says nothing about it.
+  def test_a_save_leaves_a_membership_moved_elsewhere_alone
+    with_contacts(BOOLES) do |store|
+      id = household(store, members: %w[george])
+      store.add_member(id, "mary")
+
+      post "/groups/#{id}/members", members: %w[george], was: %w[george]
+
+      assert_equal 303, last_response.status
+      assert_equal %w[george mary], store.group(id).members.sort
+    end
+  end
+
+  def test_a_save_ignores_ids_that_name_no_card
+    with_contacts(BOOLES) do |store|
+      id = household(store)
+
+      post "/groups/#{id}/members", members: %w[george nobody], was: %w[george mary]
+
+      assert_equal 303, last_response.status
+      assert_equal %w[george], store.group(id).members
+    end
+  end
+
+  def test_an_empty_book_says_so_on_the_members_screen
+    with_contacts({}) do |store|
+      id = store.create_group(name: "Booles")
+
+      get "/groups/#{id}/members"
+
+      assert_includes last_response.body, "No contacts yet."
+    end
+  end
+
+  # The cap and its lifting (Admin::GroupFilter), over contacts: the
+  # ninth row stands hidden until asked for, and a member is never
+  # capped, however far down the book it sorts.
+  def test_a_long_book_opens_capped_and_offers_the_rest
+    names = ("A".."I").each_with_index.to_h { |letter, i| ["p#{i}", "Person #{letter}"] }
+    with_contacts(names) do |store|
+      id = store.create_group(name: "Booles")
+      store.add_member(id, "p8")
+
+      get "/groups/#{id}/members"
+
+      body = last_response.body
+      assert_includes body, %(<li data-label="person i" :hidden="!visible($el)">)
+      assert_equal 1, body.scan("data-capped").length
+      assert_includes body, "show all 9 contacts"
+    end
+  end
+
+  def test_an_unknown_group_is_a_404_on_the_members_screen
+    with_contacts({}) do
+      get "/groups/zzzz/members"
+      assert_equal 404, last_response.status
+
+      post "/groups/zzzz/members", members: []
+      assert_equal 404, last_response.status
+    end
+  end
+
   ## Search
 
   def test_search_finds_a_group_by_its_name