admin: serve the UI from the root
The one human-facing screen owned the whole site but sat two segments
deep; GET / now renders it alongside the PROPFIND a DAV client asks
the same path, with detail pages at /contacts/:id.

Assisted-by: GLM-5.3 via pi
change rzsrmtqqkpuvroqvlurltzwuwswysrkr
commit 3ac73243d8b29be903f3070ae0d9b1283296247b
author Alpha Chen <alpha@kejadlen.dev>
date
parent yquszywt
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index d5d28b8..edb2556 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -6,9 +6,9 @@ require "pro_tacts/admin/layout"
 
 module ProTacts
   module Admin
-    # GET /admin/contacts — 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,
+    # 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.
     class ContactsIndex < Phlex::HTML
       RECENT_LIMIT = 10
@@ -26,7 +26,7 @@ module ProTacts
       def view_template
         render Layout.new(title: "Contacts") do
           h1(class: "type-h1") { "Contacts" }
-          form(action: "/admin/contacts", method: "get") do
+          form(action: "/", method: "get") do
             input(type: "search", name: "q", value: @query, placeholder: "Search contacts", autofocus: @query.empty?)
           end
           span(class: "type-label") { @query.empty? ? "recently updated" : "results" }
@@ -53,7 +53,7 @@ module ProTacts
       #: (Row row) -> void
       def render_row(row)
         li do
-          a(href: "/admin/contacts/#{row.contact.id}") do
+          a(href: "/contacts/#{row.contact.id}") do
             span(class: "avatar") { Format.initials(row.fields.name || row.contact.id) }
             div(style: "flex: 1; min-width: 0;") do
               div(style: "font-weight: 550;") { row.fields.name || row.contact.id }
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 4e59e94..deffaa7 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -6,7 +6,7 @@ require "pro_tacts/admin/layout"
 
 module ProTacts
   module Admin
-    # GET /admin/contacts/:id — a contact's card, read-only. Follows
+    # GET /contacts/:id — a contact's card, read-only. Follows
     # docs/DESIGN.md's alignment rule even before there is anything to
     # edit: every row's type sits in one column and its value in the
     # other, and an attribute with no data gets no row at all.
@@ -25,7 +25,7 @@ module ProTacts
 
       def view_template
         render Layout.new(title: @fields.name || @contact.id) do
-          a(href: "/admin/contacts", class: "type-label") { "‹ contacts" }
+          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
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 42543a5..705bf88 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -6,7 +6,7 @@ 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. No JavaScript: nothing served under /admin yet needs any.
+    # Gloss. No JavaScript: nothing served here yet needs any.
     class Layout < Phlex::HTML
       #: (title: String) -> void
       def initialize(title:)
@@ -28,7 +28,7 @@ module ProTacts
             link(rel: "stylesheet", href: "/admin.css")
           end
           body do
-            header(class: "admin-header") { a(href: "/admin/contacts") { "pro-tacts" } }
+            header(class: "admin-header") { a(href: "/") { "pro-tacts" } }
             main(class: "admin-main") { yield }
           end
         end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 3763e97..fdf9ed5 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -82,33 +82,35 @@ module ProTacts
     route do |r|
       r.public
 
-      # The read-only admin UI (docs/DESIGN.md). Under the same auth
-      # gate as the CardDAV routes above it — "a few family members, all
+      # The card browser, one segment down from the page at the root:
+      # /contacts/:id names what the id is without spending the whole
+      # single-segment namespace on contact ids. Under the same auth
+      # gate as the CardDAV routes — "a few family members, all
       # trusted" is the whole access model this app has, see README's
       # simplifying assumptions.
-      r.on "admin" do
-        r.on "contacts" do
-          r.is do
-            r.get do
-              response["Content-Type"] = "text/html; charset=utf-8"
-              Admin::ContactsIndex.call(recent: store.contacts_by_recency, query: r.params["q"])
-            end
-          end
-
-          r.get String do |id|
-            contact = store.contact(id)
-
-            # No match falls through to the empty-body 404 the
-            # not_found handler fills in, same as the CardDAV GET.
-            if contact
-              response["Content-Type"] = "text/html; charset=utf-8"
-              Admin::ContactsShow.call(contact:)
-            end
+      r.on "contacts" do
+        r.get String do |id|
+          contact = store.contact(id)
+
+          # No match falls through to the empty-body 404 the
+          # not_found handler fills in, same as the CardDAV GET.
+          if contact
+            response["Content-Type"] = "text/html; charset=utf-8"
+            Admin::ContactsShow.call(contact:)
           end
         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.
+        r.get do
+          response["Content-Type"] = "text/html; charset=utf-8"
+          Admin::ContactsIndex.call(recent: store.contacts_by_recency, query: r.params["q"])
+        end
+
         # DAV:current-user-principal (RFC 5397 section 3) — what a client
         # asks the root for to find the principal it is acting as.
         r.propfind do
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index cffad56..d0e2443 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -42,7 +42,7 @@ class AdminContactsPagesTest < Minitest::Test
 
   def test_index_lists_recently_updated_contacts
     with_contacts({"ada" => ADA}) do
-      get "/admin/contacts"
+      get "/"
 
       assert_equal 200, last_response.status
       assert_equal "text/html; charset=utf-8", last_response["Content-Type"]
@@ -52,7 +52,7 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   def test_index_with_no_contacts_says_so
-    with_contacts({}) { get "/admin/contacts" }
+    with_contacts({}) { get "/" }
 
     assert_includes last_response.body, "No contacts yet."
   end
@@ -63,7 +63,7 @@ class AdminContactsPagesTest < Minitest::Test
       .sub("UID:ada", "UID:grace")
 
     with_contacts({"ada" => ADA, "grace" => grace}) do
-      get "/admin/contacts", q: "hopper"
+      get "/", q: "hopper"
 
       assert_includes last_response.body, "Grace Hopper"
       refute_includes last_response.body, "Ada Lovelace"
@@ -75,17 +75,17 @@ class AdminContactsPagesTest < Minitest::Test
   # any of its values, or the groups it belongs to.
   def test_search_matches_a_phone_or_email_value
     with_contacts({"ada" => ADA}) do
-      get "/admin/contacts", q: "555-0100"
+      get "/", q: "555-0100"
       assert_includes last_response.body, "Ada Lovelace"
 
-      get "/admin/contacts", q: "ada@example.com"
+      get "/", q: "ada@example.com"
       assert_includes last_response.body, "Ada Lovelace"
     end
   end
 
   def test_search_with_no_matches_says_so
     with_contacts({"ada" => ADA}) do
-      get "/admin/contacts", q: "nobody"
+      get "/", q: "nobody"
 
       assert_includes last_response.body, "No contacts match."
     end
@@ -93,7 +93,7 @@ class AdminContactsPagesTest < Minitest::Test
 
   def test_show_renders_the_contacts_fields
     with_contacts({"ada" => ADA}) do
-      get "/admin/contacts/ada"
+      get "/contacts/ada"
 
       assert_equal 200, last_response.status
       assert_includes last_response.body, "Ada Lovelace"
@@ -112,7 +112,7 @@ class AdminContactsPagesTest < Minitest::Test
     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 "/admin/contacts/bare"
+      get "/contacts/bare"
 
       assert_equal 200, last_response.status
       refute_includes last_response.body, "notes"
@@ -121,7 +121,7 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   def test_show_of_an_unknown_contact_is_404
-    with_contacts({}) { get "/admin/contacts/nope" }
+    with_contacts({}) { get "/contacts/nope" }
 
     assert_equal 404, last_response.status
   end