admin: move the search into the page header
The header becomes the search's standing home — every collection
screen is search-first — and the shared avatar alignment is for the
multi-line rows the contacts list is growing.

Assisted-by: GLM-5.3 via pi
change tyztkonxltxnnsntqlstkrnywmklvoty
commit 0099caf6fdf04c6f41ce4d5521431fe4be3d7b0a
author Alpha Chen <alpha@kejadlen.dev>
date
parent lrpkuskw
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 916d641..0ed773c 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -62,7 +62,7 @@ thousand, so no screen exists to scroll the whole set.
 
 Every collection screen is the same shape:
 
-1. A search input at the top, focused and ready.
+1. A search input in the page header, focused and ready.
 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.
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index a8a757b..6e24a8a 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -6,12 +6,12 @@ require "pro_tacts/admin/upcoming_birthdays"
 
 module ProTacts
   module Admin
-    # GET / — the dashboard (docs/DESIGN.md): search and 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; an empty query shows the ten
-    # most recently updated. Read-only: no add, no edit, nothing to
-    # commit, so there is no state here beyond the query.
+    # 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; an empty query
+    # shows the ten most recently updated. Read-only: no add, no edit,
+    # nothing to commit, so there is no state here beyond the query.
     class ContactsIndex < Phlex::HTML
       RECENT_LIMIT = 10
       private_constant :RECENT_LIMIT
@@ -28,12 +28,9 @@ module ProTacts
       end
 
       def view_template
-        render Layout.new(title: "Contacts", wide: true) do
+        render Layout.new(title: "Contacts", wide: true, search: @query) do
           div(class: "dashboard") do
             section do
-              form(action: "/", method: "get", class: "search-form") do
-                input(type: "search", name: "q", value: @query, placeholder: "Search contacts", autofocus: @query.empty?)
-              end
               h2(class: "type-label") { @query.empty? ? "recently updated" : "results" }
               if @rows.empty?
                 p(class: "type-body-sm") { @query.empty? ? "No contacts yet." : "No contacts match." }
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index b3cd0bd..85aaf21 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -6,15 +6,18 @@ 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. `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. No JavaScript: nothing served here yet needs
-    # any.
+    # Gloss. A screen that passes `search` renders the collection
+    # search in that header, beside the name (docs/DESIGN.md's
+    # search-first rule). `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. No JavaScript: nothing served
+    # here yet needs any.
     class Layout < Phlex::HTML
-      #: (title: String, ?wide: bool) -> void
-      def initialize(title:, wide: false)
+      #: (title: String, ?wide: bool, ?search: String?) -> void
+      def initialize(title:, wide: false, search: nil)
         @title = title
         @wide = wide
+        @search = search
       end
 
       def view_template
@@ -32,7 +35,19 @@ module ProTacts
             link(rel: "stylesheet", href: "/admin.css")
           end
           body do
-            header(class: "admin-header") { a(href: "/") { "pro-tacts" } }
+            header(class: "admin-header") do
+              a(href: "/") { "pro-tacts" }
+              # The dashboard's query follows the search into the
+              # header: the input keeps its value across a search, and
+              # focuses only when the query is empty — a results page
+              # has somewhere to be besides the input.
+              if @search
+                form(action: "/", method: "get", class: "search-form") do
+                  input(type: "search", name: "q", value: @search,
+                        placeholder: "Search contacts", autofocus: @search.to_s.empty?)
+                end
+              end
+            end
             main(class: "admin-main", **(@wide ? {data: {wide: true}} : {})) { yield }
           end
         end
diff --git a/public/admin.css b/public/admin.css
index 8baf1a6..88e66d6 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -4,8 +4,16 @@
    rem width in the spirit of card.css's own max-width: 18rem; nothing
    here is a hex, a px, or a duration. */
 
+/* A chrome bar, not a content gutter: the block padding is the
+   scale's second-smallest step, so the header hugs its one line of
+   content instead of spacing it like page copy. Inline padding stays
+   at m to sit on the same vertical edges .admin-main's does. */
 .admin-header {
-  padding: var(--gl-space-s) var(--gl-space-m);
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: var(--gl-space-s);
+  padding: var(--gl-space-2xs) var(--gl-space-m);
   border-bottom: 1px solid var(--gl-color-border-hairline);
 }
 .admin-header a {
@@ -43,10 +51,29 @@
 }
 
 /* field.css's `form { max-width: 28rem; }` is sized for the
-   settings-form pattern; the search box is a single input that should
-   span the same width as the list below it. */
+   settings-form pattern; the header's search is a single input, and
+   flex: 1 + a narrower cap puts a deliberate 18rem box at the
+   header's right and lets it shrink on a narrow viewport. */
 .search-form {
-  max-width: none;
+  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"] {
+  padding-block: var(--gl-space-3xs);
+}
+
+/* Gloss zeroes every heading's margin (base/typography.css), which
+   leaves a section's type-label flush against the list it names. The
+   one vertical rhythm this layer adds, and shared by both dashboard
+   columns — every section is a type-label over a list. */
+section > h2 {
+  margin-bottom: var(--gl-space-s);
 }
 
 /* base/typography.css's `li + li { margin-top: var(--gl-space-3xs); }`
@@ -56,6 +83,18 @@
   margin: 0;
 }
 
+/* Both dashboard lists lead their rows with an avatar, and both are
+   growing rows taller than one line (the birthdays rows already are,
+   the contacts rows will be). A 1.875rem avatar centered against
+   multi-line text floats mid-row, so it leads from the top, level
+   with the name — no change today for single-line rows, where the
+   avatar is the tallest thing in the row either way. Scoped to
+   card-row links, so the contact detail header's centered avatar is
+   untouched. */
+.card > li > a:only-child .avatar {
+  align-self: flex-start;
+}
+
 .detail-header {
   display: flex;
   align-items: center;
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 549b227..4b63de3 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -117,6 +117,25 @@ class AdminContactsPagesTest < Minitest::Test
     assert_includes last_response.body, "No birthdays to show."
   end
 
+  # The search lives in the page header (docs/DESIGN.md), rendered by
+  # the layout when a screen asks for it. A screen that forgets to ask
+  # loses the input with no route failing — search still works through
+  # the URL — so this pins the wiring, not just the behavior.
+  def test_the_search_lives_in_the_header
+    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"
+
+      get "/", q: "ada"
+      header = last_response.body.split("<main").first
+      assert_includes header, "value=\"ada\""
+      refute_includes header, "autofocus"
+    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