contacts: drop the filter for the header search
The filter matched a name; the search reads every field,
so the weaker match is gone, and to the search the list will yield.
The pickers keep the filter where a box is ticked to pick;
this page is read by letter, and the search is one key quick.

Assisted-by: Claude Opus 5.5 via Claude Code
change rlulnuytrvyrnotxwvnpozxzxnxyqprk
commit a7f3526e000c6bf49155d9e453aebb6c976300d6
author Alpha Chen <alpha@kejadlen.dev>
date
parent nslsomsn
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index ae96dab..e937a37 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -2,7 +2,6 @@ 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"
 
@@ -14,11 +13,10 @@ module ProTacts
     # label the dashboard's rows do (Format.name_label) and the
     # contact's groups as chips under it.
     #
-    # A filter over the rows narrows the list by that label as it is
-    # typed, GroupFilter's the way the members screen's is, so the
-    # book's two lists of contacts match a name the same way. A letter
-    # whose rows it all hides goes with them, heading and card, rather
-    # than standing as an empty section.
+    # Nothing on the page narrows the list. Finding a contact is the
+    # header's search, which matches every field rather than the name
+    # alone; a filter here was a second, weaker match that could only
+    # disagree with it. This page is for reading the book by letter.
     class ContactsIndex < Phlex::HTML
       # @rbs @rows: Array[Contact]
       # @rbs @login: String
@@ -39,17 +37,16 @@ module ProTacts
 
       def view_template
         render Layout.new(title: "Contacts", login: @login) do
-          section(class: "contacts-list", x_data: GroupFilter::STATE) do
+          section(class: "contacts-list") do
             div(class: "section-head") do
               h2(class: "type-label") { "contacts (#{@rows.length})" }
             end
             if @rows.empty?
               p(class: "type-body-sm") { "No contacts yet." }
             else
-              render GroupFilter.new(placeholder: "Filter contacts")
-              div(class: "letter-groups", x_ref: "options") do
+              div(class: "letter-groups") do
                 letter_groups.each do |letter, rows|
-                  section(":hidden": "![...$el.querySelectorAll('[data-label]')].some(row => visible(row))") do
+                  section do
                     h3(class: "type-label") { letter }
                     ul(class: "card") do
                       rows.each { render_row(it) }
@@ -57,7 +54,6 @@ module ProTacts
                   end
                 end
               end
-              render GroupFilter::Empty.new(any: true, offer: false, noun: "contacts")
             end
           end
         end
@@ -72,7 +68,7 @@ module ProTacts
         # name is the row's link and stretches over the row
         # (.linked-row in admin.css); the chips, each a link to its
         # group's page as on the record page, sit above that stretch.
-        li(class: "linked-row", **GroupFilter.row(Format.name_label(contact))) do
+        li(class: "linked-row") do
           render Avatar.new(contact:, size: "lg")
           div(style: "flex: 1; min-width: 0;") do
             a(href: "/contacts/#{contact.id}", class: "row-link") { Format.name_label(contact) }
diff --git a/lib/pro_tacts/admin/group_filter.rb b/lib/pro_tacts/admin/group_filter.rb
index af6e0ff..aea0c1a 100644
--- a/lib/pro_tacts/admin/group_filter.rb
+++ b/lib/pro_tacts/admin/group_filter.rb
@@ -10,12 +10,10 @@ module ProTacts
     # 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 three places too long a list is picked from, and
-    # by the one it is only read from, the contacts page
-    # (Admin::ContactsIndex). Of the three pickers, 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,
+    # 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,
@@ -25,9 +23,11 @@ module ProTacts
     # 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. The
-    # contacts page renders the filter alone: its rows have no boxes,
-    # and nothing caps a list whose whole point is the whole book.
+    # and `named` idles, nothing rendering Fresh to push onto it.
+    #
+    # The contacts page does not use it: a list that is only read, not
+    # picked from, leaves finding to the header's search, which matches
+    # more than a name (Admin::ContactsIndex).
     #
     # Alpine does the filtering, matching each row's label as
     # Format.search_key folds it — lowercased, accents folded —
@@ -57,10 +57,9 @@ 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, and a row with no box at all — the contacts
-      # page's — is ticked by nobody. `rows` is whatever carries
-      # `data-label` — a label in the dialog and the import, an li on
-      # the members screen and the contacts page, whose label sits inside its row — and is the element
+      # 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
diff --git a/public/admin.css b/public/admin.css
index fb7383f..30a518e 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -943,14 +943,6 @@ section > h2 {
   line-height: calc(var(--gl-step--1) * 1.55);
 }
 
-/* The contacts page's filter (Admin::ContactsIndex), between the
-   heading and the letters it narrows: the section-head's own step to
-   the list, so the field reads as the list's first line rather than
-   as the heading's. */
-.contacts-list > .filter {
-  margin-bottom: var(--gl-space-s);
-}
-
 /* A row with links inside it (ContactsIndex's rows, whose group
    chips open their groups): Gloss's whole-row control is an anchor
    that is the row's only child, and an anchor cannot hold the chips'
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index e74e8ce..0f417e8 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -205,21 +205,13 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
-  # The filter is the members screen's (Admin::GroupFilter): each row
-  # carries the label it matches on, lowercased, and each letter hides
-  # once the filter hides every row under it.
-  def test_contacts_filters_the_rows_by_name
-    red = ADA.sub("FN:Ada Lovelace", "FN:Sarah\r\nNICKNAME:Red").sub("UID:ada", "UID:red")
-
-    with_contacts({"red" => red}) do
-      get "/contacts"
+  # Finding a contact is the header's search, which matches more than
+  # a name; the list has no filter of its own to disagree with it.
+  def test_contacts_has_no_filter_of_its_own
+    with_contacts({"ada" => ADA}) { get "/contacts" }
 
-      body = last_response.body
-      assert_includes body, %(placeholder="Filter contacts")
-      assert_includes body, %(data-label="sarah (red)")
-      assert_includes body, %(<section :hidden="![...$el.querySelectorAll('[data-label]')].some(row => visible(row))">)
-      assert_includes body, "No contacts match."
-    end
+    refute_includes last_response.body, "Filter contacts"
+    refute_includes last_response.body, "data-label="
   end
 
   def test_contacts_with_no_contacts_says_so
@@ -227,7 +219,6 @@ class AdminContactsPagesTest < Minitest::Test
 
     assert_includes last_response.body, "contacts (0)"
     assert_includes last_response.body, "No contacts yet."
-    refute_includes last_response.body, "Filter contacts"
   end
 
   # The link's answer: every stored card's bytes in id order — the