search: live results in a dialog, or a page on a phone
Search used to submit and reload the dashboard to find a name;
now it narrows as you type, and Search on the server decides the same.
Wide screens get a dialog, a phone gets a page of its own,
and the dashboard's /?q= results page is gone.

Assisted-by: Claude Opus 5.5 via Claude Code
Issue: pnrqpyvk
change ultymzqzntsuvlqqyzruwozkzmwlxrkn
commit b1c27138403cc8dc8b4512068052c9b885da3226
author Alpha Chen <alpha@kejadlen.dev>
date
parent qwsnowur
diff --git a/Steepfile b/Steepfile
index 65dc443..9c7c3a8 100644
--- a/Steepfile
+++ b/Steepfile
@@ -23,7 +23,7 @@ target :lib do
   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_members groups_show icons import_groups import_original import_saved import_sidebar
-    import_target import_upload layout list_item phlex record_card upcoming_birthdays
+    import_target import_upload layout list_item 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/docs/DESIGN.md b/docs/DESIGN.md
index 0fb51a7..c63454d 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -85,14 +85,15 @@ left.
 
 Every collection screen is the same shape:
 
-1. A search input in the page header, focused and ready. The input
-   renders on every screen — a header that changes height between
-   pages makes the chrome jump, and a search should never require
-   going home first — but focuses only on collection screens, where
-   the search is the page.
-2. Results while there's a query.
-3. **Recently updated** when there isn't — the ten or so records touched last,
-   sorted by update time.
+1. A search one key away. Its button sits in the page header on every
+   screen — a header that changes height between pages makes the
+   chrome jump, and a search should never require going home first —
+   and it, or `/` from anywhere, opens a dialog with the input
+   focused and results narrowing as it is typed. On a phone it goes
+   to a search page instead, the same input with the screen to itself.
+2. **Recently updated** on the screen itself — the ten or so records
+   touched last, sorted by update time. The screen answers no query;
+   the dialog does, over whatever screen it was opened on.
 
 Recency is the answer to "what was I doing?", which is the actual question
 someone opening this app has. Match generously: a contact should be findable
@@ -102,11 +103,9 @@ The root screen is a dashboard of two columns. The primary column is the
 shape above. The ambient column beside it is a standing answer to "what's
 coming?": the next ten or so birthdays in arrival order, each row opening
 the contact's card. It is a list that browses, like the whole-set listing,
-because its question is not "where is X?", and it stands whether or not
-there is a query. The
-columns are equal and fluid, collapsing to one — birthdays under contacts —
-when the viewport cannot hold two side by side. Every other screen keeps a
-single reading column.
+because its question is not "where is X?". The columns are equal and fluid,
+collapsing to one — birthdays under contacts — when the viewport cannot hold
+two side by side. Every other screen keeps a single reading column.
 
 Under every screen sits the footer, the header's counterpart, holding what no
 search can turn up because none of it is a record: the version answering and
diff --git a/docs/plans/2026-09-30-live-search.md b/docs/plans/2026-09-30-live-search.md
new file mode 100644
index 0000000..f01d0cf
--- /dev/null
+++ b/docs/plans/2026-09-30-live-search.md
@@ -0,0 +1,81 @@
+# Live search
+
+The header's search was a GET form: type, submit, and the dashboard
+reloaded as a results page for `/?q=`. It now narrows as it is typed:
+in a dialog the header's button opens over any screen, and on a phone
+on a search page of its own. The dashboard's results page is gone.
+Task pnrqpyvk.
+
+## What was weighed
+
+The task named three ways to make it live:
+
+- **Filter in the page with Alpine**, over every contact embedded in
+  it. No server work, but it ships the whole book to the browser and
+  writes the match a second time in JavaScript — and since
+  `Admin::Search` (task ynwtzotq) the match is words across fields,
+  phones by their digits, and a ranking, not one substring check.
+- **Pagefind**, which indexes at build time. Contacts change at
+  runtime behind the identity gate, so the index would be rebuilt on
+  every card write and served behind the same check, and its ranking
+  would replace ours rather than keep it.
+- **Keep matching on the server** and have the input fetch results.
+  One implementation of what a match is, at a request per pause in
+  typing against local SQLite.
+
+The server won, for the reason the first two lose: `Admin::Search`
+stays the one place a match is decided. SQLite FTS5 and typo
+tolerance were set aside for a family-sized book in the same pass.
+
+## Why a dialog
+
+The first cut kept the input in the header and swapped the
+dashboard's contacts column for the fetched results. That made the
+search live on one screen only; everywhere else, typing still had to
+be submitted and land on the dashboard. A dialog works the same over
+any screen, and makes the header's control a button: one height on
+every screen, and none of the fold-and-open machinery the header
+input needed on a phone.
+
+`/` opens it from anywhere a key is not already typing into a field.
+That is what replaced "focused and ready" in `docs/DESIGN.md`: a
+button cannot hold focus for a page, and opening the dialog on load
+would cover the dashboard someone came to see.
+
+## Why a phone gets a page
+
+A phone's screen is already about the dialog's width, so there the
+dialog was only a smaller screen laid over the screen. A phone's
+header links to `GET /search` instead, a page of its own holding the
+same field. It keeps its address on the
+query as it is typed, so going back to it from a result lands on
+the results, and in a GET form back to itself it answers a
+submitted query without script, which the dialog cannot. The header
+renders both the dialog's button and the page's link, and CSS shows
+one per width. Before a word is typed the page lists the recently
+updated contacts, the dashboard's own rows, rather than standing
+empty; the dialog opens over a screen with its own content and
+stays empty until typed into.
+
+## Why the results page went
+
+With the dialog, `/?q=` would restate what the dialog already shows,
+and a screen that only restates another is deleted (`docs/DESIGN.md`).
+The search page is not that page back: it is the phone's way to the
+search, not the dashboard answering a query. The results are
+uncapped and scroll, since there is no "see all" to send a long list
+to.
+
+## Shape
+
+- `GET /search/results?q=` answers `Admin::SearchResults`, a fragment
+  rather than a page: the matched contacts, then the matched groups,
+  or the recently updated contacts for a blank query.
+- `Admin::SearchField` is the input and that list. It waits for
+  typing to pause, cancels a fetch the next one supersedes, and moves
+  a highlight through the result links with the arrow keys, which
+  Enter follows.
+- `Admin::SearchDialog` holds the field over any screen, wider than
+  a phone; `GET /search` (`Admin::SearchPage`) holds it on a phone,
+  with the results of its own `?q=` rendered in.
+- The dashboard is recency and birthdays, and nothing else.
diff --git a/lib/pro_tacts/admin/contact_dialog.rb b/lib/pro_tacts/admin/contact_dialog.rb
index 350640b..c85cb1e 100644
--- a/lib/pro_tacts/admin/contact_dialog.rb
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -39,8 +39,8 @@ module ProTacts
     # browser submit gets past — and the server's backstop for a
     # hand-crafted empty POST answers with a toast on the dashboard
     # instead (see Web's POST handler).
-    # `autofocus` fires when the popover is shown (it is scoped to the
-    # popover, so the header search's own page-load autofocus stands).
+    # `autofocus` fires when the popover is shown, being scoped to the
+    # popover.
     #
     # No company variant of this dialog: organizations are created by
     # the clients (Contacts.app's own Company flow PUTs a finished
@@ -49,13 +49,6 @@ module ProTacts
     # (docs/plans/2026-09-24-company-cards.md, "Creating"). The editor
     # knows a company card when it edits one (Contact#company?).
     class ContactDialog < Phlex::HTML
-      # @rbs @query: String
-
-      #: (query: String) -> void
-      def initialize(query:)
-        @query = query
-      end
-
       def view_template
         dialog(id: "new-contact", popover: "auto") do
           header { "New contact" }
@@ -73,10 +66,6 @@ module ProTacts
               plain "Last"
               input(**NamePair.last(nil, nil))
             end
-            # The search the dialog opened over — opening a popover
-            # loads no page, so this survives only the failed-create
-            # re-render, keeping the results the user was looking at.
-            input(type: "hidden", name: "q", value: @query)
           end
           footer do
             button(type: "button", popovertarget: "new-contact",
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 65e8c79..236c282 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -34,7 +34,7 @@ module ProTacts
     # nickname or note row over a card that carries neither, which has
     # nothing to lose. The etag rides along hidden, the snapshot
     # guard's half. `autofocus` on the first field: this screen's
-    # entry point is the name, not the header search.
+    # entry point is the name.
     class ContactsEdit < Phlex::HTML
       # The property types the add dialog offers, in the order it
       # lists them — exactly the rows the save can insert
diff --git a/lib/pro_tacts/admin/dashboard.rb b/lib/pro_tacts/admin/dashboard.rb
index c734693..7b42fc8 100644
--- a/lib/pro_tacts/admin/dashboard.rb
+++ b/lib/pro_tacts/admin/dashboard.rb
@@ -1,108 +1,64 @@
 require "pro_tacts/admin/phlex"
 
-require "pro_tacts/admin/avatar"
 require "pro_tacts/admin/contact_dialog"
-require "pro_tacts/admin/format"
-require "pro_tacts/admin/group_label"
-require "pro_tacts/admin/list_item"
 require "pro_tacts/admin/layout"
-require "pro_tacts/admin/search"
+require "pro_tacts/admin/recent_row"
 require "pro_tacts/admin/upcoming_birthdays"
 
 module ProTacts
   module Admin
-    # GET / — the dashboard (docs/DESIGN.md): the search in the page
-    # header, recency in the primary column, the birthdays the year is
-    # about to bring in the ambient one beside it. A query narrows the
-    # contacts column to matches across every contact, in the order
-    # Search.contacts ranks them; an empty query
-    # shows the ten most recently updated. The create dialog rides
+    # GET / — the dashboard (docs/DESIGN.md): recency in the primary
+    # column, the birthdays the year is about to bring in the ambient
+    # one beside it. It answers no query; the search is the header's
+    # dialog, on this screen as on every other
+    # (docs/plans/2026-09-30-live-search.md). The create dialog rides
     # along hidden on every render — a popover opens where it sits, so
     # the add button needs nothing from the server but the page it is
     # already on.
     class Dashboard < Phlex::HTML
+      # How many rows recently updated lists, here and on the search
+      # page before a query (Web#search_results).
       RECENT_LIMIT = 10
-      private_constant :RECENT_LIMIT
 
-      # @rbs @query: String
       # @rbs @login: String
       # @rbs @upcoming: Array[Store::UpcomingBirthday]
-      # @rbs @labels: Hash[String, Array[String]]
-      # @rbs @groups: Array[Group]
       # @rbs @rows: Array[Store::RecentContact]
       # @rbs @notice: String?
 
-      # The groups arrive whole because a search answers from both
-      # sides of the relationship: a group is findable by its own name,
-      # and a contact by the names of the groups it is in.
-      #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, login: String, groups: Array[Group], ?notice: String?) -> void
-      def initialize(recent:, upcoming:, query:, login:, groups:, notice: nil)
-        @query = query.to_s.strip
+      #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], login: String, ?notice: String?) -> void
+      def initialize(recent:, upcoming:, login:, notice: nil)
         @login = login
         @upcoming = upcoming
         @notice = notice
-        @labels = {}
-        groups.each { |group| group.members.each { (@labels[it] ||= []) << group.label } }
-        @groups = @query.empty? ? [] : Search.groups(groups, query: @query)
-        @rows = @query.empty? ? recent.first(RECENT_LIMIT) : Search.contacts(recent, query: @query, labels: @labels)
+        @rows = recent.first(RECENT_LIMIT)
       end
 
       def view_template
-        render Layout.new(title: "Home", login: @login, wide: true, query: @query,
-                          autofocus: @query.empty?, notice: @notice) do
+        render Layout.new(title: "Home", login: @login, wide: true, notice: @notice) do
           div(class: "dashboard") do
             section do
               div(class: "section-head") do
-                h2(class: "type-label") { @query.empty? ? "recently updated" : "contacts" }
+                h2(class: "type-label") { "recently updated" }
                 # A button, not a link: it changes the page's state
                 # rather than navigating, and the Popover API is what
                 # it invokes (see ContactDialog).
                 button(data_size: "sm", popovertarget: "new-contact") { "add contact" }
               end
               if @rows.empty?
-                p(class: "type-body-sm") { @query.empty? ? "No contacts yet." : "No contacts match." }
+                p(class: "type-body-sm") { "No contacts yet." }
               else
                 ul(class: "card") do
-                  @rows.each do
-                    render_row(it)
-                  end
+                  @rows.each { render RecentRow.new(row: it) }
                 end
               end
-              group_results if @groups.any?
             end
             render UpcomingBirthdays.new(upcoming: @upcoming)
           end
           # In the page but out of the columns: a popover opens from
           # wherever it sits, and hidden is its resting state.
-          render ContactDialog.new(query: @query)
+          render ContactDialog.new
         end
       end
-
-      private
-
-      # The groups whose names match, under the contacts: a group is a
-      # record the search reaches as surely as a contact is
-      # (docs/DESIGN.md, "When adding something new").
-      #: () -> void
-      def group_results
-        div(class: "section-head group-results") do
-          h2(class: "type-label") { "groups" }
-        end
-        ul(class: "card") do
-          @groups.each do |group|
-            render ListItem.new(href: "/groups/#{group.id}") { render GroupLabel.new(group:) }
-          end
-        end
-      end
-
-      #: (Store::RecentContact row) -> void
-      def render_row(row)
-        render ListItem.new(
-          href: "/contacts/#{row.contact.id}",
-          avatar: Avatar.new(contact: row.contact, size: "lg"),
-          trailing: Format.time_ago(row.updated_at),
-        ) { Format.name_label(row.contact) }
-      end
     end
   end
 end
diff --git a/lib/pro_tacts/admin/group_filter.rb b/lib/pro_tacts/admin/group_filter.rb
index 8847ac6..af6e0ff 100644
--- a/lib/pro_tacts/admin/group_filter.rb
+++ b/lib/pro_tacts/admin/group_filter.rb
@@ -138,7 +138,7 @@ module ProTacts
         # positioned at the field's end (admin.css). The click empties
         # the state rather than the box alone — `filter` is what the
         # rows, the cap, and the offer all read — and the reveal is
-        # CSS on the box, the header search's own (see Layout).
+        # CSS on the box, the search dialog's own (see admin.css).
         div(class: "filter") do
           input(type: "search", placeholder: @placeholder,
                 aria_label: @placeholder, autofocus: @autofocus,
diff --git a/lib/pro_tacts/admin/icons.rb b/lib/pro_tacts/admin/icons.rb
index 4bf5fd2..7359302 100644
--- a/lib/pro_tacts/admin/icons.rb
+++ b/lib/pro_tacts/admin/icons.rb
@@ -21,8 +21,9 @@ module ProTacts
       # remove control's two strokes; calendar, the birthday row's
       # picker; the import row's verdict, a circle either way and
       # a check in it when the row is in the book;
-      # chevron-down, the account menu's opening direction; and
-      # search, what the header's search folds into on a phone. A tag
+      # chevron-down, the account menu's opening direction;
+      # search, the glyph on the header's search button; and house,
+      # the phone header's way home from the search page. A tag
       # per shape rather than a bare list of path data, because
       # calendar's frame is a rect and redrawing it as a path would
       # be this file copying something other than what Lucide ships.
@@ -51,6 +52,10 @@ module ProTacts
           [:path, {d: "m21 21-4.34-4.34"}],
           [:circle, {cx: 11, cy: 11, r: 8}],
         ],
+        house: [
+          [:path, {d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"}],
+          [:path, {d: "M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"}],
+        ],
       } #: Hash[Symbol, Array[[Symbol, Hash[Symbol, untyped]]]]
 
       #: (Symbol name, ?size: Integer) -> void
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 39768ed..dca3d49 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -2,6 +2,7 @@ require "pro_tacts/admin/phlex"
 
 require "pro_tacts"
 require "pro_tacts/admin/icons"
+require "pro_tacts/admin/search_dialog"
 
 module ProTacts
   module Admin
@@ -9,16 +10,12 @@ module ProTacts
     # vendored Gloss stylesheets (see public/vendor/gloss and
     # docs/DESIGN.md), and a one-line header naming the app in type —
     # "no brand mark" is one of the rules that document inherits from
-    # Gloss — with the collection search beside the name on every
-    # screen: a header that grew and shrank with the search made the
-    # chrome jump between pages, and search-first (docs/DESIGN.md)
-    # wants finding a contact possible from anywhere. `query` carries
-    # the dashboard's current search into the input's value;
-    # `autofocus` is a screen saying its entry point is the search —
-    # the dashboard with an empty query, the one place the design
-    # doc wants "focused and ready". `wide` opts a screen out of the
-    # reading width a single column wants (see admin.css) — the
-    # dashboard root is the one screen that asks.
+    # Gloss — with the search's button beside the name on every
+    # screen: search-first (docs/DESIGN.md) wants finding a contact
+    # possible from anywhere, and the dialog it opens rides along in
+    # every render. `wide` opts a screen out of the reading width a
+    # single column wants (see admin.css) — the dashboard root is the
+    # one screen that asks.
     #
     # Alpine loads on every screen, and it is the only script here.
     # The admin UI was script-free until the edit screen needed to
@@ -39,17 +36,17 @@ module ProTacts
       # @rbs @title: String
       # @rbs @login: String
       # @rbs @wide: bool
-      # @rbs @query: String?
-      # @rbs @autofocus: bool
+      # @rbs @searching: bool
       # @rbs @notice: String?
 
-      #: (title: String, login: String, ?wide: bool, ?query: String?, ?autofocus: bool, ?notice: String?) -> void
-      def initialize(title:, login:, wide: false, query: nil, autofocus: false, notice: nil)
+      # `searching` is the search page saying it is the screen: the
+      # phone's link to the search would lead back to where it is.
+      #: (title: String, login: String, ?wide: bool, ?searching: bool, ?notice: String?) -> void
+      def initialize(title:, login:, wide: false, searching: false, notice: nil)
         @title = title
         @login = login
         @wide = wide
-        @query = query
-        @autofocus = autofocus
+        @searching = searching
         @notice = notice
       end
 
@@ -90,96 +87,79 @@ module ProTacts
             header(class: "admin-header") do
               # The name and, beside it, the one control every screen
               # shares: what this app is and how it is asked a
-              # question, on the left where reading starts. The input
-              # keeps the query it carries — a results page has
-              # somewhere to be besides the input — and focuses only
-              # where the screen asked for it.
+              # question, on the left where reading starts.
               a(href: "/") { "pro-tacts" }
-              # The search and, in the field's end, its clear: a link,
-              # because clearing the dashboard's search is navigating
-              # to the page with no query — the form's own target with
-              # nothing submitted — landing focused on an empty input
-              # again. CSS reveals it the moment there is text to clear
-              # (admin.css), typed-but-unsubmitted included, so the
-              # header needs no script for it; the native cancel WebKit
-              # and Blink draw is suppressed there too, one affordance
-              # rather than two.
-              #
-              # On a phone the search folds into its glyph and opens
-              # across the whole header (admin.css, keyed on
-              # data-open). A tap on the glyph opens it, and the label
-              # is what focuses the field from there. It folds again
-              # when focus leaves with nothing typed; a page carrying
-              # a query renders open, the query being what its results
-              # answer. Focus alone does not open it, because the
-              # dashboard's autofocus would then land every phone on
-              # an open search with the account hidden behind it. A
-              # touch anywhere else folds it at once rather than
-              # waiting on the blur, which a phone was seen to send
-              # late or not at all.
-              open = !@query.to_s.empty?
-              fold = "if ($el.querySelector('input').value === '') " \
-                     "{ open = false; $el.querySelector('input').blur() }"
-              form(action: "/", method: "get", class: "search-form",
-                   x_data: "{ open: #{open} }", "x-bind:data-open": "open",
-                   "@input": "open = true",
-                   "@focusout": "open = $el.contains($event.relatedTarget) || " \
-                                "$el.querySelector('input').value !== ''",
-                   "@pointerdown.outside": fold,
-                   **(open ? {data: {open: true}} : {})) do
-                label("@click": "open = true") do
-                  render Icon.new(:search)
-                  input(type: "search", name: "q", value: @query,
-                        placeholder: "Search contacts", autofocus: @autofocus)
-                end
-                a(href: "/", class: "icon-button search-clear", data_size: "sm",
-                  aria_label: "Clear search") { render Icon.new(:x) }
+              # The search's two ways in, one shown at each width
+              # (admin.css): on a wide screen a button opening the
+              # dialog over the page (SearchDialog), in the header's
+              # middle; on a phone a link, just the glyph, to the
+              # search page (SearchPage), which has the whole screen
+              # for its results. Neither is the input itself, so the
+              # header is one height on every screen. On the search
+              # page itself the phone's glyph is home instead, the
+              # name that is the way home elsewhere being hidden there.
+              button(type: "button", class: "search-button", data_size: "sm",
+                     popovertarget: SearchDialog::ID) do
+                render Icon.new(:search)
+                span { "Search" }
               end
-              # The destinations and the account, at the right edge:
-              # the contacts list is the browse surface and the groups
-              # list is where a group is created — both reachable from
-              # anywhere (docs/DESIGN.md, "The core idea"), search
-              # still finding a group by name — with who is asking
-              # named beside them. On a phone the two move into the
-              # account menu with home, the name's own link (the list
-              # below repeats all three for that), leaving the header
-              # the search and the account.
-              nav do
-                a(href: "/contacts") { "contacts" }
-                a(href: "/groups") { "groups" }
+              if @searching
+                a(href: "/", class: "icon-button phone-glyph", aria_label: "Home") { render Icon.new(:house) }
+              else
+                a(href: "/search", class: "icon-button phone-glyph", aria_label: "Search") { render Icon.new(:search) }
               end
-              # The account the proxy vouches for (ProxyAuth), named
-              # where the session reads as belonging to someone. The
-              # button opens a menu rather than a page because the
-              # account has no page: a login decides which cards sync
-              # to whose devices (docs/plans/2026-09-12-per-user-
-              # books.md) and nothing else, so what hangs off it is
-              # the destinations that are not records and so
-              # cannot be searched for — import, a handful of times in
-              # a book's life (docs/plans/2026-09-21-import-a-vcf.md),
-              # export, its counterpart, and device setup. A popover
-              # by the Popover API, like
-              # every floated layer here; the list sits beside its
-              # button in the header because a promoted popover is
-              # positioned against the viewport, not the DOM beside
-              # its button, so admin.css anchors it to the wrapper by
-              # name — while the Popover API still renders it above
-              # everything when open.
-              div(class: "user-menu") do
-                button(type: "button", data_size: "sm", popovertarget: MENU) do
-                  span { @login }
-                  render Icon.new(:chevron_down)
+              div(class: "header-end") do
+                # The destinations and the account, at the right edge
+                # in one box, so the search can take the middle between
+                # it and the name (admin.css):
+                # the contacts list is the browse surface and the groups
+                # list is where a group is created — both reachable from
+                # anywhere (docs/DESIGN.md, "The core idea"), search
+                # still finding a group by name — with who is asking
+                # named beside them. On a phone the two move into the
+                # account menu with home, the name's own link (the list
+                # below repeats all three for that), leaving the header
+                # the search and the account.
+                nav do
+                  a(href: "/contacts") { "contacts" }
+                  a(href: "/groups") { "groups" }
                 end
-                ul(id: MENU, popover: "auto", class: "user-menu-list") do
-                  li(class: "menu-nav") { a(href: "/") { "home" } }
-                  li(class: "menu-nav") { a(href: "/contacts") { "contacts" } }
-                  li(class: "menu-nav") { a(href: "/groups") { "groups" } }
-                  li { a(href: "/import") { "import" } }
-                  li { a(href: "/contacts/export.vcf") { "export" } }
-                  li { a(href: "/setup") { "device setup" } }
+                # The account the proxy vouches for (ProxyAuth), named
+                # where the session reads as belonging to someone. The
+                # button opens a menu rather than a page because the
+                # account has no page: a login decides which cards sync
+                # to whose devices (docs/plans/2026-09-12-per-user-
+                # books.md) and nothing else, so what hangs off it is
+                # the destinations that are not records and so
+                # cannot be searched for — import, a handful of times in
+                # a book's life (docs/plans/2026-09-21-import-a-vcf.md),
+                # export, its counterpart, and device setup. A popover
+                # by the Popover API, like
+                # every floated layer here; the list sits beside its
+                # button in the header because a promoted popover is
+                # positioned against the viewport, not the DOM beside
+                # its button, so admin.css anchors it to the wrapper by
+                # name — while the Popover API still renders it above
+                # everything when open.
+                div(class: "user-menu") do
+                  button(type: "button", data_size: "sm", popovertarget: MENU) do
+                    span { @login }
+                    render Icon.new(:chevron_down)
+                  end
+                  ul(id: MENU, popover: "auto", class: "user-menu-list") do
+                    li(class: "menu-nav") { a(href: "/") { "home" } }
+                    li(class: "menu-nav") { a(href: "/contacts") { "contacts" } }
+                    li(class: "menu-nav") { a(href: "/groups") { "groups" } }
+                    li { a(href: "/import") { "import" } }
+                    li { a(href: "/contacts/export.vcf") { "export" } }
+                    li { a(href: "/setup") { "device setup" } }
+                  end
                 end
               end
             end
+            # Out of the header's row: a popover opens in the top
+            # layer from wherever it sits.
+            render SearchDialog.new
             main(class: "admin-main", **(@wide ? {data: {wide: true}} : {})) { yield }
             # The shell's other edge: which build is answering and
             # whether it is logging what it answers, both read off
diff --git a/lib/pro_tacts/admin/recent_row.rb b/lib/pro_tacts/admin/recent_row.rb
new file mode 100644
index 0000000..f0ec0ff
--- /dev/null
+++ b/lib/pro_tacts/admin/recent_row.rb
@@ -0,0 +1,30 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/list_item"
+
+module ProTacts
+  module Admin
+    # A contact's row where the list is one of recency — the
+    # dashboard's recently updated and the search's results, which
+    # keep recency within each rank (Search.contacts) — so the row
+    # says how long ago the card changed.
+    class RecentRow < Phlex::HTML
+      # @rbs @row: Store::RecentContact
+
+      #: (row: Store::RecentContact) -> void
+      def initialize(row:)
+        @row = row
+      end
+
+      def view_template
+        render ListItem.new(
+          href: "/contacts/#{@row.contact.id}",
+          avatar: Avatar.new(contact: @row.contact, size: "lg"),
+          trailing: Format.time_ago(@row.updated_at),
+        ) { Format.name_label(@row.contact) }
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/search.rb b/lib/pro_tacts/admin/search.rb
index fe6d11b..6f353dc 100644
--- a/lib/pro_tacts/admin/search.rb
+++ b/lib/pro_tacts/admin/search.rb
@@ -24,11 +24,14 @@ module ProTacts
       # a hit on the name first — every word the start of a word of the
       # name or nickname, "ada" or "love" for Ada Lovelace — then the
       # hits anywhere else, recency keeping its order within each. The
-      # rows arrive in recency order, and partition keeps it. `labels`
-      # is the names of the groups each contact is in, by id.
-      #: (Array[Store::RecentContact] rows, query: String, labels: Hash[String, Array[String]]) -> Array[Store::RecentContact]
-      def self.contacts(rows, query:, labels:)
+      # rows arrive in recency order, and partition keeps it. The groups
+      # arrive whole because a contact is found by the names of the
+      # groups it is in.
+      #: (Array[Store::RecentContact] rows, query: String, groups: Array[Group]) -> Array[Store::RecentContact]
+      def self.contacts(rows, query:, groups:)
         words = words(query)
+        labels = {} #: Hash[String, Array[String]]
+        groups.each { |group| group.members.each { (labels[it] ||= []) << group.label } }
         matched = rows.select { |row| matches?(row.contact, words, labels.fetch(row.contact.id, [])) }
         names, rest = matched.partition { name_hit?(it.contact, words) }
         names + rest
diff --git a/lib/pro_tacts/admin/search_dialog.rb b/lib/pro_tacts/admin/search_dialog.rb
new file mode 100644
index 0000000..f9dfad6
--- /dev/null
+++ b/lib/pro_tacts/admin/search_dialog.rb
@@ -0,0 +1,37 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/search_field"
+
+module ProTacts
+  module Admin
+    # The search on a wide screen: a popover the header's button opens
+    # (Layout) over whatever screen it is on, holding the search
+    # (SearchField). A phone goes to the search page instead
+    # (SearchPage); why the two differ, and why a dialog at all, is
+    # docs/plans/2026-09-30-live-search.md.
+    #
+    # The Popover API does the opening and closing — the button, Esc,
+    # a click outside — and `autofocus` lands in the input each time
+    # it opens. `/` opens it from anywhere a key is not already typing
+    # into something (docs/DESIGN.md, "The core idea"). What was typed
+    # stays when it closes, so reopening lands on the same answer.
+    class SearchDialog < Phlex::HTML
+      ID = "search" #: String
+
+      # `/` opens the dialog unless it is being typed into a field —
+      # the dialog's own input among them — or the dialog is already
+      # open, where showPopover would throw.
+      SLASH = "if (!$el.matches(':popover-open') && " \
+              "!$event.target.closest('input, textarea, select, [contenteditable]')) " \
+              "{ $event.preventDefault(); $el.showPopover() }" #: String
+      private_constant :SLASH
+
+      def view_template
+        dialog(id: ID, popover: "auto", class: "search-dialog", x_data: true,
+               "@keydown.slash.window": SLASH) do
+          render SearchField.new
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/search_field.rb b/lib/pro_tacts/admin/search_field.rb
new file mode 100644
index 0000000..d8e93e4
--- /dev/null
+++ b/lib/pro_tacts/admin/search_field.rb
@@ -0,0 +1,96 @@
+require "json"
+
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/icons"
+
+module ProTacts
+  module Admin
+    # The search's input and the results under it, fetched from
+    # GET /search/results as it is typed: the one search the header's
+    # dialog holds on a wide screen (SearchDialog) and the search page
+    # is on a phone (SearchPage). Why the results come from the server
+    # rather than a filter in the page is
+    # docs/plans/2026-09-30-live-search.md.
+    #
+    # Alpine does the fetch, waited out until typing pauses and
+    # cancelled by the next one, so an answer to an old query never
+    # lands over a newer; and the keys, ↑/↓ moving a highlight through
+    # the result links and Enter following it, the first by default —
+    # while there is a query, the recently updated list the page shows
+    # before one being no answer for Enter to pick.
+    # Enter with no result to follow submits the form the field sits
+    # in, where there is one — the page's, which answers without
+    # script too.
+    #
+    # `query` and `results` are what the field renders holding: the
+    # page's own ?q= and its server-rendered answer. `history` keeps
+    # the page's address on the query as it changes, so going back to
+    # it from a result lands on the same answer; the dialog has no
+    # address of its own to keep. `recent` has a blank query fetch
+    # the recently updated list too, as the page shows before a word
+    # is typed; the dialog opens over a screen showing its own, and
+    # clears to nothing.
+    class SearchField < Phlex::HTML
+      # @rbs @query: String
+      # @rbs @results: Phlex::HTML?
+      # @rbs @history: bool
+      # @rbs @recent: bool
+
+      #: (?query: String, ?results: Phlex::HTML?, ?history: bool, ?recent: bool) -> void
+      def initialize(query: "", results: nil, history: false, recent: false)
+        @query = query
+        @results = results
+        @history = history
+        @recent = recent
+      end
+
+      def view_template
+        div(class: "search-field", x_data: state, x_init: "mark()") do
+          div(class: "filter") do
+            input(type: "search", name: "q", value: @query, placeholder: "Search contacts",
+                  aria_label: "Search contacts", autofocus: true, x_model: "q", x_ref: "input",
+                  "@input.debounce.150ms": "search()",
+                  "@keydown.down.prevent": "move(1)",
+                  "@keydown.up.prevent": "move(-1)",
+                  "@keydown.enter.prevent": "follow()")
+            button(type: "button", class: "icon-button search-clear", data_size: "sm",
+                   aria_label: "Clear search", "@click": "q = ''; search(); $refs.input.focus()") do
+              render Icon.new(:x)
+            end
+          end
+          div(class: "search-results", x_ref: "results") { render @results if @results }
+        end
+      end
+
+      private
+
+      # The field's state and the fetch. A failed fetch throws rather
+      # than showing a stale or empty list: Alpine surfaces it in the
+      # console, and the server has already reported whatever made it
+      # fail.
+      #: () -> String
+      def state
+        "{ q: #{JSON.generate(@query)}, history: #{@history}, recent: #{@recent}, active: 0, request: null, " \
+          "async search() { this.request?.abort(); this.active = 0; const q = this.q.trim(); " \
+          "if (this.history) history.replaceState(null, '', q === '' ? '/search' : '/search?q=' + encodeURIComponent(q)); " \
+          "if (q === '' && !this.recent) { this.request = null; this.$refs.results.innerHTML = ''; return } " \
+          "const request = this.request = new AbortController(); let html; " \
+          "try { const response = await fetch('/search/results?q=' + encodeURIComponent(q), { signal: request.signal }); " \
+          "if (!response.ok) throw new Error(`The search failed: the server answered ${response.status}.`); " \
+          "html = await response.text() } " \
+          "catch (error) { if (error.name === 'AbortError') return; throw error } " \
+          "if (request !== this.request) return; " \
+          "this.$refs.results.innerHTML = html; this.mark() }, " \
+          "get links() { return [...this.$refs.results.querySelectorAll('a')] }, " \
+          "move(step) { const n = this.links.length; if (n === 0) return; " \
+          "this.active = (this.active + step + n) % n; this.mark() }, " \
+          "mark() { const on = this.q.trim() !== ''; " \
+          "this.links.forEach((link, i) => link.toggleAttribute('data-active', on && i === this.active)); " \
+          "this.links[this.active]?.scrollIntoView({ block: 'nearest' }) }, " \
+          "follow() { const link = this.q.trim() === '' ? null : this.links[this.active]; " \
+          "if (link) link.click(); else this.$el.closest('form')?.requestSubmit() } }"
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/search_page.rb b/lib/pro_tacts/admin/search_page.rb
new file mode 100644
index 0000000..1cab0bd
--- /dev/null
+++ b/lib/pro_tacts/admin/search_page.rb
@@ -0,0 +1,35 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/layout"
+require "pro_tacts/admin/search_field"
+
+module ProTacts
+  module Admin
+    # GET /search — the search on a phone, where the header's search is
+    # a link here rather than the dialog a wider screen opens (Layout;
+    # docs/plans/2026-09-30-live-search.md). The same field as the
+    # dialog's, in a GET form back to this page: typing narrows the
+    # results in place and keeps the address on the query, and without
+    # script a submitted query lands here with its results rendered.
+    class SearchPage < Phlex::HTML
+      # @rbs @login: String
+      # @rbs @query: String
+      # @rbs @results: Phlex::HTML
+
+      #: (login: String, query: String, results: Phlex::HTML) -> void
+      def initialize(login:, query:, results:)
+        @login = login
+        @query = query
+        @results = results
+      end
+
+      def view_template
+        render Layout.new(title: "Search", login: @login, searching: true) do
+          form(action: "/search", method: "get", class: "search-page") do
+            render SearchField.new(query: @query, results: @results, history: true, recent: true)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/admin/search_results.rb b/lib/pro_tacts/admin/search_results.rb
new file mode 100644
index 0000000..0e04be6
--- /dev/null
+++ b/lib/pro_tacts/admin/search_results.rb
@@ -0,0 +1,52 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/group_label"
+require "pro_tacts/admin/list_item"
+require "pro_tacts/admin/recent_row"
+
+module ProTacts
+  module Admin
+    # The list under the search's input (SearchField), which
+    # GET /search/results answers alone and the search page renders
+    # in: the contacts a query matches in the order Search ranks them
+    # and then the groups — a group is a record the search reaches as
+    # surely as a contact is (docs/DESIGN.md, "When adding something
+    # new"). Uncapped, with no "see all" to send a long list to
+    # (docs/plans/2026-09-30-live-search.md).
+    class SearchResults < Phlex::HTML
+      # @rbs @contacts: Array[Store::RecentContact]
+      # @rbs @groups: Array[Group]
+      # @rbs @recent: bool
+
+      # `recent` is the list standing in for results before there is a
+      # query — the most recently updated contacts, labeled as the
+      # dashboard labels them (Web#search_results).
+      #: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool) -> void
+      def initialize(contacts:, groups:, recent: false)
+        @contacts = contacts
+        @groups = groups
+        @recent = recent
+      end
+
+      def view_template
+        if @contacts.empty? && @groups.empty?
+          p(class: "type-body-sm") { @recent ? "No contacts yet." : "Nothing matches." }
+          return
+        end
+
+        if @contacts.any?
+          h2(class: "type-label") { @recent ? "recently updated" : "contacts" }
+          ul(class: "card") { @contacts.each { render RecentRow.new(row: it) } }
+        end
+        return if @groups.empty?
+
+        h2(class: "type-label") { "groups" }
+        ul(class: "card") do
+          @groups.each do |group|
+            render ListItem.new(href: "/groups/#{group.id}") { render GroupLabel.new(group:) }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index e55dde9..50a2f32 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -131,7 +131,7 @@ module ProTacts
         # DAV client's bootstrap. The create dialog rides along hidden
         # in the render (see Admin::ContactDialog).
         r.get do
-          dashboard(query: r.params["q"])
+          dashboard
         end
 
         r.propfind do
@@ -155,4 +155,5 @@ require "pro_tacts/web/contacts"
 require "pro_tacts/web/dav"
 require "pro_tacts/web/groups"
 require "pro_tacts/web/import"
+require "pro_tacts/web/search"
 require "pro_tacts/web/setup"
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 23705e0..359119a 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -47,7 +47,7 @@ module ProTacts
           # (Admin::NamePair), and nobody is known by their middle name
           # alone.
           if first.empty? && last.empty?
-            dashboard(query: r.params["q"], notice: "A contact needs a name.")
+            dashboard(notice: "A contact needs a name.")
           else
             id = ChangeId.mint(ChangeId::CONTACT_LENGTH)
             store.put(id, Admin::CardForm.new_card(id, first, middle, last))
@@ -129,18 +129,14 @@ module ProTacts
 
     private
 
-    # The dashboard GET and a refused create render the same page,
-    # which is why the query travels both paths — a create refused
-    # under a search re-renders the results it was refused over.
-    #: (query: String?, ?notice: String?) -> String
-    def dashboard(query:, notice: nil)
+    # The dashboard GET and a refused create render the same page.
+    #: (?notice: String?) -> String
+    def dashboard(notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
       Admin::Dashboard.call(
         recent: store.contacts_by_recency,
         upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
-        query:,
         login: @login,
-        groups: store.all_groups,
         notice:,
       )
     end
diff --git a/lib/pro_tacts/web/search.rb b/lib/pro_tacts/web/search.rb
new file mode 100644
index 0000000..550bea0
--- /dev/null
+++ b/lib/pro_tacts/web/search.rb
@@ -0,0 +1,50 @@
+require "pro_tacts/admin/dashboard"
+require "pro_tacts/admin/search"
+require "pro_tacts/admin/search_page"
+require "pro_tacts/admin/search_results"
+
+module ProTacts
+  class Web < Roda
+    # The search: the page a phone's header links to, and the results
+    # the search field fetches as the query is typed — on that page
+    # and in a wider screen's dialog (Admin::SearchField). The match
+    # stays in Admin::Search, the one place it is decided, rather than
+    # a second copy in the page's script
+    # (docs/plans/2026-09-30-live-search.md).
+    hash_branch("search") do |r|
+      query = r.params["q"].to_s.strip
+
+      r.is do
+        r.get do
+          response["Content-Type"] = "text/html; charset=utf-8"
+          Admin::SearchPage.call(login: @login, query:, results: search_results(query))
+        end
+      end
+
+      # The results alone, put under the input whole.
+      r.get "results" do
+        response["Content-Type"] = "text/html; charset=utf-8"
+        search_results(query).call
+      end
+    end
+
+    private
+
+    # A blank query answers the most recently updated contacts, the
+    # dashboard's own list, so the search page is never empty before a
+    # word is typed.
+    #: (String query) -> Admin::SearchResults
+    def search_results(query)
+      recent = store.contacts_by_recency
+      if query.empty?
+        return Admin::SearchResults.new(contacts: recent.first(Admin::Dashboard::RECENT_LIMIT), groups: [], recent: true)
+      end
+
+      groups = store.all_groups
+      Admin::SearchResults.new(
+        contacts: Admin::Search.contacts(recent, query:, groups:),
+        groups: Admin::Search.groups(groups, query:),
+      )
+    end
+  end
+end
diff --git a/public/admin.css b/public/admin.css
index 34c976a..e7bcf3e 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -102,12 +102,12 @@
 
 /* A form in a card keeps a tighter rhythm than Gloss's form-body
    defaults: the boxes shed their block padding down to the scale's
-   smallest step — the same shedding the header's search does — and
+   smallest step — the same shedding a dialog's filter does — and
    the stack closes to the detail grid's row gap, because a row is
    now one line tall there too. Scoped to the card-body form rather
    than overriding field.css globally; the label in the input
    selector out-specifies field.css's input rule rather than relying
-   on stylesheet order (see the header search's note). Everything
+   on stylesheet order (see .search-dialog's rules). Everything
    here reaches the record's own rows by the child combinator rather
    than by descent: these rules are the record's, and both dialogs'
    fields keep Gloss's own — a 26rem popover is not a record, and the
@@ -431,17 +431,36 @@ textarea:focus {
   color: var(--gl-color-text-primary);
   text-decoration: none;
 }
-/* Two groups in one flex row: the name and the search it carries on
-   the left, the destinations and the account at the right edge — the
-   nav's auto margin is what pushes the second group there, no
-   wrappers to hold. The name sits a tier above the links — one type
-   step, the identity rendered in type (docs/DESIGN.md) — while they
-   step down a shade: destinations, not the name. */
+/* Three columns: the name at the start, the search in the middle,
+   and the destinations with the account at the end, held in one box
+   (Layout). The outer two share the width left over equally, so the
+   middle is the header's own middle whatever the name and the login
+   measure, and a column that needs more than its share takes it,
+   pushing the search aside rather than squeezing its own contents —
+   left to the flex row, the squeeze came out of the account's
+   chevron first. Only wider than a
+   phone, whose header is two things at its two edges (below). The
+   name sits a tier above the links — one type step, the identity
+   rendered in type (docs/DESIGN.md) — while they step down a shade:
+   destinations, not the name. */
+@media (width > 48rem) {
+  .admin-header {
+    display: grid;
+    grid-template-columns: 1fr auto 1fr;
+  }
+}
+.header-end {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: var(--gl-space-s);
+  min-width: max-content;
+  margin-inline-start: auto;
+}
 .admin-header nav {
   display: flex;
   align-items: baseline;
   gap: var(--gl-space-s);
-  margin-left: auto;
 }
 .admin-header > a {
   font-size: var(--gl-step-0);
@@ -451,39 +470,35 @@ textarea:focus {
   color: var(--gl-color-text-secondary);
 }
 
-/* The search's label only folds it on a phone, below; wider, it is
-   the field's box and the glyph is not drawn. The menu repeats home
-   and the destinations for the phone too, and wider the name and the
-   nav carry them. */
-.search-form label {
-  display: block;
-}
-.search-form label > svg,
+/* The menu repeats home and the destinations for the phone, below;
+   wider, the name and the nav carry them. */
 .user-menu .menu-nav {
   display: none;
 }
 
 /* A phone has no room for the name, the search, two destinations
-   and the account in one row: left there, the search shrank to an
-   empty square, the name broke at its hyphen, and the account ran
-   past the right edge. So the header keeps the two things only it
-   can do, the search and the account. The name goes, being the way
-   home and nothing more once the tab says which instance this is,
-   and home joins the destinations in the account menu (Layout
-   repeats them there). The search folds into its glyph at the
-   start of the row, pulled out by its padding so it stands on the
-   edge the page's column does; a tap on it opens it (Layout says
-   when it folds again, and why focus alone does not open it), across
-   the whole header over the account, which is
-   hidden rather than removed so the header keeps its height. The
-   login is whatever the proxy vouches for, usually an email, so it
-   ends in an ellipsis when it runs out of room. The breakpoint is
-   where the one row still leaves the search room for its
-   placeholder. */
+   and the account in one row: left there, the name broke at its
+   hyphen and the account ran past the right edge. So the header
+   keeps the two things only it can do, the search and the account.
+   The name goes, being the way home and nothing more once the tab
+   says which instance this is, and home joins the destinations in
+   the account menu (Layout repeats them there). The search is its
+   link to the search page, the glyph alone, in place of the
+   dialog's button: a phone's screen is the page's whole width, and
+   the dialog would only be a smaller one over it. On the search
+   page the same glyph's place goes to home (Layout). The login is
+   whatever the proxy vouches for, usually an email, so it ends in
+   an ellipsis when it runs out of room.
+
+   The bars' sides close a step inside the page column's gutter, so
+   the glyph and the account's chevron sit nearer the screen's edges
+   than the column's content does: at the gutter they read as inset,
+   and at the bars' block step as crowding the edge. The glyph's
+   link trades Gloss's fixed icon-button box, which centers the glyph
+   in room no token names, for a 2xs step of padding pulled back out
+   by the same step: the glyph stands on the bar's edge the way the
+   chevron does (.user-menu button, below). */
 @media (max-width: 48rem) {
-  .admin-header {
-    position: relative;
-  }
   .admin-header > a,
   .admin-header nav {
     display: none;
@@ -491,59 +506,23 @@ textarea:focus {
   .user-menu .menu-nav {
     display: block;
   }
-  .admin-header .search-form {
-    flex: none;
-    margin-inline-start: calc(-1 * var(--gl-space-3xs));
-    max-width: none;
-  }
-  .search-form label {
-    display: flex;
-    padding: var(--gl-space-3xs);
-    cursor: pointer;
-  }
-  .search-form label > svg {
-    display: block;
-  }
-  .admin-header .search-form input[type="search"] {
-    position: absolute;
-    width: 1px;
-    height: 1px;
-    opacity: 0;
-    clip-path: inset(50%);
-  }
-  .admin-header .search-form[data-open] {
-    position: absolute;
-    inset: 0;
-    display: flex;
-    flex-direction: row;
-    align-items: center;
-    margin: 0;
-    padding: 0 var(--gl-space-m);
-  }
-  .admin-header .search-form[data-open] label {
-    flex: 1;
-    padding: 0;
-    cursor: auto;
-  }
-  .admin-header .search-form[data-open] label > svg {
+  .admin-header .search-button {
     display: none;
   }
-  .admin-header .search-form[data-open] input[type="search"] {
-    position: static;
-    width: 100%;
-    height: auto;
-    opacity: 1;
-    clip-path: none;
-  }
-  .admin-header .search-form[data-open] .search-clear {
-    inset-inline-end: calc(var(--gl-space-m) + var(--gl-space-2xs));
+  .admin-header,
+  .admin-footer {
+    padding-inline: var(--gl-space-s);
   }
-  .admin-header:has(.search-form[data-open]) > :not(.search-form) {
-    visibility: hidden;
+  .admin-header .phone-glyph {
+    display: inline-flex;
+    width: auto;
+    height: auto;
+    padding: var(--gl-space-2xs);
+    margin-inline-start: calc(-1 * var(--gl-space-2xs));
   }
+  .header-end,
   .user-menu {
     min-width: 0;
-    margin-inline-start: auto;
   }
   .user-menu button {
     min-width: 0;
@@ -596,10 +575,9 @@ textarea:focus {
    chrome: the menu's list is the header's DOM child but floats over
    the page on a card, so left to reach it by descent it paints the
    rows white on surface-card — unreadable. They keep the text color
-   their own rule gives them (the menu section below). The search's
-   clear is the same case: it sits in the field, not on the bar. */
+   their own rule gives them (the menu section below). */
 :root[data-accent] :is(.admin-header a, .admin-header .user-menu button,
-.admin-header .search-form label, .admin-footer .type-label):not(.user-menu ul a, .search-clear) {
+.admin-header .search-button, .admin-footer .type-label):not(.user-menu ul a) {
   color: var(--gl-color-accent-fg);
 }
 /* The one focus ring is drawn in the accent, which vanishes against a
@@ -728,36 +706,72 @@ body {
   white-space: nowrap;
 }
 
-/* field.css's `form { max-width: 28rem; }` is sized for the
-   settings-form pattern; the header's search is a single input, and
-   flex: 1 + a narrower cap lets the box grow beside the name toward
-   a deliberate 18rem and shrink on a narrow viewport. */
-.search-form {
-  flex: 1;
-  max-width: 18rem;
-}
-/* field.css sizes the input's padding for form bodies (xs block);
-   against the header's one-line chrome the box stands too tall, so it
-   sheds the vertical half of that padding down to the scale's
-   smallest step and keeps the horizontal. The selector
-   out-specifies field.css's double-:not input rule rather than
-   relying on stylesheet order. */
-.admin-header .search-form input[type="search"] {
+/* The search field (SearchField): the input over its results, which
+   take no room before there are any. The row Enter would follow
+   wears the hover's fill. */
+.search-field,
+.search-results {
+  display: flex;
+  flex-direction: column;
+}
+.search-field {
+  gap: var(--gl-space-s);
+}
+.search-results {
+  gap: var(--gl-space-2xs);
+}
+.search-results:empty {
+  display: none;
+}
+.search-results .card > li > a[data-active] {
+  background: var(--gl-color-surface-fill-2);
+}
+
+/* The dialog opens near the top rather than centered, so the input
+   stays put as the results under it grow and shrink; wider than
+   Gloss's 26rem, a result row being a whole name and its age. The
+   input sheds its block padding as the groups dialog's filter does,
+   the selector out-specifying field.css's double-:not input rule
+   rather than relying on stylesheet order. The results scroll in
+   place under the input rather than the dialog growing past the
+   viewport — Gloss gives a dialog overflow: hidden (the groups
+   dialog's note below). */
+.search-dialog {
+  width: 36rem;
+  margin-block: 12vh auto;
+}
+.search-dialog .filter > input[type="search"] {
   padding-block: var(--gl-space-3xs);
 }
+.search-dialog .search-results {
+  max-height: 60vh;
+  overflow-y: auto;
+}
+
+/* The page's field (SearchPage) spans the column: field.css caps a
+   form for the settings pattern, and this form is the page. The
+   header's phone glyph — the link to this page, or home from it —
+   shows only on a phone (above); wider, the dialog's button and the
+   name stand in for it. */
+.search-page {
+  max-width: none;
+}
+.phone-glyph {
+  display: none;
+}
 
 /* Clearing a search field: one affordance for both kinds the app
-   has — the header's search and the lists' filter — Gloss's sm
+   has — the search dialog's input and the lists' filter, both a
+   .filter box — Gloss's sm
    icon-button carrying Lucide's x, overlaid at the field's end.
    Revealed by :has(:not(:placeholder-shown)) on the box the input
    and the control share, so it appears the moment there is text to
    clear, typed-but-unsubmitted included, with no script — the
    filter's button needs Alpine only for its click, which empties
-   the state the rows read (GroupFilter). The input's end padding
+   the state the rows or the results read. The input's end padding
    reserves the control's width by the sm icon-button's own
    geometry (2× its 3xs padding + its 1.15×0.8125rem line), so text
    never runs under it. */
-.search-form,
 .filter {
   position: relative;
 }
@@ -768,7 +782,6 @@ body {
   translate: 0 -50%;
   display: none;
 }
-.search-form:has(input:not(:placeholder-shown)) .search-clear,
 .filter:has(input:not(:placeholder-shown)) .search-clear {
   display: inline-flex;
 }
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 0cc6be0..8416773 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -18,7 +18,17 @@ module ProTacts
     end
 
     class Dashboard
-      def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, login: String, groups: Array[Group], ?notice: String?) -> String
+      RECENT_LIMIT: Integer
+      def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], login: String, ?notice: String?) -> String
+    end
+
+    class SearchResults
+      def self.new: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool) -> instance
+      def call: () -> String
+    end
+
+    class SearchPage
+      def self.call: (login: String, query: String, results: SearchResults) -> String
     end
 
     class ContactsIndex
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index e4b0578..e74e8ce 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -348,58 +348,106 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
-  # The search lives in the page header (docs/DESIGN.md), rendered by
-  # the layout on every screen now — not just the dashboard's — so
-  # the header's height never changes between pages and finding a
-  # contact never requires going home first. This pins the wiring,
-  # not just the behavior: a screen that forgets `autofocus` loses
-  # the focused-and-ready entry point with no route failing.
-  def test_the_search_lives_in_the_header
+  # The search rides every screen's header (docs/DESIGN.md), the
+  # dashboard's and a record's alike — finding a contact never
+  # requires going home first. The dashboard no longer focuses
+  # anything on load: the search is a button and `/` away
+  # (Admin::SearchDialog), and the only autofocus left is inside the
+  # popovers, which fires as each one opens.
+  def test_the_search_renders_on_every_screen
     with_contacts({"ada" => ADA}) do
-      get "/"
-      header = last_response.body.split("<main").first
-      assert_includes header, "search-form"
-      assert_includes header, "name=\"q\""
-      assert_includes header, "autofocus"
-      # Folded on a phone despite the focus (Layout says why).
-      refute_includes header, " data-open"
+      ["/", "/contacts/ada"].each do |path|
+        get path
+        header = last_response.body.split("<main").first
 
-      get "/", q: "ada"
-      header = last_response.body.split("<main").first
-      assert_includes header, "value=\"ada\""
-      refute_includes header, "autofocus"
-      # Open from the start, showing the query its results answer.
-      assert_includes header, " data-open"
+        assert_includes header, %(popovertarget="search"), path
+        assert_includes header, %(<dialog id="search" popover="auto"), path
+      end
     end
   end
 
-  # The same chrome on a record's own page: the search rides the
-  # header there too, empty and unfocused — the record is that
-  # page's content, not a query.
-  def test_the_search_renders_on_detail_pages_too
+  # The dashboard answers no query: the results page it used to be is
+  # gone, and a stale ?q= link lands on recency like any other visit.
+  def test_the_dashboard_ignores_a_query
+    grace = ADA.sub("Ada Lovelace", "Grace Hopper").sub("UID:ada", "UID:grace")
+
+    with_contacts({"ada" => ADA, "grace" => grace}) do
+      get "/", q: "hopper"
+
+      assert_includes last_response.body, %(<h2 class="type-label">recently updated</h2>)
+      assert_includes last_response.body, "Ada Lovelace"
+    end
+  end
+
+  # GET /search/results is the field's list, not a page: no layout
+  # around it.
+  def test_search_answers_a_fragment
     with_contacts({"ada" => ADA}) do
-      get "/contacts/ada"
+      get "/search/results", q: "ada"
 
-      header = last_response.body.split("<main").first
-      assert_includes header, "search-form"
-      # No value attribute at all — the record's page carries no
-      # query, and Phlex omits the attribute for nil rather than
-      # rendering it empty.
-      assert_includes header, '<input type="search" name="q" placeholder="Search contacts">'
-      refute_includes header, "autofocus"
+      assert_equal 200, last_response.status
+      assert_equal "text/html; charset=utf-8", last_response["Content-Type"]
+      assert last_response.body.start_with?(%(<h2 class="type-label">contacts</h2>))
+      refute_includes last_response.body, "<html"
+      assert_includes last_response.body, %(<a href="/contacts/ada">)
     end
   end
 
-  # Searching narrows the contacts column; the ambient column stays
-  # where it is, still answering its own question.
-  def test_search_leaves_the_birthdays_column_standing
-    birthday = born(Date.today + 3, name: "Birthday Person", id: "birthday")
+  # A blank query answers the recently updated list, the search page
+  # showing it before a word is typed and again once the input is
+  # cleared.
+  def test_search_with_a_blank_query_answers_the_recent_contacts
+    with_contacts({"ada" => ADA}) do
+      get "/search/results", q: "  "
 
-    with_contacts({"ada" => ADA, "birthday" => birthday}) do
-      get "/", q: "hopper"
+      assert_equal 200, last_response.status
+      assert last_response.body.start_with?(%(<h2 class="type-label">recently updated</h2>))
+      assert_includes last_response.body, %(<a href="/contacts/ada">)
+    end
+  end
+
+  # The page a phone's header links to (Admin::SearchPage): the field
+  # in a form back to itself, focused, over the recently updated list
+  # until a word is typed.
+  def test_the_search_page_renders_the_field
+    with_contacts({"ada" => ADA}) do
+      get "/search"
+
+      assert_equal 200, last_response.status
+      main = last_response.body.split("<main").last
+      assert_includes main, %(<form action="/search" method="get" class="search-page">)
+      assert_includes main, %(<input type="search" name="q" value="" placeholder="Search contacts")
+      assert_includes main, "autofocus"
+      assert_includes main, %(<h2 class="type-label">recently updated</h2>)
+      assert_includes main, %(<a href="/contacts/ada">)
+    end
+  end
+
+  # On the search page a phone's header glyph goes home rather than
+  # back to the page it is on.
+  def test_the_search_page_trades_the_phone_search_link_for_home
+    with_contacts({}) do
+      get "/search"
+      header = last_response.body.split("<main").first
+      assert_includes header, %(<a href="/" class="icon-button phone-glyph" aria-label="Home">)
+      refute_includes header, %(<a href="/search" class="icon-button phone-glyph")
 
-      assert_includes last_response.body, "No contacts match."
-      assert_includes last_response.body, "Birthday Person"
+      get "/"
+      header = last_response.body.split("<main").first
+      assert_includes header, %(<a href="/search" class="icon-button phone-glyph" aria-label="Search">)
+    end
+  end
+
+  # A query in the address renders its results with the page, so a
+  # submitted search answers without script and going back to the
+  # page lands on the results it showed.
+  def test_the_search_page_renders_the_results_of_its_query
+    with_contacts({"ada" => ADA}) do
+      get "/search", q: "ada"
+
+      main = last_response.body.split("<main").last
+      assert_includes main, %(value="ada")
+      assert_includes main, %(<a href="/contacts/ada">)
     end
   end
 
@@ -409,15 +457,10 @@ class AdminContactsPagesTest < Minitest::Test
       .sub("UID:ada", "UID:grace")
 
     with_contacts({"ada" => ADA, "grace" => grace}) do
-      get "/", q: "hopper"
+      get "/search/results", q: "hopper"
 
-      # The columns split on the birthdays column's label: matching
-      # narrows the contacts column, and Ada stays out of it — she is
-      # still beside it, in the ambient column where she belongs.
-      contacts_column = last_response.body.split("upcoming birthdays").first
-      assert_includes contacts_column, "Grace Hopper"
-      refute_includes contacts_column, "Ada Lovelace"
-      assert_includes last_response.body, %(<h2 class="type-label">contacts</h2>)
+      assert_includes last_response.body, "Grace Hopper"
+      refute_includes last_response.body, "Ada Lovelace"
     end
   end
 
@@ -425,10 +468,10 @@ class AdminContactsPagesTest < Minitest::Test
   # any of its values, or the groups it belongs to.
   def test_search_matches_a_phone_or_email_value
     with_contacts({"ada" => ADA}) do
-      get "/", q: "555-0100"
+      get "/search/results", q: "555-0100"
       assert_includes last_response.body, "Ada Lovelace"
 
-      get "/", q: "ada@example.com"
+      get "/search/results", q: "ada@example.com"
       assert_includes last_response.body, "Ada Lovelace"
     end
   end
@@ -439,7 +482,7 @@ class AdminContactsPagesTest < Minitest::Test
     red = ADA.sub("FN:Ada Lovelace", "FN:Sarah\r\nNICKNAME:Red").sub("UID:ada", "UID:red")
 
     with_contacts({"red" => red}) do
-      get "/", q: "red"
+      get "/search/results", q: "red"
 
       assert_includes last_response.body, "Sarah (Red)"
     end
@@ -452,7 +495,7 @@ class AdminContactsPagesTest < Minitest::Test
     employed = ADA.sub("UID:ada", "UID:employed").sub("END:VCARD", "ORG:Acme Corp\r\nEND:VCARD")
 
     with_contacts({"employed" => employed}) do
-      get "/", q: "acme"
+      get "/search/results", q: "acme"
 
       assert_includes last_response.body, "Ada Lovelace"
     end
@@ -468,23 +511,22 @@ class AdminContactsPagesTest < Minitest::Test
       .sub("N:Lovelace;Ada;;;", "N:Alvarez;Jose;;;").sub("UID:ada", "UID:jose")
 
     with_contacts({"emile" => emile, "jose" => jose}) do
-      get "/", q: "munoz"
+      get "/search/results", q: "munoz"
 
-      contacts_column = last_response.body.split("upcoming birthdays").first
-      assert_includes contacts_column, "Émile Muñoz"
-      refute_includes contacts_column, "Jose Alvarez"
+      assert_includes last_response.body, "Émile Muñoz"
+      refute_includes last_response.body, "Jose Alvarez"
 
-      get "/", q: "josé"
+      get "/search/results", q: "josé"
 
-      assert_includes last_response.body.split("upcoming birthdays").first, "Jose Alvarez"
+      assert_includes last_response.body, "Jose Alvarez"
     end
   end
 
   def test_search_with_no_matches_says_so
     with_contacts({"ada" => ADA}) do
-      get "/", q: "nobody"
+      get "/search/results", q: "nobody"
 
-      assert_includes last_response.body, "No contacts match."
+      assert_includes last_response.body, "Nothing matches."
     end
   end
 
@@ -1110,8 +1152,8 @@ class AdminContactsPagesTest < Minitest::Test
 
       assert_includes last_response.body, 'src="/contacts/pic/photo"'
 
-      get "/", q: "no-match-for-this"
-      refute_includes last_response.body, 'src="/contacts/pic/photo"'
+      get "/search/results", q: "ada"
+      assert_includes last_response.body, 'src="/contacts/pic/photo"'
     end
   end
 
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 40f896f..d36bd7a 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -556,7 +556,7 @@ class AdminGroupsPagesTest < Minitest::Test
     with_contacts(BOOLES) do |store|
       id = household(store)
 
-      get "/", q: "boole"
+      get "/search/results", q: "boole"
 
       assert_includes last_response.body, %(<a href="/groups/#{id}">)
     end
@@ -566,7 +566,7 @@ class AdminGroupsPagesTest < Minitest::Test
     with_contacts({"george" => "George", "ada" => "Ada Lovelace"}) do |store|
       household(store, members: ["george"])
 
-      get "/", q: "boole"
+      get "/search/results", q: "boole"
 
       assert_includes last_response.body, %(<a href="/contacts/george">)
       refute_includes last_response.body, %(<a href="/contacts/ada">)
@@ -580,7 +580,7 @@ class AdminGroupsPagesTest < Minitest::Test
       id = store.create_group(name: "Família")
       store.add_member(id, "george")
 
-      get "/", q: "familia"
+      get "/search/results", q: "familia"
 
       assert_includes last_response.body, %(<a href="/groups/#{id}">)
       assert_includes last_response.body, %(<a href="/contacts/george">)
diff --git a/test/pro_tacts/admin/test_layout.rb b/test/pro_tacts/admin/test_layout.rb
index ce84d6a..6f1d2b3 100644
--- a/test/pro_tacts/admin/test_layout.rb
+++ b/test/pro_tacts/admin/test_layout.rb
@@ -49,12 +49,23 @@ class AdminLayoutTest < Minitest::Test
     assert_includes header_html, %(<ul id="user-menu" popover="auto")
   end
 
-  # A link, not a button: clearing the dashboard's search is navigating
-  # to the page with no query.
-  def test_the_search_carries_a_clear
+  # The search is a button in the header and the dialog it opens
+  # (Admin::SearchDialog), rendered on every screen, beside the link a
+  # phone shows instead (admin.css picks one per width). rack-test
+  # cannot open a popover or run the fetch, so this pins the wiring a
+  # browser acts on: the declarative pair, the link, the input focused
+  # as the dialog opens, the route its results come from, and the key
+  # that opens it.
+  def test_the_search_is_a_button_and_the_dialog_it_opens
     with_env({})
 
-    assert_includes header_html, %(<a href="/" class="icon-button search-clear" data-size="sm" aria-label="Clear search">)
+    assert_includes header_html, %(<button type="button" class="search-button" data-size="sm" popovertarget="search">)
+    assert_includes header_html, %(<a href="/search" class="icon-button phone-glyph" aria-label="Search">)
+    assert_includes header_html, %(<dialog id="search" popover="auto" class="search-dialog")
+    assert_includes header_html, %(<input type="search" name="q" value="" placeholder="Search contacts" aria-label="Search contacts" autofocus)
+    assert_includes header_html, "fetch('/search/results?q='"
+    assert_includes header_html, "@keydown.slash.window"
+    assert_includes header_html, %(aria-label="Clear search")
   end
 
   # On a phone the name and the nav are hidden and the menu carries
diff --git a/test/pro_tacts/admin/test_search.rb b/test/pro_tacts/admin/test_search.rb
index 3ec7247..5eab97d 100644
--- a/test/pro_tacts/admin/test_search.rb
+++ b/test/pro_tacts/admin/test_search.rb
@@ -2,6 +2,7 @@ require_relative "../../test_helper"
 
 require "pro_tacts/admin/search"
 require "pro_tacts/contact"
+require "pro_tacts/group"
 require "pro_tacts/store"
 require "pro_tacts/vcard"
 
@@ -15,8 +16,8 @@ class SearchTest < Minitest::Test
     ProTacts::Store::RecentContact.new(contact:, updated_at: Time.now)
   end
 
-  def search(rows, query, labels: {})
-    ProTacts::Admin::Search.contacts(rows, query:, labels:).map { it.contact.id }
+  def search(rows, query, groups: [])
+    ProTacts::Admin::Search.contacts(rows, query:, groups:).map { it.contact.id }
   end
 
   # Each word finds its own field: neither "ada" nor "london" is
@@ -56,8 +57,9 @@ class SearchTest < Minitest::Test
 
   def test_the_groups_a_contact_is_in_find_it
     ada = row("ada", "Ada Lovelace")
+    math = ProTacts::Group.new(id: "math", name: "Mathematicians", label: "Mathematicians", lines: [], members: ["ada"])
 
-    assert_equal ["ada"], search([ada], "mathematicians", labels: {"ada" => ["Mathematicians"]})
+    assert_equal ["ada"], search([ada], "mathematicians", groups: [math])
   end
 
   # Charles is the more recent, and only his note says "ada"; Ada's