Read a phone's label off the X-ABLabel group
The reading half of docs/plans/2026-09-18-phone-labels.md; the
importer half follows.

Assisted-by: GLM-5.3 via pi
change uuxpuznkrszoqvtvprnrrnkzqvtvouuq
commit af8566041a4123ed246173f026be0e604b5ffb36
author Alpha Chen <alpha@kejadlen.dev>
date
parent vstkwxyp
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index de2de01..34ae5ab 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -187,7 +187,7 @@ module ProTacts
                   # one, saving, then editing the other.
                   @own.phones.each do |phone|
                     label(class: "field", data: {blank_removes: true}) do
-                      span { Format.type_label(phone.types, "phone") }
+                      span { Format.type_label(phone.label, phone.types, "phone") }
                       input(type: "tel", name: "phone[#{phone.line.digest}]",
                             value: phone.value, placeholder: "removed on save")
                     end
@@ -195,7 +195,7 @@ module ProTacts
                   # An email row, the phone row's own shape over EMAIL.
                   @own.emails.each do |email|
                     label(class: "field", data: {blank_removes: true}) do
-                      span { Format.type_label(email.types, "email") }
+                      span { Format.type_label(nil, email.types, "email") }
                       input(type: "email", name: "email[#{email.line.digest}]",
                             value: email.value, placeholder: "removed on save")
                     end
@@ -431,7 +431,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(address.types, "address") }
+          span { Format.type_label(nil, address.types, "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/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index d6a35a7..66b0155 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -130,13 +130,13 @@ module ProTacts
       def rows
         groups_row
         @contact.phones.each do |phone|
-          row(Format.type_label(phone.types, "phone"), phone.value, phone.line)
+          row(Format.type_label(phone.label, phone.types, "phone"), phone.value, phone.line)
         end
         @contact.emails.each do |email|
-          row(Format.type_label(email.types, "email"), email.value, email.line)
+          row(Format.type_label(nil, email.types, "email"), email.value, email.line)
         end
         @contact.addresses.each do |address|
-          row(Format.type_label(address.types, "address"), Format.address_lines(address), address.line)
+          row(Format.type_label(nil, address.types, "address"), Format.address_lines(address), address.line)
         end
         row("birthday", @birthday) if @birthday
         @contact.notes.each do |note|
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 18e2758..3f0a7db 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -65,10 +65,15 @@ module ProTacts
         ].compact.reject { it.empty? }
       end
 
-      # A row's types as its key, or the kind of value when it has
-      # none, so no row renders unlabeled in the grid.
-      #: (Array[String] types, String kind) -> String
-      def self.type_label(types, kind)
+      # 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
 
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index 95e12cc..7bdc52b 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -90,7 +90,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(address.types, "address") }
+          span { Format.type_label(nil, address.types, "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 9183380..fcc250d 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -53,7 +53,7 @@ module ProTacts
       def rows
         members_row
         @reading.addresses.each do |address|
-          row(Format.type_label(address.types, "address")) do
+          row(Format.type_label(nil, address.types, "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 f66b610..e17dcc6 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -66,6 +66,10 @@ module ProTacts
     # ADR's seven components (section 3.2.1: post office box, extended
     # address, street, locality, region, postal code, country — nil
     # where the card left the position blank or stopped short of it).
+    # A phone also carries a `label`, the `X-ABLabel` sharing its
+    # property group — macOS's spelling of a custom label, a different
+    # fact from the types and carried beside them
+    # (docs/plans/2026-09-18-phone-labels.md, "Reading").
     # Each also carries `line`, the parsed line the value was read
     # from: the address a save names that row by
     # (docs/plans/2026-09-05-web-card-editor.md) — the accessors fold
@@ -74,7 +78,7 @@ module ProTacts
     # Data classes, whose members the inline syntax cannot read; the
     # signatures live in sig/pro_tacts/contact.rbs.
     # @rbs skip
-    Phone = Data.define(:value, :types, :line)
+    Phone = Data.define(:value, :label, :types, :line)
     # @rbs skip
     Email = Data.define(:value, :types, :line)
     # @rbs skip
@@ -287,11 +291,18 @@ module ProTacts
 
     #: () -> Array[Phone]
     def phones
+      labels = labels_by_group
       rows("TEL") { |property, line|
         value = text_of(property)
+        group = property.group
         # RFC 2426 section 3.3.1: `voice` is the type every number has
         # by default, so it names none.
-        Phone.new(value:, types: types_of(property, except: %w[voice]), line:) if value
+        Phone.new(
+          value:,
+          label: group && labels[group],
+          types: types_of(property, except: %w[voice]),
+          line:,
+        ) if value
       }
     end
 
@@ -356,6 +367,27 @@ module ProTacts
       }
     end
 
+    # A property group to the `X-ABLabel` text anchoring it — the
+    # label a group's lines carry, on a different line than the row it
+    # describes. Read off the composed card: a group lends only bare
+    # `ADR` and `NOTE` (db/migrations/004_groups.rb), so an inherited
+    # line can never collide with a member's own `item` numbering
+    # (docs/plans/2026-09-18-phone-labels.md, "Reading").
+    #: () -> Hash[String, String]
+    def labels_by_group
+      properties
+        .filter_map { |property|
+          group = property.group
+          next if group.nil? || !property.name.casecmp?("X-ABLabel")
+
+          text = text_of(property)
+          next if text.nil?
+
+          [group, text] #: [String, String]
+        }
+        .to_h
+    end
+
     # The rows a repeatable property reads as: each line naming it
     # folded by the block, which answers nil for a row with nothing
     # to show.
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 717eff1..702e9ef 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -8,10 +8,11 @@ module ProTacts
   class Contact
     class Phone
       attr_reader value: String
+      attr_reader label: String?
       attr_reader types: Array[String]
       attr_reader line: VCard::Parser::Line
 
-      def self.new: (value: String, types: Array[String], line: VCard::Parser::Line) -> instance
+      def self.new: (value: String, label: String?, types: Array[String], line: VCard::Parser::Line) -> instance
     end
 
     class Email
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index c5afa6c..98937c9 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -587,6 +587,21 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # A phone's label wins its row's key where the row carries types
+  # too — which is what Contacts itself shows
+  # (docs/plans/2026-09-18-phone-labels.md).
+  def test_show_labels_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")
+
+    with_contacts({"ada" => labeled}) do
+      get "/contacts/ada"
+
+      assert_includes last_response.body, '<dt class="type-label">Google Voice</dt>'
+      refute_includes last_response.body, '<dt class="type-label">cell</dt>'
+    end
+  end
+
   # Empty attributes do not render (docs/DESIGN.md) — a bare card shows
   # only the header, not a scaffold of blank rows.
   def test_show_hides_attributes_the_contact_has_no_data_for
@@ -1420,6 +1435,23 @@ class AdminContactsPagesTest < Minitest::Test
     end
   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.
+  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")
+
+    with_contacts({"ada" => labeled}) do |store|
+      get "/contacts/ada/edit"
+
+      digest = store.contact("ada").phones.first.line.digest
+      assert_includes last_response.body,
+        '<label class="field" data-blank-removes><span>Google Voice</span>' \
+        '<input type="tel" name="phone[' + digest + ']" value="+1-555-0100" placeholder="removed on save"></label>'
+    end
+  end
+
   ## Editing emails
 
   # The email rows: the phone row's own shape over EMAIL — one value
@@ -2018,6 +2050,27 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # A labeled phone's value edit swaps the number under the line's own
+  # header — group prefix included — so the X-ABLabel stays bound to
+  # the row it names. Removing the row is the editor's own unsolved
+  # rule, and waits for it
+  # (docs/plans/2026-09-18-phone-labels.md, "What this leaves").
+  def test_saving_a_changed_labeled_phone_keeps_its_label_bound
+    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")
+
+    with_contacts({"ada" => labeled}) do |store|
+      digest = store.contact("ada").phones.first.line.digest
+      post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+                               phone: {digest => "+1-555-0150"}
+
+      assert_equal 303, last_response.status
+      card = store.contact("ada").vcard.to_s
+      assert_includes card, "item1.TEL;type=CELL:+1-555-0150\r\n"
+      assert_includes card, "item1.X-ABLabel:Google Voice\r\n"
+    end
+  end
+
   # The header is the line's own, kept byte for byte: macOS writes
   # three TYPE parameters on one TEL, and a save that rebuilt the
   # header from a type field would drop the two the form never
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 91ac99d..daa675c 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -133,6 +133,28 @@ class ContactTest < Minitest::Test
     assert_equal %w[work fax], contact(apple).phones.fetch(1).types
   end
 
+  # 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).
+  def test_reads_a_phones_label_off_the_line_sharing_its_group
+    labeled = STRUCTURED.sub(
+      "TEL;TYPE=work:+1-555-0199\r\n",
+      "item1.TEL;TYPE=work:+1-555-0199\r\nitem1.X-ABLabel:Google Voice\r\n",
+    )
+
+    phones = contact(labeled).phones
+    assert_nil phones.fetch(0).label
+    assert_equal "Google Voice", phones.fetch(1).label
+    assert_equal %w[work], phones.fetch(1).types
+  end
+
+  def test_a_phone_in_a_group_with_no_labeling_line_reads_no_label
+    grouped = STRUCTURED.sub("TEL;TYPE=work:", "item2.TEL;TYPE=work:")
+
+    assert_equal [nil, nil], contact(grouped).phones.map(&:label)
+  end
+
   # Each phone carries the line it was read from — the address a save
   # names the row by, and the bytes a value-only edit spares. The digest
   # is over the served card's line; composition only inserts the BDAY