admin: make the root a two-column dashboard
The birthdays screen folds into the root as its ambient column, and
the /birthdays route, the header nav, and the page class go with it.
Also fixes the birthdays rows to be li under ul.card — the CardRow
look lives on that selector, and bare anchors under the ul were
getting none of it.

Assisted-by: GLM-5.3 via pi
change wtlunknxuzpmsonxzsuplotpqxsknors
commit 8f0bbd9c8b80f052aef3dca7cb6e81187bba1d95
author Alpha Chen <alpha@kejadlen.dev>
date
parent pqztmlum
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 54579ed..916d641 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -71,10 +71,14 @@ 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
 by name, by any of its values, and by the groups it belongs to.
 
-One screen is exempt from that shape: the upcoming-birthdays list is not a
-search target but a standing answer to "what's coming?" — a bounded list in
-arrival order, each row opening the contact's card. It is the one screen
-that browses, because its question is not "where is X?".
+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 the one list that browses, 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.
 
 ## Records are cards, not forms
 
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index d3621cd..a8a757b 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -2,37 +2,46 @@ require "phlex"
 
 require "pro_tacts/admin/format"
 require "pro_tacts/admin/layout"
+require "pro_tacts/admin/upcoming_birthdays"
 
 module ProTacts
   module Admin
-    # GET / — search first, no browsing (docs/DESIGN.md): a query
-    # narrows 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): 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.
     class ContactsIndex < Phlex::HTML
       RECENT_LIMIT = 10
+      private_constant :RECENT_LIMIT
 
       Row = Data.define(:contact, :updated_at)
       private_constant :Row
 
-      #: (recent: Array[Store::RecentContact], query: String?) -> void
-      def initialize(recent:, query:)
+      #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?) -> void
+      def initialize(recent:, upcoming:, query:)
         @query = query.to_s.strip
+        @upcoming = upcoming
         rows = recent.map { Row.new(contact: it.contact, updated_at: it.updated_at) }
         @rows = @query.empty? ? rows.first(RECENT_LIMIT) : rows.select { matches?(it, @query) }
       end
 
       def view_template
-        render Layout.new(title: "Contacts") do
-          h1(class: "type-h1") { "Contacts" }
-          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" }
-          if @rows.empty?
-            p(class: "type-body-sm") { @query.empty? ? "No contacts yet." : "No contacts match." }
-          else
-            ul(class: "card") { @rows.each { render_row(it) } }
+        render Layout.new(title: "Contacts", wide: true) 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." }
+              else
+                ul(class: "card") { @rows.each { render_row(it) } }
+              end
+            end
+            render UpcomingBirthdays.new(upcoming: @upcoming)
           end
         end
       end
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 65f8c0b..b3cd0bd 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -6,13 +6,15 @@ 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 screen-to-screen nav pushed to the far edge of
-    # it, quieter than the mark. No JavaScript: nothing served here yet
-    # needs any.
+    # 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.
     class Layout < Phlex::HTML
-      #: (title: String) -> void
-      def initialize(title:)
+      #: (title: String, ?wide: bool) -> void
+      def initialize(title:, wide: false)
         @title = title
+        @wide = wide
       end
 
       def view_template
@@ -30,11 +32,8 @@ module ProTacts
             link(rel: "stylesheet", href: "/admin.css")
           end
           body do
-            header(class: "admin-header") do
-              a(href: "/") { "pro-tacts" }
-              nav { a(href: "/birthdays", class: "type-label") { "birthdays" } }
-            end
-            main(class: "admin-main") { yield }
+            header(class: "admin-header") { a(href: "/") { "pro-tacts" } }
+            main(class: "admin-main", **(@wide ? {data: {wide: true}} : {})) { yield }
           end
         end
       end
diff --git a/lib/pro_tacts/admin/birthdays.rb b/lib/pro_tacts/admin/upcoming_birthdays.rb
similarity index 55%
rename from lib/pro_tacts/admin/birthdays.rb
rename to lib/pro_tacts/admin/upcoming_birthdays.rb
index cb9d6c3..4a7ecc8 100644
--- a/lib/pro_tacts/admin/birthdays.rb
+++ b/lib/pro_tacts/admin/upcoming_birthdays.rb
@@ -1,17 +1,16 @@
 require "phlex"
 
 require "pro_tacts/admin/format"
-require "pro_tacts/admin/layout"
 
 module ProTacts
   module Admin
-    # GET /birthdays — who has a birthday soon, in the order they
-    # arrive, each row opening the contact's card. The store's query
-    # has already decided the hard parts (arrival order, the year
-    # wrap, which partial shapes land on a day at all); this screen
-    # only renders what it hands back, and a birthday with no year
-    # shows its day without an age.
-    class Birthdays < Phlex::HTML
+    # The dashboard's ambient column: who has a birthday soon, in the
+    # order they arrive, each row opening the contact's card. The
+    # store's query has already decided the hard parts (arrival order,
+    # the year wrap, which partial shapes land on a day at all); this
+    # renders what it hands back, and a birthday without a year shows
+    # its day without an age.
+    class UpcomingBirthdays < Phlex::HTML
       LIMIT = 10
 
       #: (upcoming: Array[Store::UpcomingBirthday]) -> void
@@ -20,8 +19,8 @@ module ProTacts
       end
 
       def view_template
-        render Layout.new(title: "Birthdays") do
-          h1(class: "type-h1") { "Birthdays" }
+        section do
+          h2(class: "type-label") { "upcoming birthdays" }
           if @upcoming.empty?
             p(class: "type-body-sm") { "No birthdays to show." }
           else
@@ -35,13 +34,15 @@ module ProTacts
       #: (Store::UpcomingBirthday upcoming) -> void
       def render_row(upcoming)
         contact = upcoming.contact
-        a(href: "/contacts/#{contact.id}") do
-          span(class: "avatar") { Format.initials(contact) }
-          div(style: "flex: 1; min-width: 0;") do
-            div(style: "font-weight: 550;") { contact.name || contact.id }
-            div(class: "type-body-sm") { date_line(upcoming) }
+        li do
+          a(href: "/contacts/#{contact.id}") do
+            span(class: "avatar") { Format.initials(contact) }
+            div(style: "flex: 1; min-width: 0;") do
+              div(style: "font-weight: 550;") { contact.name || contact.id }
+              div(class: "type-body-sm") { date_line(upcoming) }
+            end
+            span(class: "type-label") { Format.time_until(upcoming.occurs_on) }
           end
-          span(class: "type-label") { Format.time_until(upcoming.occurs_on) }
         end
       end
 
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 15afb9d..496d650 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -8,7 +8,6 @@ require "rack/rewindable_input"
 require "nokogiri"
 require "roda"
 
-require "pro_tacts/admin/birthdays"
 require "pro_tacts/admin/contacts_index"
 require "pro_tacts/admin/contacts_show"
 require "pro_tacts/debug_logger"
@@ -102,24 +101,18 @@ module ProTacts
         end
       end
 
-      # The upcoming-birthdays screen: the coming-year question the
-      # birthdays table answers without a card in sight, one segment
-      # down like the browser and under the same auth gate.
-      r.on "birthdays" do
-        r.get do
-          response["Content-Type"] = "text/html; charset=utf-8"
-          Admin::Birthdays.call(upcoming: store.upcoming_birthdays(Admin::Birthdays::LIMIT))
-        end
-      end
-
       r.is "" do
-        # The contacts page itself: the root is the one human-facing
-        # screen's home (docs/DESIGN.md), a GET alongside the PROPFIND
-        # below it — same path, disjoint verbs, and a browser's plain
-        # GET is no DAV client's bootstrap.
+        # The dashboard home: the one human-facing screen's home
+        # (docs/DESIGN.md), a GET alongside the PROPFIND below it —
+        # same path, disjoint verbs, and a browser's plain GET is no
+        # DAV client's bootstrap.
         r.get do
           response["Content-Type"] = "text/html; charset=utf-8"
-          Admin::ContactsIndex.call(recent: store.contacts_by_recency, query: r.params["q"])
+          Admin::ContactsIndex.call(
+            recent: store.contacts_by_recency,
+            upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
+            query: r.params["q"],
+          )
         end
 
         # DAV:current-user-principal (RFC 5397 section 3) — what a client
diff --git a/public/admin.css b/public/admin.css
index 33a2447..8baf1a6 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -15,18 +15,6 @@
   text-decoration: none;
 }
 
-/* The header's nav sits at the far edge from the wordmark. Its links
-   carry the catalog-label look of .type-label; this rule exists only
-   because .admin-header a is more specific than .type-label and would
-   otherwise force the wordmark's size and ink back onto them. */
-.admin-header nav {
-  margin-inline-start: auto;
-}
-.admin-header nav a {
-  font-size: var(--gl-step--2);
-  color: var(--gl-color-text-secondary);
-}
-
 .admin-main {
   max-width: 34rem;
   margin: 0 auto;
@@ -36,6 +24,24 @@
   gap: var(--gl-space-m);
 }
 
+/* The dashboard's width — two columns beside each other rather than
+   the reading width a single column wants. Set by the layout's `wide`
+   flag, the one screen that asks for it. */
+.admin-main[data-wide] {
+  max-width: 68rem;
+}
+
+/* The dashboard itself: equal fluid columns that collapse to one when
+   the viewport cannot hold two side by side, the ambient birthdays
+   column stacking under the primary one. `align-items: start` keeps a
+   short column from stretching its card to match its neighbor. */
+.dashboard {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
+  gap: var(--gl-space-m);
+  align-items: start;
+}
+
 /* 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. */
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 67be979..f12c894 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -11,13 +11,13 @@ module ProTacts
     end
 
     class ContactsIndex
-      def self.call: (recent: Array[Store::RecentContact], query: String?) -> String
+      def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?) -> String
     end
 
-    class Birthdays
+    # Rendered by ContactsIndex, not called from checked code; only
+    # its LIMIT is read in web.rb.
+    class UpcomingBirthdays
       LIMIT: Integer
-
-      def self.call: (upcoming: Array[Store::UpcomingBirthday]) -> String
     end
   end
 end
diff --git a/test/pro_tacts/admin/test_birthdays_page.rb b/test/pro_tacts/admin/test_birthdays_page.rb
deleted file mode 100644
index ef0edfc..0000000
--- a/test/pro_tacts/admin/test_birthdays_page.rb
+++ /dev/null
@@ -1,102 +0,0 @@
-require_relative "../../test_helper"
-
-require "date"
-require "pathname"
-require "rack/test"
-require "tmpdir"
-
-require "pro_tacts/store"
-require "pro_tacts/web"
-
-# The upcoming-birthdays screen, exercised like the other admin pages:
-# real requests through the Roda app, against a throwaway store. The
-# query's own behavior — the year wrap, which partial shapes place on
-# a day at all — is test_store.rb's; here what is under test is what
-# the screen makes of what it hands back. The route reads the real
-# clock, so the cards are built around Date.today rather than a fixed
-# date.
-class AdminBirthdaysPageTest < Minitest::Test
-  include Rack::Test::Methods
-
-  def app
-    ProTacts::Web
-  end
-
-  def setup
-    header "Tailscale-User-Login", "test@example.com"
-  end
-
-  # A contact born on `date`'s month and day in 2000, so the page's
-  # ordering and labels are deterministic against the real clock.
-  def born(date, name:, id:)
-    "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:#{name}\r\n" \
-      "BDAY:2000-%02d-%02d\r\nUID:#{id}\r\nEND:VCARD\r\n" % [date.month, date.day]
-  end
-
-  def with_contacts(cards)
-    Dir.mktmpdir do |dir|
-      original = ProTacts::Web.store
-
-      ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
-        ProTacts::Web.store = store
-        cards.each { |id, card| store.put(id, card) }
-        yield store
-      ensure
-        ProTacts::Web.store = original
-      end
-    end
-  end
-
-  # The list answers "who is next": names in arrival order, each row
-  # opening the contact's card, the birthday shown with the age it
-  # turns and the day counted down beside it.
-  def test_lists_birthdays_in_arrival_order_linking_to_the_contact
-    tomorrow = born(Date.today + 1, name: "Sooner Person", id: "sooner")
-    fortnight = born(Date.today + 13, name: "Later Person", id: "later")
-
-    with_contacts({"later" => fortnight, "sooner" => tomorrow}) do
-      get "/birthdays"
-
-      assert_equal 200, last_response.status
-      assert_equal "text/html; charset=utf-8", last_response["Content-Type"]
-      body = last_response.body
-      assert body.index("Sooner Person") < body.index("Later Person")
-      assert_includes body, 'href="/contacts/sooner"'
-      assert_includes body, "(turns #{(Date.today + 1).year - 2000})"
-      assert_includes body, "tomorrow"
-      assert_includes body, "in 13d"
-    end
-  end
-
-  # A birthday without a year is on the day — shown with no age, the
-  # one thing a missing year cannot answer.
-  def test_a_birthday_without_a_year_shows_no_age
-    week = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:No Year\r\n" \
-      "BDAY;X-APPLE-OMIT-YEAR=1604:1604-%02d-%02d\r\nUID:noyear\r\nEND:VCARD\r\n" %
-      [(Date.today + 7).month, (Date.today + 7).day]
-
-    with_contacts({"noyear" => week}) do
-      get "/birthdays"
-
-      body = last_response.body
-      assert_includes body, "No Year"
-      assert_includes body, "in 7d"
-      refute_includes body, "turns"
-    end
-  end
-
-  def test_no_birthdays_says_so
-    with_contacts({}) { get "/birthdays" }
-
-    assert_includes last_response.body, "No birthdays to show."
-  end
-
-  # The screen is reachable from the page shell every screen shares.
-  def test_the_header_links_to_birthdays
-    with_contacts({}) do
-      get "/"
-
-      assert_includes last_response.body, 'href="/birthdays"'
-    end
-  end
-end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 24a36d6..549b227 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1,5 +1,6 @@
 require_relative "../../test_helper"
 
+require "date"
 require "pathname"
 require "rack/test"
 require "tmpdir"
@@ -7,9 +8,11 @@ require "tmpdir"
 require "pro_tacts/store"
 require "pro_tacts/web"
 
-# The read-only admin UI (docs/DESIGN.md), exercised the same way
-# WebTest exercises the CardDAV routes: real requests through the Roda
-# app, against a throwaway store.
+# The dashboard root (docs/DESIGN.md), exercised the same way WebTest
+# exercises the CardDAV routes: real requests through the Roda app,
+# against a throwaway store. The birthdays column reads the real
+# clock, so its cards are built around Date.today rather than a fixed
+# date.
 class AdminContactsPagesTest < Minitest::Test
   include Rack::Test::Methods
 
@@ -57,6 +60,76 @@ class AdminContactsPagesTest < Minitest::Test
     assert_includes last_response.body, "No contacts yet."
   end
 
+  # A contact born on `date`'s month and day in 2000, so the
+  # birthdays column's ordering and labels are deterministic against
+  # the real clock.
+  def born(date, name:, id:)
+    "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:#{name}\r\n" \
+      "BDAY:2000-%02d-%02d\r\nUID:#{id}\r\nEND:VCARD\r\n" % [date.month, date.day]
+  end
+
+  # The ambient column answers "who is next": names in arrival order,
+  # each row opening the contact's card, the birthday shown with the
+  # age it turns and the day counted down beside it.
+  def test_index_lists_birthdays_in_arrival_order_linking_to_the_contact
+    tomorrow = born(Date.today + 1, name: "Sooner Person", id: "sooner")
+    fortnight = born(Date.today + 13, name: "Later Person", id: "later")
+
+    with_contacts({"later" => fortnight, "sooner" => tomorrow}) do
+      get "/"
+
+      body = last_response.body
+      assert_includes body, "upcoming birthdays"
+      # Both contacts also sit in "recently updated", where a
+      # same-millisecond tie can order them either way — arrival
+      # order is asserted inside the birthdays column alone.
+      birthdays = body.split("upcoming birthdays").last
+      assert birthdays.index("Sooner Person") < birthdays.index("Later Person")
+      # The rows are CardRows — li under ul.card, where the row look
+      # lives — not bare anchors.
+      assert_includes body, '<li><a href="/contacts/sooner">'
+      assert_includes body, "(turns #{(Date.today + 1).year - 2000})"
+      assert_includes body, "tomorrow"
+      assert_includes body, "in 13d"
+    end
+  end
+
+  # A birthday without a year is on the day — shown with no age, the
+  # one thing a missing year cannot answer.
+  def test_index_shows_a_birthday_without_a_year_with_no_age
+    week = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:No Year\r\n" \
+      "BDAY;X-APPLE-OMIT-YEAR=1604:1604-%02d-%02d\r\nUID:noyear\r\nEND:VCARD\r\n" %
+      [(Date.today + 7).month, (Date.today + 7).day]
+
+    with_contacts({"noyear" => week}) do
+      get "/"
+
+      body = last_response.body
+      assert_includes body, "No Year"
+      assert_includes body, "in 7d"
+      refute_includes body, "turns"
+    end
+  end
+
+  def test_index_with_no_birthdays_says_so
+    with_contacts({}) { get "/" }
+
+    assert_includes last_response.body, "No birthdays to show."
+  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")
+
+    with_contacts({"ada" => ADA, "birthday" => birthday}) do
+      get "/", q: "hopper"
+
+      assert_includes last_response.body, "No contacts match."
+      assert_includes last_response.body, "Birthday Person"
+    end
+  end
+
   def test_search_narrows_to_matches_across_contacts
     grace = ADA.sub("Ada Lovelace", "Grace Hopper")
       .sub("ada@example.com", "grace@example.com")
@@ -65,8 +138,12 @@ class AdminContactsPagesTest < Minitest::Test
     with_contacts({"ada" => ADA, "grace" => grace}) do
       get "/", q: "hopper"
 
-      assert_includes last_response.body, "Grace Hopper"
-      refute_includes last_response.body, "Ada Lovelace"
+      # 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, "results"
     end
   end