admin: show the raw vCard in its own collapsed card
The stored bytes are the truth the detail grid interprets, and they
were invisible from the UI — an unparseable card had literally nothing
to show. Native <details> keeps the page script-free; the test asserts
the stored bytes, which compose BDAY differently from the input.

Assisted-by: GLM-5.3 via pi
change zmxwpzstwpxkxumyqrpxllkppwzqumvt
commit 4e532a03b4fe875d76b296b2b935f748e895cc55
author Alpha Chen <alpha@kejadlen.dev>
date
parent mysrnsus
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 0ed773c..3ff142f 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -92,6 +92,9 @@ Consequences worth keeping:
 - **Empty attributes do not render.** A record with one phone number shows one
   row, not a scaffold of blank fields. The card's height is the record's real
   weight — an unfilled record should look unfilled.
+- **The stored card is reachable.** In its own card beneath the record, the
+  raw vCard sits collapsed by default — the interpretation is the interface,
+  but the bytes a client downloads are never invisible from it.
 - **Adding is explicit.** A quiet add affordance opens a native `dialog` that
   names the attribute types available. Multi-part additions (picking a group,
   then a role) are steps inside that one dialog, never a chain of popovers —
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index ceb667e..f8f605c 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -52,6 +52,21 @@ module ProTacts
               dl(class: "detail-grid") { rows } if has_data?
             end
           end
+          # The stored card in its own card under the record: the bytes
+          # are the truth the grid above interprets, and they stay
+          # reachable — collapsed by default, because the record is
+          # about the person and the bytes are about the wire. Native
+          # <details>, so the admin UI stays script-free (see Layout);
+          # and the one thing a card that will not parse has to show
+          # (see Parser: no repair to make).
+          div(class: "card") do
+            div(class: "card-body") do
+              details do
+                summary(class: "type-label") { "raw vCard" }
+                pre(class: "type-mono") { @contact.vcard.to_s }
+              end
+            end
+          end
         end
       end
 
diff --git a/public/admin.css b/public/admin.css
index da0b773..d9121ab 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -159,3 +159,16 @@ img.avatar {
   grid-column: 2;
   margin: 0;
 }
+
+/* The stored card in its own collapsed card under the record — the
+   column's gap spaces the two, so no margin here. <details> is native,
+   keeping the admin UI script-free; the summary rides the label
+   register, the pre scrolls rather than wraps: the bytes are the wire
+   format, folded lines and all. */
+.card-body summary {
+  cursor: pointer;
+}
+.card-body pre {
+  margin: var(--gl-space-2xs) 0 0;
+  overflow-x: auto;
+}
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 7b856bd..d8d4b70 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -270,9 +270,26 @@ 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.
+  # The stored bytes are the truth the grid interprets, so the exact
+  # bytes stay reachable in their own card under the record —
+  # collapsed by default, so a PHOTO-heavy card does not push the page
+  # around. The stored card is the assertion's source of truth, not
+  # the input: a write composes the modeled BDAY back in, so what is
+  # served can be ordered differently from what was put.
+  def test_show_offers_the_raw_card_collapsed
+    with_contacts({"ada" => ADA}) do |store|
+      get "/contacts/ada"
+
+      body = last_response.body
+      assert_includes body, '<details><summary class="type-label">raw vCard</summary>'
+      assert_includes body, store.contact("ada").vcard.to_s
+      refute_includes body, "<details open"
+    end
+  end
+
+  # A card that will not parse is served from its bytes regardless —
+  # the raw section is the one thing such a card has to show, so it
+  # renders with no grid under the header, not instead of the page.
   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"
 
@@ -280,6 +297,7 @@ class AdminContactsPagesTest < Minitest::Test
       get "/contacts/bare"
 
       refute_includes last_response.body, "detail-grid"
+      assert_includes last_response.body, "<details"
     end
   end