Fold the phone header's search and nav away
Alpha's take on the two-row header: rather than stacking, a phone
keeps one row. The contacts and groups links move into the account
menu, and the search folds into its glyph, opening across the whole
header while it has focus or a query. The glyph is the field's label,
so tapping it focuses the field and the fold needs no script.

The search's clear no longer takes the accent bar's foreground color,
which painted it white on the field's white.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CxsxtnQrJKvwHGfRmdhR9F
change
commit c6728580f7f84fd4ae481aec0ebbb6f17bca1f52
author Claude <noreply@anthropic.com>
date
parent 74cb29ee
diff --git a/lib/pro_tacts/admin/icons.rb b/lib/pro_tacts/admin/icons.rb
index 78dd31d..4bf5fd2 100644
--- a/lib/pro_tacts/admin/icons.rb
+++ b/lib/pro_tacts/admin/icons.rb
@@ -20,8 +20,9 @@ module ProTacts
       # verbatim from the lucide-static SVG's own children: x, the
       # 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; and
-      # chevron-down, the account menu's opening direction. A tag
+      # 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
       # 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.
@@ -46,6 +47,10 @@ module ProTacts
         chevron_down: [
           [:path, {d: "m6 9 6 6 6-6"}],
         ],
+        search: [
+          [:path, {d: "m21 21-4.34-4.34"}],
+          [:circle, {cx: 11, cy: 11, r: 8}],
+        ],
       } #: 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 9763161..754b403 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -104,9 +104,18 @@ module ProTacts
               # 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 while it has focus or a query
+              # (admin.css). The label is what makes the glyph open
+              # it: a tap anywhere in a label focuses its field, so
+              # the fold needs no script either.
               form(action: "/", method: "get", class: "search-form") do
-                input(type: "search", name: "q", value: @query,
-                      placeholder: "Search contacts", autofocus: @autofocus)
+                label 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) }
               end
@@ -115,7 +124,10 @@ module ProTacts
               # 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.
+              # named beside them. On a phone the two move into the
+              # account menu (the list below repeats them for that),
+              # leaving the header the name, the search, and the
+              # account.
               nav do
                 a(href: "/contacts") { "contacts" }
                 a(href: "/groups") { "groups" }
@@ -143,6 +155,8 @@ module ProTacts
                   render Icon.new(:chevron_down)
                 end
                 ul(id: MENU, popover: "auto", class: "user-menu-list") do
+                  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" } }
diff --git a/public/admin.css b/public/admin.css
index 04361fe..1a25b94 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -451,41 +451,97 @@ textarea:focus {
   color: var(--gl-color-text-secondary);
 }
 
-/* A phone has no room for all four 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 becomes two rows
-   that keep the wide one's reading order: who this is at the top (the
-   name on the left, the account on the right), and under it the
-   search where reading starts, the destinations at its end. The break
-   between them is an empty full-width item rather than a wrap left to
-   find its own place, because a wrap moves whichever item no longer
-   fits, and which one that is depends on the login's length. The
-   login is whatever the proxy vouches for, usually an email, so it is
-   what gives up width, ending in an ellipsis. The breakpoint is where
-   the one row still leaves the search room for its placeholder. The
-   empty break is a line of its own, so the rows are spaced by the
-   second row's margin rather than a row gap it would double. */
+/* 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 the
+   destinations for the phone too, and wider they are in the nav. */
+.search-form label {
+  display: block;
+}
+.search-form label > svg,
+.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 destinations move into the account
+   menu (Layout repeats them there), and the search folds into its
+   glyph beside the account. A tap on the glyph focuses the field its
+   label holds, and while the field has focus, or a query to show,
+   it opens across the whole header over the name and the account,
+   which are hidden rather than removed so the header keeps its
+   height. The account's login is whatever the proxy vouches for,
+   usually an email, so it is what gives up width, ending in an
+   ellipsis. The breakpoint is where the one row still leaves the
+   search room for its placeholder. */
 @media (max-width: 48rem) {
   .admin-header {
-    flex-wrap: wrap;
-    row-gap: 0;
-  }
-  .admin-header::after {
-    content: "";
-    flex-basis: 100%;
-    order: 1;
+    position: relative;
   }
-  .admin-header .search-form,
   .admin-header nav {
-    order: 2;
-    margin-top: var(--gl-space-2xs);
+    display: none;
+  }
+  .user-menu .menu-nav {
+    display: block;
   }
   .admin-header .search-form {
+    flex: none;
+    margin-left: auto;
     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:is(:focus-within,
+  :has(input:not(:placeholder-shown))) {
+    position: absolute;
+    inset: 0;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    margin: 0;
+    padding: 0 var(--gl-space-m);
+  }
+  .admin-header .search-form:is(:focus-within,
+  :has(input:not(:placeholder-shown))) label {
+    flex: 1;
+    padding: 0;
+    cursor: auto;
+  }
+  .admin-header .search-form:is(:focus-within,
+  :has(input:not(:placeholder-shown))) label > svg {
+    display: none;
+  }
+  .admin-header .search-form:is(:focus-within,
+  :has(input:not(:placeholder-shown))) input[type="search"] {
+    position: static;
+    width: 100%;
+    height: auto;
+    opacity: 1;
+    clip-path: none;
+  }
+  .admin-header .search-form:is(:focus-within,
+  :has(input:not(:placeholder-shown))) .search-clear {
+    inset-inline-end: calc(var(--gl-space-m) + var(--gl-space-2xs));
+  }
+  .admin-header:has(.search-form:focus-within,
+  .search-form input:not(:placeholder-shown)) > :not(.search-form) {
+    visibility: hidden;
+  }
   .user-menu {
-    flex: 1 1 0;
-    justify-content: flex-end;
     min-width: 0;
   }
   .user-menu button {
@@ -539,9 +595,10 @@ 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). */
+   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. */
 :root[data-accent] :is(.admin-header a, .admin-header .user-menu button,
-.admin-footer .type-label):not(.user-menu ul a) {
+.admin-header .search-form label, .admin-footer .type-label):not(.user-menu ul a, .search-clear) {
   color: var(--gl-color-accent-fg);
 }
 /* The one focus ring is drawn in the accent, which vanishes against a
diff --git a/test/pro_tacts/admin/test_layout.rb b/test/pro_tacts/admin/test_layout.rb
index 6e389d1..0d779b1 100644
--- a/test/pro_tacts/admin/test_layout.rb
+++ b/test/pro_tacts/admin/test_layout.rb
@@ -57,6 +57,15 @@ class AdminLayoutTest < Minitest::Test
     assert_includes header_html, %(<a href="/" class="icon-button search-clear" data-size="sm" aria-label="Clear search">)
   end
 
+  # On a phone the nav is hidden and the menu carries the
+  # destinations instead (admin.css), so both have to be there.
+  def test_the_account_menu_repeats_the_destinations
+    with_env({})
+
+    assert_includes header_html, %(<li class="menu-nav"><a href="/contacts">contacts</a></li>)
+    assert_includes header_html, %(<li class="menu-nav"><a href="/groups">groups</a></li>)
+  end
+
   def test_the_account_menu_links_to_the_import_screen
     with_env({})