Fix layout quirks in the admin Contacts view
Three of these come from generic Gloss rules leaking into places they
weren't meant for: field.css's `form { max-width: 28rem }` (sized for
the settings-form pattern) squeezed the search box narrower than the
contact list below it, and typography.css's `li + li` prose-list rule
added a stray gap under every card row but the last.

The detail card gets breathing room between the name and its
properties, added conditionally (`.detail-header:has(+ .detail-grid)`)
so a contact with no properties — which now renders no grid at all
instead of an empty one — keeps its name centered rather than pinned
near the top with dead space below it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYiigu3aFNaiTP9SyKW9mX
change qykpwxurqppsspyruvzrlqoxnzvlrsvt
commit 4cd5f2bc81c1813bc89ac255e2516dc2a8384734
author Claude <noreply@anthropic.com>
date
parent rzsrmtqq
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index edb2556..43f95a6 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -26,7 +26,7 @@ module ProTacts
       def view_template
         render Layout.new(title: "Contacts") do
           h1(class: "type-h1") { "Contacts" }
-          form(action: "/", method: "get") do
+          form(action: "/", method: "get", class: "search-form") do
             input(type: "search", name: "q", value: @query, placeholder: "Search contacts", autofocus: @query.empty?)
           end
           span(class: "type-label") { @query.empty? ? "recently updated" : "results" }
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index deffaa7..ecca1e6 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -28,11 +28,15 @@ module ProTacts
           a(href: "/", class: "type-label") { "‹ contacts" }
           div(class: "card") do
             div(class: "card-body") do
-              div(style: "display: flex; align-items: center; gap: var(--gl-space-s);") do
+              div(class: "detail-header") do
                 span(class: "avatar", data_size: "lg") { Format.initials(@fields.name || @contact.id) }
                 h1(class: "type-h2", style: "margin: 0;") { @fields.name || @contact.id }
               end
-              dl(class: "detail-grid") { rows }
+              # Only rendered when there's something to show: an empty
+              # <dl> would still take up the gap card-body puts between
+              # its children, leaving the header off-center in a card
+              # with nothing else in it.
+              dl(class: "detail-grid") { rows } if has_data?
             end
           end
         end
@@ -40,6 +44,12 @@ module ProTacts
 
       private
 
+      #: () -> bool
+      def has_data?
+        @fields.phones.any? || @fields.emails.any? || @fields.addresses.any? ||
+          !@fields.birthday.nil? || !@fields.notes.nil?
+      end
+
       def rows
         @fields.phones.each { |phone| row(phone.type, phone.value) }
         @fields.emails.each { |email| row(email.type, email.value) }
diff --git a/public/admin.css b/public/admin.css
index 96d14ef..23754a0 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -24,6 +24,34 @@
   gap: var(--gl-space-m);
 }
 
+/* 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. */
+.search-form {
+  max-width: none;
+}
+
+/* base/typography.css's `li + li { margin-top: var(--gl-space-3xs); }`
+   is a prose-list rule (Markdown bullet lists) that also matches
+   Card's row list, adding a stray gap below every row but the last. */
+.card > li {
+  margin: 0;
+}
+
+.detail-header {
+  display: flex;
+  align-items: center;
+  gap: var(--gl-space-s);
+}
+/* Breathing room before the property grid — but only when there's a
+   grid to make room for. A contact with no properties renders no
+   .detail-grid at all (see ContactsShow#has_data?), and this rule
+   staying silent then is what keeps its name centered in the card
+   rather than pinned to the top with dead space below it. */
+.detail-header:has(+ .detail-grid) {
+  margin-bottom: var(--gl-space-m);
+}
+
 /* The detail card's load-bearing rule: type in one column, value in
    the other, every row aligned to the same grid regardless of what
    kind of attribute it is. See docs/DESIGN.md, "Alignment is the
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index d0e2443..50d2a5f 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -120,6 +120,19 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # No properties at all means no grid, not an empty one — an empty
+  # <dl> would still eat the gap card-body puts before it, leaving the
+  # name pinned near the top of the card instead of centered in it.
+  def test_show_of_a_bare_contact_renders_no_grid
+    bare = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Bare Contact\r\nUID:bare\r\nEND:VCARD\r\n"
+
+    with_contacts({"bare" => bare}) do
+      get "/contacts/bare"
+
+      refute_includes last_response.body, "detail-grid"
+    end
+  end
+
   def test_show_of_an_unknown_contact_is_404
     with_contacts({}) { get "/contacts/nope" }