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
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