admin: mark the rows a group lends on the detail card
The card read like the household's address was your own,
one grid, no seam, no way to tell what's lent from what you own.
Now every borrowed row wears the name of who lent it,
and a note of your own sits beside the household's, both spelled out.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 0c3103e..ab44fb3 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -98,18 +98,39 @@ module ProTacts
# A missing TYPE parameter still gets a key: the fallback names
# the kind of value, so no row renders unlabeled in the grid.
+ # Every row that reads from a line hands it over, because which
+ # group lends a row is a question about the line it came from.
+ # The birthday hands over none: it is the model beside the card,
+ # and a group holds only addresses and notes anyway (see
+ # db/migrations/004_groups.rb).
def rows
- @contact.phones.each { |phone| row(phone.type || "phone", phone.value) }
- @contact.emails.each { |email| row(email.type || "email", email.value) }
- @contact.addresses.each { |address| row(address.type || "address", address_lines(address)) }
+ @contact.phones.each { |phone| row(phone.type || "phone", phone.value, phone.line) }
+ @contact.emails.each { |email| row(email.type || "email", email.value, email.line) }
+ @contact.addresses.each { |address| row(address.type || "address", address_lines(address), address.line) }
row("birthday", @birthday) if @birthday
- @contact.notes.each { row("notes", it.value) }
+ @contact.notes.each { |note| row("notes", note.value, note.line) }
end
- #: (String type, String | Array[String] value) -> void
- def row(type, value)
+ # The type in one column and the value in the other, with the
+ # group that lends the line named under the value when one does.
+ # In the value cell rather than beside the type label: the type
+ # column holds one thing for every row in the card
+ # (docs/DESIGN.md, "Alignment is the layout"), and a mark on the
+ # value is what says this address is the household's rather than
+ # this contact's.
+ #: (String type, String | Array[String] value, ?VCard::Parser::Line? line) -> void
+ def row(type, value, line = nil)
+ group = line && @contact.group_of(line)
dt(class: "type-label") { type }
- dd(class: "type-body-sm") { render_value(value) }
+ dd(class: "type-body-sm") do
+ render_value(value)
+ # Gloss' Badge: a catalog mark annotating the row it sits on,
+ # pinned to that row's first line at the right edge of the
+ # value column (admin.css) — under the value it could be
+ # read as marking the row below. No tone: the accent is held
+ # constant across a view, and inherited is not a status.
+ span(class: "badge") { group } if group
+ end
end
# ADR's components as two display lines — street, then everything
diff --git a/public/admin.css b/public/admin.css
index 5f84e2f..1991288 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -486,6 +486,45 @@ img.avatar {
margin: 0;
}
+/* The group a row is lent by, marked at the right edge of the value
+ column on the row's own first line (ContactsShow#row). Under the
+ value it read as a mark between two rows — the grid's row gap is
+ wider than a line, but not by enough to say which row a line below
+ a value belongs to. Pinned to row 1 of the value cell, it can only
+ be read as this row's.
+
+ A nested grid rather than a float or a flex row, because the value
+ is several block lines for an address and one for everything else:
+ the lines stack down column 1 as they always did, and the mark
+ takes column 2 of the first row alone. No wrapper element to make
+ them one flex item, and no source-order inversion — the value is
+ still read before the group that lends it. */
+.detail-grid dd:has(> .badge) {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: start;
+ column-gap: var(--gl-space-s);
+}
+/* Not baseline-aligned, though a mark beside a value is exactly what
+ baseline is for: a grid item blockifies, so Badge's inline-flex
+ becomes a flex container, and a flex container takes its baseline
+ from its first item — which is Badge's leading square, a box with
+ no text in it. The synthesized baseline lands a few pixels off the
+ value's.
+
+ So both boxes are made one value-line tall and top-aligned instead:
+ the value's line box is --gl-step--1/1.5 (Gloss' .type-body-sm) and
+ the badge centers its own content (align-items: center, its own
+ rule), so equal heights put the two texts on the same center. */
+.detail-grid dd > .badge {
+ grid-column: 2;
+ grid-row: 1;
+ min-height: calc(var(--gl-step--1) * 1.5);
+}
+.detail-grid dd:has(> .badge) > div {
+ grid-column: 1;
+}
+
/* 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
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 8758b06..8990829 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -44,6 +44,21 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # A household's lines, and the seeding a group takes: authoring is
+ # still the admin UI's task, so the rows land through the store's
+ # own database the way FixtureData's do (test/fixture_data.rb).
+ HOUSEHOLD = [
+ "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
+ "NOTE:Gate code 1854.",
+ ].freeze #: Array[String]
+
+ def add_group(store, name:, members:, lines:)
+ database = store.instance_variable_get(:@database)
+ database[:groups].insert(id: "household", name:)
+ lines.each.with_index { |line, position| database[:group_properties].insert(group_id: "household", position:, line:) }
+ members.each { |card_id| database[:group_members].insert(group_id: "household", card_id:) }
+ end
+
def test_index_lists_recently_updated_contacts
with_contacts({"ada" => ADA}) do
get "/"
@@ -289,6 +304,34 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # A row a group lends is marked with the group's name, and the
+ # contact's own rows are not: Ada carries an address and a note of
+ # her own beside the household's two, and only the household's are
+ # marked.
+ def test_show_marks_the_rows_a_group_lends
+ with_contacts({"ada" => ADA}) do |store|
+ add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+
+ get "/contacts/ada"
+ marks = last_response.body.scan('<span class="badge">Boole household</span>')
+
+ assert_equal 2, marks.size
+ assert_includes last_response.body, "7 Calculus Close"
+ assert_includes last_response.body, "Gate code 1854."
+ assert_includes last_response.body, "Countess of Lovelace."
+ end
+ end
+
+ # A contact in no group is marked nowhere: the badge is provenance,
+ # not decoration, so a card with nothing lent renders none.
+ def test_show_marks_nothing_on_a_contact_in_no_group
+ with_contacts({"ada" => ADA}) do
+ get "/contacts/ada"
+
+ refute_includes last_response.body, "badge"
+ end
+ end
+
# A value with no TYPE parameter still gets a key — the fallback
# names the kind of value, so every row in the grid is labeled.
def test_show_labels_untyped_values_with_the_property_name