contact: every row answers its label
Contact.label_of reads the X-ABLabel beside a row, unwrapped, else
its types; Phone, Email, and Address answer it and Handle its
service. Format.type_label goes, the screens reading row.label, so
emails and addresses show their labels for the first time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138CM3cNKnfJZ8LbDsCPRaf
change
commit 8e34856caa71ca6e57edbde59bc894ccce33bb59
author Claude <noreply@anthropic.com>
date
parent fc7ad843
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 353d66c..5d9dc3f 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -92,16 +92,16 @@ module ProTacts
       def rows
         groups_row
         @contact.phones.each do |phone|
-          row(Format.type_label(phone.label, phone.types, "phone"), phone.value, phone.lent_by)
+          row(phone.label || "phone", phone.value, phone.lent_by)
         end
         @contact.emails.each do |email|
-          row(Format.type_label(nil, email.types, "email"), email.value, email.lent_by)
+          row(email.label || "email", email.value, email.lent_by)
         end
         @contact.handles.each do |handle|
-          row(Format.type_label(handle.service, [], "handle"), handle.value, handle.lent_by)
+          row(handle.label || "handle", handle.value, handle.lent_by)
         end
         @contact.addresses.each do |address|
-          row(Format.type_label(nil, address.types, "address"), Format.address_lines(address), address.lent_by)
+          row(address.label || "address", Format.address_lines(address), address.lent_by)
         end
         row("birthday", @birthday) if @birthday
         note_rows
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 67db4d2..0aac558 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -250,11 +250,11 @@ module ProTacts
                           placeholder: @own.nickname ? "removed on save" : nil)
                   end
                   @own.phones.each do |phone|
-                    value_row(Format.type_label(phone.label, phone.types, "phone"),
+                    value_row(phone.label || "phone",
                               type: "tel", name: "phone[#{phone.line.digest}]", value: phone.value)
                   end
                   @own.emails.each do |email|
-                    value_row(Format.type_label(nil, email.types, "email"),
+                    value_row(email.label || "email",
                               type: "email", name: "email[#{email.line.digest}]", value: email.value)
                   end
                   @own.handles.each do
@@ -528,7 +528,7 @@ module ProTacts
       #: (Contact::Address address) -> void
       def address_row(address)
         div(class: "field", data: {blank_removes: address.po_box.nil?}) do
-          span { Format.type_label(nil, address.types, "address") }
+          span { address.label || "address" }
           div(class: "field-stack") do
             ADDRESS_FIELDS.each do |component, label|
               input(type: "text", name: "address[#{address.line.digest}][#{component}]",
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index d6826bd..7c11ca9 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -80,18 +80,6 @@ module ProTacts
         ].compact.reject { it.empty? }
       end
 
-      # A row's key: the label it carries, else its types, else the
-      # kind of value, so no row renders unlabeled in the grid. Where
-      # a row has both a label and types the label wins, which is what
-      # Contacts itself shows (docs/plans/2026-09-18-phone-labels.md,
-      # "Reading"); a row with no label of its own passes nil.
-      #: (String? label, Array[String] types, String kind) -> String
-      def self.type_label(label, types, kind)
-        return label if label
-
-        types.empty? ? kind : types.join(", ")
-      end
-
       # A value — a property's or a parameter's — at or under this many
       # octets renders whole. Anything longer is a wall of folded or
       # unbroken octets (an inline PHOTO payload, a memoji's binary
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index f71a53a..308c653 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -126,7 +126,7 @@ module ProTacts
       #: (Contact::Address address) -> void
       def address_row(address)
         div(class: "field", data: {blank_removes: address.po_box.nil?}) do
-          span { Format.type_label(nil, address.types, "address") }
+          span { address.label || "address" }
           div(class: "field-stack") do
             ContactsEdit::ADDRESS_FIELDS.each do |component, label|
               input(type: "text", name: "address[#{address.line.digest}][#{component}]",
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index 828dcb0..ac06722 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -75,7 +75,7 @@ module ProTacts
       #: () -> void
       def rows
         @reading.addresses.each do |address|
-          row(Format.type_label(nil, address.types, "address")) do
+          row(address.label || "address") do
             Format.address_lines(address).each { |line| div { line } }
           end
         end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 95c55b6..483a128 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -121,15 +121,44 @@ module ProTacts
     # here.
     ADDRESS_COMPONENTS = %w[extended street locality region postal_code country].freeze #: Array[String]
 
-    # Reopened rather than defined in the block above, for the reason
+    # The word a row's type column says: the `X-ABLabel` sharing its
+    # group, unwrapped from Apple's own vocabulary (`_$!<Other>!$_`
+    # reads `Other`), else the types the reader shows, else nil. One
+    # word to a reader, whichever of the two spellings the line carries
+    # (docs/plans/2026-10-22-a-row-edits-its-label.md, "One word, three
+    # spellings"); where a row has both the label wins, which is what
+    # Contacts itself shows (docs/plans/2026-09-18-phone-labels.md,
+    # "Reading").
+    #: (VCard::Parser::Line? label_line, Array[String] types) -> String?
+    def self.label_of(label_line, types)
+      text = label_line&.property&.text
+      return VCard.unwrap(text) unless text.nil? || text.empty?
+
+      types.join(", ") unless types.empty?
+    end
+
+    # Reopened rather than defined in the blocks above, for the reason
     # VCard::Parser::Property is.
     class Phone
-      # The `X-ABLabel`'s text, nil for a number that has none.
       #: () -> String?
-      def label
-        text = label_line&.property&.text
-        text unless text.nil? || text.empty?
-      end
+      def label = Contact.label_of(label_line, types)
+    end
+
+    class Email
+      #: () -> String?
+      def label = Contact.label_of(label_line, types)
+    end
+
+    class Address
+      #: () -> String?
+      def label = Contact.label_of(label_line, types)
+    end
+
+    class Handle
+      # A handle's word is its service, the column the record card has
+      # always shown it in.
+      #: () -> String?
+      def label = service
     end
 
     # A handle's line in Contacts.app's spelling, the service in
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 7505cbf..7df8edd 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -2063,8 +2063,8 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   # The edit screen's phone row captions render a label the same way
-  # the show screen's keys do — both render that span through
-  # Format.type_label — while the field keeps editing the number alone.
+  # the show screen's keys do — both read Contact::Phone#label — while
+  # the field keeps editing the number alone.
   def test_the_edit_screen_captions_a_labeled_phone_with_its_label
     labeled = ADA.sub("TEL;TYPE=mobile:+1-555-0100\r\n",
       "item1.TEL;type=CELL:+1-555-0100\r\nitem1.X-ABLabel:Google Voice\r\n")
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 25b87e9..950cb54 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -154,8 +154,9 @@ class ContactTest < Minitest::Test
 
   # The label is the `X-ABLabel` sharing the phone's property group — a
   # different line than the row it describes — and the types are a
-  # different fact: a row can carry both
-  # (docs/plans/2026-09-18-phone-labels.md).
+  # different fact: a row can carry both, and the label wins
+  # (docs/plans/2026-09-18-phone-labels.md). A row with no such line
+  # reads its types as its label.
   def test_reads_a_phones_label_off_the_line_sharing_its_group
     labeled = STRUCTURED.sub(
       "TEL;TYPE=work:+1-555-0199\r\n",
@@ -163,7 +164,7 @@ class ContactTest < Minitest::Test
     )
 
     phones = contact(labeled).phones
-    assert_nil phones.fetch(0).label
+    assert_equal "mobile", phones.fetch(0).label
     assert_equal "Google Voice", phones.fetch(1).label
     assert_equal %w[work], phones.fetch(1).types
   end
@@ -179,10 +180,23 @@ class ContactTest < Minitest::Test
     assert_equal "Google Voice", contact(labeled).phones.fetch(1).label
   end
 
-  def test_a_phone_in_a_group_with_no_labeling_line_reads_no_label
+  def test_a_phone_in_a_group_with_no_labeling_line_reads_its_types
     grouped = STRUCTURED.sub("TEL;TYPE=work:", "item2.TEL;TYPE=work:")
 
-    assert_equal [nil, nil], contact(grouped).phones.map(&:label)
+    assert_equal %w[mobile work], contact(grouped).phones.map(&:label)
+  end
+
+  # Emails and addresses read the label beside them as a phone does,
+  # and Apple's wrapped vocabulary reads as its word
+  # (docs/plans/2026-10-22-a-row-edits-its-label.md).
+  def test_an_email_and_an_address_read_their_labels
+    labeled = STRUCTURED
+      .sub(/^EMAIL[^\r]*/) { "item3.#{it}\r\nitem3.X-ABLabel:_$!<Other>!$_" }
+      .sub(/^ADR[^\r]*/) { "item4.#{it}\r\nitem4.X-ABLabel:lab" }
+    contact = contact(labeled)
+
+    assert_equal "Other", contact.emails.fetch(0).label
+    assert_equal "lab", contact.addresses.fetch(0).label
   end
 
   # Each phone carries the line it was read from — the address a save