members screen: a picker the group's and the new group's screens subclass
GroupsMembers took a Group or a Contact and asked which at every
difference. Admin::MemberPicker now renders the list and calls title,
action, back, submit, ticked and was, which GroupsMembers (a group's)
and GroupsNew (one not yet made, from a contact) each define.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Ru2zvdWEwdwGhQ5V9z5X
change
commit bbdb64e2217b099126866ee2e8a1871fbc4ac111
author Claude <noreply@anthropic.com>
date
parent f978f41e
diff --git a/Steepfile b/Steepfile
index 437122c..d0bfc4c 100644
--- a/Steepfile
+++ b/Steepfile
@@ -21,8 +21,8 @@ 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 group_tag 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 recent_row record_card search_dialog search_field search_page search_results upcoming_birthdays
+    group_dialog group_filter group_label group_tag groups_edit groups_index groups_members groups_new groups_show icons import_groups import_original import_saved import_sidebar
+    import_target import_upload layout list_item member_picker phlex recent_row record_card search_dialog search_field search_page search_results upcoming_birthdays
   ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
   signature "sig"
 
diff --git a/lib/pro_tacts/admin/groups_members.rb b/lib/pro_tacts/admin/groups_members.rb
index 987ac45..9db9221 100644
--- a/lib/pro_tacts/admin/groups_members.rb
+++ b/lib/pro_tacts/admin/groups_members.rb
@@ -1,119 +1,38 @@
-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"
+require "pro_tacts/admin/member_picker"
 
 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), whose filter, cap,
-    # and show-all button these are, without its offer.
-    #
-    # The same screen picks a group's first members before the group
-    # exists: a contact card's "new" (Admin::ContactCard) opens it
-    # with that contact ticked, and its Create is the create
-    # (Web#apply_new_group), so a group someone backs out of is never
-    # made.
-    class GroupsMembers < Phlex::HTML
-      # @rbs @login: String
-      # @rbs @notice: String?
-      # @rbs @contacts: Array[ListedContact]
-      # @rbs @title: String
-      # @rbs @action: String
-      # @rbs @back: [String, (String | Phlex::HTML)]
-      # @rbs @submit: String
-      # @rbs @ticked: Array[String]
-      # @rbs @was: Array[String]
-      # @rbs @capped: Array[String]
-
-      # The form's id, for the footer's Save outside it.
-      FORM = "members-form" #: String
-      private_constant :FORM
+    # GET /groups/:id/members — the picker (Admin::MemberPicker) over
+    # a group that exists, its Save moving only what was toggled.
+    class GroupsMembers < MemberPicker
+      # @rbs @group: Group
 
-      # `from` is the record the screen was opened from: a group's
-      # page edits that group's members, and a contact's card starts a
-      # group with that contact in it. `was` is what the page loaded
-      # with, which a group not yet made has none of.
-      #: (from: Group | Contact, contacts: Array[ListedContact], login: String, ?notice: String?) -> void
-      def initialize(from:, contacts:, login:, notice: nil)
-        @login = login
-        @notice = notice
-        # 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] }
-        case from
-        in Group
-          @title = "Members of #{from.label}"
-          @action = "/groups/#{from.id}/members"
-          @back = ["/groups/#{from.id}", GroupLabel.new(group: from)]
-          @submit = "Save"
-          @ticked = from.members
-          @was = from.members
-        in Contact
-          @title = "New group"
-          @action = "/contacts/#{from.id}/groups/new"
-          @back = ["/contacts/#{from.id}", from.name || from.id]
-          # The verb the two create dialogs end on (GroupsIndex,
-          # ContactDialog).
-          @submit = "Create"
-          @ticked = [from.id]
-          @was = []
-        end
-        # The ticked are never capped (GroupFilter.capped).
-        @capped = GroupFilter.capped(@contacts.map(&:id), joined: @ticked)
+      #: (group: Group, contacts: Array[ListedContact], login: String) -> void
+      def initialize(group:, contacts:, login:)
+        @group = group
+        super(contacts:, login:)
       end
 
-      def view_template
-        render Layout.new(title: @title, login: @login, notice: @notice) do
-          form(action: @action, method: "post", class: "member-picker", id: FORM, x_data: GroupFilter::STATE) do
-            render RecordCard.new(back: @back, submit: @submit, 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|
-                  # 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: @ticked.include?(contact.id))
-                      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 @was.include?(contact.id)
-                  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
+      private
+
+      #: () -> String
+      def title = "Members of #{@group.label}"
+
+      #: () -> String
+      def action = "/groups/#{@group.id}/members"
+
+      #: () -> [String, (String | Phlex::HTML)]
+      def back = ["/groups/#{@group.id}", GroupLabel.new(group: @group)]
+
+      #: () -> String
+      def submit = "Save"
+
+      #: () -> Array[String]
+      def ticked = @group.members
+
+      #: () -> Array[String]
+      def was = @group.members
     end
   end
 end
diff --git a/lib/pro_tacts/admin/groups_new.rb b/lib/pro_tacts/admin/groups_new.rb
new file mode 100644
index 0000000..29cbaad
--- /dev/null
+++ b/lib/pro_tacts/admin/groups_new.rb
@@ -0,0 +1,43 @@
+require "pro_tacts/admin/member_picker"
+
+module ProTacts
+  module Admin
+    # GET /contacts/:id/groups/new — the picker (Admin::MemberPicker)
+    # over a group not yet made, opened from a contact card's "new"
+    # (Admin::ContactCard) with that contact ticked. Its Create is the
+    # create (Web#apply_new_group), so a group someone backs out of is
+    # never made, and with nothing yet to toggle against it sends no
+    # `was`.
+    class GroupsNew < MemberPicker
+      # @rbs @contact: Contact
+
+      #: (contact: Contact, contacts: Array[ListedContact], login: String, ?notice: String?) -> void
+      def initialize(contact:, contacts:, login:, notice: nil)
+        @contact = contact
+        super(contacts:, login:, notice:)
+      end
+
+      private
+
+      #: () -> String
+      def title = "New group"
+
+      #: () -> String
+      def action = "/contacts/#{@contact.id}/groups/new"
+
+      #: () -> [String, (String | Phlex::HTML)]
+      def back = ["/contacts/#{@contact.id}", @contact.name || @contact.id]
+
+      # The verb the two create dialogs end on (GroupsIndex,
+      # ContactDialog).
+      #: () -> String
+      def submit = "Create"
+
+      #: () -> Array[String]
+      def ticked = [@contact.id]
+
+      #: () -> Array[String]
+      def was = []
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/member_picker.rb b/lib/pro_tacts/admin/member_picker.rb
new file mode 100644
index 0000000..acdbe23
--- /dev/null
+++ b/lib/pro_tacts/admin/member_picker.rb
@@ -0,0 +1,88 @@
+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/layout"
+require "pro_tacts/admin/record_card"
+
+module ProTacts
+  module Admin
+    # A group's members picked from the whole book, the groups
+    # dialog's mirror (Admin::GroupDialog): a box per contact, ticked
+    # for the ones in the group. 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), whose filter, cap,
+    # and show-all button these are, without its offer.
+    #
+    # Two screens are this one: a group's (Admin::GroupsMembers) and a
+    # group's not yet made (Admin::GroupsNew). Each names its title,
+    # its form's action, its way back, its submit, the boxes ticked,
+    # and `was`, what the page loaded with, sent back so the save can
+    # move only what was toggled (Web#apply_members).
+    class MemberPicker < Phlex::HTML
+      # @rbs @login: String
+      # @rbs @notice: String?
+      # @rbs @contacts: Array[ListedContact]
+      # @rbs @capped: Array[String]
+
+      # The form's id, for the footer's submit outside it.
+      FORM = "members-form" #: String
+      private_constant :FORM
+
+      #: (contacts: Array[ListedContact], login: String, ?notice: String?) -> void
+      def initialize(contacts:, login:, notice: nil)
+        @login = login
+        @notice = notice
+        # 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 ticked are never capped (GroupFilter.capped).
+        @capped = GroupFilter.capped(@contacts.map(&:id), joined: ticked)
+      end
+
+      def view_template
+        render Layout.new(title:, login: @login, notice: @notice) do
+          form(action:, method: "post", class: "member-picker", id: FORM, x_data: GroupFilter::STATE) do
+            render RecordCard.new(back:, submit:, 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|
+                  # 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: ticked.include?(contact.id))
+                      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 was.include?(contact.id)
+                  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/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 81125bd..574bd99 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -4,7 +4,7 @@ require "pro_tacts/admin/contacts_edit"
 require "pro_tacts/admin/contacts_index"
 require "pro_tacts/admin/contacts_show"
 require "pro_tacts/admin/dashboard"
-require "pro_tacts/admin/groups_members"
+require "pro_tacts/admin/groups_new"
 require "pro_tacts/change_id"
 
 module ProTacts
@@ -286,7 +286,7 @@ module ProTacts
     #: (Contact contact, ?notice: String) -> String
     def new_group_screen(contact, notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::GroupsMembers.call(from: contact, contacts: store.listed_contacts, login: @login, notice:)
+      Admin::GroupsNew.call(contact:, contacts: store.listed_contacts, login: @login, notice:)
     end
 
     #: (Contact contact, ?notice: String) -> String
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index ec00357..b57b119 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -183,7 +183,7 @@ module ProTacts
     #: (Group group) -> String
     def members_screen(group)
       response["Content-Type"] = "text/html; charset=utf-8"
-      Admin::GroupsMembers.call(from: group, contacts: store.listed_contacts, login: @login)
+      Admin::GroupsMembers.call(group:, contacts: store.listed_contacts, login: @login)
     end
   end
 end
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 6f43e1b..3254979 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -49,7 +49,11 @@ module ProTacts
     end
 
     class GroupsMembers
-      def self.call: (from: Group | Contact, contacts: Array[ListedContact], login: String, ?notice: String?) -> String
+      def self.call: (group: Group, contacts: Array[ListedContact], login: String) -> String
+    end
+
+    class GroupsNew
+      def self.call: (contact: Contact, contacts: Array[ListedContact], login: String, ?notice: String?) -> String
     end
 
     class DeviceSetup