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